From 499430857b76bc7b0dd67989b42100963c3ffdc4 Mon Sep 17 00:00:00 2001 From: "vibhavgopalkrishna145@bitgo.com" Date: Thu, 13 Aug 2026 10:30:31 +0000 Subject: [PATCH 1/2] test(sdk-coin-sui): cover MPCv2 recovery for token transfers Add an MPCv2-signed recovery test under the token recovery describe block, mirroring the native-transfer MPCv2 test added in the prior commit. Asserts getTSSSignature is not called and the resulting signature is wrapped in SUI's 0x00-flag envelope. The prior commit wired signRecoveryTransaction()'s MPCv2 dispatch into both recover() and recoverSuiToken(), but only exercised the native-transfer path in tests. The ticket explicitly calls out that token recovery (getTokenTransferBuilder) must also handle MPCv2, so this closes that coverage gap found during review. Ticket: WCI-1224 Session-Id: 0c067bc3-368d-4c82-9e68-8d08c89766d5 Task-Id: 8c5b0392-4a96-4479-a83b-8ba80cd2428d --- modules/sdk-coin-sui/test/unit/sui.ts | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/modules/sdk-coin-sui/test/unit/sui.ts b/modules/sdk-coin-sui/test/unit/sui.ts index 82225fdd06..6a1e59b994 100644 --- a/modules/sdk-coin-sui/test/unit/sui.ts +++ b/modules/sdk-coin-sui/test/unit/sui.ts @@ -1147,6 +1147,74 @@ describe('SUI:', function () { sandBox.assert.callCount(basecoin.getInputCoins, 2); sandBox.assert.callCount(basecoin.getFeeEstimate, 1); }); + + describe('MPCv2 signed recovery', function () { + const mpcV2WalletPassphrase = 'test-passphrase-mpcv2-token'; + + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2WalletAddress: string; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + mpcV2UserKey = await encrypt(mpcV2WalletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = await encrypt(mpcV2WalletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + + const mpc = await EDDSAMethods.getInitializedMpcInstance(); + const accountId = mpc.deriveUnhardened(mpcV2CommonKeyChain, 'm/0').slice(0, 64); + mpcV2WalletAddress = utils.getAddressFromPublicKey(accountId); + }); + + it('should recover a token txn using MPCv2 signing material without calling getTSSSignature', async function () { + getBalanceStub + .withArgs(mpcV2WalletAddress) + .resolves({ totalBalance: '1900000000', coinObjectBalance: '1900000000', fundsInAddressBalance: '0' }) + .withArgs(mpcV2WalletAddress, coinType) + .resolves({ totalBalance: '1000', coinObjectBalance: '1000', fundsInAddressBalance: '0' }); + getInputCoinsStub.withArgs(mpcV2WalletAddress, coinType).resolves([ + { + coinType: '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8::deep::DEEP', + objectId: '0x924ab69ebba304f2975a588372b41e4e1f5db7fa824868f84199eeb1e0a15a2d', + version: '34696807', + digest: '7XRbWQTiwAUCjLLsZVpJMrABCheJBkzKVfCr7aTZZVkd', + balance: new BigNumber(1000), + }, + ]); + getInputCoinsStub.withArgs(mpcV2WalletAddress).resolves([ + { + coinType: '0x2::sui::SUI', + objectId: '0x9146928f557cb8ab1915a5886c1362435a05b4709b586bb01d4c70e85bb53161', + version: '239', + digest: 'GLSzR6HJ319nPKAFm5x3TWHcaHZzCFSBCqhvZ1qwT5wr', + balance: new BigNumber('1230261076'), + }, + ]); + getFeeEstimateStub.resolves(new BigNumber(2345504)); + + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const res = (await basecoin.recover({ + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination, + walletPassphrase: mpcV2WalletPassphrase, + tokenContractAddress, + })) as MPCTxs; + + res.should.not.be.empty(); + res.should.hasOwnProperty('transactions'); + const tx = res.transactions[0]; + should.equal(tx.scanIndex, 0); + (tx.serializedTx as string).should.be.a.String().and.not.be.empty(); + sandBox.assert.notCalled(getTSSSignatureSpy); + + // The SUI signature envelope is 1 (flag) + 64 (signature) + 32 (pubkey) bytes. + Buffer.from(tx.signature as string, 'base64').length.should.equal(97); + }); + }); }); describe('Recover Transactions for wallet with multiple addresses:', () => { From 3e17fea91e5cd1053df8fc8b8962869c5f2f41b9 Mon Sep 17 00:00:00 2001 From: "vibhavgopalkrishna145@bitgo.com" Date: Thu, 13 Aug 2026 12:12:49 +0000 Subject: [PATCH 2/2] feat(sdk-coin-sui): add MPCv2 support to recoverConsolidations Add an optional precomputedMaterial parameter to Sui.recover() and thread it through recoverSuiToken()/signRecoveryTransaction() so the caller can supply pre-detected EddsaSigningMaterial and skip the getEddsaSigningMaterial detection call. recoverConsolidations() now detects signing material once via getEddsaSigningMaterial before its scan loop and passes it into every recover() call, instead of letting each iteration independently decrypt the keycard (as it did even after WCI-1224 added MPCv2 dispatch to recover()). Consolidating a wide range of receive-address indexes on an MPCv2 hot wallet would decrypt the same keycard once per scanned index, which is wasteful and slow for the default 20-address scan window (and worse for wider ranges). This mirrors the pattern already landed for DOT (WCI-1236). Ticket: WCI-1234 Session-Id: bf130f11-6bd0-4a91-88c6-a9138ecc96ab Task-Id: 179247fb-4ff6-49f3-9849-df120620c98b --- modules/sdk-coin-sui/src/sui.ts | 38 ++++-- modules/sdk-coin-sui/test/unit/sui.ts | 161 ++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 8 deletions(-) diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index cd4c25b9ed..b050d4d4c4 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -336,8 +336,10 @@ export class Sui extends BaseCoin { * * @returns {MPCTx | MPCSweepTxs} array of the serialized transaction hex strings and indices * of the addresses being swept + * @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'); } @@ -388,7 +390,16 @@ export class Sui extends BaseCoin { } catch (e) { continue; } - return this.recoverSuiToken(params, token, senderAddress, derivationPath, derivedPublicKey, idx, bitgoKey); + return this.recoverSuiToken( + params, + token, + senderAddress, + derivationPath, + derivedPublicKey, + idx, + bitgoKey, + precomputedMaterial + ); } let inputCoins = await this.getInputCoins(senderAddress); @@ -468,7 +479,7 @@ export class Sui extends BaseCoin { return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath); } - await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, false); + await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, false, precomputedMaterial); const tx = (await txBuilder.build()) as TransferTransaction; return { transactions: [ @@ -496,7 +507,8 @@ export class Sui extends BaseCoin { derivationPath: string, derivedPublicKey: string, idx: number, - bitgoKey: string + bitgoKey: string, + precomputedMaterial?: EddsaSigningMaterial ): Promise { const coinType = `${token.packageId}::${token.module}::${token.symbol}`; let tokenObjects = await this.getInputCoins(senderAddress, coinType); @@ -559,7 +571,7 @@ export class Sui extends BaseCoin { return this.buildUnsignedSweepTransaction(txBuilder, senderAddress, bitgoKey, idx, derivationPath, token); } - await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, true); + await this.signRecoveryTransaction(txBuilder, params, derivationPath, derivedPublicKey, true, precomputedMaterial); const tx = (await txBuilder.build()) as TokenTransferTransaction; return { transactions: [ @@ -656,7 +668,8 @@ export class Sui extends BaseCoin { params: MPCRecoveryOptions, derivationPath: string, derivedPublicKey: string, - isTokenTransaction: boolean + isTokenTransaction: boolean, + precomputedMaterial?: EddsaSigningMaterial ) { // TODO(BG-51092): This looks like a common part which can be extracted out too const unsignedTx = isTokenTransaction @@ -671,7 +684,8 @@ export class Sui extends BaseCoin { const backupKey = params.backupKey.replace(/\s/g, ''); const bitgoKey = params.bitgoKey.replace(/\s/g, ''); - const signingMaterial = await this.getEddsaSigningMaterial(userKey, params.walletPassphrase); + const signingMaterial = + precomputedMaterial ?? (await this.getEddsaSigningMaterial(userKey, params.walletPassphrase)); if (signingMaterial.version === 'v2') { const signature = await this.signSuiMpcV2Recovery({ @@ -806,6 +820,14 @@ export class Sui extends BaseCoin { } 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 derivationPath = (params.seed ? getDerivationPath(params.seed) : 'm') + '/0'; const derivedPublicKey = MPC.deriveUnhardened(bitgoKey, derivationPath).slice(0, 64); @@ -828,7 +850,7 @@ export class Sui extends BaseCoin { let recoveryTransaction: MPCTxs | MPCSweepTxs; try { - recoveryTransaction = await this.recover(recoverParams); + recoveryTransaction = await this.recover(recoverParams, signingMaterial); } catch (e) { if (e.message.startsWith('Did not find an address with sufficient funds to recover.')) { lastScanIndex = idx; diff --git a/modules/sdk-coin-sui/test/unit/sui.ts b/modules/sdk-coin-sui/test/unit/sui.ts index 6a1e59b994..fc1f458ea9 100644 --- a/modules/sdk-coin-sui/test/unit/sui.ts +++ b/modules/sdk-coin-sui/test/unit/sui.ts @@ -1768,6 +1768,167 @@ describe('SUI:', function () { }); }); + describe('Build Consolidation Recoveries (MPCv2):', () => { + const sandBox = sinon.createSandbox(); + const walletPassphrase = 'p$Sw { const sandBox = sinon.createSandbox(); const walletPassphrase = 'p$Sw