This lists what remains open in opportunistic one-parent-one-child (1p1c) package relay and orphan handling after the fixes in , and a follow-up PR adding an end-to-end liveness fuzz target (). All items are low severity. Each can delay one 1p1c attempt from one peer; none lets a peer poison a txid for future attempts, and none puts funds at risk.
| Commit | Problem fixed | |
|---|---|---|
| F1 | p2p: keep orphan after a package feerate failure | A same-txid parent with a padded witness made the package fail on feerate, and the child was erased for all announcers. |
| F2 | p2p: don't cancel parent requests when a witnessless parent is rejected | Rejecting a witnessless parent as reconsiderable forgot its txid, which cancelled every announcer's request for it. |
| F3 | p2p: handle orphans by their actually missing parents | Orphans were checked against the reject filter for every input's txid, so a rejected witnessless copy of a confirmed parent caused its children to be dropped. |
| F4 | p2p: don't add already-known transactions to the reject filter | A rejected witnessless copy of a mempool parent left the parent's txid in the reject filter, which dropped its children once the parent left the mempool. |
F3 and F4 are both needed. F3 stops the reject filter from being read for parents that are present. F4 stops the filter being written when the parent is present but may later leave the mempool.
- R0 A peer that delivered a parent cannot be asked for it again for a new child
- R1 Witnessless honest child: a same-txid witnessed copy takes over its announcement
- R2 Ready-work marker lost when the assigned announcer disconnects
- R3 An orphan parent that becomes reconsiderable never looks for a package (not TRUC)
- R4 A stripped orphan parent shadows the honest parent's txid (not TRUC)
- R5 Orphan insertion order can make only the stale child pair be tried
- R6 Connection churn prolongs request starvation for a non-preferred honest peer
- R7 Replay of a confirmed parent poisons its txid (fixed by F3 and F4)
- R8 Package and orphan acceptance earn no useful-transaction credit for inbound eviction
- R9 Announcement cap boundary: a duplicate INV keeps a completed child entry counted
- R10 Mempool trimming after package acceptance hard-rejects the child
Rough order of practical relevance among the open items: R0 > R8 > R1, R5 > R6 > R2, R3, R4, R9, R10.
When a peer delivers a missing parent P requested by orphan resolution and P fails as TX_RECONSIDERABLE, that peer's TxRequestTracker entry for txid(P) becomes COMPLETED. The tracker keeps a COMPLETED entry while another peer still has a pending entry for the same hash, and ignores new announcements from a peer that already has an entry for that hash. If the same peer then supplies a different child of P, it cannot be registered as a candidate for P again until the other candidates drain, so the package with the new child is not formed in the meantime.
It arises in two ways:
- Replacement child. {P, C0} fails on fees, and the wallet bumps to C1 while another announcer's request for P is still pending. C1 is stored, and P is not requested again from this peer.
- Parent first, without witness. A peer delivered a witnessless P before its child, while another peer keeps a request for P pending. This is only reachable because F2 keeps requests alive. Before F2 the request was cancelled for every peer, which was worse.
It heals on its own. The bumped child pays a high feerate and propagates, so any other peer announcing it becomes a fresh candidate and resolves the package within seconds. A durable stall needs the node to have a single source for the bumped child while another peer keeps a request for P open.
Not fixed here. The obvious fix, deleting the COMPLETED entry before re-registering, reopens request starvation: two cooperating inbound peers ranked above the honest peer can alternate indefinitely, one stalling while the other re-arms itself with a small fake orphan spending P. A correct fix needs fair retry scheduling in TxRequestTracker, where a re-armed candidate goes behind candidates already waiting in its class. That is a large change to a delicate component and is not justified for a low, self-healing issue.
The honest child has no witness inputs at all (for example a P2A anchor plus a legacy P2PKH fee input), so its wtxid equals its txid. An attacker adds a witness item to one input, producing a copy with the same txid and a different wtxid, invalid once its inputs are known.
- The honest peer H announces the child by wtxid, which equals its txid. The node queues a getdata, delayed 2 s if H is inbound.
- Before that, attacker A sends the witnessed copy unsolicited. The parent is missing, so it fails with
TX_MISSING_INPUTS before any script check. In MempoolRejectedTx the candidate lookup
GetCandidatePeers(txid)matches on the bare hash and finds H, whose wtxid announcement has that hash, so H is recorded as an announcer of the attacker's copy.ForgetTxHash(txid)then deletes H's announcement of the honest child. - The node requests the parent from H and H delivers it. The parent alone is reconsiderable, so Find1P1CPackage pairs it with the attacker's copy, attributed to H. The package fails on scripts, the copy is erased, and the honest child is never downloaded. H does not re-announce.
Needs a fully witnessless child and A winning the pre-getdata window. Effect: delay of that child until a
new announcer appears. Reproducer: 1p1c-residual-R1-repro-public.cpp in this gist, a unit test for
src/test/txdownload_tests.cpp that fails on the fix PR.
Proposed fix: make GetCandidatePeers and ForgetTxHash take the announcement type into account.
Candidates for an orphan T are peers with a txid announcement of txid(T) and peers with a wtxid
announcement of wtxid(T). A wtxid announcement that happens to equal txid(T) refers to different bytes
and should be neither attributed nor forgotten. Acceptance and block paths can keep forgetting by hash.
Limit: this only resolves the ambiguity for wtxid-relay peers. A legacy peer's txid announcement does not identify a witness, so the takeover still works against legacy announcers even when the honest child has a witness. That is the malleation problem wtxid relay was introduced to solve.
AddChildrenToWorkSet assigns one random announcer per orphan to hold the reprocessing work. Erasing
that announcer's entries drops the marker without passing it to another announcer. This is not on the
1p1c path, where package results process the child directly. It matters when the parent enters the
mempool or a block on its own. Re-announcements of the child from a remaining announcer find no missing
parent and do not schedule the work.
C, P and Q arrive youngest first, and P is stored as an orphan waiting for Q. When Q arrives, P is
retried alone with first_time_failure=false and fails as TX_RECONSIDERABLE. No package search runs,
P is erased and filtered, and C is stranded. Needs a three-generation unconfirmed chain, which TRUC
forbids.
An attacker delivers a stripped P while P's own input Q is unknown, so the stripped P is stored as an
orphan with wtxid equal to txid(P). AlreadyHaveTx looks up a txid in the orphanage by treating it as a
wtxid, so it now reports P as present. That lookup has three consumers, and the fix must cover all of
them:
- When the honest child C first arrives, P is filtered out of its missing parents, so H is never asked for P.
- A later announcer of the stored C goes through the same filter. F3 only narrows first-time storage, so this path is unchanged by the PR.
- A direct txid announcement of the witnessed P, from a legacy peer or as MSG_TX, is dropped without being recorded.
When Q arrives, the stripped P fails on scripts and is erased, and C is stranded. Needs an unconfirmed
grandparent. Fix: use the orphanage shortcut in AlreadyHaveTx for wtxid queries only.
GetChildrenFromSamePeer returns orphans newest first, and Find1P1CPackage returns one pair per
parent arrival. If a peer is attributed two versions of the child, an older low-fee C0 stored after the
acceptable C1, only {P, C0} is tried when P arrives. It fails on fees and is cached, and {P, C1} is not
evaluated with the parent already at hand.
A peer that sends that order itself only affects pairs attributed to itself, since the parent is paired with the delivering peer's own children. Reaching it against an honest peer needs a race: another peer delivers the old C0 late, after the bump, while the honest peer's announcement is still pending. It heals on the next announcement of either child from another peer, or on the next delivery of P, where the cached pair is skipped. Fix direction: try further same-peer children when a pair fails, with a bound on validation work per parent delivery.
One fresh inbound announcer per 60 s request timeout keeps a non-preferred honest peer from being chosen, with probability 1/(n+1) after n rounds, because each new connection draws a new priority. This is a known TxRequestTracker tradeoff. Preferred (outbound) honest peers are unaffected, and it is delay rather than denial: each round costs the attacker a connection and an announcement.
Before F3, orphan handling checked every input's txid against the reject filter, including confirmed inputs. A witnessless copy of a confirmed transaction F, rejected with a result recorded by wtxid, put txid(F) in the filter, and every later orphan spending an output of F was dropped. Several rejections return before validation notices the transaction is already known: coinbase, non-final at the tip, and any current standardness rule the confirmed F no longer meets. So fixing only the already-known result was not enough. F3 makes validation report the parents it actually found missing, and orphan handling uses only those. That closes the class regardless of why the copy was rejected, and confirmed parents are no longer requested by txid when an orphan is first stored.
F3 cannot help when the parent really is missing by the time the child arrives. A witnessless copy of a parent in the mempool is rejected as TX_CONFLICT, and before F4 that put the parent's txid in the reject filter. If the parent then left the mempool before the next block, through eviction or through replacement by someone who can spend one of its inputs, a bumped child was dropped. F4 no longer adds TX_CONFLICT results to the reject filter.
CNode::m_last_tx_time is only updated in the standalone TX message handler when a transaction is
accepted. Transactions accepted as part of a package or as a resolved orphan go through
ProcessValidTx, which does not update it. A peer whose useful transactions are only CPFP children
accepted through packages never earns the inbound-eviction protection for peers with recent useful
transactions. When inbound slots are full, it is a candidate for eviction. Other protections (netgroup,
latency, uptime) still apply, and it needs slot pressure. It is not specific to 1p1c. Fix direction:
update m_last_tx_time in ProcessValidTx or at the package and orphan acceptance sites. Whether a few
connections can cheaply evict a specific peer has not been assessed.
When the honest peer H has exactly MAX_PEER_TX_ANNOUNCEMENTS (5,000) tracked announcements including the
child C, a duplicate announcement of C from an attacker before H answers keeps H's completed entry for C
counted. The orphan-resolution check Count(H) + parents.size() > MAX_PEER_TX_ANNOUNCEMENTS then refuses
to register H, C can be stored under the attacker, and both child hashes are forgotten. Needs H's queue
to be genuinely full. Worth handling in any rework of txrequest capacity accounting.
When package members are accepted and then removed by the final mempool trim, AcceptPackage reports them as TX_MEMPOOL_POLICY "mempool full". F1 only skips TX_RECONSIDERABLE members, so such a child is filtered and erased. The single-transaction path reports the same condition as TX_RECONSIDERABLE.
A keyless attack needs a parent witness that can be enlarged while staying valid (for example a P2WSH script that drops variable-length arguments; signature-only witnesses cannot), a nearly full mempool where the enlarged pair is admitted but is the lowest chunk and gets trimmed, and a fee margin where the honest compact pair would survive. Applies to TRUC packages within their limits. Fix direction: report post-trim failures the same way as the single-transaction path, as TX_RECONSIDERABLE, rather than exempting TX_MEMPOOL_POLICY wholesale.
- Cost of F4. Not adding TX_CONFLICT to the reject filter removes duplicate suppression for known transactions. A peer can make the node download a known transaction again and validate it up to the input check, once per announcement. This is bounded by announcement rate, involves no script checks, and costs about the same as a peer announcing any unknown transaction. The same applies without an attacker: honest peers relaying a different-witness variant of a mempool transaction, or rebroadcasting an old confirmed one, each cause one download, where before the first rejection filtered the rest.
- Orphan capacity. Honest orphans are protected only within per-peer reservations (404,000 weight and a share of the global latency score per contributing peer).
- Reject-filter false positives (120,000 entries at 1e-6): no cheap way to steer them was found.
- Compact-block extra transactions (100 entries, not deduplicated) can be flushed by repeated deliveries of known transactions. That costs an extra reconstruction round trip, not censorship.
- Fuzz clock. The existing
txdownloadmanfuzz target's clock never reaches the times at which orphan-resolution requests are sent, so those requests are not exercised there.
Every item is one of three kinds.
Identity. A prevout names a txid, so a missing parent is fetched, tracked and judged by a handle that does not pin its bytes. All witness-malleation issues are here. The txid-keyed sites in the transaction download manager:
| Site | Status |
|---|---|
| Reject filter read by a missing parent's txid | Closed by F3 (read) and F4 (write) |
| Forgetting a txid when a reconsiderable witnessless parent is rejected | Closed by F2 |
| Forgetting a txid when any orphan is stored | Open: R1 |
| Orphanage lookup of a txid treated as a wtxid | Open: R4 |
| Orphan-resolution requests keyed by txid at all | The root; only a protocol change removes it |
R1 and R4 follow from one rule: a txid match must never cancel or satisfy a request made for a wtxid.
Attribution. A package result is recorded against a member's wtxid although it is a fact about the combination. BIP331 does not remove this, since an attacker can include the honest child in its own package. The result types mix scopes:
| Result | Depends on | Correct scope |
|---|---|---|
| TX_CONSENSUS, TX_WITNESS_MUTATED, TX_NOT_STANDARD, TX_INPUTS_NOT_STANDARD, TX_PREMATURE_SPEND | the member's bytes and txid-fixed prevouts | wtxid |
| TX_RECONSIDERABLE | fees, and so the parent's size | package |
| TX_MEMPOOL_POLICY | mixed: TRUC child-size rules are intrinsic; "mempool full" and cluster limits depend on the parent's size | ambiguous |
On the attempt side (R0, R3, R5), validation works on packages while download bookkeeping is per peer and hash, and each parent arrival tries one pair. Options, with the resource each spends:
| Strategy | Resource | Closes |
|---|---|---|
| Keep reconsiderable parent bytes in the orphanage | memory within per-peer reservation | R0, R3, R5 |
| One-slot reconsiderable-parent cache per peer | memory, one transaction per peer | R0, R5 for that peer |
| Remember (txid, wtxid, deliverer) and re-request by wtxid | bandwidth, one parent download per new child | R0 |
| Try every same-peer child per parent arrival, capped | CPU | R5 |
| Fair retry scheduling in TxRequestTracker | complexity in a delicate component | R0, partly R6 |
| Mempool-side buffer for low-fee parents | memory, buffer policy | R0, R3, R5, replacement cycling |
Accounting. R2, R6, R8, R9. Ordinary resource bookkeeping, with no protocol angle.
Package relay in which the set of wtxids from one peer is the unit of announcement, request, delivery and validation, fetched whole when its claimed feerate clears the node's threshold:
- One key, the package hash, for requests, verdicts and accounting. A member's wtxid never enters a reject filter because of a package result; that filter is fed only by single-transaction validation. This removes the attribution class, including F1 and R10.
- Delivery as a single all-or-nothing message (BIP331
pkgtxns). Per-wtxid getdata would recreate a holding buffer, which is an orphanage. - Delivered bytes whose wtxid is not in the requested set are ignored and cancel nothing, which removes R1.
- A peer that lies about the feerate costs the node only the bandwidth it spends itself, as with any announcement of an invalid transaction today.
The fixes in the PR are the mitigation for peers that do not speak such a protocol, which will be most of the network for a long time.
The p2p_1p1c_liveness target in the follow-up PR checks that if an honest peer announces the child of
an acceptable pair, the child ends up in the mempool whatever other peers do. Its adversaries are
open-ended: witness stripping, padding and replacement, announcement by wtxid or txid, notfound, stalls,
and reconnects. Gaps are in the transactions it uses and its environment:
| Gap | Missing ingredient | Would exercise |
|---|---|---|
| Witnessless honest child or parent | P2SH{OP_TRUE} transactions | R1, F2 without malleation |
| Honest bump | a second honest child after a too-low-fee first one | R0, R5 |
| Trimming | a small, nearly full mempool | R10 |
| Parent present, then gone | parent accepted alone and removed before the child | F4 |
| Conflicting spend | an adversary replacing the parent | replacement cycling |
| Unconfirmed grandparent | a parent spending an unconfirmed input | R3, R4 |
A clean run has limits: adversaries go quiet before the check, so sustained alternation is not tested, and there are no tip changes, so filter resets and the recently-confirmed path are not exercised.
Only R5 and R6 heal on their own, and R0 does when more than one honest peer announced the child. Everything else heals when the wallet's next bump produces a new child wtxid and package hash; no open item carries state into that attempt, except R4's shadow in non-TRUC chains.
| Item | Heals by | Can an attacker repeat it on each bump? | Reaches TRUC? |
|---|---|---|---|
| R0 | other candidates draining, else the next bump | needs two attackers ranked above the honest peer, then sustained | yes |
| R1 | next bump or a second honest peer | yes, if the honest child has no witness at all | depends on wallet |
| R2 | next bump | needs disconnect timing | yes, narrow |
| R3, R4 | next bump; R4 also on orphan expiry | R4's shadow lasts up to 20 minutes across bumps | no |
| R5 | next parent delivery from any candidate | no | yes |
| R6 | time, one stall per candidate | delay only | yes |
| R8 | the honest peer reconnecting | needs full inbound slots and the honest peer as victim | conditional |
| R9 | next bump | needs the honest peer's queue at the cap | very narrow |
| R10 | a block resets the filter, then a bump | yes, with an enlargeable-valid parent and a nearly full mempool | yes, narrow |
The fixed issues differed: each poisoned a txid or erased the honest child with one message and no precondition on the honest side, and R7 affected every future child of a confirmed parent, so bumping did not help. None of the open items poisons a txid. With the PR applied, one 1p1c attempt on one link can still be lost to an attacker who wins a two-connection priority race, faces a witnessless honest child, or has an enlargeable-valid parent script and a full mempool. Each loss costs one bump interval, and the next bump starts clean.
- Report post-trim package failures as TX_RECONSIDERABLE, as the single-transaction path does. Closes R10.
- R1: make candidate lookup and forgetting on orphan storage type-aware.
- R4: use the orphanage shortcut in
AlreadyHaveTxfor wtxid queries only. Find1P1CPackage: try up to k same-peer children instead of the first pair. Closes R5.- Liveness target: add witnessless and honest-bump scenarios, then a trimming environment.
- R9: fix the boundary accounting.
txdownloadmanfuzz target: advance the clock far enough to exercise orphan-resolution requests.- Discuss making
ProcessPackageResultfollow a rule based on result scope rather than a list of result types, and the package-hash design above.