Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions modules/abstract-cosmos/src/cosmosCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BitGoBase,
Ecdsa,
ECDSAUtils,
ErrorNoInputToRecover,
ExplanationResult,
InvalidAddressError,
InvalidMemoIdError,
Expand All @@ -14,6 +15,7 @@ import {
multisigTypes,
ParsedTransaction,
ParseTransactionOptions,
RecoveryProviderError,
SignedTransaction,
SigningError,
SignTransactionOptions,
Expand Down Expand Up @@ -251,7 +253,7 @@ export class CosmosCoin<CustomMessage = never> 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();
Expand Down Expand Up @@ -280,6 +282,10 @@ export class CosmosCoin<CustomMessage = never> 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');
Expand Down Expand Up @@ -667,9 +673,14 @@ export class CosmosCoin<CustomMessage = never> 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}`);
}

/**
Expand All @@ -682,9 +693,11 @@ export class CosmosCoin<CustomMessage = never> 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}`);
}

/**
Expand All @@ -697,9 +710,8 @@ export class CosmosCoin<CustomMessage = never> 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}`);
}

/**
Expand All @@ -708,7 +720,10 @@ export class CosmosCoin<CustomMessage = never> extends BaseCoin {
protected async getAccountBalance(senderAddress: string): Promise<Coin[]> {
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;
}
Expand All @@ -719,7 +734,7 @@ export class CosmosCoin<CustomMessage = never> extends BaseCoin {
protected async getChainId(): Promise<string> {
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;
}
Expand All @@ -729,8 +744,11 @@ export class CosmosCoin<CustomMessage = never> extends BaseCoin {
*/
protected async getAccountDetails(senderAddress: string): Promise<string[]> {
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];
}
Expand Down
32 changes: 23 additions & 9 deletions modules/abstract-substrate/src/abstractSubstrateCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BitGoBase,
EDDSAMethods,
EDDSAMethodTypes,
ErrorNoInputToRecover,
KeyPair,
MPCAlgorithm,
MPCConsolidationRecoveryOptions,
Expand All @@ -17,6 +18,7 @@ import {
multisigTypes,
ParsedTransaction,
ParseTransactionOptions,
RecoveryProviderError,
RecoveryTxRequest,
SignedTransaction,
TssVerifyAddressOptions,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions modules/abstract-substrate/test/unit/abstractSubstrateCoin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
});
18 changes: 8 additions & 10 deletions modules/sdk-coin-ada/src/ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import {
EDDSAMethodTypes,
AddressFormat,
Environments,
ErrorNoInputToRecover,
ITransactionRecipient,
MPCTx,
MPCRecoveryOptions,
MPCConsolidationRecoveryOptions,
MPCSweepTxs,
RecoveryTxRequest,
RecoveryProviderError,
MPCUnsignedTx,
MPCSweepRecoveryOptions,
MPCTxs,
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -309,7 +310,7 @@ export class Ada extends BaseCoin {
protected async getChainTipInfo(): Promise<Record<string, string>> {
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down
22 changes: 19 additions & 3 deletions modules/sdk-coin-ada/test/unit/ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import assert from 'assert';
import {
common,
EDDSAMethods,
ErrorNoInputToRecover,
MPCSweepRecoveryOptions,
MPCSweepTxs,
RecoveryProviderError,
signRecoveryEddsaMPCv2,
Wallet,
} from '@bitgo/sdk-core';
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -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);
});

Expand All @@ -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):', () => {
Expand Down
Loading
Loading