Skip to content
Open
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
60 changes: 60 additions & 0 deletions modules/sdk-core/src/bitgo/defi/defiVault.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @prettier
*/
import BigNumber from 'bignumber.js';
import * as t from 'io-ts';
import { GetVaultResponse, VaultProtocol } from '@bitgo/public-types';
import {
Expand Down Expand Up @@ -35,6 +36,24 @@ export class ActiveOperationExistsError extends Error {
}
}

/**
* Error thrown when the wallet does not hold sufficient balance of the underlying asset
* to cover the requested deposit amount.
*/
export class InsufficientBalanceError extends Error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - consider moving this to an error.ts file

public readonly assetToken: string;
public readonly available: string;
public readonly requested: string;

constructor(assetToken: string, available: string, requested: string) {
super(`Insufficient ${assetToken} balance: requested ${requested}, available ${available}`);
this.name = 'InsufficientBalanceError';
this.assetToken = assetToken;
this.available = available;
this.requested = requested;
}
}

/**
* Orchestrates ERC-4626 vault deposit and withdraw flows for a wallet.
*
Expand Down Expand Up @@ -90,6 +109,8 @@ export class DefiVault implements IDefiVault {

const config = await this.getVaultConfig({ vaultId: params.vaultId });

await this.assertSufficientAssetBalance(config, params.amount);

if (config.protocol === VaultProtocol.CONCRETE_BTCCX) {
return this.depositToConcreteVault(params);
} else if (config.protocol === VaultProtocol.MORPHO) {
Expand Down Expand Up @@ -293,6 +314,45 @@ export class DefiVault implements IDefiVault {

// ── Internal helpers ────────────────────────────────────────────────

/**
* Fetch a fresh wallet snapshot and assert the spendable balance of
* `config.assetToken` is at least `amount` (base units).
*
* Fetches with `?allTokens=true` so ERC-20 token balances are included.
* When `config.assetToken` equals the wallet's native coin (e.g. BTC on a
* BTC wallet), the top-level `spendableBalanceString` is used directly.
* Otherwise the `tokens` array is searched for an entry whose `tokenName`
* matches `config.assetToken`.
*
* Throws {@link InsufficientBalanceError} on insufficient balance and
* propagates network errors as-is.
*/
private async assertSufficientAssetBalance(config: GetVaultResponse, amount: string): Promise<void> {
const walletData: Record<string, any> = await this.bitgo.get(this.wallet.url()).query({ allTokens: true }).result();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no better typing for the value we can infer here, as opposed to any?


const assetToken = config.assetToken;
const walletCoin = config.coin;

let spendableBalance: string;

if (assetToken === walletCoin) {
// Native coin (e.g. BTC on a BTC wallet)
spendableBalance = (walletData.spendableBalanceString as string | undefined) ?? '0';
} else {
// ERC-20 or other token — look in the tokens array
const tokens: Record<string, any>[] = Array.isArray(walletData.tokens) ? walletData.tokens : [];
const tokenEntry = tokens.find((t) => t.tokenName === assetToken);
spendableBalance = (tokenEntry?.spendableBalanceString as string | undefined) ?? '0';
}

const available = new BigNumber(spendableBalance);
const requested = new BigNumber(amount);

if (requested.isGreaterThan(available)) {
throw new InsufficientBalanceError(assetToken, spendableBalance, amount);
}
}

/**
* Extract txRequestId from a sendMany result.
* sendMany returns different shapes depending on wallet type:
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-core/src/bitgo/defi/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from './iDefiVault';
export { DefiVault, ActiveOperationExistsError } from './defiVault';
export { DefiVault, ActiveOperationExistsError, InsufficientBalanceError } from './defiVault';
160 changes: 147 additions & 13 deletions modules/sdk-core/test/unit/bitgo/defi/defiVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import sinon from 'sinon';
import assert from 'assert';
import 'should';
import { VaultProtocol } from '@bitgo/public-types';
import { ActiveOperationExistsError, DefiVault, Wallet } from '../../../../src';
import { ActiveOperationExistsError, DefiVault, InsufficientBalanceError, Wallet } from '../../../../src';

describe('DefiVault', function () {
let wallet: Wallet;
Expand Down Expand Up @@ -49,6 +49,26 @@ describe('DefiVault', function () {
};
}

// Helper to build the wallet data response for the balance-check GET
function makeWalletDataForBalance(opts: {
spendableBalanceString?: string;
tokens?: { tokenName: string; spendableBalanceString: string }[];
}) {
return {
id: 'test-wallet-id',
coin: 'eth',
spendableBalanceString: opts.spendableBalanceString ?? '0',
tokens: opts.tokens ?? [],
};
}

// Sets up mockBitGo.get so the first call (vault config) returns vaultData
// and the second call (wallet balance check) returns walletData.
function mockDepositGetCalls(vaultData: any, walletData: any) {
mockBitGo.get.onFirstCall().returns(mockRequest(vaultData));
mockBitGo.get.onSecondCall().returns(mockRequest(walletData));
}

beforeEach(function () {
mockBitGo = {
post: sinon.stub(),
Expand All @@ -61,7 +81,7 @@ describe('DefiVault', function () {

mockBaseCoin = {
getFamily: sinon.stub().returns('eth'),
url: sinon.stub(),
url: sinon.stub().callsFake((path: string) => `https://bitgo.com/api/v2${path}`),
keychains: sinon.stub(),
supportsTss: sinon.stub().returns(true),
getMPCAlgorithm: sinon.stub(),
Expand Down Expand Up @@ -132,7 +152,10 @@ describe('DefiVault', function () {
describe('depositToVault', function () {
describe('concrete_btccx provider', function () {
it('should call sendMany once with defi-deposit type and no recipients', async function () {
mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-1')));
mockDepositGetCalls(
makeConcreteVault('vlt-btc-1'),
makeWalletDataForBalance({ spendableBalanceString: '10000000' })
);

const sendManyStub = sinon.stub(wallet, 'sendMany').resolves({
pendingApproval: {
Expand Down Expand Up @@ -163,7 +186,10 @@ describe('DefiVault', function () {
});

it('should pass walletPassphrase when provided', async function () {
mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-2')));
mockDepositGetCalls(
makeConcreteVault('vlt-btc-2'),
makeWalletDataForBalance({ spendableBalanceString: '2000000' })
);

const sendManyStub = sinon.stub(wallet, 'sendMany').resolves({
pendingApproval: { id: 'pa-uuid-456', state: 'awaitingSignature' },
Expand All @@ -180,7 +206,10 @@ describe('DefiVault', function () {
});

it('should default state to awaitingSignature when absent from pendingApproval', async function () {
mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-3')));
mockDepositGetCalls(
makeConcreteVault('vlt-btc-3'),
makeWalletDataForBalance({ spendableBalanceString: '2000000' })
);

sinon.stub(wallet, 'sendMany').resolves({
pendingApproval: { id: 'pa-no-state' },
Expand All @@ -191,19 +220,59 @@ describe('DefiVault', function () {
});

it('should throw when sendMany returns no pendingApproval', async function () {
mockBitGo.get.returns(mockRequest(makeConcreteVault('vlt-btc-4')));
mockDepositGetCalls(
makeConcreteVault('vlt-btc-4'),
makeWalletDataForBalance({ spendableBalanceString: '2000000' })
);

sinon.stub(wallet, 'sendMany').resolves({ txRequest: { txRequestId: 'unexpected' } });

await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-btc-4', amount: '1000000' }), {
message: 'defi-deposit sendMany response: unknown; unknown',
});
});

it('should throw InsufficientBalanceError when native coin balance is too low', async function () {
mockDepositGetCalls(
makeConcreteVault('vlt-btc-5'),
makeWalletDataForBalance({ spendableBalanceString: '500000' })
);

await assert.rejects(
() => defiVault.depositToVault({ vaultId: 'vlt-btc-5', amount: '1000000' }),
(err: Error) => {
(err instanceof InsufficientBalanceError).should.be.true();
(err as InsufficientBalanceError).assetToken.should.equal('btc');
(err as InsufficientBalanceError).available.should.equal('500000');
(err as InsufficientBalanceError).requested.should.equal('1000000');
return true;
}
);
});

it('should not throw when native coin balance exactly equals the requested amount', async function () {
mockDepositGetCalls(
makeConcreteVault('vlt-btc-6'),
makeWalletDataForBalance({ spendableBalanceString: '1000000' })
);

sinon.stub(wallet, 'sendMany').resolves({
pendingApproval: { id: 'pa-exact', state: 'awaitingSignature' },
});

const result = await defiVault.depositToVault({ vaultId: 'vlt-btc-6', amount: '1000000' });
(result as any).pendingApprovalId.should.equal('pa-exact');
});
});

describe('morpho provider', function () {
const sufficientTokenBalance = makeWalletDataForBalance({
tokens: [{ tokenName: 'usdc', spendableBalanceString: '2000000' }],
});

it('should call sendMany for approve and deposit on happy path', async function () {
mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(mockRequest(sufficientTokenBalance));

const operationId = 'op-uuid-123';

Expand Down Expand Up @@ -248,7 +317,8 @@ describe('DefiVault', function () {
});

it('should extract operationId from the lite apiVersion coinSpecific', async function () {
mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(mockRequest(sufficientTokenBalance));

const operationId = 'op-uuid-lite';

Expand All @@ -272,7 +342,8 @@ describe('DefiVault', function () {
});

it('should fall back to intent.operationId for forward-compat', async function () {
mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(mockRequest(sufficientTokenBalance));

const operationId = 'op-uuid-intent';

Expand All @@ -293,7 +364,8 @@ describe('DefiVault', function () {
});

it('should throw when operationId is absent from the approve txRequest', async function () {
mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(mockRequest(sufficientTokenBalance));

const sendManyStub = sinon.stub(wallet, 'sendMany');
sendManyStub.onFirstCall().resolves({
Expand Down Expand Up @@ -330,7 +402,8 @@ describe('DefiVault', function () {
});

it('should propagate deposit sendMany failure without cleanup', async function () {
mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(mockRequest(sufficientTokenBalance));

const operationId = 'op-uuid-456';

Expand All @@ -353,6 +426,69 @@ describe('DefiVault', function () {
mockBitGo.del.called.should.be.false();
});

it('should throw InsufficientBalanceError when token balance is too low', async function () {
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(
mockRequest(
makeWalletDataForBalance({
tokens: [{ tokenName: 'usdc', spendableBalanceString: '500000' }],
})
)
);

await assert.rejects(
() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }),
(err: Error) => {
(err instanceof InsufficientBalanceError).should.be.true();
(err as InsufficientBalanceError).assetToken.should.equal('usdc');
(err as InsufficientBalanceError).available.should.equal('500000');
(err as InsufficientBalanceError).requested.should.equal('1000000');
return true;
}
);
});

it('should throw InsufficientBalanceError when token is absent from the wallet', async function () {
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(mockRequest(makeWalletDataForBalance({ tokens: [] })));

await assert.rejects(
() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }),
(err: Error) => {
(err instanceof InsufficientBalanceError).should.be.true();
(err as InsufficientBalanceError).assetToken.should.equal('usdc');
(err as InsufficientBalanceError).available.should.equal('0');
return true;
}
);
});

it('should not throw when token balance exactly equals the requested amount', async function () {
mockBitGo.get.onFirstCall().returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc')));
mockBitGo.get.onSecondCall().returns(
mockRequest(
makeWalletDataForBalance({
tokens: [{ tokenName: 'usdc', spendableBalanceString: '1000000' }],
})
)
);

const operationId = 'op-exact';
const sendManyStub = sinon.stub(wallet, 'sendMany');
sendManyStub.onFirstCall().resolves({
txRequest: {
txRequestId: 'txreq-approve-exact',
transactions: [{ unsignedTx: { coinSpecific: { operationId } } }],
},
});
sendManyStub.onSecondCall().resolves({
txRequest: { txRequestId: 'txreq-deposit-exact' },
});

const result = await defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' });
(result as any).operationId.should.equal(operationId);
});

it('should throw if vaultId is missing', async function () {
await assert.rejects(() => defiVault.depositToVault({ vaultId: '', amount: '1000000' }), {
message: 'vaultId is required',
Expand All @@ -373,8 +509,6 @@ describe('DefiVault', function () {
});

it('should throw if amount is missing', async function () {
mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-1')));

await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-1', amount: '' }), {
message: 'amount is required',
});
Expand Down
Loading