From c2798affd625d7e0c11ec5df6163a95fdae3988d Mon Sep 17 00:00:00 2001 From: BitGo Agent Date: Thu, 2 Jul 2026 14:41:35 +0000 Subject: [PATCH] feat(sdk-coin-vet): add FlushCoins transaction support for VeChain Add missing FlushCoins transaction type to the sdk-coin-vet module. Flushing native VET coins from a forwarder to the main wallet address was broken because the VeChain module had no FlushCoins transaction class, no corresponding builder, and getTransactionTypeFromClause() did not recognize the flush() method selector (0x6b9f96ea). The platform's consolidation path calls the parameterless flush() ABI function on v4 forwarder contracts, which encodes to selector 0x6b9f96ea. Without this support, those transactions could not be built, validated, or round-tripped through the factory, making it impossible to flush VET funds to the main wallet address. - Add FlushCoinsTransaction class mirroring FlushTokenTransaction - Add FlushCoinsTransactionBuilder that encodes flushCoinsData() - Recognize 0x6b9f96ea selector in getTransactionTypeFromClause() - Wire FlushCoins case into TransactionBuilderFactory.from() - Export new classes from lib/index.ts - Add unit tests for clause detection and builder behavior Ticket: COINS-611 Session-Id: 3ad67a90-935c-4b38-9c6b-d7bc96ac7cc9 Task-Id: 3183f576-f5f6-4490-8be2-7a612f7e86df --- modules/sdk-coin-vet/src/lib/index.ts | 2 + .../lib/transaction/flushCoinsTransaction.ts | 83 ++++++++++++++++ .../flushCoinsTransactionBuilder.ts | 77 +++++++++++++++ .../src/lib/transactionBuilderFactory.ts | 10 ++ modules/sdk-coin-vet/src/lib/utils.ts | 3 + modules/sdk-coin-vet/test/resources/vet.ts | 16 ++++ .../flushCoinsTransactionBuilder.ts | 96 +++++++++++++++++++ modules/sdk-coin-vet/test/unit/utils.ts | 1 + 8 files changed, 288 insertions(+) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/flushCoinsTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/flushCoinsTransactionBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/flushCoinsTransactionBuilder.ts diff --git a/modules/sdk-coin-vet/src/lib/index.ts b/modules/sdk-coin-vet/src/lib/index.ts index 9abc11fd31..851347a06c 100644 --- a/modules/sdk-coin-vet/src/lib/index.ts +++ b/modules/sdk-coin-vet/src/lib/index.ts @@ -6,6 +6,7 @@ export { KeyPair } from './keyPair'; export { Transaction } from './transaction/transaction'; export { AddressInitializationTransaction } from './transaction/addressInitializationTransaction'; export { FlushTokenTransaction } from './transaction/flushTokenTransaction'; +export { FlushCoinsTransaction } from './transaction/flushCoinsTransaction'; export { TokenTransaction } from './transaction/tokenTransaction'; export { StakingTransaction } from './transaction/stakingTransaction'; export { StakeClauseTransaction } from './transaction/stakeClauseTransaction'; @@ -23,6 +24,7 @@ export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; export { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; +export { FlushCoinsTransactionBuilder } from './transactionBuilder/flushCoinsTransactionBuilder'; export { StakingBuilder } from './transactionBuilder/stakingBuilder'; export { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder'; export { DelegateTxnBuilder } from './transactionBuilder/delegateTxnBuilder'; diff --git a/modules/sdk-coin-vet/src/lib/transaction/flushCoinsTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/flushCoinsTransaction.ts new file mode 100644 index 0000000000..afb1f1d7d4 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/flushCoinsTransaction.ts @@ -0,0 +1,83 @@ +import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction as VetTransaction, Secp256k1 } from '@vechain/sdk-core'; + +import { Transaction } from './transaction'; +import { VetTransactionData } from '../iface'; + +export class FlushCoinsTransaction extends Transaction { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.FlushCoins; + } + + /** @inheritdoc */ + buildClauses(): void { + this._clauses = [ + { + to: this._contract, + value: '0x0', + data: this._transactionData, + }, + ]; + } + + /** @inheritdoc */ + toJson(): VetTransactionData { + return { + id: this.id, + chainTag: this.chainTag, + blockRef: this.blockRef, + expiration: this.expiration, + gasPriceCoef: this.gasPriceCoef, + gas: this.gas, + dependsOn: this.dependsOn, + nonce: this.nonce, + data: this.transactionData, + value: '0', + sender: this.sender, + to: this.contract, + }; + } + + /** @inheritdoc */ + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + this.rawTransaction = signedTx; + + const body = signedTx.body; + this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0; + this.blockRef = body.blockRef || '0x0'; + this.expiration = typeof body.expiration === 'number' ? body.expiration : 64; + this.clauses = body.clauses || []; + this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128; + this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0; + this.dependsOn = body.dependsOn || null; + this.nonce = String(body.nonce); + this.contract = body.clauses[0]?.to || '0x0'; + this.transactionData = body.clauses[0]?.data || '0x0'; + this.type = TransactionType.FlushCoins; + + try { + if (signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + } catch { + // unsigned transaction has no origin + } + + if (signedTx.signature) { + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) { + this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH)); + } + } + } catch (e) { + throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`); + } + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/flushCoinsTransactionBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/flushCoinsTransactionBuilder.ts new file mode 100644 index 0000000000..edc419e7d4 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/flushCoinsTransactionBuilder.ts @@ -0,0 +1,77 @@ +import assert from 'assert'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType, BuildTransactionError } from '@bitgo/sdk-core'; +import { flushCoinsData, flushCoinsMethodId } from '@bitgo/abstract-eth'; +import { TransactionClause } from '@vechain/sdk-core'; + +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from '../transaction/transaction'; +import { FlushCoinsTransaction } from '../transaction/flushCoinsTransaction'; +import utils from '../utils'; + +export class FlushCoinsTransactionBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new FlushCoinsTransaction(_coinConfig); + } + + initBuilder(tx: FlushCoinsTransaction): void { + this._transaction = tx; + } + + get flushCoinsTransaction(): FlushCoinsTransaction { + return this._transaction as FlushCoinsTransaction; + } + + protected get transactionType(): TransactionType { + return TransactionType.FlushCoins; + } + + /** + * Validates the transaction clauses for a flush coins transaction. + * The clause must call the parameterless `flush()` method (selector 0x6b9f96ea) + * on a valid forwarder address with zero value. + */ + protected isValidTransactionClauses(clauses: TransactionClause[]): boolean { + try { + if (!clauses || !Array.isArray(clauses) || clauses.length === 0) { + return false; + } + + const clause = clauses[0]; + + if (!clause.to || !utils.isValidAddress(clause.to)) { + return false; + } + + if (clause.value !== 0) { + return false; + } + + if (!clause.data.startsWith(flushCoinsMethodId)) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + /** @inheritdoc */ + validateTransaction(transaction?: FlushCoinsTransaction): void { + if (!transaction) { + throw new BuildTransactionError('transaction not defined'); + } + assert(transaction.contract, 'Contract address is required'); + this.validateAddress({ address: transaction.contract }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + this.flushCoinsTransaction.transactionData = flushCoinsData(); + await this.flushCoinsTransaction.build(); + return this.transaction; + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts index a7ae9d8a19..2a62354c7e 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -5,6 +5,7 @@ import { TransactionBuilder } from './transactionBuilder/transactionBuilder'; import { TransferBuilder } from './transactionBuilder/transferBuilder'; import { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; import { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; +import { FlushCoinsTransactionBuilder } from './transactionBuilder/flushCoinsTransactionBuilder'; import { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; import { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; import { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder'; @@ -12,6 +13,7 @@ import { Transaction } from './transaction/transaction'; import utils from './utils'; import { AddressInitializationTransaction } from './transaction/addressInitializationTransaction'; import { FlushTokenTransaction } from './transaction/flushTokenTransaction'; +import { FlushCoinsTransaction } from './transaction/flushCoinsTransaction'; import { ExitDelegationTransaction } from './transaction/exitDelegation'; import { BurnNftTransaction } from './transaction/burnNftTransaction'; import { ClaimRewardsTransaction } from './transaction/claimRewards'; @@ -59,6 +61,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const flushTokenTx = new FlushTokenTransaction(this._coinConfig); flushTokenTx.fromDeserializedSignedTransaction(signedTx); return this.getFlushTokenTransactionBuilder(flushTokenTx); + case TransactionType.FlushCoins: + const flushCoinsTx = new FlushCoinsTransaction(this._coinConfig); + flushCoinsTx.fromDeserializedSignedTransaction(signedTx); + return this.getFlushCoinsTransactionBuilder(flushCoinsTx); case TransactionType.SendToken: const tokenTransferTx = new TokenTransaction(this._coinConfig); tokenTransferTx.fromDeserializedSignedTransaction(signedTx); @@ -132,6 +138,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new FlushTokenTransactionBuilder(this._coinConfig)); } + getFlushCoinsTransactionBuilder(tx?: FlushCoinsTransaction): FlushCoinsTransactionBuilder { + return this.initializeBuilder(tx, new FlushCoinsTransactionBuilder(this._coinConfig)); + } + getTokenTransactionBuilder(tx?: Transaction): TokenTransactionBuilder { return this.initializeBuilder(tx, new TokenTransactionBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index 448961d795..b242d20b9f 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -5,6 +5,7 @@ import { BaseUtils, TransactionRecipient, TransactionType } from '@bitgo/sdk-cor import { v4CreateForwarderMethodId, flushForwarderTokensMethodIdV4, + flushCoinsMethodId, getRawDecoded, getBufferedByteCode, } from '@bitgo/abstract-eth'; @@ -96,6 +97,8 @@ export class Utils implements BaseUtils { return TransactionType.AddressInitialization; } else if (clauses[0].data.startsWith(flushForwarderTokensMethodIdV4)) { return TransactionType.FlushTokens; + } else if (clauses[0].data.startsWith(flushCoinsMethodId)) { + return TransactionType.FlushCoins; } else if (clauses[0].data.startsWith(TRANSFER_TOKEN_METHOD_ID)) { return TransactionType.SendToken; } else if (clauses[0].data.startsWith(STAKING_METHOD_ID)) { diff --git a/modules/sdk-coin-vet/test/resources/vet.ts b/modules/sdk-coin-vet/test/resources/vet.ts index f3c0554be6..c28bdd7c9b 100644 --- a/modules/sdk-coin-vet/test/resources/vet.ts +++ b/modules/sdk-coin-vet/test/resources/vet.ts @@ -77,6 +77,14 @@ export const FLUSH_TOKEN_DATA = '0x3ef133670000000000000000000000000000000000000 export const FLUSH_TOKEN_SIGNABLE_PAYLOAD = 'f8542788014ead140e77bbc140f83df83b94043fde2fa0ece77c2461a9571f3b1b1c6faca12080a43ef133670000000000000000000000000000000000000000000000000000456e6572677981808252088082faf8c0'; +export const FLUSH_COINS_TRANSACTION = + '0xf22788014ead140e77bbc140dcdb94043fde2fa0ece77c2461a9571f3b1b1c6faca12080846b9f96ea81808252088082faf8c0'; + +export const FLUSH_COINS_DATA = '0x6b9f96ea'; + +export const FLUSH_COINS_SIGNABLE_PAYLOAD = + 'f22788014ead140e77bbc140dcdb94043fde2fa0ece77c2461a9571f3b1b1c6faca12080846b9f96ea81808252088082faf8c0'; + export const INVALID_TRANSACTION = '0xf8bc2788014ea060b5b5997e40e0df94e5f4eec44adf19c4cbeec88016345785d8a000080818082520880830bf84fc101b882418e212a40b29da685a7312829e8d1d3708b654f4ddb4388a7a80f5af3e5423b455451901d9b837fe18501e6ea5ec7d3d2711f00073d553aabe40e0260ec8a6f00da557d0a1af66b82b457324bc6fc86c7f0362e76c15b64432e66b6fa62fca38c7d208a604d1a7ec5356c95fec7bc6f332d16718a91a53e0e99e3a81ae3205ab400'; @@ -88,6 +96,14 @@ export const TRANSFER_CLAUSE = [ }, ]; +export const FLUSH_COINS_CLAUSE = [ + { + to: '0x043fde2fa0ece77c2461a9571f3b1b1c6faca120', + value: 0, + data: '0x6b9f96ea', + }, +]; + export const addresses = { validAddresses: [ '0x7ca00e3bc8a836026c2917c6c7c6d049e52099dd', diff --git a/modules/sdk-coin-vet/test/transactionBuilder/flushCoinsTransactionBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/flushCoinsTransactionBuilder.ts new file mode 100644 index 0000000000..d4ebf812b3 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/flushCoinsTransactionBuilder.ts @@ -0,0 +1,96 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilderFactory, FlushCoinsTransaction } from '../../src'; +import * as testData from '../resources/vet'; + +describe('Flush Coins Transaction', () => { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + + describe('Succeed', () => { + it('should build a flush coins transaction', async function () { + const transaction = new FlushCoinsTransaction(coins.get('tvet')); + const txBuilder = factory.getFlushCoinsTransactionBuilder(transaction); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.contract(testData.FORWARDER_ADDRESS); + const tx = (await txBuilder.build()) as FlushCoinsTransaction; + should.equal(tx.gas, 21000); + should.equal(tx.nonce, '64248'); + should.equal(tx.expiration, 64); + should.equal(tx.type, TransactionType.FlushCoins); + should.equal(tx.blockRef, '0x014ead140e77bbc1'); + should.equal(tx.clauses.length, 1); + should.equal(tx.clauses[0].to, testData.FORWARDER_ADDRESS); + should.equal(tx.clauses[0].data, testData.FLUSH_COINS_DATA); + should.equal(tx.clauses[0].value, '0x0'); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + rawTx.should.equal(testData.FLUSH_COINS_TRANSACTION); + }); + + it('should validate signable payload', async function () { + const transaction = new FlushCoinsTransaction(coins.get('tvet')); + const txBuilder = factory.getFlushCoinsTransactionBuilder(transaction); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.contract(testData.FORWARDER_ADDRESS); + const tx = (await txBuilder.build()) as FlushCoinsTransaction; + should.equal(tx.signablePayload.toString('hex'), testData.FLUSH_COINS_SIGNABLE_PAYLOAD); + }); + + it('should build and validate toJson', async function () { + const transaction = new FlushCoinsTransaction(coins.get('tvet')); + const txBuilder = factory.getFlushCoinsTransactionBuilder(transaction); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.contract(testData.FORWARDER_ADDRESS); + const tx = (await txBuilder.build()) as FlushCoinsTransaction; + + const toJson = tx.toJson(); + should.equal(toJson.nonce, '64248'); + should.equal(toJson.gas, 21000); + should.equal(toJson.gasPriceCoef, 128); + should.equal(toJson.expiration, 64); + should.equal(toJson.data, testData.FLUSH_COINS_DATA); + should.equal(toJson.to, testData.FORWARDER_ADDRESS); + should.equal(toJson.value, '0'); + }); + + it('should produce a valid raw transaction matching expected bytes', async () => { + const transaction = new FlushCoinsTransaction(coins.get('tvet')); + const txBuilder = factory.getFlushCoinsTransactionBuilder(transaction); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.contract(testData.FORWARDER_ADDRESS); + const tx = (await txBuilder.build()) as FlushCoinsTransaction; + should.equal(tx.toBroadcastFormat(), testData.FLUSH_COINS_TRANSACTION); + should.equal(txBuilder.isValidRawTransaction(testData.FLUSH_COINS_TRANSACTION), true); + }); + }); + + describe('Fail', () => { + it('should fail with invalid contract address', async function () { + const transaction = new FlushCoinsTransaction(coins.get('tvet')); + const txBuilder = factory.getFlushCoinsTransactionBuilder(transaction); + should(() => txBuilder.contract('randomString')).throwError('Invalid address randomString'); + }); + + it('should fail to validate raw tx that is not flush coins', async function () { + const txBuilder = factory.getFlushCoinsTransactionBuilder(); + should.equal(txBuilder.isValidRawTransaction(testData.FLUSH_TOKEN_TRANSACTION), false); + }); + }); +}); diff --git a/modules/sdk-coin-vet/test/unit/utils.ts b/modules/sdk-coin-vet/test/unit/utils.ts index 2465679eb0..c3cb2b71bb 100644 --- a/modules/sdk-coin-vet/test/unit/utils.ts +++ b/modules/sdk-coin-vet/test/unit/utils.ts @@ -64,6 +64,7 @@ describe('Vechain util library', function () { it('should get correct transaction type from clause', function () { should.equal(TransactionType.Send, utils.getTransactionTypeFromClause(testData.TRANSFER_CLAUSE)); + should.equal(TransactionType.FlushCoins, utils.getTransactionTypeFromClause(testData.FLUSH_COINS_CLAUSE)); }); it('is valid public key', function () {