From cd3399b3125feea5749b2bb733dde2eac972fc0f Mon Sep 17 00:00:00 2001 From: Nodari Chkuaselidze Date: Thu, 24 Apr 2025 14:29:01 +0400 Subject: [PATCH] test: add more coin selection tests to mtx and coinselection. --- lib/primitives/covenant.js | 6 +- lib/primitives/mtx.js | 12 + lib/utils/coinselector.js | 21 +- .../migrations/wallet-4-bid-reveal-gen.js | 7 +- test/mtx-test.js | 405 ++++++++++++++++-- test/util/primitives.js | 213 +++++++++ test/util/wallet.js | 13 - test/wallet-chainstate-test.js | 6 +- test/wallet-coinselection-test.js | 221 +++++++--- test/wallet-pagination-test.js | 6 +- test/wallet-test.js | 40 +- test/wallet-unit-test.js | 5 +- 12 files changed, 804 insertions(+), 151 deletions(-) create mode 100644 test/util/primitives.js diff --git a/lib/primitives/covenant.js b/lib/primitives/covenant.js index db983e98..3ac9869d 100644 --- a/lib/primitives/covenant.js +++ b/lib/primitives/covenant.js @@ -383,17 +383,17 @@ class Covenant extends bio.Struct { /** * Set covenant to BID. * @param {Hash} nameHash - * @param {Number} start + * @param {Number} height * @param {Buffer} rawName * @param {Hash} blind * @returns {Covenant} */ - setBid(nameHash, start, rawName, blind) { + setBid(nameHash, height, rawName, blind) { this.type = types.BID; this.items = []; this.pushHash(nameHash); - this.pushU32(start); + this.pushU32(height); this.push(rawName); this.pushHash(blind); diff --git a/lib/primitives/mtx.js b/lib/primitives/mtx.js index 2df4bcf3..679da1cd 100644 --- a/lib/primitives/mtx.js +++ b/lib/primitives/mtx.js @@ -231,6 +231,18 @@ class MTX extends TX { return output; } + /** + * Get the value of the change output. + * @returns {AmountValue} value - Returns -1 if no change output. + */ + + getChangeValue() { + if (this.changeIndex === -1) + return -1; + + return this.outputs[this.changeIndex].value; + } + /** * Verify all transaction inputs. * @param {VerifyFlags?} [flags=STANDARD_VERIFY_FLAGS] diff --git a/lib/utils/coinselector.js b/lib/utils/coinselector.js index 171fb774..db0b2612 100644 --- a/lib/utils/coinselector.js +++ b/lib/utils/coinselector.js @@ -41,22 +41,27 @@ class AbstractCoinSource { } } -/** @typedef {'all'|'random'|'age'|'value'} SelectionType */ +/** @typedef {'all'|'random'|'age'|'value'} MemSelectionType */ /** * @typedef {Object} CoinSourceOptions - * @property {SelectionType} [selection] - Selection type. + * @property {MemSelectionType} [selection] - Selection type. * @property {Coin[]} [coins] - Coins to select from. */ -class CoinSource extends AbstractCoinSource { +/** + * Coin Source with coins. + * @alias module:utils.CoinSource + */ + +class InMemoryCoinSource extends AbstractCoinSource { constructor(options = {}) { super(); /** @type {Coin[]} */ this.coins = []; - /** @type {SelectionType} */ + /** @type {MemSelectionType} */ this.selection = 'value'; this.index = -1; @@ -166,7 +171,7 @@ class CoinSource extends AbstractCoinSource { /** * Coin Selector - * @alias module:primitives.CoinSelector + * @alias module:utils.CoinSelector * @property {MTX} tx - clone of the original mtx. * @property {CoinView} view - reference to the original view. */ @@ -174,7 +179,7 @@ class CoinSource extends AbstractCoinSource { class CoinSelector { /** * @param {MTX} tx - * @param {CoinSource} source + * @param {InMemoryCoinSource} source * @param {CoinSelectorOptions?} [options] */ @@ -506,7 +511,7 @@ class CoinSelector { /** * Fill the transaction with inputs. - * @returns {Promise} + * @returns {Promise} */ async select() { @@ -680,6 +685,6 @@ function sortValue(a, b) { } exports.AbstractCoinSource = AbstractCoinSource; -exports.CoinSource = CoinSource; +exports.InMemoryCoinSource = InMemoryCoinSource; exports.CoinSelector = CoinSelector; exports.FundingError = FundingError; diff --git a/test/data/migrations/wallet-4-bid-reveal-gen.js b/test/data/migrations/wallet-4-bid-reveal-gen.js index b42934df..bd8aa777 100644 --- a/test/data/migrations/wallet-4-bid-reveal-gen.js +++ b/test/data/migrations/wallet-4-bid-reveal-gen.js @@ -10,6 +10,7 @@ const WalletDB = require('../../../lib/wallet/walletdb'); const MTX = require('../../../lib/primitives/mtx'); const wutils = require('../../../test/util/wallet'); const rules = require('../../../lib/covenants/rules'); +const {deterministicInput} = require('../../../test/util/primitives'); const layout = { wdb: { @@ -66,16 +67,16 @@ let timeCounter = 0; // fund wallets const mtx1 = new MTX(); - mtx1.addInput(wutils.deterministicInput(txID++)); + mtx1.addInput(deterministicInput(txID++)); mtx1.addOutput(await wallet1.receiveAddress(0), 10e6); const mtx2 = new MTX(); - mtx2.addInput(wutils.deterministicInput(txID++)); + mtx2.addInput(deterministicInput(txID++)); mtx2.addOutput(await wallet1.receiveAddress(1), 10e6); // fund second wallet. const mtx3 = new MTX(); - mtx3.addInput(wutils.deterministicInput(txID++)); + mtx3.addInput(deterministicInput(txID++)); mtx3.addOutput(await wallet2.receiveAddress(), 10e6); await wdb.addBlock(wutils.nextEntry(wdb), [ diff --git a/test/mtx-test.js b/test/mtx-test.js index 70dea97b..e67badef 100644 --- a/test/mtx-test.js +++ b/test/mtx-test.js @@ -1,13 +1,13 @@ 'use strict'; const assert = require('bsert'); -const random = require('bcrypto/lib/random'); const CoinView = require('../lib/coins/coinview'); const WalletCoinView = require('../lib/wallet/walletcoinview'); -const Coin = require('../lib/primitives/coin'); const MTX = require('../lib/primitives/mtx'); const Path = require('../lib/wallet/path'); const MemWallet = require('./util/memwallet'); +const primutils = require('./util/primitives'); +const {randomP2PKAddress, makeCoin} = primutils; const mtx1json = require('./data/mtx1.json'); const mtx2json = require('./data/mtx2.json'); @@ -138,16 +138,318 @@ describe('MTX', function() { }); }); - describe('Fund', function() { + describe('Fund with in memory coin selectors', function() { + const createCoins = (values) => { + return values.map(value => makeCoin({ value })); + }; + + it('should fund with sorted values', async () => { + const coins = createCoins([1e6, 2e6, 3e6, 4e6, 5e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 7e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 7e6); + assert.strictEqual(mtx.outputs[1].value, 2e6); + }); + + it('should fund with random selection', async () => { + const coins = createCoins([1e6, 1e6, 1e6, 1e6, 1e6, 1e6, 1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 5e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + selection: 'random' + }); + + assert.strictEqual(mtx.inputs.length, 6); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 5e6); + assert.strictEqual(mtx.getFee(), 1e5); + assert.strictEqual(mtx.outputs[1].value, 9e5); + }); + + it('should fund with all selection type', async () => { + const coins = createCoins([1e6, 2e6, 3e6, 4e6, 5e6, 6e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 2e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0, + selection: 'all' + }); + + assert.strictEqual(mtx.inputs.length, 6); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 2e6); + assert.strictEqual(mtx.getFee(), 0); + assert.strictEqual(mtx.outputs[1].value, 19e6); + }); + + it('should fund with age-based selection', async () => { + const coins = [ + makeCoin({ value: 2e6, height: 100 }), + makeCoin({ value: 3e6, height: 200 }), + makeCoin({ value: 1e6, height: 50 }) + ]; + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + selection: 'age' + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.getFee(), 1e5); + // Should select the oldest (lowest height) coins first + assert.strictEqual(mtx.inputs[0].prevout.hash.equals(coins[2].hash), true); + assert.strictEqual(mtx.inputs[1].prevout.hash.equals(coins[0].hash), true); + }); + + it('should fund with value-based selection', async () => { + const coins = [ + makeCoin({ value: 1e6 }), + makeCoin({ value: 5e6 }), + makeCoin({ value: 2e6 }) + ]; + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 4e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + selection: 'value' + }); + + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.getFee(), 1e5); + // Should select the highest value coin first + assert.strictEqual(mtx.inputs[0].prevout.hash.equals(coins[1].hash), true); + }); + + it('should handle subtractFee option', async () => { + const coins = createCoins([2e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 5e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + subtractFee: true + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.outputs[0].value, 4.9e6); // 5e6 - 1e5 = 4.9e6 + assert.strictEqual(mtx.getFee(), 1e5); + }); + + it('should handle subtractIndex option', async () => { + const coins = createCoins([3e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 3e6); + mtx.addOutput(randomP2PKAddress(), 3e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 2e5, + subtractFee: true, + subtractIndex: 1 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 3e6); + assert.strictEqual(mtx.outputs[1].value, 2.8e6); // 3e6 - 2e5 = 2.8e6 + assert.strictEqual(mtx.getFee(), 2e5); + }); + + it('should throw with insufficient funds', async () => { + const coins = createCoins([1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 5e6); + + let err; + try { + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0 + }); + } catch (e) { + err = e; + } + + assert(err); + assert.strictEqual(err.message.includes('Not enough funds'), true); + }); + + it('should throw when fee is too high', async () => { + const coins = createCoins([1e6, 1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 2e6); + + let err; + try { + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + rate: 1e6, // Extremely high fee rate + maxFee: 1e5 // But with a low maxFee + }); + } catch (e) { + err = e; + } + + assert(err); + assert.strictEqual(err.message.includes('Fee is too high'), true); + }); + + it('should handle dust change', async () => { + const coins = createCoins([1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1.999e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e3 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.getFee(), 1e3); + assert.strictEqual(mtx.changeIndex, -1); + }); + + it('should fund with exact amount needed', async () => { + const coins = createCoins([1e6, 2e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 3e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0 + }); + + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.outputs[0].value, 3e6); + assert.strictEqual(mtx.getFee(), 0); + assert.strictEqual(mtx.changeIndex, -1); + }); + + it('should add coin based on minimum required', async () => { + const wallet = new MemWallet(); + const coins = [ + makeCoin({ address: wallet.getAddress(), value: 1e5 }), + makeCoin({ address: wallet.getAddress(), value: 2e5 }), + makeCoin({ address: wallet.getAddress(), value: 5e5 }), + makeCoin({ address: wallet.getAddress(), value: 1e6 }), + makeCoin({ address: wallet.getAddress(), value: 2e6 }) + ]; + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1.5e6); + + await mtx.fund(coins, { + changeAddress: wallet.getChange(), + hardFee: 1e4 + }); + + // Should select the 2e6 coin (largest value first selection) + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 1.5e6); + assert.strictEqual(mtx.outputs[1].value, 2e6 - 1.5e6 - 1e4); + assert.bufferEqual(mtx.inputs[0].prevout.hash, coins[4].hash); + }); + + it('should combine multiple coins when necessary', async () => { + const coins = createCoins([1e5, 2e5, 3e5, 4e5, 5e5]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 5e4 + }); + + // Should need to combine multiple coins to reach 1e6 + 5e4 + assert.ok(mtx.inputs.length > 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 1e6); + assert.strictEqual(mtx.getFee(), 5e4); + }); + + it('should correctly set changeIndex', async () => { + const coins = createCoins([5e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 2e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5 + }); + + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.changeIndex, 1); + assert.strictEqual(mtx.outputs[1].value, 2.9e6); // 5e6 - 2e6 - 1e5 = 2.9e6 + }); + + it('should handle fee rates properly', async () => { + const coins = createCoins([1e6, 2e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 4e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + rate: 5000 // dollarydoos per kb + }); + + // The exact fee will depend on the estimated tx size + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.ok(mtx.getFee() > 0); + assert.ok(mtx.getFee() < 1e5); // Reasonable upper bound for test + }); + }); + + describe('Fund preferred & existing', function() { const wallet1 = new MemWallet(); const wallet2 = new MemWallet(); const coins1 = [ - dummyCoin(wallet1.getAddress(), 1000000), - dummyCoin(wallet1.getAddress(), 1000000), - dummyCoin(wallet1.getAddress(), 1000000), - dummyCoin(wallet1.getAddress(), 1000000), - dummyCoin(wallet1.getAddress(), 1000000) + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }) ]; const last1 = coins1[coins1.length - 1]; @@ -239,7 +541,10 @@ describe('MTX', function() { it('should fund with preferred inputs - view', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addOutput(wallet2.getAddress(), 1500000); mtx.view.addCoin(coin); @@ -266,7 +571,10 @@ describe('MTX', function() { it('should fund with preferred inputs - coins && view', async () => { const mtx = new MTX(); - const viewCoin = dummyCoin(wallet1.getAddress(), 1000000); + const viewCoin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const lastCoin = last1; mtx.addOutput(wallet2.getAddress(), 1500000); @@ -304,7 +612,10 @@ describe('MTX', function() { it('should not fund with preferred inputs and no coin info', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addOutput(wallet2.getAddress(), 1500000); @@ -357,7 +668,10 @@ describe('MTX', function() { it('should fund with existing inputs view - view', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addInput({ prevout: { @@ -388,7 +702,10 @@ describe('MTX', function() { it('should fund with existing inputs view - coins && view', async () => { const mtx = new MTX(); - const viewCoin = dummyCoin(wallet1.getAddress(), 1000000); + const viewCoin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const lastCoin = last1; mtx.addInput({ @@ -434,7 +751,10 @@ describe('MTX', function() { it('should not fund with existing inputs and no coin info', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addInput({ prevout: { @@ -502,8 +822,14 @@ describe('MTX', function() { it('should fund with preferred & existing inputs - view', async () => { const mtx = new MTX(); - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addInput({ prevout: { @@ -546,11 +872,17 @@ describe('MTX', function() { it('should fund with preferred & existing inputs', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -620,11 +952,17 @@ describe('MTX', function() { it('should not fund with missing coin info (both)', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -666,11 +1004,17 @@ describe('MTX', function() { it('should not fund with missing coin info(only existing)', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -713,11 +1057,17 @@ describe('MTX', function() { it('should not fund with missing coin info(only preferred)', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -758,10 +1108,3 @@ describe('MTX', function() { }); }); }); - -function dummyCoin(address, value) { - const hash = random.randomBytes(32); - const index = 0; - - return new Coin({address, value, hash, index}); -} diff --git a/test/util/primitives.js b/test/util/primitives.js new file mode 100644 index 00000000..4a6c9a4a --- /dev/null +++ b/test/util/primitives.js @@ -0,0 +1,213 @@ +'use strict'; + +const assert = require('bsert'); +const blake2b = require('bcrypto/lib/blake2b'); +const random = require('bcrypto/lib/random'); +const rules = require('../../lib/covenants/rules'); +const Input = require('../../lib/primitives/input'); +const Address = require('../../lib/primitives/address'); +const Output = require('../../lib/primitives/output'); +const Outpoint = require('../../lib/primitives/outpoint'); +const Coin = require('../../lib/primitives/coin'); +const Covenant = require('../../lib/primitives/covenant'); + +/** @typedef {import('../../lib/types').Hash} Hash */ + +exports.coinbaseInput = () => { + return Input.fromOutpoint(new Outpoint(Buffer.alloc(32), 0)); +}; + +exports.dummyInput = () => { + const hash = random.randomBytes(32); + return Input.fromOutpoint(new Outpoint(hash, 0)); +}; + +exports.deterministicInput = (id) => { + const hash = blake2b.digest(fromU32(id)); + return Input.fromOutpoint(new Outpoint(hash, 0)); +}; + +/** + * @typedef {Object} CovenantOptions + * @property {String} [name] + * @property {Hash} [nameHash] + * @property {Covenant.types} [type=Covenant.types.NONE] + * @property {Number} [height] + * @property {Array} [args] - leftover args for the covenant except + * for nameHash, name and height. + */ + +/** + * @typedef {Object} OutputOptions + * @property {Number} value + * @property {Address} [address] + * @property {CovenantOptions} [covenant] + */ + +/** + * @param {OutputOptions} options + * @returns {Output} + */ + +exports.makeOutput = (options) => { + const address = options.address || exports.randomP2PKAddress(); + const output = new Output(); + output.address = address; + output.value = options.value; + + if (options.covenant) + output.covenant = exports.makeCovenant(options.covenant); + + return output; +}; + +/** + * @param {CovenantOptions} options + * @returns {Covenant} + */ + +exports.makeCovenant = (options) => { + const covenant = new Covenant(); + covenant.type = options.type || Covenant.types.NONE; + + const args = options.args || []; + const height = options.height || 0; + let nameHash = options.nameHash; + let name = options.name; + + if (name) { + nameHash = rules.hashName(name); + } else if (!nameHash) { + name = randomString(30); + nameHash = rules.hashName(name); + } + + switch (covenant.type) { + case Covenant.types.NONE: + break; + case Covenant.types.OPEN: { + assert(args.length === 0, 'Pass `options.name` instead.'); + const rawName = Buffer.from(name, 'ascii'); + covenant.setOpen(nameHash, rawName); + break; + } + case Covenant.types.BID: { + assert(args.length < 1, 'Pass [blind?] instead.'); + const blind = args[0] || random.randomBytes(32); + const rawName = Buffer.from(name, 'ascii'); + covenant.setBid(nameHash, height, rawName, blind); + break; + } + case Covenant.types.REVEAL: { + assert(args.length < 1, 'Pass [nonce?] instead.'); + const nonce = args[0] || random.randomBytes(32); + covenant.setReveal(nameHash, height, nonce); + break; + } + case Covenant.types.REDEEM: { + assert(args.length === 0, 'No args for redeem.'); + covenant.setRedeem(nameHash, height); + break; + } + case Covenant.types.REGISTER: { + assert(args.length < 2, 'Pass [record?, blockHash?] instead.'); + const record = args[0] || Buffer.alloc(0); + const blockHash = args[1] || random.randomBytes(32); + covenant.setRegister(nameHash, height, record, blockHash); + break; + } + case Covenant.types.UPDATE: { + assert(args.length < 1, 'Pass [resource?] instead.'); + const resource = args[0] || Buffer.alloc(0); + covenant.setUpdate(nameHash, height, resource); + break; + } + case Covenant.types.RENEW: { + assert(args.length < 1, 'Pass [blockHash?] instead.'); + const blockHash = args[0] || random.randomBytes(32); + covenant.setRenew(nameHash, height, blockHash); + break; + } + case Covenant.types.TRANSFER: { + assert(args.length < 1, 'Pass [address?] instead.'); + const address = args[0] || exports.randomP2PKAddress(); + covenant.setTransfer(nameHash, height, address); + break; + } + case Covenant.types.FINALIZE: { + assert(args.length < 4, 'Pass [flags?, claimed?, renewal?, blockHash?] instead.'); + const rawName = Buffer.from(name, 'ascii'); + const flags = args[0] || 0; + const claimed = args[1] || 0; + const renewal = args[2] || 0; + const blockHash = args[3] || random.randomBytes(32); + + covenant.setFinalize( + nameHash, + height, + rawName, + flags, + claimed, + renewal, + blockHash + ); + break; + } + case Covenant.types.REVOKE: { + assert(args.length === 0, 'No args for revoke.'); + covenant.setRevoke(nameHash, height); + break; + } + default: + throw new Error(`Invalid covenant type ${covenant.type}.`); + } + + return covenant; +}; + +exports.randomP2PKAddress = () => { + const key = random.randomBytes(33); + return Address.fromPubkey(key); +}; + +/** + * @param {Object} options + * @param {String} [options.version=1] + * @param {String} [options.height=-1] + * @param {String} [options.value=0] + * @param {String} [options.address] + * @param {Object} [options.covenant] + * @param {Boolean} [options.coinbase=false] + * @param {Buffer} [options.hash] + * @param {Number} [options.index=0] + * @returns {Coin} + */ + +exports.makeCoin = (options) => { + return Coin.fromOptions({ + hash: options.hash || random.randomBytes(32), + address: options.address || Address.fromPubkey(random.randomBytes(33)), + ...options + }); +}; + +function fromU32(num) { + const data = Buffer.allocUnsafe(4); + data.writeUInt32LE(num, 0, true); + return data; +} + +function randomString(len) { + assert((len >>> 0) === len); + + let s = ''; + + for (let i = 0; i < len; i++) { + const n = Math.random() * (0x7b - 0x61) + 0x61; + const c = Math.floor(n); + + s += String.fromCharCode(c); + } + + return s; +} diff --git a/test/util/wallet.js b/test/util/wallet.js index 05b173a4..506c6def 100644 --- a/test/util/wallet.js +++ b/test/util/wallet.js @@ -2,10 +2,7 @@ const assert = require('bsert'); const blake2b = require('bcrypto/lib/blake2b'); -const random = require('bcrypto/lib/random'); const ChainEntry = require('../../lib/blockchain/chainentry'); -const Input = require('../../lib/primitives/input'); -const Outpoint = require('../../lib/primitives/outpoint'); const {ZERO_HASH} = require('../../lib/protocol/consensus'); const walletUtils = exports; @@ -35,16 +32,6 @@ walletUtils.fakeBlock = (height, prevSeed = 0, seed = prevSeed) => { }; }; -walletUtils.dummyInput = () => { - const hash = random.randomBytes(32); - return Input.fromOutpoint(new Outpoint(hash, 0)); -}; - -walletUtils.deterministicInput = (id) => { - const hash = blake2b.digest(fromU32(id)); - return Input.fromOutpoint(new Outpoint(hash, 0)); -}; - walletUtils.nextBlock = (wdb, prevSeed = 0, seed = prevSeed) => { return walletUtils.fakeBlock(wdb.state.height + 1, prevSeed, seed); }; diff --git a/test/wallet-chainstate-test.js b/test/wallet-chainstate-test.js index 43f6b16d..b92bb72f 100644 --- a/test/wallet-chainstate-test.js +++ b/test/wallet-chainstate-test.js @@ -7,10 +7,8 @@ const MTX = require('../lib/primitives/mtx'); const WorkerPool = require('../lib/workers/workerpool'); const WalletDB = require('../lib/wallet/walletdb'); const wutils = require('./util/wallet'); -const { - dummyInput, - nextEntry -} = wutils; +const {nextEntry} = wutils; +const {dummyInput} = require('./util/primitives'); const enabled = true; const size = 2; diff --git a/test/wallet-coinselection-test.js b/test/wallet-coinselection-test.js index b909644d..c1aafc29 100644 --- a/test/wallet-coinselection-test.js +++ b/test/wallet-coinselection-test.js @@ -1,72 +1,130 @@ 'use strict'; const assert = require('bsert'); -const {BlockMeta} = require('../lib/wallet/records'); -const util = require('../lib/utils/util'); const Network = require('../lib/protocol/network'); const MTX = require('../lib/primitives/mtx'); +const Covenant = require('../lib/primitives/covenant'); const WalletDB = require('../lib/wallet/walletdb'); const policy = require('../lib/protocol/policy'); +const wutils = require('./util/wallet'); +const {nextBlock} = wutils; +const primutils = require('./util/primitives'); +const {coinbaseInput, dummyInput} = primutils; + +/** @typedef {import('../lib/wallet/wallet')} Wallet */ +/** @typedef {import('../lib/covenants/rules').types} covenantTypes */ // Use main instead of regtest because (deprecated) // CoinSelector.MAX_FEE was network agnostic const network = Network.get('main'); -function dummyBlock(tipHeight) { - const height = tipHeight + 1; - const hash = Buffer.alloc(32); - hash.writeUInt16BE(height); +describe('Wallet Coin Selection', function () { + const TX_START_BAK = network.txStart; + /** @type {WalletDB?} */ + let wdb; + /** @type {Wallet?} */ + let wallet; - const prevHash = Buffer.alloc(32); - prevHash.writeUInt16BE(tipHeight); + const beforeFn = async () => { + network.txStart = 0; + wdb = new WalletDB({ network }); - const dummyBlock = { - hash, - height, - time: util.now(), - prevBlock: prevHash + await wdb.open(); + await wdb.addBlock(nextBlock(wdb), []); + wallet = wdb.primary; }; - return dummyBlock; -} + const afterFn = async () => { + network.txStart = TX_START_BAK; + await wdb.close(); -async function fundWallet(wallet, amounts) { - assert(Array.isArray(amounts)); + wdb = null; + wallet = null; + }; - const mtx = new MTX(); - const addr = await wallet.receiveAddress(); - for (const amt of amounts) { - mtx.addOutput(addr, amt); - } + describe('Selection types', function () { + beforeEach(beforeFn); + afterEach(afterFn); - const dummy = dummyBlock(wallet.wdb.height); - await wallet.wdb.addBlock(dummy, [mtx.toTX()]); -} + it('should select all spendable coins', async () => { + const spendableCovs = [ + Covenant.types.NONE, + Covenant.types.OPEN, + Covenant.types.REDEEM + ]; + + const nonSpendableCovs = [ + Covenant.types.BID, + Covenant.types.REVEAL, + Covenant.types.REGISTER, + Covenant.types.UPDATE, + Covenant.types.RENEW, + Covenant.types.TRANSFER, + Covenant.types.FINALIZE, + Covenant.types.REVOKE + ]; + + const mkopt = type => ({ value: 1e6, covenant: { type }}); + await fundWallet(wallet, [...nonSpendableCovs, ...spendableCovs].map(mkopt)); + + const coins = await wallet.getCoins(); + assert.strictEqual(coins.length, spendableCovs.length + nonSpendableCovs.length); + + const mtx = new MTX(); + await wallet.fund(mtx, { + selection: 'all' + }); + + assert.strictEqual(mtx.inputs.length, spendableCovs.length); + }); + + it('should select coin by descending value', async () => { + const values = [5e6, 4e6, 3e6, 2e6, 1e6]; + await fundWallet(wallet, values.map(value => ({ value }))); + + const mtx = new MTX(); + mtx.addOutput(primutils.randomP2PKAddress(), 9e6); + + await wallet.fund(mtx, { + selection: 'value', + hardFee: 0 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.outputs[0].value, 9e6); + }); + + it('should select coins by descending age', async () => { + const values = [1e6, 2e6, 3e6, 4e6, 5e6]; + + for (const value of values) + await fundWallet(wallet, [{ value }]); + + const mtx = new MTX(); + mtx.addOutput(primutils.randomP2PKAddress(), 9e6); + await wallet.fund(mtx, { + selection: 'age', + hardFee: 0 + }); + + // 1 + 2 + 3 + 4 = 10 + assert.strictEqual(mtx.inputs.length, 4); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 9e6); + assert.strictEqual(mtx.outputs[1].value, 1e6); + }); + }); -describe('Wallet Coin Selection', function () { describe('Fees', function () { - const wdb = new WalletDB({network}); - let wallet; - - before(async () => { - await wdb.open(); - wdb.height = network.txStart + 1; - wdb.state.height = wdb.height; - - const dummy = dummyBlock(network.txStart + 1); - const record = BlockMeta.fromEntry(dummy); - await wdb.setTip(record); - wallet = wdb.primary; - }); - - after(async () => { - await wdb.close(); - }); + before(beforeFn); + after(afterFn); it('should fund wallet', async () => { - await fundWallet(wallet, [100e6, 10e6, 1e6, 100000, 10000]); + const vals = [100e6, 10e6, 1e6, 0.1e6, 0.01e6]; + await fundWallet(wallet, vals.map(value => ({ value }))); const bal = await wallet.getBalance(); - assert.strictEqual(bal.confirmed, 111110000); + assert.strictEqual(bal.confirmed, 111.11e6); }); it('should pay default fee rate for small tx', async () => { @@ -121,16 +179,22 @@ describe('Wallet Coin Selection', function () { it('should fail to pay absurd fee rate for small tx', async () => { const address = await wallet.receiveAddress(); - await assert.rejects( - wallet.send({ + let err; + + try { + await wallet.send({ outputs: [{ address, value: 5e6 }], rate: (policy.ABSURD_FEE_FACTOR + 1) * network.minRelay - }), - {message: 'Fee exceeds absurd limit.'} - ); + }); + } catch (e) { + err = e; + } + + assert(err, 'Error not thrown.'); + assert.strictEqual(err.message, 'Fee exceeds absurd limit.'); }); it('should pay fee just under the absurd limit', async () => { @@ -177,3 +241,60 @@ describe('Wallet Coin Selection', function () { }); }); }); + +/** + * @typedef {Object} OutputInfo + * @property {String} [address] + * @property {Number} [value] + * @property {covenantTypes} [covenant] + * @property {Boolean} [coinbase=false] + */ + +/** + * @param {Wallet} wallet + * @param {primutils.OutputOptions} outputInfo + * @returns {Promise} + */ + +async function mkOutput(wallet, outputInfo) { + if (!outputInfo.address) + outputInfo.address = await wallet.receiveAddress(); + + return primutils.makeOutput(outputInfo); +} + +/** + * @param {Wallet} wallet + * @param {OutputInfo[]} outputInfos + */ + +async function fundWallet(wallet, outputInfos) { + assert(Array.isArray(outputInfos)); + + let hadCoinbase = false; + + const txs = []; + for (const info of outputInfos) { + const mtx = new MTX(); + + if (info.coinbase && hadCoinbase) + throw new Error('Coinbase already added.'); + + if (info.coinbase && !hadCoinbase) { + hadCoinbase = true; + mtx.addInput(coinbaseInput()); + } else { + mtx.addInput(dummyInput()); + } + + const output = await mkOutput(wallet, info); + mtx.addOutput(output); + + if (output.covenant.isLinked()) + mtx.addInput(dummyInput()); + + txs.push(mtx.toTX()); + } + + await wallet.wdb.addBlock(nextBlock(wallet.wdb), txs); +} diff --git a/test/wallet-pagination-test.js b/test/wallet-pagination-test.js index c2898717..2e577086 100644 --- a/test/wallet-pagination-test.js +++ b/test/wallet-pagination-test.js @@ -7,10 +7,8 @@ const WalletDB = require('../lib/wallet/walletdb'); const consensus = require('../lib/protocol/consensus'); const util = require('../lib/utils/util'); const wutils = require('./util/wallet'); -const { - dummyInput, - nextEntry -} = wutils; +const {nextEntry} = wutils; +const {dummyInput} = require('./util/primitives'); /** @typedef {import('../lib/wallet/wallet')} Wallet */ diff --git a/test/wallet-test.js b/test/wallet-test.js index e1dab594..91c186c4 100644 --- a/test/wallet-test.js +++ b/test/wallet-test.js @@ -28,12 +28,12 @@ const wutils = require('./util/wallet'); const {ownership} = require('../lib/covenants/ownership'); const {CachedStubResolver, STUB_SERVERS} = require('./util/stub'); const { - dummyInput, curBlock, nextBlock, curEntry, nextEntry } = wutils; +const {dummyInput} = require('./util/primitives'); const KEY1 = 'xprv9s21ZrQH143K3Aj6xQBymM31Zb4BVc7wxqfUhMZrzewdDVCt' + 'qUP9iWfcHgJofs25xbaUpCps9GDXj83NiWvQCAkWQhVj5J4CorfnpKX94AZ'; @@ -2224,23 +2224,16 @@ describe('Wallet', function() { // Store balance data before rescan to ensure rescan was complete let recipBalBefore, senderBalBefore; - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - before(async () => { await wdb.open(); await wdb.connect(); wallet = await wdb.create(); recip = await wdb.create(); - // rollout all names - wdb.height = 52 * 144 * 7; + network.names.noRollout = true; }); after(async () => { + network.names.noRollout = false; await wdb.disconnect(); await wdb.close(); }); @@ -2610,21 +2603,15 @@ describe('Wallet', function() { let start; let wallet; - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - before(async () => { await wdb.open(); wallet = await wdb.create(); // rollout all names - wdb.height = 52 * 144 * 7; + network.names.noRollout = true; }); after(async () => { + network.names.noRollout = false; await wdb.close(); }); @@ -3293,21 +3280,15 @@ describe('Wallet', function() { let wallet; let unsentReveal; - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - before(async () => { await wdb.open(); wallet = await wdb.create(); // rollout all names - wdb.height = 52 * 144 * 7; + network.names.noRollout = true; }); after(async () => { + network.names.noRollout = false; await wdb.close(); }); @@ -3748,13 +3729,6 @@ describe('Wallet', function() { const network = Network.get('regtest'); const wdb = new WalletDB({ network }); - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - const mineBlocks = async (count) => { for (let i = 0; i < count; i++) { await wdb.addBlock(nextEntry(wdb), []); diff --git a/test/wallet-unit-test.js b/test/wallet-unit-test.js index 1e6a5530..8bb9c2a5 100644 --- a/test/wallet-unit-test.js +++ b/test/wallet-unit-test.js @@ -13,7 +13,8 @@ const WalletDB = require('../lib/wallet/walletdb'); const Wallet = require('../lib/wallet/wallet'); const Account = require('../lib/wallet/account'); const wutils = require('./util/wallet'); -const {nextEntry, fakeEntry} = require('./util/wallet'); +const {nextEntry, fakeEntry} = wutils; +const {dummyInput} = require('./util/primitives'); const MemWallet = require('./util/memwallet'); /** @typedef {import('../lib/primitives/tx')} TX */ @@ -541,7 +542,7 @@ describe('Wallet Unit Tests', () => { function fakeTX(addr) { const tx = new MTX(); - tx.addInput(wutils.dummyInput()); + tx.addInput(dummyInput()); tx.addOutput({ address: addr, value: 5460