diff --git a/bin/bwallet-cli b/bin/bwallet-cli index 3b2b0bba9..409975f77 100755 --- a/bin/bwallet-cli +++ b/bin/bwallet-cli @@ -298,6 +298,7 @@ class CLI { passphrase: this.config.str('passphrase'), outputs: outputs, smart: this.config.bool('smart'), + replaceable: this.config.bool('replaceable'), rate: this.config.ufixed('rate', 8), subtractFee: this.config.bool('subtract-fee') }; @@ -327,6 +328,7 @@ class CLI { passphrase: this.config.str('passphrase'), outputs: [output], smart: this.config.bool('smart'), + replaceable: this.config.bool('replaceable'), rate: this.config.ufixed('rate', 8), subtractFee: this.config.bool('subtract-fee'), sign: this.config.bool('sign') @@ -362,6 +364,17 @@ class CLI { this.log('Abandoned tx: ' + hash); } + async bumpTX() { + const hash = this.config.str([0, 'hash']); + const rate = this.config.uint([1, 'rate']); + const sign = this.config.bool([2, 'sign']); + const passphrase = this.config.str([3, 'passphrase']); + + const tx = await this.wallet.bumpTX(hash, rate, sign, passphrase); + + this.log(tx); + } + async getDetails() { const hash = this.config.str(0); const details = await this.wallet.getTX(hash); @@ -603,6 +616,9 @@ class CLI { case 'rescan': await this.rescan(); break; + case 'bump': + await this.bumpTX(); + break; default: this.log('Unrecognized command.'); this.log('Commands:'); @@ -615,6 +631,7 @@ class CLI { this.log(' $ balance: Get wallet balance.'); this.log(' $ block [height]: View wallet block.'); this.log(' $ blocks: List wallet blocks.'); + this.log(' $ bump [hash]: Bump TX fee with replacement.'); this.log(' $ change: Derive new change address.'); this.log(' $ coins: View wallet coins.'); this.log(' $ dump [address]: Get wallet key WIF by address.'); diff --git a/lib/client/wallet.js b/lib/client/wallet.js index 812386e26..020de66e8 100644 --- a/lib/client/wallet.js +++ b/lib/client/wallet.js @@ -356,6 +356,21 @@ class WalletClient extends Client { return this.del(`/wallet/${id}/tx/${hash}`); } + /** + * @param {Number} id + * @param {Hash} hash + * @param {Number?} rate + * @param {Bool?} sign + * @param {String?} passphrase + * @returns {Promise} + */ + + bumpTX(id, hash, rate, sign, passphrase) { + return this.post( + `/wallet/${id}/bump/${hash}`, + {hash, rate, sign, passphrase}); + } + /** * Create a transaction, fill. * @param {Number} id @@ -832,6 +847,19 @@ class Wallet extends EventEmitter { return this.client.abandon(this.id, hash); } + /** + * Send an RBF replacement to bump fee + * @param {Hash} hash + * @param {Number?} rate + * @param {Bool?} sign + * @param {String?} passphrase + * @returns {Promise} + */ + + bumpTX(hash, rate, sign, passphrase) { + return this.client.bumpTX(this.id, hash); + } + /** * Create a transaction, fill. * @param {Object} options diff --git a/lib/mempool/mempool.js b/lib/mempool/mempool.js index 090ff470b..ba9ea72e9 100644 --- a/lib/mempool/mempool.js +++ b/lib/mempool/mempool.js @@ -513,8 +513,17 @@ class Mempool extends EventEmitter { if (!entry) return false; - if (this.isSpent(hash, index)) - return false; + const spender = this.getSpent(hash, index); + // If the TX that spent this coin signals + // replaceability, then even though it is "spent" + // we still "have" it. + if (spender) { + if (spender.tx.isRBF()) { + return true; + } else { + return false; + } + } if (index >= entry.tx.outputs.length) return false; @@ -817,15 +826,19 @@ class Mempool extends EventEmitter { 0); } - // Quick and dirty test to verify we're - // not double-spending an output in the - // mempool. - if (this.isDoubleSpend(tx)) { - this.emit('conflict', tx); - throw new VerifyError(tx, - 'duplicate', - 'bad-txns-inputs-spent', - 0); + if (!this.options.replaceByFee) { + // Quick and dirty test to verify we're + // not double-spending an output in the + // mempool. + // If we are verifying RBF, we will do a + // slow and clean test later... + if (this.isDoubleSpend(tx)) { + this.emit('conflict', tx); + throw new VerifyError(tx, + 'duplicate', + 'bad-txns-inputs-spent', + 0); + } } // Get coin viewpoint as it @@ -844,7 +857,20 @@ class Mempool extends EventEmitter { const entry = MempoolEntry.fromTX(tx, view, height); // Contextual verification. - await this.verify(entry, view); + const conflicts = await this.verify(entry, view); + + // RBF only: Remove conflicting TXs and their descendants + if (!this.options.replaceByFee) { + assert(conflicts.length === 0); + } else { + for (const conflict of conflicts) { + this.logger.debug( + 'Replacing tx %h with %h', + conflict.tx.hash(), + tx.hash()); + this.evictEntry(conflict); + } + } // Add and index the entry. await this.addEntry(entry, view); @@ -862,10 +888,11 @@ class Mempool extends EventEmitter { /** * Verify a transaction with mempool standards. + * Returns an array of conflicting TXs * @method * @param {MempoolEntry} entry * @param {CoinView} view - * @returns {Promise} + * @returns {Promise} - Returns {@link MempoolEntry[]} */ async verify(entry, view) { @@ -953,6 +980,16 @@ class Mempool extends EventEmitter { 0); } + // If we reject RBF transactions altogether we can skip these checks, + // because incoming conflicts are already rejected as double spends. + let conflicts = []; + if (this.options.replaceByFee) + conflicts = this.getConflicts(tx); + + if (conflicts.length) { + this.verifyRBF(entry, view, conflicts); + } + // Contextual sanity checks. const [fee, reason, score] = tx.checkInputs(view, height); @@ -995,6 +1032,111 @@ class Mempool extends EventEmitter { assert(await this.verifyResult(tx, view, flags), 'BUG: Verify failed for mandatory but not standard.'); } + + return conflicts; + } + + /** + * Verify BIP 125 replaceability + * https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + * Also see documented discrepancy between specification and implementation + * regarding Rule #1: + * https://www.cve.org/CVERecord?id=CVE-2021-31876 + * Bitcoin Core policy may also change regularly, see: + * https://github.com/bitcoin/bitcoin/blob/master/doc/policy/ + * mempool-replacements.md + * @method + * @param {MempoolEntry} entry + * @param {CoinView} view + * @param {MempoolEntry[]} conflicts + * @returns {Boolean} + */ + + verifyRBF(entry, view, conflicts) { + const tx = entry.tx; + + let conflictingFees = 0; + let totalEvictions = 0; + + for (const conflict of conflicts) { + // Rule #1 - as implemented in Bitcoin Core (not BIP 125) + // We do not replace TXs that do not signal opt-in RBF + if (!conflict.tx.isRBF()) { + throw new VerifyError(tx, + 'duplicate', + 'bad-txns-inputs-spent', + 0); + } + + // Rule #2 + // Replacement TXs can not consume any unconfirmed outputs that were not + // already included in the original transactions. They can only add + // confirmed UTXO, saving the trouble of checking new mempool ancestors. + // This also prevents the case of replacing our own inputs. + PREVOUTS: for (const {prevout} of tx.inputs) { + // Confirmed inputs are not relevant to Rule #2 + if (view.getHeight(prevout) > 0) { + continue; + } + + // Any unconfirmed inputs spent by the replacement + // must also be spent by the conflict. + for (const {prevout: conflictPrevout} of conflict.tx.inputs) { + if (conflictPrevout.hash.equals(prevout.hash) + && conflictPrevout.index === prevout.index) { + // Once we find a match we don't need to check any more + // of the conflict's inputs. Continue by testing the + // next input in the potential replacement tx. + continue PREVOUTS; + } + } + + // Rule #2 violation + throw new VerifyError(tx, + 'nonstandard', + 'replacement-adds-unconfirmed', + 0); + } + + conflictingFees += conflict.descFee; + totalEvictions += this.countDescendants(conflict) + 1; + + // Rule #5 + // Replacement TXs must not evict/replace more than 100 descendants + if (totalEvictions > policy.MEMPOOL_MAX_REPLACEMENTS) { + throw new VerifyError(tx, + 'nonstandard', + 'too many potential replacements', + 0); + } + + // Rule #6 - currently implemented by Bitcoin Core but not in BIP 125 + // The replacement transaction must have a higher feerate than its + // direct conflicts. + // We compare using deltaFee to account for prioritiseTransaction() + if (entry.getDeltaRate() <= conflict.getDeltaRate()) { + throw new VerifyError(tx, + 'insufficientfee', + 'insufficient fee: must not reduce total mempool fee rate', + 0); + } + } + + // Rule #3 and #4 + // Replacement TX must pay for the total fees of all descendant + // transactions that will be evicted if an ancestor is replaced. + // Thus the replacement "pays for the bandwidth" of all the conflicts. + // Once the conflicts are all paid for, the replacement TX fee + // still needs to cover it's own bandwidth. + // We use our policy min fee, Bitcoin Core has a separate incremental fee + const minFee = policy.getMinFee(entry.size, this.options.minRelay); + const feeRemaining = entry.deltaFee - conflictingFees; + if (feeRemaining < minFee) { + throw new VerifyError(tx, + 'insufficientfee', + 'insufficient fee: must pay for fees including conflicts', + 0); + } } /** @@ -1425,9 +1567,22 @@ class Mempool extends EventEmitter { const missing = []; for (const {prevout} of tx.inputs) { + // We assume the view came from mempool.getCoinView() + // which will only exclude spent coins if the spender + // is not replaceable if (view.hasEntry(prevout)) continue; + // If this prevout is missing from the view because + // it has a spender in the mempool it's not an orphan, + // it's a double-spend. + if (this.isSpent(prevout.hash, prevout.index)) { + throw new VerifyError(tx, + 'duplicate', + 'bad-txns-inputs-spent', + 0); + } + if (this.hasReject(prevout.hash)) { this.logger.debug( 'Not storing orphan %h (rejected parents).', @@ -1659,13 +1814,35 @@ class Mempool extends EventEmitter { isDoubleSpend(tx) { for (const {prevout} of tx.inputs) { const {hash, index} = prevout; - if (this.isSpent(hash, index)) + const conflict = this.getSpent(hash, index); + + if (conflict) return true; } return false; } + /** + * Get an array of all transactions currently in the mempool that + * spend one or more of the same outputs as an incoming transaction. + * @param {TX} tx + * @returns {Promise} - Returns (@link MempoolEntry[]}. + */ + + getConflicts(tx) { + const conflicts = new Set(); + + for (const { prevout: { hash, index } } of tx.inputs) { + const conflict = this.getSpent(hash, index); + + if (conflict) { + conflicts.add(conflict); + } + } + return Array.from(conflicts); + } + /** * Get coin viewpoint (lock). * Note: this does not return @@ -1713,7 +1890,7 @@ class Mempool extends EventEmitter { } /** - * Get coin viewpoint (no lock). + * Get coin viewpoint as it pertains to mempool (no lock). * @method * @param {TX} tx * @returns {Promise} - Returns {@link CoinView}. @@ -1724,14 +1901,41 @@ class Mempool extends EventEmitter { for (const {prevout} of tx.inputs) { const {hash, index} = prevout; - const tx = this.getTX(hash); - if (tx) { - if (this.hasCoin(hash, index)) - view.addIndex(tx, index, -1); - continue; + // First check mempool for the TX + // that created the coin we need for the view + const parentTX = this.getTX(hash); + + if (parentTX) { + // Does parent TX even have the output index? + if (index >= parentTX.outputs.length) + continue; + + // Check to see if this output is already spent + // by another unconfirmed TX in the mempool + const spender = this.getSpent(hash, index); + + if (!spender) { + // Parent TX output is unspent, add it to the view + view.addIndex(parentTX, index, -1); + continue; + } + + // If the spender TX signals opt-in RBF, then we + // can still consider the parent TX output as spendable. + // Note that at this point we are not fully checking all the + // replaceability rules and the tx initally passed to this + // function may not be actually allowed to replace the spender. + if (spender.tx.isRBF()) { + // If any TX in the mempool signals opt-in RBF + // we already know this option is set. + assert(this.options.replaceByFee); + view.addIndex(parentTX, index, -1); + } } + // Parent TX is not in mempool. + // Check the chain (UTXO set) for the coin. const coin = await this.chain.readCoin(prevout); if (coin) @@ -1943,7 +2147,7 @@ class MempoolOptions { this.rejectAbsurdFees = true; this.prematureWitness = false; this.paranoidChecks = false; - this.replaceByFee = false; + this.replaceByFee = true; this.maxSize = policy.MEMPOOL_MAX_SIZE; this.maxOrphans = policy.MEMPOOL_MAX_ORPHANS; diff --git a/lib/primitives/mtx.js b/lib/primitives/mtx.js index 3fc58da0c..68716fe7f 100644 --- a/lib/primitives/mtx.js +++ b/lib/primitives/mtx.js @@ -1227,11 +1227,25 @@ class MTX extends TX { async fund(coins, options) { assert(options, 'Options are required.'); assert(options.changeAddress, 'Change address is required.'); - assert(this.inputs.length === 0, 'TX is already funded.'); + if (!options.bumpFee) + assert(this.inputs.length === 0, 'TX is already funded.'); // Select necessary coins. const select = await this.selectCoins(coins, options); + // Bump Fee mode: + // We want the coin selector to ignore the values of + // all existing inputs and outputs, but still consider their size. + // Inside the coin selector this is done by making a copy of the TX + // and then setting the total output value to 0 + // (input value is already ignored). + // The coin selector will add coins to cover the fee on the entire TX + // including paying for any inputs it adds. + // Now that coin selection is done, we add back in the fee paid by + // the original existing inputs and outputs so we can set the change value. + if (options.bumpFee) + select.fee += this.getFee(); + // Add coins to transaction. for (const coin of select.chosen) this.addCoin(coin); diff --git a/lib/protocol/policy.js b/lib/protocol/policy.js index 39fce7484..85c8859d4 100644 --- a/lib/protocol/policy.js +++ b/lib/protocol/policy.js @@ -172,6 +172,13 @@ exports.MEMPOOL_EXPIRY_TIME = 72 * 60 * 60; exports.MEMPOOL_MAX_ORPHANS = 100; +/** + * BIP125 Rule #5 + * Maximum number of transactions that can be replaced. + */ + +exports.MEMPOOL_MAX_REPLACEMENTS = 100; + /** * Minimum block size to create. Block will be * filled with free transactions until block diff --git a/lib/wallet/coinselector.js b/lib/wallet/coinselector.js index ee57bff92..bd50af894 100644 --- a/lib/wallet/coinselector.js +++ b/lib/wallet/coinselector.js @@ -71,6 +71,7 @@ class CoinSelector { this.changeAddress = null; this.inputs = new BufferMap(); this.useSelectEstimate = false; + this.bumpFee = false; // Needed for size estimation. this.getAccount = null; @@ -158,6 +159,11 @@ class CoinSelector { this.useSelectEstimate = options.useSelectEstimate; } + if (options.bumpFee != null) { + assert(typeof options.bumpFee === 'boolean'); + this.bumpFee = options.bumpFee; + } + if (options.changeAddress) { const addr = options.changeAddress; if (typeof addr === 'string') { @@ -209,7 +215,7 @@ class CoinSelector { async init(coins) { this.coins = coins.slice(); - this.outputValue = this.tx.getOutputValue(); + this.outputValue = this.bumpFee ? 0 : this.tx.getOutputValue(); this.chosen = []; this.change = 0; this.fee = CoinSelector.MIN_FEE; diff --git a/lib/wallet/http.js b/lib/wallet/http.js index b3ec7d89a..6a3749a22 100644 --- a/lib/wallet/http.js +++ b/lib/wallet/http.js @@ -447,6 +447,7 @@ class HTTP extends Server { smart: valid.bool('smart'), account: valid.str('account'), sort: valid.bool('sort'), + replaceable: valid.bool('replaceable'), subtractFee: valid.bool('subtractFee'), subtractIndex: valid.i32('subtractIndex'), depth: valid.u32(['confirmations', 'depth']), @@ -495,6 +496,7 @@ class HTTP extends Server { smart: valid.bool('smart'), account: valid.str('account'), sort: valid.bool('sort'), + replaceable: valid.bool('replaceable'), subtractFee: valid.bool('subtractFee'), subtractIndex: valid.i32('subtractIndex'), depth: valid.u32(['confirmations', 'depth']), @@ -546,6 +548,23 @@ class HTTP extends Server { res.json(200, tx.getJSON(this.network)); }); + // Create replacement fee-bump TX + this.post('/wallet/:id/bump/:hash', async (req, res) => { + const valid = Validator.fromRequest(req); + const hash = valid.brhash('hash'); + enforce(hash, 'txid is required.'); + let rate = valid.u64('rate'); + if (!rate) + rate = this.network.minRelay; + const sign = valid.bool('sign', true); + const passphrase = valid.str('passphrase'); + + // Bump fee by reducing change output value. + const tx = await req.wallet.bumpTXFee(hash, rate, sign, passphrase); + + res.json(200, tx.getJSON(this.network)); + }); + // Zap Wallet TXs this.post('/wallet/:id/zap', async (req, res) => { const valid = Validator.fromRequest(req); diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index 3703deaf9..21ed2b655 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -1143,7 +1143,8 @@ class Wallet extends EventEmitter { rate: rate, maxFee: options.maxFee, useSelectEstimate: options.useSelectEstimate, - getAccount: this.getAccountByAddress.bind(this) + getAccount: this.getAccountByAddress.bind(this), + bumpFee: options.bumpFee }); assert(mtx.getFee() <= CoinSelector.MAX_FEE, 'TX exceeds MAX_FEE.'); @@ -1172,6 +1173,7 @@ class Wallet extends EventEmitter { * @param {Object} options - See {@link Wallet#fund options}. * @param {Object[]} options.outputs - See {@link MTX#addOutput}. * @param {Boolean} options.sort - Sort inputs and outputs (BIP69). + * @param {Boolean} options.replaceable - Signal BIP 125 replaceability * @param {Boolean} options.template - Build scripts for inputs. * @param {Number} options.locktime - TX locktime * @param {Boolean?} force - Bypass the lock. @@ -1215,6 +1217,10 @@ class Wallet extends EventEmitter { if (options.locktime != null) mtx.setLocktime(options.locktime); + // Opt in BIP 125 replaceability + if (options.replaceable !== false) + mtx.inputs[0].sequence = 0xfffffffd; + // Consensus sanity checks. assert(mtx.isSane(), 'TX failed sanity check.'); assert(mtx.verifyInputs(this.wdb.state.height + 1), @@ -1293,14 +1299,17 @@ class Wallet extends EventEmitter { /** * Intentionally double-spend outputs by * increasing fee for an existing transaction. + * Complies with BIP-125 replace-by-fee * @param {Hash} hash * @param {Rate} rate + * @param {Boolean?} sign - sign with wallet * @param {(String|Buffer)?} passphrase * @returns {Promise} - Returns {@link TX}. */ - async increaseFee(hash, rate, passphrase) { + async bumpTXFee(hash, rate, sign, passphrase) { assert((rate >>> 0) === rate, 'Rate must be a number.'); + assert(rate >= this.network.minRelay, 'Fee rate is below minimum.'); const wtx = await this.getTX(hash); @@ -1312,33 +1321,35 @@ class Wallet extends EventEmitter { const tx = wtx.tx; - if (tx.isCoinbase()) - throw new Error('Transaction is a coinbase.'); + // Require explicit opt-in RBF signalling (for now) BIP 125 rule #1 + if (!tx.isRBF()) + throw new Error('Transaction does not signal opt-in replace-by-fee.'); + + // No child spends + // Covers BIP 125 rule #5 and makes rule #3 fee easier to compute + for (let index = 0; index < tx.outputs.length; index++) { + if (await this.txdb.isSpent(wtx.hash, index)) + throw new Error('Transaction has descendants in the wallet.'); + } const view = await this.getSpentView(tx); if (!tx.hasCoins(view)) throw new Error('Not all coins available.'); - const oldFee = tx.getFee(view); - - let fee = tx.getMinFee(null, rate); - - if (fee > CoinSelector.MAX_FEE) - fee = CoinSelector.MAX_FEE; - - if (oldFee >= fee) - throw new Error('Fee is not increasing.'); - + // Start new TX as copy of old TX + // Covers BIP 125 rule #2 const mtx = MTX.fromTX(tx); mtx.view = view; + // Remove all existing signatures for (const input of mtx.inputs) { input.script.clear(); input.witness.clear(); } - let change; + // Find the original TX change output + let change = null; for (let i = 0; i < mtx.outputs.length; i++) { const output = mtx.outputs[i]; const addr = output.getAddress(); @@ -1352,45 +1363,112 @@ class Wallet extends EventEmitter { continue; if (path.branch === 1) { + // Edge case if (for example) a customer requests a withdrawl + // to one of our own change addresses. + // TODO: Allow bumpTXFee() caller to specify an output to reduce. + if (change) + throw new Error('Found more than one change address.'); change = output; mtx.changeIndex = i; - break; } } - if (!change) - throw new Error('No change output.'); + let changeAdjust = 0; + if (change) { + const originalFee = mtx.getFee(); + const originalChangeValue = change.value; + + // Compute fee for TX with signatures at given rate + const currentFee = tx.getMinFee(null, rate); + + // Pay for our own current fee: BIP 125 rule #4 + change.value -= currentFee; + + // If change output is below dust, + // give it all to fee (remove change output) + if (change.isDust()) { + mtx.outputs.splice(mtx.changeIndex, 1); + mtx.changeIndex = -1; + + if (change.value < 0) { + // The existing change output wasn't enough to pay the RBF fee. + // Remove it completely and add more inputs and maybe a new change. + change = null; + // We will subtract the rule 3 (original tx) fee after re-funding. + changeAdjust = originalFee - originalChangeValue; + } + } + } - change.value += oldFee; + // Note this is not just an "else" because the prior code block + // might have removed an existing change. + if (!change) { + // We need to add more inputs (and maybe a change output) to increase the + // fee. Since the original inputs and outputs already paid for + // their own fee (rule #3) all we have to do is pay for this + // new TX's fee (rule #4). + await this.fund( + mtx, + { + bumpFee: true, // set bump fee mode to ignore existing output values + rate, // rate in s/kvB for the rule 4 fee + depth: 1 // replacements can not add new unconfirmed coins + }); + + if (changeAdjust) { + // This edge case can be avoided with a more complex coin selector + assert( + mtx.changeIndex !== -1, + 'RBF re-funding of changeless tx resulted in changeless solution'); + // Add back in the value from the original change output we removed + mtx.outputs[mtx.changeIndex].value -= changeAdjust; + } + } - if (mtx.getFee() !== 0) - throw new Error('Arithmetic error for change.'); + const oldRate = tx.getRate(view); - change.value -= fee; + if (!sign) { + // Estimate final fee after signing for rule 6 check + const estSize = + await mtx.estimateSize(this.getAccountByAddress.bind(this)); + const fee = mtx.getFee(); + const estRate = policy.getRate(estSize, fee); - if (change.value < 0) - throw new Error('Fee is too high.'); + if (estRate <= oldRate) { + throw new Error(`Provided fee rate of ${rate} s/kvB results in ` + + `insufficient estimated total fee rate (${estRate}) ` + + `to replace original (${oldRate})`); + } - if (change.isDust()) { - mtx.outputs.splice(mtx.changeIndex, 1); - mtx.changeIndex = -1; + return mtx.toTX(); } await this.sign(mtx, passphrase); if (!mtx.isSigned()) - throw new Error('TX could not be fully signed.'); + throw new Error('Replacement TX could not be fully signed.'); + + const newRate = mtx.getRate(); + if (newRate <= oldRate) { + throw new Error(`Provided fee rate of ${rate} s/kvB results in ` + + `insufficient total fee rate (${newRate}) ` + + `to replace original (${oldRate})`); + } const ntx = mtx.toTX(); this.logger.debug( - 'Increasing fee for wallet tx (%s): %h', - this.id, ntx.hash()); - - await this.wdb.addTX(ntx); - await this.wdb.send(ntx); + 'Bumping fee for wallet tx (%s): replacing %h with %h', + this.id, + wtx.hash, + ntx.hash()); + + if (sign) { + await this.wdb.addTX(ntx); + await this.wdb.send(ntx); + } - return ntx; + return mtx; } /** diff --git a/test/mempool-test.js b/test/mempool-test.js index be0dd998e..0f83adfff 100644 --- a/test/mempool-test.js +++ b/test/mempool-test.js @@ -1093,4 +1093,609 @@ describe('Mempool', function() { throw err; }); }); + + describe('Replace-by-fee', function () { + const blocks = new BlockStore({ + memory: true + }); + + const chain = new Chain({ + memory: true, + blocks + }); + + const mempool = new Mempool({ + chain, + memory: true + }); + + before(async () => { + await blocks.open(); + await mempool.open(); + await chain.open(); + }); + + after(async () => { + await chain.close(); + await mempool.close(); + await blocks.close(); + }); + + beforeEach(async () => { + await mempool.reset(); + assert.strictEqual(mempool.map.size, 0); + }); + + // Number of coins available in + // chaincoins (100k satoshi per coin). + const N = 100; + const chaincoins = new MemWallet(); + const wallet = new MemWallet(); + + it('should create coins in chain', async () => { + const mtx = new MTX(); + mtx.addInput(new Input()); + + for (let i = 0; i < N; i++) { + const addr = chaincoins.createReceive().getAddress(); + mtx.addOutput(addr, 100000); + } + + const cb = mtx.toTX(); + const block = await getMockBlock(chain, [cb], false); + const entry = await chain.add(block, VERIFY_NONE); + + await mempool._addBlock(entry, block.txs); + + // Add 100 blocks so we don't get + // premature spend of coinbase. + for (let i = 0; i < 100; i++) { + const block = await getMockBlock(chain); + const entry = await chain.add(block, VERIFY_NONE); + + await mempool._addBlock(entry, block.txs); + } + + chaincoins.addTX(cb); + }); + + it('should not accept RBF tx', async() => { + mempool.options.replaceByFee = false; + + const mtx = new MTX(); + const coin = chaincoins.getCoins()[0]; + mtx.addCoin(coin); + mtx.inputs[0].sequence = 0xfffffffd; + + const addr = wallet.createReceive().getAddress(); + mtx.addOutput(addr, 90000); + + chaincoins.sign(mtx); + + assert(mtx.verify()); + const tx = mtx.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx); + }, { + type: 'VerifyError', + reason: 'replace-by-fee' + }); + + assert(!mempool.hasCoin(tx.hash(), 0)); + assert.strictEqual(mempool.map.size, 0); + }); + + it('should accept RBF tx with RBF option enabled', async() => { + mempool.options.replaceByFee = true; + + const mtx = new MTX(); + const coin = chaincoins.getCoins()[0]; + mtx.addCoin(coin); + mtx.inputs[0].sequence = 0xfffffffd; + + const addr = wallet.createReceive().getAddress(); + mtx.addOutput(addr, coin.value - 1000); + + chaincoins.sign(mtx); + + assert(mtx.verify()); + const tx = mtx.toTX(); + + await mempool.addTX(tx); + + assert(mempool.hasCoin(tx.hash(), 0)); + assert.strictEqual(mempool.map.size, 1); + }); + + it('should reject double spend without RBF from mempool', async() => { + mempool.options.replaceByFee = true; + + const coin = chaincoins.getCoins()[0]; + + const mtx1 = new MTX(); + const mtx2 = new MTX(); + mtx1.addCoin(coin); + mtx2.addCoin(coin); + + const addr1 = wallet.createReceive().getAddress(); + mtx1.addOutput(addr1, coin.value - 1000); + + const addr2 = wallet.createReceive().getAddress(); + mtx2.addOutput(addr2, coin.value - 1000); + + chaincoins.sign(mtx1); + chaincoins.sign(mtx2); + + assert(mtx1.verify()); + assert(mtx2.verify()); + const tx1 = mtx1.toTX(); + const tx2 = mtx2.toTX(); + + assert(!tx1.isRBF()); + + await mempool.addTX(tx1); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'bad-txns-inputs-spent' + }); + + assert(mempool.hasCoin(tx1.hash(), 0)); + assert.strictEqual(mempool.map.size, 1); + }); + + it('should reject replacement with lower fee rate', async() => { + mempool.options.replaceByFee = true; + + const coin = chaincoins.getCoins()[0]; + + const mtx1 = new MTX(); + const mtx2 = new MTX(); + mtx1.addCoin(coin); + mtx2.addCoin(coin); + + mtx1.inputs[0].sequence = 0xfffffffd; + + const addr1 = wallet.createReceive().getAddress(); + mtx1.addOutput(addr1, coin.value - 1000); // 1000 satoshi fee + + const addr2 = wallet.createReceive().getAddress(); + mtx2.addOutput(addr2, coin.value - 900); // 900 satoshi fee + + chaincoins.sign(mtx1); + chaincoins.sign(mtx2); + + assert(mtx1.verify()); + assert(mtx2.verify()); + const tx1 = mtx1.toTX(); + const tx2 = mtx2.toTX(); + + assert(tx1.isRBF()); + + await mempool.addTX(tx1); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'insufficient fee: must not reduce total mempool fee rate' + }); + + // Try again with higher fee + const mtx3 = new MTX(); + mtx3.addCoin(coin); + mtx3.addOutput(addr2, coin.value - 1200); // 1200 satoshi fee + chaincoins.sign(mtx3); + assert(mtx3.verify()); + const tx3 = mtx3.toTX(); + + await mempool.addTX(tx3); + + // tx1 has been replaced by tx3 + assert(!mempool.has(tx1.hash())); + assert(mempool.has(tx3.hash())); + }); + + it('should reject replacement that doesnt pay all child fees', async() => { + mempool.options.replaceByFee = true; + + const addr1 = chaincoins.createReceive().getAddress(); + const addr2 = wallet.createReceive().getAddress(); + const originalCoin = chaincoins.getCoins()[0]; + let coin = originalCoin; + + // Generate chain of 10 transactions, each paying 1000 sat fee + const childHashes = []; + for (let i = 0; i < 10; i++) { + const mtx = new MTX(); + mtx.addCoin(coin); + mtx.inputs[0].sequence = 0xfffffffd; + mtx.addOutput(addr1, coin.value - 1000); + chaincoins.sign(mtx); + assert(mtx.verify()); + const tx = mtx.toTX(); + await mempool.addTX(tx); + + childHashes.push(tx.hash()); + + coin = Coin.fromTX(tx, 0, -1); + } + + // Pay for all child fees + let fee = 10 * 1000; + + // Pay for its own bandwidth (estimating tx2 size as 200 bytes) + fee += mempool.options.minRelay * 0.2; + + // Attempt to submit a replacement for the initial parent TX + const mtx2 = new MTX(); + mtx2.addCoin(originalCoin); + mtx2.addOutput(addr2, originalCoin.value - fee + 100); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'insufficient fee: must pay for fees including conflicts' + }); + + // Try again with higher fee + const mtx3 = new MTX(); + mtx3.addCoin(originalCoin); + mtx3.addOutput(addr2, originalCoin.value - fee); + chaincoins.sign(mtx3); + assert(mtx3.verify()); + const tx3 = mtx3.toTX(); + + await mempool.addTX(tx3); + + // All child TXs have been replaced by tx3 + for (const hash of childHashes) + assert(!mempool.has(hash)); + assert(mempool.has(tx3.hash())); + }); + + it('should reject replacement including new unconfirmed UTXO', async() => { + // {confirmed coin 1} {confirmed coin 2} + // | | | + // | tx 1 tx 2 {output} + // | | + // | +--------------------------------+ + // | | + // tx 3 is invalid! + + mempool.options.replaceByFee = true; + + const coin1 = chaincoins.getCoins()[0]; + const coin2 = chaincoins.getCoins()[1]; + + // tx 1 spends a confirmed coin + const mtx1 = new MTX(); + mtx1.addCoin(coin1); + mtx1.inputs[0].sequence = 0xfffffffd; + const addr1 = chaincoins.createReceive().getAddress(); + mtx1.addOutput(addr1, coin1.value - 1000); + chaincoins.sign(mtx1); + assert(mtx1.verify()); + const tx1 = mtx1.toTX(); + assert(tx1.isRBF()); + await mempool.addTX(tx1); + + // tx 2 spends a different confirmed coin + const mtx2 = new MTX(); + mtx2.addCoin(coin2); + const addr2 = chaincoins.createReceive().getAddress(); + mtx2.addOutput(addr2, coin2.value - 1000); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + await mempool.addTX(tx2); + + // Attempt to replace tx 1 and include the unconfirmed output of tx 2 + const mtx3 = new MTX(); + mtx3.addCoin(coin1); + const coin3 = Coin.fromTX(tx2, 0, -1); + mtx3.addCoin(coin3); + const addr3 = wallet.createReceive().getAddress(); + // Remember to bump the fee! + mtx3.addOutput(addr3, coin1.value + coin3.value - 2000); + chaincoins.sign(mtx3); + assert(mtx3.verify()); + const tx3 = mtx3.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx3); + }, { + type: 'VerifyError', + reason: 'replacement-adds-unconfirmed' + }); + }); + + it('should reject replacement evicting too many descendants', async() => { + mempool.options.replaceByFee = true; + + const addr1 = chaincoins.createReceive().getAddress(); + const coin0 = chaincoins.getCoins()[0]; + const coin1 = chaincoins.getCoins()[1]; + + // Generate big TX with 100 outputs + const mtx1 = new MTX(); + mtx1.addCoin(coin0); + mtx1.addCoin(coin1); + mtx1.inputs[0].sequence = 0xfffffffd; + const outputValue = (coin0.value / 100) + (coin1.value / 100) - 100; + for (let i = 0; i < 100; i++) + mtx1.addOutput(addr1, outputValue); + + chaincoins.sign(mtx1); + assert(mtx1.verify()); + const tx1 = mtx1.toTX(); + await mempool.addTX(tx1); + + // Spend each of those outputs individually + let tx; + const hashes = []; + for (let i = 0; i < 100; i++) { + const mtx = new MTX(); + const coin = Coin.fromTX(tx1, i, -1); + mtx.addCoin(coin); + mtx.addOutput(addr1, coin.value - 1000); + chaincoins.sign(mtx); + assert(mtx.verify()); + tx = mtx.toTX(); + + hashes.push(tx.hash()); + + await mempool.addTX(tx); + } + + // Attempt to evict the whole batch by replacing the first TX (tx1) + const mtx2 = new MTX(); + mtx2.addCoin(coin0); + mtx2.addCoin(coin1); + // Send with massive fee to pay for 100 evicted TXs + mtx2.addOutput(addr1, 5000); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'too many potential replacements' + }); + + // Manually remove one of the descendants in advance + const entry = mempool.getEntry(tx.hash()); + mempool.evictEntry(entry); + + // Send back the same TX + await mempool.addTX(tx2); + + // Entire mess has been replaced by tx2 + assert(mempool.has(tx2.hash())); + assert(!mempool.has(tx1.hash())); + for (const hash of hashes) + assert(!mempool.has(hash)); + }); + + it('should accept replacement spending an unconfirmed output', async () => { + // {confirmed coin 1} + // | + // tx 0 {output} + // | | + // tx 1 | + // | + // tx 2 + + mempool.options.replaceByFee = true; + + const addr1 = chaincoins.createReceive().getAddress(); + const coin0 = chaincoins.getCoins()[0]; + + // Generate parent tx 0 + const mtx0 = new MTX(); + mtx0.addCoin(coin0); + mtx0.addOutput(addr1, coin0.value - 200); + chaincoins.sign(mtx0); + assert(mtx0.verify()); + const tx0 = mtx0.toTX(); + await mempool.addTX(tx0); + + // Spend unconfirmed output to replaceable child tx 1 + const mtx1 = new MTX(); + const coin1 = Coin.fromTX(tx0, 0, -1); + mtx1.addCoin(coin1); + mtx1.inputs[0].sequence = 0xfffffffd; + mtx1.addOutput(addr1, coin1.value - 200); + chaincoins.sign(mtx1); + assert(mtx1.verify()); + const tx1 = mtx1.toTX(); + await mempool.addTX(tx1); + + // Send replacement tx 2 + const mtx2 = new MTX(); + mtx2.addCoin(coin1); + mtx2.addOutput(addr1, coin1.value - 400); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + await mempool.addTX(tx2); + + // Unconfirmed parent tx 0 and replacement tx 2 are in mempool together + assert(mempool.has(tx0.hash())); + assert(!mempool.has(tx1.hash())); + assert(mempool.has(tx2.hash())); + }); + + it('should not accept replacement for non-rbf spender of unconfirmed utxo', async () => { + mempool.options.replaceByFee = true; + + const addr1 = chaincoins.createReceive().getAddress(); + const coin0 = chaincoins.getCoins()[0]; + + // Generate parent TX + const mtx0 = new MTX(); + mtx0.addCoin(coin0); + mtx0.addOutput(addr1, coin0.value - 200); + chaincoins.sign(mtx0); + assert(mtx0.verify()); + const tx0 = mtx0.toTX(); + await mempool.addTX(tx0); + + // Spend unconfirmed output to non-replaceable child + const mtx1 = new MTX(); + const coin1 = Coin.fromTX(tx0, 0, -1); + mtx1.addCoin(coin1); + mtx1.inputs[0].sequence = 0xffffffff; // not replaceable + mtx1.addOutput(addr1, coin1.value - 200); + chaincoins.sign(mtx1); + assert(mtx1.verify()); + const tx1 = mtx1.toTX(); + await mempool.addTX(tx1); + + // Send attempted replacement + const mtx2 = new MTX(); + mtx2.addCoin(coin1); + mtx2.addOutput(addr1, coin1.value - 400); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'bad-txns-inputs-spent' + }); + }); + + it('should not accept replacement that evicts its own inputs', async () => { + // {confirmed coin 1} + // | + // tx 0 {output} + // | | + // | tx 1 {output} + // | | + // | +-------+ + // | | + // tx 2 is invalid! + + mempool.options.replaceByFee = true; + + const addr1 = chaincoins.createReceive().getAddress(); + const coin0 = chaincoins.getCoins()[0]; + + // Generate tx 0 which spends a confirmed coin + const mtx0 = new MTX(); + mtx0.addCoin(coin0); + mtx0.addOutput(addr1, coin0.value - 200); + chaincoins.sign(mtx0); + assert(mtx0.verify()); + const tx0 = mtx0.toTX(); + await mempool.addTX(tx0); + + // Generate tx 1 which spends an output of tx 0 + const mtx1 = new MTX(); + const coin1 = Coin.fromTX(tx0, 0, -1); + mtx1.addCoin(coin1); + mtx1.inputs[0].sequence = 0xfffffffd; + mtx1.addOutput(addr1, coin1.value - 200); + chaincoins.sign(mtx1); + assert(mtx1.verify()); + const tx1 = mtx1.toTX(); + await mempool.addTX(tx1); + + // Send tx 2 which attempts to: + // - replace tx 1 by spending an output of tx 0 + // - ALSO spend an output of tx 1 + // This is obviously invalid because if tx 1 is replaced, + // its output no longer exists so it can not be spent by tx 2. + const mtx2 = new MTX(); + mtx2.addCoin(coin1); + const coin2 = Coin.fromTX(tx1, 0, -1); + mtx2.addCoin(coin2); + mtx2.addOutput(addr1, coin2.value + coin1.value - 1000); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'replacement-adds-unconfirmed' + }); + + assert(mempool.has(tx0.hash())); + assert(mempool.has(tx1.hash())); + assert(!mempool.has(tx2.hash())); + }); + + it('should not accept replacement that does not evict its own inputs', async () => { + // ...because it spends an unconfirmed coin the conflict did not spend. + + // {confirmed coin 1} + // | + // tx 0 {output 0} {output 1} + // | | | + // tx 1 +-------+ | + // | | + // tx 2 is invalid! + + mempool.options.replaceByFee = true; + + const addr1 = chaincoins.createReceive().getAddress(); + const coin0 = chaincoins.getCoins()[0]; + + // Generate tx 0 which spends a confirmed coin and creates two outputs + const mtx0 = new MTX(); + mtx0.addCoin(coin0); + mtx0.addOutput(addr1, parseInt(coin0.value / 2) - 200); + mtx0.addOutput(addr1, parseInt(coin0.value / 2) - 200); + chaincoins.sign(mtx0); + assert(mtx0.verify()); + const tx0 = mtx0.toTX(); + await mempool.addTX(tx0); + + // Generate tx 1 which spends output 0 of tx 0 + const mtx1 = new MTX(); + const coin1 = Coin.fromTX(tx0, 0, -1); + mtx1.addCoin(coin1); + mtx1.inputs[0].sequence = 0xfffffffd; + mtx1.addOutput(addr1, coin1.value - 200); + chaincoins.sign(mtx1); + assert(mtx1.verify()); + const tx1 = mtx1.toTX(); + await mempool.addTX(tx1); + + // Send tx 2 which spends outputs 0 & 1 of tx 0, replacing tx 1 + const mtx2 = new MTX(); + mtx2.addCoin(coin1); + const coin2 = Coin.fromTX(tx0, 1, -1); + mtx2.addCoin(coin2); + mtx2.addOutput(addr1, coin2.value + coin1.value - 1000); + chaincoins.sign(mtx2); + assert(mtx2.verify()); + const tx2 = mtx2.toTX(); + + await assert.rejects(async () => { + await mempool.addTX(tx2); + }, { + type: 'VerifyError', + reason: 'replacement-adds-unconfirmed' + }); + + assert(mempool.has(tx0.hash())); + assert(mempool.has(tx1.hash())); + assert(!mempool.has(tx2.hash())); + }); + }); }); diff --git a/test/util/common.js b/test/util/common.js index b794abbab..8d9b575c0 100644 --- a/test/util/common.js +++ b/test/util/common.js @@ -123,6 +123,50 @@ common.forValue = async function(obj, key, val, timeout = 30000) { }); }; +common.forEvent = async function forEvent(obj, name, count = 1, timeout = 5000) { + assert(typeof obj === 'object'); + assert(typeof name === 'string'); + assert(typeof count === 'number'); + assert(typeof timeout === 'number'); + + let countdown = count; + const events = []; + + return new Promise((resolve, reject) => { + let timeoutHandler, listener; + + const cleanup = function cleanup() { + clearTimeout(timeoutHandler); + obj.removeListener(name, listener); + }; + + listener = function listener(...args) { + events.push({ + event: name, + values: [...args] + }); + + countdown--; + if (countdown === 0) { + cleanup(); + resolve(events); + return; + } + }; + + timeoutHandler = setTimeout(() => { + cleanup(); + const msg = `Timeout waiting for event ${name} ` + + `(received ${count - countdown}/${count})`; + + reject(new Error(msg)); + return; + }, timeout); + + obj.on(name, listener); + }); +}; + function parseUndo(data) { const br = bio.read(data); const items = []; diff --git a/test/wallet-rbf-test.js b/test/wallet-rbf-test.js new file mode 100644 index 000000000..087b6e3d5 --- /dev/null +++ b/test/wallet-rbf-test.js @@ -0,0 +1,317 @@ +/* eslint-env mocha */ +/* eslint prefer-arrow-callback: "off" */ + +'use strict'; + +const assert = require('bsert'); +const {forEvent} = require('./util/common'); +const FullNode = require('../lib/node/fullnode'); +const MTX = require('../lib/primitives/mtx'); + +const node = new FullNode({ + network: 'regtest', + plugins: [require('../lib/wallet/plugin')] +}); + +let alice = null; +let bob = null; +let aliceReceive = null; +let bobReceive = null; +const {wdb} = node.require('walletdb'); + +describe('Wallet RBF', function () { + before(async () => { + await node.open(); + }); + + after(async () => { + await node.close(); + }); + + it('should create and fund wallet', async () => { + alice = await wdb.create({id: 'alice'}); + bob = await wdb.create({id: 'bob'}); + + aliceReceive = (await alice.receiveAddress()).toString('regtest'); + bobReceive = (await bob.receiveAddress()).toString('regtest'); + + await node.rpc.generateToAddress([110, aliceReceive]); + + const aliceBal = await alice.getBalance(); + assert.strictEqual(aliceBal.confirmed, 110 * 50e8); + + const bobBal = await bob.getBalance(); + assert.strictEqual(bobBal.confirmed, 0); + }); + + it('should not replace missing tx', async () => { + const dummyHash = Buffer.alloc(32, 0x10); + assert.rejects(async () => { + await alice.bumpTXFee(dummyHash, 1000 /* satoshis per kvB */, true, null); + }, { + message: 'Transaction not found.' + }); + }); + + it('should not replace confirmed tx', async () => { + const txs = await alice.getHistory(); + const cb = txs[0]; + assert.rejects(async () => { + await alice.bumpTXFee(cb.hash, 1000 /* satoshis per kvB */, true, null); + }, { + message: 'Transaction is confirmed.' + }); + }); + + it('should not replace a non-replaceable tx', async () => { + const tx = await alice.send({ + outputs: [{ + address: aliceReceive, + value: 1e8 + }], + replaceable: false + }); + + assert(!tx.isRBF()); + + await forEvent(node.mempool, 'tx'); + assert(node.mempool.hasEntry(tx.hash())); + + assert.rejects(async () => { + await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, true, null); + }, { + message: 'Transaction does not signal opt-in replace-by-fee.' + }); + }); + + it('should not replace a wallet tx with child spends', async () => { + const tx1 = await alice.send({ + outputs: [{ + address: aliceReceive, + value: 1e8 + }] + }); + + assert(tx1.isRBF()); + + await forEvent(node.mempool, 'tx'); + assert(node.mempool.hasEntry(tx1.hash())); + + const mtx2 = new MTX(); + mtx2.addTX(tx1, 0); + mtx2.addOutput(aliceReceive, 1e8 - 1000); + mtx2.inputs[0].sequence = 0xfffffffd; + await alice.sign(mtx2); + const tx2 = mtx2.toTX(); + await wdb.addTX(tx2); + await wdb.send(tx2); + + assert(tx2.isRBF()); + + await forEvent(node.mempool, 'tx'); + assert(node.mempool.hasEntry(tx2.hash())); + + assert.rejects(async () => { + await alice.bumpTXFee(tx1.hash(), 1000 /* satoshis per kvB */, true, null); + }, { + message: 'Transaction has descendants in the wallet.' + }); + }); + + it('should replace a replaceable tx', async () => { + const tx = await alice.send({ + outputs: [{ + address: bobReceive, + value: 1e8 + }], + replaceable: true + }); + + assert(tx.isRBF()); + + await forEvent(node.mempool, 'tx'); + assert(node.mempool.has(tx.hash())); + + const rtx = await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, true, null); + + await forEvent(node.mempool, 'tx'); + assert(!node.mempool.hasEntry(tx.hash())); + assert(node.mempool.hasEntry(rtx.hash())); + }); + + it('should only have paid Bob once', async () => { + let bobBal = await bob.getBalance(); + assert.strictEqual(bobBal.unconfirmed, 1e8); + + await node.rpc.generateToAddress([1, aliceReceive]); + bobBal = await bob.getBalance(); + assert.strictEqual(bobBal.confirmed, 1e8); + }); + + it('should not send replacement if original has more than one change address', async () => { + const changeAddr = (await alice.createChange()).getAddress('string'); + const tx = await alice.send({ + outputs: [{ + address: changeAddr, + value: 1e8 + }], + replaceable: true + }); + await forEvent(node.mempool, 'tx'); + + assert.rejects(async () => { + await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, true, null); + }, { + message: 'Found more than one change address.' + }); + await node.rpc.generateToAddress([1, aliceReceive]); + }); + + it('should not send replacement with too-low fee rate', async () => { + const tx = await alice.send({ + outputs: [{ + address: bobReceive, + value: 1e8 + }], + replaceable: true, + rate: 100000 + }); + await forEvent(node.mempool, 'tx'); + + assert.rejects(async () => { + // Try a fee rate below minRelay (1000) + await alice.bumpTXFee(tx.hash(), 999 /* satoshis per kvB */, true, null); + }, { + message: 'Fee rate is below minimum.' + }); + await node.rpc.generateToAddress([1, aliceReceive]); + }); + + it('should bump a tx with no change by adding new in/out pair', async () => { + const coins = await alice.getCoins(); + let coin; + for (coin of coins) { + if (!coin.coinbase) + break; + } + const mtx = new MTX(); + mtx.addCoin(coin); + mtx.addOutput(bobReceive, coin.value - 200); + mtx.inputs[0].sequence = 0xfffffffd; + await alice.sign(mtx); + const tx = mtx.toTX(); + assert.strictEqual(tx.inputs.length, 1); + assert.strictEqual(tx.outputs.length, 1); + await alice.wdb.addTX(tx); + await alice.wdb.send(tx); + await forEvent(node.mempool, 'tx'); + + const rtx = await alice.bumpTXFee(tx.hash(), 2000 /* satoshis per kvB */, true, null); + assert.strictEqual(rtx.inputs.length, 2); + assert.strictEqual(rtx.outputs.length, 2); + assert(rtx.getRate() >= 2000 && rtx.getRate() < 3000); + + await forEvent(node.mempool, 'tx'); + assert(!node.mempool.hasEntry(tx.hash())); + assert(node.mempool.hasEntry(rtx.hash())); + + await node.rpc.generateToAddress([1, aliceReceive]); + }); + + it('should not violate rule 6 signed or unsigned', async () => { + const coins = await alice.getCoins(); + let coin; + for (coin of coins) { + if (!coin.coinbase) + break; + } + const mtx = new MTX(); + mtx.addCoin(coin); + mtx.addOutput(bobReceive, coin.value - 200); + mtx.inputs[0].sequence = 0xfffffffd; + await alice.sign(mtx); + const tx = mtx.toTX(); + assert.strictEqual(tx.inputs.length, 1); + assert.strictEqual(tx.outputs.length, 1); + await alice.wdb.addTX(tx); + await alice.wdb.send(tx); + await forEvent(node.mempool, 'tx'); + + // Do not sign, estimate fee rate + await assert.rejects(async () => { + await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, false, null); + }, { + message: /^Provided fee rate of 1000 s\/kvB results in insufficient estimated total fee rate/ + }); + + // Do sign, then check fee rate + await assert.rejects(async () => { + await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, true, null); + }, { + message: /^Provided fee rate of 1000 s\/kvB results in insufficient total fee rate/ + }); + + await node.rpc.generateToAddress([1, aliceReceive]); + }); + + it('should remove change and pay to fees if below dust', async () => { + const coins = await alice.getCoins(); + let coin; + for (coin of coins) { + if (!coin.coinbase) + break; + } + const mtx = new MTX(); + const changeAddr = (await alice.createChange()).getAddress('string'); + mtx.addCoin(coin); + mtx.addOutput(bobReceive, coin.value - 400 - 141); // Bob gets most of it + + mtx.addOutput(changeAddr, 400); // small change output + assert(!mtx.outputs[1].isDust()); // not dust yet but will be after RBF + mtx.inputs[0].sequence = 0xfffffffd; + await alice.sign(mtx); + const tx = mtx.toTX(); + await alice.wdb.addTX(tx); + await alice.wdb.send(tx); + await forEvent(node.mempool, 'tx'); + + const rtx = await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, true, null); + + assert.strictEqual(rtx.outputs.length, 1); // change output was removed + + await forEvent(node.mempool, 'tx'); + assert(!node.mempool.hasEntry(tx.hash())); + assert(node.mempool.hasEntry(rtx.hash())); + + await node.rpc.generateToAddress([1, aliceReceive]); + }); + + it('should add inputs if change output is insufficient for RBF', async () => { + const coins = await alice.getCoins(); + let coin; + for (coin of coins) { + if (!coin.coinbase) + break; + } + const mtx = new MTX(); + const changeAddr = (await alice.createChange()).getAddress('string'); + mtx.addCoin(coin); + mtx.addOutput(bobReceive, coin.value - 100 - 141); // Bob gets most of it + mtx.addOutput(changeAddr, 100); // change too small to pay for fee bump + + mtx.inputs[0].sequence = 0xfffffffd; + await alice.sign(mtx); + const tx = mtx.toTX(); + await alice.wdb.addTX(tx); + await alice.wdb.send(tx); + await forEvent(node.mempool, 'tx'); + + const rtx = await alice.bumpTXFee(tx.hash(), 1000 /* satoshis per kvB */, true, null); + + await forEvent(node.mempool, 'tx'); + assert(!node.mempool.hasEntry(tx.hash())); + assert(node.mempool.hasEntry(rtx.hash())); + + await node.rpc.generateToAddress([1, aliceReceive]); + }); +});