diff --git a/lib/wallet/layout.js b/lib/wallet/layout.js index fd6b0279..ea054573 100644 --- a/lib/wallet/layout.js +++ b/lib/wallet/layout.js @@ -162,8 +162,10 @@ exports.txdb = { s: bdb.key('s', ['hash256', 'uint32']), // Coin Selector - // by value + account + // by account + value SV: bdb.key('SV', ['uint32', 'uint64', 'hash256', 'uint32']), + // by account + height + SH: bdb.key('SH', ['uint32', 'uint32', 'hash256', 'uint32']), // Transaction t: bdb.key('t', ['hash256']), diff --git a/lib/wallet/txdb.js b/lib/wallet/txdb.js index b488d022..d6a33709 100644 --- a/lib/wallet/txdb.js +++ b/lib/wallet/txdb.js @@ -202,14 +202,25 @@ class TXDB { * @param {Batch} b * @param {Credit} credit * @param {Path} path + * @param {Number?} oldHeight */ - indexCSCredit(b, credit, path) { + indexCSCredit(b, credit, path, oldHeight) { const {coin} = credit; // index coin by value + account. b.put(layout.SV.encode( path.account, coin.value, coin.hash, coin.index), null); + + const height = coin.height === -1 ? UNCONFIRMED_HEIGHT : coin.height; + b.put(layout.SH.encode( + path.account, height, coin.hash, coin.index), null); + + if (oldHeight != null) { + const height = oldHeight === -1 ? UNCONFIRMED_HEIGHT : oldHeight; + b.del(layout.SH.encode( + path.account, height, coin.hash, coin.index)); + } } /** @@ -222,8 +233,12 @@ class TXDB { unindexCSCredit(b, credit, path) { const {coin} = credit; - // Remove coin by value + account. + // Remove coin by account + value. b.del(layout.SV.encode(path.account, coin.value, coin.hash, coin.index)); + + // Remove coin by account + height + const height = coin.height === -1 ? UNCONFIRMED_HEIGHT : coin.height; + b.del(layout.SH.encode(path.account, height, coin.hash, coin.index)); } /** @@ -1192,7 +1207,7 @@ class TXDB { } await this.saveCredit(b, credit, path); - this.indexCSCredit(b, credit, path); + this.indexCSCredit(b, credit, path, null); await this.watchOpensEarly(b, output); } @@ -1429,7 +1444,7 @@ class TXDB { credit.coin.height = height; await this.saveCredit(b, credit, path); - this.indexCSCredit(b, credit, path); + this.indexCSCredit(b, credit, path, -1); } // Handle names. @@ -1554,7 +1569,7 @@ class TXDB { credit.spent = false; await this.saveCredit(b, credit, path); - this.indexCSCredit(b, credit, path); + this.indexCSCredit(b, credit, path, null); } } @@ -1818,7 +1833,7 @@ class TXDB { credit.spent = true; own = true; await this.saveCredit(b, credit, path); - this.indexCSCredit(b, credit, path); + this.indexCSCredit(b, credit, path, null); } } @@ -1870,6 +1885,7 @@ class TXDB { // Update coin height and confirmed // balance. Save once again. + const oldHeight = credit.coin.height; credit.coin.height = -1; // If the coin was not discovered now, it means @@ -1882,7 +1898,7 @@ class TXDB { } await this.saveCredit(b, credit, path); - this.indexCSCredit(b, credit, path); + this.indexCSCredit(b, credit, path, oldHeight); } // Unconfirm will also index OPENs as the transaction is now part of the @@ -3815,6 +3831,58 @@ class TXDB { } } + /** + * Get credits iterator sorted by height. + * @param {Number} acct + * @param {Object} [options] + * @param {Number} [options.minHeight=0] + * @param {Number} [options.maxHeight=UNCONFIRMED_HEIGHT] + * @param {Number} [options.limit=-1] + * @param {Boolean} [options.reverse=false] + * @returns {AsyncGenerator} + */ + + async *getAccountCreditIterByHeight(acct, options = {}) { + assert(typeof acct === 'number'); + assert(acct >= 0); + assert(options && typeof options === 'object'); + + let minHeight = 0; + let maxHeight = UNCONFIRMED_HEIGHT; + + if (options.minHeight != null) { + assert(typeof options.minHeight === 'number'); + minHeight = options.minHeight; + } + + if (options.maxHeight != null) { + assert(typeof options.maxHeight === 'number'); + maxHeight = options.maxHeight; + } + + assert(minHeight <= maxHeight); + + const min = layout.SH.min(acct, minHeight); + const max = layout.SH.max(acct, maxHeight); + + const iter = this.bucket.iterator({ + gte: min, + lte: max, + limit: options.limit, + reverse: options.reverse, + keys: true, + values: false + }); + + for await (const {key} of iter) { + const [,, hash, index] = layout.SH.decode(key); + const credit = await this.getCredit(hash, index); + + assert(credit); + yield credit; + } + } + /** * Get a coin viewpoint. * @param {TX} tx diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index 49acaa4d..7a265e30 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -5163,6 +5163,21 @@ class Wallet extends EventEmitter { return this.txdb.getAccountCreditIterByValue(acct, options); } + /** + * Get credits iterator sorted by height. + * @param {Number} acct + * @param {Object} [options] + * @param {Number} [options.minHeight=0] + * @param {Number} [options.maxHeight=UNCONFIRMED_HEIGHT] + * @param {Number} [options.limit=-1] + * @param {Boolean} [options.reverse=false] + * @returns {AsyncGenerator} + */ + + getAccountCreditIterByHeight(acct, options = {}) { + return this.txdb.getAccountCreditIterByHeight(acct, options); + } + /** * Get "smart" coins. * @param {(String|Number)?} acct diff --git a/test/wallet-coinselection-test.js b/test/wallet-coinselection-test.js index 29d560af..b25926e4 100644 --- a/test/wallet-coinselection-test.js +++ b/test/wallet-coinselection-test.js @@ -44,7 +44,13 @@ describe('Wallet Coin Selection', function() { wallet = null; }; - describe('Coin Selection Indexes', function() { + const indexes = [ + 'value', + 'height' + ]; + + for (const indexType of indexes) { + describe(`Coin Selection Indexes (${indexType})`, function() { const TX_OPTIONS = [ { value: 2e6, address: primutils.randomP2PKAddress() }, // address will be generated using wallet. @@ -57,33 +63,36 @@ describe('Wallet Coin Selection', function() { const TOTAL_COINS = 4; const TOTAL_FUNDS = 1e6 + 2e6 + 3e6 + 4e6; + let isSorted, getIterMethodName; + + before(() => { + switch (indexType) { + case 'value': + isSorted = isSortedByValue; + getIterMethodName = 'getAccountCreditIterByValue'; + break; + case 'height': + isSorted = isSortedByHeight; + getIterMethodName = 'getAccountCreditIterByHeight'; + break; + default: + throw new Error('Invalid index type.'); + } + }); + beforeEach(beforeFn); afterEach(afterFn); - /** - * @param {Credit[]} credits - * @returns {Boolean} - */ - - const isSortedByValue = (credit) => { - for (let i = 1; i < credit.length; i++) { - if (credit[i].value > credit[i - 1].value) - return false; - } - - return true; - }; - it('should index unconfirmed tx output', async () => { const txs = await createInboundTXs(wallet, TX_OPTIONS, false); await wallet.wdb.addTX(txs[0]); - const iter = wallet.getAccountCreditIterByValue(0); - const creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + const iter = wallet[getIterMethodName](0); + const credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, -1); assert.strictEqual(credit.spent, false); } @@ -100,12 +109,12 @@ describe('Wallet Coin Selection', function() { await wdb.addTX(spendAll.toTX()); // We still have the coin, even thought it is flagged: .spent = true - const iter = wallet.getAccountCreditIterByValue(0); - const creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + const iter = wallet[getIterMethodName](0); + const credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, curBlock(wdb).height); assert.strictEqual(credit.spent, true); } @@ -115,12 +124,12 @@ describe('Wallet Coin Selection', function() { await fundWallet(wallet, TX_OPTIONS, false); const currentBlock = curBlock(wdb); - const iter = wallet.getAccountCreditIterByValue(0); - const creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + const iter = wallet[getIterMethodName](0); + const credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, currentBlock.height); assert.strictEqual(credit.spent, false); } @@ -135,33 +144,33 @@ describe('Wallet Coin Selection', function() { outputs: [{ value: TOTAL_FUNDS, address: primutils.randomP2PKAddress() }] }); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, currentBlock.height); assert.strictEqual(credit.spent, false); } await wdb.addBlock(nextBlock(wdb), [spendAll.toTX()]); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, 0); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, 0); }); it('should index confirm tx output', async () => { const txs = await createInboundTXs(wallet, TX_OPTIONS, false); await wdb.addTX(txs[0]); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, -1); assert.strictEqual(credit.spent, false); } @@ -169,12 +178,12 @@ describe('Wallet Coin Selection', function() { await wdb.addBlock(nextBlock(wdb), txs); const currentBlock = curBlock(wdb); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, currentBlock.height); assert.strictEqual(credit.spent, false); } @@ -192,21 +201,21 @@ describe('Wallet Coin Selection', function() { await wdb.addTX(spendAllTX); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, currentBlock.height); assert.strictEqual(credit.spent, true); } await wdb.addBlock(nextBlock(wdb), [spendAllTX]); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, 0); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, 0); }); it('should index disconnect tx output', async () => { @@ -214,12 +223,12 @@ describe('Wallet Coin Selection', function() { const currentBlock = curBlock(wdb); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, currentBlock.height); assert.strictEqual(credit.spent, false); } @@ -228,12 +237,12 @@ describe('Wallet Coin Selection', function() { await wdb.removeBlock(currentBlock); // Only thing that must change is the HEIGHT. - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, -1); assert.strictEqual(credit.spent, false); } @@ -251,18 +260,18 @@ describe('Wallet Coin Selection', function() { const spendAllTX = spendAll.toTX(); await wdb.addBlock(nextBlock(wdb), [spendAllTX]); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, 0); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, 0); await wdb.removeBlock(curBlock(wdb)); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, createCoinHeight); assert.strictEqual(credit.spent, true); } @@ -272,12 +281,12 @@ describe('Wallet Coin Selection', function() { const txs = await createInboundTXs(wallet, TX_OPTIONS, false); await wdb.addTX(txs[0]); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, -1); assert.strictEqual(credit.spent, false); } @@ -289,9 +298,9 @@ describe('Wallet Coin Selection', function() { await wdb.addBlock(nextBlock(wdb), [mtx.toTX()]); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, 0); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, 0); }); it('should index erase tx input', async () => { @@ -305,12 +314,12 @@ describe('Wallet Coin Selection', function() { await wdb.addTX(spendAll.toTX()); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, -1); assert.strictEqual(credit.spent, true); } @@ -322,9 +331,9 @@ describe('Wallet Coin Selection', function() { await wdb.addBlock(nextBlock(wdb), [mtx.toTX()]); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, 0); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, 0); }); it('should index erase (block) tx output', async () => { @@ -336,23 +345,24 @@ describe('Wallet Coin Selection', function() { const currentBlock = curBlock(wdb); - let iter = wallet.getAccountCreditIterByValue(0); - let creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, TOTAL_COINS); - assert(isSortedByValue(creditsByValue), 'Credits not sorted.'); + let iter = wallet[getIterMethodName](0); + let credits = await collectIter(iter); + assert.strictEqual(credits.length, TOTAL_COINS); + assert(isSorted(credits), 'Credits not sorted.'); - for (const credit of creditsByValue) { + for (const credit of credits) { assert.strictEqual(credit.coin.height, currentBlock.height); assert.strictEqual(credit.spent, false); } await wdb.removeBlock(currentBlock); - iter = wallet.getAccountCreditIterByValue(0); - creditsByValue = await collectIter(iter); - assert.strictEqual(creditsByValue.length, 0); + iter = wallet[getIterMethodName](0); + credits = await collectIter(iter); + assert.strictEqual(credits.length, 0); }); }); + } describe('Selection types', function() { beforeEach(beforeFn); @@ -652,3 +662,31 @@ async function collectIter(iter) { return items; } + +/** + * @param {Credit[]} credits + * @returns {Boolean} + */ + +function isSortedByValue(credits) { + for (let i = 1; i < credits.length; i++) { + if (credits[i].value > credits[i - 1].value) + return false; + } + + return true; +}; + +/** + * @param {Credit[]} credits + * @returns {Boolean} + */ + +function isSortedByHeight(credits) { + for (let i = 1; i < credits.length; i++) { + if (credits[i].coin.height > credits[i - 1].coin.height) + return false; + } + + return true; +};