Skip to content

Commit 8c1d4e6

Browse files
authored
Merge pull request #6972 from BitGo/SC-2794
feat: add claim rewards builder for vechain
2 parents bf210b5 + 209a02a commit 8c1d4e6

File tree

9 files changed

+807
-2
lines changed

9 files changed

+807
-2
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export const STAKING_METHOD_ID = '0xa694fc3a';
77
export const EXIT_DELEGATION_METHOD_ID = '0x3fb7a871';
88
export const BURN_NFT_METHOD_ID = '0x42966c68';
99
export const TRANSFER_NFT_METHOD_ID = '0x23b872dd';
10+
export const CLAIM_BASE_REWARDS_METHOD_ID = '0x037402d3';
11+
export const CLAIM_STAKING_REWARDS_METHOD_ID = '0xeb2767fa';
1012

1113
export const STARGATE_NFT_ADDRESS = '0x1856c533ac2d94340aaa8544d35a5c1d4a21dee7';
1214
export const STARGATE_DELEGATION_ADDRESS = '0x4cb1c9ef05b529c093371264fab2c93cc6cddb0e';

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
TransactionType as BitGoTransactionType,
44
TransactionRecipient,
55
} from '@bitgo/sdk-core';
6+
import { ClaimRewardsData } from './types';
67

78
/**
89
* The transaction data returned from the toJson() function of a transaction
@@ -28,9 +29,11 @@ export interface VetTransactionData {
2829
stakingContractAddress?: string;
2930
amountToStake?: string;
3031
nftCollectionId?: string;
32+
claimRewardsData?: ClaimRewardsData;
3133
}
3234

3335
export interface VetTransactionExplanation extends BaseTransactionExplanation {
3436
sender?: string;
3537
type?: BitGoTransactionType;
38+
claimRewardsData?: ClaimRewardsData;
3639
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ 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 { ExitDelegationTransaction } from './transaction/exitDelegation';
12+
export { BurnNftTransaction } from './transaction/burnNftTransaction';
13+
export { ClaimRewardsTransaction } from './transaction/claimRewards';
1114
export { NFTTransaction } from './transaction/nftTransaction';
1215
export { TransactionBuilder } from './transactionBuilder/transactionBuilder';
1316
export { TransferBuilder } from './transactionBuilder/transferBuilder';
@@ -17,5 +20,6 @@ export { StakingBuilder } from './transactionBuilder/stakingBuilder';
1720
export { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder';
1821
export { BurnNftBuilder } from './transactionBuilder/burnNftBuilder';
1922
export { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder';
23+
export { ClaimRewardsBuilder } from './transactionBuilder/claimRewardsBuilder';
2024
export { TransactionBuilderFactory } from './transactionBuilderFactory';
2125
export { Constants, Utils, Interface };
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import EthereumAbi from 'ethereumjs-abi';
2+
import { addHexPrefix } from 'ethereumjs-util';
3+
import { TransactionType, InvalidTransactionError, TransactionRecipient } from '@bitgo/sdk-core';
4+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
5+
import { Transaction as VetTransaction, Secp256k1, TransactionClause } from '@vechain/sdk-core';
6+
import { Transaction } from './transaction';
7+
import { VetTransactionData } from '../iface';
8+
import { ClaimRewardsData } from '../types';
9+
import {
10+
CLAIM_BASE_REWARDS_METHOD_ID,
11+
CLAIM_STAKING_REWARDS_METHOD_ID,
12+
STARGATE_DELEGATION_ADDRESS,
13+
} from '../constants';
14+
15+
export class ClaimRewardsTransaction extends Transaction {
16+
private _claimRewardsData: ClaimRewardsData;
17+
18+
constructor(_coinConfig: Readonly<CoinConfig>) {
19+
super(_coinConfig);
20+
this._type = TransactionType.StakingClaim;
21+
}
22+
23+
get claimRewardsData(): ClaimRewardsData {
24+
return this._claimRewardsData;
25+
}
26+
27+
set claimRewardsData(data: ClaimRewardsData) {
28+
this._claimRewardsData = data;
29+
}
30+
31+
/** @inheritdoc */
32+
async build(): Promise<void> {
33+
this.buildClauses();
34+
await this.buildRawTransaction();
35+
this.generateTxnIdAndSetSender();
36+
this.loadInputsAndOutputs();
37+
}
38+
39+
get clauses(): TransactionClause[] {
40+
return this._clauses;
41+
}
42+
43+
set clauses(clauses: TransactionClause[]) {
44+
this._clauses = clauses;
45+
}
46+
47+
get recipients(): TransactionRecipient[] {
48+
return this._recipients;
49+
}
50+
51+
set recipients(recipients: TransactionRecipient[]) {
52+
this._recipients = recipients;
53+
}
54+
55+
/** @inheritdoc */
56+
buildClauses(): void {
57+
if (!this._claimRewardsData) {
58+
throw new InvalidTransactionError('Missing claim rewards data');
59+
}
60+
61+
const clauses: TransactionClause[] = [];
62+
63+
// Add clause for claiming base rewards if requested
64+
const shouldClaimBaseRewards = this.claimRewardsData.claimBaseRewards !== false; // Default true
65+
if (shouldClaimBaseRewards) {
66+
clauses.push(this.buildClaimBaseRewardsClause());
67+
}
68+
69+
// Add clause for claiming staking rewards if requested
70+
const shouldClaimStakingRewards = this.claimRewardsData.claimStakingRewards !== false; // Default true
71+
if (shouldClaimStakingRewards) {
72+
clauses.push(this.buildClaimStakingRewardsClause());
73+
}
74+
75+
if (clauses.length === 0) {
76+
throw new InvalidTransactionError('At least one type of rewards must be claimed');
77+
}
78+
79+
this.clauses = clauses;
80+
81+
// Set recipients as empty since claim rewards doesn't send value
82+
this.recipients = [];
83+
}
84+
85+
/**
86+
* Get the delegation contract address to use for claims
87+
* Uses the address from claimRewardsData if provided, otherwise falls back to default
88+
*/
89+
private getDelegationAddress(): string {
90+
return this._claimRewardsData.delegationContractAddress || STARGATE_DELEGATION_ADDRESS;
91+
}
92+
93+
/**
94+
* Build clause for claiming base rewards
95+
*/
96+
private buildClaimBaseRewardsClause(): TransactionClause {
97+
const methodData = this.encodeClaimRewardsMethod(
98+
CLAIM_BASE_REWARDS_METHOD_ID,
99+
this._claimRewardsData.validatorAddress,
100+
this._claimRewardsData.delegatorAddress
101+
);
102+
103+
return {
104+
to: this.getDelegationAddress(),
105+
value: '0x0',
106+
data: methodData,
107+
};
108+
}
109+
110+
/**
111+
* Build clause for claiming staking rewards
112+
*/
113+
private buildClaimStakingRewardsClause(): TransactionClause {
114+
const methodData = this.encodeClaimRewardsMethod(
115+
CLAIM_STAKING_REWARDS_METHOD_ID,
116+
this._claimRewardsData.validatorAddress,
117+
this._claimRewardsData.delegatorAddress
118+
);
119+
120+
return {
121+
to: this.getDelegationAddress(),
122+
value: '0x0',
123+
data: methodData,
124+
};
125+
}
126+
127+
/**
128+
* Encode the claim rewards method call data
129+
*/
130+
private encodeClaimRewardsMethod(methodId: string, validatorAddress: string, delegatorAddress: string): string {
131+
const methodName = methodId === CLAIM_BASE_REWARDS_METHOD_ID ? 'claimBaseRewards' : 'claimStakingRewards';
132+
const types = ['address', 'address'];
133+
const params = [validatorAddress, delegatorAddress];
134+
135+
const method = EthereumAbi.methodID(methodName, types);
136+
const args = EthereumAbi.rawEncode(types, params);
137+
138+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
139+
}
140+
141+
/** @inheritdoc */
142+
toJson(): VetTransactionData {
143+
const json: VetTransactionData = {
144+
id: this.id,
145+
chainTag: this.chainTag,
146+
blockRef: this.blockRef,
147+
expiration: this.expiration,
148+
gasPriceCoef: this.gasPriceCoef,
149+
gas: this.gas,
150+
dependsOn: this.dependsOn,
151+
nonce: this.nonce,
152+
sender: this.sender,
153+
feePayer: this.feePayerAddress,
154+
recipients: this.recipients,
155+
claimRewardsData: this._claimRewardsData,
156+
};
157+
return json;
158+
}
159+
160+
/** @inheritdoc */
161+
fromDeserializedSignedTransaction(signedTx: VetTransaction): void {
162+
try {
163+
if (!signedTx || !signedTx.body) {
164+
throw new InvalidTransactionError('Invalid transaction: missing transaction body');
165+
}
166+
167+
// Store the raw transaction
168+
this.rawTransaction = signedTx;
169+
170+
// Set transaction body properties
171+
const body = signedTx.body;
172+
this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0;
173+
this.blockRef = body.blockRef || '0x0';
174+
this.expiration = typeof body.expiration === 'number' ? body.expiration : 64;
175+
this.clauses = body.clauses || [];
176+
this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128;
177+
this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0;
178+
this.dependsOn = body.dependsOn || null;
179+
this.nonce = String(body.nonce);
180+
181+
// Parse claim rewards data from clauses
182+
this.parseClaimRewardsDataFromClauses(body.clauses);
183+
184+
// Set recipients as empty for claim rewards
185+
this.recipients = [];
186+
this.loadInputsAndOutputs();
187+
188+
// Set sender address
189+
if (signedTx.signature && signedTx.origin) {
190+
this.sender = signedTx.origin.toString().toLowerCase();
191+
}
192+
193+
// Set signatures if present
194+
if (signedTx.signature) {
195+
// First signature is sender's signature
196+
this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH));
197+
198+
// If there's additional signature data, it's the fee payer's signature
199+
if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) {
200+
this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH));
201+
}
202+
}
203+
} catch (e) {
204+
throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`);
205+
}
206+
}
207+
208+
/**
209+
* Parse claim rewards data from transaction clauses
210+
*/
211+
private parseClaimRewardsDataFromClauses(clauses: TransactionClause[]): void {
212+
if (!clauses || clauses.length === 0) {
213+
throw new InvalidTransactionError('No clauses found in transaction');
214+
}
215+
216+
let claimBaseRewards = false;
217+
let claimStakingRewards = false;
218+
let validatorAddress = '';
219+
let delegatorAddress = '';
220+
let delegationContractAddress = '';
221+
222+
for (const clause of clauses) {
223+
// Check if this is a claim rewards clause by looking at the method ID in data
224+
if (
225+
clause.data &&
226+
(clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID) ||
227+
clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID))
228+
) {
229+
// Store the contract address (could be different from default)
230+
if (!delegationContractAddress) {
231+
delegationContractAddress = clause.to || '';
232+
}
233+
234+
if (clause.data.startsWith(CLAIM_BASE_REWARDS_METHOD_ID)) {
235+
claimBaseRewards = true;
236+
if (!validatorAddress || !delegatorAddress) {
237+
const addresses = this.parseAddressesFromClaimData(clause.data);
238+
validatorAddress = addresses.validator;
239+
delegatorAddress = addresses.delegator;
240+
}
241+
} else if (clause.data.startsWith(CLAIM_STAKING_REWARDS_METHOD_ID)) {
242+
claimStakingRewards = true;
243+
if (!validatorAddress || !delegatorAddress) {
244+
const addresses = this.parseAddressesFromClaimData(clause.data);
245+
validatorAddress = addresses.validator;
246+
delegatorAddress = addresses.delegator;
247+
}
248+
}
249+
}
250+
}
251+
252+
if (!claimBaseRewards && !claimStakingRewards) {
253+
throw new InvalidTransactionError('Transaction does not contain claim rewards clauses');
254+
}
255+
256+
this._claimRewardsData = {
257+
validatorAddress,
258+
delegatorAddress,
259+
delegationContractAddress:
260+
delegationContractAddress !== STARGATE_DELEGATION_ADDRESS ? delegationContractAddress : undefined,
261+
claimBaseRewards,
262+
claimStakingRewards,
263+
};
264+
}
265+
266+
/**
267+
* Parse validator and delegator addresses from claim rewards method data.
268+
*
269+
* The method data follows Ethereum ABI encoding where each parameter occupies 32 bytes (64 hex chars).
270+
* After the 4-byte method ID, the parameters are laid out as:
271+
* - Bytes 0-31 (chars 0-63): First address parameter (validator) - right-padded, actual address in bytes 12-31
272+
* - Bytes 32-63 (chars 64-127): Second address parameter (delegator) - right-padded, actual address in bytes 44-63
273+
*
274+
* @param data The encoded method call data including method ID and parameters
275+
* @returns Object containing the extracted validator and delegator addresses
276+
*/
277+
private parseAddressesFromClaimData(data: string): { validator: string; delegator: string } {
278+
// Remove method ID (first 10 characters: '0x' + 4-byte method ID)
279+
const methodData = data.slice(10);
280+
281+
// Extract validator address from first parameter (bytes 12-31 of first 32-byte slot)
282+
// Slice 24-64: Skip first 12 bytes of padding (24 hex chars), take next 20 bytes (40 hex chars)
283+
const validatorAddress = '0x' + methodData.slice(24, 64);
284+
285+
// Extract delegator address from second parameter (bytes 44-63 of second 32-byte slot)
286+
// Slice 88-128: Skip to second slot + 12 bytes padding (88 hex chars), take next 20 bytes (40 hex chars)
287+
const delegatorAddress = '0x' + methodData.slice(88, 128);
288+
289+
return {
290+
validator: validatorAddress,
291+
delegator: delegatorAddress,
292+
};
293+
}
294+
}

0 commit comments

Comments
 (0)