From d743de985a5be9f6f81b37fc8916b67c1e9dd69f Mon Sep 17 00:00:00 2001 From: "vibhavgopalkrishna145@bitgo.com" Date: Thu, 13 Aug 2026 15:25:01 +0000 Subject: [PATCH] feat(sdk-coin-ada): support MPCv2 signed hot recovery Update Ada.recover() to detect MPCv2 CBOR keycards alongside the existing MPCv1 JSON path, using the shared getEddsaSigningMaterial and signEddsaMpcV2RecoveryTx helpers from @bitgo/sdk-core (already on master). Both helpers are wrapped as protected methods so sinon can stub them in tests. Parameter guards now use assert() instead of manual if/throw to narrow types without non-null assertions. Wallets migrated to Silence Labs EdDSA MPCv2 key generation could not previously complete non-BitGo recovery because recover() only knew how to sign with MPCv1 shares. Detection is automatic based on the decrypted keycard format, so no new RecoveryOptions params or WRW changes are needed. Ticket: WCI-1222 Session-Id: 1dbe3038-f6eb-4e72-88dc-7e8ed59d252a Task-Id: 84b98c0a-b935-4f44-a6fc-e6df8bc5bfb8 --- modules/sdk-coin-ada/src/ada.ts | 92 +++++++++++++---------- modules/sdk-coin-ada/test/unit/ada.ts | 104 +++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 39 deletions(-) diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index 40f324e713..c874aa2455 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -33,6 +33,10 @@ import { AuditDecryptedKeyParams, extractCommonKeychain, TssVerifyAddressOptions, + decryptKeychainPrivateKey, + getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, + signEddsaMpcV2RecoveryTx, + EddsaSigningMaterial, } from '@bitgo/sdk-core'; import { KeyPair as AdaKeyPair, Transaction, TransactionBuilderFactory, Utils } from './lib'; import type { Asset } from './lib/transaction'; @@ -361,6 +365,22 @@ export class Ada extends BaseCoin { return { transactions: broadcastableTransactions, lastScanIndex }; } + /** + * Detects MPCv1 vs MPCv2 keycard format from the encrypted user key and returns + * typed signing material accordingly. Wrapped as protected so tests can stub it. + */ + protected async getEddsaSigningMaterial(userKey: string, passphrase: string): Promise { + return sharedGetEddsaSigningMaterial(userKey, passphrase, this.bitgo); + } + + /** + * Runs the MPS DSG protocol locally to sign a recovery transaction for MPCv2 keycards. + * Wrapped as protected so tests can stub it. + */ + protected async signAdaMpcV2Recovery(params: Parameters[0]): Promise { + return signEddsaMpcV2RecoveryTx(params); + } + /** * Builds funds recovery transaction(s) without BitGo * @@ -447,53 +467,49 @@ export class Ada extends BaseCoin { let serializedTx = unsignedTransaction.toBroadcastFormat(); 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'); - } + assert(params.userKey, 'missing userKey'); + assert(params.backupKey, 'missing backupKey'); + assert(params.walletPassphrase, 'missing wallet passphrase'); // Clean up whitespace from entered values const userKey = params.userKey.replace(/\s/g, ''); const backupKey = params.backupKey.replace(/\s/g, ''); - // Decrypt private keys from KeyCard values - let userPrv; - try { - userPrv = await this.bitgo.decrypt({ - input: userKey, - password: params.walletPassphrase, + const signingMaterial = await this.getEddsaSigningMaterial(userKey, params.walletPassphrase); + + let signature: Buffer; + if (signingMaterial.version === 'v2') { + signature = await this.signAdaMpcV2Recovery({ + message: unsignedTransaction.signablePayload, + userKey: signingMaterial.encryptedUserKey, + backupKey, + walletPassphrase: params.walletPassphrase, + bitgoKey, + derivationPath: currPath, + bitgo: this.bitgo, }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e.message}`); + } else { + /** TODO BG-52419 Implement Codec for parsing */ + const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial; + + const backupPrv = await decryptKeychainPrivateKey( + this.bitgo, + { encryptedPrv: backupKey }, + params.walletPassphrase + ); + assert(backupPrv, 'Error decrypting backup keychain: invalid password or corrupted key'); + const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; + + signature = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + currPath, + unsignedTransaction + ); } - /** TODO BG-52419 Implement Codec for parsing */ - 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; - - // add signature - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, - currPath, - unsignedTransaction - ); const adaKeyPair = new AdaKeyPair({ pub: accountId }); - txBuilder.addSignature({ pub: adaKeyPair.getKeys().pub }, signatureHex); + txBuilder.addSignature({ pub: adaKeyPair.getKeys().pub }, signature); const signedTransaction = await txBuilder.build(); serializedTx = signedTransaction.toBroadcastFormat(); } else { diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index a63b7cd791..82c89b99d0 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -26,7 +26,8 @@ import { Ada, KeyPair, Tada } from '../../src'; import { Transaction } from '../../src/lib'; import { TransactionType } from '../../../sdk-core/src/account-lib/baseCoin/enum'; import assert from 'assert'; -import { common, Wallet } from '@bitgo/sdk-core'; +import { common, EDDSAMethods, MPCRecoveryOptions, MPCTx, Wallet } from '@bitgo/sdk-core'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; import nock from 'nock'; describe('ADA', function () { @@ -804,6 +805,107 @@ describe('ADA', function () { }); }); + describe('Recover Transactions (MPCv2):', function () { + const walletPassphrase = 'test-passphrase-mpcv2'; + const destAddr = address.address2; + const sandbox = sinon.createSandbox(); + + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2RecoverParams: MPCRecoveryOptions; + + 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')); + + mpcV2RecoverParams = { + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: destAddr, + walletPassphrase, + }; + }); + + beforeEach(function () { + const callBack = sandbox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack.withArgs('address_info', sinon.match.any).resolves(endpointResponses.addressInfoResponse.OneUTXO); + callBack.withArgs('tip').resolves(endpointResponses.tipInfoResponse); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should recover ADA 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.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should use MPCv1 path when signing material is MPCv1 format', async function () { + sandbox.stub(basecoin as unknown as { getEddsaSigningMaterial: unknown }, 'getEddsaSigningMaterial').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.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandbox.assert.calledOnce(getTSSSignatureStub); + }); + + it('should return an unsigned sweep transaction when walletPassphrase is missing', async function () { + const getEddsaSigningMaterialSpy = sandbox.spy( + basecoin as unknown as { getEddsaSigningMaterial: () => unknown }, + 'getEddsaSigningMaterial' + ); + + const result = await basecoin.recover({ + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: destAddr, + }); + + result.should.not.be.empty(); + result.txRequests[0].transactions[0].unsignedTx.should.hasOwnProperty('serializedTx'); + sandbox.assert.notCalled(getEddsaSigningMaterialSpy); + }); + + 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'); + }); + }); + describe('Recover Transactions Multiple UTXO:', () => { const destAddr = address.address2; const sandBox = sinon.createSandbox();