diff --git a/packages/bitcore-wallet-client/src/lib/verifier.ts b/packages/bitcore-wallet-client/src/lib/verifier.ts index 394a2b2da55..c81cc8dcc50 100644 --- a/packages/bitcore-wallet-client/src/lib/verifier.ts +++ b/packages/bitcore-wallet-client/src/lib/verifier.ts @@ -20,6 +20,71 @@ 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') { + // Preserve the integer value actually represented by JavaScript, including unsafe Numbers. + return Number.isInteger(value) && value >= 0 ? BigInt(value) : null; + } + if ( + typeof value === 'string' && + (/^(0|[1-9]\d*)$/.test(value) || /^0x[0-9a-fA-F]+$/.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 == null; + const value2Missing = 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(); + 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; } @@ -133,6 +198,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; @@ -150,6 +216,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; diff --git a/packages/bitcore-wallet-client/test/helpers.ts b/packages/bitcore-wallet-client/test/helpers.ts index ecedd79b8ae..f22ab2ab0ae 100644 --- a/packages/bitcore-wallet-client/test/helpers.ts +++ b/packages/bitcore-wallet-client/test/helpers.ts @@ -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, @@ -363,4 +363,4 @@ export const blockchainExplorerMock = { blockchainExplorerMock.txHistory = []; blockchainExplorerMock.feeLevels = []; } -}; \ No newline at end of file +}; diff --git a/packages/bitcore-wallet-client/test/verifier.test.ts b/packages/bitcore-wallet-client/test/verifier.test.ts index 96509174a78..4dad8ab00e5 100644 --- a/packages/bitcore-wallet-client/test/verifier.test.ts +++ b/packages/bitcore-wallet-client/test/verifier.test.ts @@ -1,14 +1,193 @@ '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: '0x3e8', + 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], + [1.5, 1.5], + ['01', '01'], + ['0x', '0x'], + ['0xgg', '0xgg'] + ]; + for (const [requestedFee, returnedFee] of feePairs) { + checkProposalCreation({ fee: requestedFee }, { fee: returnedFee }).should.be.false; + } + }); + + it('should compare unsafe integer fees using their represented JavaScript value', function() { + const unsafeFee = Number.MAX_SAFE_INTEGER + 1; + + checkProposalCreation({ fee: unsafeFee }, { fee: unsafeFee }).should.be.true; + checkProposalCreation({ fee: unsafeFee }, { fee: unsafeFee.toString() }).should.be.true; + checkProposalCreation({ fee: unsafeFee }, { fee: unsafeFee + 2 }).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 }); diff --git a/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts b/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts index aa822e79b50..c63fc7ea9b6 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts @@ -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); diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index d64b5beff64..3518834e89c 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -2398,6 +2398,7 @@ export class WalletService implements IWalletService { this.getSendMaxInfo( { + feeLevel: opts.feeLevel, feePerKb: opts.feePerKb, excludeUnconfirmedUtxos: !!opts.excludeUnconfirmedUtxos, returnInputs: true, diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index 11a7df0823b..7cd974da4c1 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -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({ @@ -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;