From 3b76e1508a7964b317f2ca90991b7c02363073cf Mon Sep 17 00:00:00 2001 From: Harit Kapadia Date: Fri, 31 Oct 2025 17:17:59 -0400 Subject: [PATCH 1/3] refactor(sdk-coin-apt): delegation pool transaction subclass Ticket: SC-3600 Ticket: SC-3601 First, we isolate `recipients` concept to transfer-based transactions. For delegation pool operations, the transaction sender is not always correlated with inputs and outputs (e.g. withdraw). Then we create delegation pool-based transactions with `validatorAddress` and `amount`. --- modules/sdk-coin-apt/src/lib/iface.ts | 10 ++- ...actDelegationPoolAmountBasedTransaction.ts | 70 ++++++++++++++++++ .../abstractDelegationPoolTransaction.ts | 23 ++++++ .../abstractTransferTransaction.ts | 37 ++++++++++ .../src/lib/transaction/customTransaction.ts | 18 ++--- .../delegationPoolAddStakeTransaction.ts | 74 +++++++++---------- .../lib/transaction/digitalAssetTransfer.ts | 8 +- .../lib/transaction/fungibleAssetTransfer.ts | 12 +-- .../src/lib/transaction/transaction.ts | 42 +++++------ .../lib/transaction/transferTransaction.ts | 6 +- ...elegationPoolAddStakeTransactionBuilder.ts | 8 ++ modules/sdk-coin-apt/test/resources/apt.ts | 10 +-- ...elegationPoolAddStakeTransactionBuilder.ts | 51 +++++-------- 13 files changed, 244 insertions(+), 125 deletions(-) create mode 100644 modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolAmountBasedTransaction.ts create mode 100644 modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolTransaction.ts create mode 100644 modules/sdk-coin-apt/src/lib/transaction/abstractTransferTransaction.ts diff --git a/modules/sdk-coin-apt/src/lib/iface.ts b/modules/sdk-coin-apt/src/lib/iface.ts index 73592df056..b4baeff87c 100644 --- a/modules/sdk-coin-apt/src/lib/iface.ts +++ b/modules/sdk-coin-apt/src/lib/iface.ts @@ -17,7 +17,7 @@ export interface TxData { id: string; sender: string; /** @deprecated - use `recipients`. */ - recipient: TransactionRecipient; + recipient?: TransactionRecipient; recipients: TransactionRecipient[]; sequenceNumber: number; maxGasAmount: number; @@ -28,6 +28,14 @@ export interface TxData { assetId: string; } +/** + * The transaction data returned from the toJson() function of a delegation pool transaction + */ +export interface DelegationPoolTxData extends TxData { + validatorAddress: string | null; + amount: string | null; +} + export interface RecipientsValidationResult { recipients: { deserializedAddresses: string[]; diff --git a/modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolAmountBasedTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolAmountBasedTransaction.ts new file mode 100644 index 0000000000..3ea78bbcd5 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolAmountBasedTransaction.ts @@ -0,0 +1,70 @@ +import { + AccountAddress, + EntryFunctionABI, + InputGenerateTransactionPayloadData, + MoveFunctionId, + TransactionPayload, + TransactionPayloadEntryFunction, + TypeTagAddress, + TypeTagU64, +} from '@aptos-labs/ts-sdk'; +import { AbstractDelegationPoolTransaction } from './abstractDelegationPoolTransaction'; +import { BaseCoin } from '@bitgo/statics'; +import { InvalidTransactionError } from '@bitgo/sdk-core'; +import utils from '../utils'; +import { APTOS_COIN } from '../constants'; + +/** + * This is a convenience class for delegation pool functions with arguments [address, amount]. + * + * Assume this class can be deleted at any time (concrete implementations remain the same). + * Therefore, do not store objects as this type. + * Good: `export abstract class DelegationPoolWithdrawTransaction extends AbstractDelegationPoolAmountBasedTransaction` + * Good: `const transaction: AbstractDelegationPoolTransaction = DelegationPoolWithdrawTransaction()` + * Bad: `const transaction: AbstractDelegationPoolAmountBasedTransaction = DelegationPoolWithdrawTransaction()` + */ +export abstract class AbstractDelegationPoolAmountBasedTransaction extends AbstractDelegationPoolTransaction { + constructor(coinConfig: Readonly) { + super(coinConfig); + this._assetId = APTOS_COIN; + } + + abstract moveFunctionId(): MoveFunctionId; + + protected override parseTransactionPayload(payload: TransactionPayload): void { + if (!this.isValidPayload(payload)) { + throw new InvalidTransactionError('Invalid transaction payload'); + } + const { entryFunction } = payload; + const addressArg = entryFunction.args[0]; + const amountArg = entryFunction.args[1]; + const [{ address, amount }] = utils.parseRecipients(addressArg, amountArg); + this.validatorAddress = address; + this.amount = amount.toString(); + } + + protected override getTransactionPayloadData(): InputGenerateTransactionPayloadData { + const { validatorAddress, amount } = this; + if (validatorAddress === undefined) throw new Error('validatorAddress is undefined'); + if (amount === undefined) throw new Error('amount is undefined'); + return { + function: this.moveFunctionId(), + typeArguments: [], + functionArguments: [AccountAddress.fromString(validatorAddress), amount], + abi: this.abi, + }; + } + + private isValidPayload(payload: TransactionPayload): payload is TransactionPayloadEntryFunction { + return ( + payload instanceof TransactionPayloadEntryFunction && + payload.entryFunction.args.length === 2 && + payload.entryFunction.type_args.length === 0 + ); + } + + private abi: EntryFunctionABI = { + typeParameters: [], + parameters: [new TypeTagAddress(), new TypeTagU64()], + }; +} diff --git a/modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolTransaction.ts new file mode 100644 index 0000000000..b95a7594f2 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/abstractDelegationPoolTransaction.ts @@ -0,0 +1,23 @@ +import { DelegationPoolTxData } from '../iface'; +import { Transaction } from './transaction'; +import { BaseCoin } from '@bitgo/statics'; + +/** + * This is for transactions where one delegator participates in a delegation pool. + */ +export abstract class AbstractDelegationPoolTransaction extends Transaction { + public validatorAddress?: string = undefined; + public amount?: string = undefined; + + constructor(coinConfig: Readonly) { + super(coinConfig); + } + + override toJson(): DelegationPoolTxData { + return { + ...super.toJson(), + validatorAddress: this.validatorAddress ?? null, + amount: this.amount ?? null, + }; + } +} diff --git a/modules/sdk-coin-apt/src/lib/transaction/abstractTransferTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/abstractTransferTransaction.ts new file mode 100644 index 0000000000..d6ebe75863 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/abstractTransferTransaction.ts @@ -0,0 +1,37 @@ +import BigNumber from 'bignumber.js'; +import { InputsAndOutputs, Transaction } from './transaction'; +import { TxData } from '../iface'; + +/** + * This is for transactions where one sender sends coins to recipients. + */ +export abstract class AbstractTransferTransaction extends Transaction { + override inputsAndOutputs(): InputsAndOutputs { + const totalAmount = this._recipients.reduce( + (accumulator, current) => accumulator.plus(current.amount), + new BigNumber('0') + ); + const inputs = [ + { + address: this.sender, + value: totalAmount.toString(), + coin: this._coinConfig.name, + }, + ]; + const outputs = this._recipients.map((recipient) => { + return { + address: recipient.address, + value: recipient.amount as string, + coin: this._coinConfig.name, + }; + }); + return { inputs, outputs, externalOutputs: this._recipients }; + } + + override toJson(): TxData { + return { + ...super.toJson(), + recipient: this.recipient, + }; + } +} diff --git a/modules/sdk-coin-apt/src/lib/transaction/customTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/customTransaction.ts index ff328a3f04..2b0acd81d4 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/customTransaction.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/customTransaction.ts @@ -1,31 +1,31 @@ -import { Transaction } from './transaction'; -import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; import { AccountAddress, EntryFunctionABI, EntryFunctionArgumentTypes, - SimpleEntryFunctionArgumentTypes, InputGenerateTransactionPayloadData, + SimpleEntryFunctionArgumentTypes, TransactionPayload, TransactionPayloadEntryFunction, TypeTagAddress, TypeTagBool, - TypeTagU8, + TypeTagU128, TypeTagU16, + TypeTagU256, TypeTagU32, TypeTagU64, - TypeTagU128, - TypeTagU256, + TypeTagU8, } from '@aptos-labs/ts-sdk'; +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { CustomTransactionParams } from '../iface'; -import { validateModuleName, validateFunctionName } from '../utils/validation'; import utils from '../utils'; +import { validateFunctionName, validateModuleName } from '../utils/validation'; +import { AbstractTransferTransaction } from './abstractTransferTransaction'; /** * Transaction class for custom Aptos transactions. */ -export class CustomTransaction extends Transaction { +export class CustomTransaction extends AbstractTransferTransaction { private _moduleName: string; private _functionName: string; private _typeArguments: string[] = []; diff --git a/modules/sdk-coin-apt/src/lib/transaction/delegationPoolAddStakeTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/delegationPoolAddStakeTransaction.ts index 4676671765..17da36e641 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/delegationPoolAddStakeTransaction.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/delegationPoolAddStakeTransaction.ts @@ -1,55 +1,47 @@ -import { Transaction } from './transaction'; -import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; -import { - AccountAddress, - EntryFunctionABI, - InputGenerateTransactionPayloadData, - TransactionPayload, - TransactionPayloadEntryFunction, - TypeTagAddress, - TypeTagU64, -} from '@aptos-labs/ts-sdk'; +import { MoveFunctionId } from '@aptos-labs/ts-sdk'; +import { TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { APTOS_COIN, DELEGATION_POOL_ADD_STAKE_FUNCTION } from '../constants'; -import utils from '../utils'; +import { DELEGATION_POOL_ADD_STAKE_FUNCTION } from '../constants'; +import { AbstractDelegationPoolAmountBasedTransaction } from './abstractDelegationPoolAmountBasedTransaction'; +import { InputsAndOutputs } from './transaction'; -export class DelegationPoolAddStakeTransaction extends Transaction { +export class DelegationPoolAddStakeTransaction extends AbstractDelegationPoolAmountBasedTransaction { constructor(coinConfig: Readonly) { super(coinConfig); this._type = TransactionType.StakingDelegate; - this._assetId = APTOS_COIN; } - protected parseTransactionPayload(payload: TransactionPayload): void { - if (!this.isValidPayload(payload)) { - throw new InvalidTransactionError('Invalid transaction payload'); - } - const { entryFunction } = payload; - const addressArg = entryFunction.args[0]; - const amountArg = entryFunction.args[1]; - this.recipients = utils.parseRecipients(addressArg, amountArg); + override moveFunctionId(): MoveFunctionId { + return DELEGATION_POOL_ADD_STAKE_FUNCTION; } - protected getTransactionPayloadData(): InputGenerateTransactionPayloadData { + override inputsAndOutputs(): InputsAndOutputs { + const { sender, validatorAddress, amount } = this; + if (sender === undefined) throw new Error('sender is undefined'); + if (validatorAddress === undefined) throw new Error('validatorAddress is undefined'); + if (amount === undefined) throw new Error('amount is undefined'); return { - function: DELEGATION_POOL_ADD_STAKE_FUNCTION, - typeArguments: [], - functionArguments: [AccountAddress.fromString(this.recipients[0].address), this.recipients[0].amount], - abi: this.abi, + inputs: [ + { + address: sender, + value: amount, + coin: this._coinConfig.name, + }, + ], + outputs: [ + { + address: validatorAddress, + value: amount, + coin: this._coinConfig.name, + }, + ], + externalOutputs: [ + { + address: validatorAddress, + amount: amount, + }, + ], }; } - - private isValidPayload(payload: TransactionPayload): payload is TransactionPayloadEntryFunction { - return ( - payload instanceof TransactionPayloadEntryFunction && - payload.entryFunction.args.length === 2 && - payload.entryFunction.type_args.length === 0 - ); - } - - private abi: EntryFunctionABI = { - typeParameters: [], - parameters: [new TypeTagAddress(), new TypeTagU64()], - }; } diff --git a/modules/sdk-coin-apt/src/lib/transaction/digitalAssetTransfer.ts b/modules/sdk-coin-apt/src/lib/transaction/digitalAssetTransfer.ts index e791f96380..0fba71e19a 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/digitalAssetTransfer.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/digitalAssetTransfer.ts @@ -1,4 +1,3 @@ -import { Transaction } from './transaction'; import { AccountAddress, EntryFunctionABI, @@ -14,12 +13,13 @@ import { import { InvalidTransactionError, TransactionRecipient, TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { - DIGITAL_ASSET_TYPE_ARGUMENT, - DIGITAL_ASSET_TRANSFER_FUNCTION, DIGITAL_ASSET_TRANSFER_AMOUNT, + DIGITAL_ASSET_TRANSFER_FUNCTION, + DIGITAL_ASSET_TYPE_ARGUMENT, } from '../constants'; +import { AbstractTransferTransaction } from './abstractTransferTransaction'; -export class DigitalAssetTransfer extends Transaction { +export class DigitalAssetTransfer extends AbstractTransferTransaction { constructor(coinConfig: Readonly) { super(coinConfig); this._type = TransactionType.SendNFT; diff --git a/modules/sdk-coin-apt/src/lib/transaction/fungibleAssetTransfer.ts b/modules/sdk-coin-apt/src/lib/transaction/fungibleAssetTransfer.ts index 19f0885c7d..ee9332b311 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/fungibleAssetTransfer.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/fungibleAssetTransfer.ts @@ -1,4 +1,3 @@ -import { Transaction } from './transaction'; import { AccountAddress, EntryFunctionABI, @@ -13,15 +12,16 @@ import { import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { - FUNGIBLE_ASSET_TYPE_ARGUMENT, - FUNGIBLE_ASSET_TRANSFER_FUNCTION, - FUNGIBLE_ASSET_BATCH_TRANSFER_FUNCTION, - FUNBIGLE_ASSET_TYPE_TAG, BATCH_FUNGIBLE_ASSET_TYPE_TAG, + FUNBIGLE_ASSET_TYPE_TAG, + FUNGIBLE_ASSET_BATCH_TRANSFER_FUNCTION, + FUNGIBLE_ASSET_TRANSFER_FUNCTION, + FUNGIBLE_ASSET_TYPE_ARGUMENT, } from '../constants'; import utils from '../utils'; +import { AbstractTransferTransaction } from './abstractTransferTransaction'; -export class FungibleAssetTransfer extends Transaction { +export class FungibleAssetTransfer extends AbstractTransferTransaction { constructor(coinConfig: Readonly) { super(coinConfig); this._type = TransactionType.SendToken; diff --git a/modules/sdk-coin-apt/src/lib/transaction/transaction.ts b/modules/sdk-coin-apt/src/lib/transaction/transaction.ts index 51e10d3b70..de6cf479fe 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/transaction.ts @@ -1,6 +1,7 @@ import { BaseKey, BaseTransaction, + Entry, InvalidTransactionError, PublicKey, Signature, @@ -38,6 +39,15 @@ import BigNumber from 'bignumber.js'; import { AptTransactionExplanation, TxData } from '../iface'; import assert from 'assert'; +export type InputsAndOutputs = { + /** Used for this.inputs */ + inputs: Entry[]; + /** Used for this.outputs */ + outputs: Entry[]; + /** Used for this.explainTransaction() */ + externalOutputs: TransactionRecipient[]; +}; + export abstract class Transaction extends BaseTransaction { protected _rawTransaction: RawTransaction; protected _senderSignature: Signature; @@ -83,7 +93,7 @@ export abstract class Transaction extends BaseTransaction { } /** @inheritDoc **/ - public get id(): string { + public override get id(): string { this.generateTxnId(); return this._id ?? UNAVAILABLE_TEXT; } @@ -278,25 +288,12 @@ export abstract class Transaction extends BaseTransaction { this.loadInputsAndOutputs(); } + abstract inputsAndOutputs(): InputsAndOutputs; + loadInputsAndOutputs(): void { - const totalAmount = this._recipients.reduce( - (accumulator, current) => accumulator.plus(current.amount), - new BigNumber('0') - ); - this._inputs = [ - { - address: this.sender, - value: totalAmount.toString(), - coin: this._coinConfig.name, - }, - ]; - this._outputs = this._recipients.map((recipient) => { - return { - address: recipient.address, - value: recipient.amount as string, - coin: this._coinConfig.name, - }; - }); + const { inputs, outputs } = this.inputsAndOutputs(); + this._inputs = inputs; + this._outputs = outputs; } fromRawTransaction(rawTransaction: string): void { @@ -325,7 +322,6 @@ export abstract class Transaction extends BaseTransaction { return { id: this.id, sender: this.sender, - recipient: this.recipient, recipients: this.recipients, sequenceNumber: this.sequenceNumber, maxGasAmount: this.maxGasAmount, @@ -341,12 +337,12 @@ export abstract class Transaction extends BaseTransaction { return new BigNumber(this.gasUsed).multipliedBy(this.gasUnitPrice).toString(); } - public get signablePayload(): Buffer { + public override get signablePayload(): Buffer { return this.feePayerAddress ? this.getSignablePayloadWithFeePayer() : this.getSignablePayloadWithoutFeePayer(); } /** @inheritDoc */ - explainTransaction(): AptTransactionExplanation { + override explainTransaction(): AptTransactionExplanation { const displayOrder = [ 'id', 'outputs', @@ -359,7 +355,7 @@ export abstract class Transaction extends BaseTransaction { 'type', ]; - const outputs: TransactionRecipient[] = this._recipients; + const outputs: TransactionRecipient[] = this.inputsAndOutputs().externalOutputs; const outputAmount = outputs .reduce((accumulator, current) => accumulator.plus(current.amount), new BigNumber('0')) .toString(); diff --git a/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts index b066d8cbac..0d5a6fcaa7 100644 --- a/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts +++ b/modules/sdk-coin-apt/src/lib/transaction/transferTransaction.ts @@ -1,5 +1,3 @@ -import { Transaction } from './transaction'; -import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; import { AccountAddress, EntryFunctionABI, @@ -10,12 +8,14 @@ import { TypeTagU64, TypeTagVector, } from '@aptos-labs/ts-sdk'; +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { APTOS_COIN, COIN_BATCH_TRANSFER_FUNCTION, COIN_TRANSFER_FUNCTION } from '../constants'; import utils from '../utils'; +import { AbstractTransferTransaction } from './abstractTransferTransaction'; -export class TransferTransaction extends Transaction { +export class TransferTransaction extends AbstractTransferTransaction { constructor(coinConfig: Readonly) { super(coinConfig); this._type = TransactionType.Send; diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts b/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts index 46ab0890e9..b033e06ea4 100644 --- a/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts +++ b/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts @@ -6,6 +6,8 @@ import { TransactionPayload, TransactionPayloadEntryFunction } from '@aptos-labs import { DelegationPoolAddStakeTransaction } from '../transaction/delegationPoolAddStakeTransaction'; export class DelegationPoolAddStakeTransactionBuilder extends TransactionBuilder { + protected override _transaction: DelegationPoolAddStakeTransaction; + constructor(_coinConfig: Readonly) { super(_coinConfig); this.transaction = new DelegationPoolAddStakeTransaction(_coinConfig); @@ -20,6 +22,12 @@ export class DelegationPoolAddStakeTransactionBuilder extends TransactionBuilder return this; } + validator(validatorAddress: string, amount: string): TransactionBuilder { + this._transaction.validatorAddress = validatorAddress; + this._transaction.amount = amount; + return this; + } + protected isValidTransactionPayload(payload: TransactionPayload): boolean { try { if (!this.isValidPayload(payload)) { diff --git a/modules/sdk-coin-apt/test/resources/apt.ts b/modules/sdk-coin-apt/test/resources/apt.ts index a2baad7dc1..16e3198e73 100644 --- a/modules/sdk-coin-apt/test/resources/apt.ts +++ b/modules/sdk-coin-apt/test/resources/apt.ts @@ -83,12 +83,10 @@ export const digitalTokenRecipients: Recipient[] = [ }, ]; -export const delegationPoolAddStakeRecipients: Recipient[] = [ - { - address: addresses.validAddresses[0], - amount: AMOUNT.toString(), - }, -]; +export const delegationPoolData = { + validatorAddress: addresses.validAddresses[0], + amount: AMOUNT.toString(), +}; export const invalidRecipients: Recipient[] = [ { diff --git a/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts b/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts index 330a2f7911..a666a84dee 100644 --- a/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts +++ b/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolAddStakeTransactionBuilder.ts @@ -5,7 +5,7 @@ import { TransactionType } from '@bitgo/sdk-core'; import should from 'should'; import { DelegationPoolAddStakeTransaction } from '../../../src/lib/transaction/delegationPoolAddStakeTransaction'; -describe('Apt Token Transfer Builder', () => { +describe('Apt Delegation Pool Add Stake Builder', () => { const factory = getBuilderFactory('tapt'); describe('Succeed', () => { @@ -13,7 +13,7 @@ describe('Apt Token Transfer Builder', () => { const transaction = new DelegationPoolAddStakeTransaction(coins.get('tapt')); const txBuilder = factory.getDelegationPoolAddStakeTransactionBuilder(transaction); txBuilder.sender(testData.sender.address); - txBuilder.recipients(testData.delegationPoolAddStakeRecipients); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); txBuilder.gasData({ maxGasAmount: 200000, gasUnitPrice: 100, @@ -23,8 +23,9 @@ describe('Apt Token Transfer Builder', () => { txBuilder.addFeePayerAddress(testData.feePayer.address); const tx = (await txBuilder.build()) as DelegationPoolAddStakeTransaction; should.equal(tx.sender, testData.sender.address); - should.equal(tx.recipients[0].address, testData.delegationPoolAddStakeRecipients[0].address); - should.equal(tx.recipients[0].amount, testData.delegationPoolAddStakeRecipients[0].amount); + should.deepEqual(tx.recipients, []); + should.equal(tx.validatorAddress, testData.delegationPoolData.validatorAddress); + should.equal(tx.amount, testData.delegationPoolData.amount); should.equal(tx.maxGasAmount, 200000); should.equal(tx.gasUnitPrice, 100); should.equal(tx.sequenceNumber, 14); @@ -33,14 +34,14 @@ describe('Apt Token Transfer Builder', () => { should.deepEqual(tx.inputs, [ { address: testData.sender.address, - value: testData.delegationPoolAddStakeRecipients[0].amount, + value: testData.delegationPoolData.amount, coin: 'tapt', }, ]); should.deepEqual(tx.outputs, [ { - address: testData.delegationPoolAddStakeRecipients[0].address, - value: testData.delegationPoolAddStakeRecipients[0].amount, + address: testData.delegationPoolData.validatorAddress, + value: testData.delegationPoolData.amount, coin: 'tapt', }, ]); @@ -55,14 +56,14 @@ describe('Apt Token Transfer Builder', () => { tx.inputs.should.deepEqual([ { address: testData.sender.address, - value: testData.delegationPoolAddStakeRecipients[0].amount, + value: testData.delegationPoolData.amount, coin: 'tapt', }, ]); tx.outputs.should.deepEqual([ { - address: testData.delegationPoolAddStakeRecipients[0].address, - value: testData.delegationPoolAddStakeRecipients[0].amount, + address: testData.delegationPoolData.validatorAddress, + value: testData.delegationPoolData.amount, coin: 'tapt', }, ]); @@ -81,7 +82,7 @@ describe('Apt Token Transfer Builder', () => { const transaction = new DelegationPoolAddStakeTransaction(coins.get('tapt')); const txBuilder = factory.getDelegationPoolAddStakeTransactionBuilder(transaction); txBuilder.sender(testData.sender.address); - txBuilder.recipients([testData.delegationPoolAddStakeRecipients[0]]); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); txBuilder.gasData({ maxGasAmount: 200000, gasUnitPrice: 100, @@ -98,7 +99,7 @@ describe('Apt Token Transfer Builder', () => { const transaction = new DelegationPoolAddStakeTransaction(coins.get('tapt')); const txBuilder = factory.getDelegationPoolAddStakeTransactionBuilder(transaction); txBuilder.sender(testData.sender.address); - txBuilder.recipients([testData.delegationPoolAddStakeRecipients[0]]); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); txBuilder.gasData({ maxGasAmount: 200000, gasUnitPrice: 100, @@ -110,16 +111,9 @@ describe('Apt Token Transfer Builder', () => { const tx = (await txBuilder.build()) as DelegationPoolAddStakeTransaction; const toJson = tx.toJson(); should.equal(toJson.sender, testData.sender.address); - should.deepEqual(toJson.recipients, [ - { - address: testData.delegationPoolAddStakeRecipients[0].address, - amount: testData.delegationPoolAddStakeRecipients[0].amount, - }, - ]); - should.deepEqual(toJson.recipient, { - address: testData.delegationPoolAddStakeRecipients[0].address, - amount: testData.delegationPoolAddStakeRecipients[0].amount, - }); + should.deepEqual(toJson.recipients, []); + should.equal(toJson.validatorAddress, testData.delegationPoolData.validatorAddress); + should.equal(toJson.amount, testData.delegationPoolData.amount); should.equal(toJson.sequenceNumber, 14); should.equal(toJson.maxGasAmount, 200000); should.equal(toJson.gasUnitPrice, 100); @@ -133,16 +127,9 @@ describe('Apt Token Transfer Builder', () => { const toJson = tx.toJson(); should.equal(toJson.id, '0xc5b960d1bec149c77896344774352c61441307af564eaa8c84f857208e411bf3'); should.equal(toJson.sender, testData.sender.address); - should.deepEqual(toJson.recipients, [ - { - address: testData.delegationPoolAddStakeRecipients[0].address, - amount: testData.delegationPoolAddStakeRecipients[0].amount.toString(), - }, - ]); - should.deepEqual(toJson.recipient, { - address: testData.delegationPoolAddStakeRecipients[0].address, - amount: testData.delegationPoolAddStakeRecipients[0].amount.toString(), - }); + should.deepEqual(toJson.recipients, []); + should.equal(toJson.validatorAddress, testData.delegationPoolData.validatorAddress); + should.equal(toJson.amount, testData.delegationPoolData.amount); should.equal(toJson.maxGasAmount, 200000); should.equal(toJson.gasUnitPrice, 100); should.equal(toJson.sequenceNumber, 14); From e2cd6f4635f5175e6c297a51e95995194e46c186 Mon Sep 17 00:00:00 2001 From: Harit Kapadia Date: Fri, 31 Oct 2025 17:24:11 -0400 Subject: [PATCH 2/3] feat(sdk-coin-apt): add delegation pool unlock transaction Ticket: SC-3600 --- modules/sdk-coin-apt/src/lib/constants.ts | 1 + .../delegationPoolUnlockTransaction.ts | 26 ++++ .../delegationPoolUnlockTransactionBuilder.ts | 52 ++++++++ .../src/lib/transactionBuilderFactory.ts | 10 ++ modules/sdk-coin-apt/src/lib/utils.ts | 3 + modules/sdk-coin-apt/test/resources/apt.ts | 6 + .../delegationPoolUnlockTransactionBuilder.ts | 115 ++++++++++++++++++ 7 files changed, 213 insertions(+) create mode 100644 modules/sdk-coin-apt/src/lib/transaction/delegationPoolUnlockTransaction.ts create mode 100644 modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts create mode 100644 modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts diff --git a/modules/sdk-coin-apt/src/lib/constants.ts b/modules/sdk-coin-apt/src/lib/constants.ts index 3828e94f0f..e1a4bf80df 100644 --- a/modules/sdk-coin-apt/src/lib/constants.ts +++ b/modules/sdk-coin-apt/src/lib/constants.ts @@ -16,6 +16,7 @@ export const COIN_TRANSFER_FUNCTION = '0x1::aptos_account::transfer_coins'; export const COIN_BATCH_TRANSFER_FUNCTION = '0x1::aptos_account::batch_transfer_coins'; export const DIGITAL_ASSET_TRANSFER_FUNCTION = '0x1::object::transfer'; export const DELEGATION_POOL_ADD_STAKE_FUNCTION = '0x1::delegation_pool::add_stake'; +export const DELEGATION_POOL_UNLOCK_FUNCTION = '0x1::delegation_pool::unlock'; export const APTOS_COIN = '0x1::aptos_coin::AptosCoin'; export const FUNGIBLE_ASSET_TYPE_ARGUMENT = '0x1::fungible_asset::Metadata'; diff --git a/modules/sdk-coin-apt/src/lib/transaction/delegationPoolUnlockTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/delegationPoolUnlockTransaction.ts new file mode 100644 index 0000000000..c998cb454f --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/delegationPoolUnlockTransaction.ts @@ -0,0 +1,26 @@ +import { MoveFunctionId } from '@aptos-labs/ts-sdk'; +import { TransactionType } from '@bitgo/sdk-core'; + +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { DELEGATION_POOL_UNLOCK_FUNCTION } from '../constants'; +import { AbstractDelegationPoolAmountBasedTransaction } from './abstractDelegationPoolAmountBasedTransaction'; +import { InputsAndOutputs } from './transaction'; + +export class DelegationPoolUnlockTransaction extends AbstractDelegationPoolAmountBasedTransaction { + constructor(coinConfig: Readonly) { + super(coinConfig); + this._type = TransactionType.StakingUnlock; + } + + override moveFunctionId(): MoveFunctionId { + return DELEGATION_POOL_UNLOCK_FUNCTION; + } + + override inputsAndOutputs(): InputsAndOutputs { + return { + inputs: [], + outputs: [], + externalOutputs: [], + }; + } +} diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts b/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts new file mode 100644 index 0000000000..9b4b47620d --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts @@ -0,0 +1,52 @@ +import { TransactionBuilder } from './transactionBuilder'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import utils from '../utils'; +import { TransactionPayload, TransactionPayloadEntryFunction } from '@aptos-labs/ts-sdk'; +import { DelegationPoolUnlockTransaction } from '../transaction/delegationPoolUnlockTransaction'; + +export class DelegationPoolUnlockTransactionBuilder extends TransactionBuilder { + protected override _transaction: DelegationPoolUnlockTransaction; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.transaction = new DelegationPoolUnlockTransaction(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.StakingUnlock; + } + + assetId(_assetId: string): TransactionBuilder { + this.transaction.assetId = _assetId; + return this; + } + + validator(validatorAddress: string, amount: string): TransactionBuilder { + this._transaction.validatorAddress = validatorAddress; + this._transaction.amount = amount; + return this; + } + + protected isValidTransactionPayload(payload: TransactionPayload): boolean { + try { + if (!this.isValidPayload(payload)) { + return false; + } + const { entryFunction } = payload; + const addressArg = entryFunction.args[0]; + const amountArg = entryFunction.args[1]; + return utils.fetchAndValidateRecipients(addressArg, amountArg).isValid; + } catch (e) { + return false; + } + } + + private isValidPayload(payload: TransactionPayload): payload is TransactionPayloadEntryFunction { + return ( + payload instanceof TransactionPayloadEntryFunction && + payload.entryFunction.args.length === 2 && + payload.entryFunction.type_args.length === 0 + ); + } +} diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts index 6f31f40e75..dd22dda918 100644 --- a/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts @@ -14,6 +14,8 @@ import { CustomTransaction } from './transaction/customTransaction'; import { CustomTransactionBuilder } from './transactionBuilder/customTransactionBuilder'; import { DelegationPoolAddStakeTransaction } from './transaction/delegationPoolAddStakeTransaction'; import { DelegationPoolAddStakeTransactionBuilder } from './transactionBuilder/delegationPoolAddStakeTransactionBuilder'; +import { DelegationPoolUnlockTransaction } from './transaction/delegationPoolUnlockTransaction'; +import { DelegationPoolUnlockTransactionBuilder } from './transactionBuilder/delegationPoolUnlockTransactionBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -43,6 +45,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const delegateTx = new DelegationPoolAddStakeTransaction(this._coinConfig); delegateTx.fromDeserializedSignedTransaction(signedTxn); return this.getDelegationPoolAddStakeTransactionBuilder(delegateTx); + case TransactionType.StakingUnlock: + const unlockTx = new DelegationPoolUnlockTransaction(this._coinConfig); + unlockTx.fromDeserializedSignedTransaction(signedTxn); + return this.getDelegationPoolUnlockTransactionBuilder(unlockTx); case TransactionType.CustomTx: const customTx = new CustomTransaction(this._coinConfig); if (abi) { @@ -82,6 +88,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new DelegationPoolAddStakeTransactionBuilder(this._coinConfig)); } + getDelegationPoolUnlockTransactionBuilder(tx?: Transaction): DelegationPoolUnlockTransactionBuilder { + return this.initializeBuilder(tx, new DelegationPoolUnlockTransactionBuilder(this._coinConfig)); + } + /** * Get a custom transaction builder * diff --git a/modules/sdk-coin-apt/src/lib/utils.ts b/modules/sdk-coin-apt/src/lib/utils.ts index 9bb201efe1..a4a1d31448 100644 --- a/modules/sdk-coin-apt/src/lib/utils.ts +++ b/modules/sdk-coin-apt/src/lib/utils.ts @@ -29,6 +29,7 @@ import { COIN_BATCH_TRANSFER_FUNCTION, COIN_TRANSFER_FUNCTION, DELEGATION_POOL_ADD_STAKE_FUNCTION, + DELEGATION_POOL_UNLOCK_FUNCTION, DIGITAL_ASSET_TRANSFER_FUNCTION, FUNGIBLE_ASSET_BATCH_TRANSFER_FUNCTION, FUNGIBLE_ASSET_TRANSFER_FUNCTION, @@ -100,6 +101,8 @@ export class Utils implements BaseUtils { return TransactionType.SendNFT; case DELEGATION_POOL_ADD_STAKE_FUNCTION: return TransactionType.StakingDelegate; + case DELEGATION_POOL_UNLOCK_FUNCTION: + return TransactionType.StakingUnlock; default: // For any other function calls, treat as a custom transaction return TransactionType.CustomTx; diff --git a/modules/sdk-coin-apt/test/resources/apt.ts b/modules/sdk-coin-apt/test/resources/apt.ts index 16e3198e73..06d1122b31 100644 --- a/modules/sdk-coin-apt/test/resources/apt.ts +++ b/modules/sdk-coin-apt/test/resources/apt.ts @@ -144,5 +144,11 @@ export const FUNGIBLE_BATCH_SIGNABLE_PAYLOAD = export const DELEGATION_POOL_ADD_STAKE_TX_HEX = '0x1aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c096164645f7374616b65000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d670000000002030020000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2002000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; +export const DELEGATION_POOL_UNLOCK_TX_HEX = + '0x1aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c06756e6c6f636b000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d670000000002030020000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2002000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + export const DELEGATION_POOL_ADD_STAKE_TX_HEX_SIGNABLE_PAYLOAD = '5efa3c4f02f83a0f4b2d69fc95c607cc02825cc4e7be536ef0992df050d9e67c011aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c096164645f7374616b65000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d67000000000200dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2'; + +export const DELEGATION_POOL_UNLOCK_TX_HEX_SIGNABLE_PAYLOAD = + '5efa3c4f02f83a0f4b2d69fc95c607cc02825cc4e7be536ef0992df050d9e67c011aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c06756e6c6f636b000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d67000000000200dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2'; diff --git a/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts b/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts new file mode 100644 index 0000000000..0ca839ee72 --- /dev/null +++ b/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolUnlockTransactionBuilder.ts @@ -0,0 +1,115 @@ +import { getBuilderFactory } from '../getBuilderFactory'; +import { coins } from '@bitgo/statics'; +import * as testData from '../../resources/apt'; +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { DelegationPoolUnlockTransaction } from '../../../src/lib/transaction/delegationPoolUnlockTransaction'; + +describe('Apt Delegation Pool Unlock Builder', () => { + const factory = getBuilderFactory('tapt'); + + describe('Succeed', () => { + it('should build a staking delegate transaction', async function () { + const transaction = new DelegationPoolUnlockTransaction(coins.get('tapt')); + const txBuilder = factory.getDelegationPoolUnlockTransactionBuilder(transaction); + txBuilder.sender(testData.sender.address); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as DelegationPoolUnlockTransaction; + should.equal(tx.sender, testData.sender.address); + should.deepEqual(tx.recipients, []); + should.deepEqual(tx.validatorAddress, testData.delegationPoolData.validatorAddress); + should.deepEqual(tx.amount, testData.delegationPoolData.amount); + should.equal(tx.maxGasAmount, 200000); + should.equal(tx.gasUnitPrice, 100); + should.equal(tx.sequenceNumber, 14); + should.equal(tx.expirationTime, 1736246155); + should.equal(tx.type, TransactionType.StakingUnlock); + should.deepEqual(tx.inputs, []); + should.deepEqual(tx.outputs, []); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + rawTx.should.equal(testData.DELEGATION_POOL_UNLOCK_TX_HEX); + }); + + it('should build and send a signed tx', async function () { + const txBuilder = factory.from(testData.DELEGATION_POOL_UNLOCK_TX_HEX); + const tx = (await txBuilder.build()) as DelegationPoolUnlockTransaction; + tx.inputs.should.deepEqual([]); + tx.outputs.should.deepEqual([]); + should.equal(tx.id, '0x471bb32955f9cff7c9c0a603ef2354e781fd80a221f9044f08df84d95473e86f'); + should.equal(tx.maxGasAmount, 200000); + should.equal(tx.gasUnitPrice, 100); + should.equal(tx.sequenceNumber, 14); + should.equal(tx.expirationTime, 1736246155); + should.equal(tx.type, TransactionType.StakingUnlock); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + should.equal(rawTx, testData.DELEGATION_POOL_UNLOCK_TX_HEX); + }); + + it('should succeed to validate a valid signablePayload', async function () { + const transaction = new DelegationPoolUnlockTransaction(coins.get('tapt')); + const txBuilder = factory.getDelegationPoolUnlockTransactionBuilder(transaction); + txBuilder.sender(testData.sender.address); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as DelegationPoolUnlockTransaction; + const signablePayload = tx.signablePayload; + should.equal(signablePayload.toString('hex'), testData.DELEGATION_POOL_UNLOCK_TX_HEX_SIGNABLE_PAYLOAD); + }); + + it('should build a unsigned tx and validate its toJson', async function () { + const transaction = new DelegationPoolUnlockTransaction(coins.get('tapt')); + const txBuilder = factory.getDelegationPoolUnlockTransactionBuilder(transaction); + txBuilder.sender(testData.sender.address); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + txBuilder.assetId(testData.fungibleTokenAddress.usdt); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as DelegationPoolUnlockTransaction; + const toJson = tx.toJson(); + should.equal(toJson.sender, testData.sender.address); + should.deepEqual(toJson.recipients, []); + should.deepEqual(tx.validatorAddress, testData.delegationPoolData.validatorAddress); + should.deepEqual(tx.amount, testData.delegationPoolData.amount); + should.equal(toJson.sequenceNumber, 14); + should.equal(toJson.maxGasAmount, 200000); + should.equal(toJson.gasUnitPrice, 100); + should.equal(toJson.expirationTime, 1736246155); + should.equal(toJson.feePayer, testData.feePayer.address); + }); + + it('should build a signed tx and validate its toJson', async function () { + const txBuilder = factory.from(testData.DELEGATION_POOL_UNLOCK_TX_HEX); + const tx = (await txBuilder.build()) as DelegationPoolUnlockTransaction; + const toJson = tx.toJson(); + should.equal(toJson.id, '0x471bb32955f9cff7c9c0a603ef2354e781fd80a221f9044f08df84d95473e86f'); + should.equal(toJson.sender, testData.sender.address); + should.deepEqual(toJson.recipients, []); + should.deepEqual(tx.validatorAddress, testData.delegationPoolData.validatorAddress); + should.deepEqual(tx.amount, testData.delegationPoolData.amount); + should.equal(toJson.maxGasAmount, 200000); + should.equal(toJson.gasUnitPrice, 100); + should.equal(toJson.sequenceNumber, 14); + should.equal(toJson.expirationTime, 1736246155); + }); + }); +}); From 3bdeb41990ab21fbd64bb0d487f81569fd8776d6 Mon Sep 17 00:00:00 2001 From: Harit Kapadia Date: Fri, 31 Oct 2025 17:25:38 -0400 Subject: [PATCH 3/3] feat(sdk-coin-apt): add delegation pool withdraw transaction Ticket: SC-3601 --- modules/sdk-coin-apt/src/lib/constants.ts | 1 + .../delegationPoolWithdrawTransaction.ts | 42 ++++++ ...elegationPoolWithdrawTransactionBuilder.ts | 52 +++++++ .../src/lib/transactionBuilderFactory.ts | 10 ++ modules/sdk-coin-apt/src/lib/utils.ts | 3 + modules/sdk-coin-apt/test/resources/apt.ts | 6 + ...elegationPoolWithdrawTransactionBuilder.ts | 139 ++++++++++++++++++ 7 files changed, 253 insertions(+) create mode 100644 modules/sdk-coin-apt/src/lib/transaction/delegationPoolWithdrawTransaction.ts create mode 100644 modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts create mode 100644 modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts diff --git a/modules/sdk-coin-apt/src/lib/constants.ts b/modules/sdk-coin-apt/src/lib/constants.ts index e1a4bf80df..43080bf6d0 100644 --- a/modules/sdk-coin-apt/src/lib/constants.ts +++ b/modules/sdk-coin-apt/src/lib/constants.ts @@ -17,6 +17,7 @@ export const COIN_BATCH_TRANSFER_FUNCTION = '0x1::aptos_account::batch_transfer_ export const DIGITAL_ASSET_TRANSFER_FUNCTION = '0x1::object::transfer'; export const DELEGATION_POOL_ADD_STAKE_FUNCTION = '0x1::delegation_pool::add_stake'; export const DELEGATION_POOL_UNLOCK_FUNCTION = '0x1::delegation_pool::unlock'; +export const DELEGATION_POOL_WITHDRAW_FUNCTION = '0x1::delegation_pool::withdraw'; export const APTOS_COIN = '0x1::aptos_coin::AptosCoin'; export const FUNGIBLE_ASSET_TYPE_ARGUMENT = '0x1::fungible_asset::Metadata'; diff --git a/modules/sdk-coin-apt/src/lib/transaction/delegationPoolWithdrawTransaction.ts b/modules/sdk-coin-apt/src/lib/transaction/delegationPoolWithdrawTransaction.ts new file mode 100644 index 0000000000..bcd000caf7 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transaction/delegationPoolWithdrawTransaction.ts @@ -0,0 +1,42 @@ +import { MoveFunctionId } from '@aptos-labs/ts-sdk'; +import { TransactionType } from '@bitgo/sdk-core'; + +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { DELEGATION_POOL_WITHDRAW_FUNCTION } from '../constants'; +import { AbstractDelegationPoolAmountBasedTransaction } from './abstractDelegationPoolAmountBasedTransaction'; +import { InputsAndOutputs } from './transaction'; + +export class DelegationPoolWithdrawTransaction extends AbstractDelegationPoolAmountBasedTransaction { + constructor(coinConfig: Readonly) { + super(coinConfig); + this._type = TransactionType.StakingWithdraw; + } + + override moveFunctionId(): MoveFunctionId { + return DELEGATION_POOL_WITHDRAW_FUNCTION; + } + + override inputsAndOutputs(): InputsAndOutputs { + const { sender, validatorAddress, amount } = this; + if (sender === undefined) throw new Error('sender is undefined'); + if (validatorAddress === undefined) throw new Error('validatorAddress is undefined'); + if (amount === undefined) throw new Error('amount is undefined'); + return { + inputs: [ + { + address: validatorAddress, + value: amount, + coin: this._coinConfig.name, + }, + ], + outputs: [ + { + address: sender, + value: amount, + coin: this._coinConfig.name, + }, + ], + externalOutputs: [], + }; + } +} diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts b/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts new file mode 100644 index 0000000000..d7d71eba36 --- /dev/null +++ b/modules/sdk-coin-apt/src/lib/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts @@ -0,0 +1,52 @@ +import { TransactionBuilder } from './transactionBuilder'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import utils from '../utils'; +import { TransactionPayload, TransactionPayloadEntryFunction } from '@aptos-labs/ts-sdk'; +import { DelegationPoolWithdrawTransaction } from '../transaction/delegationPoolWithdrawTransaction'; + +export class DelegationPoolWithdrawTransactionBuilder extends TransactionBuilder { + protected override _transaction: DelegationPoolWithdrawTransaction; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.transaction = new DelegationPoolWithdrawTransaction(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.StakingWithdraw; + } + + assetId(_assetId: string): TransactionBuilder { + this.transaction.assetId = _assetId; + return this; + } + + validator(validatorAddress: string, amount: string): TransactionBuilder { + this._transaction.validatorAddress = validatorAddress; + this._transaction.amount = amount; + return this; + } + + protected isValidTransactionPayload(payload: TransactionPayload): boolean { + try { + if (!this.isValidPayload(payload)) { + return false; + } + const { entryFunction } = payload; + const addressArg = entryFunction.args[0]; + const amountArg = entryFunction.args[1]; + return utils.fetchAndValidateRecipients(addressArg, amountArg).isValid; + } catch (e) { + return false; + } + } + + private isValidPayload(payload: TransactionPayload): payload is TransactionPayloadEntryFunction { + return ( + payload instanceof TransactionPayloadEntryFunction && + payload.entryFunction.args.length === 2 && + payload.entryFunction.type_args.length === 0 + ); + } +} diff --git a/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts index dd22dda918..e61fde3789 100644 --- a/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-apt/src/lib/transactionBuilderFactory.ts @@ -15,7 +15,9 @@ import { CustomTransactionBuilder } from './transactionBuilder/customTransaction import { DelegationPoolAddStakeTransaction } from './transaction/delegationPoolAddStakeTransaction'; import { DelegationPoolAddStakeTransactionBuilder } from './transactionBuilder/delegationPoolAddStakeTransactionBuilder'; import { DelegationPoolUnlockTransaction } from './transaction/delegationPoolUnlockTransaction'; +import { DelegationPoolWithdrawTransactionBuilder } from './transactionBuilder/delegationPoolWithdrawTransactionBuilder'; import { DelegationPoolUnlockTransactionBuilder } from './transactionBuilder/delegationPoolUnlockTransactionBuilder'; +import { DelegationPoolWithdrawTransaction } from './transaction/delegationPoolWithdrawTransaction'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -49,6 +51,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const unlockTx = new DelegationPoolUnlockTransaction(this._coinConfig); unlockTx.fromDeserializedSignedTransaction(signedTxn); return this.getDelegationPoolUnlockTransactionBuilder(unlockTx); + case TransactionType.StakingWithdraw: + const withdrawTx = new DelegationPoolWithdrawTransaction(this._coinConfig); + withdrawTx.fromDeserializedSignedTransaction(signedTxn); + return this.getDelegationPoolWithdrawTransactionBuilder(withdrawTx); case TransactionType.CustomTx: const customTx = new CustomTransaction(this._coinConfig); if (abi) { @@ -92,6 +98,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new DelegationPoolUnlockTransactionBuilder(this._coinConfig)); } + getDelegationPoolWithdrawTransactionBuilder(tx?: Transaction): DelegationPoolWithdrawTransactionBuilder { + return this.initializeBuilder(tx, new DelegationPoolWithdrawTransactionBuilder(this._coinConfig)); + } + /** * Get a custom transaction builder * diff --git a/modules/sdk-coin-apt/src/lib/utils.ts b/modules/sdk-coin-apt/src/lib/utils.ts index a4a1d31448..bef0cf1c1b 100644 --- a/modules/sdk-coin-apt/src/lib/utils.ts +++ b/modules/sdk-coin-apt/src/lib/utils.ts @@ -30,6 +30,7 @@ import { COIN_TRANSFER_FUNCTION, DELEGATION_POOL_ADD_STAKE_FUNCTION, DELEGATION_POOL_UNLOCK_FUNCTION, + DELEGATION_POOL_WITHDRAW_FUNCTION, DIGITAL_ASSET_TRANSFER_FUNCTION, FUNGIBLE_ASSET_BATCH_TRANSFER_FUNCTION, FUNGIBLE_ASSET_TRANSFER_FUNCTION, @@ -103,6 +104,8 @@ export class Utils implements BaseUtils { return TransactionType.StakingDelegate; case DELEGATION_POOL_UNLOCK_FUNCTION: return TransactionType.StakingUnlock; + case DELEGATION_POOL_WITHDRAW_FUNCTION: + return TransactionType.StakingWithdraw; default: // For any other function calls, treat as a custom transaction return TransactionType.CustomTx; diff --git a/modules/sdk-coin-apt/test/resources/apt.ts b/modules/sdk-coin-apt/test/resources/apt.ts index 06d1122b31..fd242cb7fe 100644 --- a/modules/sdk-coin-apt/test/resources/apt.ts +++ b/modules/sdk-coin-apt/test/resources/apt.ts @@ -147,8 +147,14 @@ export const DELEGATION_POOL_ADD_STAKE_TX_HEX = export const DELEGATION_POOL_UNLOCK_TX_HEX = '0x1aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c06756e6c6f636b000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d670000000002030020000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2002000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; +export const DELEGATION_POOL_WITHDRAW_TX_HEX = + '0x1aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c087769746864726177000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d670000000002030020000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2002000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + export const DELEGATION_POOL_ADD_STAKE_TX_HEX_SIGNABLE_PAYLOAD = '5efa3c4f02f83a0f4b2d69fc95c607cc02825cc4e7be536ef0992df050d9e67c011aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c096164645f7374616b65000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d67000000000200dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2'; export const DELEGATION_POOL_UNLOCK_TX_HEX_SIGNABLE_PAYLOAD = '5efa3c4f02f83a0f4b2d69fc95c607cc02825cc4e7be536ef0992df050d9e67c011aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c06756e6c6f636b000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d67000000000200dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2'; + +export const DELEGATION_POOL_WITHDRAW_TX_HEX_SIGNABLE_PAYLOAD = + '5efa3c4f02f83a0f4b2d69fc95c607cc02825cc4e7be536ef0992df050d9e67c011aed808916ab9b1b30b07abb53561afd46847285ce28651221d406173a3724490e000000000000000200000000000000000000000000000000000000000000000000000000000000010f64656c65676174696f6e5f706f6f6c087769746864726177000220f7405c28a02cf5bab4ea4498240bb3579db45951794eb1c843bef0534c093ad908e803000000000000400d03000000000064000000000000008b037d67000000000200dbc87a1c816d9bcd06b683c37e80c7162e4d48da7812198b830e4d5d8e0629f2'; diff --git a/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts b/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts new file mode 100644 index 0000000000..5fa63ccd9c --- /dev/null +++ b/modules/sdk-coin-apt/test/unit/transactionBuilder/delegationPoolWithdrawTransactionBuilder.ts @@ -0,0 +1,139 @@ +import { getBuilderFactory } from '../getBuilderFactory'; +import { coins } from '@bitgo/statics'; +import * as testData from '../../resources/apt'; +import { TransactionType } from '@bitgo/sdk-core'; +import should from 'should'; +import { DelegationPoolWithdrawTransaction } from '../../../src/lib/transaction/delegationPoolWithdrawTransaction'; + +describe('Apt Delegation Pool Withdraw Builder', () => { + const factory = getBuilderFactory('tapt'); + + describe('Succeed', () => { + it('should build a staking withdraw transaction', async function () { + const transaction = new DelegationPoolWithdrawTransaction(coins.get('tapt')); + const txBuilder = factory.getDelegationPoolWithdrawTransactionBuilder(transaction); + txBuilder.sender(testData.sender.address); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as DelegationPoolWithdrawTransaction; + should.equal(tx.sender, testData.sender.address); + should.deepEqual(tx.recipients, []); + should.equal(tx.validatorAddress, testData.delegationPoolData.validatorAddress); + should.equal(tx.amount, testData.delegationPoolData.amount); + should.equal(tx.maxGasAmount, 200000); + should.equal(tx.gasUnitPrice, 100); + should.equal(tx.sequenceNumber, 14); + should.equal(tx.expirationTime, 1736246155); + should.equal(tx.type, TransactionType.StakingWithdraw); + should.deepEqual(tx.inputs, [ + { + address: testData.delegationPoolData.validatorAddress, + value: testData.delegationPoolData.amount, + coin: 'tapt', + }, + ]); + should.deepEqual(tx.outputs, [ + { + address: testData.sender.address, + value: testData.delegationPoolData.amount, + coin: 'tapt', + }, + ]); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + rawTx.should.equal(testData.DELEGATION_POOL_WITHDRAW_TX_HEX); + }); + + it('should build and send a signed tx', async function () { + const txBuilder = factory.from(testData.DELEGATION_POOL_WITHDRAW_TX_HEX); + const tx = (await txBuilder.build()) as DelegationPoolWithdrawTransaction; + tx.inputs.should.deepEqual([ + { + address: testData.delegationPoolData.validatorAddress, + value: testData.delegationPoolData.amount, + coin: 'tapt', + }, + ]); + tx.outputs.should.deepEqual([ + { + address: testData.sender.address, + value: testData.delegationPoolData.amount, + coin: 'tapt', + }, + ]); + should.equal(tx.id, '0xd795391e85ffd5e37b844db4206c5bd99ba28a42430df996969ee9b7f16a5f21'); + should.equal(tx.maxGasAmount, 200000); + should.equal(tx.gasUnitPrice, 100); + should.equal(tx.sequenceNumber, 14); + should.equal(tx.expirationTime, 1736246155); + should.equal(tx.type, TransactionType.StakingWithdraw); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + should.equal(rawTx, testData.DELEGATION_POOL_WITHDRAW_TX_HEX); + }); + + it('should succeed to validate a valid signablePayload', async function () { + const transaction = new DelegationPoolWithdrawTransaction(coins.get('tapt')); + const txBuilder = factory.getDelegationPoolWithdrawTransactionBuilder(transaction); + txBuilder.sender(testData.sender.address); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as DelegationPoolWithdrawTransaction; + const signablePayload = tx.signablePayload; + should.equal(signablePayload.toString('hex'), testData.DELEGATION_POOL_WITHDRAW_TX_HEX_SIGNABLE_PAYLOAD); + }); + + it('should build a unsigned tx and validate its toJson', async function () { + const transaction = new DelegationPoolWithdrawTransaction(coins.get('tapt')); + const txBuilder = factory.getDelegationPoolWithdrawTransactionBuilder(transaction); + txBuilder.sender(testData.sender.address); + txBuilder.validator(testData.delegationPoolData.validatorAddress, testData.delegationPoolData.amount); + txBuilder.gasData({ + maxGasAmount: 200000, + gasUnitPrice: 100, + }); + txBuilder.sequenceNumber(14); + txBuilder.expirationTime(1736246155); + txBuilder.assetId(testData.fungibleTokenAddress.usdt); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as DelegationPoolWithdrawTransaction; + const toJson = tx.toJson(); + should.equal(toJson.sender, testData.sender.address); + should.deepEqual(toJson.recipients, []); + should.equal(toJson.validatorAddress, testData.delegationPoolData.validatorAddress); + should.equal(toJson.amount, testData.delegationPoolData.amount); + should.equal(toJson.sequenceNumber, 14); + should.equal(toJson.maxGasAmount, 200000); + should.equal(toJson.gasUnitPrice, 100); + should.equal(toJson.expirationTime, 1736246155); + should.equal(toJson.feePayer, testData.feePayer.address); + }); + + it('should build a signed tx and validate its toJson', async function () { + const txBuilder = factory.from(testData.DELEGATION_POOL_WITHDRAW_TX_HEX); + const tx = (await txBuilder.build()) as DelegationPoolWithdrawTransaction; + const toJson = tx.toJson(); + should.equal(toJson.id, '0xd795391e85ffd5e37b844db4206c5bd99ba28a42430df996969ee9b7f16a5f21'); + should.equal(toJson.sender, testData.sender.address); + should.deepEqual(toJson.recipients, []); + should.equal(toJson.validatorAddress, testData.delegationPoolData.validatorAddress); + should.equal(toJson.amount, testData.delegationPoolData.amount); + should.equal(toJson.maxGasAmount, 200000); + should.equal(toJson.gasUnitPrice, 100); + should.equal(toJson.sequenceNumber, 14); + should.equal(toJson.expirationTime, 1736246155); + }); + }); +});