Skip to content

Commit 388fbec

Browse files
authored
Merge pull request #8352 from BitGo/CHALO-347
feat: added signing support for TRX MPC
2 parents 601653e + 2225221 commit 388fbec

File tree

2 files changed

+192
-6
lines changed

2 files changed

+192
-6
lines changed

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
isTssVerifyAddressOptions,
3737
} from '@bitgo/sdk-core';
3838
import { auditEcdsaPrivateKey } from '@bitgo/sdk-lib-mpc';
39-
import { Interface, Utils, WrappedBuilder, KeyPair as TronKeyPair } from './lib';
39+
import { Enum, Interface, Utils, WrappedBuilder, KeyPair as TronKeyPair } from './lib';
4040
import { ValueFields, TransactionReceipt } from './lib/iface';
4141
import { getBuilder } from './lib/builder';
4242
import { isInteger, isUndefined } from 'lodash';
@@ -364,7 +364,7 @@ export class Trx extends BaseCoin {
364364
}
365365

366366
async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
367-
const { txParams, txPrebuild } = params;
367+
const { txParams, txPrebuild, walletType } = params;
368368

369369
if (!txParams) {
370370
throw new Error('missing txParams');
@@ -378,6 +378,28 @@ export class Trx extends BaseCoin {
378378
throw new Error('missing txHex in txPrebuild');
379379
}
380380

381+
if (walletType === 'tss') {
382+
// For TSS wallets, txHex is the signableHex (raw_data_hex protobuf bytes),
383+
// not a full transaction JSON. Decode it directly via protobuf.
384+
// Note: decodeTransaction already validates exactly 1 contract exists.
385+
const decodedTx = Utils.decodeTransaction(txPrebuild.txHex);
386+
387+
// decodedTx uses a numeric enum for contract type (from protobuf decoding),
388+
// unlike the multisig path which checks the string 'TransferContract' from node JSON.
389+
if (decodedTx.contractType === Enum.ContractType.Transfer) {
390+
// For Transfer contracts, decoded contract is an array with one element.
391+
// Addresses from decodeTransaction are already in base58 format (converted by decodeTransferContract).
392+
if (!Array.isArray(decodedTx.contract) || decodedTx.contract.length !== 1) {
393+
throw new Error('Invalid Transfer contract structure.');
394+
}
395+
return this.validateTransferContract(decodedTx.contract[0], txParams, true);
396+
}
397+
398+
return true;
399+
}
400+
401+
// On-chain multisig path: txHex is a full transaction JSON string (with txID, raw_data, raw_data_hex).
402+
// The builder parses this JSON and addresses remain in hex format from the node response.
381403
const rawTx = txPrebuild.txHex;
382404
const txBuilder = getBuilder(this.getChain()).from(rawTx);
383405
const tx = await txBuilder.build();
@@ -397,9 +419,15 @@ export class Trx extends BaseCoin {
397419
}
398420

399421
/**
400-
* Validate Transfer contract (native TRX transfer)
422+
* Validate Transfer contract (native TRX transfer).
423+
* Shared by both on-chain multisig and TSS wallet verification paths.
424+
*
425+
* @param contract The contract object from the transaction
426+
* @param txParams The original transaction parameters
427+
* @param addressesInBase58 When true, addresses are already in base58 format (TSS path via protobuf decoding).
428+
* When false (default), addresses are in hex and need conversion (on-chain multisig path via builder JSON).
401429
*/
402-
private validateTransferContract(contract: any, txParams: any): boolean {
430+
private validateTransferContract(contract: any, txParams: any, addressesInBase58 = false): boolean {
403431
if (!('parameter' in contract) || !contract.parameter?.value) {
404432
throw new Error('Invalid Transfer contract structure');
405433
}
@@ -417,7 +445,7 @@ export class Trx extends BaseCoin {
417445
const expectedAmount = recipient.amount.toString();
418446
const expectedDestination = recipient.address;
419447
const actualAmount = value.amount.toString();
420-
const actualDestination = Utils.getBase58AddressFromHex(value.to_address);
448+
const actualDestination = addressesInBase58 ? value.to_address : Utils.getBase58AddressFromHex(value.to_address);
421449

422450
if (expectedAmount !== actualAmount) {
423451
throw new Error('transaction amount in txPrebuild does not match the value given by client');

modules/sdk-coin-trx/test/unit/verifyTransaction.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BitGoAPI } from '@bitgo/sdk-api';
55
import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test';
66
import { Trx, Ttrx } from '../../src';
77
import { Utils } from '../../src/lib';
8-
import { UnsignedBuildTransaction } from '../resources';
8+
import { UnsignedBuildTransaction, UnsignedAccountPermissionUpdateContractTx } from '../resources';
99

1010
describe('TRON Verify Transaction:', function () {
1111
const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' });
@@ -417,4 +417,162 @@ describe('TRON Verify Transaction:', function () {
417417
});
418418
});
419419
});
420+
421+
describe('TSS Wallet Verification', () => {
422+
/**
423+
* Helper to build a raw_data_hex (protobuf) for a TransferContract.
424+
* For TSS, txPrebuild.txHex is the raw_data_hex directly (not a JSON string).
425+
*/
426+
function buildTssTransferTxHex(params: {
427+
ownerAddress: string;
428+
toAddress: string;
429+
amount: number;
430+
timestamp?: number;
431+
expiration?: number;
432+
}): string {
433+
const timestamp = params.timestamp || Date.now();
434+
const transferContract = {
435+
parameter: {
436+
value: {
437+
amount: params.amount,
438+
owner_address: params.ownerAddress,
439+
to_address: params.toAddress,
440+
},
441+
type_url: 'type.googleapis.com/protocol.TransferContract',
442+
},
443+
type: 'TransferContract',
444+
};
445+
446+
const transformedRawData = {
447+
contract: [transferContract] as any,
448+
refBlockBytes: 'c8cf',
449+
refBlockHash: '89177fd84c5d9196',
450+
expiration: params.expiration || timestamp + 3600000,
451+
timestamp: timestamp,
452+
};
453+
454+
return Utils.generateRawDataHex(transformedRawData);
455+
}
456+
457+
describe('TransferContract', () => {
458+
it('should validate a valid TSS TransferContract', async function () {
459+
const ownerHex = '4173a5993cd182ae152adad8203163f780c65a8aa5';
460+
const toHex = '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882';
461+
const amount = 1000000;
462+
463+
const rawDataHex = buildTssTransferTxHex({ ownerAddress: ownerHex, toAddress: toHex, amount });
464+
465+
const params = {
466+
txParams: {
467+
recipients: [
468+
{
469+
// TSS path: recipients use base58 addresses
470+
address: Utils.getBase58AddressFromHex(toHex),
471+
amount: amount.toString(),
472+
},
473+
],
474+
},
475+
txPrebuild: {
476+
// TSS path: txHex is the raw protobuf hex, not a JSON string
477+
txHex: rawDataHex,
478+
},
479+
wallet: {},
480+
walletType: 'tss',
481+
};
482+
483+
const result = await basecoin.verifyTransaction(params);
484+
assert.strictEqual(result, true);
485+
});
486+
487+
it('should fail TSS verification when amount does not match', async function () {
488+
const ownerHex = '4173a5993cd182ae152adad8203163f780c65a8aa5';
489+
const toHex = '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882';
490+
491+
const rawDataHex = buildTssTransferTxHex({ ownerAddress: ownerHex, toAddress: toHex, amount: 2000000 });
492+
493+
const params = {
494+
txParams: {
495+
recipients: [
496+
{
497+
address: Utils.getBase58AddressFromHex(toHex),
498+
amount: '1000000', // mismatch: txHex has 2000000
499+
},
500+
],
501+
},
502+
txPrebuild: {
503+
txHex: rawDataHex,
504+
},
505+
wallet: {},
506+
walletType: 'tss',
507+
};
508+
509+
await assert.rejects(basecoin.verifyTransaction(params), {
510+
message: 'transaction amount in txPrebuild does not match the value given by client',
511+
});
512+
});
513+
514+
it('should fail TSS verification when destination address does not match', async function () {
515+
const ownerHex = '4173a5993cd182ae152adad8203163f780c65a8aa5';
516+
const toHex = '41d6cd6a2c0ff35a319e6abb5b9503ba0278679882';
517+
518+
const rawDataHex = buildTssTransferTxHex({ ownerAddress: ownerHex, toAddress: toHex, amount: 1000000 });
519+
520+
const params = {
521+
txParams: {
522+
recipients: [
523+
{
524+
// Different address than what's in the transaction
525+
address: 'TTsGwnTLQ4eryFJpDvJSfuGQxPXRCjXvZz',
526+
amount: '1000000',
527+
},
528+
],
529+
},
530+
txPrebuild: {
531+
txHex: rawDataHex,
532+
},
533+
wallet: {},
534+
walletType: 'tss',
535+
};
536+
537+
await assert.rejects(basecoin.verifyTransaction(params), {
538+
message: 'destination address does not match with the recipient address',
539+
});
540+
});
541+
});
542+
543+
it('should return true for non-Transfer contract types in TSS', async function () {
544+
// For non-Transfer contracts (e.g., AccountPermissionUpdate), TSS path returns true
545+
// without detailed validation.
546+
const rawDataHex = UnsignedAccountPermissionUpdateContractTx.raw_data_hex;
547+
548+
const params = {
549+
txParams: {
550+
recipients: [],
551+
},
552+
txPrebuild: {
553+
txHex: rawDataHex,
554+
},
555+
wallet: {},
556+
walletType: 'tss',
557+
};
558+
559+
const result = await basecoin.verifyTransaction(params);
560+
assert.strictEqual(result, true);
561+
});
562+
563+
it('should throw error when txHex is missing for TSS wallet', async function () {
564+
const params = {
565+
txParams: {
566+
recipients: [{ address: 'TQFxDSoXy2yXRE5HtKwAwrNRXGxYxkeSGk', amount: '1000000' }],
567+
},
568+
txPrebuild: {},
569+
wallet: {},
570+
walletType: 'tss',
571+
};
572+
573+
await assert.rejects(basecoin.verifyTransaction(params), {
574+
message: 'missing txHex in txPrebuild',
575+
});
576+
});
577+
});
420578
});

0 commit comments

Comments
 (0)