From 4bf92c1273a1fd0d051269ec340e44c713e9eb36 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Fri, 9 Oct 2026 11:10:35 -0400 Subject: [PATCH] feat: honest empty vs provider-failed in recover() Add RecoveryProviderError alongside ErrorNoInputToRecover and retype empty-wallet throws and provider-failure paths across UTXO, EVM, XLM, EOS, SOL, and IOTA so a recovery scanner can distinguish the two by type Ticket: WCN-2892 --- .../src/abstractEthLikeNewCoins.ts | 26 ++++--- modules/abstract-eth/src/ethLikeToken.ts | 15 +++- modules/abstract-eth/src/lib/utils.ts | 13 +++- .../src/recovery/RecoveryProvider.ts | 29 ++++++- .../backupKeyRecoveryUnspentGathering.ts | 53 ++++++++++++- modules/sdk-coin-avaxc/src/avaxc.ts | 24 ++++-- modules/sdk-coin-avaxc/test/unit/avaxc.ts | 26 ++++++- modules/sdk-coin-coredao/package.json | 3 +- modules/sdk-coin-coredao/src/coredao.ts | 16 +++- modules/sdk-coin-coredao/test/unit/coredao.ts | 18 ++++- modules/sdk-coin-eos/src/eos.ts | 14 ++-- modules/sdk-coin-eos/test/unit/eos.ts | 66 +++++++++++++++- modules/sdk-coin-etc/src/etc.ts | 33 +++++--- modules/sdk-coin-etc/test/unit/etc.ts | 26 ++++++- modules/sdk-coin-eth/package.json | 1 + modules/sdk-coin-eth/src/erc20Token.ts | 7 +- modules/sdk-coin-eth/src/erc721Token.ts | 7 +- modules/sdk-coin-eth/src/erc7984Token.ts | 5 +- modules/sdk-coin-eth/src/eth.ts | 15 +++- modules/sdk-coin-eth/test/unit/eth.ts | 75 +++++++++++++++++++ modules/sdk-coin-iota/src/iota.ts | 66 ++++++++++------ modules/sdk-coin-iota/test/unit/iota.ts | 50 +++++++++++++ modules/sdk-coin-sol/src/sol.ts | 49 ++++++------ modules/sdk-coin-sol/test/unit/sol.ts | 28 +++++++ modules/sdk-coin-xlm/src/xlm.ts | 13 +++- modules/sdk-coin-xlm/test/unit/xlm.ts | 42 ++++++++++- modules/sdk-core/src/bitgo/errors.ts | 12 +++ 27 files changed, 626 insertions(+), 106 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index c353f958f6f..6e4d4f0d224 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -10,6 +10,7 @@ import { Ecdsa, ECDSAMethodTypes, ECDSAUtils, + ErrorNoInputToRecover, EthereumLibraryUnavailableError, FeeEstimateOptions, FullySignedTransaction, @@ -29,6 +30,7 @@ import { PrebuildTransactionResult, PresignTransactionOptions as BasePresignTransactionOptions, Recipient, + RecoveryProviderError, SignTransactionOptions as BaseSignTransactionOptions, TxIntentMismatchError, TxIntentMismatchRecipientError, @@ -663,7 +665,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem ); // throw if the result does not exist or the result is not a valid number if (!result || !result.result || isNaN(result.result)) { - throw new Error(`Could not obtain address balance for ${address} from the explorer, got: ${result.result}`); + throw new RecoveryProviderError( + `Could not obtain address balance for ${address} from the explorer, got: ${result.result}` + ); } return new optionalDeps.ethUtil.BN(result.result, 10); } @@ -770,7 +774,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem apiKey ); if (!result || !result.result) { - throw new Error('Could not obtain sequence ID from explorer, got: ' + result.result); + throw new RecoveryProviderError('Could not obtain sequence ID from explorer, got: ' + result.result); } const sequenceIdHex = result.result; return new optionalDeps.ethUtil.BN(sequenceIdHex.slice(2), 16).toNumber(); @@ -986,7 +990,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem } if (!result || !Array.isArray(result.result)) { - throw new Error('Unable to find next nonce from Etherscan, got: ' + JSON.stringify(result)); + throw new RecoveryProviderError('Unable to find next nonce from Etherscan, got: ' + JSON.stringify(result)); } const backupKeyTxList = result.result; if (backupKeyTxList.length > 0) { @@ -1436,11 +1440,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem try { recoveryTransaction = await this.recover(recoverParams); } catch (e) { - if ( - e.message === 'Did not find address with funds to recover' || - e.message === 'Did not find token account to recover tokens, please check token account' || - e.message === 'Not enough token funds to recover' - ) { + if (e instanceof ErrorNoInputToRecover) { lastScanIndex = i; continue; } @@ -1557,7 +1557,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem // get balance of wallet const txAmount = await this.queryAddressBalance(params.walletContractAddress, params.apiKey); 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'); } // build recipients object @@ -1998,7 +1998,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem ); // throw if the result does not exist or the result is not a valid number if (!result || !result.result || isNaN(result.result)) { - throw new Error( + throw new RecoveryProviderError( `Could not obtain token address balance for ${tokenContractAddress} from Etherscan, got: ${result.result}` ); } @@ -3764,6 +3764,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem console.log(` Got gas price: ${gasPrice}`); return gasPrice; } catch (e) { + if (e instanceof RecoveryProviderError) { + throw e; + } throw new Error(`Failed to get gas price. Please make sure to use the api key of ${wrongChainCoin}`); } } @@ -3799,6 +3802,9 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem console.log(`Got gas limit: ${gasLimit}`); return gasLimit; } catch (e) { + if (e instanceof RecoveryProviderError) { + throw e; + } throw new Error( `Failed to get gas limit. Please make sure to use the privateKey aka userKey of ${intendedChain} wallet ${to}` ); diff --git a/modules/abstract-eth/src/ethLikeToken.ts b/modules/abstract-eth/src/ethLikeToken.ts index 997dfe10957..7b92d955d5d 100644 --- a/modules/abstract-eth/src/ethLikeToken.ts +++ b/modules/abstract-eth/src/ethLikeToken.ts @@ -6,7 +6,15 @@ import _ from 'lodash'; import { bip32 } from '@bitgo/secp256k1'; import { BigNumber } from 'bignumber.js'; -import { BitGoBase, CoinConstructor, NamedCoinConstructor, getIsUnsignedSweep, Util } from '@bitgo/sdk-core'; +import { + BitGoBase, + CoinConstructor, + NamedCoinConstructor, + ErrorNoInputToRecover, + RecoveryProviderError, + getIsUnsignedSweep, + Util, +} from '@bitgo/sdk-core'; import { coinFamiliesWithL1Fees, TransactionBuilder as EthLikeTransactionBuilder, @@ -265,6 +273,9 @@ export class EthLikeToken extends AbstractEthLikeNewCoins { address: backupKeyAddress, }); + if (!Array.isArray(result.result)) { + throw new RecoveryProviderError('Could not obtain nonce from explorer, got: ' + JSON.stringify(result.result)); + } const backupKeyTxList = result.result; if (backupKeyTxList.length > 0) { // Calculate last nonce used @@ -297,7 +308,7 @@ export class EthLikeToken extends AbstractEthLikeNewCoins { params.walletContractAddress ); 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'); } // build recipients object diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 550cd4ced4a..95dc0ca69d9 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -22,6 +22,7 @@ import { ActivateMethodId, BuildTransactionError, LockMethodId, + RecoveryProviderError, SigningError, TransactionType, UnlockMethodId, @@ -1298,14 +1299,20 @@ export async function recoveryBlockchainExplorerQuery( if (token) { query.apikey = token; } - const response = await request.get(`${explorerUrl}/api`).query(query); + + let response; + try { + response = await request.get(`${explorerUrl}/api`).query(query); + } catch (e) { + throw new RecoveryProviderError(`could not reach explorer at ${explorerUrl}`, e); + } if (!response.ok) { - throw new Error('could not reach explorer'); + throw new RecoveryProviderError(`could not reach explorer at ${explorerUrl}`); } if (response.body.status === '0' && response.body.message === 'NOTOK') { - throw new Error('Explorer rate limit reached'); + throw new RecoveryProviderError('Explorer rate limit reached'); } return response.body; } diff --git a/modules/abstract-utxo/src/recovery/RecoveryProvider.ts b/modules/abstract-utxo/src/recovery/RecoveryProvider.ts index eb5bd3b1278..eaf3d2c3bcb 100644 --- a/modules/abstract-utxo/src/recovery/RecoveryProvider.ts +++ b/modules/abstract-utxo/src/recovery/RecoveryProvider.ts @@ -1,4 +1,5 @@ -import { BlockchairApi, AddressInfo, TransactionIO } from '@bitgo/blockapis'; +import { ApiRequestError, BlockchairApi, AddressInfo, TransactionIO } from '@bitgo/blockapis'; +import { RecoveryProviderError } from '@bitgo/sdk-core'; import type { Unspent } from '../unspent'; @@ -22,6 +23,30 @@ export interface RecoveryProvider { getTransactionIO(txid: string): Promise; } +/** + * Re-map `@bitgo/blockapis` provider failures (`ApiRequestError`) to the shared + * `RecoveryProviderError` so a recovery scanner can distinguish "indexer failed" + * from "wallet is empty". `ApiRequestError` is the single error type the blockchair + * http client surfaces for network failures, non-2xx responses, and malformed bodies. + */ +function wrapProviderErrors(provider: T): T { + return new Proxy(provider, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value === 'function') { + return (...args: unknown[]) => + Promise.resolve(value.apply(target, args)).catch((e: unknown) => { + if (e instanceof ApiRequestError) { + throw new RecoveryProviderError(e.message, e); + } + throw e; + }); + } + return value; + }, + }) as T; +} + export function forCoin(coinName: string, apiToken?: string): RecoveryProvider { switch (coinName) { case 'btc': @@ -34,7 +59,7 @@ export function forCoin(coinName: string, apiToken?: string): RecoveryProvider Promise.reject(new ApiRequestError('https://api.blockchair.com', 'status code 503')), + getUnspentsForAddresses: () => Promise.reject(new Error('unused')), + getTransactionHex: () => Promise.reject(new Error('unused')), + getTransactionIO: () => Promise.reject(new Error('unused')), + }; + const stub = sinon.stub(BlockchairApi, 'forCoin').returns(failingProvider as any); + try { + const provider = forCoin('btc'); + await assert.rejects(() => provider.getAddressInfo('addr'), RecoveryProviderError); + } finally { + stub.restore(); + } + }); + + it('maps a real blockchair 503 to RecoveryProviderError', async function () { + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + nock('https://api.blockchair.com') + .get('/bitcoin/dashboards/address/' + address) + .reply(503); + + const provider = forCoin('btc'); + await assert.rejects(() => provider.getAddressInfo(address), RecoveryProviderError); + }); + + it('maps a blockchair rate-limit body (data:null) to RecoveryProviderError', async function () { + const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'; + nock('https://api.blockchair.com') + .get('/bitcoin/dashboards/address/' + address) + .reply(200, { + data: null, + context: { + code: 430, + error: 'Your IP address is temporary blacklisted due to exceeding usage of API resources.', + }, + api: {}, + }); + + const provider = forCoin('btc'); + await assert.rejects(() => provider.getAddressInfo(address), RecoveryProviderError); + }); + }); }); diff --git a/modules/sdk-coin-avaxc/src/avaxc.ts b/modules/sdk-coin-avaxc/src/avaxc.ts index a0b897b4eae..f82999ab3ea 100644 --- a/modules/sdk-coin-avaxc/src/avaxc.ts +++ b/modules/sdk-coin-avaxc/src/avaxc.ts @@ -30,6 +30,7 @@ import { ParsedTransaction, ParseTransactionOptions, Recipient, + RecoveryProviderError, TransactionExplanation, Util, VerifyAddressOptions, @@ -328,16 +329,21 @@ export class AvaxC extends AbstractEthLikeNewCoins { * @returns {Promise} response from avax.network */ async recoveryBlockchainExplorerQuery(query: Record, apiKey?: string): Promise { - const response = await request - .post(common.Environments[this.bitgo.getEnv()].avaxcNetworkBaseUrl + '/ext/bc/C/rpc') - .send(query); + let response; + try { + response = await request + .post(common.Environments[this.bitgo.getEnv()].avaxcNetworkBaseUrl + '/ext/bc/C/rpc') + .send(query); + } catch (e) { + throw new RecoveryProviderError('could not reach avax.network', e); + } if (!response.ok) { - throw new Error('could not reach avax.network'); + throw new RecoveryProviderError('could not reach avax.network'); } if (response.body.status === '0' && response.body.message === 'NOTOK') { - throw new Error('avax.network rate limit reached'); + throw new RecoveryProviderError('avax.network rate limit reached'); } return response.body; } @@ -357,7 +363,7 @@ export class AvaxC extends AbstractEthLikeNewCoins { id: 1, }); if (!result || isNaN(result.result)) { - throw new Error('Unable to find next nonce from avax.network, got: ' + JSON.stringify(result)); + throw new RecoveryProviderError('Unable to find next nonce from avax.network, got: ' + JSON.stringify(result)); } const nonceHex = result.result; return new optionalDeps.ethUtil.BN(nonceHex.slice(2), 16).toNumber(); @@ -377,7 +383,9 @@ export class AvaxC extends AbstractEthLikeNewCoins { }); // throw if the result does not exist or the result is not a valid number if (!result || !result.result || isNaN(result.result)) { - throw new Error(`Could not obtain address balance for ${address} from avax.network, got: ${result.result}`); + throw new RecoveryProviderError( + `Could not obtain address balance for ${address} from avax.network, got: ${result.result}` + ); } const nativeBalanceHex = result.result; return new optionalDeps.ethUtil.BN(nativeBalanceHex.slice(2), 16); @@ -435,7 +443,7 @@ export class AvaxC extends AbstractEthLikeNewCoins { id: 1, }); if (!result || !result.result) { - throw new Error('Could not obtain sequence ID from avax.network, got: ' + result.result); + throw new RecoveryProviderError('Could not obtain sequence ID from avax.network, got: ' + result.result); } const sequenceIdHex = result.result; return new optionalDeps.ethUtil.BN(sequenceIdHex.slice(2), 16).toNumber(); diff --git a/modules/sdk-coin-avaxc/test/unit/avaxc.ts b/modules/sdk-coin-avaxc/test/unit/avaxc.ts index 65b7b844bf9..98826a5b7fc 100644 --- a/modules/sdk-coin-avaxc/test/unit/avaxc.ts +++ b/modules/sdk-coin-avaxc/test/unit/avaxc.ts @@ -5,7 +5,7 @@ import { getBuilder } from './getBuilder'; import * as secp256k1 from 'secp256k1'; import { bip32 } from '@bitgo/secp256k1'; import * as nock from 'nock'; -import { common, TransactionType, Wallet } from '@bitgo/sdk-core'; +import { common, RecoveryProviderError, TransactionType, Wallet } from '@bitgo/sdk-core'; import { Eth, optionalDeps } from '@bitgo/sdk-coin-eth'; import { AvaxSignTransactionOptions } from '../../src/iface'; import * as should from 'should'; @@ -13,6 +13,7 @@ import { EXPORT_C, IMPORT_C, endpointResponses, recoveryUsers } from '../resourc import { TavaxP } from '@bitgo/sdk-coin-avaxp'; import { decodeTransaction, parseTransaction, walletSimpleABI } from './helpers'; import * as sinon from 'sinon'; +import request from 'superagent'; import { BN } from 'ethereumjs-util'; import { EthereumNetwork } from '@bitgo/statics'; @@ -1474,3 +1475,26 @@ describe('Avalanche C-Chain', function () { }); }); }); + +describe('avaxc recoveryBlockchainExplorerQuery', function () { + let tavaxCoin: TavaxC; + + before(function () { + const bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('tavaxc', TavaxC.createInstance); + tavaxCoin = bitgo.coin('tavaxc') as TavaxC; + }); + + it('should throw RecoveryProviderError when the node is unreachable', async function () { + const originalPost = request.post; + request.post = (() => ({ send: () => Promise.reject(new Error('connection refused')) })) as any; + try { + await tavaxCoin + .recoveryBlockchainExplorerQuery({ jsonrpc: '2.0', method: 'eth_getBalance', params: [] }) + .should.be.rejectedWith(RecoveryProviderError); + } finally { + request.post = originalPost; + } + }); +}); diff --git a/modules/sdk-coin-coredao/package.json b/modules/sdk-coin-coredao/package.json index f9814487ea5..2636d21bc9c 100644 --- a/modules/sdk-coin-coredao/package.json +++ b/modules/sdk-coin-coredao/package.json @@ -49,7 +49,8 @@ }, "devDependencies": { "@bitgo/sdk-api": "^3.0.7", - "@bitgo/sdk-test": "^9.1.84" + "@bitgo/sdk-test": "^9.1.84", + "superagent": "^9.0.1" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", "files": [ diff --git a/modules/sdk-coin-coredao/src/coredao.ts b/modules/sdk-coin-coredao/src/coredao.ts index dacc551193f..50abc674da8 100644 --- a/modules/sdk-coin-coredao/src/coredao.ts +++ b/modules/sdk-coin-coredao/src/coredao.ts @@ -1,4 +1,12 @@ -import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core'; +import { + BaseCoin, + BitGoBase, + common, + MPCAlgorithm, + MultisigType, + multisigTypes, + RecoveryProviderError, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { AbstractEthLikeNewCoins, @@ -67,7 +75,9 @@ export class Coredao extends AbstractEthLikeNewCoins { }); // throw if the result does not exist or the result is not a valid number if (!result || !result.result || isNaN(Number(result.result))) { - throw new Error(`Could not obtain address balance for ${address} from the explorer, got: ${result.result}`); + throw new RecoveryProviderError( + `Could not obtain address balance for ${address} from the explorer, got: ${result.result}` + ); } result.result = result.result.toString(); @@ -86,7 +96,7 @@ export class Coredao extends AbstractEthLikeNewCoins { address: address, }); if (!result || !Array.isArray(result.result)) { - throw new Error('Unable to find next nonce from the explorer, got: ' + JSON.stringify(result)); + throw new RecoveryProviderError('Unable to find next nonce from the explorer, got: ' + JSON.stringify(result)); } const backupKeyTxList = result.result.filter((tx) => tx.from === address); if (backupKeyTxList.length > 0) { diff --git a/modules/sdk-coin-coredao/test/unit/coredao.ts b/modules/sdk-coin-coredao/test/unit/coredao.ts index a847387011e..bbcfc75d136 100644 --- a/modules/sdk-coin-coredao/test/unit/coredao.ts +++ b/modules/sdk-coin-coredao/test/unit/coredao.ts @@ -5,10 +5,11 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { Coredao, Tcoredao } from '../../src'; import nock from 'nock'; +import request from 'superagent'; import { FeeMarketEIP1559Transaction } from '@ethereumjs/tx'; import { stripHexPrefix } from '@ethereumjs/util'; -import { common } from '@bitgo/sdk-core'; +import { common, RecoveryProviderError } from '@bitgo/sdk-core'; import { mockDataNonBitGoRecovery } from '../resources'; const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' }); @@ -87,3 +88,18 @@ describe('Coredao', function () { }); }); }); + +describe('coredao recoveryBlockchainExplorerQuery', function () { + it('should throw RecoveryProviderError when the explorer is unreachable', async function () { + const coredaoCoin = bitgo.coin('tcoredao') as Tcoredao; + const originalGet = request.get; + request.get = (() => ({ query: () => Promise.reject(new Error('connection refused')) })) as any; + try { + await coredaoCoin + .recoveryBlockchainExplorerQuery({ module: 'account', action: 'balance', address: '0x' + '0'.repeat(40) }) + .should.be.rejectedWith(RecoveryProviderError); + } finally { + request.get = originalGet; + } + }); +}); diff --git a/modules/sdk-coin-eos/src/eos.ts b/modules/sdk-coin-eos/src/eos.ts index 52d6eec7793..8bcbc5e5873 100644 --- a/modules/sdk-coin-eos/src/eos.ts +++ b/modules/sdk-coin-eos/src/eos.ts @@ -20,6 +20,7 @@ import { BitGoBase, checkKrsProvider, Environments, + ErrorNoInputToRecover, getBip32Keys, getIsKrsRecovery, getIsUnsignedSweep, @@ -31,6 +32,7 @@ import { multisigTypes, ParsedTransaction, ParseTransactionOptions, + RecoveryProviderError, RequestTracer, SignTransactionOptions as BaseSignTransactionOptions, TransactionExplanation, @@ -793,7 +795,9 @@ export class Eos extends BaseCoin { // let's hope another call succeeds } } - throw new Error(`Unable to call endpoint: ${params.endpoint} from nodes: ${_.join(nodeUrls, ', ')}`); + throw new RecoveryProviderError( + `Unable to call endpoint: ${params.endpoint} from nodes: ${_.join(nodeUrls, ', ')}` + ); } /** @@ -802,7 +806,7 @@ export class Eos extends BaseCoin { protected async getChainInfoFromNode(): Promise { const response = await this.getDataFromNode({ endpoint: '/v1/chain/get_info' }); if (response.status !== 200) { - throw new Error('Unable to fetch chain info'); + throw new RecoveryProviderError('Unable to fetch chain info'); } return response.body; } @@ -817,7 +821,7 @@ export class Eos extends BaseCoin { payload: { account_name: address }, }); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError(`Account lookup failed with status ${response.status}`); } return response.body; } @@ -832,7 +836,7 @@ export class Eos extends BaseCoin { payload: { block_num_or_id: blockNumOrId }, }); if (response.status !== 200) { - throw new Error('Block not found'); + throw new RecoveryProviderError('Block not found'); } return response.body; } @@ -911,7 +915,7 @@ export class Eos extends BaseCoin { const account = await this.getAccountFromNode({ address: rootAddressDetails.address }); if (!account.core_liquid_balance) { - throw new Error('Could not find any balance to recovery for ' + params.rootAddress); + throw new ErrorNoInputToRecover('Could not find any balance to recovery for ' + params.rootAddress); } if (!account.permissions) { diff --git a/modules/sdk-coin-eos/test/unit/eos.ts b/modules/sdk-coin-eos/test/unit/eos.ts index a72f611d8f2..b85f19eee8f 100644 --- a/modules/sdk-coin-eos/test/unit/eos.ts +++ b/modules/sdk-coin-eos/test/unit/eos.ts @@ -2,7 +2,7 @@ * @prettier */ 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 { bip32 } from '@bitgo/secp256k1'; import * as ecc from 'eosjs-ecc'; @@ -188,6 +188,70 @@ describe('EOS:', function () { sandBox.restore(); }); + it('should throw ErrorNoInputToRecover when the account has no liquid balance', async function () { + const userKey = + 'xpub661MyMwAqRbcH1oUADxatLuKkVjaDB2zTNJoZQsGVQEvoogpbXJw24QMokNwFKj9Qhci6KWaCcQKrzpL4LCQXXX3YpTQxgD9KLBjhDrUWo4'; + const backupKey = + 'xpub661MyMwAqRbcH1n6sgY29G7dAxL7twS8rt1jyuuQb1kfnA7s3FJPGoVqb9JenXkeJmC4jZ8iVscn3AH6MkYAVc61FTYCHpxv5cxWar5Jw3C'; + const rootAddress = 'i1skda3kso43'; + const destinationAddress = 'ks13kdh245ls'; + + const sandBox = sinon.createSandbox(); + const callBack = sandBox.stub(Eos.prototype, 'getDataFromNode' as any); + callBack + .withArgs({ + endpoint: '/v1/chain/get_account', + payload: { account_name: rootAddress }, + }) + .resolves({ status: 200, body: { account_name: rootAddress } }); + + try { + await basecoin + .recover({ + userKey, + backupKey, + bitgoKey: 'key', + recoveryDestination: destinationAddress, + rootAddress, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + } finally { + sandBox.restore(); + } + }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + const userKey = + 'xpub661MyMwAqRbcH1oUADxatLuKkVjaDB2zTNJoZQsGVQEvoogpbXJw24QMokNwFKj9Qhci6KWaCcQKrzpL4LCQXXX3YpTQxgD9KLBjhDrUWo4'; + const backupKey = + 'xpub661MyMwAqRbcH1n6sgY29G7dAxL7twS8rt1jyuuQb1kfnA7s3FJPGoVqb9JenXkeJmC4jZ8iVscn3AH6MkYAVc61FTYCHpxv5cxWar5Jw3C'; + const rootAddress = 'i1skda3kso43'; + const destinationAddress = 'ks13kdh245ls'; + + const sandBox = sinon.createSandbox(); + const callBack = sandBox.stub(Eos.prototype, 'getDataFromNode' as any); + callBack + .withArgs({ + endpoint: '/v1/chain/get_account', + payload: { account_name: rootAddress }, + }) + .resolves({ status: 500, body: {} }); + + try { + await basecoin + .recover({ + userKey, + backupKey, + bitgoKey: 'key', + recoveryDestination: destinationAddress, + rootAddress, + }) + .should.be.rejectedWith(RecoveryProviderError); + } finally { + sandBox.restore(); + } + }); + describe('Transactions:', function () { const testExplainTransaction = (input, expectedOutput) => async function () { diff --git a/modules/sdk-coin-etc/src/etc.ts b/modules/sdk-coin-etc/src/etc.ts index 1bb99229d17..2a9ee5eaf1f 100644 --- a/modules/sdk-coin-etc/src/etc.ts +++ b/modules/sdk-coin-etc/src/etc.ts @@ -14,10 +14,12 @@ import { import { BaseCoin, BitGoBase, + ErrorNoInputToRecover, common, getIsUnsignedSweep, Util, Recipient, + RecoveryProviderError, MultisigType, multisigTypes, } from '@bitgo/sdk-core'; @@ -143,7 +145,7 @@ export class Etc extends AbstractEthLikeCoin { // get balance of wallet const txAmount = await this.queryAddressBalance(params.walletContractAddress); if (txAmount.lt(new BN(0))) { - throw new Error('Wallet does not have enough funds to recover'); + throw new ErrorNoInputToRecover('Wallet does not have enough funds to recover'); } // build recipients object @@ -243,16 +245,19 @@ export class Etc extends AbstractEthLikeCoin { * @returns {Promise} response from etc.network */ async recoveryBlockchainExplorerQuery(query: Record): Promise { - const response = await request - .post(common.Environments[this.bitgo.getEnv()].etcNodeUrl + '/api/eth-rpc') - .send(query); + let response; + try { + response = await request.post(common.Environments[this.bitgo.getEnv()].etcNodeUrl + '/api/eth-rpc').send(query); + } catch (e) { + throw new RecoveryProviderError('could not reach etc.network', e); + } if (!response.ok) { - throw new Error('could not reach etc.network'); + throw new RecoveryProviderError('could not reach etc.network'); } if (response.body.status === '0' && response.body.message === 'NOTOK') { - throw new Error('etc.network rate limit reached'); + throw new RecoveryProviderError('etc.network rate limit reached'); } return response.body; } @@ -297,7 +302,7 @@ export class Etc extends AbstractEthLikeCoin { id: 1, }); if (!result || isNaN(result.result)) { - throw new Error('Unable to find next nonce from etc.network, got: ' + JSON.stringify(result)); + throw new RecoveryProviderError('Unable to find next nonce from etc.network, got: ' + JSON.stringify(result)); } const nonceHex = result.result; return new optionalDeps.ethUtil.BN(nonceHex.slice(2), 16).toNumber(); @@ -318,13 +323,19 @@ export class Etc extends AbstractEthLikeCoin { // throw if the result object does not exist if (!result) { - throw new Error(`Could not obtain address balance for ${address} from etc.network, got: Empty object response`); + throw new RecoveryProviderError( + `Could not obtain address balance for ${address} from etc.network, got: Empty object response` + ); } else if (result.error) { // throw if result.error exists - throw new Error(`Could not obtain address balance for ${address} from etc.network, got: ${result.error}`); + throw new RecoveryProviderError( + `Could not obtain address balance for ${address} from etc.network, got: ${result.error}` + ); } else if (!result.result || isNaN(result.result)) { // throw if the result.result is not a number - throw new Error(`Could not obtain address balance for ${address} from etc.network, got: Incorrect Balance Hex`); + throw new RecoveryProviderError( + `Could not obtain address balance for ${address} from etc.network, got: Incorrect Balance Hex` + ); } const nativeBalanceHex = result.result; @@ -348,7 +359,7 @@ export class Etc extends AbstractEthLikeCoin { id: 1, }); if (!result || !result.result) { - throw new Error('Could not obtain sequence ID from etc.network, got: ' + result.result); + throw new RecoveryProviderError('Could not obtain sequence ID from etc.network, got: ' + result.result); } const sequenceIdHex = result.result; return new optionalDeps.ethUtil.BN(sequenceIdHex.slice(2), 16).toNumber(); diff --git a/modules/sdk-coin-etc/test/unit/etc.ts b/modules/sdk-coin-etc/test/unit/etc.ts index b4a424fb111..da4c1028a18 100644 --- a/modules/sdk-coin-etc/test/unit/etc.ts +++ b/modules/sdk-coin-etc/test/unit/etc.ts @@ -6,8 +6,9 @@ import { OfflineVaultTxInfo, SignTransactionOptions } from '@bitgo/abstract-eth' import { BN } from 'ethereumjs-util'; import { getBuilder } from './getBuilder'; -import { FullySignedTransaction } from '@bitgo/sdk-core'; +import { FullySignedTransaction, RecoveryProviderError } from '@bitgo/sdk-core'; import * as should from 'should'; +import request from 'superagent'; describe('Ethereum Classic', function () { let bitgo: TestBitGoAPI; @@ -191,3 +192,26 @@ describe('Wallet Recovery Wizard', function () { }); }); }); + +describe('etc recoveryBlockchainExplorerQuery', function () { + let tetcCoin: Tetc; + + before(function () { + const bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('tetc', Tetc.createInstance); + tetcCoin = bitgo.coin('tetc') as Tetc; + }); + + it('should throw RecoveryProviderError when the node is unreachable', async function () { + const originalPost = request.post; + request.post = (() => ({ send: () => Promise.reject(new Error('connection refused')) })) as any; + try { + await tetcCoin + .recoveryBlockchainExplorerQuery({ jsonrpc: '2.0', method: 'eth_getBalance', params: [] }) + .should.be.rejectedWith(RecoveryProviderError); + } finally { + request.post = originalPost; + } + }); +}); diff --git a/modules/sdk-coin-eth/package.json b/modules/sdk-coin-eth/package.json index b8a1c938bce..1e14460afad 100644 --- a/modules/sdk-coin-eth/package.json +++ b/modules/sdk-coin-eth/package.json @@ -57,6 +57,7 @@ "devDependencies": { "@bitgo/sdk-api": "^3.0.7", "@bitgo/sdk-test": "^9.1.84", + "bn.js": "^5.2.1", "tweetnacl": "^1.0.3" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", diff --git a/modules/sdk-coin-eth/src/erc20Token.ts b/modules/sdk-coin-eth/src/erc20Token.ts index 22f069e3b8a..64bc3f52b1d 100644 --- a/modules/sdk-coin-eth/src/erc20Token.ts +++ b/modules/sdk-coin-eth/src/erc20Token.ts @@ -4,6 +4,8 @@ import { BitGoBase, CoinConstructor, + ErrorNoInputToRecover, + RecoveryProviderError, Util, checkKrsProvider, getIsKrsRecovery, @@ -216,6 +218,9 @@ export class Erc20Token extends Eth { }, params.apiKey ); + if (!Array.isArray(result.result)) { + throw new RecoveryProviderError('Could not obtain nonce from explorer, got: ' + JSON.stringify(result.result)); + } const backupKeyTxList = result.result; if (backupKeyTxList.length > 0) { // Calculate last nonce used @@ -241,7 +246,7 @@ export class Erc20Token extends Eth { params.apiKey ); 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'); } // build recipients object diff --git a/modules/sdk-coin-eth/src/erc721Token.ts b/modules/sdk-coin-eth/src/erc721Token.ts index 45cb4e79db6..6f750a41683 100644 --- a/modules/sdk-coin-eth/src/erc721Token.ts +++ b/modules/sdk-coin-eth/src/erc721Token.ts @@ -4,6 +4,8 @@ import { BitGoBase, CoinConstructor, + ErrorNoInputToRecover, + RecoveryProviderError, Util, checkKrsProvider, getIsKrsRecovery, @@ -216,6 +218,9 @@ export class Erc721Token extends Eth { }, params.apiKey ); + if (!Array.isArray(result.result)) { + throw new RecoveryProviderError('Could not obtain nonce from explorer, got: ' + JSON.stringify(result.result)); + } const backupKeyTxList = result.result; if (backupKeyTxList.length > 0) { // Calculate last nonce used @@ -241,7 +246,7 @@ export class Erc721Token extends Eth { params.apiKey ); 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'); } // build recipients object diff --git a/modules/sdk-coin-eth/src/erc7984Token.ts b/modules/sdk-coin-eth/src/erc7984Token.ts index cc4dbf28cd4..ab04c4d9c22 100644 --- a/modules/sdk-coin-eth/src/erc7984Token.ts +++ b/modules/sdk-coin-eth/src/erc7984Token.ts @@ -9,6 +9,7 @@ import { getIsUnsignedSweep, MPCAlgorithm, NamedCoinConstructor, + RecoveryProviderError, Util, } from '@bitgo/sdk-core'; import { coins, Erc7984TokenConfig, EthereumNetwork, tokens } from '@bitgo/statics'; @@ -900,14 +901,14 @@ export class Erc7984Token extends Eth { ); if (!result || !result.result) { - throw new Error( + throw new RecoveryProviderError( `Could not obtain confidential balance for ${walletAddress} from token ${this.tokenContractAddress}` ); } const handle = result.result as string; if (!handle.startsWith('0x') || handle.length !== 66) { - throw new Error(`Unexpected confidentialBalanceOf response format: ${handle}`); + throw new RecoveryProviderError(`Unexpected confidentialBalanceOf response format: ${handle}`); } return handle; diff --git a/modules/sdk-coin-eth/src/eth.ts b/modules/sdk-coin-eth/src/eth.ts index 9b351c8f9e8..b3dac7f4434 100644 --- a/modules/sdk-coin-eth/src/eth.ts +++ b/modules/sdk-coin-eth/src/eth.ts @@ -9,6 +9,7 @@ import { BitGoBase, checkKrsProvider, common, + ErrorNoInputToRecover, FullySignedTransaction, getIsKrsRecovery, getIsUnsignedSweep, @@ -17,6 +18,7 @@ import { MultisigType, multisigTypes, Recipient, + RecoveryProviderError, Util, TokenEnablementConfig, } from '@bitgo/sdk-core'; @@ -174,14 +176,19 @@ export class Eth extends AbstractEthLikeNewCoins { if (token) { query.apikey = token; } - const response = await request.get(common.Environments[this.bitgo.getEnv()].etherscanBaseUrl + '/api').query(query); + let response; + try { + response = await request.get(common.Environments[this.bitgo.getEnv()].etherscanBaseUrl + '/api').query(query); + } catch (e) { + throw new RecoveryProviderError('could not reach Etherscan', e); + } if (!response.ok) { - throw new Error('could not reach Etherscan'); + throw new RecoveryProviderError('could not reach Etherscan'); } if (response.body.status === '0' && response.body.message === 'NOTOK') { - throw new Error('Etherscan rate limit reached'); + throw new RecoveryProviderError('Etherscan rate limit reached'); } return response.body; } @@ -272,7 +279,7 @@ export class Eth extends AbstractEthLikeNewCoins { // get balance of wallet and deduct fees to get transaction amount const txAmount = await this.queryAddressBalance(params.walletContractAddress, params.apiKey); 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'); } // build recipients object diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index fcb5274ac0e..51ea6c937de 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -7,15 +7,18 @@ import * as secp256k1 from 'secp256k1'; import request from 'superagent'; import { common, + ErrorNoInputToRecover, generateRandomPassword, InvalidAddressError, InvalidAddressVerificationObjectPropertyError, MPCSweepTxs, + RecoveryProviderError, TransactionType, TxIntentMismatchRecipientError, UnexpectedAddressError, Wallet, } from '@bitgo/sdk-core'; +import BN from 'bn.js'; import { BitGoAPI } from '@bitgo/sdk-api'; import { AbstractEthLikeNewCoins, @@ -2516,6 +2519,34 @@ describe('ETH:', function () { assert(spy.returned(true)); }); + it('should throw ErrorNoInputToRecover when the multisig wallet has no funds', async function () { + const params = mockData.getNonBitGoRecoveryForHotWalletsMPCv2(); + const basecoin = bitgo.coin('hteth') as Hteth; + + const nonceStub = sinon.stub(basecoin, 'getAddressNonce').resolves(0); + const balanceStub = sinon.stub(basecoin, 'queryAddressBalance'); + balanceStub.onFirstCall().resolves(new BN('99999999999999999999')); + balanceStub.onSecondCall().resolves(new BN('0')); + + try { + await basecoin + .recover({ + userKey: params.userKey, + backupKey: params.backupKey, + walletPassphrase: params.walletPassphrase, + walletContractAddress: params.walletContractAddress, + recoveryDestination: params.recoveryDestination, + eip1559: { maxFeePerGas: 20000000000, maxPriorityFeePerGas: 10000000000 }, + gasLimit: 500000, + intendedChain: params.intendedChain, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + } finally { + nonceStub.restore(); + balanceStub.restore(); + } + }); + describe('Non-BitGo Recovery for Hot Wallets (MPCv2)', function () { const baseUrl = common.Environments.test.etherscanBaseUrl as string; let bitgo: TestBitGoAPI; @@ -2902,6 +2933,50 @@ describe('ETH:', function () { common.Environments.test.etherscanApiToken = originalApiToken; } }); + + it('should throw RecoveryProviderError when the explorer request fails', async function () { + const coin = bitgo.coin('teth') as Teth; + const query = { + module: 'account', + action: 'balance', + address: '0x1234567890123456789012345678901234567890', + }; + const originalGet = request.get; + request.get = function () { + return { + query: function () { + throw new Error('connection refused'); + }, + } as any; + }; + try { + await coin.recoveryBlockchainExplorerQuery(query).should.be.rejectedWith(RecoveryProviderError); + } finally { + request.get = originalGet; + } + }); + + it('should throw RecoveryProviderError when the explorer rate-limits the request', async function () { + const coin = bitgo.coin('teth') as Teth; + const query = { + module: 'account', + action: 'balance', + address: '0x1234567890123456789012345678901234567890', + }; + const originalGet = request.get; + request.get = function () { + return { + query: function () { + return { ok: true, body: { status: '0', message: 'NOTOK' } }; + }, + } as any; + }; + try { + await coin.recoveryBlockchainExplorerQuery(query).should.be.rejectedWith(RecoveryProviderError); + } finally { + request.get = originalGet; + } + }); }); describe('Audit Key', () => { diff --git a/modules/sdk-coin-iota/src/iota.ts b/modules/sdk-coin-iota/src/iota.ts index d3c0d8e6aa7..57de368d2b3 100644 --- a/modules/sdk-coin-iota/src/iota.ts +++ b/modules/sdk-coin-iota/src/iota.ts @@ -6,6 +6,7 @@ import { EDDSAMethodTypes, EddsaSigningMaterial, Environments, + ErrorNoInputToRecover, getEddsaSigningMaterial as sharedGetEddsaSigningMaterial, isEddsaSigningMaterial, KeyPair, @@ -23,6 +24,7 @@ import { ParsedTransaction, PopulatedIntent, PrebuildTransactionWithIntentOptions, + RecoveryProviderError, RecoveryTxRequest, signEddsaMpcV2RecoveryTx, SignedTransaction, @@ -333,7 +335,10 @@ export class Iota extends BaseCoin { params.tokenContractAddress ); } catch (e) { - continue; + if (e instanceof RecoveryProviderError) { + throw e; + } + throw new RecoveryProviderError(`Failed to fetch token objects for ${senderAddress}`, e); } if (tokenObjects.length === 0) { @@ -352,6 +357,9 @@ export class Iota extends BaseCoin { precomputedMaterial ); } catch (e) { + if (e instanceof RecoveryProviderError) { + throw e; + } continue; } } @@ -360,7 +368,10 @@ export class Iota extends BaseCoin { try { ownedObjects = await this.fetchOwnedObjects(senderAddress, params.fullnodeRpcUrl); } catch (e) { - continue; + if (e instanceof RecoveryProviderError) { + throw e; + } + throw new RecoveryProviderError(`Failed to fetch owned objects for ${senderAddress}`, e); } if (ownedObjects.length === 0) { @@ -432,7 +443,7 @@ export class Iota extends BaseCoin { }; } - throw new Error( + throw new ErrorNoInputToRecover( `Did not find an address with sufficient funds to recover. ` + `Scanned addresses from index ${startIdx} to ${endIdx - 1}. ` + `Please start the next scan at address index ${endIdx}.` @@ -447,7 +458,10 @@ export class Iota extends BaseCoin { const balance = await this.getBalance(senderAddress, params.fullnodeRpcUrl, params.tokenContractAddress); return balance > 0n; } catch (e) { - return false; + if (e instanceof RecoveryProviderError) { + throw e; + } + throw new RecoveryProviderError(`Failed to query token balance for ${senderAddress}`, e); } } @@ -514,7 +528,7 @@ export class Iota extends BaseCoin { try { recoveryTransaction = await this.recover(recoverParams, signingMaterial); } catch (e) { - if ((e as Error).message.startsWith('Did not find an address with sufficient funds to recover.')) { + if (e instanceof ErrorNoInputToRecover) { lastScanIndex = idx; continue; } @@ -530,7 +544,7 @@ export class Iota 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 }.` @@ -625,25 +639,30 @@ export class Iota extends BaseCoin { * @throws Error if RPC call fails */ private async makeRpcCall(url: string, method: string, params: any[]): Promise { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method, - params, - }), - }); + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + }); + } catch (e) { + throw new RecoveryProviderError(`RPC call failed for ${method}: ${(e as Error).message}`, e); + } if (!response.ok) { - throw new Error(`RPC call failed with status ${response.status}`); + throw new RecoveryProviderError(`RPC call failed with status ${response.status}`); } const json = await response.json(); if (json.error) { - throw new Error(`RPC error: ${json.error.message || JSON.stringify(json.error)}`); + throw new RecoveryProviderError(`RPC error: ${json.error.message || JSON.stringify(json.error)}`); } return json.result; @@ -741,17 +760,20 @@ export class Iota extends BaseCoin { })); const tokenBalance = tokenObjectsWithBalance.reduce((sum, obj) => sum + BigInt(obj.balance), 0n); if (tokenBalance <= 0n) { - throw new Error('Token balance is zero'); + throw new ErrorNoInputToRecover('Token balance is zero'); } let gasObjectsWithBalance: IotaObjectWithBalance[]; try { gasObjectsWithBalance = await this.fetchOwnedObjects(senderAddress, params.fullnodeRpcUrl); } catch (e) { - throw new Error('Failed to fetch gas objects for token recovery'); + if (e instanceof RecoveryProviderError) { + throw e; + } + throw new RecoveryProviderError('Failed to fetch gas objects for token recovery', e); } if (gasObjectsWithBalance.length === 0) { - throw new Error('No gas objects found for token recovery'); + throw new ErrorNoInputToRecover('No gas objects found for token recovery'); } gasObjectsWithBalance = gasObjectsWithBalance.sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); @@ -767,7 +789,7 @@ export class Iota extends BaseCoin { ); const netGasBalance = totalBalance - BigInt(gasBudget); if (netGasBalance <= 0n) { - throw new Error('Insufficient gas balance for token recovery'); + throw new ErrorNoInputToRecover('Insufficient gas balance for token recovery'); } const recoveryAmount = tokenBalance.toString(); diff --git a/modules/sdk-coin-iota/test/unit/iota.ts b/modules/sdk-coin-iota/test/unit/iota.ts index d4b83f31d72..b98b92e0faf 100644 --- a/modules/sdk-coin-iota/test/unit/iota.ts +++ b/modules/sdk-coin-iota/test/unit/iota.ts @@ -7,8 +7,10 @@ import { coins, GasTankAccountCoin } from '@bitgo/statics'; import * as testData from '../resources/iota'; import { EDDSAMethods, + ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, + RecoveryProviderError, RecoveryTxRequest, signRecoveryEddsaMPCv2, TransactionType, @@ -1633,6 +1635,54 @@ describe('IOTA:', function () { sandBox.assert.callCount(basecoin.fetchOwnedObjects, 10); }); + + it('should throw ErrorNoInputToRecover when no address has funds', async function () { + sandBox.stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota).resolves([]); + + await basecoin + .recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + recoveryDestination, + walletPassphrase, + scan: 1, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the fullnode RPC fails', async function () { + sandBox + .stub(Iota.prototype, 'fetchOwnedObjects' as keyof Iota) + .rejects(new Error('RPC call failed with status 503')); + + await basecoin + .recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + recoveryDestination, + walletPassphrase, + scan: 1, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); + + it('should throw RecoveryProviderError when the token balance query fails', async function () { + sandBox.stub(Iota.prototype, 'getBalance' as keyof Iota).rejects(new Error('RPC error: connection refused')); + + await basecoin + .recover({ + userKey: keys.userKey, + backupKey: keys.backupKey, + bitgoKey: keys.bitgoKey, + recoveryDestination, + walletPassphrase, + tokenContractAddress: '0x2::usdc::USDC', + scan: 1, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); }); describe('Consolidation Transaction Failures:', () => { diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 5bcf279e6de..2e7c4e61492 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -24,6 +24,7 @@ import { EDDSAMethods, EDDSAMethodTypes, Environments, + ErrorNoInputToRecover, ITokenEnablement, KeyPair, MAX_SOL_MESSAGE_BYTES, @@ -47,6 +48,7 @@ import { PrebuildTransactionWithIntentOptions, PresignTransactionOptions, PublicKey, + RecoveryProviderError, RecoveryTxRequest, SignedTransaction, SignTransactionOptions, @@ -1060,12 +1062,19 @@ export class Sol extends BaseCoin { apiKey?: string ): Promise { const nodeUrl = this.getPublicNodeUrl(apiKey); + let response: request.Response; try { - return await request.post(nodeUrl).send(params.payload); + response = await request.post(nodeUrl).send(params.payload); } catch (e) { console.debug(e); + throw new RecoveryProviderError(`Unable to call endpoint: '/' from node: ${nodeUrl}`); } - throw new Error(`Unable to call endpoint: '/' from node: ${nodeUrl}`); + // Solana returns HTTP 200 with a JSON-RPC `error` field (no `result`) for RPC-level + // failures such as rate limiting, invalid params, or transient node errors. + if (response.body && response.body.error) { + throw new RecoveryProviderError(`RPC error: ${JSON.stringify(response.body.error)}`); + } + return response; } protected async getBlockhash(apiKey?: string): Promise { @@ -1085,7 +1094,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return response.body.result.value.blockhash; @@ -1109,7 +1118,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return response.body.result.value; @@ -1128,7 +1137,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200 || response.error) { - throw new Error(JSON.stringify(response.error)); + throw new RecoveryProviderError(JSON.stringify(response.error)); } return response.body.result; @@ -1147,7 +1156,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return response.body.result.value; } @@ -1170,7 +1179,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return { authority: response.body.result.value.data.parsed.info.authority, @@ -1202,7 +1211,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } if (response.body.result.value.length !== 0) { @@ -1234,7 +1243,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } return { pubKey: pubKey, @@ -1268,7 +1277,7 @@ export class Sol extends BaseCoin { apiKey ); if (response.status !== 200) { - throw new Error('Account not found'); + throw new RecoveryProviderError('Account not found'); } const value = response.body?.result?.value; if (!value) { @@ -1560,11 +1569,11 @@ export class Sol extends BaseCoin { } } } else { - throw Error('Not enough token funds to recover'); + throw new ErrorNoInputToRecover('Not enough token funds to recover'); } } else { // there are no recoverable token accounts , need to check if there are tokens to recover - throw Error('Did not find token account to recover tokens, please check token account'); + throw new ErrorNoInputToRecover('Did not find token account to recover tokens, please check token account'); } } else { txBuilder = factory @@ -1595,13 +1604,13 @@ export class Sol extends BaseCoin { totalFee = totalFee.plus(new BigNumber(baseFee)); totalFeeForTokenRecovery = totalFeeForTokenRecovery.plus(new BigNumber(baseFee)); if (totalFee.gt(balance)) { - throw Error('Did not find address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find address with funds to recover'); } if (params.tokenContractAddress) { // Check if there is sufficient native solana to recover tokens if (new BigNumber(balance).lt(totalFeeForTokenRecovery)) { - throw Error( + throw new ErrorNoInputToRecover( 'Not enough funds to pay for recover tokens fees, have: ' + balance + ' need: ' + @@ -1750,7 +1759,7 @@ export class Sol extends BaseCoin { balance = await this.getAccountBalance(params.closeAtaAddress); if (balance <= 0) { - throw Error('Did not find closeAtaAddress with sol funds to recover'); + throw new ErrorNoInputToRecover('Did not find closeAtaAddress with sol funds to recover'); } const factory = this.getBuilder(); @@ -1790,7 +1799,7 @@ export class Sol extends BaseCoin { const baseFee = params.durableNonce ? feePerSignature * 2 : feePerSignature; const totalFee = new BigNumber(baseFee); if (totalFee.gt(accountBalance)) { - throw Error('Did not find address with funds to recover'); + throw new ErrorNoInputToRecover('Did not find address with funds to recover'); } txBuilder.fee({ amount: feePerSignature }); @@ -2051,11 +2060,7 @@ export class Sol extends BaseCoin { try { recoveryTransaction = await this.recover(recoverParams); } catch (e) { - if ( - e.message === 'Did not find address with funds to recover' || - e.message === 'Did not find token account to recover tokens, please check token account' || - e.message === 'Not enough token funds to recover' - ) { + if (e instanceof ErrorNoInputToRecover) { lastScanIndex = i; continue; } @@ -2077,7 +2082,7 @@ export class Sol 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-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index d720a8afceb..4e85ebc4a2e 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -17,6 +17,7 @@ import { common, EDDSAMethods, Environments, + ErrorNoInputToRecover, generateRandomPassword, IWallet, MAX_SOL_MESSAGE_BYTES, @@ -25,6 +26,7 @@ import { MPCSweepTxs, MPCTx, MPCTxs, + RecoveryProviderError, signRecoveryEddsaMPCv2, PrebuildAndSignTransactionOptions, TransactionPrebuild, @@ -3128,6 +3130,32 @@ describe('SOL:', function () { .should.rejectedWith('Did not find address with funds to recover'); }); + it('should throw ErrorNoInputToRecover when no address has sufficient funds', async function () { + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + bitgoKey: testData.keys.bitgoKeyNoFunds, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase: testData.keys.walletPassword, + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when the node returns a non-200 response', async function () { + callBack.resetBehavior(); + callBack.resolves({ status: 500, body: {} }); + await basecoin + .recover({ + userKey: testData.keys.userKey, + backupKey: testData.keys.backupKey, + bitgoKey: testData.keys.bitgoKey, + recoveryDestination: testData.keys.destinationPubKey, + walletPassphrase: testData.keys.walletPassword, + }) + .should.be.rejectedWith(RecoveryProviderError); + }); + it('should recover sol tokens to recovery destination with no existing token accounts', async function () { const getTSSSignatureSpy = sandBox.spy(EDDSAMethods, 'getTSSSignature'); diff --git a/modules/sdk-coin-xlm/src/xlm.ts b/modules/sdk-coin-xlm/src/xlm.ts index e2db57090c8..79841ed18d7 100644 --- a/modules/sdk-coin-xlm/src/xlm.ts +++ b/modules/sdk-coin-xlm/src/xlm.ts @@ -22,6 +22,7 @@ import { BitGoBase, checkKrsProvider, common, + ErrorNoInputToRecover, ExtraPrebuildParamsOptions, InvalidAddressError, InvalidMemoIdError, @@ -34,6 +35,7 @@ import { ParsedTransaction, ParseTransactionOptions, promiseProps, + RecoveryProviderError, StellarFederationUserNotFoundError, TokenEnablementConfig, UnexpectedAddressError, @@ -757,7 +759,10 @@ export class Xlm extends BaseCoin { try { accountData = await toBitgoRequest(request.get(accountDataUrl)).result(); } catch (e) { - throw new Error('Unable to reach the Stellar network via Horizon.'); + if (e.status === 404) { + throw new ErrorNoInputToRecover('Account does not exist on the Stellar network.'); + } + throw new RecoveryProviderError('Unable to reach the Stellar network via Horizon.', e); } // Now check if the destination account is empty or not @@ -768,11 +773,13 @@ export class Xlm extends BaseCoin { if (e.status === 404) { // If the destination account does not yet exist, horizon responds with 404 unfundedDestination = true; + } else { + throw new RecoveryProviderError('Unable to reach the Stellar network via Horizon.', e); } } if (!accountData.sequence || !accountData.balances) { - throw new Error('Horizon server error - unable to retrieve sequence ID or account balance'); + throw new RecoveryProviderError('Horizon server error - unable to retrieve sequence ID or account balance'); } const account = new stellar.Account(params.rootAddress, accountData.sequence); @@ -781,7 +788,7 @@ export class Xlm extends BaseCoin { const nativeBalanceInfo = accountData.balances.find((assetBalance) => assetBalance['asset_type'] === 'native'); if (!nativeBalanceInfo) { - throw new Error('Provided wallet has a balance of 0 XLM, recovery aborted'); + throw new ErrorNoInputToRecover('Provided wallet has a balance of 0 XLM, recovery aborted'); } const walletBalance = Number(this.bigUnitsToBaseUnits(nativeBalanceInfo.balance)); diff --git a/modules/sdk-coin-xlm/test/unit/xlm.ts b/modules/sdk-coin-xlm/test/unit/xlm.ts index f436b3dcd5d..660f59bea7b 100644 --- a/modules/sdk-coin-xlm/test/unit/xlm.ts +++ b/modules/sdk-coin-xlm/test/unit/xlm.ts @@ -3,7 +3,13 @@ import * as should from 'should'; import * as stellar from 'stellar-sdk'; import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; -import { Environments, PrebuildAndSignTransactionOptions, Wallet } from '@bitgo/sdk-core'; +import { + Environments, + ErrorNoInputToRecover, + PrebuildAndSignTransactionOptions, + RecoveryProviderError, + Wallet, +} from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { Txlm } from '../../src'; import { KeyPair } from '../../src/lib/keyPair'; @@ -505,6 +511,40 @@ describe('XLM:', function () { recovery.feeInfo.fee.should.equal(100); }); + it('should throw ErrorNoInputToRecover when the account does not exist', async function () { + nock('https://horizon-testnet.stellar.org/accounts') + .get('/' + wallet.receiveAddress()) + .reply(404, { + status: 404, + title: 'Resource Missing', + detail: 'The resource at the url requested was not found.', + }); + + await basecoin + .recover({ + userKey: 'GA34NPQ4M54HHZBKSDZ5B3J3BZHTXKCZD4UFO2OYZERPOASK4DAATSIB', + backupKey: 'GC3D3ZNNK7GHLMSWJA54DQO6QJUJJF7K6J5JGCEW45ZT6QMKZ6PMUHUM', + recoveryDestination: 'GDDHCKMYYYCVXOSAVMSEIYGYNX74LIAV3ACXYQ6WPMDUF7W3KZNWTHTH', + rootAddress: wallet.receiveAddress(), + }) + .should.be.rejectedWith(ErrorNoInputToRecover); + }); + + it('should throw RecoveryProviderError when Horizon is unreachable', async function () { + nock('https://horizon-testnet.stellar.org/accounts') + .get('/' + wallet.receiveAddress()) + .replyWithError('network error'); + + await basecoin + .recover({ + userKey: 'GA34NPQ4M54HHZBKSDZ5B3J3BZHTXKCZD4UFO2OYZERPOASK4DAATSIB', + backupKey: 'GC3D3ZNNK7GHLMSWJA54DQO6QJUJJF7K6J5JGCEW45ZT6QMKZ6PMUHUM', + recoveryDestination: 'GDDHCKMYYYCVXOSAVMSEIYGYNX74LIAV3ACXYQ6WPMDUF7W3KZNWTHTH', + rootAddress: wallet.receiveAddress(), + }) + .should.be.rejectedWith(RecoveryProviderError); + }); + it('should fail to verify a transaction signed with the wrong key', async function () { // sign transaction const tx = await wallet.signTransaction({ diff --git a/modules/sdk-core/src/bitgo/errors.ts b/modules/sdk-core/src/bitgo/errors.ts index 441cc2fadec..34393a5587b 100644 --- a/modules/sdk-core/src/bitgo/errors.ts +++ b/modules/sdk-core/src/bitgo/errors.ts @@ -152,11 +152,23 @@ export class StellarFederationUserNotFoundError extends BitGoJsError { } export class ErrorNoInputToRecover extends BitGoJsError { + public code = 'no_input_to_recover'; + public constructor(message?: string) { super(message || 'No input to recover - aborting!'); } } +export class RecoveryProviderError extends BitGoJsError { + public code = 'recovery_provider_failed'; + public cause?: unknown; + + public constructor(message?: string, cause?: unknown) { + super(message || 'Unable to reach the chain provider while attempting recovery.'); + this.cause = cause; + } +} + export class InvalidKeyPathError extends BitGoJsError { public constructor(keyPath: string) { super(`invalid keypath: ${keyPath}`);