From 7713863810ae505aebf4f434e365b79b54489c8e Mon Sep 17 00:00:00 2001 From: Abhijit Madhusudan Date: Tue, 22 Apr 2025 16:34:13 +0530 Subject: [PATCH] feat(sdk-coin-trx): add unfreeze and withdraw for tron unstaking Ticket: SC-1670 --- modules/sdk-coin-trx/src/lib/enum.ts | 8 ++ modules/sdk-coin-trx/src/lib/iface.ts | 55 ++++++++- modules/sdk-coin-trx/src/lib/resourceTypes.ts | 7 ++ modules/sdk-coin-trx/src/lib/transaction.ts | 34 +++++- .../sdk-coin-trx/src/lib/unfreezeBuilder.ts | 94 +++++++++++++++ modules/sdk-coin-trx/src/lib/utils.ts | 111 +++++++++++++++++- .../src/lib/withdrawBalanceBuilder.ts | 79 +++++++++++++ .../sdk-coin-trx/src/lib/wrappedBuilder.ts | 26 ++++ modules/sdk-coin-trx/test/resources.ts | 50 ++++++++ .../sdk-coin-trx/test/unit/unfreezeBuilder.ts | 89 ++++++++++++++ .../test/unit/withdrawBalanceBuilder.ts | 89 ++++++++++++++ 11 files changed, 634 insertions(+), 8 deletions(-) create mode 100644 modules/sdk-coin-trx/src/lib/resourceTypes.ts create mode 100644 modules/sdk-coin-trx/src/lib/unfreezeBuilder.ts create mode 100644 modules/sdk-coin-trx/src/lib/withdrawBalanceBuilder.ts create mode 100644 modules/sdk-coin-trx/test/unit/unfreezeBuilder.ts create mode 100644 modules/sdk-coin-trx/test/unit/withdrawBalanceBuilder.ts diff --git a/modules/sdk-coin-trx/src/lib/enum.ts b/modules/sdk-coin-trx/src/lib/enum.ts index c0c9447581..4c05b16afa 100644 --- a/modules/sdk-coin-trx/src/lib/enum.ts +++ b/modules/sdk-coin-trx/src/lib/enum.ts @@ -14,6 +14,14 @@ export enum ContractType { * This is a smart contract type. */ TriggerSmartContract, + /** + * This is the contract for unfreezing balances + */ + UnfreezeBalanceV2, + /** + * This is the contract for withdrawing expired unfrozen balances + */ + WithdrawExpireUnfreeze, } export enum PermissionType { diff --git a/modules/sdk-coin-trx/src/lib/iface.ts b/modules/sdk-coin-trx/src/lib/iface.ts index d48b5493a0..aba34fa792 100644 --- a/modules/sdk-coin-trx/src/lib/iface.ts +++ b/modules/sdk-coin-trx/src/lib/iface.ts @@ -42,7 +42,12 @@ export interface RawData { ref_block_hash: string; fee_limit?: number; contractType?: ContractType; - contract: TransferContract[] | AccountPermissionUpdateContract[] | TriggerSmartContract[]; + contract: + | TransferContract[] + | AccountPermissionUpdateContract[] + | TriggerSmartContract[] + | UnfreezeBalanceV2Contract[] + | WithdrawExpireUnfreezeContract[]; } export interface Value { @@ -117,3 +122,51 @@ export interface AccountInfo { active_permission: [{ keys: [PermissionKey] }]; trc20: [Record]; } + +/** + * Unfreeze transaction value fields + */ +export interface UnfreezeBalanceValueFields { + resource: string; + unfreeze_balance: number; + owner_address: string; +} + +/** + * Unfreeze balance contract value interface + */ +export interface UnfreezeBalanceValue { + type_url?: string; + value: UnfreezeBalanceValueFields; +} + +/** + * Unfreeze balance v2 contract interface + */ +export interface UnfreezeBalanceV2Contract { + parameter: UnfreezeBalanceValue; + type?: string; +} + +/** + * Withdraw transaction value fields + */ +export interface WithdrawBalanceValueFields { + owner_address: string; +} + +/** + * Withdraw balance contract value interface + */ +export interface WithdrawBalanceValue { + type_url?: string; + value: WithdrawBalanceValueFields; +} + +/** + * Withdraw expire unfreeze contract interface + */ +export interface WithdrawExpireUnfreezeContract { + parameter: WithdrawBalanceValue; + type?: string; +} diff --git a/modules/sdk-coin-trx/src/lib/resourceTypes.ts b/modules/sdk-coin-trx/src/lib/resourceTypes.ts new file mode 100644 index 0000000000..937a88fcbe --- /dev/null +++ b/modules/sdk-coin-trx/src/lib/resourceTypes.ts @@ -0,0 +1,7 @@ +/** + * Valid resource types for Tron freezing and unfreezing + */ +export enum TronResource { + BANDWIDTH = 'BANDWIDTH', + ENERGY = 'ENERGY', +} diff --git a/modules/sdk-coin-trx/src/lib/transaction.ts b/modules/sdk-coin-trx/src/lib/transaction.ts index 077be2224d..6d7815cfcf 100644 --- a/modules/sdk-coin-trx/src/lib/transaction.ts +++ b/modules/sdk-coin-trx/src/lib/transaction.ts @@ -17,7 +17,15 @@ import { tokenMainnetContractAddresses, tokenTestnetContractAddresses, } from './utils'; -import { ContractEntry, RawData, TransactionReceipt, TransferContract, TriggerSmartContract } from './iface'; +import { + ContractEntry, + RawData, + TransactionReceipt, + TransferContract, + TriggerSmartContract, + UnfreezeBalanceV2Contract, + WithdrawExpireUnfreezeContract, +} from './iface'; /** * Tron transaction model. @@ -126,6 +134,30 @@ export class Transaction extends BaseTransaction { value: '0', }; break; + case ContractType.UnfreezeBalanceV2: + this._type = TransactionType.StakingUnlock; + const unfreezeValues = (rawData.contract[0] as UnfreezeBalanceV2Contract).parameter.value; + output = { + address: unfreezeValues.owner_address, + value: unfreezeValues.unfreeze_balance.toString(), + }; + input = { + address: unfreezeValues.owner_address, + value: unfreezeValues.unfreeze_balance.toString(), + }; + break; + case ContractType.WithdrawExpireUnfreeze: + this._type = TransactionType.StakingWithdraw; + const withdrawValues = (rawData.contract[0] as WithdrawExpireUnfreezeContract).parameter.value; + output = { + address: withdrawValues.owner_address, + value: '0', // no value field + }; + input = { + address: withdrawValues.owner_address, + value: '0', + }; + break; default: throw new ParseTransactionError('Unsupported contract type'); } diff --git a/modules/sdk-coin-trx/src/lib/unfreezeBuilder.ts b/modules/sdk-coin-trx/src/lib/unfreezeBuilder.ts new file mode 100644 index 0000000000..857324fbcf --- /dev/null +++ b/modules/sdk-coin-trx/src/lib/unfreezeBuilder.ts @@ -0,0 +1,94 @@ +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilder } from './transactionBuilder'; +import { TronResource } from './resourceTypes'; + +export class UnfreezeBuilder extends TransactionBuilder { + /** @inheritdoc */ + protected get transactionType(): TransactionType { + return TransactionType.StakingUnlock; + } + + /** Override to initialize this builder from a raw transaction */ + initBuilder(rawTransaction: string | any): void { + this.transaction = this.fromImplementation(rawTransaction); + // Explicitly set the transaction type after initialization + this.transaction.setTransactionType(this.transactionType); + } + + validateTransaction(transaction: any): void { + if (transaction && typeof transaction.toJson === 'function') { + super.validateTransaction(transaction); + // Get the raw transaction data from the Transaction object + const rawTx = transaction.toJson(); + this.validateUnfreezeTransaction(rawTx); + } else { + // If it's already a raw transaction object, validate it directly + this.validateUnfreezeTransaction(transaction); + } + } + + /** + * Validates if the transaction is a valid unfreeze transaction + * @param transaction The transaction to validate + * @throws {InvalidTransactionError} when the transaction is invalid + */ + private validateUnfreezeTransaction(transaction: any): void { + if ( + !transaction || + !transaction.raw_data || + !transaction.raw_data.contract || + transaction.raw_data.contract.length === 0 + ) { + throw new InvalidTransactionError('Invalid transaction: missing or empty contract array'); + } + + const contract = transaction.raw_data.contract[0]; + + // Validate contract type + if (contract.type !== 'UnfreezeBalanceV2Contract') { + throw new InvalidTransactionError( + `Invalid unfreeze transaction: expected contract type UnfreezeBalanceV2Contract but got ${contract.type}` + ); + } + + // Validate parameter value + if (!contract.parameter || !contract.parameter.value) { + throw new InvalidTransactionError('Invalid unfreeze transaction: missing parameter value'); + } + + const value = contract.parameter.value; + + // Validate resource + if (!Object.values(TronResource).includes(value.resource)) { + throw new InvalidTransactionError( + `Invalid unfreeze transaction: resource must be ${Object.values(TronResource).join(' or ')}, got ${ + value.resource + }` + ); + } + + // Validate unfreeze_balance + if (!value.unfreeze_balance || value.unfreeze_balance <= 0) { + throw new InvalidTransactionError('Invalid unfreeze transaction: unfreeze_balance must be positive'); + } + + // Validate owner_address + if (!value.owner_address || typeof value.owner_address !== 'string' || value.owner_address.length === 0) { + throw new InvalidTransactionError('Invalid unfreeze transaction: missing or invalid owner_address'); + } + } + + /** + * Check if the transaction is a valid unfreeze transaction + * @param transaction Transaction to check + * @returns True if the transaction is a valid unfreeze transaction + */ + canSign(transaction: any): boolean { + try { + this.validateUnfreezeTransaction(transaction); + return true; + } catch (e) { + return false; + } + } +} diff --git a/modules/sdk-coin-trx/src/lib/utils.ts b/modules/sdk-coin-trx/src/lib/utils.ts index 3c33b46ab7..ee9e83cd79 100644 --- a/modules/sdk-coin-trx/src/lib/utils.ts +++ b/modules/sdk-coin-trx/src/lib/utils.ts @@ -90,21 +90,21 @@ export function getHexAddressFromBase58Address(base58: string): string { // pulled from: https://github.com/TRON-US/tronweb/blob/dcb8efa36a5ebb65c4dab3626e90256a453f3b0d/src/utils/help.js#L17 // but they don't surface this call in index.js const bytes = tronweb.utils.crypto.decodeBase58Address(base58); - return getHexAddressFromByteArray(bytes); + return getHexAddressFromByteArray(bytes as any); } /** * @param privateKey */ export function getPubKeyFromPriKey(privateKey: TronBinaryLike): ByteArray { - return tronweb.utils.crypto.getPubKeyFromPriKey(privateKey); + return tronweb.utils.crypto.getPubKeyFromPriKey(privateKey as any); } /** * @param privateKey */ export function getAddressFromPriKey(privateKey: TronBinaryLike): ByteArray { - return tronweb.utils.crypto.getAddressFromPriKey(privateKey); + return tronweb.utils.crypto.getAddressFromPriKey(privateKey as any); } /** @@ -127,7 +127,7 @@ export function getBase58AddressFromHex(hex: string): string { * @param transaction */ export function signTransaction(privateKey: string | ByteArray, transaction: TransactionReceipt): TransactionReceipt { - return tronweb.utils.crypto.signTransaction(privateKey, transaction); + return tronweb.utils.crypto.signTransaction(privateKey, transaction) as unknown as TransactionReceipt; } /** @@ -136,14 +136,14 @@ export function signTransaction(privateKey: string | ByteArray, transaction: Tra * @param useTronHeader */ export function signString(message: string, privateKey: string | ByteArray, useTronHeader = true): string { - return tronweb.Trx.signString(message, privateKey, useTronHeader); + return tronweb.Trx.signString(message, privateKey as any, useTronHeader); } /** * @param pubBytes */ export function getRawAddressFromPubKey(pubBytes: TronBinaryLike): ByteArray { - return tronweb.utils.crypto.computeAddress(pubBytes); + return tronweb.utils.crypto.computeAddress(pubBytes as any); } /** @@ -175,6 +175,14 @@ export function decodeTransaction(hexString: string): RawData { contractType = ContractType.TriggerSmartContract; contract = exports.decodeTriggerSmartContract(rawTransaction.contracts[0].parameter.value); break; + case 'type.googleapis.com/protocol.WithdrawExpireUnfreezeContract': + contract = decodeWithdrawExpireUnfreezeContract(rawTransaction.contracts[0].parameter.value); + contractType = ContractType.WithdrawExpireUnfreeze; + break; + case 'type.googleapis.com/protocol.UnfreezeBalanceV2Contract': + contract = decodeUnfreezeBalanceV2Contract(rawTransaction.contracts[0].parameter.value); + contractType = ContractType.UnfreezeBalanceV2; + break; default: throw new UtilsError('Unsupported contract type'); } @@ -360,6 +368,97 @@ export function decodeAccountPermissionUpdateContract(base64: string): AccountPe }; } +/** + * Deserialize the segment of the txHex corresponding with unfreeze balance contract + * + * @param {string} base64 - The base64 encoded contract data + * @returns {Array} - Array containing the decoded unfreeze contract + */ +export function decodeUnfreezeBalanceV2Contract(base64: string): any[] { + let unfreezeContract; + try { + unfreezeContract = protocol.UnfreezeBalanceContract.decode(Buffer.from(base64, 'base64')).toJSON(); + } catch (e) { + throw new UtilsError('There was an error decoding the unfreeze contract in the transaction.'); + } + + if (!unfreezeContract.ownerAddress) { + throw new UtilsError('Owner address does not exist in this unfreeze contract.'); + } + + if (!unfreezeContract.resource) { + throw new UtilsError('Resource type does not exist in this unfreeze contract.'); + } + + if (!unfreezeContract.hasOwnProperty('unfrozenBalance')) { + throw new UtilsError('Unfreeze balance does not exist in this unfreeze contract.'); + } + + // deserialize attributes + const owner_address = getBase58AddressFromByteArray( + getByteArrayFromHexAddress(Buffer.from(unfreezeContract.ownerAddress, 'base64').toString('hex')) + ); + + // Convert ResourceCode enum value to string resource name + const resourceValue = unfreezeContract.resource; + let resource: string; + if (resourceValue === protocol.ResourceCode.BANDWIDTH) { + resource = 'BANDWIDTH'; + } else if (resourceValue === protocol.ResourceCode.ENERGY) { + resource = 'ENERGY'; + } else { + throw new UtilsError(`Unknown resource type: ${resourceValue}`); + } + + const unfreeze_balance = unfreezeContract.unfrozenBalance; + + return [ + { + parameter: { + value: { + resource, + unfreeze_balance: Number(unfreeze_balance), + owner_address, + }, + }, + }, + ]; +} + +/** + * Deserialize the segment of the txHex corresponding with withdraw expire unfreeze contract + * + * @param {string} base64 - The base64 encoded contract data + * @returns {Array} - Array containing the decoded withdraw contract + */ +export function decodeWithdrawExpireUnfreezeContract(base64: string): any[] { + let withdrawContract; + try { + withdrawContract = protocol.WithdrawBalanceContract.decode(Buffer.from(base64, 'base64')).toJSON(); + } catch (e) { + throw new UtilsError('There was an error decoding the withdraw contract in the transaction.'); + } + + if (!withdrawContract.ownerAddress) { + throw new UtilsError('Owner address does not exist in this withdraw contract.'); + } + + // deserialize attributes + const owner_address = getBase58AddressFromByteArray( + getByteArrayFromHexAddress(Buffer.from(withdrawContract.ownerAddress, 'base64').toString('hex')) + ); + + return [ + { + parameter: { + value: { + owner_address, + }, + }, + }, + ]; +} + /** * @param raw */ diff --git a/modules/sdk-coin-trx/src/lib/withdrawBalanceBuilder.ts b/modules/sdk-coin-trx/src/lib/withdrawBalanceBuilder.ts new file mode 100644 index 0000000000..4dd5c9f438 --- /dev/null +++ b/modules/sdk-coin-trx/src/lib/withdrawBalanceBuilder.ts @@ -0,0 +1,79 @@ +import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilder } from './transactionBuilder'; + +export class WithdrawBuilder extends TransactionBuilder { + /** @inheritdoc */ + protected get transactionType(): TransactionType { + return TransactionType.StakingWithdraw; + } + + /** Override to initialize this builder from a raw transaction */ + initBuilder(rawTransaction: string | any): void { + this.transaction = this.fromImplementation(rawTransaction); + // Explicitly set the transaction type after initialization + this.transaction.setTransactionType(this.transactionType); + } + + validateTransaction(transaction: any): void { + if (transaction && typeof transaction.toJson === 'function') { + super.validateTransaction(transaction); + // Get the raw transaction data from the Transaction object + const rawTx = transaction.toJson(); + this.validateWithdrawTransaction(rawTx); + } else { + // If it's already a raw transaction object, validate it directly + this.validateWithdrawTransaction(transaction); + } + } + + /** + * Validates if the transaction is a valid withdraw transaction + * @param transaction The transaction to validate + * @throws {InvalidTransactionError} when the transaction is invalid + */ + private validateWithdrawTransaction(transaction: any): void { + if ( + !transaction || + !transaction.raw_data || + !transaction.raw_data.contract || + transaction.raw_data.contract.length === 0 + ) { + throw new InvalidTransactionError('Invalid transaction: missing or empty contract array'); + } + + const contract = transaction.raw_data.contract[0]; + + // Validate contract type + if (contract.type !== 'WithdrawExpireUnfreezeContract') { + throw new InvalidTransactionError( + `Invalid withdraw transaction: expected contract type WithdrawExpireUnfreezeContract but got ${contract.type}` + ); + } + + // Validate parameter value + if (!contract.parameter || !contract.parameter.value) { + throw new InvalidTransactionError('Invalid withdraw transaction: missing parameter value'); + } + + const value = contract.parameter.value; + + // Validate owner_address + if (!value.owner_address || typeof value.owner_address !== 'string' || value.owner_address.length === 0) { + throw new InvalidTransactionError('Invalid withdraw transaction: missing or invalid owner_address'); + } + } + + /** + * Check if the transaction is a valid withdraw transaction + * @param transaction Transaction to check + * @returns True if the transaction is a valid withdraw transaction + */ + canSign(transaction: any): boolean { + try { + this.validateWithdrawTransaction(transaction); + return true; + } catch (e) { + return false; + } + } +} diff --git a/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts b/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts index a0e5755a5f..98c9d9490b 100644 --- a/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts +++ b/modules/sdk-coin-trx/src/lib/wrappedBuilder.ts @@ -10,6 +10,8 @@ import { ContractType } from './enum'; import { ContractCallBuilder } from './contractCallBuilder'; import { TransactionReceipt } from './iface'; import { TokenTransferBuilder } from './tokenTransferBuilder'; +import { UnfreezeBuilder } from './unfreezeBuilder'; +import { WithdrawBuilder } from './withdrawBalanceBuilder'; /** * Wrapped Builder class @@ -43,6 +45,26 @@ export class WrappedBuilder extends TransactionBuilder { return this.initializeBuilder(tx, new TokenTransferBuilder(this._coinConfig)); } + /** + * Returns a specific builder to create an unfreeze balance transaction + * + * @param {Transaction} [tx] The transaction to initialize builder + * @returns {UnfreezeBuilder} The specific unfreeze builder + */ + getUnfreezeBuilder(tx?: TransactionReceipt | string): UnfreezeBuilder { + return this.initializeBuilder(tx, new UnfreezeBuilder(this._coinConfig)); + } + + /** + * Returns a specific builder to create a withdraw expire unfreeze transaction + * + * @param {Transaction} [tx] The transaction to initialize builder + * @returns {WithdrawBuilder} The specific withdraw builder + */ + getWithdrawBuilder(tx?: TransactionReceipt | string): WithdrawBuilder { + return this.initializeBuilder(tx, new WithdrawBuilder(this._coinConfig)); + } + private initializeBuilder(tx: TransactionReceipt | string | undefined, builder: T): T { if (tx) { builder.initBuilder(tx); @@ -78,6 +100,10 @@ export class WrappedBuilder extends TransactionBuilder { return this._builder; case ContractType.TriggerSmartContract: return this.getContractCallBuilder(raw); + case ContractType.UnfreezeBalanceV2: + return this.getUnfreezeBuilder(raw); + case ContractType.WithdrawExpireUnfreeze: + return this.getWithdrawBuilder(raw); default: throw new InvalidTransactionError('Invalid transaction type: ' + contractType); } diff --git a/modules/sdk-coin-trx/test/resources.ts b/modules/sdk-coin-trx/test/resources.ts index 7a10692a5a..f7fab811e7 100644 --- a/modules/sdk-coin-trx/test/resources.ts +++ b/modules/sdk-coin-trx/test/resources.ts @@ -636,3 +636,53 @@ export const mockTokenTx = { raw_data_hex: '0a02c8cf220889177fd84c5d919640ccd2b9a1cf305aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a15414887974f42a789ef6d4dfc7ba28b1583219434b312154142a1e39aefa49290f2b3f9ed688d7cecf86cd6e02244a9059cbb0000000000000000000000004887974f42a789ef6d4dfc7ba28b1583219434b3000000000000000000000000000000000000000000000000000000003b9aca0070ccf5dd9fcf30900180a3c347', }; + +export const validUnfreezeUnsignedTx = { + visible: false, + txID: '227b7c700ea3bf507a7d8b3627b6e9db1d7e6116681f2e125c16fc40e6a19235', + raw_data: { + contract: [ + { + parameter: { + value: { + resource: 'ENERGY', + owner_address: '41e5e00fc1cdb3921b8340c20b2b65b543c84aa1dd', + unfreeze_balance: 1000000, + }, + type_url: 'type.googleapis.com/protocol.UnfreezeBalanceV2Contract', + }, + type: 'UnfreezeBalanceV2Contract', + }, + ], + ref_block_bytes: '67b9', + ref_block_hash: '3aef8803c1aceb03', + expiration: 1745312676000, + timestamp: 1745312617917, + }, + raw_data_hex: + '0a0267b922083aef8803c1aceb0340a0f1f7e5e5325a5b083712570a36747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e556e667265657a6542616c616e63655632436f6e7472616374121d0a1541e5e00fc1cdb3921b8340c20b2b65b543c84aa1dd10c0843d180170bdabf4e5e532', +}; + +export const validWithdrawUnsignedTx = { + visible: false, + txID: '7a9d37540e56f3f61ed0f97cd8a5213652c90c4b75dad876da9c35291d231367', + raw_data: { + contract: [ + { + parameter: { + value: { + owner_address: '41E5E00FC1CDB3921B8340C20B2B65B543C84AA1DD', + }, + type_url: 'type.googleapis.com/protocol.WithdrawExpireUnfreezeContract', + }, + type: 'WithdrawExpireUnfreezeContract', + }, + ], + ref_block_bytes: '5b4c', + ref_block_hash: 'c989a213a861c2a1', + expiration: 1714088400000, + timestamp: 1714031128253, + }, + raw_data_hex: + '0a025b4c2208c989a213a861c2a140c0c1dee8f30d5a66080112620a32747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5769746864726177457870697265556e667265657a65436f6e747261637412280a1541e5e00fc1cdb3921b8340c20b2b65b543c84aa1dd70fde8f3e8f30d', +}; diff --git a/modules/sdk-coin-trx/test/unit/unfreezeBuilder.ts b/modules/sdk-coin-trx/test/unit/unfreezeBuilder.ts new file mode 100644 index 0000000000..072f0339a1 --- /dev/null +++ b/modules/sdk-coin-trx/test/unit/unfreezeBuilder.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, it } from 'node:test'; +import assert from 'assert'; +import { UnfreezeBuilder } from '../../src/lib/unfreezeBuilder'; +import { validUnfreezeUnsignedTx } from '../resources'; +import { getBuilder } from '../../src/lib/builder'; +import { TransactionType } from '@bitgo/sdk-core'; + +describe('Tron UnfreezeBuilder', function () { + let unfreezeBuilder: UnfreezeBuilder; + let wrappedBuilder; + + beforeEach(() => { + wrappedBuilder = getBuilder('ttrx'); + // Get UnfreezeBuilder from the wrapped builder + unfreezeBuilder = wrappedBuilder.getUnfreezeBuilder(); + }); + + describe('validateTransaction', () => { + it('should validate a correct unfreeze transaction', () => { + assert.doesNotThrow(() => unfreezeBuilder.validateTransaction(validUnfreezeUnsignedTx)); + }); + + it('should reject a transaction with invalid resource', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + invalidTx.raw_data.contract[0].parameter.value.resource = 'INVALID_RESOURCE'; + assert.throws(() => unfreezeBuilder.validateTransaction(invalidTx), /Invalid unfreeze transaction: resource/); + }); + + it('should reject a transaction with zero unfreeze_balance', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + invalidTx.raw_data.contract[0].parameter.value.unfreeze_balance = 0; + assert.throws(() => unfreezeBuilder.validateTransaction(invalidTx), /unfreeze_balance must be positive/); + }); + + it('should reject a transaction with negative unfreeze_balance', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + invalidTx.raw_data.contract[0].parameter.value.unfreeze_balance = -100; + assert.throws(() => unfreezeBuilder.validateTransaction(invalidTx), /unfreeze_balance must be positive/); + }); + + it('should reject a transaction with missing owner_address', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + delete invalidTx.raw_data.contract[0].parameter.value.owner_address; + assert.throws(() => unfreezeBuilder.validateTransaction(invalidTx), /missing or invalid owner_address/); + }); + + it('should reject a transaction with wrong contract type', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + invalidTx.raw_data.contract[0].type = 'TransferContract'; + assert.throws( + () => unfreezeBuilder.validateTransaction(invalidTx), + /expected contract type UnfreezeBalanceV2Contract/ + ); + }); + + it('should reject a transaction with missing parameter value', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + delete invalidTx.raw_data.contract[0].parameter.value; + assert.throws(() => unfreezeBuilder.validateTransaction(invalidTx), /missing parameter value/); + }); + }); + + describe('canSign', () => { + it('should return true for valid unfreeze transaction', () => { + const result = unfreezeBuilder.canSign(validUnfreezeUnsignedTx); + assert.strictEqual(result, true); + }); + + it('should return false for invalid unfreeze transaction', () => { + const invalidTx = JSON.parse(JSON.stringify(validUnfreezeUnsignedTx)); + invalidTx.raw_data.contract[0].type = 'TransferContract'; + const result = unfreezeBuilder.canSign(invalidTx); + assert.strictEqual(result, false); + }); + }); + + describe('transaction type', () => { + it('should set transaction type to StakingUnlock', () => { + assert.strictEqual(unfreezeBuilder['transactionType'], TransactionType.StakingUnlock); + }); + }); + + describe.skip('builder integration', () => { + it('should be able to deserialize a valid unfreeze transaction using from method', () => { + assert.doesNotThrow(() => wrappedBuilder.from(validUnfreezeUnsignedTx)); + assert.strictEqual(wrappedBuilder._builder instanceof UnfreezeBuilder, true); + }); + }); +}); diff --git a/modules/sdk-coin-trx/test/unit/withdrawBalanceBuilder.ts b/modules/sdk-coin-trx/test/unit/withdrawBalanceBuilder.ts new file mode 100644 index 0000000000..3314b069b4 --- /dev/null +++ b/modules/sdk-coin-trx/test/unit/withdrawBalanceBuilder.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, it } from 'node:test'; +import assert from 'assert'; +import { WithdrawBuilder } from '../../src/lib/withdrawBalanceBuilder'; +import { validWithdrawUnsignedTx } from '../resources'; +import { getBuilder } from '../../src/lib/builder'; +import { TransactionType } from '@bitgo/sdk-core'; + +describe('Tron WithdrawBuilder', function () { + let withdrawBuilder: WithdrawBuilder; + let wrappedBuilder; + + beforeEach(() => { + wrappedBuilder = getBuilder('ttrx'); + // Get WithdrawBuilder from the wrapped builder + withdrawBuilder = wrappedBuilder.getWithdrawBuilder(); + }); + + describe('validateTransaction', () => { + it('should validate a correct withdraw transaction', () => { + assert.doesNotThrow(() => withdrawBuilder.validateTransaction(validWithdrawUnsignedTx)); + }); + + it('should reject a transaction with wrong contract type', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + invalidTx.raw_data.contract[0].type = 'TransferContract'; + assert.throws( + () => withdrawBuilder.validateTransaction(invalidTx), + /expected contract type WithdrawExpireUnfreezeContract/ + ); + }); + + it('should reject a transaction with missing owner_address', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + delete invalidTx.raw_data.contract[0].parameter.value.owner_address; + assert.throws(() => withdrawBuilder.validateTransaction(invalidTx), /missing or invalid owner_address/); + }); + + it('should reject a transaction with empty owner_address', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + invalidTx.raw_data.contract[0].parameter.value.owner_address = ''; + assert.throws(() => withdrawBuilder.validateTransaction(invalidTx), /missing or invalid owner_address/); + }); + + it('should reject a transaction with missing parameter value', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + delete invalidTx.raw_data.contract[0].parameter.value; + assert.throws(() => withdrawBuilder.validateTransaction(invalidTx), /missing parameter value/); + }); + + it('should reject a transaction with empty contract array', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + invalidTx.raw_data.contract = []; + assert.throws(() => withdrawBuilder.validateTransaction(invalidTx), /missing or empty contract array/); + }); + + it('should reject a transaction without contract array', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + delete invalidTx.raw_data.contract; + assert.throws(() => withdrawBuilder.validateTransaction(invalidTx), /missing or empty contract array/); + }); + }); + + describe('canSign', () => { + it('should return true for valid withdraw transaction', () => { + const result = withdrawBuilder.canSign(validWithdrawUnsignedTx); + assert.strictEqual(result, true); + }); + + it('should return false for invalid withdraw transaction', () => { + const invalidTx = JSON.parse(JSON.stringify(validWithdrawUnsignedTx)); + invalidTx.raw_data.contract[0].type = 'TransferContract'; + const result = withdrawBuilder.canSign(invalidTx); + assert.strictEqual(result, false); + }); + }); + + describe('transaction type', () => { + it('should set transaction type to StakingWithdraw', () => { + assert.strictEqual(withdrawBuilder['transactionType'], TransactionType.StakingWithdraw); + }); + }); + + describe.skip('builder integration', () => { + it('should be able to deserialize a valid withdraw transaction using from method', () => { + assert.doesNotThrow(() => wrappedBuilder.from(validWithdrawUnsignedTx)); + assert.strictEqual(wrappedBuilder._builder instanceof WithdrawBuilder, true); + }); + }); +});