# ADR-037: The swap-simulation gate — one deep swap-read interface **Status:** Accepted **Date:** 2026-08-25 **Task:** NHNNIZ / A7T56B (epic "Swap-simulation gate") ## Context Every simulation read on `BotState` shipped in two shapes: a `*_miss_aware` variant returning `Result<_, SimulateSwapError>` and a `*_with_fetch` twin that re-implemented the identical fetch → merge → retry loop three times (tokens-out, exact-input, exact-output), each with its own dedup set and give-up-to-`U256::ZERO` semantics. Two further reads carried the same disease in worse forms: `calculate_tokens_in` silently swallowed `NotComputable` *and* `MissingTickWord` for V3/V4 pools behind a docstring claiming constant-product math, and `simulate_swap_with_override` carried two contradictory doc blocks over its fourth copy of the retry loop, shaped as a 10-argument parameter bag. The consequence was a shallow interface: callers had to know which shape they held, failure modes were invisible (`ZERO`/`None` meant at least four distinct things), and any change to give-up semantics required three synchronized edits plus three parity suites. An architecture review (ergo epic `NHNNIZ`) settled the replacement design; this ADR records it so future reviews do not re-suggest the twins. ## Decision 1. **One public swap-read interface.** `BotState::swap_simulation(pool_id, SwapRequest) -> SwapRead` (module `bot_core/swap_simulation.rs`) replaces all seven methods: the three `*_miss_aware` twins, the three `*_with_fetch` twins, and `calculate_tokens_in`. The pure family cores stay put (`degenbot_pools::simulate_swap::simulate_swap`, `v3_state::v3_simulate_swap`, `v4_state::v4_simulate_swap`); the module owns request/outcome types and the single fetch→merge→retry policy, generic over compute closure + merge target so the override path reuses it without touching registered state. 2. **Signed request, user perspective.** `SwapRequest { zero_for_one, amount_specified: I256, sqrt_price_limit }`: positive `amount_specified` = exact-output (the pool delivers that magnitude to the user); negative = exact-input (the user sends it). This follows the **V4/user perspective** deliberately — neither engine's internal convention is canonical at this seam. The mapping lives in exactly one place inside the module: - V3 engine: negates both directions vs canonical (V3 exact-in `>0`, exact-out `<0`); - V4 engine: identity (exact-in `<0`, exact-out `>0`). Outcome deltas are reported in the same user perspective (positive = received). The mapping is pinned by table tests. 3. **Typed outcome, hard cutover.** No backwards-compatibility layer (per AGENTS.md): every former silent-`ZERO`/silent-`None` failure mode becomes an observable variant — `NotComputable`, `FetchFailed { word }`, `FetchExhausted { word }` (repeated miss on an attempted word, or no fetcher registered). The information is never destroyed; a convenience flatten may exist for callers that want today's ergonomics. 4. **Trust = caveat set, not bool.** Outcomes carry an additive, `#[non_exhaustive]` flag set whose EMPTY value means "this number is exact". First variant `SparseCoverage`, derived from registration-time `PoolTickCoverage` (glossary Tracked/Sparse). Per-family payload variants keep invalid states unrepresentable (V2 has no per-hop detail; CL payloads carry consumed/delivered/end-state/`fetched_words`). 5. **Hooked pools: caveat, not refusal.** `RegisterV4PoolError::DynamicFee` and `FeeExceedsEncoderLimit` rejections stay (un-encodable fee / fee-flag ambiguity). Amount-modifying hooks are ADMITTED since ergo task `X4EU3J`: their simulations carry `Caveats::HOOKED_POOL` (derived from `hook_flags & 0xCC`, the low 16 bits of the hook address per v4-core `Hooks.sol`) and hop projection excludes them from solving, so the net trading behavior is unchanged while the pools become queryable. The archived Python pattern (`archive/main-20260721` `v4_liquidity_pool.py` `Hooks` enum; `PossibleInaccurateResult` carrying the approximate amounts) is the model for the Python-side surfacing in the tail task. ADR-012's spec-width admission contract is unaffected (storage widths, not hooks). ## Consequences - The miss policy has ONE home: dedup set, repeated-miss give-up, missing fetcher handling, and reentrancy discipline (fetcher cloned off the entry before looping) concentrate where they are unit-tested once. - Both consumers benefit equally — the standalone Rust bot and the PyO3 driver shell sit on the same seam; Python-side retry/sign-shuffling becomes deletable after the FFI stabilizes (tail task `3XGX4A`). - Parity tests that pinned silent-ZERO shapes now assert explicit variants. - Non-goals: no third bespoke pump channel or bus unification (ADR-027); no window-guard changes (ADR-036); no solve-seam reshaping (ADR-015); no family-trait work (ADR-014/016/017). Domain vocabulary lives in CONTEXT.md under "Swap simulation".