From 2b8391a6044c12c0656aa4f1a6afb5aab7069040 Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Wed, 5 Aug 2026 13:54:09 +0530 Subject: [PATCH] feat(sdk-coin-ton): add MPCv2 signed hot recovery to Ton.recover() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MPCv1 wallets use JSON keycards; MPCv2 wallets use CBOR-encoded reduced key shares. Without this change, recover() silently fails for MPCv2 wallets. Detection is automatic via isEddsaMpcV1SigningMaterial — no caller changes required. Ticket: WCI-1225 --- modules/sdk-coin-ton/src/ton.ts | 131 ++++++++++++++------- modules/sdk-coin-ton/test/unit/ton.ts | 158 +++++++++++++++++++++++++- 2 files changed, 240 insertions(+), 49 deletions(-) diff --git a/modules/sdk-coin-ton/src/ton.ts b/modules/sdk-coin-ton/src/ton.ts index 84c531cae3..c937c7fb98 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -1,10 +1,13 @@ +import assert from 'assert'; import BigNumber from 'bignumber.js'; import * as _ from 'lodash'; import TonWeb from 'tonweb'; import { BaseCoin, BitGoBase, + decryptKeychainPrivateKey, EDDSAMethods, + EDDSAUtils, InvalidAddressError, KeyPair, MPCAlgorithm, @@ -43,6 +46,8 @@ export interface TonParseTransactionOptions extends ParseTransactionOptions { toAddressBounceable?: boolean; } +type TonSigningMaterial = { version: 'v1'; userPrv: string } | { version: 'v2'; encryptedUserKey: string }; + export class Ton extends BaseCoin { protected readonly _staticsCoin: Readonly; protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { @@ -314,6 +319,76 @@ export class Ton extends BaseCoin { return new TransactionBuilderFactory(coins.get(this.getChain())); } + /** + * Discriminated union carrying keycard version and decrypted V1 user key (to avoid re-decryption). + * V1 keycards are JSON; V2 keycards are CBOR-encoded reduced key shares. + */ + private async isMpcV2Keycard(userKey: string, walletPassphrase: string): Promise { + const normalized = userKey.replace(/\s/g, ''); + let isV1: boolean; + try { + isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial(normalized, walletPassphrase, this.bitgo); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); + } + if (isV1) { + const userPrv = await this.decryptKeychain(normalized, walletPassphrase, 'user'); + return { version: 'v1', userPrv }; + } + return { version: 'v2', encryptedUserKey: normalized }; + } + + private async decryptKeychain(encryptedKey: string, passphrase: string, label: string): Promise { + const prv = await decryptKeychainPrivateKey(this.bitgo, { encryptedPrv: encryptedKey }, passphrase); + if (!prv) { + throw new Error(`Error decrypting ${label} keychain: invalid password or corrupted key`); + } + return prv; + } + + private async addRecoverySignature( + signingMaterial: TonSigningMaterial, + txBuilder: TransactionBuilder, + senderAddr: string, + unsignedTransaction: any, + currPath: string, + bitgoKey: string, + backupKey: string, + walletPassphrase: string + ): Promise { + if (signingMaterial.version === 'v2') { + const { userKeyShare, backupKeyShare, commonKeyChain } = + await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + signingMaterial.encryptedUserKey, + backupKey, + walletPassphrase, + this.bitgo + ); + if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) { + throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + } + const signature = await EDDSAUtils.signRecoveryEddsaMPCv2( + unsignedTransaction.signablePayload, + currPath, + userKeyShare, + backupKeyShare, + commonKeyChain + ); + txBuilder.addSignature({ pub: senderAddr } as PublicKey, signature); + } else { + const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial; + const backupPrv = await this.decryptKeychain(backupKey, walletPassphrase, 'backup'); + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + const signatureHex = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + currPath, + unsignedTransaction + ); + txBuilder.addSignature({ pub: senderAddr } as PublicKey, signatureHex); + } + } + async recover( params: MPCRecoveryOptions & { jettonMaster?: string; senderJettonAddress?: string } ): Promise { @@ -440,52 +515,20 @@ export class Ton extends BaseCoin { } if (!isUnsignedSweep) { - if (!params.userKey) { - throw new Error('missing userKey'); - } - if (!params.backupKey) { - throw new Error('missing backupKey'); - } - if (!params.walletPassphrase) { - throw new Error('missing wallet passphrase'); - } - - // Clean up whitespace from entered values - const userKey = params.userKey.replace(/\s/g, ''); - const backupKey = params.backupKey.replace(/\s/g, ''); - - let userPrv; - - try { - userPrv = await this.bitgo.decrypt({ - input: userKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e.message}`); - } - const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; - - let backupPrv; - try { - backupPrv = await this.bitgo.decrypt({ - input: backupKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); - } - const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; - - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, + assert(params.userKey, 'missing userKey'); + assert(params.backupKey, 'missing backupKey'); + assert(params.walletPassphrase, 'missing wallet passphrase'); + const signingMaterial = await this.isMpcV2Keycard(params.userKey, params.walletPassphrase); + await this.addRecoverySignature( + signingMaterial, + txBuilder, + senderAddr, + unsignedTransaction, currPath, - unsignedTransaction + bitgoKey, + params.backupKey.replace(/\s/g, ''), + params.walletPassphrase ); - - const publicKeyObj = { pub: senderAddr }; - txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex); } const walletCoin = this.getChain(); diff --git a/modules/sdk-coin-ton/test/unit/ton.ts b/modules/sdk-coin-ton/test/unit/ton.ts index 9862cc95dc..e53e370d37 100644 --- a/modules/sdk-coin-ton/test/unit/ton.ts +++ b/modules/sdk-coin-ton/test/unit/ton.ts @@ -1,13 +1,14 @@ import { TestBitGo } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; +import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { Ton, TonParseTransactionOptions, Tton } from '../../src'; import * as sinon from 'sinon'; import assert from 'assert'; import * as testData from '../resources/ton'; -import { EDDSAMethods, TransactionExplanation } from '@bitgo/sdk-core'; +import { EDDSAMethods, MPCRecoveryOptions, MPCTx, TransactionExplanation } from '@bitgo/sdk-core'; import should from 'should'; import utils from '../../src/lib/utils'; import Tonweb from 'tonweb'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; describe('TON:', function () { let basecoin; @@ -714,10 +715,12 @@ describe('TON:', function () { }; sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider); + sandbox.stub(basecoin as any, 'isMpcV2Keycard').resolves({ + version: 'v1', + userPrv: JSON.stringify({ dummy: 'userSigningMaterial' }), + }); - const decryptStub = sandbox.stub(bitgo, 'decrypt'); - decryptStub.onFirstCall().resolves(JSON.stringify({ dummy: 'userSigningMaterial' })); - decryptStub.onSecondCall().resolves(JSON.stringify({ dummy: 'backupSigningMaterial' })); + sandbox.stub(bitgo, 'decrypt').resolves(JSON.stringify({ dummy: 'backupSigningMaterial' })); sandbox .stub(EDDSAMethods, 'getTSSSignature') @@ -808,6 +811,7 @@ describe('TON:', function () { result.txRequests[0].should.have.property('transactions'); result.txRequests[0].transactions[0].should.have.property('unsignedTx'); result.txRequests[0].transactions[0].unsignedTx.should.equal(mockUnsignedTx); + sandbox.restore(); }); it('should take OVC output and generate a signed sweep transaction', async function () { @@ -846,5 +850,149 @@ describe('TON:', function () { recoveryTxn.transactions[0].scanIndex.should.equal(0); recoveryTxn.lastScanIndex.should.equal(0); }); + + describe('MPCv2 signed recovery', function () { + const walletPassphrase = 'test-passphrase-mpcv2'; + const recoveryDestination = 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ'; + const apiKey = 'db2554641c61e60a979cc6c0053f2ec91da9b13e71d287768c93c2fb556be53b'; + + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2WalletAddress: string; + let mpcV2RecoverParams: MPCRecoveryOptions & { apiKey: string }; + + let mockProvider: { + getBalance: sinon.SinonStub; + getEstimateFee: sinon.SinonStub; + call: sinon.SinonStub; + send: sinon.SinonStub; + }; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + + const mpc = await EDDSAMethods.getInitializedMpcInstance(); + const accountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + mpcV2WalletAddress = await utils.getAddressFromPublicKey(accountId); + + mpcV2RecoverParams = { + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination, + walletPassphrase, + apiKey, + }; + }); + + beforeEach(function () { + 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(), + send: sandbox.stub().callsFake((method: string) => { + if (method === 'runGetMethod') { + return Promise.resolve({ gas_used: 0, stack: [['num', '0']] }); + } + return Promise.resolve({}); + }), + }; + + sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider as any); + }); + + it('should recover native TON using MPCv2 signing material', async function () { + const getTSSSignatureSpy = sandbox.spy(EDDSAMethods, 'getTSSSignature'); + + const result = (await basecoin.recover(mpcV2RecoverParams)) as MPCTx; + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal(result.scanIndex, 0); + (result.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.calledWith(mockProvider.getBalance, mpcV2WalletAddress); + sandbox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should recover jetton using MPCv2 signing material', async function () { + mockProvider.call.callsFake((address: string, method: string) => { + if (method === 'get_wallet_data') { + return Promise.resolve({ + stack: [ + ['num', '5000000000'], + ['num', '0'], + ['cell', { bytes: '' }], + ['cell', { bytes: '' }], + ], + }); + } + return Promise.resolve({ stack: [] }); + }); + + const getTSSSignatureSpy = sandbox.spy(EDDSAMethods, 'getTSSSignature'); + + const jettonParams = { + ...mpcV2RecoverParams, + senderJettonAddress: recoveryDestination, + }; + + const result = (await basecoin.recover(jettonParams)) as MPCTx; + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal(result.scanIndex, 0); + (result.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.calledWith(mockProvider.getBalance, mpcV2WalletAddress); + sandbox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should use MPCv1 path when signing material is MPCv1 format', async function () { + sandbox.stub(basecoin as any, 'isMpcV2Keycard').resolves({ + version: 'v1', + userPrv: JSON.stringify({ uShare: {}, bitgoYShare: {} }), + }); + + const getTSSSignatureStub = sandbox + .stub(EDDSAMethods, 'getTSSSignature') + .resolves( + Buffer.from( + '1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' + + 'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857', + 'hex' + ) + ); + + sandbox.stub(bitgo, 'decrypt').resolves(JSON.stringify({ bShare: {}, yShares: {} })); + + const result = (await basecoin.recover(mpcV2RecoverParams)) as MPCTx; + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal(result.scanIndex, 0); + (result.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.calledOnce(getTSSSignatureStub); + }); + + it('should throw when commonKeyChain from MPCv2 keycard does not match bitgoKey', async function () { + const mismatchedBitgoKey = mpcV2CommonKeyChain.slice(0, -8) + '00000000'; + const mismatchedParams = { + ...mpcV2RecoverParams, + bitgoKey: mismatchedBitgoKey, + }; + + await basecoin + .recover(mismatchedParams) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + }); }); });