From 60fd47b68b7d319cf8f7a66811273adbea76ed64 Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Tue, 25 Aug 2026 16:14:49 +0530 Subject: [PATCH] feat(sdk-coin-near): support MPCv2 consolidation recovery Add MPCv2 signed hot recovery support to recoverConsolidations(), following the same precomputedMaterial pattern used by IOTA/DOT/SUI: - recover() accepts an optional precomputedMaterial param and threads it through recoverNearToken()/signRecoveryTransaction() instead of re-detecting the keycard version per call - signRecoveryTransaction() reuses precomputedMaterial when supplied, otherwise detects the keycard version once via getEddsaSigningMaterial() - recoverConsolidations() detects signing material once before the scan loop and passes it into each recover() call, avoiding re-decryption per index; base address (index 0) is derived once via the shared EDDSAMethods.getInitializedMpcInstance() derivation used elsewhere - Add MPCv2 consolidation test coverage (signed sweep across two funded indexes, unsigned cold sweep, detection called exactly once) plus an MPCv1 regression test This covers the SDK hot signed path only, per ticket scope; WRW always strips walletPassphrase so WRW consolidation is unaffected. TICKET: WCI-1233 --- modules/sdk-coin-near/src/near.ts | 118 +++++++++++++- modules/sdk-coin-near/test/unit/near.ts | 202 ++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 7 deletions(-) diff --git a/modules/sdk-coin-near/src/near.ts b/modules/sdk-coin-near/src/near.ts index 28010fabe6..934d50fecd 100644 --- a/modules/sdk-coin-near/src/near.ts +++ b/modules/sdk-coin-near/src/near.ts @@ -23,6 +23,7 @@ import { getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, KeyPair, MPCAlgorithm, + MPCConsolidationRecoveryOptions, MPCRecoveryOptions, MPCSweepRecoveryOptions, MPCSweepTxs, @@ -347,8 +348,10 @@ export class Near extends BaseCoin { /** * Builds a funds recovery transaction without BitGo * @param params + * @param {EddsaSigningMaterial} [precomputedMaterial] signing material detected once by the + * caller (e.g. recoverConsolidations) to avoid re-decrypting the keycard on every loop iteration */ - async recover(params: MPCRecoveryOptions): Promise { + async recover(params: MPCRecoveryOptions, precomputedMaterial?: EddsaSigningMaterial): Promise { if (!params.bitgoKey) { throw new Error('missing bitgoKey'); } @@ -449,7 +452,8 @@ export class Near extends BaseCoin { bitgoKey, isStorageDepositEnabled, availableTokenBalance, - isUnsignedSweep + isUnsignedSweep, + precomputedMaterial ); } @@ -483,7 +487,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, precomputedMaterial); } else { return this.buildUnsignedSweepTransaction( txBuilder, @@ -501,6 +505,97 @@ export class Near extends BaseCoin { throw new Error('Did not find an address with funds to recover'); } + /** + * Consolidates funds from multiple receive addresses to the base address (index 0). + * If walletPassphrase is not provided, returns unsigned transactions for offline signing + * (cold/custody wallet recovery). Otherwise, returns signed transactions. + * + * @param params - Consolidation recovery parameters + * @param params.bitgoKey - The commonKeychain (combined TSS public key) + * @param params.startingScanIndex - Starting address index to scan (default: 1) + * @param params.endingScanIndex - Ending address index to scan (default: startingScanIndex + 20) + * @param params.walletPassphrase - Optional passphrase for signing (omit for unsigned transactions) + * @returns MPCTxs (signed) or MPCSweepTxs (unsigned) containing all consolidation transactions + * @throws Error if no addresses with funds are found in the scan range + */ + async recoverConsolidations(params: MPCConsolidationRecoveryOptions): Promise { + const isUnsignedSweep = !params.walletPassphrase; + + const startIdx = params.startingScanIndex ?? 1; + if (!Number.isInteger(startIdx) || startIdx < 1) { + throw new Error('Invalid starting index to scan for addresses'); + } + const endIdx = params.endingScanIndex ?? startIdx + 20; + if (!Number.isInteger(endIdx) || endIdx <= startIdx || endIdx - startIdx > 200) { + throw new Error( + `Invalid starting or ending index to scan for addresses. startingScanIndex: ${startIdx}, endingScanIndex: ${endIdx}.` + ); + } + + const bitgoKey = params.bitgoKey.replace(/\s/g, ''); + const userKey = params.userKey?.replace(/\s/g, ''); + + // Detect signing material once to avoid re-decrypting the keycard on every loop iteration. + const signingMaterial = + userKey && params.walletPassphrase + ? await this.getEddsaSigningMaterial(userKey, params.walletPassphrase) + : undefined; + + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + const baseAccountId = MPC.deriveUnhardened(bitgoKey, 'm/0').slice(0, 64); + + const consolidationTransactions: Array = []; + let lastScanIndex = startIdx; + + for (let idx = startIdx; idx < endIdx; idx++) { + const recoverParams: MPCRecoveryOptions = { + userKey: params.userKey, + backupKey: params.backupKey, + bitgoKey: params.bitgoKey, + walletPassphrase: params.walletPassphrase, + seed: params.seed, + tokenContractAddress: params.tokenContractAddress, + recoveryDestination: baseAccountId, // Consolidate to base address + startingScanIndex: idx, + scan: 1, + }; + + let recoveryTransaction: MPCTx | MPCSweepTxs; + try { + recoveryTransaction = await this.recover(recoverParams, signingMaterial); + } catch (e) { + if ((e as Error).message.startsWith('Did not find an address with funds to recover')) { + lastScanIndex = idx; + continue; + } + throw e; + } + + if (isUnsignedSweep) { + consolidationTransactions.push((recoveryTransaction as MPCSweepTxs).txRequests[0]); + } else { + consolidationTransactions.push(recoveryTransaction as MPCTx); + } + lastScanIndex = idx; + } + + if (consolidationTransactions.length === 0) { + throw new Error( + `Did not find an address with sufficient funds to recover. Please start the next scan at address index ${ + lastScanIndex + 1 + }.` + ); + } + + if (isUnsignedSweep) { + const txRequests = consolidationTransactions as RecoveryTxRequest[]; + txRequests[txRequests.length - 1].transactions[0].unsignedTx.coinSpecific!.lastScanIndex = lastScanIndex; + return { txRequests }; + } + + return { transactions: consolidationTransactions as MPCTx[], lastScanIndex }; + } + /** * Function to handle near token recovery * @param {MPCRecoveryOptions} params mpc recovery options input @@ -523,7 +618,8 @@ export class Near extends BaseCoin { bitgoKey: string, isStorageDepositEnabled: boolean, availableTokenBalance: BigNumber, - isUnsignedSweep: boolean + isUnsignedSweep: boolean, + precomputedMaterial?: EddsaSigningMaterial ): Promise { const factory = new TransactionBuilderFactory(token); const bs58EncodedPublicKey = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(senderAddress, 'hex'))); @@ -558,7 +654,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, + precomputedMaterial + ); return { serializedTx: serializedTx, scanIndex: idx }; } } @@ -656,7 +758,8 @@ export class Near extends BaseCoin { txBuilder: TransactionBuilder, params: MPCRecoveryOptions, derivationPath: string, - senderAddress: string + senderAddress: string, + precomputedMaterial?: EddsaSigningMaterial ): Promise { const unsignedTransaction = (await txBuilder.build()) as Transaction; @@ -673,7 +776,8 @@ export class Near extends BaseCoin { const backupKey = params.backupKey.replace(/\s/g, ''); const bitgoKey = params.bitgoKey.replace(/\s/g, ''); - const signingMaterial = await this.getEddsaSigningMaterial(params.userKey, params.walletPassphrase); + const signingMaterial = + precomputedMaterial ?? (await this.getEddsaSigningMaterial(params.userKey, params.walletPassphrase)); let signatureHex: Buffer; if (signingMaterial.version === 'v2') { diff --git a/modules/sdk-coin-near/test/unit/near.ts b/modules/sdk-coin-near/test/unit/near.ts index 68cf64c1d3..f3e8de1087 100644 --- a/modules/sdk-coin-near/test/unit/near.ts +++ b/modules/sdk-coin-near/test/unit/near.ts @@ -1901,4 +1901,206 @@ describe('NEAR:', function () { isValidSignature.should.be.true(); }); }); + + describe('Recover Consolidations (MPCv2):', () => { + const consolidationSandbox = sinon.createSandbox(); + const walletPassphrase = 'test-passphrase-mpcv2-consolidation'; + let callBack: sinon.SinonStub; + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let baseAccountId: string; + let address1: string; + let address2: string; + let bs58Address1: string; + let bs58Address2: string; + const coin = coins.get('tnear'); + + before(async function () { + const [userDkg, backupDkg] = 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(); + baseAccountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + address1 = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/1').slice(0, 64); + address2 = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/2').slice(0, 64); + bs58Address1 = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(address1, 'hex'))); + bs58Address2 = nearAPI.utils.serialize.base_encode(new Uint8Array(Buffer.from(address2, 'hex'))); + }); + + beforeEach(() => { + callBack = consolidationSandbox.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); + + for (const [addr, bs58] of [ + [address1, bs58Address1], + [address2, bs58Address2], + ]) { + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_access_key', + finality: 'final', + account_id: addr, + public_key: bs58, + }, + }, + }) + .resolves(NearResponses.getAccessKeyResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_account', + finality: 'final', + account_id: addr, + }, + }, + }) + .resolves(NearResponses.getAccountResponse); + } + }); + + afterEach(() => { + consolidationSandbox.restore(); + }); + + it('should detect MPCv2 signing material once and sweep two recoveries to the base address', async function () { + const getEddsaSigningMaterialSpy = consolidationSandbox.spy( + Near.prototype as unknown as { getEddsaSigningMaterial: unknown }, + 'getEddsaSigningMaterial' + ); + + const res = (await basecoin.recoverConsolidations({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + walletPassphrase, + startingScanIndex: 1, + endingScanIndex: 3, + })) as { transactions: Array<{ scanIndex: number; serializedTx: string }> }; + + res.transactions.length.should.equal(2); + res.transactions[0].scanIndex.should.equal(1); + res.transactions[1].scanIndex.should.equal(2); + + const recovered1 = new Transaction(coin); + recovered1.fromRawTransaction(res.transactions[0].serializedTx); + recovered1.toJson().receiverId.should.equal(baseAccountId); + + const recovered2 = new Transaction(coin); + recovered2.fromRawTransaction(res.transactions[1].serializedTx); + recovered2.toJson().receiverId.should.equal(baseAccountId); + + // Detected once up front in recoverConsolidations(), not per scanned index. + consolidationSandbox.assert.calledOnce(getEddsaSigningMaterialSpy); + }); + + it('should return unsigned MPCv2 consolidation sweeps when walletPassphrase is absent', async function () { + const res = (await basecoin.recoverConsolidations({ + bitgoKey: mpcV2CommonKeyChain, + startingScanIndex: 1, + endingScanIndex: 3, + })) as { txRequests: Array<{ transactions: Array<{ unsignedTx: { scanIndex: number } }> }> }; + + res.txRequests.length.should.equal(2); + res.txRequests[0].transactions[0].unsignedTx.scanIndex.should.equal(1); + res.txRequests[1].transactions[0].unsignedTx.scanIndex.should.equal(2); + }); + }); + + describe('Recover Consolidations (MPCv1 regression):', () => { + const sandBox = sinon.createSandbox(); + const coin = coins.get('tnear'); + const address1Info = { + accountId: 'f6842bf4a8e980704fbd9fb799bfbe0a116fd5d8d06f6774e792c68c907d9b20', + bs58EncodedPublicKey: 'HbJBqyagBqtSNUR74fLMQSjQ8HyQVs66fyMySPhZLXz7', + blockHash: '844N9aWefd4TvJwdiBgXDVPz4W9z436kohTiXnp5y4fq', + }; + + beforeEach(function () { + const callBack = sandBox.stub(Near.prototype, 'getDataFromNode' as keyof Near); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_access_key', + finality: 'final', + account_id: address1Info.accountId, + public_key: address1Info.bs58EncodedPublicKey, + }, + }, + }) + .resolves(NearResponses.getAccessKeyResponse); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'query', + params: { + request_type: 'view_account', + finality: 'final', + account_id: address1Info.accountId, + }, + }, + }) + .resolves(NearResponses.getAccountResponse); + callBack.withArgs().resolves(NearResponses.getProtocolConfigResp); + callBack + .withArgs({ + payload: { + jsonrpc: '2.0', + id: 'dontcare', + method: 'gas_price', + params: [address1Info.blockHash], + }, + }) + .resolves(NearResponses.getGasPriceResponse); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should sweep an MPCv1 signed consolidation to the legacy-derived base address, unchanged', async function () { + const res = (await basecoin.recoverConsolidations({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + walletPassphrase: 'Ghghjkg!455544llll', + startingScanIndex: 1, + endingScanIndex: 2, + })) as { transactions: Array<{ scanIndex: number; serializedTx: string }> }; + + res.transactions.length.should.equal(1); + res.transactions[0].scanIndex.should.equal(1); + + const recovered = new Transaction(coin); + recovered.fromRawTransaction(res.transactions[0].serializedTx); + recovered.toJson().receiverId.should.equal(accountInfo.accountId); + }); + }); });