From b5faa7cd4d254abcd9840146b3c8186dd842711b Mon Sep 17 00:00:00 2001 From: Sachin Roy Date: Mon, 29 Sep 2025 15:06:23 +0530 Subject: [PATCH] feat(sdk-coin-trx): added the validation for verifyTxn for trx COIN-3579 TICKET: COIN-3579 --- modules/sdk-coin-trx/src/lib/utils.ts | 97 ++++ modules/sdk-coin-trx/src/trx.ts | 66 ++- modules/sdk-coin-trx/test/resources.ts | 8 +- .../test/unit/verifyTransaction.ts | 420 ++++++++++++++++++ 4 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 modules/sdk-coin-trx/test/unit/verifyTransaction.ts diff --git a/modules/sdk-coin-trx/src/lib/utils.ts b/modules/sdk-coin-trx/src/lib/utils.ts index 087aca2f39..99c6e576eb 100644 --- a/modules/sdk-coin-trx/src/lib/utils.ts +++ b/modules/sdk-coin-trx/src/lib/utils.ts @@ -283,6 +283,17 @@ export function decodeRawTransaction(hexString: string): { }; } +/** + * Converts a base64 encoded string to hex + * + * @param base64 - The base64 encoded string to convert + * @returns {string} - The hex representation + */ +export function getHexFromBase64(base64: string): string { + const buffer = Buffer.from(base64, 'base64'); + return buffer.toString('hex'); +} + /** * Indicates whether the passed string is a safe hex string for tron's purposes. * @@ -837,3 +848,89 @@ export function decodeDataParams(types: string[], data: string): any[] { return obj; }, []); } + +/** + * Generate raw_data_hex for a TRON transaction + * + * @param {Object} rawData - The transaction raw data object containing: + * @param {Array} rawData.contract - Array of contract objects + * @param {string} rawData.refBlockBytes - Reference block bytes + * @param {string} rawData.refBlockHash - Reference block hash + * @param {number} rawData.expiration - Transaction expiration timestamp + * @param {number} rawData.timestamp - Transaction creation timestamp + * @param {number} [rawData.feeLimit] - Optional fee limit for smart contracts + * @returns {string} The hex string representation of the encoded transaction data + */ +export function generateRawDataHex( + rawData: { + contract?: protocol.Transaction.Contract[]; + refBlockBytes?: string; + refBlockHash?: string; + expiration?: number; + timestamp?: number; + feeLimit?: number; + } = {} +): string { + try { + // Process contracts to ensure proper protobuf encoding + let processedContracts = rawData.contract; + if (rawData.contract && rawData.contract.length > 0) { + processedContracts = rawData.contract.map((contract) => { + // Handle TransferContract specifically + if (contract.parameter?.type_url === 'type.googleapis.com/protocol.TransferContract') { + const contractValue = contract.parameter.value as any; + + // Create the protobuf contract object + const transferContract: any = {}; + + // Handle owner_address (required field) + if (contractValue.owner_address) { + transferContract.ownerAddress = Buffer.from(contractValue.owner_address, 'hex'); + } + + // Handle to_address (required field) + if (contractValue.to_address) { + transferContract.toAddress = Buffer.from(contractValue.to_address, 'hex'); + } + + // Handle amount (required field) + if (contractValue.amount !== undefined) { + transferContract.amount = contractValue.amount; + } + + // Encode the contract using protobuf + const encodedContract = protocol.TransferContract.encode(transferContract).finish(); + const base64Value = Buffer.from(encodedContract).toString('base64'); + + return { + ...contract, + parameter: { + ...contract.parameter, + value: base64Value, + }, + } as any; + } + + return contract; + }) as protocol.Transaction.Contract[]; + } + + // Create raw transaction object matching protobuf schema + const rawTx: protocol.Transaction.Iraw = { + contract: processedContracts, + refBlockBytes: rawData.refBlockBytes ? Buffer.from(rawData.refBlockBytes, 'hex') : undefined, + refBlockHash: rawData.refBlockHash ? Buffer.from(rawData.refBlockHash, 'hex') : undefined, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.feeLimit, + }; + + // Encode using protobuf and get final bytes + const encodedBytes = protocol.Transaction.raw.encode(rawTx).finish(); + + // Convert to hex string + return Buffer.from(encodedBytes).toString('hex'); + } catch (e) { + throw new UtilsError('Failed to generate raw data hex: ' + e.message); + } +} diff --git a/modules/sdk-coin-trx/src/trx.ts b/modules/sdk-coin-trx/src/trx.ts index fe13de9528..cd3fb02d49 100644 --- a/modules/sdk-coin-trx/src/trx.ts +++ b/modules/sdk-coin-trx/src/trx.ts @@ -31,8 +31,8 @@ import { AuditDecryptedKeyParams, } from '@bitgo/sdk-core'; import { Interface, Utils, WrappedBuilder } from './lib'; +import { ValueFields, TransactionReceipt } from './lib/iface'; import { getBuilder } from './lib/builder'; -import { TransactionReceipt } from './lib/iface'; import { isInteger, isUndefined } from 'lodash'; export const MINIMUM_TRON_MSIG_TRANSACTION_FEE = 1e6; @@ -250,6 +250,70 @@ export class Trx extends BaseCoin { } async verifyTransaction(params: VerifyTransactionOptions): Promise { + const { txParams, txPrebuild } = params; + + if (!txParams) { + throw new Error('missing txParams'); + } + + if (!txPrebuild) { + throw new Error('missing txPrebuild'); + } + + if (!txPrebuild.txHex) { + throw new Error('missing txHex in txPrebuild'); + } + + const rawTx = txPrebuild.txHex; + const txBuilder = getBuilder(this.getChain()).from(rawTx); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + if (!txJson.raw_data || !txJson.raw_data.contract || txJson.raw_data.contract.length !== 1) { + throw new Error('Number of contracts is greater than 1.'); + } + + const contract = txJson.raw_data.contract[0]; + + if (contract.type === 'TransferContract') { + return this.validateTransferContract(contract, txParams); + } else { + return true; + } + } + + /** + * Validate Transfer contract (native TRX transfer) + */ + private validateTransferContract(contract: any, txParams: any): boolean { + if (!('parameter' in contract) || !contract.parameter?.value) { + throw new Error('Invalid Transfer contract structure'); + } + + const value = contract.parameter.value as ValueFields; + + // Validate amount + if (!value.amount || value.amount < 0) { + throw new Error('Invalid transfer amount'); + } + + // If txParams has recipients, validate against expected values + if (txParams.recipients && txParams.recipients.length === 1) { + const recipient = txParams.recipients[0]; + const expectedAmount = recipient.amount.toString(); + const expectedDestination = recipient.address; + const actualAmount = value.amount.toString(); + const actualDestination = Utils.getBase58AddressFromHex(value.to_address); + + if (expectedAmount !== actualAmount) { + throw new Error('transaction amount in txPrebuild does not match the value given by client'); + } + + if (expectedDestination.toLowerCase() !== actualDestination.toLowerCase()) { + throw new Error('destination address does not match with the recipient address'); + } + } + return true; } diff --git a/modules/sdk-coin-trx/test/resources.ts b/modules/sdk-coin-trx/test/resources.ts index b0d8212161..2aa7c409fa 100644 --- a/modules/sdk-coin-trx/test/resources.ts +++ b/modules/sdk-coin-trx/test/resources.ts @@ -175,8 +175,8 @@ export const DELEGATE_RESOURCE_CONTRACT = [ value: { resource: 'ENERGY', balance: 1000000, - owner_address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882', - receiver_address: '416ffedf93921506c3efdb510f7c4f256036c48a6a', + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + receiver_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', }, type_url: 'type.googleapis.com/protocol.DelegateResourceContract', }, @@ -190,8 +190,8 @@ export const UNDELEGATE_RESOURCE_CONTRACT = [ value: { resource: 'ENERGY', balance: 1000000, - owner_address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882', - receiver_address: '416ffedf93921506c3efdb510f7c4f256036c48a6a', + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + receiver_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', }, type_url: 'type.googleapis.com/protocol.UnDelegateResourceContract', }, diff --git a/modules/sdk-coin-trx/test/unit/verifyTransaction.ts b/modules/sdk-coin-trx/test/unit/verifyTransaction.ts new file mode 100644 index 0000000000..606ed8df42 --- /dev/null +++ b/modules/sdk-coin-trx/test/unit/verifyTransaction.ts @@ -0,0 +1,420 @@ +import assert from 'node:assert'; +import { createHash } from 'crypto'; +import { describe, it, before } from 'node:test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; +import { Trx, Ttrx } from '../../src'; +import { Utils } from '../../src/lib'; +import { UnsignedBuildTransaction } from '../resources'; + +describe('TRON Verify Transaction:', function () { + const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('trx', Trx.createInstance); + bitgo.safeRegister('ttrx', Ttrx.createInstance); + + let basecoin; + + before(function () { + basecoin = bitgo.coin('ttrx'); + }); + + describe('Parameter Validation', () => { + it('should throw error when txParams is missing', async function () { + const params = { + txPrebuild: { + txHex: JSON.stringify({ + ...UnsignedBuildTransaction, + raw_data: { + ...UnsignedBuildTransaction.raw_data, + expiration: Date.now() + 3600000, + timestamp: Date.now(), + }, + }), + }, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'missing txParams', + }); + }); + + it('should throw error when wallet or txPrebuild is missing', async function () { + const params = { + txParams: { + recipients: [{ address: 'TQFxDSoXy2yXRE5HtKwAwrNRXGxYxkeSGk', amount: '1000000' }], + }, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'missing txPrebuild', + }); + }); + + it('should throw error when txPrebuild.txHex is missing', async function () { + const params = { + txParams: { + recipients: [{ address: 'TQFxDSoXy2yXRE5HtKwAwrNRXGxYxkeSGk', amount: '1000000' }], + }, + txPrebuild: {}, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'missing txHex in txPrebuild', + }); + }); + }); + + describe('Contract Type Validation', () => { + describe('TransferContract', () => { + it('should validate valid TransferContract', async function () { + const timestamp = Date.now(); + const transferContract = { + parameter: { + value: { + amount: 1000000, + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + to_address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882', + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const rawData = { + contract: [transferContract], + ref_block_bytes: 'c8cf', + ref_block_hash: '89177fd84c5d9196', + expiration: timestamp + 3600000, + timestamp: timestamp, + fee_limit: 150000000, + }; + + // Transform rawData to match the expected parameter structure + const transformedRawData = { + contract: rawData.contract as any, + refBlockBytes: rawData.ref_block_bytes, + refBlockHash: rawData.ref_block_hash, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.fee_limit, + }; + + // Generate raw_data_hex using the utility function + const rawDataHex = Utils.generateRawDataHex(transformedRawData); + + // Calculate txID as SHA256 hash of raw_data_hex + const txID = createHash('sha256').update(Buffer.from(rawDataHex, 'hex')).digest('hex'); + + const params = { + txParams: { + recipients: [ + { + address: Utils.getBase58AddressFromHex('41d6cd6a2c0ff35a319e6abb5b9503ba0278679882'), + amount: '1000000', + }, + ], + }, + txPrebuild: { + txHex: JSON.stringify({ + txID, + raw_data: rawData, + raw_data_hex: rawDataHex, + }), + }, + wallet: {}, + }; + + const result = await basecoin.verifyTransaction(params); + assert.strictEqual(result, true); + }); + + it('should fail with missing owner address', async function () { + const timestamp = Date.now(); + const txID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const transferContract = { + parameter: { + value: { + amount: 1000000, + to_address: '41c25420255c2c5a2dd54ef69f92ef261e6bd4216a', + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const rawData = { + txID, + contract: [transferContract], + ref_block_bytes: 'c8cf', + ref_block_hash: '89177fd84c5d9196', + expiration: timestamp + 3600000, + timestamp: timestamp, + fee_limit: 150000000, + }; + + const transformedRawData = { + contract: rawData.contract as any, + refBlockBytes: rawData.ref_block_bytes, + refBlockHash: rawData.ref_block_hash, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.fee_limit, + }; + + const expectedRawDataHex = Utils.generateRawDataHex(transformedRawData); + + const params = { + txParams: { + recipients: [{ address: 'TLWh67P93KgtnZNCtGnEHM1H33Nhq2uvvN', amount: '1000000' }], + }, + txPrebuild: { + txHex: JSON.stringify({ + txID, + raw_data: rawData, + raw_data_hex: expectedRawDataHex, + }), + }, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'Transaction has not have a valid id', + }); + }); + + it('should fail with missing destination address', async function () { + const timestamp = Date.now(); + const txID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const transferContract = { + parameter: { + value: { + amount: 1000000, + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const rawData = { + txID, + contract: [transferContract], + ref_block_bytes: 'c8cf', + ref_block_hash: '89177fd84c5d9196', + expiration: timestamp + 3600000, + timestamp: timestamp, + fee_limit: 150000000, + }; + + const transformedRawData = { + contract: rawData.contract as any, + refBlockBytes: rawData.ref_block_bytes, + refBlockHash: rawData.ref_block_hash, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.fee_limit, + }; + + const expectedRawDataHex = Utils.generateRawDataHex(transformedRawData); + + const params = { + txParams: { + recipients: [{ address: 'TLWh67P93KgtnZNCtGnEHM1H33Nhq2uvvN', amount: '1000000' }], + }, + txPrebuild: { + txHex: JSON.stringify({ + txID, + raw_data: rawData, + raw_data_hex: expectedRawDataHex, + }), + }, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'Transaction has not have a valid id', + }); + }); + + it('should fail with missing amount', async function () { + const timestamp = Date.now(); + const transferContract = { + parameter: { + value: { + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + to_address: '41c25420255c2c5a2dd54ef69f92ef261e6bd4216a', + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const rawData = { + contract: [transferContract], + ref_block_bytes: 'c8cf', + ref_block_hash: '89177fd84c5d9196', + expiration: timestamp + 3600000, + timestamp: timestamp, + fee_limit: 150000000, + }; + + const transformedRawData = { + contract: rawData.contract as any, + refBlockBytes: rawData.ref_block_bytes, + refBlockHash: rawData.ref_block_hash, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.fee_limit, + }; + + const expectedRawDataHex = Utils.generateRawDataHex(transformedRawData); + + const params = { + txParams: { + recipients: [{ address: 'TLWh67P93KgtnZNCtGnEHM1H33Nhq2uvvN', amount: '1000000' }], + }, + txPrebuild: { + txHex: JSON.stringify({ + raw_data: rawData, + raw_data_hex: expectedRawDataHex, + }), + }, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'Amount does not exist in this transfer contract.', + }); + }); + + it('should fail due to amount missmatch', async function () { + const timestamp = Date.now(); + const transferContract = { + parameter: { + value: { + amount: 2000000, + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + to_address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882', + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const rawData = { + contract: [transferContract], + ref_block_bytes: 'c8cf', + ref_block_hash: '89177fd84c5d9196', + expiration: timestamp + 3600000, + timestamp: timestamp, + fee_limit: 150000000, + }; + + // Transform rawData to match the expected parameter structure + const transformedRawData = { + contract: rawData.contract as any, + refBlockBytes: rawData.ref_block_bytes, + refBlockHash: rawData.ref_block_hash, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.fee_limit, + }; + + // Generate raw_data_hex using the utility function + const rawDataHex = Utils.generateRawDataHex(transformedRawData); + + // Calculate txID as SHA256 hash of raw_data_hex + const txID = createHash('sha256').update(Buffer.from(rawDataHex, 'hex')).digest('hex'); + + const params = { + txParams: { + recipients: [ + { + address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882', + amount: '1000000', + }, + ], + }, + txPrebuild: { + txHex: JSON.stringify({ + txID, + raw_data: rawData, + raw_data_hex: rawDataHex, + }), + }, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'transaction amount in txPrebuild does not match the value given by client', + }); + }); + + it('should fail due to destination address missmatch', async function () { + const timestamp = Date.now(); + const transferContract = { + parameter: { + value: { + amount: 1000000, + owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5', + to_address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882', + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const rawData = { + contract: [transferContract], + ref_block_bytes: 'c8cf', + ref_block_hash: '89177fd84c5d9196', + expiration: timestamp + 3600000, + timestamp: timestamp, + fee_limit: 150000000, + }; + + // Transform rawData to match the expected parameter structure + const transformedRawData = { + contract: rawData.contract as any, + refBlockBytes: rawData.ref_block_bytes, + refBlockHash: rawData.ref_block_hash, + expiration: rawData.expiration, + timestamp: rawData.timestamp, + feeLimit: rawData.fee_limit, + }; + + // Generate raw_data_hex using the utility function + const rawDataHex = Utils.generateRawDataHex(transformedRawData); + + // Calculate txID as SHA256 hash of raw_data_hex + const txID = createHash('sha256').update(Buffer.from(rawDataHex, 'hex')).digest('hex'); + + const params = { + txParams: { + recipients: [ + { + address: '41d6cd6a2c0ff35a319e6abb5b9503ba0278679883', + amount: '1000000', + }, + ], + }, + txPrebuild: { + txHex: JSON.stringify({ + txID, + raw_data: rawData, + raw_data_hex: rawDataHex, + }), + }, + wallet: {}, + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'destination address does not match with the recipient address', + }); + }); + }); + }); +});