From 86036b83f8d3c5c27dbe91e0cc915b2548b0a6ba Mon Sep 17 00:00:00 2001 From: Aditya Tiwari Date: Sun, 20 Jul 2025 20:05:03 +0530 Subject: [PATCH] feat(sdk-coin-vet): add token transaction builder for vechain Ticket: COIN-4887 --- modules/sdk-coin-vet/src/lib/constants.ts | 2 + modules/sdk-coin-vet/src/lib/index.ts | 1 + .../src/lib/transaction/tokenTransaction.ts | 104 ++++++++ .../src/lib/transaction/transaction.ts | 4 +- .../tokenTransactionBuilder.ts | 80 +++++++ .../src/lib/transactionBuilderFactory.ts | 10 + modules/sdk-coin-vet/src/lib/utils.ts | 45 +++- modules/sdk-coin-vet/test/resources/vet.ts | 21 ++ .../addressInitializationBuilder.ts | 8 +- .../flushTokenTransactionBuilder.ts | 6 +- .../tokenTransactionBuilder.ts | 222 ++++++++++++++++++ .../transactionBuilder/transferBuilder.ts | 6 +- 12 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 modules/sdk-coin-vet/src/lib/transaction/tokenTransaction.ts create mode 100644 modules/sdk-coin-vet/src/lib/transactionBuilder/tokenTransactionBuilder.ts create mode 100644 modules/sdk-coin-vet/test/transactionBuilder/tokenTransactionBuilder.ts diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index 989cae883d..4269214d12 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -1,3 +1,5 @@ export const VET_TRANSACTION_ID_LENGTH = 64; export const VET_ADDRESS_LENGTH = 40; export const VET_BLOCK_ID_LENGTH = 64; + +export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb'; diff --git a/modules/sdk-coin-vet/src/lib/index.ts b/modules/sdk-coin-vet/src/lib/index.ts index d3a258cf21..a6ff46ba88 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 { TokenTransaction } from './transaction/tokenTransaction'; export { TransactionBuilder } from './transactionBuilder/transactionBuilder'; export { TransferBuilder } from './transactionBuilder/transferBuilder'; export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; diff --git a/modules/sdk-coin-vet/src/lib/transaction/tokenTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/tokenTransaction.ts new file mode 100644 index 0000000000..12f5b8dce0 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/tokenTransaction.ts @@ -0,0 +1,104 @@ +import assert from 'assert'; +import { Secp256k1, Transaction as VetTransaction } from '@vechain/sdk-core'; + +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { Transaction } from './transaction'; +import utils from '../utils'; + +import { VetTransactionData } from '../iface'; + +export class TokenTransaction extends Transaction { + private _tokenAddress: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.Send; + } + + get tokenAddress(): string { + return this._tokenAddress; + } + + set tokenAddress(address: string) { + this._tokenAddress = address; + } + + buildClauses(): void { + if (!this.tokenAddress) { + throw new Error('Token address is not set'); + } + this.clauses = this.recipients.map((recipient) => { + const data = utils.getTransferTokenData(recipient.address, String(recipient.amount)); + return { + to: this.tokenAddress, + value: '0x0', + data, + }; + }); + } + + toJson(): VetTransactionData { + const json: VetTransactionData = { + id: this.id, + chainTag: this.chainTag, + blockRef: this.blockRef, + expiration: this.expiration, + recipients: this.recipients, + gasPriceCoef: this.gasPriceCoef, + gas: this.gas, + dependsOn: this.dependsOn, + nonce: this.nonce, + sender: this.sender, + feePayer: this.feePayerAddress, + tokenAddress: this.tokenAddress, + }; + + return json; + } + + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + // Store the raw transaction + this.rawTransaction = signedTx; + + // Set transaction body properties + const body = signedTx.body; + this.chainTag = body.chainTag; + this.blockRef = body.blockRef; + this.expiration = body.expiration; + this.clauses = body.clauses; + this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128; + this.gas = Number(body.gas); + this.dependsOn = body.dependsOn; + this.nonce = String(body.nonce); + // Set recipients from clauses + assert(body.clauses[0].to, 'token address not found in the clauses'); + this.tokenAddress = body.clauses[0].to; + this.recipients = body.clauses.map((clause) => utils.decodeTransferTokenData(clause.data)); + this.loadInputsAndOutputs(); + + // Set sender address + if (signedTx.signature && signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + + // Set signatures if present + if (signedTx.signature) { + // First signature is sender's signature + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + + // If there's additional signature data, it's the fee payer's signature + 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/transaction/transaction.ts b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts index 866052df2a..7b62062b66 100644 --- a/modules/sdk-coin-vet/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-vet/src/lib/transaction/transaction.ts @@ -363,7 +363,7 @@ export class Transaction extends BaseTransaction { const transactionBody: TransactionBody = { chainTag: this.chainTag, blockRef: this.blockRef, - expiration: 64, //move this value to constants + expiration: this.expiration, clauses: this.clauses, gasPriceCoef: this.gasPriceCoef, gas: this.gas, @@ -371,7 +371,7 @@ export class Transaction extends BaseTransaction { nonce: this.nonce, }; - if (this.type === TransactionType.Send) { + if (this.type === TransactionType.Send || this.type === TransactionType.SendToken) { transactionBody.reserved = { features: 1, // mark transaction as delegated i.e. will use gas payer }; diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/tokenTransactionBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/tokenTransactionBuilder.ts new file mode 100644 index 0000000000..d69b7adb00 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/tokenTransactionBuilder.ts @@ -0,0 +1,80 @@ +import { addHexPrefix } from 'ethereumjs-util'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionClause } from '@vechain/sdk-core'; + +import { TransactionBuilder } from './transactionBuilder'; +import { TokenTransaction } from '../transaction/tokenTransaction'; +import utils from '../utils'; + +export class TokenTransactionBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + initBuilder(tx: TokenTransaction): void { + this._transaction = tx; + } + + get tokenTransaction(): TokenTransaction { + return this._transaction as TokenTransaction; + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + /** + * Validates the transaction clauses for flush token transaction. + * @param {TransactionClause[]} clauses - The transaction clauses to validate. + * @returns {boolean} - Returns true if the clauses are valid, false otherwise. + */ + 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; + } + + // For token transactions, the value should be 0 + if (clause.value !== 0) { + return false; + } + + const { address } = utils.decodeTransferTokenData(clause.data); + const recipientAddress = addHexPrefix(address.toString()).toLowerCase(); + + if (!recipientAddress || !utils.isValidAddress(recipientAddress)) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + tokenAddress(address: string): this { + this.validateAddress({ address }); + this.tokenTransaction.tokenAddress = address; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: TokenTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + + if (!transaction.tokenAddress) { + throw new Error('Token address is required'); + } + + this.validateAddress({ address: transaction.tokenAddress }); + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts index d46cd4a7e9..03050f4876 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -9,6 +9,8 @@ import { Transaction } from './transaction/transaction'; import utils from './utils'; import { AddressInitializationTransaction } from './transaction/addressInitializationTransaction'; import { FlushTokenTransaction } from './transaction/flushTokenTransaction'; +import { TokenTransactionBuilder } from './transactionBuilder/tokenTransactionBuilder'; +import { TokenTransaction } from './transaction/tokenTransaction'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -33,6 +35,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const flushTokenTx = new FlushTokenTransaction(this._coinConfig); flushTokenTx.fromDeserializedSignedTransaction(signedTx); return this.getFlushTokenTransactionBuilder(flushTokenTx); + case TransactionType.SendToken: + const tokenTransferTx = new TokenTransaction(this._coinConfig); + tokenTransferTx.fromDeserializedSignedTransaction(signedTx); + return this.getTokenTransactionBuilder(tokenTransferTx); default: throw new InvalidTransactionError('Invalid transaction type'); } @@ -54,6 +60,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new FlushTokenTransactionBuilder(this._coinConfig)); } + getTokenTransactionBuilder(tx?: Transaction): TokenTransactionBuilder { + return this.initializeBuilder(tx, new TokenTransactionBuilder(this._coinConfig)); + } + /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index 43c7a6b5d1..7739436d8d 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -1,8 +1,20 @@ -import { BaseUtils, TransactionType } from '@bitgo/sdk-core'; -import { v4CreateForwarderMethodId, flushForwarderTokensMethodIdV4 } from '@bitgo/abstract-eth'; -import { VET_ADDRESS_LENGTH, VET_BLOCK_ID_LENGTH, VET_TRANSACTION_ID_LENGTH } from './constants'; -import { KeyPair } from './keyPair'; import { HexUInt, Transaction, TransactionClause } from '@vechain/sdk-core'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix, BN } from 'ethereumjs-util'; +import { BaseUtils, TransactionRecipient, TransactionType } from '@bitgo/sdk-core'; +import { + v4CreateForwarderMethodId, + flushForwarderTokensMethodIdV4, + getRawDecoded, + getBufferedByteCode, +} from '@bitgo/abstract-eth'; +import { + TRANSFER_TOKEN_METHOD_ID, + VET_ADDRESS_LENGTH, + VET_BLOCK_ID_LENGTH, + VET_TRANSACTION_ID_LENGTH, +} from './constants'; +import { KeyPair } from './keyPair'; export class Utils implements BaseUtils { isValidAddress(address: string): boolean { @@ -64,10 +76,35 @@ export class Utils implements BaseUtils { return TransactionType.AddressInitialization; } else if (clauses[0].data.startsWith(flushForwarderTokensMethodIdV4)) { return TransactionType.FlushTokens; + } else if (clauses[0].data.startsWith(TRANSFER_TOKEN_METHOD_ID)) { + return TransactionType.SendToken; } else { return TransactionType.SendToken; } } + + getTransferTokenData(toAddress: string, amountWei: string): string { + const methodName = 'transfer'; + const types = ['address', 'uint256']; + const params = [toAddress, new BN(amountWei)]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + + decodeTransferTokenData(data: string): TransactionRecipient { + const [address, amount] = getRawDecoded( + ['address', 'uint256'], + getBufferedByteCode(TRANSFER_TOKEN_METHOD_ID, data) + ); + const recipientAddress = addHexPrefix(address.toString()).toLowerCase(); + return { + address: recipientAddress, + amount: amount.toString(), + }; + } } const utils = new Utils(); diff --git a/modules/sdk-coin-vet/test/resources/vet.ts b/modules/sdk-coin-vet/test/resources/vet.ts index b590b1959b..1728167a92 100644 --- a/modules/sdk-coin-vet/test/resources/vet.ts +++ b/modules/sdk-coin-vet/test/resources/vet.ts @@ -6,12 +6,21 @@ export const AMOUNT = 100000000000000000; // 0.1 VET in base units export const SPONSORED_TRANSACTION = '0xf8bc2788014e9cad44bade0940e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a0000808180825208808302d6b5c101b882ee76129c1259eb0c8a2b3b3e5f2b089cd11068da1c0db32a9e22228db83dd4be5c721858bc813514141fbd5cf641d0972ce47ceb9be61133fa2ebf0ea37c1f290011fdce201f56d639d827035a5ed8bcef42a42f6eb562bc76a6d95c7736cf8cf340122d1e2fb034668dc491d47b7d3bb10724ba2338a6e79df87bce9617fdce9c00'; +export const SPONSORED_TOKEN_TRANSACTION = + '0xf8fb278801543ac0a0b3aaf940f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a0000818082c93e808307e669c101b8825dc39997717119cec9e65bfc927d1ad6ef31956e4747f95cea49f76f9e66164a737a155a6694f86287bcec043a83c7f9a3169fd3bb57ce617ab3fd4c59da437701277f6455fc041e672082bff003e75520c3167ad61edd98fba7e0ed87eb5323fc47feb3f51bfe2faa31c2174a2f7bb548e9727d7628f15137b4007a26651413fd00'; + +export const VALID_TOKEN_SIGNABLE_PAYLOAD = + 'f8762788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101'; + export const UNSIGNED_TRANSACTION = '0xf4278801536ce9e9fb063840dddc94c52584d1c56e7bddcb6f65d50ff00f71e0ef897a85e8d4a510008081808252088083061c70c0'; export const UNSIGNED_TRANSACTION_2 = '0xf72788014ead140e77bbc140e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a00008081808252088082faf8c101'; +export const UNSIGNED_TRANSACTION_3 = + '0xf8762788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101'; + export const ADDRESS_INITIALIZATION_TRANSACTION = '0xf8952788014ead140e77bbc140f87ef87c9465343e18c376d2fc8c3cf10cd146d63e2e0dc9ef80b86413b2f75c00000000000000000000000055b00b5c807d5696197b48d4affa40bb876df2400000000000000000000000007c87b9ffc6fd6c167c0e4fa9418720f3d659358e000000000000000000000000000000000000000000000000000000000000000181808252088082faf8c0'; @@ -63,12 +72,24 @@ export const senderSig = export const feePayerSig = '47ae79e23effd233206180ece3ab3d6d496310c03c55bd0566a54bb7d42095c97a90ca9ff4cfb94ec579a7ec7f79de4814dfc816d0a130189c07b746945482fb00'; +export const senderSig2 = + '9d05e90a00c538afd68652f63a4e21481d1d082fdbd4a300b04b561dc76185ad292bb48d70846bd0fb8b526782b9d2f940dbcee3bc554bfb7b53e362bbcb39d701'; + +export const feePayerSig2 = + '45d8aec4d2ed5f74b788695ceb76d18dd3597503bc0466d667493b2aff2c2b095d352788a345b2e3b49e9ad19bc28b0bdee45d0cce986962e61b0febe60a259301'; + export const senderSignedSerializedTxHex = '0xf72788014ead140e77bbc140e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a00008081808252088082faf8c101'; +export const senderSignedSerializedTxHex2 = + '0xf8762788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101'; + export const completeSignedSerializedHex = '0xf8bb2788014ead140e77bbc140e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a00008081808252088082faf8c101b882062080cc77db9d5ce5db84f1cfb57c9906c9bfdce78e4d53757bbd3d536c731e1e5e406a4e783a4f63446aa28d21dc27796aae90fac79151c3d0b3ba68cde3680147ae79e23effd233206180ece3ab3d6d496310c03c55bd0566a54bb7d42095c97a90ca9ff4cfb94ec579a7ec7f79de4814dfc816d0a130189c07b746945482fb00'; +export const completeSignedSerializedHex2 = + '0xf8fa2788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101b8829d05e90a00c538afd68652f63a4e21481d1d082fdbd4a300b04b561dc76185ad292bb48d70846bd0fb8b526782b9d2f940dbcee3bc554bfb7b53e362bbcb39d70145d8aec4d2ed5f74b788695ceb76d18dd3597503bc0466d667493b2aff2c2b095d352788a345b2e3b49e9ad19bc28b0bdee45d0cce986962e61b0febe60a259301'; + export const blockIds: { validBlockIds: string[]; invalidBlockIds: string[] } = { validBlockIds: [ '0x014f12ed94c4b4770f7f9a73e2aa41a9dfbac02a49f36ec05acfdba8c7244ff0', diff --git a/modules/sdk-coin-vet/test/transactionBuilder/addressInitializationBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/addressInitializationBuilder.ts index 6b82221732..1247a18c48 100644 --- a/modules/sdk-coin-vet/test/transactionBuilder/addressInitializationBuilder.ts +++ b/modules/sdk-coin-vet/test/transactionBuilder/addressInitializationBuilder.ts @@ -1,10 +1,8 @@ -import { TransactionBuilderFactory, Transaction } from '../../src'; -import { coins } from '@bitgo/statics'; -import * as testData from '../resources/vet'; import should from 'should'; import { TransactionType } from '@bitgo/sdk-core'; - -import { AddressInitializationTransaction } from '../../src/lib/transaction/addressInitializationTransaction'; +import { coins } from '@bitgo/statics'; +import * as testData from '../resources/vet'; +import { TransactionBuilderFactory, Transaction, AddressInitializationTransaction } from '../../src'; describe('Address Initialisation Transaction', () => { const factory = new TransactionBuilderFactory(coins.get('tvet')); diff --git a/modules/sdk-coin-vet/test/transactionBuilder/flushTokenTransactionBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/flushTokenTransactionBuilder.ts index e9f7f48753..d8dab9f05c 100644 --- a/modules/sdk-coin-vet/test/transactionBuilder/flushTokenTransactionBuilder.ts +++ b/modules/sdk-coin-vet/test/transactionBuilder/flushTokenTransactionBuilder.ts @@ -1,10 +1,8 @@ -import { TransactionBuilderFactory } from '../../src'; -import { coins } from '@bitgo/statics'; import should from 'should'; +import { coins } from '@bitgo/statics'; import { TransactionType } from '@bitgo/sdk-core'; - +import { TransactionBuilderFactory, FlushTokenTransaction } from '../../src'; import * as testData from '../resources/vet'; -import { FlushTokenTransaction } from '../../src/lib/transaction/flushTokenTransaction'; describe('Flush Token Transaction', () => { const factory = new TransactionBuilderFactory(coins.get('tvet')); diff --git a/modules/sdk-coin-vet/test/transactionBuilder/tokenTransactionBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/tokenTransactionBuilder.ts new file mode 100644 index 0000000000..4eaa49fea1 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/tokenTransactionBuilder.ts @@ -0,0 +1,222 @@ +import should from 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { Transaction, TransactionBuilderFactory, TokenTransaction } from '../../src'; +import * as testData from '../resources/vet'; + +describe('tokenTransactionBuilder', () => { + const factory = new TransactionBuilderFactory(coins.get('tvet:vtho')); + describe('Succeed', () => { + it('should build a token transfer transaction', async function () { + const transaction = new TokenTransaction(coins.get('tvet:vtho')); + const txBuilder = factory.getTokenTransactionBuilder(transaction); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.tokenAddress(testData.TOKEN_ADDRESS); + txBuilder.recipients(testData.recipients); + txBuilder.addFeePayerAddress(testData.feePayer.address); + const tx = (await txBuilder.build()) as TokenTransaction; + should.equal(tx.sender, testData.addresses.validAddresses[0]); + should.equal(tx.recipients[0].address, testData.recipients[0].address); + should.equal(tx.recipients[0].amount, testData.recipients[0].amount); + should.equal(tx.gas, 21000); + should.equal(tx.getFee(), '315411764705882352'); + should.equal(tx.nonce, '64248'); + should.equal(tx.expiration, 64); + should.equal(tx.type, TransactionType.Send); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.addresses.validAddresses[0], + value: testData.recipients[0].amount, + coin: 'tvet:vtho', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.recipients[0].address, + value: testData.recipients[0].amount, + coin: 'tvet:vtho', + }); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + rawTx.should.equal(testData.UNSIGNED_TRANSACTION_3); + }); + + it('should build and send a signed tx', async function () { + const txBuilder = factory.from(testData.SPONSORED_TOKEN_TRANSACTION); + txBuilder.getNonce().should.equal('517737'); + + const tx = (await txBuilder.build()) as TokenTransaction; + should.equal(tx.type, TransactionType.Send); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.addresses.validAddresses[2], + value: testData.AMOUNT.toString(), + coin: 'tvet:vtho', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.recipients[0].address, + value: testData.AMOUNT.toString(), + coin: 'tvet:vtho', + }); + should.equal(tx.id, '0x16a0ebdfe43a8a62e7d62e65603c9563c8342fff6d8150c71bff9ec37634f50e'); + should.equal(tx.gas, 51518); + should.equal(tx.getFee(), '773780156862745098'); + should.equal(tx.nonce, '517737'); + should.equal(tx.expiration, 64); + should.equal(tx.type, TransactionType.Send); + const rawTx = tx.toBroadcastFormat(); + should.equal(txBuilder.isValidRawTransaction(rawTx), true); + should.equal(rawTx, testData.SPONSORED_TOKEN_TRANSACTION); + }); + + it('should validate a valid signablePayload', async function () { + const transaction = new TokenTransaction(coins.get('tvet:vtho')); + const txBuilder = factory.getTokenTransactionBuilder(transaction); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.recipients(testData.recipients); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.expiration(64); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.gasPriceCoef(128); + txBuilder.addFeePayerAddress(testData.feePayer.address); + txBuilder.tokenAddress(testData.TOKEN_ADDRESS); + const tx = (await txBuilder.build()) as Transaction; + const signablePayload = tx.signablePayload; + should.equal(signablePayload.toString('hex'), testData.VALID_TOKEN_SIGNABLE_PAYLOAD); + }); + + it('should build a unsigned tx and validate its toJson', async function () { + const transaction = new TokenTransaction(coins.get('tvet:vtho')); + const txBuilder = factory.getTokenTransactionBuilder(transaction); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.recipients(testData.recipients); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.expiration(64); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.gasPriceCoef(128); + txBuilder.addFeePayerAddress(testData.feePayer.address); + txBuilder.tokenAddress(testData.TOKEN_ADDRESS); + const tx = (await txBuilder.build()) as TokenTransaction; + const toJson = tx.toJson(); + should.equal(toJson.sender, testData.addresses.validAddresses[0]); + should.deepEqual(toJson.recipients, [ + { + address: testData.recipients[0].address, + amount: testData.recipients[0].amount, + }, + ]); + should.equal(toJson.nonce, '64248'); + should.equal(toJson.gas, 21000); + should.equal(toJson.gasPriceCoef, 128); + should.equal(toJson.expiration, 64); + should.equal(toJson.feePayer, testData.feePayer.address); + should.equal(toJson.tokenAddress, testData.TOKEN_ADDRESS); + }); + + it('should build a signed tx and validate its toJson', async function () { + const txBuilder = factory.from(testData.SPONSORED_TOKEN_TRANSACTION); + const tx = (await txBuilder.build()) as TokenTransaction; + const toJson = tx.toJson(); + should.equal(toJson.id, '0x16a0ebdfe43a8a62e7d62e65603c9563c8342fff6d8150c71bff9ec37634f50e'); + should.equal(toJson.sender, testData.addresses.validAddresses[2]); + should.deepEqual(toJson.recipients, [ + { + address: testData.addresses.validAddresses[1], + amount: testData.AMOUNT.toString(), + }, + ]); + should.equal(toJson.nonce, '517737'); + should.equal(toJson.gas, 51518); + should.equal(toJson.gasPriceCoef, 128); + should.equal(toJson.expiration, 64); + should.equal(toJson.tokenAddress, testData.TOKEN_ADDRESS); + }); + + it('should build a unsigned tx then add sender sig and build again', async function () { + const transaction = new TokenTransaction(coins.get('tvet:vtho')); + const txBuilder = factory.getTokenTransactionBuilder(transaction); + txBuilder.sender(testData.addresses.validAddresses[2]); + txBuilder.recipients(testData.recipients); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.addFeePayerAddress(testData.feePayer.address); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.tokenAddress(testData.TOKEN_ADDRESS); + const tx = (await txBuilder.build()) as Transaction; + const unsignedSerializedTx = tx.toBroadcastFormat(); + const builder1 = factory.from(unsignedSerializedTx); + builder1.addSenderSignature(Buffer.from(testData.senderSig2, 'hex')); + const senderSignedTx = await builder1.build(); + const senderSignedSerializedTx = senderSignedTx.toBroadcastFormat(); + should.equal(senderSignedSerializedTx, testData.senderSignedSerializedTxHex2); + + const builder2 = factory.from(testData.senderSignedSerializedTxHex2); + builder2.addSenderSignature(Buffer.from(testData.senderSig2, 'hex')); + builder2.addFeePayerSignature(Buffer.from(testData.feePayerSig2, 'hex')); + const completelySignedTx = await builder2.build(); + should.equal(completelySignedTx.toBroadcastFormat(), testData.completeSignedSerializedHex2); + should.equal(completelySignedTx.id, '0x065dfe80e3113f8c4638f17b7f255152fc52c29b42ed617cdff72fd41289152b'); + }); + }); + + describe('Fail', () => { + it('should fail for invalid sender', async function () { + const transaction = new Transaction(coins.get('tvet:vtho')); + const builder = factory.getTokenTransactionBuilder(transaction); + should(() => builder.sender('randomString')).throwError('Invalid address randomString'); + }); + + it('should fail for invalid recipient', async function () { + const builder = factory.getTokenTransactionBuilder(); + should(() => builder.recipients([testData.invalidRecipients[0]])).throwError('Invalid address randomString'); + should(() => builder.recipients([testData.invalidRecipients[1]])).throwError('Value cannot be less than zero'); + should(() => builder.recipients([testData.invalidRecipients[2]])).throwError('Invalid amount format'); + }); + + it('should fail for invalid gas amount', async function () { + const builder = factory.getTokenTransactionBuilder(); + should(() => builder.gas(-1)).throwError('Value cannot be less than zero'); + }); + + it('should fail to build if token address if not set', async function () { + const transaction = new TokenTransaction(coins.get('tvet:vtho')); + const txBuilder = factory.getTokenTransactionBuilder(transaction); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.recipients(testData.recipients); + txBuilder.addFeePayerAddress(testData.feePayer.address); + try { + await txBuilder.build(); + } catch (err) { + should.equal(err.message, 'Token address is required'); + } + }); + + it('should fail on setting invalid token address', async function () { + const transaction = new TokenTransaction(coins.get('tvet:vtho')); + const txBuilder = factory.getTokenTransactionBuilder(transaction); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + txBuilder.recipients(testData.recipients); + txBuilder.addFeePayerAddress(testData.feePayer.address); + should(() => txBuilder.tokenAddress('InvalidTokenAddress')).throwError('Invalid address InvalidTokenAddress'); + }); + }); +}); diff --git a/modules/sdk-coin-vet/test/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/transferBuilder.ts index 7930104763..c0c759a556 100644 --- a/modules/sdk-coin-vet/test/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-vet/test/transactionBuilder/transferBuilder.ts @@ -1,8 +1,8 @@ -import { TransactionBuilderFactory, Transaction } from '../../src'; -import { coins } from '@bitgo/statics'; -import * as testData from '../resources/vet'; import should from 'should'; import { TransactionType } from '@bitgo/sdk-core'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory, Transaction } from '../../src'; +import * as testData from '../resources/vet'; describe('Vet Transfer Transaction', () => { const factory = new TransactionBuilderFactory(coins.get('tvet'));