From 88f5e939d50c62b06aadb81a54cc50a84fd631d1 Mon Sep 17 00:00:00 2001 From: "vibhavgopalkrishna145@bitgo.com" Date: Wed, 12 Aug 2026 10:24:28 +0000 Subject: [PATCH] feat(sdk-coin-polyx): add MPCv2 signed hot recovery Detect CBOR (MPCv2) vs JSON (MPCv1) keycards in Polyx.recover() using the shared getEddsaSigningMaterial/signSubstrateMpcV2Recovery helpers from SubstrateCoin. Fixes missing 0x00 Ed25519 discriminant prefix on MPCv2 signatures. Uses assert() for guards in newly introduced recovery code. Ticket: WCI-1228 --- modules/sdk-coin-polyx/src/polyx.ts | 73 ++++++------- modules/sdk-coin-polyx/test/unit/polyx.ts | 125 +++++++++++++++++++++- 2 files changed, 154 insertions(+), 44 deletions(-) diff --git a/modules/sdk-coin-polyx/src/polyx.ts b/modules/sdk-coin-polyx/src/polyx.ts index c0d1f70b97..ff1d0c1765 100644 --- a/modules/sdk-coin-polyx/src/polyx.ts +++ b/modules/sdk-coin-polyx/src/polyx.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import { AuditDecryptedKeyParams, BaseCoin, @@ -189,52 +190,42 @@ export class Polyx extends SubstrateCoin { 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'); - 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(params.userKey, params.walletPassphrase); + const ED25519_PREFIX = 0x00; + const substrateKeyPair = new SubstrateKeyPair({ pub: accountId }); + if (signingMaterial.version === 'v2') { + const rawSig = await this.signSubstrateMpcV2Recovery({ + message: unsignedTransaction.signablePayload, + userKey: signingMaterial.encryptedUserKey, + backupKey: params.backupKey.replace(/\s/g, ''), + walletPassphrase: params.walletPassphrase, + bitgoKey, + derivationPath: currPath, + bitgo: this.bitgo, }); - } 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, + txBuilder.addSignature( + { pub: substrateKeyPair.getKeys().pub }, + Buffer.concat([Buffer.from([ED25519_PREFIX]), rawSig]) + ); + } else { + const userSigningMaterial = JSON.parse(signingMaterial.userPrv) as EDDSAMethodTypes.UserSigningMaterial; + const backupPrv = await this.bitgo.decrypt({ + input: params.backupKey.replace(/\s/g, ''), 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, + currPath, + unsignedTransaction + ); + txBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, signatureHex); } - const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; - - // add signature - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, - currPath, - unsignedTransaction - ); - - const substrateKeyPair = new SubstrateKeyPair({ pub: accountId }); - txBuilder.addSignature({ pub: substrateKeyPair.getKeys().pub }, signatureHex); const signedTransaction = await txBuilder.build(); serializedTx = signedTransaction.toBroadcastFormat(); } else { diff --git a/modules/sdk-coin-polyx/test/unit/polyx.ts b/modules/sdk-coin-polyx/test/unit/polyx.ts index 0db9b3eb46..3d7439134c 100644 --- a/modules/sdk-coin-polyx/test/unit/polyx.ts +++ b/modules/sdk-coin-polyx/test/unit/polyx.ts @@ -1,6 +1,6 @@ import should from 'should'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; +import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { Polyx, Tpolyx } from '../../src'; import { POLYX_ADDRESS_FORMAT, TPOLYX_ADDRESS_FORMAT } from '../../src/lib/constants'; import utils from '../../src/lib/utils'; @@ -9,9 +9,10 @@ import * as sinon from 'sinon'; import * as testData from '../resources/wrwUsers'; import { afterEach } from 'mocha'; import { genesisHash, specVersion, txVersion, rawTx, accounts, mockTssSignature } from '../resources'; -import { TxIntentMismatchRecipientError } from '@bitgo/sdk-core'; -import { V8TransferBuilder } from '../../src/lib'; +import { EDDSAMethods, MPCRecoveryOptions, MPCTx, TxIntentMismatchRecipientError } from '@bitgo/sdk-core'; +import { TransferBuilder, V8TransferBuilder } from '../../src/lib'; import { testnetV8Material } from '../../src/resources'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; describe('Polyx:', function () { let bitgo: TestBitGoAPI; @@ -186,6 +187,124 @@ describe('Polyx:', function () { should.deepEqual(txJson.transactionVersion, txVersion); should.deepEqual(txJson.eraPeriod, baseCoin.SWEEP_TXN_DURATION); }); + + describe('MPCv2 signed recovery', function () { + const walletPassphrase = 'test-passphrase-mpcv2'; + 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, + walletPassphrase, + }; + }); + + beforeEach(function () { + // Use unrestricted resolves (not .withArgs) since the mismatched-bitgoKey test + // derives a different sender address than mpcV2WalletAddress. + accountInfoCB.resolves({ nonce: 0, freeBalance: 100007000000 }); + headerInfoCB.resolves({ + headerNumber: testData.testnetBlock.blockNumber, + headerHash: testData.testnetBlock.hash, + }); + getFeeCB.resolves(74401); + }); + + it('should recover a tx using MPCv2 signing material', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const addSignatureSpy = sandBox.spy(TransferBuilder.prototype, 'addSignature'); + + 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.notCalled(getTSSSignatureSpy); + + // Substrate MultiSignature Ed25519 discriminant (0x00) must prefix the 64-byte signature. + sandBox.assert.calledOnce(addSignatureSpy); + const signature: Buffer = addSignatureSpy.firstCall.args[1]; + signature.length.should.equal(65); + signature[0].should.equal(0x00); + }); + + it('should use MPCv1 path when signing material is MPCv1 format', async function () { + const userPrv = JSON.stringify({ uShare: { seed: 'aa' }, bitgoYShare: { u: 'bb' }, backupYShare: { u: 'cc' } }); + sandBox + .stub(baseCoin as unknown as { getEddsaSigningMaterial: unknown }, 'getEddsaSigningMaterial') + .resolves({ version: 'v1', userPrv }); + + const getTSSSignatureStub = sandBox + .stub(EDDSAMethods, 'getTSSSignature') + .resolves( + Buffer.from( + '1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9da' + + 'e1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857', + 'hex' + ) + ); + + sandBox + .stub(bitgo, 'decrypt') + .withArgs(sinon.match({ input: mpcV2BackupKey.replace(/\s/g, '') })) + .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 a descriptive error when user keychain decryption fails on the MPCv1 path', async function () { + sandBox.stub(bitgo, 'decrypt').rejects(new Error('password error')); + + await baseCoin.recover(mpcV2RecoverParams).should.be.rejectedWith(/Error decrypting user keychain/); + }); + + 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'); + }); + + it('should throw missing userKey when backupKey and walletPassphrase are present but userKey is not', async function () { + const paramsWithoutUserKey = { ...mpcV2RecoverParams, userKey: undefined }; + await baseCoin.recover(paramsWithoutUserKey).should.be.rejectedWith('missing userKey'); + }); + + it('should throw missing backupKey when userKey and walletPassphrase are present but backupKey is not', async function () { + const paramsWithoutBackupKey = { ...mpcV2RecoverParams, backupKey: undefined }; + await baseCoin.recover(paramsWithoutBackupKey).should.be.rejectedWith('missing backupKey'); + }); + + it('should throw missing wallet passphrase when userKey and backupKey are present but walletPassphrase is not', async function () { + const paramsWithoutPassphrase = { ...mpcV2RecoverParams, walletPassphrase: undefined }; + await baseCoin.recover(paramsWithoutPassphrase).should.be.rejectedWith('missing wallet passphrase'); + }); + }); }); describe('Token Enablement:', function () {