Skip to content

Instantly share code, notes, and snippets.

@instagibbs
Created September 28, 2026 16:01
Show Gist options
  • Select an option

  • Save instagibbs/880a76535fd802fced9f9a5253cdbb40 to your computer and use it in GitHub Desktop.

Select an option

Save instagibbs/880a76535fd802fced9f9a5253cdbb40 to your computer and use it in GitHub Desktop.

Opportunistic 1p1c and orphan relay: known residual issues

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.

Fixes in the PR

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.

Index

  • 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.

R0. A peer that delivered a parent cannot be asked for it again for a new child

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.

R1. Witnessless honest child: a same-txid witnessed copy takes over its announcement

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.

  1. 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.
  2. 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.
  3. 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.

R2. Ready-work marker lost when the assigned announcer disconnects

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.

R3. An orphan parent that becomes reconsiderable never looks for a package (not TRUC)

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.

R4. A stripped orphan parent shadows the honest parent's txid (not TRUC)

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:

  1. When the honest child C first arrives, P is filtered out of its missing parents, so H is never asked for P.
  2. 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.
  3. 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.

R5. Orphan insertion order can make only the stale child pair be tried

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.

R6. Connection churn prolongs request starvation for a non-preferred honest peer

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.

R7. Replay of a confirmed parent poisons its txid (fixed by F3 and F4)

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.

R8. Package and orphan acceptance earn no useful-transaction credit for inbound eviction

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.

R9. Announcement cap boundary: a duplicate INV keeps a completed child entry counted

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.

R10. Mempool trimming after package acceptance hard-rejects the child

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.

Other notes

  • 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 txdownloadman fuzz target's clock never reaches the times at which orphan-resolution requests are sent, so those requests are not exercised there.

How the issues group

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.

Possible longer-term direction

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.

Coverage by the liveness fuzz target

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.

Risk with the PR applied

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.

Possible follow-ups

  1. Report post-trim package failures as TX_RECONSIDERABLE, as the single-transaction path does. Closes R10.
  2. R1: make candidate lookup and forgetting on orphan storage type-aware.
  3. R4: use the orphanage shortcut in AlreadyHaveTx for wtxid queries only.
  4. Find1P1CPackage: try up to k same-peer children instead of the first pair. Closes R5.
  5. Liveness target: add witnessless and honest-bump scenarios, then a trimming environment.
  6. R9: fix the boundary accounting.
  7. txdownloadman fuzz target: advance the clock far enough to exercise orphan-resolution requests.
  8. Discuss making ProcessPackageResult follow a rule based on result scope rather than a list of result types, and the package-hash design above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment