Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion modules/sdk-coin-trx/src/trx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -822,6 +823,10 @@ export class Trx extends BaseCoin {
* @param params
*/
async recover(params: RecoveryOptions): Promise<RecoveryTransaction> {
if (params.isTss) {
return this.recoverTss(params);
}

const isKrsRecovery = getIsKrsRecovery(params);
const isUnsignedSweep = getIsUnsignedSweep(params);

Expand Down Expand Up @@ -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<RecoveryTransaction> {
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.
Expand Down
27 changes: 27 additions & 0 deletions modules/sdk-coin-trx/test/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
192 changes: 192 additions & 0 deletions modules/sdk-coin-trx/test/unit/trx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
SampleRawTokenSendTxn,
receiveAddressBalance,
TestRecoverData,
TssTestRecoverData,
creationTransaction,
} from '../resources';

Expand Down Expand Up @@ -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({
Expand Down
Loading