Skip to content
Draft
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
43 changes: 41 additions & 2 deletions packages/bitcore-wallet-service/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { TransakService } from '../externalservices/transak';
import { WyreService } from '../externalservices/wyre';
import { serverMessages } from '../serverMessages';
import type { ExternalServicesConfig } from '../types/externalservices';
import type { GetAddressesOpts, UpgradeCheckOpts } from '../types/server';
import type { GetAddressesOpts, GetBalanceObj, UpgradeCheckOpts } from '../types/server';
import { BCHAddressTranslator } from './bchaddresstranslator';
import { BlockChainExplorer } from './blockchainexplorer';
import { V8 } from './blockchainexplorers/v8';
Expand Down Expand Up @@ -2053,6 +2053,15 @@ export class WalletService implements IWalletService {
});
}

getBalanceAsync(opts): Promise<GetBalanceObj> {
return new Promise((resolve, reject) => {
this.getBalance(opts, (err, bal) => {
if (err) return reject(err);
return resolve(bal);
});
});
}

/**
* Return info needed to send all funds in the wallet
* @param {Object} opts
Expand Down Expand Up @@ -3292,11 +3301,41 @@ export class WalletService implements IWalletService {
{
txProposalId: opts.txProposalId
},
(err, txp) => {
async (err, txp) => {
if (err) return cb(err);

if (txp.status == 'broadcasted') return cb(Errors.TX_ALREADY_BROADCASTED);
if (txp.status != 'accepted') return cb(Errors.TX_NOT_ACCEPTED);
if (!ChainService.isUTXOChain(wallet.chain)) {
let walletAmount = 0;
let txpAmount = txp.getTotalAmount();

try {
// Final safegaurd to ensure there is enough funds to be sent.
const balance = await this.getBalanceAsync({
wallet,
tokenAddress: txp.tokenAddress,
...opts
});
// Use totalConfirmedAmount (not availableConfirmedAmount) to prevent double-counting the current txp
walletAmount = balance.totalConfirmedAmount;
// Add fee for native currencies
txpAmount += txp.tokenAddress ? 0 : (txp.fee || 0);
} catch (err) {
return cb(err);
}

if (typeof walletAmount !== 'number' || typeof txpAmount !== 'number') {
return cb(new Error('Invalid balance or amount values'));
}
if (!Number.isInteger(walletAmount) || !Number.isInteger(txpAmount)) {
return cb(new Error(`Non-integer amounts detected: wallet=${walletAmount}, txp=${txpAmount}`));
}
if (BigInt(walletAmount) < BigInt(txpAmount)) {
logger.warn(`Insufficient funds: wallet=${walletAmount}, required=${txpAmount}`);
return cb(Errors.INSUFFICIENT_FUNDS);
}
}

const sub = TxConfirmationSub.create({
copayerId: txp.creatorId,
Expand Down
9 changes: 9 additions & 0 deletions packages/bitcore-wallet-service/src/types/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,13 @@ export interface UpgradeCheckOpts {
version?: number | string;
signingMethod?: string;
supportBchSchnorr?: boolean;
}

export interface GetBalanceObj {
totalAmount: number;
lockedAmount: number;
totalConfirmedAmount: number;
lockedConfirmedAmount: number;
availableAmount: number;
availableConfirmedAmount: number;
}
104 changes: 104 additions & 0 deletions packages/bitcore-wallet-service/test/integration/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8094,6 +8094,110 @@ describe('Wallet service', function() {
});
});
});

it('should fail to broadcast a tx when balance is insufficient', async function() {
const blockchainExplorer = helpers.getBlockchainExplorer();
// Stub balance to be less than the transaction amount + fee
// Transaction is 9e8 (900000000) + fee, so setting balance to 8e8 (800000000) should fail
blockchainExplorer.getBalance = sinon.stub().callsArgWith(1, null, {
unconfirmed: 0,
confirmed: 8e8,
balance: 8e8
});

helpers.stubBroadcast(txid);
await util.promisify(server.broadcastTx).call(server, {
txProposalId: txpid
}).then(() => {
throw new Error('Should have failed');
}).catch((err) => {
should.exist(err);
err.message.should.equal('Insufficient funds');
});
});

it('should fail to broadcast a tx when getBalance returns non-integer amounts', async function() {
const blockchainExplorer = helpers.getBlockchainExplorer();
// Stub balance to return decimal value (which should not happen in practice)
blockchainExplorer.getBalance = sinon.stub().callsArgWith(1, null, {
unconfirmed: 0,
confirmed: 10.5e18,
balance: 10.5e18
});

helpers.stubBroadcast(txid);
await util.promisify(server.broadcastTx).call(server, {
txProposalId: txpid
}).then(() => {
throw new Error('Should have failed');
}).catch((err) => {
should.exist(err);
err.message.should.contain('Non-integer amounts detected');
});
});

it('should fail to broadcast a tx when getBalance errors', async function() {
const blockchainExplorer = helpers.getBlockchainExplorer();
// Stub getBalance to return an error
blockchainExplorer.getBalance = sinon.stub().callsArgWith(1, new Error('Network error'));

helpers.stubBroadcast(txid);
await util.promisify(server.broadcastTx).call(server, {
txProposalId: txpid
}).then(() => {
throw new Error('Should have failed');
}).catch((err) => {
should.exist(err);
err.message.should.equal('Network error');
});
});

it('should broadcast a token tx and not include fee in balance check', async function() {
// Create a new wallet and tx for token transaction
const { server: tokenServer, wallet: tokenWallet } = await helpers.createAndJoinWallet(1, 1, { coin: 'eth' });
const from = await util.promisify(tokenServer.createAddress).call(tokenServer, {});
const blockchainExplorer = helpers.getBlockchainExplorer();

// Setup token balance
const tokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; // USDC
await helpers.stubUtxos(tokenServer, tokenWallet, [10], { tokenAddress });

const txOpts = {
outputs: [{
toAddress: '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A',
amount: 1e6, // 1 USDC
}],
from,
tokenAddress,
feePerKb: 100e2,
};

let txp = await helpers.createAndPublishTx(tokenServer, txOpts, TestData.copayers[0].privKey_1H_0);
should.exist(txp);
const signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H);
txp = await util.promisify(tokenServer.signTx).call(tokenServer, {
txProposalId: txp.id,
signatures: signatures,
});

// Stub token balance to exactly match the transaction amount (no fee added for tokens)
blockchainExplorer.getBalance = sinon.stub().callsFake(function(opts, cb) {
if (opts.tokenAddress) {
// Token balance exactly matches transaction amount
return cb(null, { unconfirmed: 0, confirmed: 1e6, balance: 1e6 });
}
// Native balance for gas
return cb(null, { unconfirmed: 0, confirmed: 10e18, balance: 10e18 });
});

helpers.stubBroadcast(txp.txid);
const result = await util.promisify(tokenServer.broadcastTx).call(tokenServer, {
txProposalId: txp.id
});

should.exist(result);
result.status.should.equal('broadcasted');
});
});

describe('Tx proposal workflow', function() {
Expand Down