Skip to content

Commit b9fc852

Browse files
Merge pull request #7357 from BitGo/SC-3642
feat(sdk-coin-vet): add support for stake txn builder
2 parents e4cc451 + f170b75 commit b9fc852

File tree

8 files changed

+630
-0
lines changed

8 files changed

+630
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const VET_BLOCK_ID_LENGTH = 64;
44

55
export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb';
66
export const STAKING_METHOD_ID = '0xd8da3bbf';
7+
export const STAKE_CLAUSE_METHOD_ID = '0x604f2177';
78
export const EXIT_DELEGATION_METHOD_ID = '0x69e79b7d';
89
export const BURN_NFT_METHOD_ID = '0x2e17de78';
910
export const TRANSFER_NFT_METHOD_ID = '0x23b872dd';

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { AddressInitializationTransaction } from './transaction/addressInitializ
88
export { FlushTokenTransaction } from './transaction/flushTokenTransaction';
99
export { TokenTransaction } from './transaction/tokenTransaction';
1010
export { StakingTransaction } from './transaction/stakingTransaction';
11+
export { StakeClauseTransaction } from './transaction/stakeClauseTransaction';
1112
export { ExitDelegationTransaction } from './transaction/exitDelegation';
1213
export { BurnNftTransaction } from './transaction/burnNftTransaction';
1314
export { ClaimRewardsTransaction } from './transaction/claimRewards';
@@ -17,6 +18,7 @@ export { TransferBuilder } from './transactionBuilder/transferBuilder';
1718
export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder';
1819
export { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder';
1920
export { StakingBuilder } from './transactionBuilder/stakingBuilder';
21+
export { StakeClauseTxnBuilder } from './transactionBuilder/stakeClauseTxnBuilder';
2022
export { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder';
2123
export { BurnNftBuilder } from './transactionBuilder/burnNftBuilder';
2224
export { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder';
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { Transaction as VetTransaction, Secp256k1 } from '@vechain/sdk-core';
4+
import { Transaction } from './transaction';
5+
import { VetTransactionData } from '../iface';
6+
import EthereumAbi from 'ethereumjs-abi';
7+
import utils from '../utils';
8+
import BigNumber from 'bignumber.js';
9+
import { addHexPrefix } from 'ethereumjs-util';
10+
11+
export class StakeClauseTransaction extends Transaction {
12+
private _stakingContractAddress: string;
13+
private _levelId: number;
14+
private _amountToStake: string;
15+
16+
constructor(_coinConfig: Readonly<CoinConfig>) {
17+
super(_coinConfig);
18+
this._type = TransactionType.StakingActivate;
19+
}
20+
21+
get stakingContractAddress(): string {
22+
return this._stakingContractAddress;
23+
}
24+
25+
set stakingContractAddress(address: string) {
26+
this._stakingContractAddress = address;
27+
}
28+
29+
get levelId(): number {
30+
return this._levelId;
31+
}
32+
33+
set levelId(levelId: number) {
34+
this._levelId = levelId;
35+
}
36+
37+
get amountToStake(): string {
38+
return this._amountToStake;
39+
}
40+
41+
set amountToStake(amount: string) {
42+
this._amountToStake = amount;
43+
}
44+
45+
buildClauses(): void {
46+
if (!this.stakingContractAddress) {
47+
throw new Error('Staking contract address is not set');
48+
}
49+
50+
utils.validateStakingContractAddress(this.stakingContractAddress, this._coinConfig);
51+
52+
if (this.levelId === undefined || this.levelId === null) {
53+
throw new Error('Level ID is not set');
54+
}
55+
56+
if (!this.amountToStake) {
57+
throw new Error('Amount to stake is not set');
58+
}
59+
60+
const data = this.getStakingData(this.levelId);
61+
this._transactionData = data;
62+
63+
// Create the clause for staking
64+
this._clauses = [
65+
{
66+
to: this.stakingContractAddress,
67+
value: this.amountToStake,
68+
data: this._transactionData,
69+
},
70+
];
71+
72+
// Set recipients based on the clauses
73+
this._recipients = [
74+
{
75+
address: this.stakingContractAddress,
76+
amount: this.amountToStake,
77+
},
78+
];
79+
}
80+
/**
81+
* Encodes staking transaction data using ethereumjs-abi for stake method
82+
*
83+
* @param {number} levelId - The level ID for staking
84+
* @returns {string} - The encoded transaction data
85+
*/
86+
getStakingData(levelId: number): string {
87+
const methodName = 'stake';
88+
const types = ['uint8'];
89+
const params = [levelId];
90+
91+
const method = EthereumAbi.methodID(methodName, types);
92+
const args = EthereumAbi.rawEncode(types, params);
93+
94+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
95+
}
96+
97+
toJson(): VetTransactionData {
98+
const json: VetTransactionData = {
99+
id: this.id,
100+
chainTag: this.chainTag,
101+
blockRef: this.blockRef,
102+
expiration: this.expiration,
103+
gasPriceCoef: this.gasPriceCoef,
104+
gas: this.gas,
105+
dependsOn: this.dependsOn,
106+
nonce: this.nonce,
107+
data: this.transactionData,
108+
value: this.amountToStake,
109+
sender: this.sender,
110+
to: this.stakingContractAddress,
111+
stakingContractAddress: this.stakingContractAddress,
112+
amountToStake: this.amountToStake,
113+
nftTokenId: this.levelId,
114+
};
115+
116+
return json;
117+
}
118+
119+
fromDeserializedSignedTransaction(signedTx: VetTransaction): void {
120+
try {
121+
if (!signedTx || !signedTx.body) {
122+
throw new InvalidTransactionError('Invalid transaction: missing transaction body');
123+
}
124+
125+
// Store the raw transaction
126+
this.rawTransaction = signedTx;
127+
128+
// Set transaction body properties
129+
const body = signedTx.body;
130+
this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0;
131+
this.blockRef = body.blockRef || '0x0';
132+
this.expiration = typeof body.expiration === 'number' ? body.expiration : 64;
133+
this.clauses = body.clauses || [];
134+
this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128;
135+
this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0;
136+
this.dependsOn = body.dependsOn || null;
137+
this.nonce = String(body.nonce);
138+
139+
// Set staking-specific properties
140+
if (body.clauses.length > 0) {
141+
const clause = body.clauses[0];
142+
if (clause.to) {
143+
this.stakingContractAddress = clause.to;
144+
}
145+
if (clause.value) {
146+
this.amountToStake = String(clause.value);
147+
}
148+
if (clause.data) {
149+
this.transactionData = clause.data;
150+
const decoded = utils.decodeStakeClauseData(clause.data);
151+
this.levelId = decoded.levelId;
152+
}
153+
}
154+
155+
// Set recipients from clauses
156+
this.recipients = body.clauses.map((clause) => ({
157+
address: (clause.to || '0x0').toString().toLowerCase(),
158+
amount: new BigNumber(clause.value || 0).toString(),
159+
}));
160+
this.loadInputsAndOutputs();
161+
162+
// Set sender address
163+
if (signedTx.signature && signedTx.origin) {
164+
this.sender = signedTx.origin.toString().toLowerCase();
165+
}
166+
167+
// Set signatures if present
168+
if (signedTx.signature) {
169+
// First signature is sender's signature
170+
this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH));
171+
172+
// If there's additional signature data, it's the fee payer's signature
173+
if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) {
174+
this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH));
175+
}
176+
}
177+
} catch (e) {
178+
throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`);
179+
}
180+
}
181+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ export class Transaction extends BaseTransaction {
389389
this.type === TransactionType.SendToken ||
390390
this.type === TransactionType.SendNFT ||
391391
this.type === TransactionType.ContractCall ||
392+
this.type === TransactionType.StakingActivate ||
392393
this.type === TransactionType.StakingUnlock ||
393394
this.type === TransactionType.StakingWithdraw ||
394395
this.type === TransactionType.StakingClaim
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import assert from 'assert';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { TransactionType } from '@bitgo/sdk-core';
4+
import { TransactionClause } from '@vechain/sdk-core';
5+
6+
import { TransactionBuilder } from './transactionBuilder';
7+
import { Transaction } from '../transaction/transaction';
8+
import { StakeClauseTransaction } from '../transaction/stakeClauseTransaction';
9+
import utils from '../utils';
10+
11+
export class StakeClauseTxnBuilder extends TransactionBuilder {
12+
/**
13+
* Creates a new StakingBuilder instance.
14+
*
15+
* @param {Readonly<CoinConfig>} _coinConfig - The coin configuration object
16+
*/
17+
constructor(_coinConfig: Readonly<CoinConfig>) {
18+
super(_coinConfig);
19+
this._transaction = new StakeClauseTransaction(_coinConfig);
20+
}
21+
22+
/**
23+
* Initializes the builder with an existing StakingTransaction.
24+
*
25+
* @param {StakingTransaction} tx - The transaction to initialize the builder with
26+
*/
27+
initBuilder(tx: StakeClauseTransaction): void {
28+
this._transaction = tx;
29+
}
30+
31+
/**
32+
* Gets the staking transaction instance.
33+
*
34+
* @returns {StakingTransaction} The staking transaction
35+
*/
36+
get stakingTransaction(): StakeClauseTransaction {
37+
return this._transaction as StakeClauseTransaction;
38+
}
39+
40+
/**
41+
* Gets the transaction type for staking.
42+
*
43+
* @returns {TransactionType} The transaction type
44+
*/
45+
protected get transactionType(): TransactionType {
46+
return TransactionType.StakingActivate;
47+
}
48+
49+
/**
50+
* Validates the transaction clauses for staking transaction.
51+
* @param {TransactionClause[]} clauses - The transaction clauses to validate.
52+
* @returns {boolean} - Returns true if the clauses are valid, false otherwise.
53+
*/
54+
protected isValidTransactionClauses(clauses: TransactionClause[]): boolean {
55+
try {
56+
if (!clauses || !Array.isArray(clauses) || clauses.length === 0) {
57+
return false;
58+
}
59+
60+
const clause = clauses[0];
61+
62+
if (!clause.to || !utils.isValidAddress(clause.to)) {
63+
return false;
64+
}
65+
66+
// For staking transactions, value must be greater than 0
67+
if (!clause.value || clause.value === '0x0' || clause.value === '0') {
68+
return false;
69+
}
70+
71+
return true;
72+
} catch (e) {
73+
return false;
74+
}
75+
}
76+
77+
/**
78+
* Sets the staking contract address for this staking tx.
79+
* The address must be explicitly provided to ensure the correct contract is used.
80+
*
81+
* @param {string} address - The staking contract address (required)
82+
* @returns {StakingBuilder} This transaction builder
83+
* @throws {Error} If no address is provided
84+
*/
85+
stakingContractAddress(address: string): this {
86+
if (!address) {
87+
throw new Error('Staking contract address is required');
88+
}
89+
this.validateAddress({ address });
90+
this.stakingTransaction.stakingContractAddress = address;
91+
return this;
92+
}
93+
94+
/**
95+
* Sets the level ID for this staking tx.
96+
*
97+
* @param {number} levelId - The level ID for staking
98+
* @returns {StakingBuilder} This transaction builder
99+
*/
100+
levelId(levelId: number): this {
101+
this.stakingTransaction.levelId = levelId;
102+
return this;
103+
}
104+
105+
/**
106+
* Sets the amount to stake for this staking tx (VET amount being sent).
107+
*
108+
* @param {string} amount - The amount to stake in wei
109+
* @returns {StakingBuilder} This transaction builder
110+
*/
111+
amountToStake(amount: string): this {
112+
this.stakingTransaction.amountToStake = amount;
113+
return this;
114+
}
115+
116+
/**
117+
* Sets the transaction data for this staking tx.
118+
*
119+
* @param {string} data - The transaction data
120+
* @returns {StakingBuilder} This transaction builder
121+
*/
122+
transactionData(data: string): this {
123+
this.stakingTransaction.transactionData = data;
124+
return this;
125+
}
126+
127+
/** @inheritdoc */
128+
validateTransaction(transaction?: StakeClauseTransaction): void {
129+
if (!transaction) {
130+
throw new Error('transaction not defined');
131+
}
132+
assert(transaction.stakingContractAddress, 'Staking contract address is required');
133+
assert(transaction.amountToStake, 'Amount to stake is required');
134+
135+
// Validate amount is a valid number string
136+
if (transaction.amountToStake) {
137+
try {
138+
const bn = new (require('bignumber.js'))(transaction.amountToStake);
139+
if (!bn.isFinite() || bn.isNaN()) {
140+
throw new Error('Invalid character');
141+
}
142+
} catch (e) {
143+
throw new Error('Invalid character');
144+
}
145+
}
146+
147+
assert(transaction.levelId, 'Level ID is required');
148+
this.validateAddress({ address: transaction.stakingContractAddress });
149+
}
150+
151+
/** @inheritdoc */
152+
protected async buildImplementation(): Promise<Transaction> {
153+
this.transaction.type = this.transactionType;
154+
await this.stakingTransaction.build();
155+
return this.transaction;
156+
}
157+
}

0 commit comments

Comments
 (0)