Skip to content

Commit ec96bcb

Browse files
feat(sdk-coin-ton): add jetton recovery support
TICKET: COIN-6203
1 parent 6c62fc2 commit ec96bcb

File tree

2 files changed

+195
-66
lines changed

2 files changed

+195
-66
lines changed

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

Lines changed: 121 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ import {
2020
EDDSAMethodTypes,
2121
MPCRecoveryOptions,
2222
MPCTx,
23-
MPCUnsignedTx,
24-
RecoveryTxRequest,
2523
OvcInput,
2624
OvcOutput,
2725
Environments,
@@ -35,7 +33,7 @@ import {
3533
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
3634
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
3735
import { KeyPair as TonKeyPair } from './lib/keyPair';
38-
import { TransactionBuilderFactory, Utils, TransferBuilder } from './lib';
36+
import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib';
3937
import { getFeeEstimate } from './lib/utils';
4038

4139
export interface TonParseTransactionOptions extends ParseTransactionOptions {
@@ -273,7 +271,9 @@ export class Ton extends BaseCoin {
273271
return new TransactionBuilderFactory(coins.get(this.getChain()));
274272
}
275273

276-
async recover(params: MPCRecoveryOptions): Promise<MPCTx | MPCSweepTxs> {
274+
async recover(
275+
params: MPCRecoveryOptions & { jettonMaster?: string; senderJettonAddress?: string }
276+
): Promise<MPCTx | MPCSweepTxs> {
277277
if (!params.bitgoKey) {
278278
throw new Error('missing bitgoKey');
279279
}
@@ -295,50 +295,106 @@ export class Ton extends BaseCoin {
295295
const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64);
296296
const senderAddr = await Utils.default.getAddressFromPublicKey(accountId);
297297
const balance = await tonweb.getBalance(senderAddr);
298-
if (new BigNumber(balance).isEqualTo(0)) {
299-
throw Error('Did not find address with funds to recover');
298+
299+
const jettonBalances: { minterAddress?: string; walletAddress: string; balance: string }[] = [];
300+
if (params.senderJettonAddress) {
301+
try {
302+
const jettonWalletData = await tonweb.provider.call(params.senderJettonAddress, 'get_wallet_data');
303+
const jettonBalance = jettonWalletData.stack[0][1];
304+
if (jettonBalance && new BigNumber(jettonBalance).gt(0)) {
305+
jettonBalances.push({
306+
walletAddress: params.senderJettonAddress,
307+
balance: jettonBalance,
308+
});
309+
}
310+
} catch (e) {
311+
throw new Error(`Failed to query jetton balance for address ${params.senderJettonAddress}: ${e.message}`);
312+
}
300313
}
301314

302315
const WalletClass = tonweb.wallet.all['v4R2'];
303316
const wallet = new WalletClass(tonweb.provider, {
304317
publicKey: tonweb.utils.hexToBytes(accountId),
305318
wc: 0,
306319
});
307-
let seqno = await wallet.methods.seqno().call();
308-
if (seqno === null) {
309-
seqno = 0;
310-
}
320+
const seqnoResult = await wallet.methods.seqno().call();
321+
const seqno: number = seqnoResult !== null && seqnoResult !== undefined ? seqnoResult : 0;
311322

312-
const feeEstimate = await getFeeEstimate(wallet, params.recoveryDestination, balance, seqno as number);
323+
const factory = this.getBuilder();
324+
const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7;
325+
326+
let txBuilder: TransactionBuilder;
327+
let unsignedTransaction: any;
328+
let feeEstimate: number;
329+
let transactionType: 'ton' | 'jetton';
330+
331+
if ((params.jettonMaster || params.senderJettonAddress) && jettonBalances.length > 0) {
332+
const jettonInfo = jettonBalances[0];
333+
const tonAmount = '50000000';
334+
const forwardTonAmount = '1';
335+
336+
const totalRequiredTon = new BigNumber(tonAmount).plus(new BigNumber(forwardTonAmount));
337+
if (new BigNumber(balance).lt(totalRequiredTon)) {
338+
throw new Error(
339+
`Insufficient TON balance for jetton transfer. Required: ${totalRequiredTon.toString()} nanoTON, Available: ${balance}`
340+
);
341+
}
313342

314-
const totalFeeEstimate = Math.round(
315-
(feeEstimate.source_fees.in_fwd_fee +
316-
feeEstimate.source_fees.storage_fee +
317-
feeEstimate.source_fees.gas_fee +
318-
feeEstimate.source_fees.fwd_fee) *
319-
1.5
320-
);
343+
txBuilder = factory
344+
.getTokenTransferBuilder()
345+
.sender(senderAddr)
346+
.sequenceNumber(seqno)
347+
.publicKey(accountId)
348+
.expireTime(expireAt);
349+
350+
(txBuilder as TokenTransferBuilder).recipient(
351+
params.recoveryDestination,
352+
jettonInfo.walletAddress,
353+
tonAmount,
354+
jettonInfo.balance,
355+
forwardTonAmount
356+
);
321357

322-
if (new BigNumber(totalFeeEstimate).gt(balance)) {
323-
throw Error('Did not find address with funds to recover');
324-
}
358+
unsignedTransaction = await txBuilder.build();
359+
feeEstimate = parseInt(tonAmount, 10);
360+
transactionType = 'jetton';
361+
} else {
362+
if (new BigNumber(balance).isEqualTo(0)) {
363+
throw Error('Did not find address with TON balance to recover');
364+
}
325365

326-
const factory = this.getBuilder();
327-
const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7; // 7 days
328-
329-
const txBuilder = factory
330-
.getTransferBuilder()
331-
.sender(senderAddr)
332-
.sequenceNumber(seqno as number)
333-
.publicKey(accountId)
334-
.expireTime(expireAt);
335-
336-
(txBuilder as TransferBuilder).send({
337-
address: params.recoveryDestination,
338-
amount: new BigNumber(balance).minus(new BigNumber(totalFeeEstimate)).toString(),
339-
});
366+
const tonFeeEstimate = await getFeeEstimate(wallet, params.recoveryDestination, balance, seqno as number);
367+
368+
const totalFeeEstimate = Math.round(
369+
(tonFeeEstimate.source_fees.in_fwd_fee +
370+
tonFeeEstimate.source_fees.storage_fee +
371+
tonFeeEstimate.source_fees.gas_fee +
372+
tonFeeEstimate.source_fees.fwd_fee) *
373+
1.5
374+
);
340375

341-
const unsignedTransaction = await txBuilder.build();
376+
if (new BigNumber(totalFeeEstimate).gte(balance)) {
377+
throw new Error(
378+
`Insufficient TON balance for transaction. Required: ${totalFeeEstimate} nanoTON, Available: ${balance}`
379+
);
380+
}
381+
382+
txBuilder = factory
383+
.getTransferBuilder()
384+
.sender(senderAddr)
385+
.sequenceNumber(seqno)
386+
.publicKey(accountId)
387+
.expireTime(expireAt);
388+
389+
(txBuilder as TransferBuilder).send({
390+
address: params.recoveryDestination,
391+
amount: new BigNumber(balance).minus(new BigNumber(totalFeeEstimate)).toString(),
392+
});
393+
394+
unsignedTransaction = await txBuilder.build();
395+
feeEstimate = totalFeeEstimate;
396+
transactionType = 'ton';
397+
}
342398

343399
if (!isUnsignedSweep) {
344400
if (!params.userKey) {
@@ -389,31 +445,33 @@ export class Ton extends BaseCoin {
389445
txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex);
390446
}
391447

392-
const completedTransaction = await txBuilder.build();
393-
const serializedTx = completedTransaction.toBroadcastFormat();
394448
const walletCoin = this.getChain();
395-
396-
const inputs: OvcInput[] = [];
397-
for (const input of completedTransaction.inputs) {
398-
inputs.push({
399-
address: input.address,
400-
valueString: input.value,
401-
value: new BigNumber(input.value).toNumber(),
402-
});
403-
}
404-
const outputs: OvcOutput[] = [];
405-
for (const output of completedTransaction.outputs) {
406-
outputs.push({
407-
address: output.address,
408-
valueString: output.value,
409-
coinName: output.coin,
410-
});
411-
}
412-
const spendAmount = completedTransaction.inputs.length === 1 ? completedTransaction.inputs[0].value : 0;
413-
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' };
414-
const feeInfo = { fee: totalFeeEstimate, feeString: totalFeeEstimate.toString() };
415449
const coinSpecific = { commonKeychain: bitgoKey };
450+
416451
if (isUnsignedSweep) {
452+
const completedTransaction = await txBuilder.build();
453+
const serializedTx = completedTransaction.toBroadcastFormat();
454+
455+
const inputs: OvcInput[] = [];
456+
for (const input of completedTransaction.inputs) {
457+
inputs.push({
458+
address: input.address,
459+
valueString: input.value,
460+
value: new BigNumber(input.value).toNumber(),
461+
});
462+
}
463+
const outputs: OvcOutput[] = [];
464+
for (const output of completedTransaction.outputs) {
465+
outputs.push({
466+
address: output.address,
467+
valueString: output.value,
468+
coinName: output.coin,
469+
});
470+
}
471+
const spendAmount = completedTransaction.inputs.length === 1 ? completedTransaction.inputs[0].value : 0;
472+
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: transactionType };
473+
const feeInfo = { fee: feeEstimate, feeString: feeEstimate.toString() };
474+
417475
const transaction: MPCTx = {
418476
serializedTx: serializedTx,
419477
scanIndex: index,
@@ -424,16 +482,13 @@ export class Ton extends BaseCoin {
424482
feeInfo: feeInfo,
425483
coinSpecific: coinSpecific,
426484
};
427-
const unsignedTx: MPCUnsignedTx = { unsignedTx: transaction, signatureShares: [] };
428-
const transactions: MPCUnsignedTx[] = [unsignedTx];
429-
const txRequest: RecoveryTxRequest = {
430-
transactions: transactions,
431-
walletCoin: walletCoin,
432-
};
433-
const txRequests: MPCSweepTxs = { txRequests: [txRequest] };
434-
return txRequests;
485+
486+
return transaction;
435487
}
436488

489+
const completedTransaction = await txBuilder.build();
490+
const serializedTx = completedTransaction.toBroadcastFormat();
491+
437492
const transaction: MPCTx = {
438493
serializedTx: serializedTx,
439494
scanIndex: index,

modules/sdk-coin-ton/test/unit/ton.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,80 @@ describe('TON:', function () {
650650
sandbox.restore(); // Restore the stubbed method
651651
});
652652

653+
it('should successfully recover funds using senderJettonAddress', async function () {
654+
const recoveryParams = {
655+
bitgoKey:
656+
'e31b1580400dd4f96e8c6281cfb83e28aa25fcf8fb831996a40176a3e30878c80f366ff6463567dbeb7aa7faeeb9f0f6d1fc0a091ec9918e81ad2c34c345f4d0',
657+
recoveryDestination: 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ',
658+
apiKey: 'db2554641c61e60a979cc6c0053f2ec91da9b13e71d287768c93c2fb556be53b',
659+
userKey:
660+
'{"iv":"/9Ca180omwEYKiMgUJ90JQ==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"ipPFqg6qHHY=","ct":"eF6ZstvqobLWB/UfjpmsOcz5+bgvkMhq+Zh+f6TDIa++JkPYRq6hr2LhbidBBCNYHCgahi3nd/FdsFLQ1UkaN9t40mHbUdlSTA42ThH5FI9Um1aACm961Eu5b0Mp7o6U3zuVlaFay6T+/nUWlOKjv+/5MYwfxA/EzMd6yDie7qAIryLCPEIvRuusHupzcJWfN2WGkxw4EhGsSQvs62+PBOCJUeOCThv2HpssJV9gb6mj4lYVTqtEay7G6VFNb+T6OIDU9HNrdxV+5D1zzV5sHNWqDghF03w9adwxgTCPSeZywZPDgBrM/NqHIt+8CPd84wgey61Mz58br08zl2ikksDF5PB8DJzEens6AbY0gosqzAPTuOmy7IS9vP9lfCSaHDoS66hD/I0yh1c6th4gQ1dziwx+hnBKNgqpekJz6P0isnB3urhZInpc0RzKh6R2jrx+hKNr/2dok3dIwgbXWGq7NAm1mSREEs/ChVVeUMmeU9UFmwhOTbRK8CLYHqAqydRLCexNmWhTOPKy3WqS8yG6WkHqSGb9FGLyBRcmQFIBpihV8Xl4W2dB6AX7HsI0F2dYABX0drLiuj2N7mNraRGzxs5jT0lzwjXmy5gmyar23Dqa2eBVOyGdK3DL8jYvgX0mJ58/pKkogrxzMrPkJo2a+gP4OR7cDcL6TxrcwnViYOOGP9OaQZfcKKaAhmJZJGwzyWzjvyEILkCksESuy+c53tdLAku51j2KiV32Rg0tDO3UNjze0qTj3t+YfmTN1GuiNjxFx8dNbX2yxzYxB+MzesuNZAMunwVHrqJpAiozRqpE79R7KRLw7qhBhm5Uez60FBtmAf0kQsGdiIlEwCyTcBYyADlZE+ojD8+x2uiJBBIzd0s7n9ZY3aCYuTVEYGfI9EMGs9WzXcGeTu2xw2OubVHYkvaBbswlrJihIqRH+ce/9+wDedcjbsbo2MODsCJ3sK9KL6JjMWPvhSwR6cMbzDjWrFmRyuekmfSTHuhQEdBAK11N43/ZoYYDBMwBwxjMwwA4hLuBqjehYQ2N0N4o6fpbGSA1+g0IJqkQvh2qn8teYSj67m1vGlEJu852UjJZ4tdt9rSjQ5Tc23YK+XD2kKrXbB7wprraa9PQjnrrIT25yX+l5g7W0EcGTlAcv5/5FC6f6+7R8K5WlLGrp3YM/+570gBS2qbIhl2JzfwgTqjfyh5Z"}',
661+
backupKey:
662+
'{"iv":"NY65fme2sMeYdu77SW/aLw==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"1SErmofmV7s=","ct":"28zQG7xA2zFzAFyNJQfnIPvou12RwVPu3juzo2cIDareyfMkIXp1QDbo6mSBfzlJfOLgYAn0iQH8uCgnmYCN4CbK2IfOukMmQHGa4lHMGUXrL1rnNOsUZkh8YMcMMvr7qoCRjaKLQvRUWeqGt1/TCoROoHwR88ctVhEKwkhpQoIY0TBeLdvluGVB7pFATtEKgolyQaizZ8YOFvjQn9AGhVlOrRrjRwzHXqiu7yW8guL6f0B7ZczvFgLdCTFVNiyQQdr6Fo1Mi4GS/wRKbWakEx7Lktl3kva88BXARe8+undTZ0H2MbmzMSr5zBYpfhVXTO/axttktL2AWqMy8lR85Y7tVp307VUA8XNY3neTbTxuSqm+xrlB/R0nGFEvRfikGsk4KKHOQ6HFneNRdhj/k6dxE2VyH9IwQ6j+Lxjms+pg1yb56iBnGWM1sU0bdKaJFTRZGdiF//lQaKUVPcH2ReNJsRJaU8go5g1HBuOHeMqdnlB7uK2MQKNPCRb3y0ZFQx8ygidOaAF6aiJpz3LewnXhstD1y9lZ0yY9qXImB4lFCrMrmBnHPjEymzmrK+pchd4dBJHtJiqwngxqpL6l+aa69xGeyWDsVmMklZVPwdS5l+5SVygQGF5M1s1I7RfbTWb7WlHFFCv0C1J/tpKT3BziGEfP5Vj0aqeqicgmTRFwvhR1HiNmGvBANHM/PvSv5+cyRj8G7EHmTeuHeghO2h9ZwoZbihnSmyb/ncSM9MgxrSR4Xz7G33hnbujfHV4IpBG/vWTpJUKI11Lgwr71U2cVCn9WA+TY9CXsGkfeOQbbXngoPt4pOhikJKQYg9rOvgaR+hZAOSItk6pWXozADD8sSfs7R6+n/wlkEvuPI1cfcimbHLQ6oH9kTkDU/LbOzmtDDd8MYZUbqvpyES7bFujmw6tc1ZIBLDsSWD2r4siv0KPeVr5WuoJSYw6QRduAryO1NDw4Sk1alypgOXxJapcXgqwi1v9HdRzG1A0IULDZtyEcHjUBFW3vYqHxVc/j/z2hnAC/SbtRcCcZKgDerTNLbPw3iTQQfLiw+LPkeZdO4UUwsImaP3ywb112ieljWJoUtuYAYCEnz5k9sP3PfCDdgSb5BRJZIaDk7i3vWNXW1ydLNeXzRYpmr4vyMkGE7agL/+I5SZh5EVK/CYcAXgY2Mp/VfZWmeMbFVPXS89xLAwWnQQ=="}',
663+
walletPassphrase: 'Ghghjfbvdkmn!234',
664+
senderJettonAddress: 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ',
665+
};
666+
667+
const mockProvider = {
668+
getBalance: sandbox.stub().resolves('1000000000'),
669+
getEstimateFee: sandbox.stub().resolves({
670+
source_fees: {
671+
in_fwd_fee: 1000,
672+
storage_fee: 1000,
673+
gas_fee: 1000,
674+
fwd_fee: 1000,
675+
},
676+
}),
677+
call: sandbox.stub().callsFake((address, method, params) => {
678+
if (method === 'get_wallet_data') {
679+
return Promise.resolve({
680+
stack: [
681+
['num', '5000000000'],
682+
['num', '0'],
683+
['cell', { bytes: '' }],
684+
['cell', { bytes: '' }],
685+
],
686+
});
687+
}
688+
return Promise.resolve({ stack: [] });
689+
}),
690+
send: sandbox.stub().callsFake((method, params) => {
691+
if (method === 'runGetMethod') {
692+
return Promise.resolve({
693+
gas_used: 0,
694+
stack: [['num', '0']],
695+
});
696+
}
697+
if (method === 'sendBoc') {
698+
return Promise.resolve({ ok: true });
699+
}
700+
return Promise.resolve({});
701+
}),
702+
};
703+
704+
sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider);
705+
706+
const decryptStub = sandbox.stub(bitgo, 'decrypt');
707+
decryptStub.onFirstCall().returns(JSON.stringify({ dummy: 'userSigningMaterial' }));
708+
decryptStub.onSecondCall().returns(JSON.stringify({ dummy: 'backupSigningMaterial' }));
709+
710+
sandbox
711+
.stub(EDDSAMethods, 'getTSSSignature')
712+
.resolves(
713+
Buffer.from(
714+
'1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' +
715+
'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857',
716+
'hex'
717+
)
718+
);
719+
720+
const result = await basecoin.recover(recoveryParams);
721+
722+
result.should.have.property('serializedTx');
723+
result.should.have.property('scanIndex');
724+
result.scanIndex.should.equal(0);
725+
});
726+
653727
it('should return an unsigned sweep transaction if userKey and backupKey are missing', async function () {
654728
// Define recovery parameters
655729
const recoveryParams = {

0 commit comments

Comments
 (0)