Skip to content

Commit ba09c1b

Browse files
committed
feat(sdk-coin-vet): add token transaction builder for vechain
Ticket: COIN-4887
1 parent 5c473ab commit ba09c1b

File tree

9 files changed

+482
-4
lines changed

9 files changed

+482
-4
lines changed

modules/sdk-coin-vet/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@
5050
"ethereumjs-abi": "^0.6.5",
5151
"ethereumjs-util": "7.1.5",
5252
"lodash": "^4.17.21",
53-
"tweetnacl": "^1.0.3"
53+
"tweetnacl": "^1.0.3",
54+
"bn.js": "^5.2.1"
5455
},
5556
"devDependencies": {
5657
"@bitgo/sdk-api": "^1.65.0",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export const VET_TRANSACTION_ID_LENGTH = 64;
22
export const VET_ADDRESS_LENGTH = 40;
33
export const VET_BLOCK_ID_LENGTH = 64;
4+
5+
export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb';
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Secp256k1, Transaction as VetTransaction } from '@vechain/sdk-core';
2+
3+
import { Transaction } from './transaction';
4+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
5+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
6+
import utils from '../utils';
7+
import assert from 'assert';
8+
import { VetTransactionData } from '../iface';
9+
10+
export class TokenTransaction extends Transaction {
11+
private _tokenAddress: string;
12+
13+
constructor(_coinConfig: Readonly<CoinConfig>) {
14+
super(_coinConfig);
15+
this._type = TransactionType.SendToken;
16+
}
17+
18+
get tokenAddress(): string {
19+
return this._tokenAddress;
20+
}
21+
22+
set tokenAddress(address: string) {
23+
this._tokenAddress = address;
24+
}
25+
26+
buildClauses(): void {
27+
if (!this.tokenAddress) {
28+
throw new Error('Token address is not set');
29+
}
30+
this.clauses = this.recipients.map((recipient) => {
31+
const data = utils.getTransferTokenData(recipient.address, String(recipient.amount));
32+
return {
33+
to: this.tokenAddress,
34+
value: '0x0',
35+
data,
36+
};
37+
});
38+
}
39+
40+
toJson(): VetTransactionData {
41+
const json: VetTransactionData = {
42+
id: this.id,
43+
chainTag: this.chainTag,
44+
blockRef: this.blockRef,
45+
expiration: this.expiration,
46+
recipients: this.recipients,
47+
gasPriceCoef: this.gasPriceCoef,
48+
gas: this.gas,
49+
dependsOn: this.dependsOn,
50+
nonce: this.nonce,
51+
sender: this.sender,
52+
feePayer: this.feePayerAddress,
53+
tokenAddress: this.tokenAddress,
54+
};
55+
56+
return json;
57+
}
58+
59+
fromDeserializedSignedTransaction(signedTx: VetTransaction): void {
60+
try {
61+
if (!signedTx || !signedTx.body) {
62+
throw new InvalidTransactionError('Invalid transaction: missing transaction body');
63+
}
64+
65+
// Store the raw transaction
66+
this.rawTransaction = signedTx;
67+
68+
// Set transaction body properties
69+
const body = signedTx.body;
70+
this.chainTag = body.chainTag;
71+
this.blockRef = body.blockRef;
72+
this.expiration = body.expiration;
73+
this.clauses = body.clauses;
74+
this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128;
75+
this.gas = Number(body.gas);
76+
this.dependsOn = body.dependsOn;
77+
this.nonce = String(body.nonce);
78+
// Set recipients from clauses
79+
assert(body.clauses[0].to, 'token address not found in the clauses');
80+
this.tokenAddress = body.clauses[0].to;
81+
this.recipients = body.clauses.map((clause) => utils.decodeTransferTokenData(clause.data));
82+
this.loadInputsAndOutputs();
83+
84+
// Set sender address
85+
if (signedTx.signature && signedTx.origin) {
86+
this.sender = signedTx.origin.toString().toLowerCase();
87+
}
88+
89+
// Set signatures if present
90+
if (signedTx.signature) {
91+
// First signature is sender's signature
92+
this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH));
93+
94+
// If there's additional signature data, it's the fee payer's signature
95+
if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) {
96+
this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH));
97+
}
98+
}
99+
} catch (e) {
100+
throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`);
101+
}
102+
}
103+
}

modules/sdk-coin-vet/src/lib/transaction/transaction.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,15 +363,15 @@ export class Transaction extends BaseTransaction {
363363
const transactionBody: TransactionBody = {
364364
chainTag: this.chainTag,
365365
blockRef: this.blockRef,
366-
expiration: 64, //move this value to constants
366+
expiration: this.expiration,
367367
clauses: this.clauses,
368368
gasPriceCoef: this.gasPriceCoef,
369369
gas: this.gas,
370370
dependsOn: null,
371371
nonce: this.nonce,
372372
};
373373

374-
if (this.type === TransactionType.Send) {
374+
if (this.type === TransactionType.Send || this.type === TransactionType.SendToken) {
375375
transactionBody.reserved = {
376376
features: 1, // mark transaction as delegated i.e. will use gas payer
377377
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { TransactionBuilder } from './transactionBuilder';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { TokenTransaction } from '../transaction/tokenTransaction';
4+
import { TransactionType } from '@bitgo/sdk-core';
5+
import { TransactionClause } from '@vechain/sdk-core';
6+
import utils from '../utils';
7+
8+
export class TokenTransactionBuilder extends TransactionBuilder {
9+
constructor(_coinConfig: Readonly<CoinConfig>) {
10+
super(_coinConfig);
11+
}
12+
13+
initBuilder(tx: TokenTransaction): void {
14+
this._transaction = tx;
15+
}
16+
17+
get tokenTransaction(): TokenTransaction {
18+
return this._transaction as TokenTransaction;
19+
}
20+
21+
protected get transactionType(): TransactionType {
22+
return TransactionType.SendToken;
23+
}
24+
25+
/**
26+
* Validates the transaction clauses for flush token transaction.
27+
* @param {TransactionClause[]} clauses - The transaction clauses to validate.
28+
* @returns {boolean} - Returns true if the clauses are valid, false otherwise.
29+
*/
30+
protected isValidTransactionClauses(clauses: TransactionClause[]): boolean {
31+
try {
32+
if (!clauses || !Array.isArray(clauses) || clauses.length === 0) {
33+
return false;
34+
}
35+
36+
const clause = clauses[0];
37+
38+
if (!clause.to || !utils.isValidAddress(clause.to)) {
39+
return false;
40+
}
41+
42+
// For token transactions, the value should be 0
43+
if (clause.value !== 0) {
44+
return false;
45+
}
46+
47+
const { address } = utils.decodeTransferTokenData(clause.data);
48+
49+
if (!address || !utils.isValidAddress(address)) {
50+
return false;
51+
}
52+
53+
return true;
54+
} catch (e) {
55+
return false;
56+
}
57+
}
58+
59+
tokenAddress(address: string): this {
60+
this.validateAddress({ address });
61+
this.tokenTransaction.tokenAddress = address;
62+
return this;
63+
}
64+
65+
/** @inheritdoc */
66+
validateTransaction(transaction?: TokenTransaction): void {
67+
if (!transaction) {
68+
throw new Error('transaction not defined');
69+
}
70+
71+
if (!transaction.tokenAddress) {
72+
throw new Error('Token address is required');
73+
}
74+
75+
this.validateAddress({ address: transaction.tokenAddress });
76+
}
77+
}

modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { Transaction } from './transaction/transaction';
99
import utils from './utils';
1010
import { AddressInitializationTransaction } from './transaction/addressInitializationTransaction';
1111
import { FlushTokenTransaction } from './transaction/flushTokenTransaction';
12+
import { TokenTransactionBuilder } from './transactionBuilder/tokenTransactionBuilder';
13+
import { TokenTransaction } from './transaction/tokenTransaction';
1214

1315
export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
1416
constructor(_coinConfig: Readonly<CoinConfig>) {
@@ -33,6 +35,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
3335
const flushTokenTx = new FlushTokenTransaction(this._coinConfig);
3436
flushTokenTx.fromDeserializedSignedTransaction(signedTx);
3537
return this.getFlushTokenTransactionBuilder(flushTokenTx);
38+
case TransactionType.SendToken:
39+
const tokenTransferTx = new TokenTransaction(this._coinConfig);
40+
tokenTransferTx.fromDeserializedSignedTransaction(signedTx);
41+
return this.getTokenTransactionBuilder(tokenTransferTx);
3642
default:
3743
throw new InvalidTransactionError('Invalid transaction type');
3844
}
@@ -54,6 +60,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
5460
return this.initializeBuilder(tx, new FlushTokenTransactionBuilder(this._coinConfig));
5561
}
5662

63+
getTokenTransactionBuilder(tx?: Transaction): TokenTransactionBuilder {
64+
return this.initializeBuilder(tx, new TokenTransactionBuilder(this._coinConfig));
65+
}
66+
5767
/** @inheritdoc */
5868
getWalletInitializationBuilder(): void {
5969
throw new Error('Method not implemented.');

modules/sdk-coin-vet/src/lib/utils.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import EthereumAbi from 'ethereumjs-abi';
2+
import { addHexPrefix, toBuffer, toChecksumAddress } from 'ethereumjs-util';
3+
import { BN } from 'bn.js';
14
import { BaseUtils, TransactionType } from '@bitgo/sdk-core';
25
import { v4CreateForwarderMethodId, flushForwarderTokensMethodIdV4 } from '@bitgo/abstract-eth';
3-
import { VET_ADDRESS_LENGTH, VET_BLOCK_ID_LENGTH, VET_TRANSACTION_ID_LENGTH } from './constants';
6+
import {
7+
TRANSFER_TOKEN_METHOD_ID,
8+
VET_ADDRESS_LENGTH,
9+
VET_BLOCK_ID_LENGTH,
10+
VET_TRANSACTION_ID_LENGTH,
11+
} from './constants';
412
import { KeyPair } from './keyPair';
513
import { HexUInt, Transaction, TransactionClause } from '@vechain/sdk-core';
614

@@ -64,10 +72,41 @@ export class Utils implements BaseUtils {
6472
return TransactionType.AddressInitialization;
6573
} else if (clauses[0].data.startsWith(flushForwarderTokensMethodIdV4)) {
6674
return TransactionType.FlushTokens;
75+
} else if (clauses[0].data.startsWith(TRANSFER_TOKEN_METHOD_ID)) {
76+
return TransactionType.SendToken;
6777
} else {
6878
return TransactionType.SendToken;
6979
}
7080
}
81+
82+
getTransferTokenData(toAddress: string, amountWei: string): string {
83+
const methodName = 'transfer';
84+
const types = ['address', 'uint256'];
85+
const params = [toAddress, new BN(amountWei)];
86+
87+
const method = EthereumAbi.methodID(methodName, types);
88+
const args = EthereumAbi.rawEncode(types, params);
89+
90+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
91+
}
92+
93+
decodeTransferTokenData(data: string): { address: string; amount: string } {
94+
if (!data.startsWith(TRANSFER_TOKEN_METHOD_ID)) {
95+
throw new Error('Not a transfer(address,uint256) call');
96+
}
97+
98+
const rawData = toBuffer(data);
99+
100+
// Skip 4-byte method ID to get raw ABI-encoded args (64 bytes)
101+
const paramsBuf = rawData.slice(4);
102+
103+
const [recipientRaw, amountRaw] = EthereumAbi.rawDecode(['address', 'uint256'], paramsBuf);
104+
105+
return {
106+
address: toChecksumAddress('0x' + recipientRaw).toLowerCase(),
107+
amount: amountRaw.toString(),
108+
};
109+
}
71110
}
72111

73112
const utils = new Utils();

modules/sdk-coin-vet/test/resources/vet.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ export const AMOUNT = 100000000000000000; // 0.1 VET in base units
66
export const SPONSORED_TRANSACTION =
77
'0xf8bc2788014e9cad44bade0940e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a0000808180825208808302d6b5c101b882ee76129c1259eb0c8a2b3b3e5f2b089cd11068da1c0db32a9e22228db83dd4be5c721858bc813514141fbd5cf641d0972ce47ceb9be61133fa2ebf0ea37c1f290011fdce201f56d639d827035a5ed8bcef42a42f6eb562bc76a6d95c7736cf8cf340122d1e2fb034668dc491d47b7d3bb10724ba2338a6e79df87bce9617fdce9c00';
88

9+
export const SPONSORED_TOKEN_TRANSACTION =
10+
'0xf8fb278801543ac0a0b3aaf940f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a0000818082c93e808307e669c101b8825dc39997717119cec9e65bfc927d1ad6ef31956e4747f95cea49f76f9e66164a737a155a6694f86287bcec043a83c7f9a3169fd3bb57ce617ab3fd4c59da437701277f6455fc041e672082bff003e75520c3167ad61edd98fba7e0ed87eb5323fc47feb3f51bfe2faa31c2174a2f7bb548e9727d7628f15137b4007a26651413fd00';
11+
12+
export const VALID_TOKEN_SIGNABLE_PAYLOAD =
13+
'f8762788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101';
14+
915
export const UNSIGNED_TRANSACTION =
1016
'0xf4278801536ce9e9fb063840dddc94c52584d1c56e7bddcb6f65d50ff00f71e0ef897a85e8d4a510008081808252088083061c70c0';
1117

1218
export const UNSIGNED_TRANSACTION_2 =
1319
'0xf72788014ead140e77bbc140e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a00008081808252088082faf8c101';
1420

21+
export const UNSIGNED_TRANSACTION_3 =
22+
'0xf8762788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101';
23+
1524
export const ADDRESS_INITIALIZATION_TRANSACTION =
1625
'0xf8952788014ead140e77bbc140f87ef87c9465343e18c376d2fc8c3cf10cd146d63e2e0dc9ef80b86413b2f75c00000000000000000000000055b00b5c807d5696197b48d4affa40bb876df2400000000000000000000000007c87b9ffc6fd6c167c0e4fa9418720f3d659358e000000000000000000000000000000000000000000000000000000000000000181808252088082faf8c0';
1726

@@ -63,12 +72,24 @@ export const senderSig =
6372
export const feePayerSig =
6473
'47ae79e23effd233206180ece3ab3d6d496310c03c55bd0566a54bb7d42095c97a90ca9ff4cfb94ec579a7ec7f79de4814dfc816d0a130189c07b746945482fb00';
6574

75+
export const senderSig2 =
76+
'9d05e90a00c538afd68652f63a4e21481d1d082fdbd4a300b04b561dc76185ad292bb48d70846bd0fb8b526782b9d2f940dbcee3bc554bfb7b53e362bbcb39d701';
77+
78+
export const feePayerSig2 =
79+
'45d8aec4d2ed5f74b788695ceb76d18dd3597503bc0466d667493b2aff2c2b095d352788a345b2e3b49e9ad19bc28b0bdee45d0cce986962e61b0febe60a259301';
80+
6681
export const senderSignedSerializedTxHex =
6782
'0xf72788014ead140e77bbc140e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a00008081808252088082faf8c101';
6883

84+
export const senderSignedSerializedTxHex2 =
85+
'0xf8762788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101';
86+
6987
export const completeSignedSerializedHex =
7088
'0xf8bb2788014ead140e77bbc140e0df94e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec88016345785d8a00008081808252088082faf8c101b882062080cc77db9d5ce5db84f1cfb57c9906c9bfdce78e4d53757bbd3d536c731e1e5e406a4e783a4f63446aa28d21dc27796aae90fac79151c3d0b3ba68cde3680147ae79e23effd233206180ece3ab3d6d496310c03c55bd0566a54bb7d42095c97a90ca9ff4cfb94ec579a7ec7f79de4814dfc816d0a130189c07b746945482fb00';
7189

90+
export const completeSignedSerializedHex2 =
91+
'0xf8fa2788014ead140e77bbc140f85ef85c940000000000000000000000000000456e6572677980b844a9059cbb000000000000000000000000e59f1cea4e0fef511e3d0f4eec44adf19c4cbeec000000000000000000000000000000000000000000000000016345785d8a000081808252088082faf8c101b8829d05e90a00c538afd68652f63a4e21481d1d082fdbd4a300b04b561dc76185ad292bb48d70846bd0fb8b526782b9d2f940dbcee3bc554bfb7b53e362bbcb39d70145d8aec4d2ed5f74b788695ceb76d18dd3597503bc0466d667493b2aff2c2b095d352788a345b2e3b49e9ad19bc28b0bdee45d0cce986962e61b0febe60a259301';
92+
7293
export const blockIds: { validBlockIds: string[]; invalidBlockIds: string[] } = {
7394
validBlockIds: [
7495
'0x014f12ed94c4b4770f7f9a73e2aa41a9dfbac02a49f36ec05acfdba8c7244ff0',
@@ -119,6 +140,8 @@ export const FEE_ADDRESS = '0x7c87b9ffc6fd6c167c0e4fa9418720f3d659358e';
119140

120141
export const TOKEN_ADDRESS = '0x0000000000000000000000000000456e65726779';
121142

143+
export const VTHO_ADDRESS = '0x0000000000000000000000000000456e65726779';
144+
122145
export const SALT = '0x1';
123146

124147
export const SERIALIZED_SIGNED_ADDRESS_INIT_TX =

0 commit comments

Comments
 (0)