Skip to content

Commit bdeb737

Browse files
feat(sdk-coin-ton): add support for recovery for ton
TICKET: WIN-5046
1 parent 959fb86 commit bdeb737

File tree

5 files changed

+445
-2
lines changed

5 files changed

+445
-2
lines changed

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,36 @@ export class Utils implements BaseUtils {
8787
}
8888
}
8989

90+
const DUMMY_PRIVATE_KEY = '43e8594854cb53947c4a1a2fab926af11e123f6251dcd5bd0dfb100604186430'; // This dummy private key is used only for fee estimation
91+
92+
/**
93+
* Function to estimate the fee for a transaction.
94+
* This function uses the dummy private key exclusively for fee estimation.
95+
* @param wallet - The wallet instance.
96+
* @param toAddress - The destination address.
97+
* @param amount - The amount to transfer.
98+
* @param seqno - The sequence number for the transaction.
99+
* @returns The estimated fee for the transaction.
100+
*/
101+
102+
export async function getFeeEstimate(wallet: any, toAddress: string, amount: string, seqno: number): Promise<any> {
103+
try {
104+
const secretKey = TonWeb.utils.stringToBytes(DUMMY_PRIVATE_KEY);
105+
const feeEstimate = await wallet.methods
106+
.transfer({
107+
secretKey,
108+
toAddress,
109+
amount,
110+
seqno,
111+
sendMode: 1,
112+
})
113+
.estimateFee();
114+
return feeEstimate;
115+
} catch (error) {
116+
throw new Error(`Failed to estimate fee: ${error.message}`);
117+
}
118+
}
119+
90120
const utils = new Utils();
91121

92122
export default utils;

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

Lines changed: 251 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,27 @@ import {
1414
TransactionExplanation,
1515
TssVerifyAddressOptions,
1616
VerifyTransactionOptions,
17+
EDDSAMethodTypes,
18+
MPCRecoveryOptions,
19+
MPCTx,
20+
MPCUnsignedTx,
21+
RecoveryTxRequest,
22+
OvcInput,
23+
OvcOutput,
24+
Environments,
25+
MPCSweepTxs,
26+
PublicKey,
27+
MPCTxs,
28+
MPCSweepRecoveryOptions,
1729
} from '@bitgo/sdk-core';
1830
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
1931
import { KeyPair as TonKeyPair } from './lib/keyPair';
2032
import BigNumber from 'bignumber.js';
2133
import * as _ from 'lodash';
22-
import { Transaction, TransactionBuilderFactory, Utils } from './lib';
34+
import { Transaction, TransactionBuilderFactory, Utils, TransferBuilder } from './lib';
2335
import TonWeb from 'tonweb';
36+
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
37+
import { getFeeEstimate } from './lib/utils';
2438

2539
export interface TonParseTransactionOptions extends ParseTransactionOptions {
2640
txHex: string;
@@ -240,4 +254,240 @@ export class Ton extends BaseCoin {
240254
throw new Error('Invalid transaction');
241255
}
242256
}
257+
258+
protected getPublicNodeUrl(): string {
259+
return Environments[this.bitgo.getEnv()].tonNodeUrl;
260+
}
261+
262+
private getBuilder(): TransactionBuilderFactory {
263+
return new TransactionBuilderFactory(coins.get(this.getChain()));
264+
}
265+
266+
async recover(params: MPCRecoveryOptions): Promise<MPCTx | MPCSweepTxs> {
267+
if (!params.bitgoKey) {
268+
throw new Error('missing bitgoKey');
269+
}
270+
if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) {
271+
throw new Error('invalid recoveryDestination');
272+
}
273+
if (!params.apiKey) {
274+
throw new Error('missing apiKey');
275+
}
276+
const bitgoKey = params.bitgoKey.replace(/\s/g, '');
277+
const isUnsignedSweep = !params.userKey && !params.backupKey && !params.walletPassphrase;
278+
279+
// Build the transaction
280+
const tonweb = new TonWeb(new TonWeb.HttpProvider(this.getPublicNodeUrl(), { apiKey: params.apiKey }));
281+
const MPC = await EDDSAMethods.getInitializedMpcInstance();
282+
283+
const index = params.index || 0;
284+
const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`;
285+
const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64);
286+
const senderAddr = await Utils.default.getAddressFromPublicKey(accountId);
287+
const balance = await tonweb.getBalance(senderAddr);
288+
if (new BigNumber(balance).isEqualTo(0)) {
289+
throw Error('Did not find address with funds to recover');
290+
}
291+
292+
const WalletClass = tonweb.wallet.all['v4R2'];
293+
const wallet = new WalletClass(tonweb.provider, {
294+
publicKey: tonweb.utils.hexToBytes(accountId),
295+
wc: 0,
296+
});
297+
let seqno = await wallet.methods.seqno().call();
298+
if (seqno === null) {
299+
seqno = 0;
300+
}
301+
302+
const feeEstimate = await getFeeEstimate(wallet, params.recoveryDestination, balance, seqno as number);
303+
304+
const totalFeeEstimate = Math.round(
305+
(feeEstimate.source_fees.in_fwd_fee +
306+
feeEstimate.source_fees.storage_fee +
307+
feeEstimate.source_fees.gas_fee +
308+
feeEstimate.source_fees.fwd_fee) *
309+
1.5
310+
);
311+
312+
if (new BigNumber(totalFeeEstimate).gt(balance)) {
313+
throw Error('Did not find address with funds to recover');
314+
}
315+
316+
const factory = this.getBuilder();
317+
const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7; // 7 days
318+
319+
const txBuilder = factory
320+
.getTransferBuilder()
321+
.sender(senderAddr)
322+
.sequenceNumber(seqno as number)
323+
.publicKey(accountId)
324+
.expireTime(expireAt);
325+
326+
(txBuilder as TransferBuilder).send({
327+
address: params.recoveryDestination,
328+
amount: new BigNumber(balance).minus(new BigNumber(totalFeeEstimate)).toString(),
329+
});
330+
331+
const unsignedTransaction = await txBuilder.build();
332+
333+
if (!isUnsignedSweep) {
334+
if (!params.userKey) {
335+
throw new Error('missing userKey');
336+
}
337+
if (!params.backupKey) {
338+
throw new Error('missing backupKey');
339+
}
340+
if (!params.walletPassphrase) {
341+
throw new Error('missing wallet passphrase');
342+
}
343+
344+
// Clean up whitespace from entered values
345+
const userKey = params.userKey.replace(/\s/g, '');
346+
const backupKey = params.backupKey.replace(/\s/g, '');
347+
348+
let userPrv;
349+
350+
try {
351+
userPrv = this.bitgo.decrypt({
352+
input: userKey,
353+
password: params.walletPassphrase,
354+
});
355+
} catch (e) {
356+
throw new Error(`Error decrypting user keychain: ${e.message}`);
357+
}
358+
const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial;
359+
360+
let backupPrv;
361+
try {
362+
backupPrv = this.bitgo.decrypt({
363+
input: backupKey,
364+
password: params.walletPassphrase,
365+
});
366+
} catch (e) {
367+
throw new Error(`Error decrypting backup keychain: ${e.message}`);
368+
}
369+
const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial;
370+
371+
const signatureHex = await EDDSAMethods.getTSSSignature(
372+
userSigningMaterial,
373+
backupSigningMaterial,
374+
currPath,
375+
unsignedTransaction
376+
);
377+
378+
const publicKeyObj = { pub: senderAddr };
379+
txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex);
380+
}
381+
382+
const completedTransaction = await txBuilder.build();
383+
const serializedTx = completedTransaction.toBroadcastFormat();
384+
const walletCoin = this.getChain();
385+
386+
const inputs: OvcInput[] = [];
387+
for (const input of completedTransaction.inputs) {
388+
inputs.push({
389+
address: input.address,
390+
valueString: input.value,
391+
value: new BigNumber(input.value).toNumber(),
392+
});
393+
}
394+
const outputs: OvcOutput[] = [];
395+
for (const output of completedTransaction.outputs) {
396+
outputs.push({
397+
address: output.address,
398+
valueString: output.value,
399+
coinName: output.coin,
400+
});
401+
}
402+
const spendAmount = completedTransaction.inputs.length === 1 ? completedTransaction.inputs[0].value : 0;
403+
const parsedTx = { inputs: inputs, outputs: outputs, spendAmount: spendAmount, type: '' };
404+
const feeInfo = { fee: totalFeeEstimate, feeString: totalFeeEstimate.toString() };
405+
const coinSpecific = { commonKeychain: bitgoKey };
406+
if (isUnsignedSweep) {
407+
const transaction: MPCTx = {
408+
serializedTx: serializedTx,
409+
scanIndex: index,
410+
coin: walletCoin,
411+
signableHex: completedTransaction.signablePayload.toString('hex'),
412+
derivationPath: currPath,
413+
parsedTx: parsedTx,
414+
feeInfo: feeInfo,
415+
coinSpecific: coinSpecific,
416+
};
417+
const unsignedTx: MPCUnsignedTx = { unsignedTx: transaction, signatureShares: [] };
418+
const transactions: MPCUnsignedTx[] = [unsignedTx];
419+
const txRequest: RecoveryTxRequest = {
420+
transactions: transactions,
421+
walletCoin: walletCoin,
422+
};
423+
const txRequests: MPCSweepTxs = { txRequests: [txRequest] };
424+
return txRequests;
425+
}
426+
427+
const transaction: MPCTx = {
428+
serializedTx: serializedTx,
429+
scanIndex: index,
430+
};
431+
return transaction;
432+
}
433+
434+
/**
435+
* Creates funds sweep recovery transaction(s) without BitGo
436+
*
437+
* @param {MPCSweepRecoveryOptions} params parameters needed to combine the signatures
438+
* and transactions to create broadcastable transactions
439+
*
440+
* @returns {MPCTxs} array of the serialized transaction hex strings and indices
441+
* of the addresses being swept
442+
*/
443+
async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise<MPCTxs> {
444+
const req = params.signatureShares;
445+
const broadcastableTransactions: MPCTx[] = [];
446+
let lastScanIndex = 0;
447+
448+
for (let i = 0; i < req.length; i++) {
449+
const MPC = await EDDSAMethods.getInitializedMpcInstance();
450+
const transaction = req[i].txRequest.transactions[0].unsignedTx;
451+
if (!req[i].ovc || !req[i].ovc[0].eddsaSignature) {
452+
throw new Error('Missing signature(s)');
453+
}
454+
const signature = req[i].ovc[0].eddsaSignature;
455+
if (!transaction.signableHex) {
456+
throw new Error('Missing signable hex');
457+
}
458+
const messageBuffer = Buffer.from(transaction.signableHex!, 'hex');
459+
const result = MPC.verify(messageBuffer, signature);
460+
if (!result) {
461+
throw new Error('Invalid signature');
462+
}
463+
const signatureHex = Buffer.concat([Buffer.from(signature.R, 'hex'), Buffer.from(signature.sigma, 'hex')]);
464+
const txBuilder = this.getBuilder().from(transaction.serializedTx as string);
465+
if (!transaction.coinSpecific?.commonKeychain) {
466+
throw new Error('Missing common keychain');
467+
}
468+
const commonKeychain = transaction.coinSpecific!.commonKeychain! as string;
469+
if (!transaction.derivationPath) {
470+
throw new Error('Missing derivation path');
471+
}
472+
const derivationPath = transaction.derivationPath as string;
473+
const accountId = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64);
474+
const tonKeyPair = new TonKeyPair({ pub: accountId });
475+
476+
// add combined signature from ovc
477+
txBuilder.addSignature({ pub: tonKeyPair.getKeys().pub }, signatureHex);
478+
const signedTransaction = await txBuilder.build();
479+
const serializedTx = signedTransaction.toBroadcastFormat();
480+
481+
broadcastableTransactions.push({
482+
serializedTx: serializedTx,
483+
scanIndex: transaction.scanIndex,
484+
});
485+
486+
if (i === req.length - 1 && transaction.coinSpecific!.lastScanIndex) {
487+
lastScanIndex = transaction.coinSpecific!.lastScanIndex as number;
488+
}
489+
}
490+
491+
return { transactions: broadcastableTransactions, lastScanIndex };
492+
}
243493
}

0 commit comments

Comments
 (0)