P2P orphanage: park is not reject, fetch parents, 1p1c, compact extra - #404
Conversation
P2P accept of a missing-parent tx parks it, but on_tx treats Orphaned like a hard reject (INFO was-not-accepted + debug txrelay: reject). Pin the operator-visible contract before changing the logger.
Orphaned is a soft accept into the side pool. Duplicate stays silent. Hard rejects keep the Core-shaped INFO + debug reject lines.
Core AlreadyHave includes the orphanage. We re-download parked children from every announcer because try_contains only looks at the live graph.
try_contains / try_contains_wtxid treat parked orphans as already held so peers do not re-GETDATA a child we are waiting to promote.
A live parent with too-high vout is missing, not a child waiting on a future parent. Q-58: hard-reject, not orphan-forever.
creator() matches any vout of a live txid. A too-high vout never existed, so reject missing rather than Policy double-spend or orphan-forever (Q-58).
Child-before-parent only completes if we ask the announcer for the parent txid, as Core TxDownloadManager does via unique_parents.
Same-peer parent fetch with a 60s inflight TTL and 16-parent cap per park. Without this, parked children only promote if the parent is independently INV'd or lands in a block.
BIP152 reconstruct currently clones only live mempool bodies, so a child that arrived before its parent cannot fill getblocktxn holes.
Parked children are often in the next block. Reconstruct consults them before getblocktxn, matching Core vExtraTxnForCompact + orphanage.
Children of InputsDuplicate parents must not park. A below-min-relay parent plus a paying child must admit as a 1p1c package.
Min-relay rejects keep the parent body for opportunistic 1p1c when a paying child arrives. Consensus-invalid txids are remembered so their children are missing-prevout, not parked forever. Extra-compact bodies (RBF replacements and min-relay rejects) also feed BIP152 short-id fill.
Operators diagnosing txrelay park/promote need a live count without DEBUG logs. size is unique parked txs; bytes is vsize (weight/4).
There was a problem hiding this comment.
Verdict: request changes (posted as a comment review because the App token authored this PR and GitHub refuses REQUEST_CHANGES on its own PR).
Reviewed all 13 commits; ran the branch's tests plus three throwaway probes against fae74c6d (probes not committed anywhere). CI is green, the log/INV/GETDATA/compact-extra slices look right, but the 1p1c slice does not work on the path the PR is about (P2P via MempoolHub) and the skip_min_relay flag opens a min-relay bypass. Details below; inline comments point at the exact lines.
Blocking
1. Hub-level 1p1c never admits — the P2P path is not exercised by any test. MempoolHub::accept_with_utxo → admit_staged already parks the child (park_orphan under write) before admit_1p1c runs. admit_1p1c then calls MempoolHub::accept_package, which preps all members under the read lock before committing any; the child's prepare_admit returns Orphaned (first from the orphanage.contains(&txid) early return, and even without that because the parent is not in the graph yet). accept_package errors, res.ok()? is None, and the child is left parked with the parent unadmitted.
Probe (hub, regtest UTXO from pad_empty_from, set_min_relay_sat_kvb(50_000)): parent alone → Policy("min relay fee") ✓; then hub.accept_tx(&child) → Err(Orphaned(..)), orphan_count()==1, neither tx live. The only passing 1p1c test (one_parent_one_child_admits_below_minrelay_parent) drives ActiveMempool::accept_tx, which P2P/RPC/Esplora never call. Needs a red hub test first, then a fix that commits the parent before prepping the child (sequential staged admit), or unparks the child and evaluates the package under one write lock with the trade named.
2. skip_min_relay is process-global mutable state and leaks. (a) Inside ActiveMempool::accept_package, promote_orphans_of(parent) runs while the flag is still true, so every parked orphan of that parent is admitted at floor 0. Probe: parent with two outputs (min-relay reject), sibling A spending vout 0 with a 1 sat fee (parks), child B spending vout 1 with a real fee → after B's 1p1c, A is live. (b) In the hub, set_skip_min_relay(true) / (false) are separate write-lock acquisitions around accept_package, so any concurrent accept_tx from another peer during that window is admitted below min relay. Fix: pass the floor (or skip_min_relay: bool) through AdmitSpec / prepare_admit / accept_tx_inner as a parameter; drop the field and setter.
Should fix in this PR
3. try_one_parent_package counts confirmed inputs as missing parents. It mirrors park_orphan's graph-only scan, so a child that spends the low-fee parent and one confirmed coin has missing.len()==2 and is never eligible. Probe: Err(Orphaned). The same over-approximation feeds orphan_missing_parents → take_parent_getdata, which filters only via try_contains (mempool/orphanage), so we GETDATA confirmed txids from the peer and burn the 16-slot cap on them. prepare_admit already computed the exact missing_parents set — carry it (e.g. in the Orphaned variant or into Orphanage::insert) instead of recomputing graph-only.
4. recent_invalid remembers tip-dependent rejects and is keyed by txid. NotFinal / NonBip68Final / ImmatureCoinbase are true only at the current tip; Core clears its recent-rejects filter on every tip change (hashRecentRejectsChainTip). Here they persist until the 4096 cap wipes everything, so children of a parent that becomes final next block get MissingPrevout instead of parking, and 1p1c refuses the parent. Either exclude those three variants or clear recent_invalid in remove_for_block_with_utxo. Separately, for Script(_) Core only inserts the txid when the tx has no witness (wtxid==txid) because a peer can send a witness-malleated invalid copy; inserting txid unconditionally lets one peer poison a valid txid so honest children never park. Gate on !tx.input.iter().any(|i| !i.witness.is_empty()) (or key by wtxid).
5. Write lock on every hub reject. accept_with_utxo now takes lock_write() for note_accept_failure on all failures including Duplicate and Orphaned (the common ones), which is a write-lock acquisition on the hot reject path with compute_txid inside. Match the recordable variants first and skip the lock otherwise.
Minor
note_conflict_and_parent:.unwrap_or(true)when the creator's body is missing maps a broken invariant toMissingPrevout. AGENTS: missing promised fact →Durable/Corrupt, not a fallback (prepare_admitalready usesDurable("parent body missing")for the same case).- The
recent_invalidbranch returnsMissingPrevout(tx.input[0].previous_output)regardless of which input is missing (the existing non-park branch has the same defect); report the actual outpoint. note_extradoesretain(|t| t.compute_txid() != txid)— up to 100 double-SHA256 per RBF replacement / min-relay reject. Store the txid alongside the body (the orphanage already cacheswtxid).orphan_statsbytes useswu / 4; vsize isceil(wu/4)(policy::get_virtual_size). The livebytesabove has the same rounding, so not blocking, but both are off.- No test for
PARENT_GETDATA_TTL/MAX_PARENTS_PER_PARK(take_parent_getdatadedupe, TTL expiry, cap). - CodeQL alert 199 (
nonce = 1u64incompact_fill_uses_parked_orphan) fires only in a test: per AGENTS, ask the operator to dismiss; do not shuffle the literal.
Looks good
tx_accept_log/TxAcceptLogsplit and the INFO→debug park log.try_contains/try_contains_wtxidincluding the orphanage; checked the other callers (announce gate,rebroadcast_unbroadcast) and none can see an orphan txid there.- Known-parent OOB vout →
MissingPrevoutand the Q-58 doc update. - Compact fill from orphanage +
extra_compact, andnote_extraon RBF replacement (matches CorevExtraTxnForCompact). Orphanage::by_wtxidkept in sync on all three removal paths.
P2P never called ActiveMempool::accept_tx. MempoolHub parked the child then accept_package prepped every member, so the child stayed Orphaned and the parent unadmitted. Admit parent then child with min_relay threaded through AdmitSpec / prepare_admit (Some(0) only for the 1p1c parent). Promote of siblings uses the real floor. skip_min_relay field/setter is gone. Also: carry prepare_admit missing parents into the orphanage (no confirmed GETDATA); recent_invalid skips tip-dependent rejects and witness-malleated script fails; write-lock only on recordable failures; Durable on missing parent body; extra-compact keyed by txid.
There was a problem hiding this comment.
Re-review of 655dcfcc (fixup on top of the 13 reviewed commits). Verdict: one more round — the two blockers from round 1 are fixed properly, but the fix introduced a user-visible RPC regression and left some dead/misleading pieces.
Round-1 items — resolved
- Hub 1p1c:
accept_with_utxono longer parks before trying 1p1c;admit_1p1cadmits the parent atmin_relay: Some(0), then the child at the real floor, and rolls the parent back if the child fails.hub_one_parent_one_child_admitsdrivesMempoolHub::accept_tx. ✓ skip_min_relay: gone; floor is anAdmitSpec/prepare_admitparameter. Sibling orphans are promoted at the real floor (one_parent_one_child_does_not_skip_minrelay_for_siblings). ✓- Chain-aware missing set:
Orphaned { missing }carriesprepare_admit's set intopark_orphan/ GETDATA /try_one_parent_package; confirmed extra input covered. ✓ recent_invalidno longer records tip-dependent variants;Scriptkeyed by txid only for witness-less txs. ✓Durable("parent body missing"), actual missing outpoint inMissingPrevout,(Txid, Transaction)in extra-compact, write lock gated byrecords_accept_failure,take_parent_getdata_atTTL/cap test. ✓
New in this round
1. testmempoolaccept regression (behavior change, untested). prepare_admit now ignores park_orphans (let _ = park_orphans;) and returns Orphaned { .. } unconditionally on missing inputs. MempoolHub::test_accept (park_orphans: false) therefore returns Orphaned instead of MissingPrevout, and accept_reject_reason has no arm for "orphaned <txid>", so the RPC reject-reason falls through to the raw string orphaned <txid> where master returned bad-txns-inputs-missingorspent. Same for the sendrawtransaction-style error path via accept_reject_reason. Either keep park_orphans=false → MissingPrevout in prepare_admit (and have the hub's 1p1c path pass true), or map Orphaned in accept_reject_reason and pin it with an RPC test. Whichever you pick, the existing prepare_admit_without_park_does_not_orphan / hub dry-run tests currently accept either variant, so they did not catch this — tighten one of them.
2. let _ = park_orphans; is dead code. If the parameter is genuinely unused after (1), remove it from prepare_admit (the hub still needs AdmitSpec.park_orphans for admit_staged, that's fine). AGENTS: do not silence unused warnings — delete the code.
3. recent_invalid.clear() on tip connect is on a path the node never calls. It lives in ActiveMempool::remove_for_block_with_utxo, but MempoolHub::remove_for_block / evict_live_txids go through remove_live_txids + erase_orphans_for_block directly. With NotFinal/NonBip68Final/ImmatureCoinbase no longer recorded, what remains (InputsDuplicate, Coinbase, witness-less Script) is permanently invalid, so a tip-change clear is arguably unnecessary — but then drop the line and the review-reply claim rather than leave a clear that only fires in crate tests. If you want the Core semantics, put it in remove_live_txids.
4. publish_admitted does not unindex replaced txs. accept_with_utxo and accept_package both do for old in &r.replaced { self.unindex_txid(old) }; publish_admitted skips it, so a child that RBF-replaces a live tx during 1p1c leaves the replaced txid in sh_index / relay maps / unbroadcast. Add the loop (or route both call sites through one shared post-commit helper — accept_with_utxo's success arm is now a near-duplicate of publish_admitted).
Minor
- Hub 1p1c: between the parent's
commit_after_script(write lock #1) and the child's commit (write lock #2) the parent is live in the graph while the child's scripts verify off-lock. A concurrentaccept_txof another spender of the parent that lands in that window, followed by a child failure, hitsremove_txid(parent)with a live descendant. Narrow, andaccept_package's rollback has the same shape but under one lock. Worth a comment naming the window, or commit both under one write lock and accept the on-lock script cost as a named trade. - Hub
admit_1p1cpassesfee_delta: 0for the parent (ignoresprioritisetransaction); the crate path does the same. Nit, butself.fee_delta(&parent_txid)is one line. - If the parent fails its own
admit_stagedinsideadmit_1p1c(e.g. invalid scripts), the failure is discarded (.ok()?) and not recorded, so every re-INV of the child re-verifies the parent. Bounded byextra_compact, butrecords_accept_failure+note_accept_failureon that error would close it. - CodeQL alert 199 is still the test-only nonce — operator dismiss.
Coverage job was still pending when I looked; everything else green.
…aced prepare_admit honors park_orphans again (false → MissingPrevout, including an already-parked tx). Parking is the caller's job: accept_with_utxo after 1p1c fails, staged_reorg_admit on Orphaned. testmempoolaccept pins bad-txns-inputs-missingorspent and does not grow the orphanage. publish_admitted unindexes r.replaced so hub success matches the old accept_with_utxo path. 1p1c parent uses hub fee_delta and records admit_staged failures. Drop recent_invalid.clear on a path the hub never calls.
There was a problem hiding this comment.
Re-review of fd7324f3 against the PR's eight goals, with a pass for tightness.
Verdict: goals met; one cleanup round before merge. Nothing below is a behavior bug in the P2P path — the earlier blockers stay fixed and the round-2 items are all resolved (testmempoolaccept pinned to bad-txns-inputs-missingorspent including for an already-parked tx, let _ = park_orphans gone, dead recent_invalid.clear() gone, publish_admitted unindexes replaced and is now the single post-commit path for accept_with_utxo, parent fee_delta honored, parent-stage failures recorded, race window named). What is left is duplicated machinery and one silent semantic drift.
Goals
| # | Goal | State |
|---|---|---|
| 1 | Park is debug, not INFO reject | ✓ tx_accept_log |
| 2 | INV already-have includes orphanage | ✓ try_contains{,_wtxid} |
| 3 | Known-parent OOB vout → MissingPrevout |
✓ |
| 4 | GETDATA unique missing parents (TTL, cap) | ✓ exact chain-aware set, tested |
| 5 | Compact fill from orphanage + extra | ✓ |
| 6 | Recent-invalid parents do not park | ✓ permanent variants only, txid only for witness-less |
| 7 | 1p1c on the hub | ✓ hub_one_parent_one_child_admits |
| 8 | getmempoolinfo.orphanage |
✓ |
Tightness (please fix)
1. Two 1p1c implementations. ActiveMempool::admit_1p1c_pair (crate) and MempoolHub::admit_1p1c (hub) are parallel implementations of the same sequence. The hub never calls ActiveMempool::accept_tx; the only callers of the crate-level accept_tx are crate tests and crate-level reorg_reaccept (which the hub also does not use). So admit_1p1c_pair is production code whose only consumer is its own tests — the dual path AGENTS says not to add. Keep try_one_parent_package / package_pays_min_relay in the crate (that is the shared policy), delete admit_1p1c_pair from ActiveMempool::accept_tx, and re-target the three crate tests (one_parent_one_child_*) at try_one_parent_package directly (sibling → None for the 1-sat child; confirmed extra input → Some(parent)). Admission-sequence coverage already lives in the hub test.
2. AdmitSpec.park_orphans no longer parks, and accept_package silently changed. Removing the park arm from admit_staged turned park_orphans into "return Orphaned instead of MissingPrevout"; parking is now the caller's job (accept_with_utxo, staged_reorg_admit). MempoolHub::accept_package still passes park_orphans: true but no longer parks an orphan member (master did, via the removed arm). Not obviously wrong — Core's package accept does not park either — but it is an unflagged behavior change on the Esplora POST /txs/package path with no test. Pick one: (a) rename the field to what it now means (e.g. orphan_as_error / report_orphans) and pin accept_package's no-park in a test, or (b) park explicitly in accept_package if that was the intended behavior. Either way the name and the behavior should agree.
3. accept_tx_inner is accept_tx_with(tx, utxos, tip, 0, true, None) minus note_accept_failure. Fold it; promote_orphans_of and crate accept_package call the one function.
4. records_accept_failure / note_accept_failure restate the same match. Two match arms lists to keep in sync, and the hub repeats the if records { lock_write; note } block twice. One shape: fn accept_failure_record(tx, e) -> Option<FailureRecord> (enum Invalid(Txid) | Extra), note_accept_failure applies it, hub does if let Some(rec) = ... { lock_write().apply(rec) } from one helper.
5. orphan_missing_parents is redundant. The Orphaned { missing } error in on_tx's Park arm already carries the exact set; queue_orphan_parent_getdata re-takes a read lock to fetch it again. Pass missing from the error and delete the hub API.
Minor
admit_1p1c: the two rollback arms are identical (remove_txid+unindex_txid, the latter on a parent that was never indexed) — one closure/helper;stages/lock_usare collected but never metered, so the 1p1c admit is invisible tometer_accept_stages;expire_staleruns twice per 1p1c (once perpublish_admitted).prepare_admitmissing-inputs block scanstx.inputtwice with near-identical predicates and an unreachableunwrap_or(tx.input[0]...); onefirst_missing_outpoint(&missing_parents)helper covers both therecent_invalidand the non-park return.park_orphanalready-parked arm:missing_of(..).cloned().unwrap_or(missing)— theunwrap_oris unreachable aftercontains.package_pays_min_relayrecomputesparent.compute_txid()per child input; hoist. It also requires every parent input to be confirmed (unconfirmed-grandparent 1p1c is out) — fine, but say so in the doc comment since Core's 1p1c has the same limit.- Debug
txrelay: parktakes a read lock fororphan_count()on every park; fine at these rates, just noting it is a lock in the log line. - CodeQL 199 remains the test-only nonce — operator dismiss.
All required checks green except coverage, still running when I posted.
Drop ActiveMempool::admit_1p1c_pair — hub admit_1p1c is the production sequence. Crate 1p1c tests pin try_one_parent_package. Fold accept_tx_inner into accept_tx_with. AdmitSpec.park_orphans is report_orphans (return Orphaned vs MissingPrevout). Parking stays the caller's job; accept_package does not park (pinned). One AcceptFailureRecord for recent-invalid / extra-compact; hub notes through a single helper. Pass Orphaned.missing into parent GETDATA (drop orphan_missing_parents). 1p1c meters stages, rolls the parent back once, and expire_stale once.
There was a problem hiding this comment.
Round 4, b5b55001 (refactor(mempool): one 1p1c path; report_orphans; shared failure record).
Verdict: approve once required checks are green. Posted before test / windows / macos / coverage finished at the operator's request; fmt, clippy, deny, ast-grep were already green. Nothing below is blocking.
Round-3 items — resolved
- One 1p1c path.
admit_1p1c_pairdeleted;ActiveMempool::accept_txjust parks onOrphaned.try_one_parent_package/package_pays_min_relaystay in the crate as the shared policy; the three crate tests now drivetry_one_parent_packagedirectly; admission sequence is covered byhub_one_parent_one_child_admits. ✓ report_orphansreplacespark_orphansinAdmitSpec,prepare_admit,accept_tx_with; doc onprepare_admitsays what it does;accept_package_does_not_park_orphan_memberpins the no-park behavior. ✓accept_tx_innerfolded intoaccept_tx_with(.., 0, true, None). ✓AcceptFailureRecord— one arm list (accept_failure_record), one applier (apply_accept_failure), hubnote_if_accept_failureused at both sites. ✓orphan_missing_parentsremoved;on_txpasses the error'smissingset. ✓rollback_1p1c_parent(no strayunindex_txid), 1p1c stages metered,expire_staleonce per accept at the two callers,first_missing_outpointreplaces the doubled scan,park_orphanunwrap_orgone,pidhoisted, parent-inputs-confirmed limit documented. ✓
Non-blocking nits
- Sibling test lost its original contract.
one_parent_one_child_does_not_skip_minrelay_for_siblingsnow asserts "a 1-sat sibling is not itself a 1p1c package" — that is package-feerate policy, not the min-relay-leak regression it was written for. The real contract (a parked below-min-relay sibling stays out when the parent is admitted via 1p1c, becausepromote_orphans_stagedruns at the real floor) is now only true by construction and has no test on the hub. Cheapest fix: inhub_one_parent_one_child_admits, park a 1-sat sibling before the paying child, then assert the sibling is not live afterwards; and rename the crate test to what it asserts. take_parent_getdata(&[Txid])forcesmissing.iter().copied().collect::<Vec<_>>()at the one call site; take&BTreeSet<Txid>(orimpl IntoIterator<Item = Txid>) and drop the alloc.on_txmatchesetwice:tx_accept_logclassifies toPark, then the arm re-matchesOrphaned { missing }.TxAcceptLog::Park(&BTreeSet<Txid>)would carry it in one step.- On the 1p1c success return in
accept_with_utxo, the child's first (orphaned) prep stages are no longer metered —admit_1p1cmeters its own, so only the initial UTXO lookups are lost. Fine to leave. first_missing_outpoint(..).expect("missing_parents is built from tx.input")— the invariant is local and trivially true; just noting it is a newexpectin the accept path.- CodeQL 199 (test-only BIP152 nonce) — still awaiting operator dismissal.
Good work getting this to one path each for park, 1p1c, and failure recording.
The crate test only checked that a 1-sat sibling is not itself a 1p1c package. Pin the original contract on the hub: park the sibling, admit the paying child, sibling stays out (promote uses the real min-relay). Also: take_parent_getdata takes IntoIterator<&Txid> (no Vec collect); TxAcceptLog::Park carries the missing set so on_tx does not rematch.
Goal
Child-before-parent on P2P actually completes: parked orphans are not logged as rejects, INV already-have includes the orphanage, we GETDATA missing parents, compact reconstruction uses orphan/extra bodies, known-invalid parents do not park forever, and a below-min-relay parent plus paying child admits as 1p1c.
Why
txrelay: reject orphanedwas a successful park mislabeled as a hard reject (INFO + debug). INV ignored the orphanage so we re-GETDATA'd parked children. We never asked the announcer for unique missing parents, so orphans only promoted if the parent was independently INV'd or landed in a block.Core has cluster mempool; this is not a unique-advantage play. 1p1c here is Core-class package admit from the orphanage + reconsiderable min-relay rejects, without waiting on BIP331 (Q-48).
Plan (how-we-plan slices)
Each slice is Red test → Green → still-green related tests.
Orphanedis park (debugtxrelay: park), not INFO reject. Duplicate stays silent.try_contains/try_contains_wtxidinclude the orphanage.MissingPrevout, not orphan-forever (Q-58).getmempoolinfo.orphanage.{size,bytes}.Out of scope
Test