Skip to content

Commit 7a53073

Browse files
committed
Merge #1564: mempool: disable full-RBF, require BIP125 opt-in signaling
0e0d019 mempool: disable full-RBF, require BIP125 opt-in signaling (Byron Hambly) Pull request description: Bitcoin Core defaults to accepting any fee-bumping replacement regardless of signaling (full-RBF). Revert this for Elements: only replace mempool transactions that explicitly opt in via BIP125 nSequence signaling (or TRUC), and report fullrbf=false in getmempoolinfo. Update feature_rbf.py accordingly, and add a regression test for CVE-2021-31876 confirming that inherited signaling from an unconfirmed parent does not make a non-signaling child replaceable. ACKs for top commit: tomt1664: Tested locally ACK 0e0d019 Tree-SHA512: 58847919c6a702fe3d5ea4065e07912684cce6f084ca2507d9ef24c6fe8fdb290bb08108d9e025f26a5674e731bf71a9bdbd262aaf78e4f991bcdf06b61228f7
2 parents e52226c + 0e0d019 commit 7a53073

3 files changed

Lines changed: 87 additions & 9 deletions

File tree

src/rpc/mempool.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ UniValue MempoolInfoToJSON(const CTxMemPool& pool)
695695
ret.pushKV("minrelaytxfee", ValueFromAmount(pool.m_opts.min_relay_feerate.GetFeePerK()));
696696
ret.pushKV("incrementalrelayfee", ValueFromAmount(pool.m_opts.incremental_relay_feerate.GetFeePerK()));
697697
ret.pushKV("unbroadcastcount", uint64_t{pool.GetUnbroadcastTxs().size()});
698-
ret.pushKV("fullrbf", true);
698+
ret.pushKV("fullrbf", false);
699699
return ret;
700700
}
701701

@@ -717,7 +717,7 @@ static RPCHelpMan getmempoolinfo()
717717
{RPCResult::Type::STR_AMOUNT, "minrelaytxfee", "Current minimum relay fee for transactions"},
718718
{RPCResult::Type::NUM, "incrementalrelayfee", "minimum fee rate increment for mempool limiting or replacement in " + CURRENCY_UNIT + "/kvB"},
719719
{RPCResult::Type::NUM, "unbroadcastcount", "Current number of transactions that haven't passed initial broadcast yet"},
720-
{RPCResult::Type::BOOL, "fullrbf", "True if the mempool accepts RBF without replaceability signaling inspection (DEPRECATED)"},
720+
{RPCResult::Type::BOOL, "fullrbf", "True if the mempool accepts RBF without replaceability signaling inspection (DEPRECATED). Always false: this mempool requires BIP125 opt-in signaling for replacement, except for TRUC transactions."},
721721
}},
722722
RPCExamples{
723723
HelpExampleCli("getmempoolinfo", "")

src/validation.cpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,24 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
986986
// Transaction conflicts with a mempool tx, but we're not allowing replacements in this context.
987987
return state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "bip125-replacement-disallowed");
988988
}
989-
ws.m_conflicts.insert(ptxConflicting->GetHash());
989+
if (!ws.m_conflicts.count(ptxConflicting->GetHash()))
990+
{
991+
// Transactions that don't explicitly signal replaceability are
992+
// *not* replaceable with the current logic, even if one of their
993+
// unconfirmed ancestors signals replaceability. This diverges
994+
// from BIP125's inherited signaling description (see CVE-2021-31876).
995+
// Applications relying on first-seen mempool behavior should
996+
// check all unconfirmed ancestors; otherwise an opt-in ancestor
997+
// might be replaced, causing removal of this descendant.
998+
//
999+
// All TRUC transactions are considered replaceable.
1000+
const bool allow_rbf{SignalsOptInRBF(*ptxConflicting) || ptxConflicting->version == TRUC_VERSION};
1001+
if (!allow_rbf) {
1002+
return state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "txn-mempool-conflict");
1003+
}
1004+
1005+
ws.m_conflicts.insert(ptxConflicting->GetHash());
1006+
}
9901007
}
9911008
}
9921009

test/functional/feature_rbf.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# from test_framework.script import CScript
1010
from test_framework.messages import (
1111
MAX_BIP125_RBF_SEQUENCE,
12+
SEQUENCE_FINAL,
1213
COIN,
1314
# CTransaction,
1415
# CTxIn,
@@ -82,6 +83,9 @@ def run_test(self):
8283
self.log.info("Running test full replace by fee...")
8384
self.test_fullrbf()
8485

86+
self.log.info("Running test no inherited signaling (CVE-2021-31876)...")
87+
self.test_no_inherited_signaling()
88+
8589
self.log.info("Passed")
8690

8791
def make_utxo(self, node, amount, *, confirmed=True, scriptPubKey=None):
@@ -602,11 +606,13 @@ def test_replacement_relay_fee(self):
602606

603607
def test_fullrbf(self):
604608
# BIP125 signaling is not respected
609+
# ELEMENTS: disable mempoolfullrbf and keep BIP125 signaling
605610

606611
confirmed_utxo = self.make_utxo(self.nodes[0], int(2 * COIN))
607-
assert self.nodes[0].getmempoolinfo()["fullrbf"]
612+
assert not self.nodes[0].getmempoolinfo()["fullrbf"] # ELEMENTS
608613

609614
# Create an explicitly opt-out BIP125 transaction, which will be ignored
615+
# ELEMENTS: respect BIP125 signal
610616
optout_tx = self.wallet.send_self_transfer(
611617
from_node=self.nodes[0],
612618
utxo_to_spend=confirmed_utxo,
@@ -620,12 +626,67 @@ def test_fullrbf(self):
620626
fee_rate=Decimal('0.02'),
621627
)
622628

623-
# Send the replacement transaction, conflicting with the optout_tx.
624-
self.nodes[0].sendrawtransaction(conflicting_tx['hex'], 0)
629+
# ELEMENTS: The replacement is rejected because optout_tx did not signal replaceability.
630+
assert_raises_rpc_error(-26, "txn-mempool-conflict", self.nodes[0].sendrawtransaction, conflicting_tx['hex'], 0)
631+
632+
# ELEMENTS: Optout_tx is still in the mempool; the replacement was rejected
633+
assert optout_tx['txid'] in self.nodes[0].getrawmempool()
634+
assert conflicting_tx['txid'] not in self.nodes[0].getrawmempool()
635+
636+
# ELEMENTS
637+
def test_no_inherited_signaling(self):
638+
"""Make sure we're not susceptible to CVE-2021-31876.
639+
640+
BIP125 says a transaction is replaceable if it directly signals via
641+
nSequence, *or* if it spends an output of an unconfirmed parent that
642+
itself signals replaceability ("inherited signaling").
643+
Elements only enforces the direct-signaling half of that rule
644+
when deciding whether to *accept* a replacement -- see the comment
645+
next to the SignalsOptInRBF() call in MemPoolAccept::PreChecks().
646+
This is the discrepancy reported as CVE-2021-31876: a transaction
647+
can be reported as `bip125-replaceable` (because it inherits the
648+
signal from an unconfirmed ancestor) while an actual attempt to
649+
replace it is still rejected.
650+
651+
This test confirms that this divergence is consistent: a
652+
non-signaling child of a signaling, unconfirmed parent must remain
653+
non-replaceable.
654+
"""
655+
confirmed_utxo = self.make_utxo(self.nodes[0], int(2 * COIN))
656+
657+
# Parent explicitly signals replaceability...
658+
parent_utxo = self.wallet.send_self_transfer(
659+
from_node=self.nodes[0],
660+
utxo_to_spend=confirmed_utxo,
661+
sequence=MAX_BIP125_RBF_SEQUENCE,
662+
fee=Decimal("0.01"),
663+
)["new_utxo"]
664+
665+
# ...but the child spending it does not.
666+
child_tx = self.wallet.send_self_transfer(
667+
from_node=self.nodes[0],
668+
utxo_to_spend=parent_utxo,
669+
sequence=SEQUENCE_FINAL,
670+
fee=Decimal("0.01"),
671+
)
672+
673+
# `bip125-replaceable` reports True: it is inherited from the
674+
# unconfirmed, signaling parent (the BIP125-compliant half of the logic,
675+
# implemented by IsRBFOptIn() for RPC reporting purposes).
676+
assert_equal(True, self.nodes[0].getmempoolentry(child_tx['txid'])['bip125-replaceable'])
677+
678+
# However, an actual replacement of the child is rejected: PreChecks()
679+
# only consults the child's own sequence numbers, not its ancestors',
680+
# so the inherited signal is *not* honoured for acceptance purposes.
681+
conflicting_tx = self.wallet.create_self_transfer(
682+
utxo_to_spend=parent_utxo,
683+
fee=Decimal("0.05"),
684+
)
685+
assert_raises_rpc_error(-26, "txn-mempool-conflict", self.nodes[0].sendrawtransaction, conflicting_tx['hex'], 0)
625686

626-
# Optout_tx is not anymore in the mempool.
627-
assert optout_tx['txid'] not in self.nodes[0].getrawmempool()
628-
assert conflicting_tx['txid'] in self.nodes[0].getrawmempool()
687+
# The non-signaling child is still in the mempool; the replacement never got in.
688+
assert child_tx['txid'] in self.nodes[0].getrawmempool()
689+
assert conflicting_tx['txid'] not in self.nodes[0].getrawmempool()
629690

630691
if __name__ == '__main__':
631692
ReplaceByFeeTest(__file__).main()

0 commit comments

Comments
 (0)