diff --git a/modules/abstract-cosmos/src/cosmosCoin.ts b/modules/abstract-cosmos/src/cosmosCoin.ts index cd7ef8de6c4..9271a2e5754 100644 --- a/modules/abstract-cosmos/src/cosmosCoin.ts +++ b/modules/abstract-cosmos/src/cosmosCoin.ts @@ -5,6 +5,7 @@ import { BitGoBase, Ecdsa, ECDSAUtils, + ErrorNoInputToRecover, ExplanationResult, InvalidAddressError, InvalidMemoIdError, @@ -14,6 +15,7 @@ import { multisigTypes, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, SignedTransaction, SigningError, SignTransactionOptions, @@ -251,7 +253,7 @@ export class CosmosCoin extends BaseCoin { */ private processBalances(balances: Coin[]): BalanceResult { if (!balances?.length) { - throw new Error('No balance found on account'); + throw new ErrorNoInputToRecover('No balance found on account'); } const denomination = this.getDenomination(); @@ -280,6 +282,10 @@ export class CosmosCoin extends BaseCoin { } }); + if (nativeBalance.isLessThanOrEqualTo(0)) { + throw new ErrorNoInputToRecover('Did not have enough funds to recover'); + } + const actualBalance = nativeBalance.minus(gasAmount); if (actualBalance.isLessThanOrEqualTo(0)) { throw new Error('Did not have enough funds to recover'); @@ -667,9 +673,14 @@ export class CosmosCoin extends BaseCoin { try { return await request.get(fullEndpoint).send(); } catch (e) { - console.debug(e); + if ((e as { status?: number })?.status === 404) { + throw new ErrorNoInputToRecover('Account not found'); + } + throw new RecoveryProviderError( + `Unable to call endpoint ${getAccountPath + senderAddress} from node: ${nodeUrl}`, + e + ); } - throw new Error(`Unable to call endpoint ${getAccountPath + senderAddress} from node: ${nodeUrl}`); } /** @@ -682,9 +693,11 @@ export class CosmosCoin extends BaseCoin { try { return await request.get(fullEndpoint).send(); } catch (e) { - console.debug(e); + throw new RecoveryProviderError( + `Unable to call endpoint ${getBalancePath + senderAddress} from node: ${nodeUrl}`, + e + ); } - throw new Error(`Unable to call endpoint ${getBalancePath + senderAddress} from node: ${nodeUrl}`); } /** @@ -697,9 +710,8 @@ export class CosmosCoin extends BaseCoin { try { return await request.get(fullEndpoint).send(); } catch (e) { - console.debug(e); + throw new RecoveryProviderError(`Unable to call endpoint ${getLatestBlockPath} from node: ${nodeUrl}`, e); } - throw new Error(`Unable to call endpoint ${getLatestBlockPath} from node: ${nodeUrl}`); } /** @@ -708,7 +720,10 @@ export class CosmosCoin extends BaseCoin { protected async getAccountBalance(senderAddress: string): Promise { const response = await this.getBalanceFromNode(senderAddress); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); + } + if (!response.body?.balances) { + throw new RecoveryProviderError(`Malformed balance response for ${senderAddress}`); } return response.body.balances; } @@ -719,7 +734,7 @@ export class CosmosCoin extends BaseCoin { protected async getChainId(): Promise { const response = await this.getChainIdFromNode(); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return response.body.block.header.chain_id; } @@ -729,8 +744,11 @@ export class CosmosCoin extends BaseCoin { */ protected async getAccountDetails(senderAddress: string): Promise { const response = await this.getAccountFromNode(senderAddress); + if (response.status === 404) { + throw new ErrorNoInputToRecover('Account not found'); + } if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return [response.body.account.account_number, response.body.account.sequence]; } diff --git a/modules/abstract-substrate/src/abstractSubstrateCoin.ts b/modules/abstract-substrate/src/abstractSubstrateCoin.ts index d2f2fcba8d5..ef1486f81de 100644 --- a/modules/abstract-substrate/src/abstractSubstrateCoin.ts +++ b/modules/abstract-substrate/src/abstractSubstrateCoin.ts @@ -4,6 +4,7 @@ import { BitGoBase, EDDSAMethods, EDDSAMethodTypes, + ErrorNoInputToRecover, KeyPair, MPCAlgorithm, MPCConsolidationRecoveryOptions, @@ -17,6 +18,7 @@ import { multisigTypes, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, RecoveryTxRequest, SignedTransaction, TssVerifyAddressOptions, @@ -322,18 +324,30 @@ export class SubstrateCoin extends BaseCoin { const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); const senderAddr = this.getAddressFromPublicKey(accountId); - const { nonce, freeBalance } = await this.getAccountInfo(senderAddr); - const destAddr = params.recoveryDestination; - const amount = freeBalance; - const partialFee = await this.getFee(destAddr, senderAddr, amount); + let accountInfo; + let partialFee; + try { + accountInfo = await this.getAccountInfo(senderAddr); + partialFee = await this.getFee(params.recoveryDestination, senderAddr, accountInfo.freeBalance); + } catch (e) { + throw new RecoveryProviderError(`Failed to query account balance or fee for ${senderAddr}`, e); + } + const { nonce, freeBalance } = accountInfo; const value = new BigNumber(freeBalance).minus(new BigNumber(partialFee)); if (value.isLessThanOrEqualTo(0)) { - throw new Error('Did not find address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find address with funds to recover'); } - const { headerNumber, headerHash } = await this.getHeaderInfo(); - const material = await this.getMaterial(); + let headerInfo; + let material; + try { + headerInfo = await this.getHeaderInfo(); + material = await this.getMaterial(); + } catch (e) { + throw new RecoveryProviderError('Failed to query chain header or material', e); + } + const { headerNumber, headerHash } = headerInfo; const validityWindow = { firstValid: headerNumber, maxDuration: this.MAX_VALIDITY_DURATION }; const txBuilder = this.getBuilder().getTransferBuilder().material(material); @@ -461,7 +475,7 @@ export class SubstrateCoin extends BaseCoin { try { recoveryTransaction = await this.recover(recoverParams); } catch (e) { - if (e.message === 'Did not find address with funds to recover') { + if (e instanceof ErrorNoInputToRecover) { lastScanIndex = i; continue; } @@ -477,7 +491,7 @@ export class SubstrateCoin extends BaseCoin { } if (consolidationTransactions.length == 0) { - throw new Error('Did not find an address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find an address with funds to recover'); } if (isUnsignedSweep) { diff --git a/modules/abstract-substrate/test/unit/abstractSubstrateCoin.ts b/modules/abstract-substrate/test/unit/abstractSubstrateCoin.ts index e5476a79121..a2e3d1500ef 100644 --- a/modules/abstract-substrate/test/unit/abstractSubstrateCoin.ts +++ b/modules/abstract-substrate/test/unit/abstractSubstrateCoin.ts @@ -1,5 +1,7 @@ +import 'should'; import * as sinon from 'sinon'; import * as sdkCore from '@bitgo/sdk-core'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; // Cross-module relative import: tsx resolves TypeScript source directly in the monorepo, // avoiding a circular devDependency (sdk-coin-tao depends on abstract-substrate at runtime). import { Ttao } from '../../../sdk-coin-tao/src'; @@ -142,4 +144,29 @@ describe('SubstrateCoin MPCv2 recovery helpers:', function () { addSignatureStub.firstCall.args[1].should.equal(mockSig); }); }); + + describe('recover()', function () { + const recoveryDestination = '5FJ18ywfrWuRifNyc8aPwQ5ium19Fefwmx18H4XYkDc36F2A'; + const bitgoKey = 'aa'.repeat(64); + + beforeEach(function () { + sandBox.stub(Ttao.prototype, 'getAccountInfo' as keyof Ttao).resolves({ nonce: 0, freeBalance: 0 }); + sandBox.stub(Ttao.prototype, 'getFee' as keyof Ttao).resolves(100); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should throw ErrorNoInputToRecover for a verifiably empty wallet', async function () { + await basecoin.recover({ bitgoKey, recoveryDestination }).should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the account info query fails', async function () { + (Ttao.prototype as unknown as { getAccountInfo: sinon.SinonStub }).getAccountInfo.rejects( + new Error('network failure') + ); + await basecoin.recover({ bitgoKey, recoveryDestination }).should.be.rejectedWith(RecoveryProviderError); + }); + }); }); diff --git a/modules/sdk-coin-ada/src/ada.ts b/modules/sdk-coin-ada/src/ada.ts index b3330d903e8..7ebca8fb95c 100644 --- a/modules/sdk-coin-ada/src/ada.ts +++ b/modules/sdk-coin-ada/src/ada.ts @@ -17,12 +17,14 @@ import { EDDSAMethodTypes, AddressFormat, Environments, + ErrorNoInputToRecover, ITransactionRecipient, MPCTx, MPCRecoveryOptions, MPCConsolidationRecoveryOptions, MPCSweepTxs, RecoveryTxRequest, + RecoveryProviderError, MPCUnsignedTx, MPCSweepRecoveryOptions, MPCTxs, @@ -286,9 +288,8 @@ export class Ada extends BaseCoin { const res = await request.post(restEndpoint).send(requestBody); return res; } catch (e) { - console.debug(e); + throw new RecoveryProviderError(`Unable to call endpoint ${restEndpoint}`, e); } - throw new Error(`Unable to call endpoint ${restEndpoint}`); } protected async getAddressInfo( @@ -297,7 +298,7 @@ export class Ada extends BaseCoin { const requestBody = { _addresses: [walletAddr] }; const res = await this.getDataFromNode('address_info', requestBody); if (res.status != 200) { - throw new Error(`Failed to retrieve address info for address ${walletAddr}`); + throw new RecoveryProviderError(`Failed to retrieve address info for address ${walletAddr}`); } const body = res.body[0]; if (body === undefined) { @@ -309,7 +310,7 @@ export class Ada extends BaseCoin { protected async getChainTipInfo(): Promise> { const res = await this.getDataFromNode('tip'); if (res.status != 200) { - throw new Error('Failed to retrieve chain tip info'); + throw new RecoveryProviderError('Failed to retrieve chain tip info'); } const body = res.body[0]; return body; @@ -413,7 +414,7 @@ export class Ada extends BaseCoin { const isUnsignedSweep = !params.walletPassphrase; const { balance, utxoSet } = await this.getAddressInfo(senderAddr); if (balance <= 0) { - throw new Error('Did not find address with funds to recover.'); + throw new ErrorNoInputToRecover('Did not find address with funds to recover.'); } // Aggregate token assets from all UTxOs into a fingerprint-keyed map for the builder @@ -619,10 +620,7 @@ export class Ada extends BaseCoin { try { recoveryTransaction = await this.recover(recoverParams, signingMaterial); } catch (e) { - if ( - e.message === 'Did not find address with funds to recover.' || - e.message.startsWith('Insufficient funds to recover') - ) { + if (e instanceof ErrorNoInputToRecover || e.message.startsWith('Insufficient funds to recover')) { lastScanIndex = i; continue; } @@ -638,7 +636,7 @@ export class Ada extends BaseCoin { } if (consolidationTransactions.length == 0) { - throw new Error('Did not find an address with funds to recover.'); + throw new ErrorNoInputToRecover('Did not find an address with funds to recover.'); } if (isUnsignedSweep) { diff --git a/modules/sdk-coin-ada/test/unit/ada.ts b/modules/sdk-coin-ada/test/unit/ada.ts index fdd45cdbc57..9ff133f5306 100644 --- a/modules/sdk-coin-ada/test/unit/ada.ts +++ b/modules/sdk-coin-ada/test/unit/ada.ts @@ -30,8 +30,10 @@ import assert from 'assert'; import { common, EDDSAMethods, + ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, + RecoveryProviderError, signRecoveryEddsaMPCv2, Wallet, } from '@bitgo/sdk-core'; @@ -1159,7 +1161,7 @@ describe('ADA', function () { startingScanIndex: 1, endingScanIndex: 2, }) - .should.rejectedWith('Did not find an address with funds to recover.'); + .should.rejectedWith(ErrorNoInputToRecover); }); it('should throw if startingScanIndex is not ge to 1', async () => { @@ -1381,7 +1383,7 @@ describe('ADA', function () { startingScanIndex: 1, endingScanIndex: 4, }) - .should.be.rejectedWith('Did not find an address with funds to recover.'); + .should.be.rejectedWith(ErrorNoInputToRecover); }); it('should build even if single address has no funds', async function () { @@ -1568,7 +1570,7 @@ describe('ADA', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destAddr, }) - .should.rejectedWith('Did not find address with funds to recover.'); + .should.rejectedWith(ErrorNoInputToRecover); sandBox.assert.calledOnce(basecoin.getDataFromNode); }); @@ -1591,6 +1593,20 @@ describe('ADA', function () { ); sandBox.assert.calledTwice(basecoin.getDataFromNode); }); + + it('should throw RecoveryProviderError when the node returns a non-2xx status', async function () { + const callBack = sandBox.stub(Ada.prototype, 'getDataFromNode' as keyof Ada); + callBack.withArgs('address_info', sinon.match.has('_addresses')).resolves({ status: 500 }); + await basecoin + .recover({ + userKey: wrwUser.userKey, + backupKey: wrwUser.backupKey, + bitgoKey: wrwUser.bitgoKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: destAddr, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); }); describe('Verify wallet address (isWalletAddress):', () => { diff --git a/modules/sdk-coin-algo/src/algo.ts b/modules/sdk-coin-algo/src/algo.ts index f17cf0ec5e1..8e0e12e38e7 100644 --- a/modules/sdk-coin-algo/src/algo.ts +++ b/modules/sdk-coin-algo/src/algo.ts @@ -11,12 +11,14 @@ import { BaseBroadcastTransactionResult, BaseCoin, BitGoBase, + ErrorNoInputToRecover, InvalidAddressError, InvalidKey, KeyIndices, KeyPair, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, SignedTransaction, SignTransactionOptions as BaseSignTransactionOptions, TokenManagementType, @@ -830,9 +832,13 @@ export class Algo extends BaseCoin { * @param client */ async getAccountBalance(rootAddress: string, client: algosdk.Algodv2): Promise { - const accountInformation = await client.accountInformation(rootAddress).do(); - // Extract the balance from the account information - return accountInformation.amount; + try { + const accountInformation = await client.accountInformation(rootAddress).do(); + // Extract the balance from the account information + return accountInformation.amount; + } catch (e) { + throw new RecoveryProviderError(`Failed to get account balance for ${rootAddress}`, e); + } } /** @@ -903,7 +909,7 @@ export class Algo extends BaseCoin { const spendableAmount = new BigNumber(nativeBalance).minus(params.fee).minus(MIN_MICROALGOS_BALANCE).toNumber(); if (new BigNumber(spendableAmount).isZero() || new BigNumber(spendableAmount).isLessThanOrEqualTo(params.fee)) { - throw new Error( + throw new ErrorNoInputToRecover( 'Insufficient balance to recover, got balance: ' + nativeBalance + ' fee: ' + @@ -915,15 +921,21 @@ export class Algo extends BaseCoin { let latestRound: number | undefined; if (!params.firstRound) { - latestRound = await client - .status() - .do() - .then((status) => status['last-round']); + try { + latestRound = await client + .status() + .do() + .then((status) => status['last-round']); + } catch (e) { + throw new RecoveryProviderError('Failed to fetch the latest round from the node', e); + } } const firstRound = !params.firstRound ? latestRound : params.firstRound; if (!firstRound) { - throw new Error('Unable to fetch the latest round from the node. Please provide the firstRound or try again.'); + throw new RecoveryProviderError( + 'Unable to fetch the latest round from the node. Please provide the firstRound or try again.' + ); } const LAST_ROUND_BUFFER = 1000; const lastRound = firstRound + LAST_ROUND_BUFFER; diff --git a/modules/sdk-coin-algo/test/unit/algo.ts b/modules/sdk-coin-algo/test/unit/algo.ts index ee8e1d090b6..d2af1a4c30b 100644 --- a/modules/sdk-coin-algo/test/unit/algo.ts +++ b/modules/sdk-coin-algo/test/unit/algo.ts @@ -9,7 +9,7 @@ import assert from 'assert'; import { Algo } from '../../src/algo'; import BigNumber from 'bignumber.js'; import { TransactionBuilderFactory } from '../../src/lib'; -import { common, KeyPair, Wallet } from '@bitgo/sdk-core'; +import { common, ErrorNoInputToRecover, KeyPair, RecoveryProviderError, Wallet } from '@bitgo/sdk-core'; import { algoBackupKey } from './fixtures/algoBackupKey'; import nock from 'nock'; @@ -865,26 +865,48 @@ describe('ALGO:', function () { it('should throw if there is no enough balance to recover', async function () { const getBalanceStub = sandBox.stub(Algo.prototype, 'getAccountBalance').resolves(100500); - await assert.rejects( - async () => { - await basecoin.recover({ - userKey, - backupKey, - rootAddress, - fee, - walletPassphrase, - bitgoKey: bitgoPub, - recoveryDestination, - firstRound: 5003596, - nodeParams, - }); - }, - { message: 'Insufficient balance to recover, got balance: 100500 fee: 1000 min account balance: 100000' } - ); + await assert.rejects(async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress, + fee, + walletPassphrase, + bitgoKey: bitgoPub, + recoveryDestination, + firstRound: 5003596, + nodeParams, + }); + }, ErrorNoInputToRecover); getBalanceStub.callCount.should.equal(1); }); + it('should throw RecoveryProviderError when the node fails to return the account balance', async function () { + sandBox.stub(Algo.prototype, 'getClient').returns({ + accountInformation: () => ({ + do: () => Promise.reject(new Error('node unreachable')), + }), + status: () => ({ + do: () => Promise.resolve({ 'last-round': 5002596 }), + }), + } as any); + + await assert.rejects(async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress, + walletPassphrase, + fee, + bitgoKey: bitgoPub, + recoveryDestination: recoveryDestination, + firstRound: 5002596, + nodeParams, + }); + }, RecoveryProviderError); + }); + it('should throw if the walletPassphrase is undefined', async function () { await assert.rejects( async () => { diff --git a/modules/sdk-coin-atom/test/unit/atom.ts b/modules/sdk-coin-atom/test/unit/atom.ts index 206a961976f..ae8a7d5c481 100644 --- a/modules/sdk-coin-atom/test/unit/atom.ts +++ b/modules/sdk-coin-atom/test/unit/atom.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { TestBitGo, TestBitGoAPI, mockSerializedChallengeWithProofs } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import sinon from 'sinon'; import { Atom, Tatom } from '../../src'; @@ -603,7 +604,7 @@ describe('ATOM', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-bld/test/unit/bld.ts b/modules/sdk-coin-bld/test/unit/bld.ts index 855d92ebc20..a584fc8d15f 100644 --- a/modules/sdk-coin-bld/test/unit/bld.ts +++ b/modules/sdk-coin-bld/test/unit/bld.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { mockSerializedChallengeWithProofs, TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; import sinon from 'sinon'; @@ -496,7 +497,7 @@ describe('BLD', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-coreum/test/unit/coreum.ts b/modules/sdk-coin-coreum/test/unit/coreum.ts index 5a533026e23..f5398f3e060 100644 --- a/modules/sdk-coin-coreum/test/unit/coreum.ts +++ b/modules/sdk-coin-coreum/test/unit/coreum.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { TestBitGo, TestBitGoAPI, mockSerializedChallengeWithProofs } from '@bitgo/sdk-test'; import { NetworkType, coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; import sinon from 'sinon'; @@ -515,7 +516,7 @@ describe('Coreum', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-dot/src/dot.ts b/modules/sdk-coin-dot/src/dot.ts index 1cb0da4223e..d3a7a372314 100644 --- a/modules/sdk-coin-dot/src/dot.ts +++ b/modules/sdk-coin-dot/src/dot.ts @@ -6,6 +6,7 @@ import { DotAssetTypes, Eddsa, Environments, + ErrorNoInputToRecover, ExplanationResult, KeyPair, MPCAlgorithm, @@ -23,6 +24,7 @@ import { MPCConsolidationRecoveryOptions, MPCSweepTxs, RecoveryTxRequest, + RecoveryProviderError, MPCUnsignedTx, MPCSweepRecoveryOptions, MPCTxs, @@ -390,19 +392,31 @@ export class Dot extends BaseCoin { const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); const senderAddr = this.getAddressFromPublicKey(accountId); - const { nonce, freeBalance } = await this.getAccountInfo(senderAddr); - const destAddr = params.recoveryDestination; - const amount = freeBalance; - const partialFee = await this.getFee(destAddr, senderAddr, amount); + let accountInfo; + let partialFee; + try { + accountInfo = await this.getAccountInfo(senderAddr); + partialFee = await this.getFee(params.recoveryDestination, senderAddr, accountInfo.freeBalance); + } catch (e) { + throw new RecoveryProviderError(`Failed to query account balance or fee for ${senderAddr}`, e); + } + const { nonce, freeBalance } = accountInfo; const value = new BigNumber(freeBalance).minus(new BigNumber(partialFee)); if (value.isLessThanOrEqualTo(0)) { - throw new Error('Did not find address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find address with funds to recover'); } // first build the unsigned txn - const { headerNumber, headerHash } = await this.getHeaderInfo(); - const material = await this.getMaterial(); + let headerInfo; + let material; + try { + headerInfo = await this.getHeaderInfo(); + material = await this.getMaterial(); + } catch (e) { + throw new RecoveryProviderError('Failed to query chain header or material', e); + } + const { headerNumber, headerHash } = headerInfo; const validityWindow = { firstValid: headerNumber, maxDuration: this.MAX_VALIDITY_DURATION }; const txnBuilder = this.getBuilder().getTransferBuilder().material(material); @@ -530,7 +544,7 @@ export class Dot extends BaseCoin { try { recoveryTransaction = await this.recover(recoverParams, signingMaterial); } catch (e) { - if (e.message === 'Did not find address with funds to recover') { + if (e instanceof ErrorNoInputToRecover) { lastScanIndex = i; continue; } @@ -546,7 +560,7 @@ export class Dot extends BaseCoin { } if (consolidationTransactions.length == 0) { - throw new Error('Did not find an address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find an address with funds to recover'); } if (isUnsignedSweep) { diff --git a/modules/sdk-coin-dot/test/unit/dot.ts b/modules/sdk-coin-dot/test/unit/dot.ts index 4d5ef4151b1..143a9d08049 100644 --- a/modules/sdk-coin-dot/test/unit/dot.ts +++ b/modules/sdk-coin-dot/test/unit/dot.ts @@ -9,10 +9,12 @@ import { chainName, txVersion, genesisHash, specVersion } from '../resources'; import * as sinon from 'sinon'; import { EDDSAMethods, + ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, MPCTx, MPCTxs, + RecoveryProviderError, signRecoveryEddsaMPCv2, TransactionType, Wallet, @@ -873,7 +875,7 @@ describe('DOT:', function () { startingScanIndex: 1, endingScanIndex: 2, }) - .should.rejectedWith('Did not find an address with funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); it('should throw if startingScanIndex is not ge to 1', async () => { @@ -1071,7 +1073,22 @@ describe('DOT:', function () { walletPassphrase: testData.wrwUser.walletPassphrase, recoveryDestination: destAddr, }) - .should.rejectedWith('Did not find address with funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when account info query fails', async function () { + (Dot.prototype as unknown as { getAccountInfo: sinon.SinonStub }).getAccountInfo.rejects( + new Error('network failure') + ); + await basecoin + .recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + walletPassphrase: testData.wrwUser.walletPassphrase, + recoveryDestination: destAddr, + }) + .should.be.rejectedWith(RecoveryProviderError); }); }); diff --git a/modules/sdk-coin-hash/test/unit/hash.ts b/modules/sdk-coin-hash/test/unit/hash.ts index a32bde2469f..6c4e930c9ee 100644 --- a/modules/sdk-coin-hash/test/unit/hash.ts +++ b/modules/sdk-coin-hash/test/unit/hash.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { TestBitGo, TestBitGoAPI, mockSerializedChallengeWithProofs } from '@bitgo/sdk-test'; import { NetworkType, coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; import sinon from 'sinon'; @@ -510,7 +511,7 @@ describe('HASH', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-hbar/src/hbar.ts b/modules/sdk-coin-hbar/src/hbar.ts index 86db4bf1adc..d13e49f2a25 100644 --- a/modules/sdk-coin-hbar/src/hbar.ts +++ b/modules/sdk-coin-hbar/src/hbar.ts @@ -5,9 +5,11 @@ import { CoinFamily, BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { BaseCoin, BitGoBase, + ErrorNoInputToRecover, KeyPair, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, SignedTransaction, SignTransactionOptions, VerifyAddressOptions as BaseVerifyAddressOptions, @@ -627,7 +629,7 @@ export class Hbar extends BaseCoin { let txBuilder; if (!params.tokenId) { if (spendableAmount.isZero() || spendableAmount.isNegative()) { - throw new Error(`Insufficient balance to recover, got balance: ${nativeBalance} fee: ${fee}`); + throw new ErrorNoInputToRecover(`Insufficient balance to recover, got balance: ${nativeBalance} fee: ${fee}`); } txBuilder = this.getBuilderFactory().getTransferBuilder(); txBuilder.send({ address: destinationAddress, amount: spendableAmount.toString() }); @@ -643,7 +645,9 @@ export class Hbar extends BaseCoin { throw new Error(`Unsupported token: ${params.tokenId}`); } if (!tokenBalance || new BigNumber(tokenBalance.balance).isZero()) { - throw new Error(`Insufficient balance to recover token: ${params.tokenId} for account: ${params.rootAddress}`); + throw new ErrorNoInputToRecover( + `Insufficient balance to recover token: ${params.tokenId} for account: ${params.rootAddress}` + ); } txBuilder = this.getBuilderFactory().getTokenTransferBuilder(); txBuilder.send({ address: destinationAddress, amount: tokenBalance.balance, tokenName: token.name }); @@ -845,7 +849,7 @@ export class Hbar extends BaseCoin { return balance.toJSON(); } catch (e) { - throw new Error('Failed to get account balance, error: ' + e.message); + throw new RecoveryProviderError('Failed to get account balance, error: ' + e.message, e); } } diff --git a/modules/sdk-coin-hbar/test/unit/hbar.ts b/modules/sdk-coin-hbar/test/unit/hbar.ts index 996ad209611..16a9addecdc 100644 --- a/modules/sdk-coin-hbar/test/unit/hbar.ts +++ b/modules/sdk-coin-hbar/test/unit/hbar.ts @@ -7,7 +7,7 @@ import { randomBytes } from 'crypto'; import { BigNumber } from 'bignumber.js'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; -import { common, Wallet } from '@bitgo/sdk-core'; +import { common, ErrorNoInputToRecover, RecoveryProviderError, Wallet } from '@bitgo/sdk-core'; import { TxData, Transfer } from '../../src/lib/iface'; import * as TestData from '../fixtures/hbar'; @@ -1093,18 +1093,15 @@ describe('Hedera Hashgraph:', function () { const getBalanceStub = sandBox .stub(Hbar.prototype, 'getAccountBalance') .resolves({ hbars: formatBalanceResponse('100'), tokens: [] }); - await assert.rejects( - async () => { - await basecoin.recover({ - userKey, - backupKey, - rootAddress, - walletPassphrase, - recoveryDestination: recoveryDestination + '?memoId=' + memo, - }); - }, - { message: 'Insufficient balance to recover, got balance: 100 fee: 10000000' } - ); + await assert.rejects(async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + }); + }, ErrorNoInputToRecover); getBalanceStub.callCount.should.equal(1); }); @@ -1208,19 +1205,16 @@ describe('Hedera Hashgraph:', function () { tokens: [{ tokenId: 'randomString', balance: '100', decimals: 6 }], }; sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); - await assert.rejects( - async () => { - await basecoin.recover({ - userKey, - backupKey, - rootAddress: rootAddress, - walletPassphrase, - recoveryDestination: recoveryDestination + '?memoId=' + memo, - tokenId: tokenId, - }); - }, - { message: 'Insufficient balance to recover token: ' + tokenId + ' for account: ' + rootAddress } - ); + await assert.rejects(async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress: rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: tokenId, + }); + }, ErrorNoInputToRecover); }); it('should throw error for insufficient balance for tokenId if token balance exist with 0 amount', async function () { @@ -1229,19 +1223,16 @@ describe('Hedera Hashgraph:', function () { tokens: [{ tokenId: 'randomString', balance: '0', decimals: 6 }], }; sandBox.stub(Hbar.prototype, 'getAccountBalance').resolves(data); - await assert.rejects( - async () => { - await basecoin.recover({ - userKey, - backupKey, - rootAddress: rootAddress, - walletPassphrase, - recoveryDestination: recoveryDestination + '?memoId=' + memo, - tokenId: tokenId, - }); - }, - { message: 'Insufficient balance to recover token: ' + tokenId + ' for account: ' + rootAddress } - ); + await assert.rejects(async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress: rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + tokenId: tokenId, + }); + }, ErrorNoInputToRecover); }); it('should throw error for insufficient native balance for token transfer', async function () { @@ -1264,6 +1255,21 @@ describe('Hedera Hashgraph:', function () { { message: 'Insufficient native balance to recover tokens, got native balance: 1000000 fee: ' + defaultFee } ); }); + + it('should throw RecoveryProviderError when the account balance query fails', async function () { + sandBox + .stub(Hbar.prototype, 'getAccountBalance') + .rejects(new RecoveryProviderError('Failed to get account balance')); + await assert.rejects(async () => { + await basecoin.recover({ + userKey, + backupKey, + rootAddress, + walletPassphrase, + recoveryDestination: recoveryDestination + '?memoId=' + memo, + }); + }, RecoveryProviderError); + }); }); describe('Unsigned Sweep', function () { diff --git a/modules/sdk-coin-icp/src/icp.ts b/modules/sdk-coin-icp/src/icp.ts index 0440aff66c3..09576760969 100644 --- a/modules/sdk-coin-icp/src/icp.ts +++ b/modules/sdk-coin-icp/src/icp.ts @@ -8,6 +8,7 @@ import { Ecdsa, ECDSAUtils, Environments, + ErrorNoInputToRecover, InvalidAddressError, KeyPair, MPCAlgorithm, @@ -15,6 +16,7 @@ import { multisigTypes, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, SignedTransaction, SigningError, SignTransactionOptions, @@ -446,6 +448,9 @@ export class Icp extends BaseCoin { const senderAddress = this.getAddressFromPublicKey(publicKey); const balance = await this.getAccountBalance(publicKey); const feeData = await this.getFeeData(); + if (balance.isLessThanOrEqualTo(0)) { + throw new ErrorNoInputToRecover('Did not have enough funds to recover'); + } const actualBalance = balance.minus(feeData); if (actualBalance.isLessThanOrEqualTo(0)) { throw new Error('Did not have enough funds to recover'); @@ -492,6 +497,9 @@ export class Icp extends BaseCoin { }; return recoveredTransaction; } catch (error) { + if (error instanceof RecoveryProviderError || error instanceof ErrorNoInputToRecover) { + throw error; + } throw new Error(`Error during ICP recovery: ${error.message || error}`); } } diff --git a/modules/sdk-coin-icp/src/lib/icpAgent.ts b/modules/sdk-coin-icp/src/lib/icpAgent.ts index f8c7e76c269..62fb10fd9f9 100644 --- a/modules/sdk-coin-icp/src/lib/icpAgent.ts +++ b/modules/sdk-coin-icp/src/lib/icpAgent.ts @@ -11,6 +11,7 @@ import { } from './iface'; import BigNumber from 'bignumber.js'; import { NetworkType, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { RecoveryProviderError } from '@bitgo/sdk-core'; export class IcpAgent { private readonly host: string; @@ -57,10 +58,10 @@ export class IcpAgent { * @throws Error if the balance could not be fetched. */ public async getBalance(principalId: string): Promise { + if (!principalId) { + throw new Error('Principal ID is required'); + } try { - if (!principalId) { - throw new Error('Principal ID is required'); - } const ledger = this.getLedger(); const account = { owner: Principal.fromText(principalId), @@ -71,7 +72,7 @@ export class IcpAgent { return BigNumber(balance); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Error fetching balance for principal ${principalId}: ${errorMessage}`); + throw new RecoveryProviderError(`Error fetching balance for principal ${principalId}: ${errorMessage}`, error); } } @@ -96,7 +97,7 @@ export class IcpAgent { return BigNumber(feeEntry[1].Nat); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Error fetching transaction fee: ${errorMessage}`); + throw new RecoveryProviderError(`Error fetching transaction fee: ${errorMessage}`, error); } } } diff --git a/modules/sdk-coin-icp/test/unit/transactionBuilder/transactionRecover.ts b/modules/sdk-coin-icp/test/unit/transactionBuilder/transactionRecover.ts index e0ede7f526c..0f6bc06b26a 100644 --- a/modules/sdk-coin-icp/test/unit/transactionBuilder/transactionRecover.ts +++ b/modules/sdk-coin-icp/test/unit/transactionBuilder/transactionRecover.ts @@ -9,6 +9,7 @@ import { IcpAgent } from '../../../src/lib/icpAgent'; import { RecoveryOptions, LEDGER_CANISTER_ID } from '../../../src/lib/iface'; import { Principal } from '@dfinity/principal'; import BigNumber from 'bignumber.js'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import utils from '../../../src/lib/utils'; describe('ICP transaction recovery', async () => { @@ -148,6 +149,13 @@ describe('ICP transaction recovery', async () => { ); }); + it('should fail to recover txn if balance is zero', async () => { + sinon.restore(); + sinon.stub(IcpAgent.prototype, 'getBalance').resolves(BigNumber(0)); + sinon.stub(IcpAgent.prototype, 'getFee').resolves(BigNumber(10000)); + await icp.recover(recoveryParams).should.rejectedWith(ErrorNoInputToRecover); + }); + it('should fail to recover txn if balance is low', async () => { sinon.restore(); setupLowBalanceStubs(); @@ -156,6 +164,12 @@ describe('ICP transaction recovery', async () => { .should.rejectedWith('Error during ICP recovery: Did not have enough funds to recover'); }); + it('should fail to recover txn if the balance provider fails', async () => { + sinon.restore(); + sinon.stub(IcpAgent.prototype, 'getBalance').rejects(new RecoveryProviderError('Error fetching balance')); + await icp.recover(recoveryParams).should.rejectedWith(RecoveryProviderError); + }); + it('should fail to recover txn if userKey is not provided', async () => { recoveryParams.userKey = ''; await icp.recover(recoveryParams).should.rejectedWith('Error during ICP recovery: missing userKey'); diff --git a/modules/sdk-coin-injective/src/injective.ts b/modules/sdk-coin-injective/src/injective.ts index bdac7c39199..cdd53c73adb 100644 --- a/modules/sdk-coin-injective/src/injective.ts +++ b/modules/sdk-coin-injective/src/injective.ts @@ -1,5 +1,5 @@ import { CosmosCoin, CosmosKeyPair, GasAmountDetails } from '@bitgo/abstract-cosmos'; -import { BaseCoin, BitGoBase, Environments } from '@bitgo/sdk-core'; +import { BaseCoin, BitGoBase, Environments, ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { BaseUnit, BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { KeyPair, TransactionBuilderFactory } from './lib'; @@ -67,8 +67,11 @@ export class Injective extends CosmosCoin { /** @inheritDoc **/ protected async getAccountDetails(senderAddress: string): Promise { const response = await this.getAccountFromNode(senderAddress); + if (response.status === 404) { + throw new ErrorNoInputToRecover('Account not found'); + } if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return [response.body.account.base_account.account_number, response.body.account.base_account.sequence]; } diff --git a/modules/sdk-coin-injective/test/unit/injective.ts b/modules/sdk-coin-injective/test/unit/injective.ts index dd1269fab62..d60e38b7f25 100644 --- a/modules/sdk-coin-injective/test/unit/injective.ts +++ b/modules/sdk-coin-injective/test/unit/injective.ts @@ -1,5 +1,6 @@ import { CosmosTransaction, SendMessage } from '@bitgo/abstract-cosmos'; import { BitGoAPI } from '@bitgo/sdk-api'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { mockSerializedChallengeWithProofs, TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; @@ -401,12 +402,7 @@ describe('INJ', function () { describe('Recover transaction: failure path', () => { const sandBox = sinon.createSandbox(); const destinationAddress = wrwUser.destinationAddress; - const testZeroBalance = [ - { - denom: 'inj', - amount: '0', - }, - ]; + const testZeroBalance = []; const testChainId = 'test-chain'; beforeEach(() => { @@ -471,7 +467,45 @@ describe('INJ', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); + }); + }); + + describe('Recover transaction: provider failure', () => { + const sandBox = sinon.createSandbox(); + const destinationAddress = wrwUser.destinationAddress; + + beforeEach(() => { + const deserializedEntChallenge = EcdsaTypes.deserializeNtildeWithProofs(mockSerializedChallengeWithProofs); + sinon.stub(EcdsaRangeProof, 'generateNtilde').resolves(deserializedEntChallenge); + + sandBox.stub(Injective.prototype, 'getChainIdFromNode' as keyof Injective).resolves({ status: 500, body: {} }); + + sandBox + .stub(Injective.prototype, 'getAccountFromNode' as keyof Injective) + .resolves({ status: 200, body: mockAccountDetailsResponse }); + + sandBox + .stub(Injective.prototype, 'getAccountBalance' as keyof Injective) + .withArgs(wrwUser.senderAddress) + .resolves([{ denom: 'inj', amount: '0' }]); + }); + + afterEach(() => { + sandBox.restore(); + sinon.restore(); + }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + await basecoin + .recover({ + userKey: wrwUser.userPrivateKey, + backupKey: wrwUser.backupPrivateKey, + bitgoKey: wrwUser.bitgoPublicKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: destinationAddress, + }) + .should.rejectedWith(RecoveryProviderError); }); }); }); diff --git a/modules/sdk-coin-islm/src/islm.ts b/modules/sdk-coin-islm/src/islm.ts index 484050e2630..70c3e37adaa 100644 --- a/modules/sdk-coin-islm/src/islm.ts +++ b/modules/sdk-coin-islm/src/islm.ts @@ -1,5 +1,5 @@ import { CosmosCoin, CosmosKeyPair, GasAmountDetails } from '@bitgo/abstract-cosmos'; -import { BaseCoin, BitGoBase, Environments } from '@bitgo/sdk-core'; +import { BaseCoin, BitGoBase, Environments, ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, BaseUnit, coins } from '@bitgo/statics'; import { KeyPair, TransactionBuilderFactory } from './lib'; import { GAS_AMOUNT, GAS_LIMIT } from './lib/constants'; @@ -73,8 +73,11 @@ export class Islm extends CosmosCoin { /** @inheritDoc **/ protected async getAccountDetails(senderAddress: string): Promise { const response = await this.getAccountFromNode(senderAddress); + if (response.status === 404) { + throw new ErrorNoInputToRecover('Account not found'); + } if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return [response.body.account.base_account.account_number, response.body.account.base_account.sequence]; } diff --git a/modules/sdk-coin-islm/test/unit/islm.ts b/modules/sdk-coin-islm/test/unit/islm.ts index 03d34cbe73f..3371a561ad7 100644 --- a/modules/sdk-coin-islm/test/unit/islm.ts +++ b/modules/sdk-coin-islm/test/unit/islm.ts @@ -1,4 +1,5 @@ import { BitGoAPI } from '@bitgo/sdk-api'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import BigNumber from 'bignumber.js'; import sinon from 'sinon'; @@ -332,4 +333,46 @@ describe('Islm', function () { stub.restore(); }); }); + + describe('Recover transaction: negative path', function () { + const sandBox = sinon.createSandbox(); + const recoveryDestination = address.address1; + const commonKeyChain = + '03924e763697f4388577cf234a57b1675ff5048f3adddec23959d85862f55cc97f6a06c263e' + + 'a42166875f0f13acb828c724bd9d67d06c440dff6e308326e9833ab'; + + beforeEach(function () { + sandBox.stub(Islm.prototype, 'getAccountDetails' as keyof Islm).resolves(['0', '0']); + sandBox.stub(Islm.prototype, 'getAccountBalance' as keyof Islm).resolves([]); + }); + + afterEach(function () { + sandBox.restore(); + sinon.restore(); + }); + + it('should throw ErrorNoInputToRecover when there is no balance', async function () { + sandBox + .stub(Islm.prototype, 'getChainIdFromNode' as keyof Islm) + .resolves({ status: 200, body: { block: { header: { chain_id: 'test-chain' } } } }); + await basecoin + .recover({ + rootAddress: recoveryDestination, + bitgoKey: commonKeyChain, + recoveryDestination, + }) + .should.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + sandBox.stub(Islm.prototype, 'getChainIdFromNode' as keyof Islm).resolves({ status: 500, body: {} }); + await basecoin + .recover({ + rootAddress: recoveryDestination, + bitgoKey: commonKeyChain, + recoveryDestination, + }) + .should.rejectedWith(RecoveryProviderError); + }); + }); }); diff --git a/modules/sdk-coin-near/src/near.ts b/modules/sdk-coin-near/src/near.ts index 39a820a58fd..927d013971f 100644 --- a/modules/sdk-coin-near/src/near.ts +++ b/modules/sdk-coin-near/src/near.ts @@ -21,6 +21,7 @@ import { EDDSAMethodTypes, EddsaSigningMaterial, Environments, + ErrorNoInputToRecover, getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, KeyPair, MPCAlgorithm, @@ -35,6 +36,7 @@ import { ParsedTransaction, ParseTransactionOptions as BaseParseTransactionOptions, PublicKey, + RecoveryProviderError, RecoveryTxRequest, signEddsaMpcV2RecoveryTx, SignedTransaction, @@ -396,7 +398,7 @@ export class Near extends BaseCoin { } catch (e) { // UNKNOWN_ACCOUNT error indicates that the address has not partake in any transaction so far, so we will // treat it as a zero balance address - if (e.message !== 'UNKNOWN_ACCOUNT') { + if (!(e instanceof ErrorNoInputToRecover)) { throw e; } } @@ -499,7 +501,7 @@ export class Near extends BaseCoin { } return { serializedTx: serializedTx, scanIndex: i }; } - throw new Error('Did not find an address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find an address with funds to recover'); } /** @@ -798,14 +800,15 @@ export class Near extends BaseCoin { */ protected async getDataFromNode(params: { payload?: Record }): Promise { const nodeUrls = this.getPublicNodeUrls(); + let lastError; for (const nodeUrl of nodeUrls) { try { return await request.post(nodeUrl).send(params.payload); } catch (e) { - console.debug(e); + lastError = e; } } - throw new Error(`Unable to call endpoint: '/' from nodes: ${_.join(nodeUrls, ', ')}`); + throw new RecoveryProviderError(`Unable to call endpoint: '/' from nodes: ${_.join(nodeUrls, ', ')}`, lastError); } protected async getAccessKey({ @@ -829,7 +832,7 @@ export class Near extends BaseCoin { }, }); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } const accessKey = response.body.result; return { nonce: accessKey.nonce + 1, blockHash: accessKey.block_hash }; @@ -849,11 +852,14 @@ export class Near extends BaseCoin { }, }); if (response.status !== 200) { - throw new Error('Failed to query account information'); + throw new RecoveryProviderError('Failed to query account information'); + } + const errorCause = response.body.error?.cause?.name; + if (errorCause === 'UNKNOWN_ACCOUNT') { + throw new ErrorNoInputToRecover(errorCause); } - const errorCause = response.body.error?.cause.name; if (errorCause !== undefined) { - throw new Error(errorCause); + throw new RecoveryProviderError(errorCause); } const account = response.body.result; @@ -888,11 +894,11 @@ export class Near extends BaseCoin { }, }); if (response.status !== 200) { - throw new Error('Failed to fetch ft balance of the account'); + throw new RecoveryProviderError('Failed to fetch ft balance of the account'); } const errorCause = response.body.error?.cause?.name; if (errorCause !== undefined) { - throw new Error(errorCause); + throw new RecoveryProviderError(errorCause); } const resultUint8Array: Uint8Array = new Uint8Array(response.body.result.result); const raw = new TextDecoder().decode(resultUint8Array); @@ -922,11 +928,11 @@ export class Near extends BaseCoin { }, }); if (response.status !== 200) { - throw new Error('Failed to fetch storage deposit of the account'); + throw new RecoveryProviderError('Failed to fetch storage deposit of the account'); } const errorCause = response.body.error?.cause?.name; if (errorCause !== undefined) { - throw new Error(errorCause); + throw new RecoveryProviderError(errorCause); } const resultUint8Array: Uint8Array = new Uint8Array(response.body.result.result); const raw = new TextDecoder().decode(resultUint8Array); @@ -946,7 +952,7 @@ export class Near extends BaseCoin { }, }); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } const config = response.body.result; @@ -977,7 +983,7 @@ export class Near extends BaseCoin { }, }); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return response.body.result.gas_price; } diff --git a/modules/sdk-coin-near/test/unit/near.ts b/modules/sdk-coin-near/test/unit/near.ts index c16b873f057..a3b63fa6380 100644 --- a/modules/sdk-coin-near/test/unit/near.ts +++ b/modules/sdk-coin-near/test/unit/near.ts @@ -13,9 +13,11 @@ import { coins } from '@bitgo/statics'; import { common, EDDSAMethods, + ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, MPCTx, + RecoveryProviderError, signRecoveryEddsaMPCv2, TransactionPrebuild, Wallet, @@ -1331,11 +1333,27 @@ describe('NEAR:', function () { recoveryDestination: destAddr, scan: numIteration, }) - .should.rejectedWith('Did not find an address with funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); // getDataFromNode should be called numIteration + 1 times since we initially // call getProtocolConfig sandBox.assert.callCount(basecoin.getDataFromNode, numIteration + 1); }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + (Near.prototype as any).getDataFromNode + .withArgs(sinon.match.hasNested('payload.params.request_type', 'view_account')) + .resolves({ status: 500, body: {} }); + await basecoin + .recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + walletPassphrase: 'Ghghjkg!455544llll', + recoveryDestination: destAddr, + scan: numIteration, + }) + .should.rejectedWith(RecoveryProviderError); + }); }); describe('Recover Token Transactions:', () => { diff --git a/modules/sdk-coin-osmo/test/unit/osmo.ts b/modules/sdk-coin-osmo/test/unit/osmo.ts index 4d27e0972f1..f5b8e4eb888 100644 --- a/modules/sdk-coin-osmo/test/unit/osmo.ts +++ b/modules/sdk-coin-osmo/test/unit/osmo.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { mockSerializedChallengeWithProofs, TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; import sinon from 'sinon'; @@ -529,7 +530,7 @@ describe('OSMO', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-polyx/src/polyx.ts b/modules/sdk-coin-polyx/src/polyx.ts index aaa2c29df1d..4b16f95f7c2 100644 --- a/modules/sdk-coin-polyx/src/polyx.ts +++ b/modules/sdk-coin-polyx/src/polyx.ts @@ -5,11 +5,13 @@ import { BitGoBase, EDDSAMethods, EDDSAMethodTypes, + ErrorNoInputToRecover, MPCRecoveryOptions, MPCSweepTxs, MPCTx, MPCUnsignedTx, RecoveryTxRequest, + RecoveryProviderError, Environments, MPCSweepRecoveryOptions, MPCTxs, @@ -159,21 +161,33 @@ export class Polyx extends SubstrateCoin { const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); const senderAddr = this.getAddressFromPublicKey(accountId); - const { nonce, freeBalance } = await this.getAccountInfo(senderAddr); + let accountInfo; + let partialFee; + try { + accountInfo = await this.getAccountInfo(senderAddr); + partialFee = await this.getFee(params.recoveryDestination, senderAddr, accountInfo.freeBalance); + } catch (e) { + throw new RecoveryProviderError(`Failed to query account balance or fee for ${senderAddr}`, e); + } + const { nonce, freeBalance } = accountInfo; - const destAddr = params.recoveryDestination; - const amount = freeBalance; - const partialFee = await this.getFee(destAddr, senderAddr, amount); const paddedFee = new BigNumber(partialFee).times(10).toNumber(); - const amountToSend = new BigNumber(amount).minus(new BigNumber(paddedFee)); + const amountToSend = new BigNumber(freeBalance).minus(new BigNumber(paddedFee)); const value = new BigNumber(freeBalance).minus(new BigNumber(partialFee)); if (value.isLessThanOrEqualTo(0)) { - throw new Error('Did not find address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find address with funds to recover'); } - const { headerNumber, headerHash } = await this.getHeaderInfo(); - const material = await this.getMaterial(); + let headerInfo; + let material; + try { + headerInfo = await this.getHeaderInfo(); + material = await this.getMaterial(); + } catch (e) { + throw new RecoveryProviderError('Failed to query chain header or material', e); + } + const { headerNumber, headerHash } = headerInfo; const validityWindow = { firstValid: headerNumber, maxDuration: this.MAX_VALIDITY_DURATION }; const txBuilder = this.getBuilder().getTransferBuilder().material(material) as TransferBuilder; diff --git a/modules/sdk-coin-polyx/test/unit/polyx.ts b/modules/sdk-coin-polyx/test/unit/polyx.ts index 3d399fb7a4c..86eb37fbf42 100644 --- a/modules/sdk-coin-polyx/test/unit/polyx.ts +++ b/modules/sdk-coin-polyx/test/unit/polyx.ts @@ -11,10 +11,12 @@ import { afterEach } from 'mocha'; import { genesisHash, specVersion, txVersion, rawTx, accounts, mockTssSignature } from '../resources'; import { EDDSAMethods, + ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, MPCRecoveryOptions, MPCTx, + RecoveryProviderError, signRecoveryEddsaMPCv2, TxIntentMismatchRecipientError, } from '@bitgo/sdk-core'; @@ -362,6 +364,47 @@ describe('Polyx:', function () { }); }); + describe('Recover Transaction Failures:', function () { + const sandBox = sinon.createSandbox(); + const recoveryDestination = '5H56f31hSYGCRV3URjQHv2Cc4ZSkJNHTM8MKGtkkV6hzCqN7'; + + beforeEach(function () { + sandBox.stub(Polyx.prototype, 'getAccountInfo' as keyof Polyx).resolves({ nonce: 0, freeBalance: 0 }); + sandBox.stub(Polyx.prototype, 'getFee' as keyof Polyx).resolves(74401); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should fail to recover due to not finding an address with funds', async function () { + await baseCoin + .recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + walletPassphrase: testData.wrwUser.walletPassphrase, + recoveryDestination, + }) + .should.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when account info query fails', async function () { + (Polyx.prototype as unknown as { getAccountInfo: sinon.SinonStub }).getAccountInfo.rejects( + new Error('network failure') + ); + await baseCoin + .recover({ + userKey: testData.wrwUser.userKey, + backupKey: testData.wrwUser.backupKey, + bitgoKey: testData.wrwUser.bitgoKey, + walletPassphrase: testData.wrwUser.walletPassphrase, + recoveryDestination, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); + }); + describe('Token Enablement:', function () { it('should have correct token enablement config', function () { const config = baseCoin.getTokenEnablementConfig(); diff --git a/modules/sdk-coin-rune/src/rune.ts b/modules/sdk-coin-rune/src/rune.ts index fcd2fb0aa04..3931accc9c6 100644 --- a/modules/sdk-coin-rune/src/rune.ts +++ b/modules/sdk-coin-rune/src/rune.ts @@ -14,6 +14,7 @@ import { Ecdsa, ECDSAUtils, Environments, + ErrorNoInputToRecover, TransactionType, VerifyTransactionOptions, } from '@bitgo/sdk-core'; @@ -179,11 +180,17 @@ export class Rune extends CosmosCoin { // Step 4: Fetch account details such as accountNo, balance and check for sufficient funds once gasAmount has been deducted const [accountNumber, sequenceNo] = await this.getAccountDetails(senderAddress); const balances = await this.getAccountBalance(senderAddress); + if (!balances?.length) { + throw new ErrorNoInputToRecover('No balance found on account'); + } const balance = new BigNumber(balances[0].amount); const gasBudget: FeeData = { amount: [{ denom: this.getDenomination(), amount: this.getGasAmountDetails().gasAmount }], gasLimit: this.getGasAmountDetails().gasLimit, }; + if (balance.isLessThanOrEqualTo(0)) { + throw new ErrorNoInputToRecover('Did not have enough funds to recover'); + } const actualBalance = balance.minus(this.getNativeRuneTxnFees()); if (actualBalance.isLessThanOrEqualTo(0)) { diff --git a/modules/sdk-coin-rune/test/unit/rune.ts b/modules/sdk-coin-rune/test/unit/rune.ts index 887d2bbd5b5..b8bae21ea20 100644 --- a/modules/sdk-coin-rune/test/unit/rune.ts +++ b/modules/sdk-coin-rune/test/unit/rune.ts @@ -1,6 +1,7 @@ import { CosmosTransaction, SendMessage } from '@bitgo/abstract-cosmos'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { coins, NetworkType } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; @@ -398,7 +399,29 @@ describe('Rune', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: wrwUser.destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); + }); + }); + + describe('Recover transaction: provider failure', () => { + const sandBox = sinon.createSandbox(); + + afterEach(() => { + sandBox.restore(); + sinon.restore(); + }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + sandBox.stub(Trune.prototype, 'getChainIdFromNode' as keyof Trune).resolves({ status: 500, body: {} }); + await trune + .recover({ + userKey: wrwUser.userPrivateKey, + backupKey: wrwUser.backupPrivateKey, + bitgoKey: wrwUser.bitgoPublicKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: wrwUser.destinationAddress, + }) + .should.rejectedWith(RecoveryProviderError); }); }); }); diff --git a/modules/sdk-coin-sei/test/unit/sei.ts b/modules/sdk-coin-sei/test/unit/sei.ts index 08cd9269498..3b06ab57ccc 100644 --- a/modules/sdk-coin-sei/test/unit/sei.ts +++ b/modules/sdk-coin-sei/test/unit/sei.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { mockSerializedChallengeWithProofs, TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; import sinon from 'sinon'; @@ -472,7 +473,7 @@ describe('SEI', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-stx/src/stx.ts b/modules/sdk-coin-stx/src/stx.ts index d51f242c788..12800815d67 100644 --- a/modules/sdk-coin-stx/src/stx.ts +++ b/modules/sdk-coin-stx/src/stx.ts @@ -4,6 +4,7 @@ import { BaseTransaction, BitGoBase, Environments, + ErrorNoInputToRecover, getBip32Keys, getIsUnsignedSweep, KeyPair, @@ -11,6 +12,7 @@ import { MultisigType, multisigTypes, SignedTransaction, + RecoveryProviderError, TransactionParams, TransactionRecipient, TransactionType, @@ -423,7 +425,7 @@ export class Stx extends BaseCoin { const body: NativeStxBalance = response.body; return body; } catch (e) { - throw new Error(`unable to get native stx balance from node: ${e.message}`); + throw new RecoveryProviderError(`unable to get native stx balance from node: ${e.message}`, e); } } @@ -449,7 +451,7 @@ export class Stx extends BaseCoin { const body: SingleFungibleTokenBalance = response.body; return body; } catch (e) { - throw new Error(`unable to get native stx balance from node: ${e.message}`); + throw new RecoveryProviderError(`unable to get native stx balance from node: ${e.message}`, e); } } @@ -468,7 +470,7 @@ export class Stx extends BaseCoin { const body: StxNonceResponse = response.body; return body; } catch (e) { - throw new Error(`unable to get account nonce from node: ${e.message}`); + throw new RecoveryProviderError(`unable to get account nonce from node: ${e.message}`, e); } } @@ -501,7 +503,7 @@ export class Stx extends BaseCoin { } return body.estimations[0].fee; } catch (e) { - throw new Error(`unable to get transaction fee estimation: ${e.message}`); + throw new RecoveryProviderError(`unable to get transaction fee estimation: ${e.message}`, e); } } @@ -625,7 +627,7 @@ export class Stx extends BaseCoin { }); const tokenBalance = tokenBalanceData?.balance; if (!Number(tokenBalance) || isNaN(Number(tokenBalance))) { - throw new Error( + throw new ErrorNoInputToRecover( `no token balance found to recover for address: ${rootAddressDetails.address}, token: ${assetId}` ); } @@ -805,7 +807,7 @@ export class Stx extends BaseCoin { ]); const balance = Number(accountBalanceData.balance); if (!balance || isNaN(balance)) { - throw new Error('could not find any balance to recover for ' + params.rootAddress); + throw new ErrorNoInputToRecover('could not find any balance to recover for ' + params.rootAddress); } const userPub = publicKeyFromBuffer(keys[0].publicKey); const backupPub = publicKeyFromBuffer(keys[1].publicKey); diff --git a/modules/sdk-coin-stx/test/unit/stx.ts b/modules/sdk-coin-stx/test/unit/stx.ts index 347b26da881..982233555e6 100644 --- a/modules/sdk-coin-stx/test/unit/stx.ts +++ b/modules/sdk-coin-stx/test/unit/stx.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import nock from 'nock'; import { BitGoAPI } from '@bitgo/sdk-api'; -import { Wallet } from '@bitgo/sdk-core'; +import { ErrorNoInputToRecover, RecoveryProviderError, Wallet } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; import { cvToString, pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; @@ -617,9 +617,7 @@ describe('STX:', function () { bitgoKey: testData.HOT_WALLET_KEY_CARD_INFO.BITGO_PUB_KEY, walletPassphrase: testData.HOT_WALLET_KEY_CARD_INFO.WALLET_PASSPHRASE, }; - await basecoin - .recover(recoveryOptions) - .should.rejectedWith(`could not find any balance to recover for ${rootAddress}`); + await basecoin.recover(recoveryOptions).should.rejectedWith(ErrorNoInputToRecover); }); it('should fail with insufficient balance when stx balance is lower than fee', async function () { @@ -649,5 +647,24 @@ describe('STX:', function () { }; await basecoin.recover(recoveryOptions).should.rejectedWith('insufficient balance to build the transaction'); }); + + it('should throw RecoveryProviderError when the node fails to return the stx balance', async function () { + const rootAddress = testData.HOT_WALLET_ROOT_ADDRESS; + nock(`https://api.testnet.hiro.so`) + .get(`/extended/v2/addresses/${rootAddress}/balances/stx`) + .reply(500, { error: 'node unavailable' }); + nock(`https://api.testnet.hiro.so`) + .get(`/extended/v1/address/${rootAddress}/nonces`) + .reply(200, testData.ACCOUNT_NONCE_RESPONSE); + const recoveryOptions: RecoveryOptions = { + backupKey: testData.HOT_WALLET_KEY_CARD_INFO.BACKUP_KEY, + userKey: testData.HOT_WALLET_KEY_CARD_INFO.USER_KEY, + rootAddress: rootAddress, + recoveryDestination: testData.DESTINATION_ADDRESS_WRW, + bitgoKey: testData.HOT_WALLET_KEY_CARD_INFO.BITGO_PUB_KEY, + walletPassphrase: testData.HOT_WALLET_KEY_CARD_INFO.WALLET_PASSPHRASE, + }; + await basecoin.recover(recoveryOptions).should.rejectedWith(RecoveryProviderError); + }); }); }); diff --git a/modules/sdk-coin-sui/src/lib/rpcClient.ts b/modules/sdk-coin-sui/src/lib/rpcClient.ts index 88da462dcc9..f36e8cefba4 100644 --- a/modules/sdk-coin-sui/src/lib/rpcClient.ts +++ b/modules/sdk-coin-sui/src/lib/rpcClient.ts @@ -1,4 +1,5 @@ import * as superagent from 'superagent'; +import { RecoveryProviderError } from '@bitgo/sdk-core'; export type Params = any; @@ -23,18 +24,23 @@ export async function requestIgnoreStatusCode(params: Params): Promise> { - const res = await requestIgnoreStatusCode({ - method: 'post', - url, - timeoutMs: 3000, - params: { jsonrpc: '2.0', id: 1, method: method, params: params }, - }); + let res; + try { + res = await requestIgnoreStatusCode({ + method: 'post', + url, + timeoutMs: 3000, + params: { jsonrpc: '2.0', id: 1, method: method, params: params }, + }); + } catch (e) { + throw new RecoveryProviderError(`RPC call failed for ${method}: ${(e as Error).message}`, e); + } if (res.body.error) { const error = res.body.error; - throw new Error(`Request to the node failed. Error code: ${error.code}, message: ${error.message}`); + throw new RecoveryProviderError(`Request to the node failed. Error code: ${error.code}, message: ${error.message}`); } if (res.statusCode !== 200) { - throw new Error(`Request to the node failed, Status code: ${res.statusCode}`); + throw new RecoveryProviderError(`Request to the node failed, Status code: ${res.statusCode}`); } return res.body.result; } diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index 30bef91c371..ca025e8a8af 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -5,6 +5,7 @@ import { isValidEd25519PublicKey, ParseTransactionError, Recipient, + RecoveryProviderError, TransactionType, } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; @@ -603,7 +604,7 @@ export class Utils implements BaseUtils { cursor = result.nextCursor; } catch (e) { console.error(`Failed to get input coins from the node ${e}`); - throw new Error(`Failed to get input coins from the node.`); + throw new RecoveryProviderError(`Failed to get input coins from the node.`, e); } } return data diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index c77dffbd906..902ee51ccf0 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -11,6 +11,7 @@ import { EDDSAMethodTypes, EddsaSigningMaterial, Environments, + ErrorNoInputToRecover, getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, isEddsaSigningMaterial, KeyPair, @@ -24,6 +25,7 @@ import { MPCUnsignedTx, ParsedTransaction, ParseTransactionOptions as BaseParseTransactionOptions, + RecoveryProviderError, RecoveryTxRequest, signEddsaMpcV2RecoveryTx, SignedTransaction, @@ -370,10 +372,14 @@ export class Sui extends BaseCoin { availableBalance = new BigNumber(balanceInfo.totalBalance); fundsInAddressBalance = new BigNumber(balanceInfo.fundsInAddressBalance); } catch (e) { - throw new Error( + if (e instanceof RecoveryProviderError) { + throw e; + } + throw new RecoveryProviderError( `Failed to query Sui balance for address ${senderAddress} at index ${idx} via ${this.getPublicNodeUrl()}: ${ (e as Error).message - }` + }`, + e ); } if (availableBalance.minus(MAX_GAS_BUDGET).toNumber() <= 0) { @@ -394,7 +400,10 @@ export class Sui extends BaseCoin { continue; } } catch (e) { - continue; + if (e instanceof RecoveryProviderError) { + throw e; + } + throw new RecoveryProviderError(`Failed to query Sui token balance for address ${senderAddress}`, e); } return this.recoverSuiToken( params, @@ -508,7 +517,7 @@ export class Sui extends BaseCoin { }; } - throw new Error( + throw new ErrorNoInputToRecover( `Did not find an address with sufficient funds to recover. Please start the next scan at address index ${endIdx}. If it is token transaction, please keep sufficient Sui balance in the address for the transaction fee.` ); } @@ -882,7 +891,7 @@ export class Sui extends BaseCoin { try { recoveryTransaction = await this.recover(recoverParams, signingMaterial); } catch (e) { - if (e.message.startsWith('Did not find an address with sufficient funds to recover.')) { + if (e instanceof ErrorNoInputToRecover) { lastScanIndex = idx; continue; } @@ -898,7 +907,7 @@ export class Sui extends BaseCoin { } if (consolidationTransactions.length === 0) { - throw new Error( + throw new ErrorNoInputToRecover( `Did not find an address with sufficient funds to recover. Please start the next scan at address index ${ lastScanIndex + 1 }.` diff --git a/modules/sdk-coin-sui/test/unit/sui.ts b/modules/sdk-coin-sui/test/unit/sui.ts index d6ba1b02214..0280248a25a 100644 --- a/modules/sdk-coin-sui/test/unit/sui.ts +++ b/modules/sdk-coin-sui/test/unit/sui.ts @@ -16,10 +16,12 @@ import { Buffer } from 'buffer'; import { common, EDDSAMethods, + ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, MPCRecoveryOptions, MPCTxs, + RecoveryProviderError, signRecoveryEddsaMPCv2, TransactionPrebuild, Wallet, @@ -3002,9 +3004,7 @@ describe('SUI:', function () { startingScanIndex: '0', scan: 1, }) - .should.rejectedWith( - 'Did not find an address with sufficient funds to recover. Please start the next scan at address index 1. If it is token transaction, please keep sufficient Sui balance in the address for the transaction fee.' - ); + .should.rejectedWith(ErrorNoInputToRecover); sandBox.assert.callCount(basecoin.getBalance, 1); }); @@ -3022,9 +3022,7 @@ describe('SUI:', function () { walletPassphrase, scan: '10', }) - .should.rejectedWith( - 'Did not find an address with sufficient funds to recover. Please start the next scan at address index 10. If it is token transaction, please keep sufficient Sui balance in the address for the transaction fee.' - ); + .should.rejectedWith(ErrorNoInputToRecover); sandBox.assert.callCount(basecoin.getBalance, 10); }); @@ -3043,7 +3041,7 @@ describe('SUI:', function () { startingScanIndex: '0', scan: 1, }) - .should.be.rejectedWith(/Failed to query Sui balance.*Method not found/); + .should.be.rejectedWith(RecoveryProviderError); sandBox.assert.callCount(basecoin.getBalance, 1); }); @@ -3069,9 +3067,7 @@ describe('SUI:', function () { startingScanIndex: '1', endingScanIndex: '2', }) - .should.rejectedWith( - 'Did not find an address with sufficient funds to recover. Please start the next scan at address index 2.' - ); + .should.rejectedWith(ErrorNoInputToRecover); sandBox.assert.callCount(basecoin.getBalance, 1); }); diff --git a/modules/sdk-coin-tia/test/unit/tia.ts b/modules/sdk-coin-tia/test/unit/tia.ts index 7c41b3d201c..7255347df45 100644 --- a/modules/sdk-coin-tia/test/unit/tia.ts +++ b/modules/sdk-coin-tia/test/unit/tia.ts @@ -3,6 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { mockSerializedChallengeWithProofs, TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; +import { ErrorNoInputToRecover } from '@bitgo/sdk-core'; import BigNumber from 'bignumber.js'; import { beforeEach } from 'mocha'; import sinon from 'sinon'; @@ -571,7 +572,7 @@ describe('TIA', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); }); }); }); diff --git a/modules/sdk-coin-ton/src/lib/utils.ts b/modules/sdk-coin-ton/src/lib/utils.ts index 8a611057705..487f44ae076 100644 --- a/modules/sdk-coin-ton/src/lib/utils.ts +++ b/modules/sdk-coin-ton/src/lib/utils.ts @@ -1,6 +1,6 @@ import TonWeb from 'tonweb'; import { BN } from 'bn.js'; -import { BaseUtils, isValidEd25519PublicKey } from '@bitgo/sdk-core'; +import { BaseUtils, isValidEd25519PublicKey, RecoveryProviderError } from '@bitgo/sdk-core'; import { VESTING_CONTRACT_CODE_B64 } from './constants'; import { VestingContractParams } from './iface'; export class Utils implements BaseUtils { @@ -153,7 +153,7 @@ export async function getFeeEstimate(wallet: any, toAddress: string, amount: str .estimateFee(); return feeEstimate; } catch (error) { - throw new Error(`Failed to estimate fee: ${error.message}`); + throw new RecoveryProviderError(`Failed to estimate fee: ${error.message}`, error); } } diff --git a/modules/sdk-coin-ton/src/ton.ts b/modules/sdk-coin-ton/src/ton.ts index 46c6595da91..80870c13290 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -28,8 +28,10 @@ import { OvcInput, OvcOutput, Environments, + ErrorNoInputToRecover, MPCSweepTxs, PublicKey, + RecoveryProviderError, MPCTxs, MPCSweepRecoveryOptions, AuditDecryptedKeyParams, @@ -394,7 +396,12 @@ export class Ton extends BaseCoin { const currPath = params.seed ? getDerivationPath(params.seed) + `/${index}` : `m/${index}`; const accountId = MPC.deriveUnhardened(bitgoKey, currPath).slice(0, 64); const senderAddr = await Utils.default.getAddressFromPublicKey(accountId); - const balance = await tonweb.getBalance(senderAddr); + let balance; + try { + balance = await tonweb.getBalance(senderAddr); + } catch (e) { + throw new RecoveryProviderError(`Failed to get TON balance for address ${senderAddr}`, e); + } const jettonBalances: { minterAddress?: string; walletAddress: string; balance: string }[] = []; if (params.senderJettonAddress) { @@ -408,7 +415,10 @@ export class Ton extends BaseCoin { }); } } catch (e) { - throw new Error(`Failed to query jetton balance for address ${params.senderJettonAddress}: ${e.message}`); + throw new RecoveryProviderError( + `Failed to query jetton balance for address ${params.senderJettonAddress}: ${e.message}`, + e + ); } } @@ -417,7 +427,12 @@ export class Ton extends BaseCoin { publicKey: tonweb.utils.hexToBytes(accountId), wc: 0, }); - const seqnoResult = await wallet.methods.seqno().call(); + let seqnoResult; + try { + seqnoResult = await wallet.methods.seqno().call(); + } catch (e) { + throw new RecoveryProviderError(`Failed to query seqno for address ${senderAddr}`, e); + } const seqno: number = seqnoResult !== null && seqnoResult !== undefined ? seqnoResult : 0; const factory = this.getBuilder(); @@ -460,7 +475,7 @@ export class Ton extends BaseCoin { transactionType = 'jetton'; } else { if (new BigNumber(balance).isEqualTo(0)) { - throw Error('Did not find address with TON balance to recover'); + throw new ErrorNoInputToRecover('Did not find address with TON balance to recover'); } const tonFeeEstimate = await getFeeEstimate(wallet, params.recoveryDestination, balance, seqno as number); diff --git a/modules/sdk-coin-ton/test/unit/ton.ts b/modules/sdk-coin-ton/test/unit/ton.ts index 84b6651d88c..61fdd64ab8e 100644 --- a/modules/sdk-coin-ton/test/unit/ton.ts +++ b/modules/sdk-coin-ton/test/unit/ton.ts @@ -6,10 +6,12 @@ import assert from 'assert'; import * as testData from '../resources/ton'; import { EDDSAMethods, + ErrorNoInputToRecover, MPCRecoveryOptions, MPCSweepRecoveryOptions, MPCSweepTxs, MPCTx, + RecoveryProviderError, signRecoveryEddsaMPCv2, TransactionExplanation, } from '@bitgo/sdk-core'; @@ -860,6 +862,46 @@ describe('TON:', function () { (unsigned as any).txRequests[0].transactions[0].unsignedTx.coinSpecific.commonKeychain.should.equal(bitgoKey); }); + it('should throw ErrorNoInputToRecover when there is no TON balance to recover', async function () { + const mockProvider = { + getBalance: sandbox.stub().resolves('0'), + getEstimateFee: sandbox.stub().resolves({ + source_fees: { in_fwd_fee: 1000, storage_fee: 1000, gas_fee: 1000, fwd_fee: 1000 }, + }), + call: sandbox.stub(), + send: sandbox.stub(), + }; + sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider as any); + + const bitgoKey = + '1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9dae1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857'; + const recoveryDestination = 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ'; + + await basecoin + .recover({ bitgoKey, recoveryDestination, apiKey: 'dummy' }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the provider fails to fetch the balance', async function () { + const mockProvider = { + getBalance: sandbox.stub().rejects(new Error('provider down')), + getEstimateFee: sandbox.stub().resolves({ + source_fees: { in_fwd_fee: 1000, storage_fee: 1000, gas_fee: 1000, fwd_fee: 1000 }, + }), + call: sandbox.stub(), + send: sandbox.stub(), + }; + sandbox.stub(Tonweb, 'HttpProvider').returns(mockProvider as any); + + const bitgoKey = + '1baafa0d62174bf0c78f3256318613ffc44b6dd54ab1a63c2185232f92ede9dae1b2818dbeb52a8215fd56f5a5f2a9f94c079ce89e4dc3b1ce6ed6e84ce71857'; + const recoveryDestination = 'UQBL2idCXR4ATdQtaNa4VpofcpSxuxIgHH7_slOZfdOXSadJ'; + + await basecoin + .recover({ bitgoKey, recoveryDestination, apiKey: 'dummy' }) + .should.be.rejectedWith(RecoveryProviderError); + }); + it('should take OVC output and generate a signed sweep transaction', async function () { // Define the parameters (mock OVC response) const params = { diff --git a/modules/sdk-coin-trx/src/trx.ts b/modules/sdk-coin-trx/src/trx.ts index 1a2171079a0..16abaf4b04b 100644 --- a/modules/sdk-coin-trx/src/trx.ts +++ b/modules/sdk-coin-trx/src/trx.ts @@ -9,6 +9,7 @@ import * as request from 'superagent'; import { BaseCoin, BitGoBase, + ErrorNoInputToRecover, common, getBip32Keys, getIsKrsRecovery, @@ -19,6 +20,7 @@ import { MPCAlgorithm, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, SignedTransaction, SignTransactionOptions, TransactionExplanation, @@ -747,17 +749,26 @@ export class Trx extends BaseCoin { private async recoveryPost(query: { path: string; jsonObj: any; node: NodeTypes }): Promise { const nodeUri = this.getNodeUrl(query.node); - const response = await request - .post(nodeUri + query.path) - .type('json') - .send(query.jsonObj); + let response; + try { + response = await request + .post(nodeUri + query.path) + .type('json') + .send(query.jsonObj); + } catch (e) { + throw new RecoveryProviderError('could not reach Tron node', e); + } if (!response.ok) { - throw new Error('could not reach Tron node'); + throw new RecoveryProviderError('could not reach Tron node'); } // unfortunately, it doesn't look like most TRON nodes return valid json as body - return JSON.parse(response.text); + try { + return JSON.parse(response.text); + } catch (e) { + throw new RecoveryProviderError('malformed response from Tron node', e); + } } /** @@ -768,17 +779,26 @@ export class Trx extends BaseCoin { private async recoveryGet(query: { path: string; jsonObj: any; node: NodeTypes }): Promise { const nodeUri = this.getNodeUrl(query.node); - const response = await request - .get(nodeUri + query.path) - .type('json') - .send(query.jsonObj); + let response; + try { + response = await request + .get(nodeUri + query.path) + .type('json') + .send(query.jsonObj); + } catch (e) { + throw new RecoveryProviderError('could not reach Tron node', e); + } if (!response.ok) { - throw new Error('could not reach Tron node'); + throw new RecoveryProviderError('could not reach Tron node'); } // unfortunately, it doesn't look like most TRON nodes return valid json as body - return JSON.parse(response.text); + try { + return JSON.parse(response.text); + } catch (e) { + throw new RecoveryProviderError('malformed response from Tron node', e); + } } /** @@ -1006,7 +1026,7 @@ export class Trx extends BaseCoin { } return this.formatForOfflineVault(await txBuilder.build(), SAFE_TRON_TOKEN_TRANSACTION_FEE, recoveryAmount); } else { - throw Error('Not found token to recover, please check token balance'); + throw new ErrorNoInputToRecover('Not found token to recover, please check token balance'); } } // let us recover the native Tron @@ -1130,7 +1150,7 @@ export class Trx extends BaseCoin { const account = await this.getAccountBalancesFromNode(walletAddress); if (!account.data[0]) { - throw new Error(`Account ${walletAddress} not found or has no data`); + throw new ErrorNoInputToRecover(`Account ${walletAddress} not found or has no data`); } const tokenContractAddr = params.tokenContractAddress; @@ -1157,7 +1177,7 @@ export class Trx extends BaseCoin { } if (!rawTokenTxn) { - throw new Error('Not found token to recover, please check token balance'); + throw new ErrorNoInputToRecover('Not found token to recover, please check token balance'); } const trxBalance = account.data[0].balance; diff --git a/modules/sdk-coin-trx/test/unit/trx.ts b/modules/sdk-coin-trx/test/unit/trx.ts index e0adbc31507..4e95a0fe42b 100644 --- a/modules/sdk-coin-trx/test/unit/trx.ts +++ b/modules/sdk-coin-trx/test/unit/trx.ts @@ -1,8 +1,10 @@ import assert from 'node:assert'; import { afterEach, before, describe, it, mock } from 'node:test'; import { BitGoAPI } from '@bitgo/sdk-api'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; import * as _ from 'lodash'; +import nock from 'nock'; import { Trx, Ttrx, Utils } from '../../src'; import { signTxOptions, mockTx } from '../fixtures'; import { @@ -855,9 +857,30 @@ describe('TRON:', function () { recoveryDestination: TssTestRecoverData.recoveryDestination, isTss: true, }), - { - message: 'Not found token to recover, please check token balance', - } + ErrorNoInputToRecover + ); + }); + }); + + describe('Recover Provider Failure', () => { + afterEach(() => { + mock.reset(); + nock.cleanAll(); + }); + + it('should throw RecoveryProviderError when the Tron node is unreachable', async () => { + nock('https://api.shasta.trongrid.io') + .get(/\/v1\/accounts\/.*/) + .reply(500, { error: 'node unavailable' }); + + await assert.rejects( + basecoin.recover({ + userKey: TestRecoverData.userKey, + backupKey: TestRecoverData.backupKey, + bitgoKey: TestRecoverData.bitgoKey, + recoveryDestination: TestRecoverData.recoveryDestination, + }), + RecoveryProviderError ); }); }); diff --git a/modules/sdk-coin-vet/src/vet.ts b/modules/sdk-coin-vet/src/vet.ts index a7c1a71312e..32bf478cf40 100644 --- a/modules/sdk-coin-vet/src/vet.ts +++ b/modules/sdk-coin-vet/src/vet.ts @@ -27,6 +27,8 @@ import { Ecdsa, ECDSAUtils, Environments, + ErrorNoInputToRecover, + RecoveryProviderError, BaseBroadcastTransactionOptions, BaseBroadcastTransactionResult, verifyMPCWalletAddress, @@ -464,6 +466,9 @@ export class Vet extends BaseCoin { tx: signedTx.toBroadcastFormat(), }; } catch (error) { + if (error instanceof RecoveryProviderError || error instanceof ErrorNoInputToRecover) { + throw error; + } throw new Error(`Error during Vechain recovery: ${error.message || error}`); } } @@ -512,6 +517,9 @@ export class Vet extends BaseCoin { ); } } catch (error) { + if (error instanceof RecoveryProviderError) { + throw error; + } throw new Error(`Failed to ensure VTHO balance: ${error.message}`); } } @@ -537,7 +545,7 @@ export class Vet extends BaseCoin { return balance; } catch (error) { - throw new Error('Failed to get native balance.'); + throw new RecoveryProviderError('Failed to get native balance.', error); } } @@ -566,14 +574,17 @@ export class Vet extends BaseCoin { // Validate response and extract the balance data if (!simResponse || !Array.isArray(simResponse) || simResponse.length === 0 || !simResponse[0].data) { - throw new Error('Invalid simulation response from VeChain node'); + throw new RecoveryProviderError('Invalid simulation response from VeChain node'); } // The returned data is the hex-encoded balance return new BigNumber(simResponse[0].data); } catch (error) { logger.error('Error fetching token balance:', error); - throw new Error(`Failed to get token balance: ${error.message}`); + if (error instanceof RecoveryProviderError) { + throw error; + } + throw new RecoveryProviderError(`Failed to get token balance: ${error.message}`, error); } } @@ -593,14 +604,17 @@ export class Vet extends BaseCoin { // Validate the response data if (!data || !data.id) { - throw new Error('Invalid response from the VeChain node'); + throw new RecoveryProviderError('Invalid response from the VeChain node'); } // Return the first 18 characters of the block ID return data.id.slice(0, 18); } catch (error) { // Rethrow or return a sensible default - throw new Error('Failed to get block ref: '); + if (error instanceof RecoveryProviderError) { + throw error; + } + throw new RecoveryProviderError('Failed to get block ref: ', error); } } @@ -642,7 +656,7 @@ export class Vet extends BaseCoin { const simResponse = response.data; if (!simResponse || !Array.isArray(simResponse)) { - throw new Error('Invalid simulation response from VeChain node'); + throw new RecoveryProviderError('Invalid simulation response from VeChain node'); } const totalSimulatedGas = simResponse.reduce((sum, result) => sum + (result.gasUsed || 0), 0); @@ -653,7 +667,10 @@ export class Vet extends BaseCoin { return new BigNumber(totalGas); } catch (error) { - throw new Error(`Failed to estimate gas: ${error.message}`); + if (error instanceof RecoveryProviderError) { + throw error; + } + throw new RecoveryProviderError(`Failed to estimate gas: ${error.message}`, error); } } @@ -673,7 +690,7 @@ export class Vet extends BaseCoin { const balance = await this.getBalance(baseAddress); if (balance.isLessThanOrEqualTo(0)) { - throw new Error(`no VET balance to recover for address ${baseAddress}`); + throw new ErrorNoInputToRecover(`no VET balance to recover for address ${baseAddress}`); } const recipients = [ @@ -814,6 +831,9 @@ export class Vet extends BaseCoin { tx: signedTx.toBroadcastFormat(), }; } catch (error) { + if (error instanceof RecoveryProviderError || error instanceof ErrorNoInputToRecover) { + throw error; + } throw new Error(`Error during Vechain token recovery: ${error.message || error}`); } } @@ -830,7 +850,7 @@ export class Vet extends BaseCoin { //replace with get balance function if (balance.isLessThanOrEqualTo(0)) { - throw new Error( + throw new ErrorNoInputToRecover( `no token balance to recover for address ${baseAddress} contract address ${tokenContractAddress}` ); } diff --git a/modules/sdk-coin-vet/test/unit/vet.ts b/modules/sdk-coin-vet/test/unit/vet.ts index 1411fe20d07..add46c9b1d0 100644 --- a/modules/sdk-coin-vet/test/unit/vet.ts +++ b/modules/sdk-coin-vet/test/unit/vet.ts @@ -4,6 +4,8 @@ import _ from 'lodash'; import should from 'should'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; +import BigNumber from 'bignumber.js'; import { coins, GasTankAccountCoin } from '@bitgo/statics'; import { Vet, Tvet, Transaction } from '../../src'; import * as testData from '../resources/vet'; @@ -333,4 +335,46 @@ describe('Vechain', function () { await basecoin.isWalletAddress(params).should.be.rejected(); }); }); + + describe('Recovery', () => { + const sandbox = sinon.createSandbox(); + const bitgoKey = + '0310768736a005ea5364e1b5b5288cf553224dd28b2df8ced63b72a8020478967f05ec5bce1f26cd7eb009a4bea445bb55c2f54a30f2706c1a3747e8df2d288829'; + const recoveryDestination = testData.addresses.validAddresses[1]; + + afterEach(function () { + sandbox.restore(); + }); + + it('should throw ErrorNoInputToRecover when there is no VET balance to recover', async function () { + sandbox.stub(Vet.prototype, 'getBalance').resolves(new BigNumber(0)); + await basecoin + .recover({ + bitgoKey, + recoveryDestination, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw ErrorNoInputToRecover when there is no token balance to recover', async function () { + sandbox.stub(Vet.prototype, 'getBalance').resolves(new BigNumber(0)); + await basecoin + .recover({ + bitgoKey, + recoveryDestination, + tokenContractAddress: testData.NFT_CONTRACT_ADDRESS, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the balance query fails', async function () { + sandbox.stub(Vet.prototype, 'getBalance').rejects(new RecoveryProviderError('Failed to get native balance.')); + await basecoin + .recover({ + bitgoKey, + recoveryDestination, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); + }); }); diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index eb8c5e697e3..4e4120b8bd5 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -11,6 +11,7 @@ import { BaseCoin, BitGoBase, checkKrsProvider, + ErrorNoInputToRecover, getBip32Keys, InvalidAddressError, KeyPair, @@ -20,6 +21,7 @@ import { ParsedTransaction, ParseTransactionOptions, promiseProps, + RecoveryProviderError, TokenEnablementConfig, TransactionParams, UnexpectedAddressError, @@ -697,12 +699,20 @@ export class Xrp extends BaseCoin { const keys = await getBip32Keys(this.bitgo, params, { requireBitGoXpub: false }); - const { addressDetails, feeDetails, serverDetails, accountLines } = await promiseProps({ - addressDetails: this.bitgo.post(rippledUrl).send(accountInfoParams), - feeDetails: this.bitgo.post(rippledUrl).send({ method: 'fee' }), - serverDetails: this.bitgo.post(rippledUrl).send({ method: 'server_info' }), - accountLines: this.bitgo.post(rippledUrl).send(accountLinesParams), - }); + let addressDetails; + let feeDetails; + let serverDetails; + let accountLines; + try { + ({ addressDetails, feeDetails, serverDetails, accountLines } = await promiseProps({ + addressDetails: this.bitgo.post(rippledUrl).send(accountInfoParams), + feeDetails: this.bitgo.post(rippledUrl).send({ method: 'fee' }), + serverDetails: this.bitgo.post(rippledUrl).send({ method: 'server_info' }), + accountLines: this.bitgo.post(rippledUrl).send(accountLinesParams), + })); + } catch (e) { + throw new RecoveryProviderError('Unable to query rippled node for recovery', e); + } const openLedgerFee = new BigNumber(feeDetails.body.result.drops.open_ledger_fee); const baseReserve = new BigNumber(serverDetails.body.result.info.validated_ledger.reserve_base_xrp).times( @@ -791,7 +801,7 @@ export class Xrp extends BaseCoin { } if (recoverableBalance.toNumber() <= 0) { - throw new Error( + throw new ErrorNoInputToRecover( `Quantity of XRP to recover must be greater than 0. Current balance: ${balance.toNumber()}, blockchain reserve: ${reserve.toNumber()}, spendable balance: ${recoverableBalance.toNumber()}` ); } @@ -987,10 +997,10 @@ export class Xrp extends BaseCoin { } if (amount === undefined) { - throw new Error(`Does not have Trustline with ${issuer}`); + throw new ErrorNoInputToRecover(`Does not have Trustline with ${issuer}`); } if (amount === '0') { - throw new Error(`Does not have funds to recover`); + throw new ErrorNoInputToRecover(`Does not have funds to recover`); } const decimalPlaces = coins.get(tokenName).decimalPlaces; diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index f6cdf11b4b5..5ae73d14387 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -5,7 +5,7 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import ripple from '../../src/ripple'; import { Txrp } from '../../src/txrp'; -import { Wallet } from '@bitgo/sdk-core'; +import { ErrorNoInputToRecover, RecoveryProviderError, Wallet } from '@bitgo/sdk-core'; import assert from 'assert'; import * as _ from 'lodash'; import * as nock from 'nock'; @@ -837,6 +837,97 @@ describe('XRP:', function () { }); }); + describe('Recover - empty input and provider failure', () => { + const sandBox = sinon.createSandbox(); + const destination = 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP?dt=12345'; + const passPhrase = '#Bondiola1234'; + let xrplStub; + + afterEach(() => { + sandBox.restore(); + }); + + function stubRippled({ accountInfoResponse, accountLinesResponse, fail = false }: any) { + xrplStub = sandBox.stub(basecoin.bitgo, 'post'); + const sendStub = sinon.stub(); + if (fail) { + sendStub.rejects(new Error('rippled node unreachable')); + } else { + sendStub + .withArgs({ + method: 'account_info', + params: [ + { + account: testData.keys.rootAddress, + strict: true, + ledger_index: 'current', + queue: true, + signer_lists: true, + }, + ], + }) + .resolves(accountInfoResponse); + sendStub.withArgs({ method: 'fee' }).resolves(testData.feeResponse); + sendStub.withArgs({ method: 'server_info' }).resolves(testData.serverInfoResponse); + sendStub + .withArgs({ + method: 'account_lines', + params: [{ account: testData.keys.rootAddress, ledger_index: 'validated' }], + }) + .resolves(accountLinesResponse); + } + xrplStub.withArgs(basecoin.getRippledUrl()).returns({ send: sendStub }); + } + + it('should throw ErrorNoInputToRecover when there is no XRP to recover', async function () { + const noBalance = JSON.parse(JSON.stringify(testData.accountInfoResponse)); + noBalance.body.result.account_data.Balance = '0'; + stubRippled({ accountInfoResponse: noBalance, accountLinesResponse: testData.accountlinesResponseEmpty }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw ErrorNoInputToRecover when the token trustline has no funds', async function () { + const zeroBalanceLines = JSON.parse(JSON.stringify(testData.accountlinesResponse)); + zeroBalanceLines.body.result.lines[0].balance = '0'; + stubRippled({ accountInfoResponse: testData.accountInfoResponse, accountLinesResponse: zeroBalanceLines }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + issuerAddress: 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV', + currencyCode: '524C555344000000000000000000000000000000', + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the rippled node query fails', async function () { + stubRippled({ fail: true }); + + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + rootAddress: testData.keys.rootAddress, + recoveryDestination: destination, + walletPassphrase: passPhrase, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); + }); + describe('Verify Transaction', () => { let newTxPrebuild; diff --git a/modules/sdk-coin-xtz/src/xtz.ts b/modules/sdk-coin-xtz/src/xtz.ts index eec643a89ae..17f16c59232 100644 --- a/modules/sdk-coin-xtz/src/xtz.ts +++ b/modules/sdk-coin-xtz/src/xtz.ts @@ -2,9 +2,11 @@ import { BaseCoin, BaseTransactionBuilder, BitGoBase, + ErrorNoInputToRecover, MethodNotImplementedError, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, KeyPair as SdkCoreKeyPair, SignedTransaction, TransactionExplanation, @@ -283,18 +285,23 @@ export class Xtz extends BaseCoin { }, apiKey?: string ): Promise { - const response = await request.get( - `${common.Environments[this.bitgo.getEnv()].xtzExplorerBaseUrl}/v1/${params.actionPath}${ - params.address ? '/' + params.address : '' - }${params.action ? '/' + params.action : ''}${apiKey ? `?apikey=${apiKey}` : ''}` - ); + let response; + try { + response = await request.get( + `${common.Environments[this.bitgo.getEnv()].xtzExplorerBaseUrl}/v1/${params.actionPath}${ + params.address ? '/' + params.address : '' + }${params.action ? '/' + params.action : ''}${apiKey ? `?apikey=${apiKey}` : ''}` + ); + } catch (e) { + throw new RecoveryProviderError('could not reach TZKT', e); + } if (!response.ok) { - throw new Error('could not reach TZKT'); + throw new RecoveryProviderError('could not reach TZKT'); } if (response.status === 429) { - throw new Error('TZKT rate limit reached'); + throw new RecoveryProviderError('TZKT rate limit reached'); } return response.body; } @@ -315,7 +322,7 @@ export class Xtz extends BaseCoin { ); if (!result) { - throw new Error(`Unable to find details for ${address}`); + throw new ErrorNoInputToRecover(`Unable to find details for ${address}`); } return result; } @@ -336,7 +343,7 @@ export class Xtz extends BaseCoin { ); // throw if the result does not exist or the result is not a valid number if (!result || !result.balance) { - throw new Error(`Could not obtain address balance for ${address} from the explorer`); + throw new ErrorNoInputToRecover(`Could not obtain address balance for ${address} from the explorer`); } return new BigNumber(result.balance, 10); } @@ -358,14 +365,21 @@ export class Xtz extends BaseCoin { }/chains/main/blocks/head/helpers/scripts/pack_data`; if (!xtzRpcUrl) { - throw new Error('XTZ RPC url not found'); + throw new RecoveryProviderError('XTZ RPC url not found'); } - const response = await request.post(xtzRpcUrl).send(dataToSign); + let response; + try { + response = await request.post(xtzRpcUrl).send(dataToSign); + } catch (e) { + throw new RecoveryProviderError(`unable to pack data to sign: ${(e as Error).message}`, e); + } if (response.status === 404) { - throw new Error(`unable to pack data to sign ${response.status}: ${response.body.error.message}`); + throw new RecoveryProviderError(`unable to pack data to sign ${response.status}: ${response.body.error.message}`); } else if (response.status !== 200) { - throw new Error(`unexpected IMS response status ${response.status}: ${response.body.error.message}`); + throw new RecoveryProviderError( + `unexpected IMS response status ${response.status}: ${response.body.error.message}` + ); } return response.body.packed; } @@ -444,11 +458,11 @@ export class Xtz extends BaseCoin { // get balance of sender address if (!userAddressDetails.balance || userAddressDetails.balance === 0) { - throw new Error('No funds to recover from source address'); + throw new ErrorNoInputToRecover('No funds to recover from source address'); } const txAmount = userAddressDetails.balance; if (new BigNumber(txAmount).isLessThanOrEqualTo(0)) { - throw new Error('Wallet does not have enough funds to recover'); + throw new ErrorNoInputToRecover('Wallet does not have enough funds to recover'); } const feeInfo = { @@ -468,7 +482,7 @@ export class Xtz extends BaseCoin { }); if (!chainHead || !chainHead.hash) { - throw new Error('Unable to fetch chain head'); + throw new RecoveryProviderError('Unable to fetch chain head'); } txBuilder.branch(chainHead.hash); diff --git a/modules/sdk-coin-xtz/test/unit/offlineTransactionBuilder.ts b/modules/sdk-coin-xtz/test/unit/offlineTransactionBuilder.ts index aaf5c624ba2..315c1f5a95d 100644 --- a/modules/sdk-coin-xtz/test/unit/offlineTransactionBuilder.ts +++ b/modules/sdk-coin-xtz/test/unit/offlineTransactionBuilder.ts @@ -1,9 +1,10 @@ import should from 'should'; -import { common, TransactionType } from '@bitgo/sdk-core'; +import { common, ErrorNoInputToRecover, RecoveryProviderError, TransactionType } from '@bitgo/sdk-core'; import { coins } from '@bitgo/statics'; import { Txtz, Xtz, XtzLib } from '../../src'; import nock from 'nock'; +import request from 'superagent'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; import { @@ -355,5 +356,49 @@ describe('Offline Tezos Transaction builder', function () { consolidationResult.sourceCounter.should.equal('54996303'); consolidationResult.transferCounters[0].should.equal('54996303'); }); + + it('should throw ErrorNoInputToRecover when there are no funds to recover', async function () { + const basecoin = bitgo.coin('txtz') as Xtz; + const params = paramsDetailsForRecovery; + + nock(baseUrl) + .get(`/v1/accounts/${params.walletContractAddress}`) + .times(1) + .reply(200, { ...contractAddressDetails, balance: 0 }); + nock(baseUrl).get(`/v1/accounts/${params.feeAddress}`).times(1).reply(200, feeAddressDetails); + + await basecoin + .recover({ + userKey: params.userKey, + backupKey: params.backupKey, + recoveryDestination: params.recoveryDestination, + walletContractAddress: params.walletContractAddress, + walletPassphrase: params.walletPassphrase, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the explorer is unreachable', async function () { + const basecoin = bitgo.coin('txtz') as Xtz; + const params = paramsDetailsForRecovery; + + const originalGet = request.get; + request.get = function () { + return Promise.reject(new Error('connection refused')) as any; + }; + try { + await basecoin + .recover({ + userKey: params.userKey, + backupKey: params.backupKey, + recoveryDestination: params.recoveryDestination, + walletContractAddress: params.walletContractAddress, + walletPassphrase: params.walletPassphrase, + }) + .should.be.rejectedWith(RecoveryProviderError); + } finally { + request.get = originalGet; + } + }); }); }); diff --git a/modules/sdk-coin-zeta/src/zeta.ts b/modules/sdk-coin-zeta/src/zeta.ts index 01bbf33ee95..2eb40953652 100644 --- a/modules/sdk-coin-zeta/src/zeta.ts +++ b/modules/sdk-coin-zeta/src/zeta.ts @@ -1,5 +1,13 @@ import { CosmosCoin, CosmosKeyPair, GasAmountDetails } from '@bitgo/abstract-cosmos'; -import { BaseCoin, BitGoBase, Environments, TransactionType, VerifyTransactionOptions } from '@bitgo/sdk-core'; +import { + BaseCoin, + BitGoBase, + Environments, + ErrorNoInputToRecover, + RecoveryProviderError, + TransactionType, + VerifyTransactionOptions, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, BaseUnit, coins } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; import * as _ from 'lodash'; @@ -69,8 +77,11 @@ export class Zeta extends CosmosCoin { /** @inheritDoc **/ protected async getAccountDetails(senderAddress: string): Promise { const response = await this.getAccountFromNode(senderAddress); + if (response.status === 404) { + throw new ErrorNoInputToRecover('Account not found'); + } if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return [response.body.account.base_account.account_number, response.body.account.base_account.sequence]; } diff --git a/modules/sdk-coin-zeta/test/unit/zeta.ts b/modules/sdk-coin-zeta/test/unit/zeta.ts index ebefd3cd2e2..4ff40477c50 100644 --- a/modules/sdk-coin-zeta/test/unit/zeta.ts +++ b/modules/sdk-coin-zeta/test/unit/zeta.ts @@ -1,6 +1,6 @@ import { CosmosTransaction, RedelegateMessage, SendMessage } from '@bitgo/abstract-cosmos'; import { BitGoAPI } from '@bitgo/sdk-api'; -import { Environments } from '@bitgo/sdk-core'; +import { Environments, ErrorNoInputToRecover, RecoveryProviderError } from '@bitgo/sdk-core'; import { EcdsaRangeProof, EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { TestBitGo, TestBitGoAPI, mockSerializedChallengeWithProofs } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; @@ -490,12 +490,7 @@ describe('Zeta', function () { describe('Recover transaction: failure path', () => { const sandBox = sinon.createSandbox(); const destinationAddress = wrwUser.destinationAddress; - const testZeroBalance = [ - { - denom: 'azeta', - amount: '0', - }, - ]; + const testZeroBalance = []; const testChainId = 'test-chain'; beforeEach(() => { @@ -560,7 +555,45 @@ describe('Zeta', function () { walletPassphrase: wrwUser.walletPassphrase, recoveryDestination: destinationAddress, }) - .should.rejectedWith('Did not have enough funds to recover'); + .should.rejectedWith(ErrorNoInputToRecover); + }); + }); + + describe('Recover transaction: provider failure', () => { + const sandBox = sinon.createSandbox(); + const destinationAddress = wrwUser.destinationAddress; + + beforeEach(() => { + const deserializedEntChallenge = EcdsaTypes.deserializeNtildeWithProofs(mockSerializedChallengeWithProofs); + sinon.stub(EcdsaRangeProof, 'generateNtilde').resolves(deserializedEntChallenge); + + sandBox.stub(Zeta.prototype, 'getChainIdFromNode' as keyof Zeta).resolves({ status: 500, body: {} }); + + sandBox + .stub(Zeta.prototype, 'getAccountFromNode' as keyof Zeta) + .resolves({ status: 200, body: mockAccountDetailsResponse }); + + sandBox + .stub(Zeta.prototype, 'getAccountBalance' as keyof Zeta) + .withArgs(wrwUser.senderAddress) + .resolves([{ denom: 'azeta', amount: '0' }]); + }); + + afterEach(() => { + sandBox.restore(); + sinon.restore(); + }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + await basecoin + .recover({ + userKey: wrwUser.userPrivateKey, + backupKey: wrwUser.backupPrivateKey, + bitgoKey: wrwUser.bitgoPublicKey, + walletPassphrase: wrwUser.walletPassphrase, + recoveryDestination: destinationAddress, + }) + .should.rejectedWith(RecoveryProviderError); }); }); });