Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/release-notes-438.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Transaction relay and mining policy
----------------------------------

- Ephemeral-dust transactions may have negative modified fees from the
calculated sub-dust fee penalty. Actual fees and manual fee deltas must
remain zero; the child must cover the penalty in package fee evaluation.
The penalty follows the configured dust relay rate and output values.
Policy defaults, `corepolicy` overrides, and existing dust and package
restrictions are unchanged. The intended version-3 parent/child anchor
packages require `-mempooltruc=enforce`. (#438)
15 changes: 8 additions & 7 deletions src/validation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,14 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)

ws.m_vsize = ws.m_tx_handle->GetTxSize();

// Check actual fees and manual fee deltas before applying the automatic
// dust penalty. StageAddition already applied mapDeltas to the entry.
if (m_pool.m_opts.require_standard && !ignore_rejects.count("dust")) {
if (!PreCheckEphemeralTx(*ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
return false; // state filled in by PreCheckEphemeralTx
}
}

// Reduce effective fee by dust threshold for each sub-dust output
if (m_pool.m_opts.subdustfeepenalty) {
CAmount dust_penalty{0};
Expand All @@ -1132,13 +1140,6 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
}
}

// Enforces 0-fee for dust transactions, no incentive to be mined alone
if (m_pool.m_opts.require_standard && !ignore_rejects.count("dust")) {
if (!PreCheckEphemeralTx(*ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
return false; // state filled in by PreCheckEphemeralTx
}
}

// To avoid rejecting low-sigop bare-multisig transactions, the sigops
// are counted a second time more accurately.
if ((nSigOpsCost > MAX_STANDARD_TX_SIGOPS_COST) || (nBytesPerSigOpStrict && GetAccurateTransactionSigOpCost(tx, m_view, STANDARD_SCRIPT_VERIFY_FLAGS) > ws.m_vsize * WITNESS_SCALE_FACTOR / nBytesPerSigOpStrict)) {
Expand Down
69 changes: 69 additions & 0 deletions test/functional/mempool_ephemeral_dust.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.

from copy import deepcopy
from decimal import Decimal

from test_framework.messages import (
COIN,
COutPoint,
CTxIn,
CTxInWitness,
CTxOut,
)
from test_framework.script_util import PAY_TO_ANCHOR
from test_framework.test_framework import BitcoinTestFramework
from test_framework.mempool_util import assert_mempool_contents
from test_framework.util import (
Expand Down Expand Up @@ -78,6 +83,70 @@ def run_test(self):
self.test_unspent_ephemeral()
self.test_reorgs()
self.test_no_minrelay_fee()
self.test_negative_modified_fee(3)
self.test_negative_modified_fee(6)
self.test_negative_modified_fee(3, penalty_enabled=False)

def test_negative_modified_fee(self, dust_rate, penalty_enabled=True):
self.log.info("Test ephemeral anchor package fees with the native sub-dust penalty")
# Retain native dust/anchor policy. Only enable TRUC; pin fee rates to
# check the exact package boundary. No policy defaults are changed.
self.restart_node(0, extra_args=["-corepolicy=0", "-acceptnonstdtxn=0", "-persistmempool=0",
"-mempooltruc=enforce", "-minrelaytxfee=0.00001000",
f"-dustrelayfee={Decimal(dust_rate) / 100000:.8f}",
f"-subdustfeepenalty={int(penalty_enabled)}"])
node = self.nodes[0]
self.wallet.rescan_utxos()
parent = self.wallet.create_self_transfer(version=3, fee_rate=0, confirmed_only=True)["tx"]
parent.vout.append(CTxOut(0, PAY_TO_ANCHOR))
parent.rehash()
child = self.wallet.create_self_transfer_multi(version=3, fee_per_output=1000, confirmed_only=True)["tx"]
child.vin.append(CTxIn(COutPoint(parent.sha256, 1)))
child.wit.vtxinwit.append(CTxInWitness())
child.rehash()
penalty = (len(parent.vout[1].serialize()) + 67) * dust_rate
assert_equal(penalty, 80 * dust_rate)
if not penalty_enabled:
penalty = 0
assert_equal(node.testmempoolaccept([parent.serialize().hex()])[0]["reject-reason"], "min relay fee not met")

# Reject manual fee deltas independently of the automatic penalty,
# including when the penalty is disabled. The child has ample fees.
for delta in [-1, 1]:
node.prioritisetransaction(txid=parent.hash, fee_delta=delta)
assert node.submitpackage([parent.serialize().hex(), child.serialize().hex()])["package_msg"] != "success"
assert_equal(node.getrawmempool(), [])
node.prioritisetransaction(txid=parent.hash, fee_delta=-delta)

required = parent.get_vsize() + child.get_vsize() + penalty
low = deepcopy(child)
low.vout[0].nValue += 1000 - (required - 1)
low.rehash()
assert node.submitpackage([parent.serialize().hex(), low.serialize().hex()])["package_msg"] != "success"
assert_equal(node.getrawmempool(), [])

child.vout[0].nValue += 1000 - required
child.rehash()
assert_equal(node.submitpackage([parent.serialize().hex(), child.serialize().hex()])["package_msg"], "success")
fees = node.getmempoolentry(parent.hash)["fees"]
assert_equal(fees["base"], 0)
assert_equal(fees["modified"], -Decimal(penalty) / COIN)

# Once accepted, existing RPC policy also forbids manual fee changes.
assert_raises_rpc_error(-8, "Priority is not supported for transactions with dust outputs.",
node.prioritisetransaction, parent.hash, 0, -1)

# A higher-fee child remains replaceable and the package is mineable.
replacement = deepcopy(child)
replacement.vout[0].nValue -= 2000
replacement.rehash()
node.sendrawtransaction(replacement.serialize().hex())
assert_equal(set(node.getrawmempool()), {parent.hash, replacement.hash})
template = node.getblocktemplate({"rules": ["segwit"]})
assert {parent.hash, replacement.hash}.issubset({tx["txid"] for tx in template["transactions"]})
block = node.getblock(self.generate(node, 1, sync_fun=self.no_op)[0])
assert {parent.hash, replacement.hash}.issubset(set(block["tx"]))
assert_equal(node.gettxout(parent.hash, 1), None)

def test_normal_dust(self):
self.log.info("Create 0-value dusty output, show that it works inside truc when spent in package")
Expand Down