diff --git a/modules/sdk-coin-trx/src/trx.ts b/modules/sdk-coin-trx/src/trx.ts index e3bc919b3a..507ee73bdd 100644 --- a/modules/sdk-coin-trx/src/trx.ts +++ b/modules/sdk-coin-trx/src/trx.ts @@ -87,13 +87,14 @@ export interface ExplainTransactionOptions { export interface RecoveryOptions { userKey: string; // Box A backupKey: string; // Box B - bitgoKey: string; // Box C - this is bitgo's xpub and will be used to derive their root address + bitgoKey: string; // Box C - for multisig: BitGo's xpub; for TSS: the commonKeychain (compressed secp256k1 hex) recoveryDestination: string; // base58 address krsProvider?: string; tokenContractAddress?: string; walletPassphrase?: string; startingScanIndex?: number; scan?: number; + isTss?: boolean; } export interface ConsolidationRecoveryOptions { @@ -822,6 +823,10 @@ export class Trx extends BaseCoin { * @param params */ async recover(params: RecoveryOptions): Promise { + if (params.isTss) { + return this.recoverTss(params); + } + const isKrsRecovery = getIsKrsRecovery(params); const isUnsignedSweep = getIsUnsignedSweep(params); @@ -1012,6 +1017,114 @@ export class Trx extends BaseCoin { return this.formatForOfflineVault(txSigned, SAFE_TRON_TRANSACTION_FEE, recoveryAmountMinusFees, addressInfo); } + /** + * Builds a TSS (MPC/ECDSA) funds recovery transaction without BitGo. + * TSS wallets are single-address (no receive address scanning) and use a single-key signing model + * (no Tron on-chain multisig permission structure). + * + * For unsigned sweeps: pass xpub-format userKey/backupKey so getIsUnsignedSweep() returns true. + * For signed recovery: pass encrypted userKey JSON and walletPassphrase; the decrypted value + * is a raw secp256k1 private key hex (not a BIP32 xprv). + * + * @param params - RecoveryOptions with isTss: true. bitgoKey is the commonKeychain + * (compressed secp256k1 public key hex, 66 chars). + */ + private async recoverTss(params: RecoveryOptions): Promise { + const isUnsignedSweep = getIsUnsignedSweep(params); + + if (!this.isValidAddress(params.recoveryDestination)) { + throw new Error('Invalid destination address!'); + } + + // Derive wallet address from commonKeychain using same method as verifyMPCWalletAddress + const walletAddress = new TronKeyPair({ pub: params.bitgoKey }).getAddress(); + const walletAddressHex = Utils.getHexAddressFromBase58Address(walletAddress); + const recoveryToAddressHex = Utils.getHexAddressFromBase58Address(params.recoveryDestination); + + const account = await this.getAccountBalancesFromNode(walletAddress); + + if (!account.data[0]) { + throw new Error(`Account ${walletAddress} not found or has no data`); + } + + const tokenContractAddr = params.tokenContractAddress; + + if (tokenContractAddr) { + let rawTokenTxn: any | undefined; + let recoveryAmount = 0; + + for (const token of account.data[0].trc20) { + if (token[tokenContractAddr]) { + const amount = token[tokenContractAddr]; + const tokenContractAddrHex = Utils.getHexAddressFromBase58Address(tokenContractAddr); + rawTokenTxn = ( + await this.getTriggerSmartContractTransaction( + recoveryToAddressHex, + walletAddressHex, + amount, + tokenContractAddrHex + ) + ).transaction; + recoveryAmount = parseInt(amount, 10); + break; + } + } + + if (!rawTokenTxn) { + throw new Error('Not found token to recover, please check token balance'); + } + + const trxBalance = account.data[0].balance; + if (trxBalance < SAFE_TRON_TOKEN_TRANSACTION_FEE) { + throw new Error( + `Amount of funds to recover ${trxBalance} is less than ${SAFE_TRON_TOKEN_TRANSACTION_FEE} and wouldn't be able to fund a trc20 send` + ); + } + + const txBuilder = getBuilder(this.getChain()).from(rawTokenTxn); + txBuilder.extendValidTo(RECOVER_TRANSACTION_EXPIRY); + + if (isUnsignedSweep) { + return this.formatForOfflineVault(await txBuilder.build(), SAFE_TRON_TOKEN_TRANSACTION_FEE, recoveryAmount); + } + + const userPrvHex = await this.bitgo.decryptAsync({ + password: params.walletPassphrase!, + input: params.userKey, + }); + txBuilder.sign({ key: userPrvHex }); + return this.formatForOfflineVault(await txBuilder.build(), SAFE_TRON_TOKEN_TRANSACTION_FEE, recoveryAmount); + } + + // Native TRX recovery + const recoveryAmount = account.data[0].balance; + + if (!recoveryAmount || SAFE_TRON_TRANSACTION_FEE >= recoveryAmount) { + throw new Error( + `Amount of funds to recover ${recoveryAmount} is less than ${SAFE_TRON_TRANSACTION_FEE} and wouldn't be able to fund a send` + ); + } + + const recoveryAmountMinusFees = recoveryAmount - SAFE_TRON_TRANSACTION_FEE; + const buildTx = await this.getBuildTransaction(recoveryToAddressHex, walletAddressHex, recoveryAmountMinusFees); + + const txBuilder = (getBuilder(this.getChain()) as WrappedBuilder).from(buildTx); + txBuilder.extendValidTo(RECOVER_TRANSACTION_EXPIRY); + const tx = await txBuilder.build(); + + if (isUnsignedSweep) { + return this.formatForOfflineVault(tx, SAFE_TRON_TRANSACTION_FEE, recoveryAmountMinusFees); + } + + const userPrvHex = await this.bitgo.decryptAsync({ + password: params.walletPassphrase!, + input: params.userKey, + }); + txBuilder.sign({ key: userPrvHex }); + const txSigned = await txBuilder.build(); + return this.formatForOfflineVault(txSigned, SAFE_TRON_TRANSACTION_FEE, recoveryAmountMinusFees); + } + /** * Builds native TRX recoveries of receive addresses in batch without BitGo. * Funds will be recovered to base address first. You need to initiate another sweep txn after that. diff --git a/modules/sdk-coin-trx/test/resources.ts b/modules/sdk-coin-trx/test/resources.ts index 1b0e16bce4..9a477c0761 100644 --- a/modules/sdk-coin-trx/test/resources.ts +++ b/modules/sdk-coin-trx/test/resources.ts @@ -238,6 +238,33 @@ export const TestRecoverData = { recoveryDestination: 'TWkzN4WjxkyoRTmFHaMQ9po77uEerngjyQ', }; +/** + * TSS recovery test data. + * + * bitgoKey is a compressed secp256k1 public key (66 hex chars) used as the commonKeychain. + * The wallet address is derived from it via new TronKeyPair({ pub: bitgoKey }).getAddress(). + * userPrvKey is the raw secp256k1 private key hex that the encrypted userKey decrypts to. + * userKey is a placeholder encrypted blob; tests mock decryptAsync to return userPrvKey directly. + */ +export const TssTestRecoverData = { + // compressed pub of PARTICIPANTS.from.pk — used as commonKeychain (bitgoKey) + bitgoKey: '03f130498035202e4fb73ff376e05817a935de2af8b3a2f12633b1926e47255b13', + // Tron address derived from bitgoKey via new TronKeyPair({ pub: bitgoKey }).getAddress() + walletAddress: 'TVYygaQGXRKvc4GBeDXBWh4FRDVSdTeCua', + // placeholder xpub strings that make getIsUnsignedSweep() return true + userKey: + 'xpub6BvMpt8ke8tCycBBw6uDob6PyNBkHbTyEztaRuwdMZhpiFk1mXpS7P7iv4c4w7XWFFRySMokUuFUqqgpZxK5wLxm6pgjpkNFhKsMaXTJoUN', + backupKey: + 'xpub687kC8LeSJwj1gYQr4Js2BHbLK1nFeLvMzsDmH2LKMNrqAHNfeCw1sp61cbf2WxeY1QssaUBh9EFJbJ9LBuPivv7XDsFPVaFYj19ueCNczT', + // encrypted userKey JSON (placeholder — tests mock decryptAsync) + encryptedUserKey: + '{"iv":"abc","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","ct":"xyz"}', + walletPassphrase: 'testpassphrase123', + // raw secp256k1 private key hex returned when decryptAsync is called (PARTICIPANTS.custodian.pk) + userPrvKey: 'c4b3a04836efc2ee2917235f55ccfb2dcf6b8341e5ea0405da5ba10cd526dfed', + recoveryDestination: 'TWkzN4WjxkyoRTmFHaMQ9po77uEerngjyQ', +}; + export function baseAddressBalance(trxBalance: number, trc20Balances: any[] = []) { return { data: [ diff --git a/modules/sdk-coin-trx/test/unit/trx.ts b/modules/sdk-coin-trx/test/unit/trx.ts index e54d3c1558..6ef3e9eb35 100644 --- a/modules/sdk-coin-trx/test/unit/trx.ts +++ b/modules/sdk-coin-trx/test/unit/trx.ts @@ -10,6 +10,7 @@ import { SampleRawTokenSendTxn, receiveAddressBalance, TestRecoverData, + TssTestRecoverData, creationTransaction, } from '../resources'; @@ -634,6 +635,197 @@ describe('TRON:', function () { }); }); + describe('TSS Recovery', () => { + afterEach(() => { + mock.reset(); + }); + + const walletAddrHex = Utils.getHexAddressFromBase58Address(TssTestRecoverData.walletAddress); + const destinationHex = Utils.getHexAddressFromBase58Address(TssTestRecoverData.recoveryDestination); + + it('should recover TRX from TSS wallet (unsigned sweep)', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve(baseAddressBalance(3000000)); + }); + + mock.method(Trx.prototype as any, 'getBuildTransaction', (...args) => { + if (args[0] === destinationHex && args[1] === walletAddrHex && args[2] === 900000) { + return Promise.resolve(creationTransaction(walletAddrHex, destinationHex, 900000)); + } + return undefined; + }); + + const res = await basecoin.recover({ + userKey: TssTestRecoverData.userKey, + backupKey: TssTestRecoverData.backupKey, + bitgoKey: TssTestRecoverData.bitgoKey, + recoveryDestination: TssTestRecoverData.recoveryDestination, + isTss: true, + }); + + assert.ok(Object.prototype.hasOwnProperty.call(res, 'txHex')); + assert.ok(Object.prototype.hasOwnProperty.call(res, 'feeInfo')); + assert.equal(res.recoveryAmount, 900000); + const rawData = JSON.parse(res.txHex).raw_data; + assert.ok(Object.prototype.hasOwnProperty.call(rawData, 'contract')); + const value = rawData.contract[0].parameter.value; + assert.equal(value.amount, 900000); + assert.equal(Utils.getBase58AddressFromHex(value.owner_address), TssTestRecoverData.walletAddress); + assert.equal(Utils.getBase58AddressFromHex(value.to_address), TssTestRecoverData.recoveryDestination); + // unsigned sweep: no signatures + const sig = JSON.parse(res.txHex).signature; + assert.ok(!sig || sig.length === 0); + }); + + it('should recover TRX from TSS wallet (signed recovery)', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve(baseAddressBalance(3000000)); + }); + + mock.method(Trx.prototype as any, 'getBuildTransaction', (...args) => { + if (args[0] === destinationHex && args[1] === walletAddrHex && args[2] === 900000) { + return Promise.resolve(creationTransaction(walletAddrHex, destinationHex, 900000)); + } + return undefined; + }); + + mock.method(bitgo as any, 'decryptAsync', () => { + return Promise.resolve(TssTestRecoverData.userPrvKey); + }); + + const res = await basecoin.recover({ + userKey: TssTestRecoverData.encryptedUserKey, + backupKey: TssTestRecoverData.encryptedUserKey, + bitgoKey: TssTestRecoverData.bitgoKey, + recoveryDestination: TssTestRecoverData.recoveryDestination, + walletPassphrase: TssTestRecoverData.walletPassphrase, + isTss: true, + }); + + assert.ok(Object.prototype.hasOwnProperty.call(res, 'txHex')); + assert.ok(Object.prototype.hasOwnProperty.call(res, 'feeInfo')); + assert.equal(res.recoveryAmount, 900000); + const rawData = JSON.parse(res.txHex).raw_data; + const value = rawData.contract[0].parameter.value; + assert.equal(value.amount, 900000); + assert.equal(Utils.getBase58AddressFromHex(value.owner_address), TssTestRecoverData.walletAddress); + assert.equal(Utils.getBase58AddressFromHex(value.to_address), TssTestRecoverData.recoveryDestination); + // signed recovery: should have exactly 1 signature + const sig = JSON.parse(res.txHex).signature; + assert.equal(sig.length, 1); + }); + + it('should recover TRC-20 token from TSS wallet (unsigned sweep)', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve( + baseAddressBalance(100000000, [ + { + TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs: '1100000000', + }, + ]) + ); + }); + + mock.method(Trx.prototype as any, 'getTriggerSmartContractTransaction', () => { + return Promise.resolve(SampleRawTokenSendTxn); + }); + + const res = await basecoin.recover({ + userKey: TssTestRecoverData.userKey, + backupKey: TssTestRecoverData.backupKey, + bitgoKey: TssTestRecoverData.bitgoKey, + tokenContractAddress: 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + recoveryDestination: TssTestRecoverData.recoveryDestination, + isTss: true, + }); + + assert.ok(Object.prototype.hasOwnProperty.call(res, 'txHex')); + assert.equal(res.recoveryAmount, 1100000000); + assert.equal(res.feeInfo.fee, '100000000'); + const expirationDuration = res.tx.raw_data.expiration - res.tx.raw_data.timestamp; + assert.ok(expirationDuration >= 86400000); + // unsigned sweep: no signatures + const sig = JSON.parse(res.txHex).signature; + assert.ok(!sig || sig.length === 0); + }); + + it('should recover TRC-20 token from TSS wallet (signed recovery)', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve( + baseAddressBalance(100000000, [ + { + TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs: '1100000000', + }, + ]) + ); + }); + + mock.method(Trx.prototype as any, 'getTriggerSmartContractTransaction', () => { + return Promise.resolve(SampleRawTokenSendTxn); + }); + + mock.method(bitgo as any, 'decryptAsync', () => { + return Promise.resolve(TssTestRecoverData.userPrvKey); + }); + + const res = await basecoin.recover({ + userKey: TssTestRecoverData.encryptedUserKey, + backupKey: TssTestRecoverData.encryptedUserKey, + bitgoKey: TssTestRecoverData.bitgoKey, + tokenContractAddress: 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + recoveryDestination: TssTestRecoverData.recoveryDestination, + walletPassphrase: TssTestRecoverData.walletPassphrase, + isTss: true, + }); + + assert.ok(Object.prototype.hasOwnProperty.call(res, 'txHex')); + assert.equal(res.recoveryAmount, 1100000000); + assert.equal(res.feeInfo.fee, '100000000'); + // signed recovery: should have exactly 1 signature + const sig = JSON.parse(res.txHex).signature; + assert.equal(sig.length, 1); + }); + + it('should throw if TSS wallet has insufficient balance', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve(baseAddressBalance(1000000)); // less than SAFE_TRON_TRANSACTION_FEE (2.1e6) + }); + + await assert.rejects( + basecoin.recover({ + userKey: TssTestRecoverData.userKey, + backupKey: TssTestRecoverData.backupKey, + bitgoKey: TssTestRecoverData.bitgoKey, + recoveryDestination: TssTestRecoverData.recoveryDestination, + isTss: true, + }), + { + message: "Amount of funds to recover 1000000 is less than 2100000 and wouldn't be able to fund a send", + } + ); + }); + + it('should throw if TRC-20 token not found for TSS wallet', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve(baseAddressBalance(100000000, [])); + }); + + await assert.rejects( + basecoin.recover({ + userKey: TssTestRecoverData.userKey, + backupKey: TssTestRecoverData.backupKey, + bitgoKey: TssTestRecoverData.bitgoKey, + tokenContractAddress: 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + recoveryDestination: TssTestRecoverData.recoveryDestination, + isTss: true, + }), + { + message: 'Not found token to recover, please check token balance', + } + ); + }); + }); + describe('isWalletAddress', () => { it('should verify root address (index 0)', async function () { const result = await basecoin.isWalletAddress({