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
68 changes: 68 additions & 0 deletions packages/bitcore-wallet-client/src/lib/verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,67 @@ const BCHAddress = BitcoreLibCash.Address;
export class Verifier {
private static _useRegtest: boolean = false;

private static normalizeAtomicValue(value) {
if (typeof value === 'bigint') return value >= 0n ? value : null;
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
}
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
return BigInt(value);
}
return null;
}

private static atomicValuesEqual(value1, value2) {
const normalizedValue1 = this.normalizeAtomicValue(value1);
const normalizedValue2 = this.normalizeAtomicValue(value2);
return normalizedValue1 !== null &&
normalizedValue2 !== null &&
normalizedValue1 === normalizedValue2;
}

private static optionalAtomicValuesEqual(value1, value2) {
const value1Missing = value1 === undefined || value1 === null;
const value2Missing = value2 === undefined || value2 === null;
if (value1Missing || value2Missing) return value1Missing && value2Missing;
return this.atomicValuesEqual(value1, value2);
}

private static mapInputsByOutpoint(inputs) {
if (!Array.isArray(inputs)) return null;

const inputsByOutpoint = new Map<string, bigint>();
let total = 0n;
for (const input of inputs) {
const vout = this.normalizeAtomicValue(input?.vout);
const satoshis = this.normalizeAtomicValue(input?.satoshis);
if (typeof input?.txid !== 'string' || !input.txid || vout === null || satoshis === null) {
return null;
}

const outpoint = `${input.txid}:${vout}`;
if (inputsByOutpoint.has(outpoint)) return null;

inputsByOutpoint.set(outpoint, satoshis);
total += satoshis;
}

return { inputsByOutpoint, total };
}

private static explicitInputsEqual(inputs1, inputs2) {
const mappedInputs1 = this.mapInputsByOutpoint(inputs1);
const mappedInputs2 = this.mapInputsByOutpoint(inputs2);
if (!mappedInputs1 || !mappedInputs2) return false;
if (mappedInputs1.inputsByOutpoint.size !== mappedInputs2.inputsByOutpoint.size) return false;
if (mappedInputs1.total !== mappedInputs2.total) return false;

for (const [outpoint, satoshis] of mappedInputs1.inputsByOutpoint) {
if (mappedInputs2.inputsByOutpoint.get(outpoint) !== satoshis) return false;
}
return true;
}

static useRegtest() {
this._useRegtest = true;
}
Expand Down Expand Up @@ -133,6 +194,7 @@ export class Verifier {
const o2 = args.outputs[i];
if (!strEqual(o1.toAddress, o2.toAddress)) return false;
if (!strEqual(o1.script, o2.script)) return false;
if (!this.optionalAtomicValuesEqual(o1.tag, o2.tag)) return false;
// Amounts need to be equal OR sendMax arg is set and amount arg is omitted, otherwise return check failure
if (o1.amount != o2.amount && !(args.sendMax && o2.amount == null)) return false;
let decryptedMessage: boolean | string = false;
Expand All @@ -150,6 +212,12 @@ export class Verifier {
return false;
if (typeof args.feePerKb === 'number' && txp.feePerKb != args.feePerKb)
return false;
if (args.fee != null && !this.atomicValuesEqual(args.fee, txp.fee))
return false;
if (args.inputs != null && !this.explicitInputsEqual(args.inputs, txp.inputs))
return false;
if (!this.optionalAtomicValuesEqual(args.destinationTag, txp.destinationTag))
return false;
if (!strEqual(txp.payProUrl, args.payProUrl)) return false;

let decryptedMessage: boolean | string = false;
Expand Down
4 changes: 2 additions & 2 deletions packages/bitcore-wallet-client/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const blockchainExplorerMock = {
should.exist(scriptPubKey);
blockchainExplorerMock.utxos.push({
txid: new Bitcore.crypto.Hash.sha256(Buffer.alloc(Math.random() * 100000)).toString('hex'),
outputIndex: 0,
vout: 0,
amount: amount,
satoshis: amount * 1e8,
address: address.address,
Expand Down Expand Up @@ -363,4 +363,4 @@ export const blockchainExplorerMock = {
blockchainExplorerMock.txHistory = [];
blockchainExplorerMock.feeLevels = [];
}
};
};
169 changes: 169 additions & 0 deletions packages/bitcore-wallet-client/test/verifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,183 @@
'use strict';

import chai from 'chai';
import { Verifier } from '../src/lib/verifier';
import { Key } from '../src/lib/key';

chai.should();

const aKey = new Key({
seedData: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
seedType: 'mnemonic'
});

describe('Verifier', function() {
describe('checkProposalCreation', function() {
const inputs = [
{
txid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
vout: 0,
satoshis: 6000
},
{
txid: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
vout: 1,
satoshis: 5000
}
];
const createProposal = overrides => ({
outputs: [{
toAddress: '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA',
amount: 10000
}],
...overrides
});
const checkProposalCreation = (argsOverrides = {}, txpOverrides = {}) => {
return Verifier.checkProposalCreation(
createProposal(argsOverrides),
createProposal(txpOverrides),
'shared-encrypting-key'
);
};

it('should accept a matching fixed fee and reordered explicit inputs', function() {
checkProposalCreation(
{ fee: 1000n, inputs },
{
fee: '1000',
inputs: [
{ ...inputs[1], vout: '1', satoshis: '5000' },
{ ...inputs[0], vout: '0', satoshis: '6000' }
]
}
).should.be.true;
});

it('should reject changed or invalid fixed fees', function() {
const feePairs = [
[1000, 1001],
[-1, -1],
[Number.MAX_SAFE_INTEGER + 1, Number.MAX_SAFE_INTEGER + 1],
['01', '01']
];
for (const [requestedFee, returnedFee] of feePairs) {
checkProposalCreation({ fee: requestedFee }, { fee: returnedFee }).should.be.false;
}
});

it('should reject changed or invalid explicit inputs', function() {
const returnedInputSets = [
[inputs[0]],
[
...inputs,
{
txid: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
vout: 2,
satoshis: 4000
}
],
[inputs[0], { ...inputs[1], vout: 2 }],
[{ ...inputs[0], satoshis: 6001 }, { ...inputs[1], satoshis: 4999 }],
[inputs[0], inputs[0]]
];
for (const returnedInputs of returnedInputSets) {
checkProposalCreation({ inputs }, { inputs: returnedInputs }).should.be.false;
}
});

it('should not verify fee or inputs unless explicitly requested', function() {
checkProposalCreation(
{},
{
fee: 9999,
inputs: [{
txid: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
vout: 3,
satoshis: 1
}]
}
).should.be.true;
});

it('should treat a null fee as omitted', function() {
checkProposalCreation(
{ fee: null },
{ fee: 1000 }
).should.be.true;
});

it('should treat null inputs as omitted', function() {
checkProposalCreation(
{ inputs: null },
{ inputs }
).should.be.true;
});

it('should accept matching XRP destination tags', function() {
checkProposalCreation(
{
destinationTag: '12345',
outputs: [{
toAddress: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
amount: 10000,
tag: 12345
}]
},
{
destinationTag: 12345,
outputs: [{
toAddress: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
amount: 10000,
tag: '12345'
}]
}
).should.be.true;
});

it('should reject changed, removed, or injected XRP destination tags', function() {
const tagPairs = [
[{ destinationTag: 12345 }, { destinationTag: 54321 }],
[{ destinationTag: 12345 }, {}],
[{}, { destinationTag: 12345 }],
[
{
outputs: [{
toAddress: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
amount: 10000,
tag: 12345
}]
},
{
outputs: [{
toAddress: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
amount: 10000,
tag: 54321
}]
}
],
[
{
outputs: [{
toAddress: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
amount: 10000
}]
},
{
outputs: [{
toAddress: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh',
amount: 10000,
tag: 12345
}]
}
]
];

for (const [requestedTag, returnedTag] of tagPairs) {
checkProposalCreation(requestedTag, returnedTag).should.be.false;
}
});
});

describe('checkAddress', function() {
it('should verify a BTC address', () => {
const cred = aKey.createCredentials(null, { coin: 'btc', network: 'livenet', account: 0, n: 1 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ export class DogeChain extends BtcChain implements IChain {
if (amount < Defaults.MIN_OUTPUT_AMOUNT) return cb(null, info);

if (opts.returnInputs) {
info.inputs = _.shuffle(inputs);
info.inputs = _.shuffle(txp.inputs);
}

return cb(null, info);
Expand Down
1 change: 1 addition & 0 deletions packages/bitcore-wallet-service/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,7 @@ export class WalletService implements IWalletService {

this.getSendMaxInfo(
{
feeLevel: opts.feeLevel,
feePerKb: opts.feePerKb,
excludeUnconfirmedUtxos: !!opts.excludeUnconfirmedUtxos,
returnInputs: true,
Expand Down
49 changes: 49 additions & 0 deletions packages/bitcore-wallet-service/test/integration/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4423,6 +4423,31 @@ describe('Wallet service', function() {
});
});

if (coin === 'btc') {
it('should use the specified feeLevel when calculating send max', function(done) {
helpers.stubUtxos(server, wallet, [1, 2], { coin }).then(function() {
const getSendMaxInfo = sinon.spy(server, 'getSendMaxInfo');
const txOpts = Object.assign({
outputs: [{
toAddress: addressStr,
amount: null,
}],
feeLevel: level,
sendMax: true,
}, flags);
server.createTx(txOpts, function(err, txp) {
should.not.exist(err);
should.exist(txp);
getSendMaxInfo.calledOnce.should.equal(true);
getSendMaxInfo.firstCall.args[0].feeLevel.should.equal(level);
txp.feeLevel.should.equal(level);
txp.feePerKb.should.equal(expected);
done();
});
});
});
}

it('should fail if the specified fee level does not exist', function(done) {
helpers.stubUtxos(server, wallet, 2).then(function() {
const txOpts = Object.assign({
Expand Down Expand Up @@ -7233,6 +7258,30 @@ describe('Wallet service', function() {
});


describe('#getSendMaxInfo DOGE', function() {
let server: WalletService;
let wallet: Model.Wallet;

beforeEach(async function() {
({ server, wallet } = await helpers.createAndJoinWallet(1, 1, { coin: 'doge' }));
});

it('should only return inputs that fit within the maximum transaction size', async function() {
sinon.stub(Defaults, 'MAX_TX_SIZE_IN_KB_DOGE').value(2);
await helpers.stubUtxos(server, wallet, new Array(20).fill(1), { coin: 'doge' });

const info = await util.promisify(server.getSendMaxInfo).call(server, {
feePerKb: 10000,
returnInputs: true,
});

info.size.should.be.below(2000);
info.inputs.length.should.be.below(20);
info.utxosAboveMaxSize.should.be.above(0);
info.inputs.reduce((sum, input) => sum + input.satoshis, 0).should.equal(info.amount + info.fee);
});
});

describe('Check requiredFeeRate DOGE', function() {
let server: WalletService;
let wallet: Model.Wallet;
Expand Down