Skip to content
Merged
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
187 changes: 121 additions & 66 deletions modules/sdk-coin-ton/src/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import {
EDDSAMethodTypes,
MPCRecoveryOptions,
MPCTx,
MPCUnsignedTx,
RecoveryTxRequest,
OvcInput,
OvcOutput,
Environments,
Expand All @@ -35,7 +33,7 @@ import {
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
import { KeyPair as TonKeyPair } from './lib/keyPair';
import { TransactionBuilderFactory, Utils, TransferBuilder } from './lib';
import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib';
import { getFeeEstimate } from './lib/utils';

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

async recover(params: MPCRecoveryOptions): Promise<MPCTx | MPCSweepTxs> {
async recover(
params: MPCRecoveryOptions & { jettonMaster?: string; senderJettonAddress?: string }
): Promise<MPCTx | MPCSweepTxs> {
if (!params.bitgoKey) {
throw new Error('missing bitgoKey');
}
Expand All @@ -295,50 +295,106 @@ export class Ton extends BaseCoin {
const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64);
const senderAddr = await Utils.default.getAddressFromPublicKey(accountId);
const balance = await tonweb.getBalance(senderAddr);
if (new BigNumber(balance).isEqualTo(0)) {
throw Error('Did not find address with funds to recover');

const jettonBalances: { minterAddress?: string; walletAddress: string; balance: string }[] = [];
if (params.senderJettonAddress) {
try {
const jettonWalletData = await tonweb.provider.call(params.senderJettonAddress, 'get_wallet_data');
const jettonBalance = jettonWalletData.stack[0][1];
if (jettonBalance && new BigNumber(jettonBalance).gt(0)) {
jettonBalances.push({
walletAddress: params.senderJettonAddress,
balance: jettonBalance,
});
}
} catch (e) {
throw new Error(`Failed to query jetton balance for address ${params.senderJettonAddress}: ${e.message}`);
}
}

const WalletClass = tonweb.wallet.all['v4R2'];
const wallet = new WalletClass(tonweb.provider, {
publicKey: tonweb.utils.hexToBytes(accountId),
wc: 0,
});
let seqno = await wallet.methods.seqno().call();
if (seqno === null) {
seqno = 0;
}
const seqnoResult = await wallet.methods.seqno().call();
const seqno: number = seqnoResult !== null && seqnoResult !== undefined ? seqnoResult : 0;

const feeEstimate = await getFeeEstimate(wallet, params.recoveryDestination, balance, seqno as number);
const factory = this.getBuilder();
const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7;

let txBuilder: TransactionBuilder;
let unsignedTransaction: any;
let feeEstimate: number;
let transactionType: 'ton' | 'jetton';

if ((params.jettonMaster || params.senderJettonAddress) && jettonBalances.length > 0) {
const jettonInfo = jettonBalances[0];
const tonAmount = '50000000';
const forwardTonAmount = '1';

const totalRequiredTon = new BigNumber(tonAmount).plus(new BigNumber(forwardTonAmount));
if (new BigNumber(balance).lt(totalRequiredTon)) {
throw new Error(
`Insufficient TON balance for jetton transfer. Required: ${totalRequiredTon.toString()} nanoTON, Available: ${balance}`
);
}

const totalFeeEstimate = Math.round(
(feeEstimate.source_fees.in_fwd_fee +
feeEstimate.source_fees.storage_fee +
feeEstimate.source_fees.gas_fee +
feeEstimate.source_fees.fwd_fee) *
1.5
);
txBuilder = factory
.getTokenTransferBuilder()
.sender(senderAddr)
.sequenceNumber(seqno)
.publicKey(accountId)
.expireTime(expireAt);

(txBuilder as TokenTransferBuilder).recipient(
params.recoveryDestination,
jettonInfo.walletAddress,
tonAmount,
jettonInfo.balance,
forwardTonAmount
);

if (new BigNumber(totalFeeEstimate).gt(balance)) {
throw Error('Did not find address with funds to recover');
}
unsignedTransaction = await txBuilder.build();
feeEstimate = parseInt(tonAmount, 10);
transactionType = 'jetton';
} else {
if (new BigNumber(balance).isEqualTo(0)) {
throw Error('Did not find address with TON balance to recover');
}

const factory = this.getBuilder();
const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7; // 7 days

const txBuilder = factory
.getTransferBuilder()
.sender(senderAddr)
.sequenceNumber(seqno as number)
.publicKey(accountId)
.expireTime(expireAt);

(txBuilder as TransferBuilder).send({
address: params.recoveryDestination,
amount: new BigNumber(balance).minus(new BigNumber(totalFeeEstimate)).toString(),
});
const tonFeeEstimate = await getFeeEstimate(wallet, params.recoveryDestination, balance, seqno as number);

const totalFeeEstimate = Math.round(
(tonFeeEstimate.source_fees.in_fwd_fee +
tonFeeEstimate.source_fees.storage_fee +
tonFeeEstimate.source_fees.gas_fee +
tonFeeEstimate.source_fees.fwd_fee) *
1.5
);

const unsignedTransaction = await txBuilder.build();
if (new BigNumber(totalFeeEstimate).gte(balance)) {
throw new Error(
`Insufficient TON balance for transaction. Required: ${totalFeeEstimate} nanoTON, Available: ${balance}`
);
}

txBuilder = factory
.getTransferBuilder()
.sender(senderAddr)
.sequenceNumber(seqno)
.publicKey(accountId)
.expireTime(expireAt);

(txBuilder as TransferBuilder).send({
address: params.recoveryDestination,
amount: new BigNumber(balance).minus(new BigNumber(totalFeeEstimate)).toString(),
});

unsignedTransaction = await txBuilder.build();
feeEstimate = totalFeeEstimate;
transactionType = 'ton';
}

if (!isUnsignedSweep) {
if (!params.userKey) {
Expand Down Expand Up @@ -389,31 +445,33 @@ export class Ton extends BaseCoin {
txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex);
}

const completedTransaction = await txBuilder.build();
const serializedTx = completedTransaction.toBroadcastFormat();
const walletCoin = this.getChain();

const inputs: OvcInput[] = [];
for (const input of completedTransaction.inputs) {
inputs.push({
address: input.address,
valueString: input.value,
value: new BigNumber(input.value).toNumber(),
});
}
const outputs: OvcOutput[] = [];
for (const output of completedTransaction.outputs) {
outputs.push({
address: output.address,
valueString: output.value,
coinName: output.coin,
});
}
const spendAmount = completedTransaction.inputs.length === 1 ? completedTransaction.inputs[0].value : 0;
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' };
const feeInfo = { fee: totalFeeEstimate, feeString: totalFeeEstimate.toString() };
const coinSpecific = { commonKeychain: bitgoKey };

if (isUnsignedSweep) {
const completedTransaction = await txBuilder.build();
const serializedTx = completedTransaction.toBroadcastFormat();

const inputs: OvcInput[] = [];
for (const input of completedTransaction.inputs) {
inputs.push({
address: input.address,
valueString: input.value,
value: new BigNumber(input.value).toNumber(),
});
}
const outputs: OvcOutput[] = [];
for (const output of completedTransaction.outputs) {
outputs.push({
address: output.address,
valueString: output.value,
coinName: output.coin,
});
}
const spendAmount = completedTransaction.inputs.length === 1 ? completedTransaction.inputs[0].value : 0;
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: transactionType };
const feeInfo = { fee: feeEstimate, feeString: feeEstimate.toString() };

const transaction: MPCTx = {
serializedTx: serializedTx,
scanIndex: index,
Expand All @@ -424,16 +482,13 @@ export class Ton extends BaseCoin {
feeInfo: feeInfo,
coinSpecific: coinSpecific,
};
const unsignedTx: MPCUnsignedTx = { unsignedTx: transaction, signatureShares: [] };
const transactions: MPCUnsignedTx[] = [unsignedTx];
const txRequest: RecoveryTxRequest = {
transactions: transactions,
walletCoin: walletCoin,
};
const txRequests: MPCSweepTxs = { txRequests: [txRequest] };
return txRequests;

return transaction;
}

const completedTransaction = await txBuilder.build();
const serializedTx = completedTransaction.toBroadcastFormat();

const transaction: MPCTx = {
serializedTx: serializedTx,
scanIndex: index,
Expand Down
74 changes: 74 additions & 0 deletions modules/sdk-coin-ton/test/unit/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,80 @@ describe('TON:', function () {
sandbox.restore(); // Restore the stubbed method
});

it('should successfully recover funds using senderJettonAddress', async function () {
const recoveryParams = {
bitgoKey:
'e31b1580400dd4f96e8c6281cfb83e28aa25fcf8fb831996a40176a3e30878c80f366ff6463567dbeb7aa7faeeb9f0f6d1fc0a091ec9918e81ad2c34c345f4d0',
recoveryDestination: 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ',
apiKey: 'db2554641c61e60a979cc6c0053f2ec91da9b13e71d287768c93c2fb556be53b',
userKey:
'{"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"}',
backupKey:
'{"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=="}',
walletPassphrase: 'Ghghjfbvdkmn!234',
senderJettonAddress: 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ',
};

const mockProvider = {
getBalance: sandbox.stub().resolves('1000000000'),
getEstimateFee: sandbox.stub().resolves({
source_fees: {
in_fwd_fee: 1000,
storage_fee: 1000,
gas_fee: 1000,
fwd_fee: 1000,
},
}),
call: sandbox.stub().callsFake((address, method, params) => {
if (method === 'get_wallet_data') {
return Promise.resolve({
stack: [
['num', '5000000000'],
['num', '0'],
['cell', { bytes: '' }],
['cell', { bytes: '' }],
],
});
}
return Promise.resolve({ stack: [] });
}),
send: sandbox.stub().callsFake((method, params) => {
if (method === 'runGetMethod') {
return Promise.resolve({
gas_used: 0,
stack: [['num', '0']],
});
}
if (method === 'sendBoc') {
return Promise.resolve({ ok: true });
}
return Promise.resolve({});
}),
};

sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider);

const decryptStub = sandbox.stub(bitgo, 'decrypt');
decryptStub.onFirstCall().returns(JSON.stringify({ dummy: 'userSigningMaterial' }));
decryptStub.onSecondCall().returns(JSON.stringify({ dummy: 'backupSigningMaterial' }));

sandbox
.stub(EDDSAMethods, 'getTSSSignature')
.resolves(
Buffer.from(
'1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' +
'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857',
'hex'
)
);

const result = await basecoin.recover(recoveryParams);

result.should.have.property('serializedTx');
result.should.have.property('scanIndex');
result.scanIndex.should.equal(0);
});

it('should return an unsigned sweep transaction if userKey and backupKey are missing', async function () {
// Define recovery parameters
const recoveryParams = {
Expand Down