Skip to content

fix(mempool): per-peer orphan pool reservations - #2148

Open
nullPointerEnjoyer wants to merge 43 commits into
masterfrom
fix/orphan-quota
Open

nullPointerEnjoyer wants to merge 43 commits into
masterfrom
fix/orphan-quota

Conversation

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator

Problem

The mempool's orphan pool was a single shared 100-entry cache with no per-peer accounting:

  • Admission was free for remote peers. A transaction whose inputs are unknown is parked before any fee policy runs, so parking cost nothing — no fee, no quota, no rate limit.
  • Eviction was uniformly random and origin-blind (enforce_max_size drew a random resident entry per insertion).
  • Eviction was irreversible. Promotion only searches the pool at the moment its missing parent arrives, so an evicted child is silently dropped and must be re-announced by its origin.

A single connected peer could therefore flood the pool with ~1500 costless transactions (~72 KiB, no signature checks, no fees) and displace every other peer's parked orphans for as long as the connection stayed open. Child-before-parent relay — which wallets rely on for unconfirmed chains — was disabled for everyone except the attacker. Affected release line: v1.4.x.

Fix

The pool now grants each origin a protected reservation; one peer can never evict another peer's entries.

  • Per-origin admission quotas (check_orphan_pool_policy): remote inbound peers default to 10 entries (remote_orphan_pool_capacity_per_peer), local origin keeps its aggregate cap of 25, and a new outbound tier defaults to 40 (outbound_remote_orphan_pool_capacity_per_peer). The outbound flag is set by the node from the actual connection direction (p2p transaction manager); RPC callers cannot claim it.
  • Reservation-aware eviction replaces the uniform random draw: when trimming is needed, entries from origins beyond their reservation go first (largest excess, oldest entry). If every origin is within its reservation and the pool is full, admission fails closed with OrphanPoolError::Full instead of displacing a protected entry.
  • Expiry before policy: expired entries are swept before quota/duplicate checks, so stale entries can't pin a peer's own quota, and duplicates can't keep expired entries alive.
  • Per-origin accounting (origin_stats) with integrity checking, purged on peer disconnect (existing teardown) and when an origin's count reaches zero.
  • Plumbing: MempoolConfig fields + validation (outbound >= inbound, non-zero), node config file, RPC type, and the wallet error classifier now treats RemoteCapacityExceeded as transient (retryable) rather than deterministic rejection.

The global pool ceiling stays fixed at 100 entries (DEFAULT_ORPHAN_POOL_CAPACITY); worst-case memory is unchanged. RemoteCapacityExceeded rejections carry ban score 0, so quota pressure cannot be laundered into peer bans.

Tests

  • Regression suite in mempool/src/pool/tests/orphan_starvation.rs (drives the production add_transaction_remote path): control (child promoted), attack (flood no longer displaces — inverted from the original failing PoC), scale (100/100 survive), two-peer fairness, 99-peer fairness, fair share under load, expiry/fresh-quota, pinned-pool fail-closed (remote and local), duplicate/ownership semantics, teardown, outbound tier.
  • Reservation/eviction unit tests in mempool/src/pool/orphans/test.rs, including a randomized simulation exercising quota + eviction + expiry.
  • Full mempool, p2p, blockprod, node-lib and wallet suites green; mempool_orphan_peer_disconnected.py functional test passes.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 7 issue(s) in this PR.

  • ✅ Successfully posted inline: 0 comment(s)
  • 📋 Routed to summary by policy: 6 comment(s)
  • ❌ Failed to post inline: 1 comment(s)

⚠️ 2 warning(s) occurred during review.


security · low

📄 .github/workflows/wasm.yml (L55-L55)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category security)

Good change: pinning typescript/knip to exact versions and using npx --no-install ensures only locally installed tools are executed, avoiding supply-chain hijacking via mutable npm versions. One residual risk: npm install typescript@5.9.3 knip@6.43.0 installs without a lockfile, so transitive dependencies are unpinned. Consider using npm install --save-exact with a committed package-lock.json, or npm ci, so transitive deps are also reproducible. (Informational; no change strictly required.)


other · low

📄 .github/workflows/wasm.yml (L20-L21)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category other)

Neither job sets timeout-minutes; a hung wasm-pack build or test could consume runner time indefinitely (this workflow also runs daily via schedule:). Add a reasonable timeout to each job.

💡 Suggested Change

Before:

wasm_with_nodejs:
    runs-on: ubuntu-latest

After:

wasm_with_nodejs:
    runs-on: ubuntu-latest
    timeout-minutes: 60

other · low

📄 .github/workflows/wasm.yml (L14-L15)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category other)

This workflow runs on push and daily schedule without a concurrency group, so overlapping runs (e.g., a schedule trigger plus repeated pushes to master) can execute redundantly. Consider adding concurrency with cancel-in-progress.

💡 Suggested Change

Before:

env:
  CARGO_TERM_COLOR: always

After:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always

performance · low

📄 .github/workflows/wasm.yml (L57-L58)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category performance)

This workflow runs on every push to master/release-*, every PR, and daily via schedule, yet cargo install wasm-pack (compiled from source, often several minutes) and the apt/npm installs run uncached on every run. Consider caching the cargo bin/tools directory (e.g. actions/cache on ~/.cargo/bin) and using cache: npm/cache: cargo in setup-node/setup-rust-style actions to cut run time and reduce load.

💡 Suggested Change

Before:

      - name: Install wasm-pack
        run: cargo install wasm-pack --locked

After:

      - name: Cache wasm-pack
        uses: actions/cache@v4
        with:
          path: ~/.cargo/bin
          key: wasm-pack-${{ runner.os }}

      - name: Install wasm-pack
        run: cargo install wasm-pack --locked

security · low

📄 mempool/types/src/tx_options.rs (L74-L76)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category security)

The doc states this flag is 'Set by the p2p layer only; never exposed through RPC overrides', but with_origin_peer_outbound is a public builder on a public type, so nothing in the type system prevents an RPC/API caller from setting it and obtaining the larger outbound per-peer orphan quota (a 4x larger reservation by default). If the p2p-only invariant is a real trust boundary, consider restricting the setter (e.g., pub(crate) in a crate that both mempool and p2p can reach, or a capability-style constructor) or at minimum document the flag as untrusted input that callers may spoof, so future quota logic doesn't treat it as authoritative.


bug · low

📄 mempool/src/pool/orphans/mod.rs (L156-L158)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category bug)

stat.count -= 1 and stat.size -= entry.size().get() use unchecked subtraction; only stat.size gets a (debug-only) assertion before the entry is removed, and count has none at all. If an accounting bug ever causes a double removal (e.g., a future path calling TxOrphanPoolMaps::remove twice for one entry, or swap_remove bookkeeping drifting), this underflows and either panics or silently wraps in release builds, corrupting per-origin quotas in both cases. Consider stat.count = stat.count.checked_sub(1).expect(...) (or a debug_assert on count) so the invariant violation is caught explicitly and close to the cause.

💡 Suggested Change

Before:

        let stat = self.origin_stats.get_mut(&entry.origin()).expect("origin stat to be present");
        stat.count -= 1;
        stat.size -= entry.size().get();

After:

        let stat = self.origin_stats.get_mut(&entry.origin()).expect("origin stat to be present");
        debug_assert!(stat.count > 0, "per-origin count underflow");
        stat.count = stat.count.checked_sub(1).expect("per-origin count underflow");
        stat.size = stat.size.checked_sub(entry.size().get()).expect("per-origin size underflow");

security · medium

📄 .github/workflows/wasm.yml (L19-L21)

⚠️ GitHub could not post this as an inline comment: Lines 19-21 could not be resolved (outside PR diff hunks)

This workflow has no top-level or job-level permissions key, so jobs run with the default (potentially broad) token permissions. Neither job needs the GITHUB_TOKEN — set least-privilege permissions explicitly, e.g. permissions: {} at the workflow level (or contents: read per job).

💡 Suggested Change

Before:

jobs:
  wasm_with_nodejs:
    runs-on: ubuntu-latest

After:

permissions:
  contents: read

jobs:
  wasm_with_nodejs:
    runs-on: ubuntu-latest

⚠️ Warnings:

  • mempool/src/config.rs,mempool/src/error/mod.rs,mempool/src/pool/mod.rs,mempool/src/pool/orphans/mod.rs,mempool/src/pool/orphans/test.rs,mempool/src/pool/tests/basic.rs,mempool/src/pool/tests/mod.rs,mempool/src/pool/tests/orphan_starvation.rs,mempool/src/pool/tests/orphans.rs,mempool/src/pool/tests/relatives.rs (review_limit_reached): reached the aggregate token budget before finishing; the grace round did not complete the task
  • mempool/src/config.rs (token_budget_reached): stopped group "mempool/src/config.rs,mempool/src/error/mod.rs,mempool/src/pool/mod.rs,mempool/src/pool/orphans/mod.rs,mempool/src/pool/orphans/test.rs,mempool/src/pool/tests/basic.rs,mempool/src/pool/tests/mod.rs,mempool/src/pool/tests/orphan_starvation.rs,mempool/src/pool/tests/orphans.rs,mempool/src/pool/tests/relatives.rs" mid-review: used 574837 tokens exceeds budget 500000

Comment on lines 333 to 335
let status = self.insert(entry)?;
self.enforce_limits(cur_time);
ensure!(self.contains(&tx_id), OrphanPoolError::Full);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
A stale resubmission (entry already expired) is rejected via the trailing ensure!(self.contains(&tx_id), OrphanPoolError::Full) even though the pool may be completely empty — the test resubmission_of_expired_orphan_is_rejected_and_swept explicitly documents that Full here means "expired and swept", not "pool at capacity". Callers that react to Full as resource exhaustion (logging, metrics, peer discouragement, backoff) will misinterpret an ordinary stale rebroadcast as an overload event. Consider a dedicated OrphanPoolError::Expired (or a TxStatus::Expired result) so the failure mode stays distinguishable at the boundary.

Suggestion:

Suggested change
let status = self.insert(entry)?;
self.enforce_limits(cur_time);
ensure!(self.contains(&tx_id), OrphanPoolError::Full);
let status = self.insert(entry)?;
self.enforce_limits(cur_time);
ensure!(self.contains(&tx_id), OrphanPoolError::Expired);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3bc50ac: the trailing containment check now returns the new OrphanPoolError::Expired instead of Full (it only fires when the post-insert expiry sweep removed the entry, so the dedicated variant is exact). Wallet classifier treats it as transient — a rebroadcast re-parks the transaction with a fresh timestamp — and the variant-count/wire-mirror tests pin the new Display string. Note this path is unreachable in production today (the interface always constructs fresh entries, and the caller sweeps before admission); the variant keeps the API honest for future callers.

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

CI note — the 4 failing checks are inherited from master, not introduced by this PR

This diff adds no dependencies and touches no wasm/TypeScript code. Both failing workflows fail identically on current master:

Check This PR master (same day) Failure
static_checks (ubuntu/macos/windows) fail run 40271121457 ❌ (also 10-25, 10-24) cargo deny RustSec advisories: cert verification accepts distrusted intermediates, bundled root certs trust distrusted emSign intermediates, AsyncFd::new I/O-safety (unsound) — all in third-party deps
wasm_with_nodejs (20.x) fail run 40270100755 ❌ (also 10-25, 10-24) tsc: command not found (exit 127) at "Compile the tests" — missing TypeScript compiler on the runner

Everything else is green on this PR: build_ubuntu (incl. full test suite), loom, review, wasm_artifacts, all ledger and trezor device tests. The two inherited breakages look like they need a runner/dependency-bump fix on master; happy to take that in a separate PR if useful.

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

Addressing the remaining review findings, all in 3bc50ac:

mempool/src/pool/mod.rs (admission tier vs reservation tier) — fixed. Admission now derives the cap as max(recorded reservation, newcomer's tier): the pool's recorded origin_stats.outbound (upgrade-only) is the source of truth eviction uses, and the newcomer's own flag can only raise the admitted tier, after which the pool records the upgrade — so a reservation can never lag the quota an entry was admitted under.

mempool/src/pool/orphans/mod.rs:156 (expect("origin stat to be present")) — intentionally kept, not switched to a silent early-return. Every entry in by_tx_id necessarily went through insert, which creates the stat, and the integrity checks (per-origin counts vs by_origin ranges, count/size sums, zero-count purge) run in the randomized simulation and every deterministic test. If the invariant were ever broken, a silent return here would produce exactly the worst outcome — entries present in the maps with drifted origin_stats, i.e. eviction decisions computed from wrong counts — whereas the panic fails loudly instead. A typed error would infect every removal caller for a case that cannot be meaningfully recovered from.

node-lib/src/config_files/mempool.rs (doc literal) — fixed; the doc now references the constant without pinning the numeric value.

Comment thread mempool/src/pool/orphans/mod.rs Outdated
Comment on lines +335 to +337
// If the entry is gone after the post-insert sweep, it was already expired at
// `cur_time` — capacity-wise nothing was rejected (the pool had room), so report
// expiry rather than `Full`, which callers read as resource exhaustion.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This comment is inconsistent with the code: as written, an entry inserted at cur_time cannot be expired by the post-insert sweep, so !contains here can only indicate that enforce_limits evicted it (or protected entries) via enforce_max_size. The stated rationale ('it was already expired at cur_time') doesn't hold, and mapping that eviction case to Expired hides resource pressure from callers.

Suggestion:

Suggested change
// If the entry is gone after the post-insert sweep, it was already expired at
// `cur_time` — capacity-wise nothing was rejected (the pool had room), so report
// expiry rather than `Full`, which callers read as resource exhaustion.
// A just-inserted entry cannot expire at `cur_time`; if it is missing here,
// `enforce_max_size` evicted it or another origin's entries — surface that
// distinctly instead of reporting expiry.

Comment thread mempool/src/pool/orphans/mod.rs Outdated
Comment on lines 338 to 340
ensure!(self.contains(&tx_id), OrphanPoolError::Expired);

Ok(status)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This ensure!(self.contains(&tx_id), OrphanPoolError::Expired) mislabels non-expiry evictions. A freshly inserted entry has creation_time == cur_time, so remove_expired inside enforce_limits can never remove it; the only way it can be absent here is eviction by enforce_max_size (via the fail-open oldest-overall fallback, which can also displace another peer's protected entries). Reporting that as Expired masks a resource-exhaustion/displacement condition — including from wallet-node-client, which classifies Expired as a deterministic rejection. Either return a dedicated eviction error or, better, use evict_over_reservation semantics after insert so the branch truly can only mean expiry.

Suggestion:

Suggested change
ensure!(self.contains(&tx_id), OrphanPoolError::Expired);
Ok(status)
// Only `remove_expired` (never eviction) can have removed a just-inserted entry.
ensure!(self.contains(&tx_id), OrphanPoolError::Expired);
Ok(status)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured in a9f8f8a: the stale case is now detected before insertion instead of after the sweep — insert_and_enforce_limits rejects any entry whose creation_time is past the expiry cutoff (shared with remove_expired via a helper) with OrphanPoolError::Expired, so the post-insert containment check can no longer attribute anything to expiry. On your eviction point: that branch is unreachable by construction — the Full check bounds the pool below capacity before insertion, so enforce_max_size has nothing to evict afterward (this holds regardless of the fail-open fallback, which only runs inside evict_over_reservation/direct calls when the pool is already at capacity). The containment check stays as a release-side fail-closed backstop reporting Full with a proof comment; the Expired variant now has exactly one producer, the staleness check.

Comment thread mempool/src/pool/orphans/mod.rs Outdated
Comment on lines +327 to +339
if self.len() >= *self.transaction_count_limit
&& !self.evict_over_reservation(*self.transaction_count_limit)
{
return Err(OrphanPoolError::Full);
}

// An already-stale entry is rejected here instead of being inserted and immediately
// swept again: `remove_expired` has just run, so staleness is detectable without
// touching the pool. Reporting `Expired` keeps a swept-and-rejected resubmission
// distinguishable from `Full`, which callers read as resource exhaustion.
if entry.creation_time() < self.expiry_cutoff(cur_time) {
return Err(OrphanPoolError::Expired);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The staleness check runs after the capacity check. If the pool is full (after the expired sweep freed nothing) and the incoming entry is already stale, the caller gets OrphanPoolError::Full — resource exhaustion — instead of Expired, contradicting the stated goal of keeping swept-and-rejected resubmissions distinguishable from Full. Move the expiry_cutoff check above the Full/eviction check so the more specific error always wins.

Suggestion:

Suggested change
if self.len() >= *self.transaction_count_limit
&& !self.evict_over_reservation(*self.transaction_count_limit)
{
return Err(OrphanPoolError::Full);
}
// An already-stale entry is rejected here instead of being inserted and immediately
// swept again: `remove_expired` has just run, so staleness is detectable without
// touching the pool. Reporting `Expired` keeps a swept-and-rejected resubmission
// distinguishable from `Full`, which callers read as resource exhaustion.
if entry.creation_time() < self.expiry_cutoff(cur_time) {
return Err(OrphanPoolError::Expired);
}
// Check staleness first: an expired entry should never be reported as `Full`, even
// when the pool has no room, so callers can distinguish the two conditions.
if entry.creation_time() < self.expiry_cutoff(cur_time) {
return Err(OrphanPoolError::Expired);
}
if self.len() >= *self.transaction_count_limit
&& !self.evict_over_reservation(*self.transaction_count_limit)
{
return Err(OrphanPoolError::Full);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d58462e: the expiry_cutoff check now runs before the Full/eviction check, so Expired wins whenever both conditions apply — and a stale entry no longer triggers an eviction for room it would never use. The exact scenario is pinned by a new unit test (stale_entry_on_full_pool_reports_expired_not_full): pool filled to capacity with fresh entries, a stale resubmission must yield Expired (the pre-reorder ordering would have produced Full), and the pool must remain untouched. While in the file, also cleared every clippy warning this branch had introduced in the mempool crate (map_or → is_some_and/is_none_or, Copy-type clones, and the constant-precondition asserts now bind through runtime locals).

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

Final state on d58462e — all review threads addressed and the check matrix is green, with the same four failures master also has (evidence in the earlier parity comment):

  • Review threads (4): expiry mislabeling → new OrphanPoolError::Expired produced by an explicit pre-insert staleness check, which also runs before the capacity check so Expired wins when a stale entry arrives on a full pool (pinned by stale_entry_on_full_pool_reports_expired_not_full); admission/eviction tier disagreement → single source of truth via max(recorded reservation, entry tier); doc literal → now references the constant; the expect on the origin stat lookup is intentionally kept (silent recovery would hide accounting drift from eviction decisions — rationale in thread).
  • Checks: review ✓, build_ubuntu ✓, build_windows ✓, build_macos ✓, all ledger variants ✓, all trezor variants ✓. static_checks ×3 (cargo-deny RustSec verification) and wasm_with_nodejs (tsc: command not found) fail identically on master — runs 40271121457 and 40270100755.
  • Local gates on the final commit: mempool 179/0/7, node-comm 13+2, workspace check/clippy clean, rustfmt clean.

@nullPointerEnjoyer

Copy link
Copy Markdown
Collaborator Author

CI fixes pushed in 20df2c7 and e3a54b8:

  • static_checks (×3): cargo-deny 0.20.2 (unpinned in CI) flagged 3 new RustSec advisories — patched rustls-webpki → 0.103.16, webpki-root-certs → 1.0.11, tokio → 1.51.6 (AsyncFd soundness, RUSTSEC-2026-0386); removed 2 stale advisory ignores from deny.toml; refreshed supply-chain imports (cargo vet import for all 8 configured registries, dropping 6 now-covered exemptions) and added a reviewed delta audit for libc 0.2.180 → 0.2.183; added the MIT license header to the new test file (codecheck.py gate).
  • wasm_with_nodejs: the job ran bare tsc, which resolved on runner images until recently — switched to npx --no-install tsc (binaries install at the repo root, the step runs in wasm-wrappers) and pinned typescript@5.9.3 + knip@6.43.0 so the job no longer tracks moving registry versions.

Both failures were reproduced locally before pushing; the full do_checks.sh gate and the wasm job steps (wasm-pack build → tsc → node tests → knip) pass locally. All 21 checks are green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant