diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 65dd0ba894..81ebb9061b 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -8,6 +8,7 @@ import * as base58 from 'bs58'; import * as _ from 'lodash'; import * as request from 'superagent'; import { logger } from '@bitgo/logger'; +import assert from 'assert'; import { AuditDecryptedKeyParams, @@ -1226,28 +1227,7 @@ export class Sol extends BaseCoin { const isUnsignedSweep = !params.walletPassphrase; // Validate signing keys and detect MPCv2 format early for signed recovery - let isMpcV2 = false; - if (params.walletPassphrase) { - if (!params.userKey) { - throw new Error('missing userKey'); - } - if (!params.backupKey) { - throw new Error('missing backupKey'); - } - // Detect MPCv2 keycards — will throw if decryption fails (e.g., wrong password). - // MPCv1 keycards decrypt to JSON with uShare/bitgoYShare; MPCv2 keycards are CBOR. - try { - const isV1 = await EDDSAUtils.EddsaMPCv2RecoveryFunctions.isEddsaMpcV1SigningMaterial( - params.userKey.replace(/\s/g, ''), - params.walletPassphrase, - this.bitgo - ); - isMpcV2 = !isV1; - } catch (e) { - // Re-wrap decryption errors with context - throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); - } - } + const isMpcV2 = await this.isMpcv2SigningMaterial(params.userKey, params.backupKey, params.walletPassphrase); let balance = 0; @@ -1448,63 +1428,15 @@ export class Sol extends BaseCoin { // build the transaction with fee const unsignedTransaction = (await txBuilder.build()) as Transaction; - const userKey = params.userKey.replace(/\s/g, ''); - const backupKey = params.backupKey.replace(/\s/g, ''); - - if (!isMpcV2) { - // MPCv1 path: decrypt JSON signing material and use TSS signing - let userPrv: string; - try { - userPrv = await this.bitgo.decryptAsync({ - 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: string; - try { - backupPrv = await this.bitgo.decryptAsync({ - input: backupKey, - 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: bs58EncodedPublicKey } as PublicKey, signatureHex); - } else { - // MPCv2 path: decrypt CBOR reduced key shares and sign with MPS DSG protocol - const { userKeyShare, backupKeyShare, commonKeyChain } = - await EDDSAUtils.EddsaMPCv2RecoveryFunctions.getEddsaMpcV2RecoveryKeySharesFromReducedKey( - userKey, - backupKey, - params.walletPassphrase, - this.bitgo - ); - - if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) { - throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); - } - - const signature = EDDSAUtils.EddsaMPCv2RecoveryFunctions.signRecoveryEddsaMPCv2( - unsignedTransaction.signablePayload, - currPath, - userKeyShare, - backupKeyShare, - commonKeyChain - ); - txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signature); - } + await this.addRecoverySignature( + params, + txBuilder, + bs58EncodedPublicKey, + isMpcV2, + unsignedTransaction, + currPath, + bitgoKey + ); } if (params.durableNonce) { @@ -1585,13 +1517,22 @@ export class Sol extends BaseCoin { const bitgoKey = params.bitgoKey.replace(/\s/g, ''); + // Validate signing keys and detect MPCv2 format early + const isMpcV2 = await this.isMpcv2SigningMaterial(params.userKey, params.backupKey, params.walletPassphrase); + // Build the transaction - const MPC = await EDDSAMethods.getInitializedMpcInstance(); let balance = 0; const index = params.index || 0; const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`; - const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); + + let accountId: string; + if (isMpcV2) { + accountId = deriveUnhardenedMps(bitgoKey, currPath).slice(0, 64); + } else { + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); + } const bs58EncodedPublicKey = new SolKeyPair({ pub: accountId }).getAddress(); const accountBalance = await this.getAccountBalance(bs58EncodedPublicKey); @@ -1653,7 +1594,8 @@ export class Sol extends BaseCoin { const tokenRecoveryTxn = await this.signAndGenerateBroadcastableTransaction( params, txBuilder, - bs58EncodedPublicKey + bs58EncodedPublicKey, + isMpcV2 ); const serializedTokenRecoveryTxn = (await tokenRecoveryTxn).serializedTx; const broadcastTokenRecoveryTxn = await this.broadcastTransaction({ @@ -1682,7 +1624,8 @@ export class Sol extends BaseCoin { const closeATARecoveryTxn = await this.signAndGenerateBroadcastableTransaction( params, txBuilder, - bs58EncodedPublicKey + bs58EncodedPublicKey, + isMpcV2 ); const serializedCloseATARecoveryTxn = (await closeATARecoveryTxn).serializedTx; const broadcastCloseATARecoveryTxn = await this.broadcastTransaction({ @@ -1771,10 +1714,10 @@ export class Sol extends BaseCoin { async signAndGenerateBroadcastableTransaction( params: SolRecoveryOptions, - txBuilder: any, - bs58EncodedPublicKey: string + txBuilder: TransactionBuilder, + bs58EncodedPublicKey: string, + isMpcV2 = false ): Promise { - // Sign the txn if (!params.userKey) { throw new Error('missing userKey'); } @@ -1788,48 +1731,19 @@ export class Sol extends BaseCoin { } const unsignedTransaction = (await txBuilder.build()) as Transaction; - - 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.decryptAsync({ - 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.decryptAsync({ - input: backupKey, - password: params.walletPassphrase, - }); - } catch (e) { - throw new Error(`Error decrypting backup keychain: ${e.message}`); - } - const backupSigningMaterial = JSON.parse(backupPrv) as EDDSAMethodTypes.BackupSigningMaterial; - const index = params.index || 0; const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`; - const signatureHex = await EDDSAMethods.getTSSSignature( - userSigningMaterial, - backupSigningMaterial, + await this.addRecoverySignature( + params, + txBuilder, + bs58EncodedPublicKey, + isMpcV2, + unsignedTransaction, currPath, - unsignedTransaction + params.bitgoKey.replace(/\s/g, '') ); - const publicKeyObj = { pub: bs58EncodedPublicKey }; - txBuilder.addSignature(publicKeyObj as PublicKey, signatureHex); - const completedTransaction = await txBuilder.build(); const serializedTx = completedTransaction.toBroadcastFormat(); const transaction: MPCTx = { @@ -1964,6 +1878,104 @@ export class Sol extends BaseCoin { return new TransactionBuilderFactory(coins.get(this.getChain())); } + private async addRecoverySignature( + params: SolRecoveryOptions, + txBuilder: TransactionBuilder, + bs58EncodedPublicKey: string, + isMpcV2: boolean, + unsignedTransaction: Transaction, + currPath: string, + bitgoKey: string + ): Promise { + assert(params.userKey, 'missing userKey'); + assert(params.backupKey, 'missing backupKey'); + const userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + + if (!isMpcV2) { + let userPrv: string; + try { + userPrv = await this.bitgo.decryptAsync({ + 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: string; + try { + backupPrv = await this.bitgo.decryptAsync({ + input: backupKey, + 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: bs58EncodedPublicKey } as PublicKey, signatureHex); + } else { + const { userKeyShare, backupKeyShare, commonKeyChain } = + await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + userKey, + backupKey, + params.walletPassphrase!, + this.bitgo + ); + + if (commonKeyChain.toLowerCase() !== bitgoKey.toLowerCase()) { + throw new Error('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + } + + const signature = EDDSAUtils.signRecoveryEddsaMPCv2( + unsignedTransaction.signablePayload, + currPath, + userKeyShare, + backupKeyShare, + commonKeyChain + ); + txBuilder.addSignature({ pub: bs58EncodedPublicKey } as PublicKey, signature); + } + } + + private async isMpcv2SigningMaterial( + userKey?: string, + backupKey?: string, + walletPassphrase?: string + ): Promise { + let isMpcV2 = false; + if (walletPassphrase) { + if (!userKey) { + throw new Error('missing userKey'); + } + if (!backupKey) { + throw new Error('missing backupKey'); + } + // Detect MPCv2 keycards — will throw if decryption fails (e.g., wrong password). + // MPCv1 keycards decrypt to JSON with uShare/bitgoYShare; MPCv2 keycards are CBOR. + try { + const isV1 = await EDDSAUtils.isEddsaMpcV1SigningMaterial( + userKey.replace(/\s/g, ''), + walletPassphrase, + this.bitgo + ); + isMpcV2 = !isV1; + } catch (e) { + // Re-wrap decryption errors with context + throw new Error(`Error decrypting user keychain: ${e instanceof Error ? e.message : String(e)}`); + } + } + return isMpcV2; + } + async broadcastTransaction({ serializedSignedTransaction, }: BaseBroadcastTransactionOptions): Promise { diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index f11fb40b8d..0cf41263be 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -9,7 +9,7 @@ import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; import { common, - EDDSAUtils, + EDDSAMethods, Environments, generateRandomPassword, IWallet, @@ -23,9 +23,18 @@ import { Wallet, WalletCoinSpecific, } from '@bitgo/sdk-core'; +import { deriveUnhardenedMps, MPSUtil } from '@bitgo/sdk-lib-mpc'; +import * as sjcl from '@bitgo/sjcl'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; -import { getAmountBasedOnEndianness, KeyPair, Sol, SolVerifyTransactionOptions, Tsol } from '../../src'; +import { + getAmountBasedOnEndianness, + KeyPair, + Sol, + SolRecoveryOptions, + SolVerifyTransactionOptions, + Tsol, +} from '../../src'; import { Transaction } from '../../src/lib'; import { AtaInit, InstructionParams, TokenTransfer } from '../../src/lib/iface'; import { getAssociatedTokenAccountAddress } from '../../src/lib/utils'; @@ -2294,6 +2303,8 @@ describe('SOL:', function () { }); it('should recover a txn for non-bitgo recoveries (latest blockhash)', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + // Latest Blockhash Recovery (BitGo-less) const latestBlockHashTxn = await basecoin.recover({ userKey: testData.keys.userKey, @@ -2316,9 +2327,12 @@ describe('SOL:', function () { should.equal(latestBlockhashTxnJson.numSignatures, testData.SolInputData.latestBlockhashSignatures); const solCoin = basecoin as any; sandBox.assert.callCount(solCoin.getDataFromNode, 3); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover a txn for non-bitgo recoveries (durable nonce)', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + // Durable Nonce Recovery (BitGo-less) const durableNonceTxn = await basecoin.recover({ userKey: testData.keys.userKey, @@ -2346,6 +2360,7 @@ describe('SOL:', function () { should.equal(durableNonceTxnJson.numSignatures, testData.SolInputData.durableNonceSignatures); const solCoin = basecoin as any; sandBox.assert.callCount(solCoin.getDataFromNode, 4); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover a txn for unsigned sweep recoveries', async function () { @@ -2422,6 +2437,8 @@ describe('SOL:', function () { }); it('should recover sol tokens to recovery destination with no existing token accounts', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const tokenTxn = await basecoin.recover({ userKey: testData.wrwUser.userKey, backupKey: testData.wrwUser.backupKey, @@ -2476,9 +2493,12 @@ describe('SOL:', function () { const solCoin = basecoin as any; sandBox.assert.callCount(solCoin.getDataFromNode, 7); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover sol 2022 tokens to recovery destination with no existing token accounts', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const tokenTxn = await basecoin.recover({ userKey: testData.wrwUser.userKey, backupKey: testData.wrwUser.backupKey, @@ -2538,9 +2558,12 @@ describe('SOL:', function () { const solCoin = basecoin as any; sandBox.assert.callCount(solCoin.getDataFromNode, 7); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover sol tokens to recovery destination with existing token accounts', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const tokenTxn = await basecoin.recover({ userKey: testData.wrwUser.userKey, backupKey: testData.wrwUser.backupKey, @@ -2588,9 +2611,12 @@ describe('SOL:', function () { const solCoin = basecoin as any; sandBox.assert.callCount(solCoin.getDataFromNode, 7); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover sol 2022 tokens to recovery destination with existing token accounts', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const tokenTxn = await basecoin.recover({ userKey: testData.wrwUser.userKey, backupKey: testData.wrwUser.backupKey, @@ -2642,6 +2668,7 @@ describe('SOL:', function () { should.equal(instructionsData[1].params.sourceAddress, source2022TokenAccount); const solCoin = basecoin as any; sandBox.assert.callCount(solCoin.getDataFromNode, 7); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover sol tokens to recovery destination with existing token accounts for unsigned sweep recoveries', async function () { @@ -2698,7 +2725,8 @@ describe('SOL:', function () { }); it('should recover sol funds from ATA address for non-bitgo recoveries', async function () { - // close ATA address instruction type txn + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const closeATATxns = await basecoin.recoverCloseATA({ userKey: testData.closeATAkeys.userKey, backupKey: testData.closeATAkeys.backupKey, @@ -2717,6 +2745,39 @@ describe('SOL:', function () { closeATATxns[1].txId, '5oUBgXX4enGmFEspG64goy3PRysjfrekZGg3rZNkBHUCQFd482vrVWbfDcRYMBEJt65JXymfEPm8M6d89X4xV79n' ); + sandBox.assert.calledTwice(getTSSSignatureSpy); + }); + + it('should sign recoverCloseATA via signAndGenerateBroadcastableTransaction with MPCv1 keys', async function () { + const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); + const factory = (basecoin as any).getBuilder(); + const blockhash = testData.SolResponses.getBlockhashResponse.body.result.value.blockhash; + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(blockhash); + txBuilder.sender(testData.closeATAkeys.bs58EncodedPublicKey); + txBuilder.accountAddress(testData.closeATAkeys.closeAtaAddress); + txBuilder.destinationAddress(testData.closeATAkeys.destinationPubKey); + txBuilder.authorityAddress(testData.closeATAkeys.bs58EncodedPublicKey); + txBuilder.associatedTokenAccountRent('2039280'); + + const result = await basecoin.signAndGenerateBroadcastableTransaction( + { + userKey: testData.closeATAkeys.userKey, + backupKey: testData.closeATAkeys.backupKey, + bitgoKey: testData.closeATAkeys.bitgoKey, + recoveryDestination: testData.closeATAkeys.destinationPubKey, + walletPassphrase: testData.closeATAkeys.walletPassword, + closeAtaAddress: testData.closeATAkeys.closeAtaAddress, + }, + txBuilder, + testData.closeATAkeys.bs58EncodedPublicKey, + false + ); + + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal(result.scanIndex, 0); + sandBox.assert.calledOnce(getTSSSignatureSpy); }); it('should recover tokens from a nested ATA (ATA whose owner is another ATA)', async function () { @@ -2783,12 +2844,77 @@ describe('SOL:', function () { describe('Recover Transactions (MPCv2):', () => { const mpcV2SandBox = sinon.createSandbox(); - // MPCv2-derived address for wrwUser.bitgoKey at m/0 via deriveUnhardenedMps - const mpcV2WalletAddress = '4nPsUgktdtrPBf3V8GojBukffEbYPq7sQ15gSRNztk1n'; const usdtMintAddress = '9cgpBeNZ2HnLda7NWaaU1i3NyTstk2c4zCMUcoAGsi9C'; const t22mintAddress = '5NR1bQwLWqjbkhbQ1hx72HKJybbuvwkDnUZNoAZ2VhW6'; const token22ProgramId = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; let callBack: sinon.SinonStub; + let mpcV2UserKey: string; + let mpcV2BackupKey: string; + let mpcV2CommonKeyChain: string; + let mpcV2WalletAddress: string; + let mpcV2TokenUserKey: string; + let mpcV2TokenBackupKey: string; + let mpcV2TokenCommonKeyChain: string; + let mpcV2TokenWalletAddress: string; + let mismatchedBitgoKey: string; + let mismatchedWalletAddress: string; + let mpcV2RecoverParams: SolRecoveryOptions; + let mpcV2TokenRecoverParams: SolRecoveryOptions; + let closeAtaMpcV2Params: SolRecoveryOptions; + + before(async function () { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [tokenUserDkg, tokenBackupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [otherUserDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const walletPassphrase = testData.keys.walletPassword; + + mpcV2UserKey = sjcl.encrypt(walletPassphrase, userDkg.getReducedKeyShare().toString('base64')); + mpcV2BackupKey = sjcl.encrypt(walletPassphrase, backupDkg.getReducedKeyShare().toString('base64')); + mpcV2CommonKeyChain = userDkg.getCommonKeychain(); + mpcV2WalletAddress = new KeyPair({ + pub: deriveUnhardenedMps(mpcV2CommonKeyChain, 'm/0').slice(0, 64), + }).getAddress(); + mismatchedBitgoKey = otherUserDkg.getCommonKeychain(); + mismatchedWalletAddress = new KeyPair({ + pub: deriveUnhardenedMps(mismatchedBitgoKey, 'm/0').slice(0, 64), + }).getAddress(); + + mpcV2TokenUserKey = sjcl.encrypt(walletPassphrase, tokenUserDkg.getReducedKeyShare().toString('base64')); + mpcV2TokenBackupKey = sjcl.encrypt(walletPassphrase, tokenBackupDkg.getReducedKeyShare().toString('base64')); + mpcV2TokenCommonKeyChain = tokenUserDkg.getCommonKeychain(); + mpcV2TokenWalletAddress = new KeyPair({ + pub: deriveUnhardenedMps(mpcV2TokenCommonKeyChain, 'm/0').slice(0, 64), + }).getAddress(); + + mpcV2RecoverParams = { + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase, + }; + mpcV2TokenRecoverParams = { + userKey: mpcV2TokenUserKey, + backupKey: mpcV2TokenBackupKey, + bitgoKey: mpcV2TokenCommonKeyChain, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase, + tokenContractAddress: usdtMintAddress, + durableNonce: { + publicKey: testData.keys.durableNoncePubKey, + secretKey: testData.keys.durableNoncePrivKey, + }, + }; + closeAtaMpcV2Params = { + userKey: mpcV2UserKey, + backupKey: mpcV2BackupKey, + bitgoKey: mpcV2CommonKeyChain, + recoveryDestination: testData.closeATAkeys.destinationPubKey, + walletPassphrase, + closeAtaAddress: testData.closeATAkeys.closeAtaAddress, + recoveryDestinationAtaAddress: testData.closeATAkeys.recoveryDestinationAtaAddress, + }; + }); beforeEach(() => { callBack = mpcV2SandBox.stub(Sol.prototype, 'getDataFromNode' as keyof Sol); @@ -2799,16 +2925,9 @@ describe('SOL:', function () { }) .resolves(testData.SolResponses.getBlockhashResponse); - // MPCv2 derives address via deriveUnhardenedMps — different from MPCv1's MPC.deriveUnhardened. - // Derived from testData.keys.bitgoKey at m/0 using deriveUnhardenedMps. callBack .withArgs({ - payload: { - id: '1', - jsonrpc: '2.0', - method: 'getBalance', - params: ['6UhWoz6WMtnk1quBibd7KAuoshvSL7vSiPe3ycDw2Kh6'], - }, + payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2WalletAddress] }, }) .resolves(testData.SolResponses.getAccountBalanceResponse); @@ -2827,8 +2946,6 @@ describe('SOL:', function () { .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getMinimumBalanceForRentExemption', params: [165] } }) .resolves(testData.SolResponses.getMinimumBalanceForRentExemptionResponse); - // The stub signature (Buffer.alloc(64)) is not a valid Ed25519 signature so Solana's - // serializer would reject it. Bypass the check by returning a fixed hex string. mpcV2SandBox.stub(Transaction.prototype, 'toBroadcastFormat').returns('stub-serialized-tx'); }); @@ -2837,58 +2954,34 @@ describe('SOL:', function () { }); it('should route to MPCv2 path for native SOL recovery when keycard is MPCv2', async function () { - mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); - const getSharesStub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') - .resolves({ - userKeyShare: Buffer.alloc(32), - backupKeyShare: Buffer.alloc(32), - commonKeyChain: testData.keys.bitgoKey.replace(/\s/g, ''), - }); - const signMpcV2Stub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') - .returns(Buffer.alloc(64)); + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); - const result = await basecoin.recover({ - userKey: testData.keys.userKey, - backupKey: testData.keys.backupKey, - bitgoKey: testData.keys.bitgoKey, - recoveryDestination: testData.keys.destinationPubKey, - walletPassphrase: testData.keys.walletPassword, - }); + const result = await basecoin.recover(mpcV2RecoverParams); result.should.not.be.empty(); result.should.hasOwnProperty('serializedTx'); result.should.hasOwnProperty('scanIndex'); should.equal((result as MPCTx).scanIndex, 0); - mpcV2SandBox.assert.calledOnce(getSharesStub); - mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + should.equal( + callBack.getCalls().find((call) => call.args[0]?.payload?.method === 'getBalance')?.args[0].payload.params[0], + mpcV2WalletAddress + ); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); }); it('should throw when MPCv2 commonKeyChain does not match bitgoKey', async function () { - mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); - mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') - .resolves({ - userKeyShare: Buffer.alloc(32), - backupKeyShare: Buffer.alloc(32), - commonKeyChain: 'deadbeef'.repeat(16), // deliberate mismatch - }); + callBack + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mismatchedWalletAddress] } }) + .resolves(testData.SolResponses.getAccountBalanceResponse); await basecoin - .recover({ - userKey: testData.keys.userKey, - backupKey: testData.keys.backupKey, - bitgoKey: testData.keys.bitgoKey, - recoveryDestination: testData.keys.destinationPubKey, - walletPassphrase: testData.keys.walletPassword, - }) + .recover({ ...mpcV2RecoverParams, bitgoKey: mismatchedBitgoKey }) .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); }); it('should route to MPCv2 path for SPL token recovery when keycard is MPCv2', async function () { callBack - .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2WalletAddress] } }) + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2TokenWalletAddress] } }) .resolves(testData.SolResponses.getAccountBalanceResponse); callBack .withArgs({ @@ -2897,7 +2990,7 @@ describe('SOL:', function () { jsonrpc: '2.0', method: 'getTokenAccountsByOwner', params: [ - mpcV2WalletAddress, + mpcV2TokenWalletAddress, { programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, { encoding: 'jsonParsed' }, ], @@ -2929,42 +3022,20 @@ describe('SOL:', function () { }) .resolves(testData.SolResponses.getAccountInfoResponse); - mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); - const getSharesStub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') - .resolves({ - userKeyShare: Buffer.alloc(32), - backupKeyShare: Buffer.alloc(32), - commonKeyChain: testData.wrwUser.bitgoKey.replace(/\s/g, ''), - }); - const signMpcV2Stub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') - .returns(Buffer.alloc(64)); + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); - const result = await basecoin.recover({ - userKey: testData.wrwUser.userKey, - backupKey: testData.wrwUser.backupKey, - bitgoKey: testData.wrwUser.bitgoKey, - recoveryDestination: testData.keys.destinationPubKey, - tokenContractAddress: usdtMintAddress, - walletPassphrase: testData.wrwUser.walletPassphrase, - durableNonce: { - publicKey: testData.keys.durableNoncePubKey, - secretKey: testData.keys.durableNoncePrivKey, - }, - }); + const result = await basecoin.recover(mpcV2TokenRecoverParams); result.should.not.be.empty(); result.should.hasOwnProperty('serializedTx'); result.should.hasOwnProperty('scanIndex'); should.equal((result as MPCTx).scanIndex, 0); - mpcV2SandBox.assert.calledOnce(getSharesStub); - mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); }); it('should route to MPCv2 path for Token-2022 recovery and preserve programId', async function () { callBack - .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2WalletAddress] } }) + .withArgs({ payload: { id: '1', jsonrpc: '2.0', method: 'getBalance', params: [mpcV2TokenWalletAddress] } }) .resolves(testData.SolResponses.getAccountBalanceResponse); callBack .withArgs({ @@ -2972,7 +3043,7 @@ describe('SOL:', function () { id: '1', jsonrpc: '2.0', method: 'getTokenAccountsByOwner', - params: [mpcV2WalletAddress, { programId: token22ProgramId }, { encoding: 'jsonParsed' }], + params: [mpcV2TokenWalletAddress, { programId: token22ProgramId }, { encoding: 'jsonParsed' }], }, }) .resolves(testData.SolResponses.getTokenAccountsByOwnerForSol2022Response); @@ -2997,51 +3068,22 @@ describe('SOL:', function () { }) .resolves(testData.SolResponses.getAccountInfoResponse); - mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); - const getSharesStub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') - .resolves({ - userKeyShare: Buffer.alloc(32), - backupKeyShare: Buffer.alloc(32), - commonKeyChain: testData.wrwUser.bitgoKey.replace(/\s/g, ''), - }); - const signMpcV2Stub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') - .returns(Buffer.alloc(64)); + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); const result = await basecoin.recover({ - userKey: testData.wrwUser.userKey, - backupKey: testData.wrwUser.backupKey, - bitgoKey: testData.wrwUser.bitgoKey, - recoveryDestination: testData.keys.destinationPubKey, + ...mpcV2TokenRecoverParams, tokenContractAddress: t22mintAddress, programId: token22ProgramId, - walletPassphrase: testData.wrwUser.walletPassphrase, - durableNonce: { - publicKey: testData.keys.durableNoncePubKey, - secretKey: testData.keys.durableNoncePrivKey, - }, }); result.should.not.be.empty(); result.should.hasOwnProperty('serializedTx'); result.should.hasOwnProperty('scanIndex'); should.equal((result as MPCTx).scanIndex, 0); - mpcV2SandBox.assert.calledOnce(getSharesStub); - mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); }); it('should apply durable nonce account signature when keycard is MPCv2', async function () { - callBack - .withArgs({ - payload: { - id: '1', - jsonrpc: '2.0', - method: 'getBalance', - params: ['6UhWoz6WMtnk1quBibd7KAuoshvSL7vSiPe3ycDw2Kh6'], - }, - }) - .resolves(testData.SolResponses.getAccountBalanceResponse); callBack .withArgs({ payload: { @@ -3053,24 +3095,10 @@ describe('SOL:', function () { }) .resolves(testData.SolResponses.getAccountInfoResponse); - mpcV2SandBox.stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'isEddsaMpcV1SigningMaterial').resolves(false); - const getSharesStub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'getEddsaMpcV2RecoveryKeySharesFromReducedKey') - .resolves({ - userKeyShare: Buffer.alloc(32), - backupKeyShare: Buffer.alloc(32), - commonKeyChain: testData.keys.bitgoKey.replace(/\s/g, ''), - }); - const signMpcV2Stub = mpcV2SandBox - .stub(EDDSAUtils.EddsaMPCv2RecoveryFunctions, 'signRecoveryEddsaMPCv2') - .returns(Buffer.alloc(64)); + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); const result = await basecoin.recover({ - userKey: testData.keys.userKey, - backupKey: testData.keys.backupKey, - bitgoKey: testData.keys.bitgoKey, - recoveryDestination: testData.keys.destinationPubKey, - walletPassphrase: testData.keys.walletPassword, + ...mpcV2RecoverParams, durableNonce: { publicKey: testData.keys.durableNoncePubKey, secretKey: testData.keys.durableNoncePrivKey, @@ -3081,8 +3109,152 @@ describe('SOL:', function () { result.should.hasOwnProperty('serializedTx'); result.should.hasOwnProperty('scanIndex'); should.equal((result as MPCTx).scanIndex, 0); - mpcV2SandBox.assert.calledOnce(getSharesStub); - mpcV2SandBox.assert.calledOnce(signMpcV2Stub); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); + }); + + describe('recoverCloseATA', () => { + const zeroTokenInfoResponse = _.cloneDeep(testData.SolResponses.getTokenInfoResponse); + zeroTokenInfoResponse.body.result.value.data.parsed.info.tokenAmount.amount = '0'; + + beforeEach(() => { + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getBalance', + params: [testData.closeATAkeys.closeAtaAddress], + }, + }) + .resolves(testData.SolResponses.getAccountBalanceResponseM2Derivation); + + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getBalance', + params: sinon.match((address: string) => address !== testData.closeATAkeys.closeAtaAddress), + }, + }) + .resolves(testData.SolResponses.getAccountBalanceResponse); + + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [testData.closeATAkeys.closeAtaAddress, { encoding: 'jsonParsed' }], + }, + }) + .resolves(testData.SolResponses.getTokenInfoResponse); + + callBack + .withArgs({ + payload: { id: '1', jsonrpc: '2.0', method: 'sendTransaction', params: sinon.match.array }, + }) + .onCall(0) + .resolves(testData.SolResponses.broadcastTransactionResponse) + .onCall(1) + .resolves(testData.SolResponses.broadcastTransactionResponse1); + + let broadcastCallIndex = 0; + mpcV2SandBox.stub(Sol.prototype, 'broadcastTransaction' as keyof Sol).callsFake(async () => { + broadcastCallIndex++; + return { + txId: + broadcastCallIndex === 1 + ? testData.SolResponses.broadcastTransactionResponse.body.result + : testData.SolResponses.broadcastTransactionResponse1.body.result, + }; + }); + }); + + it('should recover close ATA with MPCv2 keys when no token balance is present', async function () { + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [testData.closeATAkeys.closeAtaAddress, { encoding: 'jsonParsed' }], + }, + }) + .resolves(zeroTokenInfoResponse); + + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const results = await basecoin.recoverCloseATA(closeAtaMpcV2Params); + + results.should.have.length(1); + should.equal(results[0].txId, testData.SolResponses.broadcastTransactionResponse.body.result); + should.equal( + callBack.getCalls().find((call) => call.args[0]?.payload?.method === 'getBalance')?.args[0].payload.params[0], + mpcV2WalletAddress + ); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should recover close ATA with MPCv2 keys when token balance is present', async function () { + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); + + const results = await basecoin.recoverCloseATA(closeAtaMpcV2Params); + + results.should.have.length(2); + should.equal(results[0].txId, testData.SolResponses.broadcastTransactionResponse.body.result); + should.equal(results[1].txId, testData.SolResponses.broadcastTransactionResponse1.body.result); + should.equal( + callBack.getCalls().find((call) => call.args[0]?.payload?.method === 'getBalance')?.args[0].payload.params[0], + mpcV2WalletAddress + ); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); + }); + + it('should throw when MPCv2 recoverCloseATA bitgoKey does not match keycard commonKeyChain', async function () { + callBack + .withArgs({ + payload: { + id: '1', + jsonrpc: '2.0', + method: 'getAccountInfo', + params: [testData.closeATAkeys.closeAtaAddress, { encoding: 'jsonParsed' }], + }, + }) + .resolves(zeroTokenInfoResponse); + + await basecoin + .recoverCloseATA({ ...closeAtaMpcV2Params, bitgoKey: mismatchedBitgoKey }) + .should.be.rejectedWith('EdDSA MPCv2 recovery: commonKeyChain from keycard does not match bitgoKey'); + }); + + it('should sign recoverCloseATA via signAndGenerateBroadcastableTransaction with MPCv2 keys', async function () { + const getTSSSignatureSpy = mpcV2SandBox.spy(EDDSAMethods, 'getTSSSignature'); + const factory = (basecoin as any).getBuilder(); + const blockhash = testData.SolResponses.getBlockhashResponse.body.result.value.blockhash; + const txBuilder = factory.getCloseAtaInitializationBuilder(); + txBuilder.nonce(blockhash); + txBuilder.sender(mpcV2WalletAddress); + txBuilder.accountAddress(testData.closeATAkeys.closeAtaAddress); + txBuilder.destinationAddress(testData.closeATAkeys.destinationPubKey); + txBuilder.authorityAddress(mpcV2WalletAddress); + txBuilder.associatedTokenAccountRent('2039280'); + const addSignatureSpy = mpcV2SandBox.spy(txBuilder, 'addSignature'); + + const result = await basecoin.signAndGenerateBroadcastableTransaction( + closeAtaMpcV2Params, + txBuilder, + mpcV2WalletAddress, + true + ); + + result.should.hasOwnProperty('serializedTx'); + result.should.hasOwnProperty('scanIndex'); + should.equal(result.scanIndex, 0); + mpcV2SandBox.assert.notCalled(getTSSSignatureSpy); + mpcV2SandBox.assert.calledOnce(addSignatureSpy); + should.equal(addSignatureSpy.firstCall.args[0].pub, mpcV2WalletAddress); + }); }); });