Skip to content

feat(sdk-coin-vet): add token transaction builder for vechain #6487

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/sdk-coin-vet/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions modules/sdk-coin-vet/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
104 changes: 104 additions & 0 deletions modules/sdk-coin-vet/src/lib/transaction/tokenTransaction.ts
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be imported from abstract-eth if possible as the code for data generation is same

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}`);
}
}
}
4 changes: 2 additions & 2 deletions modules/sdk-coin-vet/src/lib/transaction/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,15 +363,15 @@ 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,
dependsOn: null,
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
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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 });
}
}
10 changes: 10 additions & 0 deletions modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinConfig>) {
Expand All @@ -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');
}
Expand All @@ -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.');
Expand Down
45 changes: 41 additions & 4 deletions modules/sdk-coin-vet/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions modules/sdk-coin-vet/test/resources/vet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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'));
Expand Down
Original file line number Diff line number Diff line change
@@ -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'));
Expand Down
Loading