Skip to content

Commit

Permalink
feat(sdk-coin-ada): support vote delegation
Browse files Browse the repository at this point in the history
  • Loading branch information
ryandengBG committed Dec 12, 2024
1 parent 7e4f921 commit 6395115
Show file tree
Hide file tree
Showing 12 changed files with 218 additions and 58 deletions.
1 change: 1 addition & 0 deletions modules/sdk-coin-ada/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export { StakingClaimRewardsBuilder } from './stakingClaimRewardsBuilder';
export { StakingDeactivateBuilder } from './stakingDeactivateBuilder';
export { StakingWithdrawBuilder } from './stakingWithdrawBuilder';
export { StakingPledgeBuilder } from './stakingPledgeBuilder';
export { VoteDelegationBuilder } from './voteDelegationBuilder';
export { Utils };
7 changes: 6 additions & 1 deletion modules/sdk-coin-ada/src/lib/stakingActivateBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { TransactionBuilder } from './transactionBuilder';
import { Transaction } from './transaction';
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
import adaUtils from './utils';

export class StakingActivateBuilder extends TransactionBuilder {
constructor(_coinConfig: Readonly<CoinConfig>) {
Expand All @@ -25,7 +26,7 @@ export class StakingActivateBuilder extends TransactionBuilder {
* @param stakingPublicKey The user's public stake key
* @param poolHash Pool ID Hash of the pool we are going to delegate to
*/
stakingCredential(stakingPublicKey: string, poolHash: string): this {
stakingCredential(stakingPublicKey: string, poolHash: string, dRepId: string): this {
const stakeCredential = CardanoWasm.Credential.from_keyhash(
CardanoWasm.PublicKey.from_bytes(Buffer.from(stakingPublicKey, 'hex')).hash()
);
Expand All @@ -40,6 +41,10 @@ export class StakingActivateBuilder extends TransactionBuilder {
)
);
this._certs.push(stakeDelegationCert);
const voteDelegationCert = CardanoWasm.Certificate.new_vote_delegation(
CardanoWasm.VoteDelegation.new(stakeCredential, adaUtils.getDRepFromDRepId(dRepId))
);
this._certs.push(voteDelegationCert);
return this;
}

Expand Down
16 changes: 15 additions & 1 deletion modules/sdk-coin-ada/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,19 @@ export interface Witness {
publicKey: string;
signature: string;
}
enum CertType {
export enum CertType {
StakeKeyRegistration,
StakeKeyDelegation,
StakeKeyDeregistration,
StakePoolRegistration,
VoteDelegation,
}

export interface Cert {
type: CertType;
stakeCredentialHash?: string;
poolKeyHash?: string;
dRepId?: string;
}

export interface Withdrawal {
Expand Down Expand Up @@ -183,6 +185,14 @@ export class Transaction extends BaseTransaction {
poolKeyHash: Buffer.from(stakePoolRegistration.pool_params().operator().to_bytes()).toString('hex'),
});
}
if (cert.as_vote_delegation() !== undefined) {
const voteDelegation = cert.as_vote_delegation() as CardanoWasm.VoteDelegation;
result.certs.push({
type: CertType.VoteDelegation,
stakeCredentialHash: Buffer.from(voteDelegation.stake_credential().to_bytes()).toString('hex'),
dRepId: Buffer.from(voteDelegation.drep().to_bytes()).toString('hex'),
});
}
}
}

Expand Down Expand Up @@ -279,6 +289,8 @@ export class Transaction extends BaseTransaction {
this._type = TransactionType.StakingActivate;
} else if (certs.some((c) => c.as_stake_deregistration() !== undefined)) {
this._type = TransactionType.StakingDeactivate;
} else if (certs.some((c) => c.as_vote_delegation() !== undefined)) {
this._type = TransactionType.VoteDelegation;
}
}
if (this._transaction.body().withdrawals()) {
Expand Down Expand Up @@ -394,6 +406,8 @@ export class Transaction extends BaseTransaction {
? 'StakingDeactivate'
: this._type === TransactionType.StakingPledge
? 'StakingPledge'
: this._type === TransactionType.VoteDelegation
? 'VoteDelegation'
: 'undefined';
return {
displayOrder,
Expand Down
4 changes: 2 additions & 2 deletions modules/sdk-coin-ada/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {

const adjustment = BigNum.from_str('2000000');
let change = utxoBalance.checked_sub(this._fee).checked_sub(totalAmountToSend);
if (this._type === TransactionType.StakingActivate) {
if (this._type === TransactionType.StakingActivate || this._type === TransactionType.VoteDelegation) {
change = change.checked_sub(adjustment);
} else if (this._type === TransactionType.StakingDeactivate) {
change = change.checked_add(adjustment);
Expand Down Expand Up @@ -326,7 +326,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {

const adjustment = BigNum.from_str('2000000');
let change = utxoBalance.checked_sub(this._fee).checked_sub(totalAmountToSend);
if (this._type === TransactionType.StakingActivate) {
if (this._type === TransactionType.StakingActivate || this._type === TransactionType.VoteDelegation) {
change = change.checked_sub(adjustment);
} else if (this._type === TransactionType.StakingDeactivate) {
change = change.checked_add(adjustment);
Expand Down
15 changes: 11 additions & 4 deletions modules/sdk-coin-ada/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StakingDeactivateBuilder } from './stakingDeactivateBuilder';
import { StakingWithdrawBuilder } from './stakingWithdrawBuilder';
import { StakingPledgeBuilder } from './stakingPledgeBuilder';
import { StakingClaimRewardsBuilder } from './stakingClaimRewardsBuilder';
import { VoteDelegationBuilder } from './voteDelegationBuilder';

export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
constructor(_coinConfig: Readonly<CoinConfig>) {
Expand Down Expand Up @@ -38,6 +39,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return this.getWalletInitializationBuilder(tx);
case TransactionType.StakingPledge:
return this.getStakingPledgeBuilder(tx);
case TransactionType.VoteDelegation:
return this.getVoteDelegationBuilder(tx);
default:
throw new InvalidTransactionError('unsupported transaction');
}
Expand All @@ -60,19 +63,23 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return TransactionBuilderFactory.initializeBuilder(tx, new StakingActivateBuilder(this._coinConfig));
}

getStakingClaimRewardsBuilder(tx?: Transaction) {
getVoteDelegationBuilder(tx?: Transaction): VoteDelegationBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new VoteDelegationBuilder(this._coinConfig));
}

getStakingClaimRewardsBuilder(tx?: Transaction): StakingClaimRewardsBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new StakingClaimRewardsBuilder(this._coinConfig));
}

getStakingDeactivateBuilder(tx?: Transaction) {
getStakingDeactivateBuilder(tx?: Transaction): StakingDeactivateBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new StakingDeactivateBuilder(this._coinConfig));
}

getStakingWithdrawBuilder(tx?: Transaction) {
getStakingWithdrawBuilder(tx?: Transaction): StakingWithdrawBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new StakingWithdrawBuilder(this._coinConfig));
}

getStakingPledgeBuilder(tx?: Transaction) {
getStakingPledgeBuilder(tx?: Transaction): StakingPledgeBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new StakingPledgeBuilder(this._coinConfig));
}

Expand Down
26 changes: 26 additions & 0 deletions modules/sdk-coin-ada/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from 'assert';
import { AddressFormat, BaseUtils } from '@bitgo/sdk-core';
import {
BaseAddress,
Expand All @@ -7,11 +8,15 @@ import {
Credential,
RewardAddress,
Transaction as CardanoTransaction,
DRep,
} from '@emurgo/cardano-serialization-lib-nodejs';
import { KeyPair } from './keyPair';
import { bech32 } from 'bech32';

export const MIN_ADA_FOR_ONE_ASSET = '1500000';
export const VOTE_ALWAYS_ABSTAIN = 'always-abstain';
export const VOTE_ALWAYS_NO_CONFIDENCE = 'always-no-confidence';
export const DEFAULT_VOTE_OPTIONS = [VOTE_ALWAYS_ABSTAIN, VOTE_ALWAYS_NO_CONFIDENCE];

export class Utils implements BaseUtils {
createBaseAddressWithStakeAndPaymentKey(
Expand Down Expand Up @@ -75,6 +80,27 @@ export class Utils implements BaseUtils {
return rewardAddress.to_address().to_bech32();
}

isValidDRepId(dRepId: string): boolean {
try {
DRep.from_bech32(dRepId);
return true;
} catch (err) {
return DEFAULT_VOTE_OPTIONS.includes(dRepId);
}
}

getDRepFromDRepId(dRepId: string): DRep {
assert(this.isValidDRepId(dRepId), 'Invalid dRepId ' + dRepId);
switch (dRepId) {
case VOTE_ALWAYS_ABSTAIN:
return DRep.new_always_abstain();
case VOTE_ALWAYS_NO_CONFIDENCE:
return DRep.new_always_no_confidence();
default:
return DRep.from_bech32(dRepId);
}
}

/** @inheritdoc */
// this will validate both stake and payment addresses
isValidAddress(address: string): boolean {
Expand Down
56 changes: 56 additions & 0 deletions modules/sdk-coin-ada/src/lib/voteDelegationBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { BaseKey, TransactionType } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { TransactionBuilder } from './transactionBuilder';
import { Transaction } from './transaction';
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-nodejs';
import adaUtils from './utils';

export class VoteDelegationBuilder extends TransactionBuilder {
constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
this._type = TransactionType.VoteDelegation;
}

protected get transactionType(): TransactionType {
return TransactionType.VoteDelegation;
}

/** @inheritdoc */
initBuilder(tx: Transaction): void {
super.initBuilder(tx);
}

/**
* Creates the proper certificates needed to delegate a user's vote to a given DRep
*
* @param stakingPublicKey The user's public stake key
* @param dRepId The DRep ID of the DRep we will delegate vote to
*/
addVoteDelegationCertificate(stakingPublicKey: string, dRepId: string): this {
const stakeCredential = CardanoWasm.Credential.from_keyhash(
CardanoWasm.PublicKey.from_bytes(Buffer.from(stakingPublicKey, 'hex')).hash()
);
const voteDelegationCert = CardanoWasm.Certificate.new_vote_delegation(
CardanoWasm.VoteDelegation.new(stakeCredential, adaUtils.getDRepFromDRepId(dRepId))
);
this._certs.push(voteDelegationCert);
return this;
}

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
const tx = await super.buildImplementation();
tx.setTransactionType(TransactionType.StakingActivate);
return tx;
}

/** @inheritdoc */
protected fromImplementation(rawTransaction: string): Transaction {
return super.fromImplementation(rawTransaction);
}

/** @inheritdoc */
protected signImplementation(key: BaseKey): Transaction {
return super.signImplementation(key);
}
}
11 changes: 8 additions & 3 deletions modules/sdk-coin-ada/test/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,16 @@ export const rawTx = {
address: 'addr_test1vr8rakm66rcfv4fcxqykg5lf0yv7lsyk9mvapx369jpvtcgfcuk7f',
value: '248329150',
},
unsignedVoteDelegationTx:
'84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a001bf227021a00029259031a2faf0800048183098200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb8200581c8b75035882d4165bea8000c4d3f2c123ae33c1d92a751a78135a2402a0f5f6',
unsignedVoteDelegationTxBody:
'a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a001bf227021a00029259031a2faf0800048183098200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb8200581c8b75035882d4165bea8000c4d3f2c123ae33c1d92a751a78135a2402',
unsignedVoteDelegationTxHash: 'c33c503919f9c5aa5a4ca40c0c6e4bb0099a7452a093d9ee01aff93958415e93',
unsignedStakingActiveTx:
'84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a001beca7021a000297d9031a2faf0800048282008200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb83028200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb581c7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3a0f5f6',
'84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a001be677021a00029e09031a2faf0800048382008200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb83028200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb581c7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b383098200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb8102a0f5f6',
unsignedStakingActiveTxBody:
'a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a001beca7021a000297d9031a2faf0800048282008200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb83028200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb581c7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3',
unsignedStakingActiveTxHash: '4e2777152ebc92c73daebc0075cee8a52ca9fb8c00511ac2c035b58c52f27125',
'a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a001be677021a00029e09031a2faf0800048382008200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb83028200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb581c7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b383098200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdb8102',
unsignedStakingActiveTxHash: 'd8ecaa7bfee2e28691673be378ea6583bc8efd5a6e29ca9cfb278279a06dd216',
unsignedStakingDeactiveTx:
'84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba2101018182583901c7b28bcea90d440b5455a6a02a27ca59b8696f067fc1967f47f933e79558e969caa9e57adcfc40b9907eb794363b590faf42fff48c38eb881a005900a7021a00028cd9031a2faf0800048182018200581c188fde65b1f9bd69b0edcc4e5a65fa93a13773090e1e2eef7a25cfdba0f5f6',
unsignedStakingDeactiveTxBody:
Expand Down
65 changes: 19 additions & 46 deletions modules/sdk-coin-ada/test/unit/StakingActivateBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TransactionType, AddressFormat } from '@bitgo/sdk-core';
import * as testData from '../resources';
import { KeyPair, TransactionBuilderFactory } from '../../src';
import { coins } from '@bitgo/statics';
import { Transaction } from '../../src/lib/transaction';
import { CertType, Transaction } from '../../src/lib/transaction';
import * as Utils from '../../src/lib/utils';

describe('ADA Staking Activate Transaction Builder', async () => {
Expand All @@ -21,19 +21,24 @@ describe('ADA Staking Activate Transaction Builder', async () => {
'addr1q8rm9z7w4yx5gz652kn2q238efvms6t0qelur9nlglun8eu4tr5knj4fu4adelzqhxg8adu5xca4jra0gtllfrpcawyq9psz23',
totalInput
);
txBuilder.stakingCredential(keyPairStake.getKeys().pub, '7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3');
txBuilder.stakingCredential(
keyPairStake.getKeys().pub,
'7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3',
'always-abstain'
);
txBuilder.ttl(800000000);
const tx = (await txBuilder.build()) as Transaction;
should.equal(tx.type, TransactionType.StakingActivate);
const txData = tx.toJson();
const fee = tx.getFee;
txData.certs.length.should.equal(2);
txData.certs.length.should.equal(3);
txData.certs[0].type.should.equal(0);
txData.certs[1].type.should.equal(1);
txData.certs[2].type.should.equal(CertType.VoteDelegation);

txData.outputs.length.should.equal(1);
txData.outputs[0].amount.should.equal((Number(totalInput) - 2000000 - Number(fee)).toString());
fee.should.equal('169945');
fee.should.equal('171529');
tx.toBroadcastFormat().should.equal(testData.rawTx.unsignedStakingActiveTx);
should.equal(tx.id, testData.rawTx.unsignedStakingActiveTxHash);
});
Expand All @@ -50,7 +55,11 @@ describe('ADA Staking Activate Transaction Builder', async () => {
'addr1q8rm9z7w4yx5gz652kn2q238efvms6t0qelur9nlglun8eu4tr5knj4fu4adelzqhxg8adu5xca4jra0gtllfrpcawyq9psz23',
totalInput
);
txBuilder.stakingCredential(keyPairStake.getKeys().pub, '7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3');
txBuilder.stakingCredential(
keyPairStake.getKeys().pub,
'7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3',
'always-abstain'
);
txBuilder.ttl(800000000);
const tx = (await txBuilder.build()) as Transaction;
should.equal(tx.type, TransactionType.StakingActivate);
Expand Down Expand Up @@ -129,7 +138,11 @@ describe('ADA Staking Activate Transaction Builder', async () => {
const txBuilder = factory.getStakingActivateBuilder();
const senderBalance = '22122071';
txBuilder.changeAddress(senderAddress, senderBalance);
txBuilder.stakingCredential(keyPairStake.getKeys().pub, '7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3');
txBuilder.stakingCredential(
keyPairStake.getKeys().pub,
'7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3',
'always-abstain'
);

txBuilder.input({
transaction_id: '0a4f80d83ba9ce1f83306a79252909241308d7eff317d04c9ea018966d687fe3',
Expand Down Expand Up @@ -160,44 +173,4 @@ describe('ADA Staking Activate Transaction Builder', async () => {
const serializedTransaction2 = signedTransaction2.toBroadcastFormat();
serializedTransaction2.should.equal(serializedTransaction);
});

it('should submit a staking transaction using one signature', async () => {
const keyPairPayment = new KeyPair({ prv: testData.privateKeys.prvKey9 });
const keyPairStake = keyPairPayment;
const senderAddress = Utils.default.createBaseAddressWithStakeAndPaymentKey(
keyPairStake,
keyPairPayment,
AddressFormat.testnet
);
const txBuilder = factory.getStakingActivateBuilder();
const senderBalance = '22122071';
txBuilder.changeAddress(senderAddress, senderBalance);
txBuilder.stakingCredential(keyPairStake.getKeys().pub, '7a623c48348501c2380e60ac2307fcd1b67df4218f819930821a15b3');

txBuilder.input({
transaction_id: '0a4f80d83ba9ce1f83306a79252909241308d7eff317d04c9ea018966d687fe3',
transaction_index: 0,
});

txBuilder.ttl(900000000);

const unsignedTx = await txBuilder.build();
const signableHex = unsignedTx.signablePayload.toString('hex');
const serializedTx = unsignedTx.toBroadcastFormat();
txBuilder.sign({ key: keyPairPayment.getKeys().prv });
const signedTransaction = await txBuilder.build();
const serializedTransaction = signedTransaction.toBroadcastFormat();

const txBuilder2 = factory.from(serializedTx);
const tx = await txBuilder2.build();
tx.type.should.equal(TransactionType.StakingActivate);
const signableHex2 = tx.signablePayload.toString('hex');
signableHex.should.equal(signableHex2);
const signaturePayment = keyPairPayment.signMessage(signableHex2);
txBuilder2.addSignature({ pub: keyPairPayment.getKeys().pub }, Buffer.from(signaturePayment));
const signedTransaction2 = await txBuilder2.build();
signedTransaction.id.should.equal(tx.id);
const serializedTransaction2 = signedTransaction2.toBroadcastFormat();
serializedTransaction2.should.equal(serializedTransaction);
});
});
Loading

0 comments on commit 6395115

Please sign in to comment.