From 2225221d03ff5353e6535c8ab6fb7365218c81e7 Mon Sep 17 00:00:00 2001 From: Bhuvan R Date: Thu, 26 Mar 2026 14:09:07 +0530 Subject: [PATCH] feat: added signing support for TRX MPC TICKET: CHALO-347 --- modules/sdk-coin-trx/src/trx.ts | 38 ++++- .../test/unit/verifyTransaction.ts | 160 +++++++++++++++++- 2 files changed, 192 insertions(+), 6 deletions(-) diff --git a/modules/sdk-coin-trx/src/trx.ts b/modules/sdk-coin-trx/src/trx.ts index dfc13007c8..7096e3836c 100644 --- a/modules/sdk-coin-trx/src/trx.ts +++ b/modules/sdk-coin-trx/src/trx.ts @@ -36,7 +36,7 @@ import { isTssVerifyAddressOptions, } from '@bitgo/sdk-core'; import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc'; -import { Interface, Utils, WrappedBuilder, KeyPair as TronKeyPair } from './lib'; +import { Enum, Interface, Utils, WrappedBuilder, KeyPair as TronKeyPair } from './lib'; import { ValueFields, TransactionReceipt } from './lib/iface'; import { getBuilder } from './lib/builder'; import { isInteger, isUndefined } from 'lodash'; @@ -364,7 +364,7 @@ export class Trx extends BaseCoin { } async verifyTransaction(params: VerifyTransactionOptions): Promise { - const { txParams, txPrebuild } = params; + const { txParams, txPrebuild, walletType } = params; if (!txParams) { throw new Error('missing txParams'); @@ -378,6 +378,28 @@ export class Trx extends BaseCoin { throw new Error('missing txHex in txPrebuild'); } + if (walletType === 'tss') { + // For TSS wallets, txHex is the signableHex (raw_data_hex protobuf bytes), + // not a full transaction JSON. Decode it directly via protobuf. + // Note: decodeTransaction already validates exactly 1 contract exists. + const decodedTx = Utils.decodeTransaction(txPrebuild.txHex); + + // decodedTx uses a numeric enum for contract type (from protobuf decoding), + // unlike the multisig path which checks the string 'TransferContract' from node JSON. + if (decodedTx.contractType === Enum.ContractType.Transfer) { + // For Transfer contracts, decoded contract is an array with one element. + // Addresses from decodeTransaction are already in base58 format (converted by decodeTransferContract). + if (!Array.isArray(decodedTx.contract) || decodedTx.contract.length !== 1) { + throw new Error('Invalid Transfer contract structure.'); + } + return this.validateTransferContract(decodedTx.contract[0], txParams, true); + } + + return true; + } + + // On-chain multisig path: txHex is a full transaction JSON string (with txID, raw_data, raw_data_hex). + // The builder parses this JSON and addresses remain in hex format from the node response. const rawTx = txPrebuild.txHex; const txBuilder = getBuilder(this.getChain()).from(rawTx); const tx = await txBuilder.build(); @@ -397,9 +419,15 @@ export class Trx extends BaseCoin { } /** - * Validate Transfer contract (native TRX transfer) + * Validate Transfer contract (native TRX transfer). + * Shared by both on-chain multisig and TSS wallet verification paths. + * + * @param contract The contract object from the transaction + * @param txParams The original transaction parameters + * @param addressesInBase58 When true, addresses are already in base58 format (TSS path via protobuf decoding). + * When false (default), addresses are in hex and need conversion (on-chain multisig path via builder JSON). */ - private validateTransferContract(contract: any, txParams: any): boolean { + private validateTransferContract(contract: any, txParams: any, addressesInBase58 = false): boolean { if (!('parameter' in contract) || !contract.parameter?.value) { throw new Error('Invalid Transfer contract structure'); } @@ -417,7 +445,7 @@ export class Trx extends BaseCoin { const expectedAmount = recipient.amount.toString(); const expectedDestination = recipient.address; const actualAmount = value.amount.toString(); - const actualDestination = Utils.getBase58AddressFromHex(value.to_address); + const actualDestination = addressesInBase58 ? value.to_address : Utils.getBase58AddressFromHex(value.to_address); if (expectedAmount !== actualAmount) { throw new Error('transaction amount in txPrebuild does not match the value given by client'); diff --git a/modules/sdk-coin-trx/test/unit/verifyTransaction.ts b/modules/sdk-coin-trx/test/unit/verifyTransaction.ts index 606ed8df42..c25becdacf 100644 --- a/modules/sdk-coin-trx/test/unit/verifyTransaction.ts +++ b/modules/sdk-coin-trx/test/unit/verifyTransaction.ts @@ -5,7 +5,7 @@ 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'; +import { UnsignedBuildTransaction, UnsignedAccountPermissionUpdateContractTx } from '../resources'; describe('TRON Verify Transaction:', function () { const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' }); @@ -417,4 +417,162 @@ describe('TRON Verify Transaction:', function () { }); }); }); + + describe('TSS Wallet Verification', () => { + /** + * Helper to build a raw_data_hex (protobuf) for a TransferContract. + * For TSS, txPrebuild.txHex is the raw_data_hex directly (not a JSON string). + */ + function buildTssTransferTxHex(params: { + ownerAddress: string; + toAddress: string; + amount: number; + timestamp?: number; + expiration?: number; + }): string { + const timestamp = params.timestamp || Date.now(); + const transferContract = { + parameter: { + value: { + amount: params.amount, + owner_address: params.ownerAddress, + to_address: params.toAddress, + }, + type_url: 'type.googleapis.com/protocol.TransferContract', + }, + type: 'TransferContract', + }; + + const transformedRawData = { + contract: [transferContract] as any, + refBlockBytes: 'c8cf', + refBlockHash: '89177fd84c5d9196', + expiration: params.expiration || timestamp + 3600000, + timestamp: timestamp, + }; + + return Utils.generateRawDataHex(transformedRawData); + } + + describe('TransferContract', () => { + it('should validate a valid TSS TransferContract', async function () { + const ownerHex = '4173a5993cd182ae152adad8203163f780c65a8aa5'; + const toHex = '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882'; + const amount = 1000000; + + const rawDataHex = buildTssTransferTxHex({ ownerAddress: ownerHex, toAddress: toHex, amount }); + + const params = { + txParams: { + recipients: [ + { + // TSS path: recipients use base58 addresses + address: Utils.getBase58AddressFromHex(toHex), + amount: amount.toString(), + }, + ], + }, + txPrebuild: { + // TSS path: txHex is the raw protobuf hex, not a JSON string + txHex: rawDataHex, + }, + wallet: {}, + walletType: 'tss', + }; + + const result = await basecoin.verifyTransaction(params); + assert.strictEqual(result, true); + }); + + it('should fail TSS verification when amount does not match', async function () { + const ownerHex = '4173a5993cd182ae152adad8203163f780c65a8aa5'; + const toHex = '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882'; + + const rawDataHex = buildTssTransferTxHex({ ownerAddress: ownerHex, toAddress: toHex, amount: 2000000 }); + + const params = { + txParams: { + recipients: [ + { + address: Utils.getBase58AddressFromHex(toHex), + amount: '1000000', // mismatch: txHex has 2000000 + }, + ], + }, + txPrebuild: { + txHex: rawDataHex, + }, + wallet: {}, + walletType: 'tss', + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'transaction amount in txPrebuild does not match the value given by client', + }); + }); + + it('should fail TSS verification when destination address does not match', async function () { + const ownerHex = '4173a5993cd182ae152adad8203163f780c65a8aa5'; + const toHex = '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882'; + + const rawDataHex = buildTssTransferTxHex({ ownerAddress: ownerHex, toAddress: toHex, amount: 1000000 }); + + const params = { + txParams: { + recipients: [ + { + // Different address than what's in the transaction + address: 'TTsGwnTLQ4eryFJpDvJSfuGQxPXRCjXvZz', + amount: '1000000', + }, + ], + }, + txPrebuild: { + txHex: rawDataHex, + }, + wallet: {}, + walletType: 'tss', + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'destination address does not match with the recipient address', + }); + }); + }); + + it('should return true for non-Transfer contract types in TSS', async function () { + // For non-Transfer contracts (e.g., AccountPermissionUpdate), TSS path returns true + // without detailed validation. + const rawDataHex = UnsignedAccountPermissionUpdateContractTx.raw_data_hex; + + const params = { + txParams: { + recipients: [], + }, + txPrebuild: { + txHex: rawDataHex, + }, + wallet: {}, + walletType: 'tss', + }; + + const result = await basecoin.verifyTransaction(params); + assert.strictEqual(result, true); + }); + + it('should throw error when txHex is missing for TSS wallet', async function () { + const params = { + txParams: { + recipients: [{ address: 'TQFxDSoXy2yXRE5HtKwAwrNRXGxYxkeSGk', amount: '1000000' }], + }, + txPrebuild: {}, + wallet: {}, + walletType: 'tss', + }; + + await assert.rejects(basecoin.verifyTransaction(params), { + message: 'missing txHex in txPrebuild', + }); + }); + }); });