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 () {