From e2660bca64882fa1ed750656a3da6b33015f58bc Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Thu, 13 Aug 2026 06:05:26 +0000 Subject: [PATCH] feat(sdk-coin-near): add MPCv2 signed hot recovery Add MPCv2 detection and signing to Near.recover() alongside the existing MPCv1 path. What changed: - Import getEddsaSigningMaterial and signEddsaMpcV2RecoveryTx from @bitgo/sdk-core in near.ts - Add isMpcv2SigningMaterial() private method that decrypts the user keycard once and returns true when the plaintext is CBOR (MPCv2) - Refactor signRecoveryTransaction() to accept an isMpcV2 boolean; when true it calls signEddsaMpcV2RecoveryTx (MPS DSG) instead of the legacy EDDSAMethods.getTSSSignature path - Call isMpcv2SigningMaterial() once at the top of recover() and thread the isMpcV2 flag into both the native NEAR and NEP141 FT token paths - Add three new unit tests: native MPCv2 signed recovery, NEP141 FT token MPCv2 signed recovery, and bitgoKey/commonKeyChain mismatch Why: NEAR wallets provisioned with the new Silence Labs (MPCv2) key material cannot be recovered with the Zengo-era getTSSSignature path because the keycard format is different (CBOR base64 vs JSON uShare/yShare). This adds the same MPCv2 detection+signing path that was introduced for SOL in WCI-398, enabling hot recovery for MPCv2 NEAR wallets without any new caller-visible parameters. Ticket: WCI-1223 Session-Id: 8de2a998-4754-4499-82b5-49167b8d9fd6 Task-Id: 7e0eb924-0c33-4cc6-9402-c71587bb14a5 --- modules/sdk-coin-near/src/near.ts | 111 +++++++---- modules/sdk-coin-near/test/unit/near.ts | 253 +++++++++++++++++++++++- 2 files changed, 321 insertions(+), 43 deletions(-) diff --git a/modules/sdk-coin-near/src/near.ts b/modules/sdk-coin-near/src/near.ts index f4ca475aa0..9200f90cb5 100644 --- a/modules/sdk-coin-near/src/near.ts +++ b/modules/sdk-coin-near/src/near.ts @@ -18,6 +18,7 @@ import { EDDSAMethods, EDDSAMethodTypes, Environments, + getEddsaSigningMaterial, KeyPair, MPCAlgorithm, MPCRecoveryOptions, @@ -32,6 +33,7 @@ import { ParseTransactionOptions as BaseParseTransactionOptions, PublicKey, RecoveryTxRequest, + signEddsaMpcV2RecoveryTx, SignedTransaction, SignTransactionOptions as BaseSignTransactionOptions, TokenEnablementConfig, @@ -365,6 +367,7 @@ export class Near extends BaseCoin { } const bitgoKey = params.bitgoKey.replace(/\s/g, ''); const isUnsignedSweep = !params.userKey && !params.backupKey && !params.walletPassphrase; + const isMpcV2 = await this.isMpcv2SigningMaterial(params.userKey, params.backupKey, params.walletPassphrase); const MPC = await EDDSAMethods.getInitializedMpcInstance(); const { storageAmountPerByte, transferCost, receiptConfig } = await this.getProtocolConfig(); let isStorageDepositEnabled = false; @@ -440,7 +443,8 @@ export class Near extends BaseCoin { bitgoKey, isStorageDepositEnabled, availableTokenBalance, - isUnsignedSweep + isUnsignedSweep, + isMpcV2 ); } @@ -474,7 +478,7 @@ export class Near extends BaseCoin { const unsignedTransaction = (await txBuilder.build()) as Transaction; let serializedTx = unsignedTransaction.toBroadcastFormat(); if (!isUnsignedSweep) { - serializedTx = await this.signRecoveryTransaction(txBuilder, params, currPath, accountId); + serializedTx = await this.signRecoveryTransaction(txBuilder, params, currPath, accountId, isMpcV2); } else { return this.buildUnsignedSweepTransaction( txBuilder, @@ -514,7 +518,8 @@ export class Near extends BaseCoin { bitgoKey: string, isStorageDepositEnabled: boolean, availableTokenBalance: BigNumber, - isUnsignedSweep: boolean + isUnsignedSweep: boolean, + isMpcV2 = false ): Promise { const factory = new TransactionBuilderFactory(token); const bs58EncodedPublicKey = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(senderAddress, 'hex'))); @@ -549,7 +554,13 @@ export class Near extends BaseCoin { token ); } else { - const serializedTx = await this.signRecoveryTransaction(txBuilder, params, derivationPath, senderAddress); + const serializedTx = await this.signRecoveryTransaction( + txBuilder, + params, + derivationPath, + senderAddress, + isMpcV2 + ); return { serializedTx: serializedTx, scanIndex: idx }; } } @@ -631,12 +642,11 @@ export class Near extends BaseCoin { txBuilder: TransactionBuilder, params: MPCRecoveryOptions, derivationPath: string, - senderAddress: string + senderAddress: string, + isMpcV2 = false ): Promise { const unsignedTransaction = (await txBuilder.build()) as Transaction; - // Sign the txn - /* ***************** START **************************************/ - // TODO(BG-51092): This looks like a common part which can be extracted out too + if (!params.userKey) { throw new Error('missing userKey'); } @@ -647,49 +657,68 @@ export class Near extends BaseCoin { 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, ''); - // Decrypt private keys from KeyCard values - let userPrv; - try { - userPrv = await this.bitgo.decrypt({ - input: userKey, - password: params.walletPassphrase, + let signatureHex: Buffer; + if (isMpcV2) { + signatureHex = await signEddsaMpcV2RecoveryTx({ + message: unsignedTransaction.signablePayload, + userKey, + backupKey, + walletPassphrase: params.walletPassphrase, + bitgoKey: params.bitgoKey.replace(/\s/g, ''), + derivationPath, + bitgo: this.bitgo, }); - } catch (e) { - throw new Error(`Error decrypting user keychain: ${e.message}`); - } - /** TODO BG-52419 Implement Codec for parsing */ - const userSigningMaterial = JSON.parse(userPrv) as EDDSAMethodTypes.UserSigningMaterial; + } else { + 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; - /* ********************** END ***********************************/ - - // add signature - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, - derivationPath, - unsignedTransaction - ); - const publicKeyObj = { pub: senderAddress }; - txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex); + 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; + signatureHex = await EDDSAMethods.getTSSSignature( + userSigningMaterial, + backupSigningMaterial, + derivationPath, + unsignedTransaction + ); + } + + txBuilder.addSignature({ pub: senderAddress } as PublicKey, signatureHex); const completedTransaction = await txBuilder.build(); return completedTransaction.toBroadcastFormat(); } + private async isMpcv2SigningMaterial( + userKey?: string, + backupKey?: string, + walletPassphrase?: string + ): Promise { + if (!walletPassphrase) return false; + if (!userKey) throw new Error('missing userKey'); + if (!backupKey) throw new Error('missing backupKey'); + const material = await getEddsaSigningMaterial(userKey.replace(/\s/g, ''), walletPassphrase, this.bitgo); + return material.version === 'v2'; + } + async createBroadcastableSweepTransaction(params: MPCSweepRecoveryOptions): Promise { const req = params.signatureShares; const broadcastableTransactions: MPCTx[] = []; diff --git a/modules/sdk-coin-near/test/unit/near.ts b/modules/sdk-coin-near/test/unit/near.ts index a4e603cf31..8561438bdc 100644 --- a/modules/sdk-coin-near/test/unit/near.ts +++ b/modules/sdk-coin-near/test/unit/near.ts @@ -6,11 +6,13 @@ import sinon from 'sinon'; import nock from 'nock'; import assert from 'assert'; -import { BitGoAPI } from '@bitgo/sdk-api'; +import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; -import { common, TransactionPrebuild, Wallet } from '@bitgo/sdk-core'; +import { common, EDDSAMethods, MPCTx, TransactionPrebuild, Wallet } from '@bitgo/sdk-core'; +import { MPSUtil } from '@bitgo/sdk-lib-mpc'; +import * as nearAPI from 'near-api-js'; import { KeyPair, Near, TNear, Transaction } from '../../src'; import nearUtils from '../../src/lib/utils'; import { getBuilderFactory } from './getBuilderFactory'; @@ -1565,4 +1567,251 @@ describe('NEAR:', function () { ); }); }); + + describe('Recover Transactions (MPCv2):', () => { + const mpcV2SandBox = sinon.createSandbox(); + let callBack: sinon.SinonStub; + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2AccountId: string; + let mpcV2Bs58EncodedPublicKey: string; + let mpcV2TokenUserKey: string; + let mpcV2TokenBackupKey: string; + let mpcV2TokenCommonKeyChain: string; + let mpcV2TokenAccountId: string; + let mpcV2TokenBs58EncodedPublicKey: string; + let mismatchedBitgoKey: string; + const walletPassphrase = 'test-passphrase-mpcv2'; + const coin = coins.get('tnear'); + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [tokenUserDkg, tokenBackupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [otherUserDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + + mpcV2UserKey = await encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + + const mpc = await EDDSAMethods.getInitializedMpcInstance(); + mpcV2AccountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + mpcV2Bs58EncodedPublicKey = nearAPI.utils.serialize.base_encode( + new Uint8Array(Buffer.from(mpcV2AccountId, 'hex')) + ); + + mismatchedBitgoKey = otherUserDkg.getCommonKeychain(); + + mpcV2TokenUserKey = await encrypt(walletPassphrase, tokenUserDkg.getReducedKeyShare().toString('base64')); + mpcV2TokenBackupKey = await encrypt(walletPassphrase, tokenBackupDkg.getReducedKeyShare().toString('base64')); + mpcV2TokenCommonKeyChain = tokenUserDkg.getCommonKeychain(); + mpcV2TokenAccountId = mpc.deriveUnhardened(mpcV2TokenCommonKeyChain, 'm/0').slice(0, 64); + mpcV2TokenBs58EncodedPublicKey = nearAPI.utils.serialize.base_encode( + new Uint8Array(Buffer.from(mpcV2TokenAccountId, 'hex')) + ); + }); + + beforeEach(() => { + callBack = mpcV2SandBox.stub(Near.prototype, 'getDataFromNode' as keyof Near); + callBack.withArgs().resolves(NearResponses.getProtocolConfigResp); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'gas_price', + params: [accountInfo.blockHash], + }, + }) + .resolves(NearResponses.getGasPriceResponse); + }); + + afterEach(() => { + mpcV2SandBox.restore(); + }); + + it('should route to MPCv2 path for native NEAR recovery when keycard is MPCv2', async function () { + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_access_key', + finality: 'final', + account_id: mpcV2AccountId, + public_key: mpcV2Bs58EncodedPublicKey, + }, + }, + }) + .resolves(NearResponses.getAccessKeyResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_account', + finality: 'final', + account_id: mpcV2AccountId, + }, + }, + }) + .resolves(NearResponses.getAccountResponse); + + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const result = await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: accountInfo.recoveryDestination, + walletPassphrase, + }); + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal((result as MPCTx).scanIndex, 0); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); + + const recovered = new Transaction(coin); + recovered.fromRawTransaction((result as MPCTx).serializedTx); + const json = recovered.toJson(); + should.equal(json.signerId, mpcV2AccountId); + should.equal(json.publicKey, 'ed25519:' + mpcV2Bs58EncodedPublicKey); + }); + + it('should throw when MPCv2 commonKeyChain does not match bitgoKey', async function () { + const mismatchedAccountId = (await EDDSAMethods.getInitializedMpcInstance()) + .deriveUnhardened(mismatchedBitgoKey, 'm/0') + .slice(0, 64); + const mismatchedBs58 = nearAPI.utils.serialize.base_encode( + new Uint8Array(Buffer.from(mismatchedAccountId, 'hex')) + ); + + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_access_key', + finality: 'final', + account_id: mismatchedAccountId, + public_key: mismatchedBs58, + }, + }, + }) + .resolves(NearResponses.getAccessKeyResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_account', + finality: 'final', + account_id: mismatchedAccountId, + }, + }, + }) + .resolves(NearResponses.getAccountResponse); + + await basecoin + .recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mismatchedBitgoKey, + recoveryDestination: accountInfo.recoveryDestination, + walletPassphrase, + }) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + + it('should route to MPCv2 path for NEP141 FT token recovery when keycard is MPCv2', async function () { + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_access_key', + finality: 'final', + account_id: mpcV2TokenAccountId, + public_key: mpcV2TokenBs58EncodedPublicKey, + }, + }, + }) + .resolves(NearResponses.getAccessKeyResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_account', + finality: 'final', + account_id: mpcV2TokenAccountId, + }, + }, + }) + .resolves(NearResponses.getAccountResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'call_function', + finality: 'final', + account_id: accountInfo.tokenContractAddress, + method_name: 'ft_balance_of', + args_base64: nearUtils.convertToBase64({ account_id: mpcV2TokenAccountId }), + }, + }, + }) + .resolves(NearResponses.getAccountFungibleTokenBalanceResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'call_function', + finality: 'final', + account_id: accountInfo.tokenContractAddress, + method_name: 'storage_balance_of', + args_base64: nearUtils.convertToBase64({ account_id: accountInfo.recoveryDestination }), + }, + }, + }) + .resolves(NearResponses.getStorageBalanceResponsePresent); + + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const result = await basecoin.recover({ + userKey: mpcV2TokenUserKey, + backupKey: mpcV2TokenBackupKey, + bitgoKey: mpcV2TokenCommonKeyChain, + recoveryDestination: accountInfo.recoveryDestination, + walletPassphrase, + tokenContractAddress: accountInfo.tokenContractAddress, + }); + + result.should.not.be.empty(); + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal((result as MPCTx).scanIndex, 0); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); + }); + }); });