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 82225fdd06..fc1f458ea9 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:', () => { @@ -1700,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