Skip to content

Commit 5f076f4

Browse files
committed
feat(sdk-coin-trx): add unfreeze and withdraw for tron unstaking
Ticket: SC-1670
1 parent 0f0826e commit 5f076f4

11 files changed

+649
-3
lines changed

modules/sdk-coin-trx/src/lib/enum.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export enum ContractType {
1414
* This is a smart contract type.
1515
*/
1616
TriggerSmartContract,
17+
/**
18+
* This is the contract for unfreezing balances
19+
*/
20+
UnfreezeBalanceV2,
21+
/**
22+
* This is the contract for withdrawing expired unfrozen balances
23+
*/
24+
WithdrawExpireUnfreeze,
1725
}
1826

1927
export enum PermissionType {

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Entry } from '@bitgo/sdk-core';
22
import { ContractType, PermissionType } from './enum';
3+
import { TronResource } from './resourceTypes';
34

45
export interface Account {
56
publicKey: string;
@@ -42,7 +43,12 @@ export interface RawData {
4243
ref_block_hash: string;
4344
fee_limit?: number;
4445
contractType?: ContractType;
45-
contract: TransferContract[] | AccountPermissionUpdateContract[] | TriggerSmartContract[];
46+
contract:
47+
| TransferContract[]
48+
| AccountPermissionUpdateContract[]
49+
| TriggerSmartContract[]
50+
| UnfreezeBalanceV2Contract[]
51+
| WithdrawExpireUnfreezeContract[];
4652
}
4753

4854
export interface Value {
@@ -117,3 +123,69 @@ export interface AccountInfo {
117123
active_permission: [{ keys: [PermissionKey] }];
118124
trc20: [Record<string, string>];
119125
}
126+
127+
/**
128+
* Unfreeze transaction value fields
129+
*/
130+
export interface UnfreezeBalanceValueFields {
131+
resource: string;
132+
unfreeze_balance: number;
133+
owner_address: string;
134+
}
135+
136+
/**
137+
* Unfreeze balance contract value interface
138+
*/
139+
export interface UnfreezeBalanceValue {
140+
type_url?: string;
141+
value: UnfreezeBalanceValueFields;
142+
}
143+
144+
/**
145+
* Unfreeze balance v2 contract interface
146+
*/
147+
export interface UnfreezeBalanceV2Contract {
148+
parameter: UnfreezeBalanceValue;
149+
type?: string;
150+
}
151+
152+
/**
153+
* Withdraw transaction value fields
154+
*/
155+
export interface WithdrawExpireUnfreezeValueFields {
156+
owner_address: string;
157+
}
158+
159+
/**
160+
* Withdraw balance contract value interface
161+
*/
162+
export interface WithdrawExpireUnfreezeValue {
163+
type_url?: string;
164+
value: WithdrawExpireUnfreezeValueFields;
165+
}
166+
167+
/**
168+
* Withdraw expire unfreeze contract interface
169+
*/
170+
export interface WithdrawExpireUnfreezeContract {
171+
parameter: WithdrawExpireUnfreezeValue;
172+
type?: string;
173+
}
174+
175+
export interface UnfreezeBalanceContractParameter {
176+
parameter: {
177+
value: {
178+
resource: TronResource;
179+
unfreeze_balance: number;
180+
owner_address: string;
181+
};
182+
};
183+
}
184+
185+
export interface WithdrawExpireUnfreezeContractParameter {
186+
parameter: {
187+
value: {
188+
owner_address: string;
189+
};
190+
};
191+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum TronResource {
2+
BANDWIDTH = 'BANDWIDTH',
3+
ENERGY = 'ENERGY',
4+
}

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ import {
1717
tokenMainnetContractAddresses,
1818
tokenTestnetContractAddresses,
1919
} from './utils';
20-
import { ContractEntry, RawData, TransactionReceipt, TransferContract, TriggerSmartContract } from './iface';
20+
import {
21+
ContractEntry,
22+
RawData,
23+
TransactionReceipt,
24+
TransferContract,
25+
TriggerSmartContract,
26+
UnfreezeBalanceV2Contract,
27+
WithdrawExpireUnfreezeContract,
28+
} from './iface';
2129

2230
/**
2331
* Tron transaction model.
@@ -126,6 +134,30 @@ export class Transaction extends BaseTransaction {
126134
value: '0',
127135
};
128136
break;
137+
case ContractType.UnfreezeBalanceV2:
138+
this._type = TransactionType.StakingUnlock;
139+
const unfreezeValues = (rawData.contract[0] as UnfreezeBalanceV2Contract).parameter.value;
140+
output = {
141+
address: unfreezeValues.owner_address,
142+
value: unfreezeValues.unfreeze_balance.toString(),
143+
};
144+
input = {
145+
address: unfreezeValues.owner_address,
146+
value: unfreezeValues.unfreeze_balance.toString(),
147+
};
148+
break;
149+
case ContractType.WithdrawExpireUnfreeze:
150+
this._type = TransactionType.StakingWithdraw;
151+
const withdrawValues = (rawData.contract[0] as WithdrawExpireUnfreezeContract).parameter.value;
152+
output = {
153+
address: withdrawValues.owner_address,
154+
value: '0', // no value field
155+
};
156+
input = {
157+
address: withdrawValues.owner_address,
158+
value: '0',
159+
};
160+
break;
129161
default:
130162
throw new ParseTransactionError('Unsupported contract type');
131163
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
2+
import { TransactionBuilder } from './transactionBuilder';
3+
import { Transaction } from './transaction';
4+
import { TransactionReceipt } from './iface';
5+
import { TronResource } from './resourceTypes';
6+
7+
interface RawUnfreezeBalanceContract {
8+
parameter: {
9+
value: {
10+
resource?: string;
11+
unfreeze_balance?: number;
12+
owner_address?: string;
13+
};
14+
};
15+
type: string;
16+
}
17+
18+
export class UnfreezeBalanceTxBuilder extends TransactionBuilder {
19+
/** @inheritdoc */
20+
protected get transactionType(): TransactionType {
21+
return TransactionType.StakingUnlock;
22+
}
23+
24+
initBuilder(rawTransaction: TransactionReceipt | string): void {
25+
this.transaction = this.fromImplementation(rawTransaction);
26+
this.transaction.setTransactionType(this.transactionType);
27+
}
28+
29+
validateTransaction(transaction: Transaction | TransactionReceipt): void {
30+
if (transaction && typeof (transaction as Transaction).toJson === 'function') {
31+
super.validateTransaction(transaction as Transaction);
32+
const rawTx = (transaction as Transaction).toJson();
33+
this.validateUnfreezeTransaction(rawTx);
34+
} else {
35+
this.validateUnfreezeTransaction(transaction as TransactionReceipt);
36+
}
37+
}
38+
39+
/**
40+
* Validates if the transaction is a valid unfreeze transaction
41+
* @param {TransactionReceipt} transaction - The transaction to validate
42+
* @throws {InvalidTransactionError} when the transaction is invalid
43+
*/
44+
private validateUnfreezeTransaction(transaction: TransactionReceipt): void {
45+
if (!transaction?.raw_data?.contract?.length) {
46+
throw new InvalidTransactionError('Invalid transaction: missing or empty contract array');
47+
}
48+
49+
const contract = transaction.raw_data.contract[0] as RawUnfreezeBalanceContract;
50+
51+
// Validate contract type
52+
if (contract.type !== 'UnfreezeBalanceV2Contract') {
53+
throw new InvalidTransactionError(
54+
`Invalid unfreeze transaction: expected contract type UnfreezeBalanceV2Contract but got ${contract.type}`
55+
);
56+
}
57+
58+
// Validate parameter value
59+
if (!contract?.parameter?.value) {
60+
throw new InvalidTransactionError('Invalid unfreeze transaction: missing parameter value');
61+
}
62+
63+
const value = contract.parameter.value;
64+
65+
// Validate resource
66+
if (!Object.values(TronResource).includes(value.resource as TronResource)) {
67+
throw new InvalidTransactionError(
68+
`Invalid unfreeze transaction: resource must be ${Object.values(TronResource).join(' or ')}, got ${
69+
value.resource
70+
}`
71+
);
72+
}
73+
74+
// Validate unfreeze_balance
75+
if (!value.unfreeze_balance || value.unfreeze_balance <= 0) {
76+
throw new InvalidTransactionError('Invalid unfreeze transaction: unfreeze_balance must be positive');
77+
}
78+
79+
// Validate owner_address
80+
if (!value.owner_address || typeof value.owner_address !== 'string' || value.owner_address.length === 0) {
81+
throw new InvalidTransactionError('Invalid unfreeze transaction: missing or invalid owner_address');
82+
}
83+
}
84+
85+
/**
86+
* Check if the transaction is a valid unfreeze transaction
87+
* @param {TransactionReceipt} transaction - Transaction to check
88+
* @returns True if the transaction is a valid unfreeze transaction
89+
*/
90+
canSign(transaction: TransactionReceipt): boolean {
91+
try {
92+
this.validateUnfreezeTransaction(transaction);
93+
return true;
94+
} catch (e) {
95+
return false;
96+
}
97+
}
98+
}

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

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import {
1212
TransactionReceipt,
1313
Permission,
1414
TriggerSmartContract,
15+
UnfreezeBalanceContractParameter,
16+
WithdrawExpireUnfreezeContractParameter,
1517
} from './iface';
1618
import { ContractType, PermissionType } from './enum';
1719
import { AbiCoder, hexConcat } from 'ethers/lib/utils';
20+
import { TronResource } from './resourceTypes';
1821

1922
const ADDRESS_PREFIX_REGEX = /^(41)/;
2023
const ADDRESS_PREFIX = '41';
@@ -159,7 +162,13 @@ export function decodeTransaction(hexString: string): RawData {
159162
throw new UtilsError('Number of contracts is greater than 1.');
160163
}
161164

162-
let contract: TransferContract[] | AccountPermissionUpdateContract[] | TriggerSmartContract[];
165+
let contract:
166+
| TransferContract[]
167+
| AccountPermissionUpdateContract[]
168+
| TriggerSmartContract[]
169+
| UnfreezeBalanceContractParameter[]
170+
| WithdrawExpireUnfreezeContractParameter[];
171+
163172
let contractType: ContractType;
164173
// ensure the contract type is supported
165174
switch (rawTransaction.contracts[0].parameter.type_url) {
@@ -175,6 +184,14 @@ export function decodeTransaction(hexString: string): RawData {
175184
contractType = ContractType.TriggerSmartContract;
176185
contract = exports.decodeTriggerSmartContract(rawTransaction.contracts[0].parameter.value);
177186
break;
187+
case 'type.googleapis.com/protocol.WithdrawExpireUnfreezeContract':
188+
contract = decodeWithdrawExpireUnfreezeContract(rawTransaction.contracts[0].parameter.value);
189+
contractType = ContractType.WithdrawExpireUnfreeze;
190+
break;
191+
case 'type.googleapis.com/protocol.UnfreezeBalanceV2Contract':
192+
contract = decodeUnfreezeBalanceV2Contract(rawTransaction.contracts[0].parameter.value);
193+
contractType = ContractType.UnfreezeBalanceV2;
194+
break;
178195
default:
179196
throw new UtilsError('Unsupported contract type');
180197
}
@@ -360,6 +377,98 @@ export function decodeAccountPermissionUpdateContract(base64: string): AccountPe
360377
};
361378
}
362379

380+
/**
381+
* Deserialize the segment of the txHex corresponding with unfreeze balance contract
382+
*
383+
* @param {string} base64 - The base64 encoded contract data
384+
* @returns {UnfreezeBalanceContractParameter[]} - Array containing the decoded unfreeze contract
385+
*/
386+
export function decodeUnfreezeBalanceV2Contract(base64: string): UnfreezeBalanceContractParameter[] {
387+
interface UnfreezeContractDecoded {
388+
ownerAddress?: string;
389+
resource?: number;
390+
unfrozenBalance?: string | number;
391+
}
392+
393+
let unfreezeContract: UnfreezeContractDecoded;
394+
try {
395+
unfreezeContract = protocol.UnfreezeBalanceContract.decode(Buffer.from(base64, 'base64')).toJSON();
396+
} catch (e) {
397+
throw new UtilsError('There was an error decoding the unfreeze contract in the transaction.');
398+
}
399+
400+
if (!unfreezeContract.ownerAddress) {
401+
throw new UtilsError('Owner address does not exist in this unfreeze contract.');
402+
}
403+
404+
if (unfreezeContract.resource === undefined) {
405+
throw new UtilsError('Resource type does not exist in this unfreeze contract.');
406+
}
407+
408+
if (unfreezeContract.unfrozenBalance === undefined) {
409+
throw new UtilsError('Unfreeze balance does not exist in this unfreeze contract.');
410+
}
411+
412+
// deserialize attributes
413+
const owner_address = getBase58AddressFromByteArray(
414+
getByteArrayFromHexAddress(Buffer.from(unfreezeContract.ownerAddress, 'base64').toString('hex'))
415+
);
416+
417+
// Convert ResourceCode enum value to string resource name
418+
const resourceValue = unfreezeContract.resource;
419+
const resourceEnum = resourceValue === protocol.ResourceCode.BANDWIDTH ? TronResource.BANDWIDTH : TronResource.ENERGY;
420+
421+
return [
422+
{
423+
parameter: {
424+
value: {
425+
resource: resourceEnum,
426+
unfreeze_balance: Number(unfreezeContract.unfrozenBalance),
427+
owner_address,
428+
},
429+
},
430+
},
431+
];
432+
}
433+
434+
/**
435+
* Deserialize the segment of the txHex corresponding with withdraw expire unfreeze contract
436+
*
437+
* @param {string} base64 - The base64 encoded contract data
438+
* @returns {WithdrawExpireUnfreezeContractParameter[]} - Array containing the decoded withdraw contract
439+
*/
440+
export function decodeWithdrawExpireUnfreezeContract(base64: string): WithdrawExpireUnfreezeContractParameter[] {
441+
interface WithdrawContractDecoded {
442+
ownerAddress?: string;
443+
}
444+
445+
let withdrawContract: WithdrawContractDecoded;
446+
try {
447+
withdrawContract = protocol.WithdrawBalanceContract.decode(Buffer.from(base64, 'base64')).toJSON();
448+
} catch (e) {
449+
throw new UtilsError('There was an error decoding the withdraw contract in the transaction.');
450+
}
451+
452+
if (!withdrawContract.ownerAddress) {
453+
throw new UtilsError('Owner address does not exist in this withdraw contract.');
454+
}
455+
456+
// deserialize attributes
457+
const owner_address = getBase58AddressFromByteArray(
458+
getByteArrayFromHexAddress(Buffer.from(withdrawContract.ownerAddress, 'base64').toString('hex'))
459+
);
460+
461+
return [
462+
{
463+
parameter: {
464+
value: {
465+
owner_address,
466+
},
467+
},
468+
},
469+
];
470+
}
471+
363472
/**
364473
* @param raw
365474
*/

0 commit comments

Comments
 (0)