ADR-055: Pending-transaction strategy seams — MarketContext, PendingTxStrategy, SubmissionTarget, and the V4 closure

Status: accepted (2026-09-18). Basis: the seam synthesis (.scratch/strategy-arch-survey/strategy-seam-design.md, reviewed and approved), ADR-054’s promoted evidence seams, ADR-019’s strategy-vs-engine doctrine. The engine-family generalization of ADR-018 is pulled here by decision: a strategy suite over one core is the stated end-state, which Phases B/C below schedule.

Context

Two strategies (settlement arbitrage over sealed blocks; backrun over observed pending transactions) shared a healthy lower substrate (solvers, pools, simulation, executor grammar, RPC provider) but nothing above it: strategy was not a config/console/launcher concept, the pending-transaction pipeline was one monolithic function (frame_pipeline::process_frame) with backrun selection and economics inline, StrategyRuntime misnamed shared caches as strategy identity, V4 pools could neither be extracted from the replay journal nor admitted into the per-trigger sandbox, and unknown pool families were silently skipped at use sites.

Decision

D1 — Vocabulary (CONTEXT.md is canonical)

pending transaction, PendingTxDriver, PendingTxStrategy, SettledBlockStrategy, MarketContext, ComposedIntent/Decided, SubmissionTarget, strategy facet. Names carry plain meanings; the retired “frame”/”lane” vocabulary survives only where a historical module name still says it.

D2 — The pending-transaction seam (landed, commit 024cad4d4)

  • MarketContext (degenbot-submission/src/market_context.rs) — the frame-surviving caches: connector index + DFS graph, token id/address joins, warm code cache. Substrate, never strategy; the former StrategyRuntime is retired.

  • PendingTxStrategy (degenbot-submission/src/pending_tx.rs) — one strategy reacting to observed pending transactions. Stages: admit → discover → evaluate → compose → decide, with strategy-neutral hand-off artifacts (ComposedIntent, Decided) and associated types (Affected/Intents/Evaluated) keeping strategy shapes out of the driver. Consumed via generic dispatch; the async_fn_in_trait expectation records the no-dyn contract.

  • BackrunStrategy (degenbot-submission/src/backrun_strategy.rs) — the first implementation: WETH/quote-orientation gating, anchored DFS wiring, envelope-gated evaluation, net-bid economics, and the decision policy moved out of the driver. The driver’s stage code names no WETH / MEVBlocker / wallet-economics vocabulary.

  • Driver side — frame_pipeline::process_frame is re-expressed as the pending-transaction driver: replay (ADR-054 seam 1) → descriptors + journal extraction (seam 2) → the strategy’s stage chain → bundle simulation gate → submission. The driver keeps replay/extraction/ timings/liveness as strategy-neutral machinery; PipelineConfig and the honest observe vocabulary are driver-owned.

  • SubmissionTarget { Bundle(BundleTarget), Public } at dispatch_and_submit — the typed channel vocabulary replacing Option<&BundleTarget>; the Python FFI wrapper passes Public.

D3 — V4 is first-class in the pending-transaction substrate

  • Sandbox admission (commit 90668c03e): ExplicitPoolState::V4 delegates to the canonical register_v4_pool; a golden V4→V2 route solve (workspace_v4_cycle_reaches_the_solver) pins the behavior.

  • Journal extraction (commit 024cad4d4): PoolFamily::V4PoolManager carries a V4PoolSet of known pool identities; extraction decodes slot0, liquidity, and touched tick words per poolId against the pinned PoolManager layout (docs/architecture/v4_poolmanager_storage_layout.md). An unknown poolId NEVER fabricates state — it stays explicitly Unsupported. The stale “layout nobody pinned” premise is revoked.

  • Remaining lane-level gap (tracked): the connector index (V2ConnectorIndex) carries V2/V3 edges only, so production descriptors pass an empty V4PoolSet until the index learns V4 identities and SuccessStrategy compose gains V4 funding/capture shapes. Substrate is ready; lane wiring is a strategy task, not a substrate defect.

D4 — Loud-abort posture for pool families at use sites

Standing rule (user directive): if code tries to use a pool of a family the infrastructure cannot serve, that aborts loudly (typed fatal/tripwire), never a silent skip. Transient RPC/timing/fetch failures keep existing skip semantics. The audit (.scratch/strategy-arch-survey/loud-abort-audit.md) found 24 family-at-use silent-skip sites vs 8 legitimate transient skips — the five worst (silent pool-kind drop in DB pathfinding, #[non_exhaustive] PoolKind walker abandonment, silent V4-half drop in mixed frames, fabricated zero V2SwapOutcome for unknown pools, silent exchange-name drop in the pool updater) are the fix order; each fix is a typed-abort conversion with a named red test.

D5 — Phases B/C (recorded, not built here)

  • Phase B (ergo PF37R7): core-service consolidation toward the strategy host — one event hub over pending-tx feeds + newHeads/logs with fan-out to subscribed strategies; one pool-state tracker every strategy reads (the sidecar’s leaked empty anchor retires); one route registry built once and shared by reference; both drivers consume services by handle.

  • Phase C (ergo GOTEEG): the dynamic strategy host — register/enable/ disable strategies at runtime (operator verbs + .enabled facets; the current strategy.name single-selector lets exactly one arm run until then); pulls the ADR-018 engine generalization for the settled-block family (parameterized stage payloads, generic EngineDriver, per-family fleet globals) — scheduled, not speculative.

D6 — Amendments to ADR-054 (corrections of record)

  • Seam 4’s cited path_selection::solve_witnesses never landed; the witness role is played by SolveStats/ChainOutcome in the pipeline.

  • Anchored discovery is depth-capped at 3 hops (find_paths_iter(..., Some(2), ...)); “3-hop and beyond by curve” is an aspiration, not a shipped capability.

  • The decision gate consumed the per-frame liveness pair (age_ms > stale) historically; the finality-based liveness FSM (Tentative/revive/death; NonceConsumed/MinedAt/SlotTakenAt) replaced it and is deliberately strategy-agnostic channel machinery — its eventual home is the submission channel (Phase B), recorded jointly with the liveness task’s own record.

Consequences

  • Adding a pending-transaction strategy is: one PendingTxStrategy impl + typed config facet readers + one launcher/console row. The four surveyed wedge buckets (driver/FFI/CLI/config/env) collapse to one new Rust module and values.

  • V2/V3/V4 all admit, extract, and solve inside the per-trigger sandbox.

  • The settlement path drives the same submission channel with SubmissionTarget::Public; its remaining Python-side candidate assembly is tracked (ergo GE5BE7).

  • Loud-abort is the running audit-and-fix posture (ergo TYIXQ7, blocked on the fix pass).