Skip to content

feat(core): set min_amount_out from the pAMM fallback amount - #402

Open
tamaralipows wants to merge 6 commits into
mainfrom
tnl/feat/propamm-fallback-quoting
Open

feat(core): set min_amount_out from the pAMM fallback amount#402
tamaralipows wants to merge 6 commits into
mainfrom
tnl/feat/propamm-fallback-quoting

Conversation

@tamaralipows

@tamaralipows tamaralipows commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fynd side of the pAMM simulation fix. Contract side: propeller-heads/tycho#1278.

Problem

propeller-heads/tycho#1278 makes the PropAMMRouter fall back to a Uniswap V3 pool when a stale pAMM quote reverts the venue. That is not enough, because Fynd writes min_amount_out before the fallback runs.

The pAMM quotes 2000 USDC. The user accepts 1% slippage.

1. Fynd quotes the pAMM leg          -> 2000 USDC
2. Fynd writes min_amount_out        -> 1980 USDC   (2000 less 1%)
3. Integrator simulates. pAMM stale. Router falls back to Uniswap V3.
4. Uniswap V3 pays                   -> 1950 USDC
5. TychoRouter compares 1950 < 1980  -> revert

Step 3 works. Step 5 fails, so the revert moves from StaleUpdate to NegativeSlippage.

How it works

Four steps, one per commit.

Learn which pool the router would use. The router picks it with resolvedFee(tokenIn, tokenOut), a public view function. A background task reads that on a timer and caches it. Governance changes it rarely, and only about 12 pairs across Fermi and Kipseli need one, so the quote path never waits on RPC.

Keep the candidate pools indexed. Each solver worker holds an index of Uniswap V3 pools by token pair and fee tier, built once and updated from market events. A pool qualifies on its component alone, so the index only moves when the market adds or removes one. Rebuilding it per block would cost 97 ms.

Price the fallback while solving. When a worker produces a route with a pAMM leg, it replays that route with each pAMM leg swapped for its fallback pool, and stamps the result on the route. Replay is the existing replay_route, so split fractions and shared-pool depletion behave as they do everywhere else. A route whose fallback cannot be priced is dropped rather than quoted — with no fallback amount there is no min_amount_out we can justify.

Use it when encoding. The encoder derives min_amount_out from the stamped amount instead of the pAMM quote. amount_out in the response stays the pAMM quote; only the floor moves.

No client impact

Clients change nothing.

The wire format is untouched — clients/openapi.json is identical to main, so there is no field to set and no client to regenerate.

Nothing emits propammfallback: yet, so today every route reports no pAMM leg and behaviour is unchanged. Venue selection turns it on.

Once on, amount_out still carries the pAMM quote. Only min_amount_out drops, on pAMM routes, which is what lets those quotes simulate.

Still open

Selecting which venues use the propammfallback: prefix. That belongs in tycho-simulation's PriceLevelStreamBuilder (propeller-heads/tycho#1212) and must read the router's on-chain whitelist.

Limits

bopAMM does not work. The PropAMMRouter whitelist does not contain its venue, so bopAMM quotes continue to fail simulation.

A venue outside the whitelist is worse than not doing this. Every swap then executes on Uniswap V3 at a worse price than the pAMM gives.

Large trades get weak slippage protection. One pool in one fee tier moves 60 bps on 1000 WETH to USDC (about $1.87M), and 7 bps on $2M USDC to USDT. min_amount_out also bounds a sandwich attack, so a low floor widens it — about $11,200 of headroom on that WETH trade.

Set a cap before this ships: trade size, or refuse the pAMM leg when the fallback sits more than N bps below it. Open decision.

🤖 Generated with Claude Code

`fallback_amount_out` re-simulates a route with every `propammrouter:`
leg replaced by the Uniswap V3 pool the PropAMMRouter falls back to.
The encoder derives `min_amount_out` from that number instead of from
the pAMM quote.

The contract-side retry (propeller-heads/tycho#1278) stops a stale pAMM
quote from reverting the venue call, but the route-level `minAmountOut`
still comes from the pAMM quote, which the fallback cannot clear. So the
route reverts on `NegativeSlippage` instead of `StaleUpdate`.

`FallbackFees` mirrors the router's `resolvedFee`: the per-pair override
if set, else `fallbackFee`. `FallbackPoolIndex` indexes uniswap_v3
components by sorted pair and fee tier, so the quote path needs no RPC
call. `FallbackAmountOut::NoFallbackPool` marks a route the caller must
drop, because no pool exists at the resolved tier and the fallback would
revert too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tamaralipows
tamaralipows force-pushed the tnl/feat/propamm-fallback-quoting branch 2 times, most recently from 38af81d to c975481 Compare August 6, 2026 03:54
tamaralipows and others added 2 commits August 6, 2026 00:17
`FallbackFeeFetcher` reads `fallbackFee` from the PropAMMRouter and the
per-pair override for every pair the pAMM venues quote, then writes them
into `SharedFallbackFees`.

Governance can change both without a contract upgrade, so the tiers
cannot be hardcoded. They change rarely, so a timer is enough and the
quote path stays free of RPC calls.

Each refresh costs one `fallbackFee` call, one `getPairs` call per
venue, and one `getPairFee` call per pair. Fermi quotes about 8 pairs
and Kipseli about 4. A venue that fails to answer is skipped, so one
unreachable venue keeps the tiers of the others. A failed `fallbackFee`
read aborts the refresh and keeps the previous tiers, because every pair
without an override resolves through it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SolverWorker` builds a `FallbackPoolIndex` when it initializes its
graph, then updates it from every `MarketEvent::MarketUpdated`.

A pool qualifies on its component alone — protocol system, token pair
and `fee` attribute — so the index only changes when the market adds or
removes a component. `apply_event` costs one lookup per added component.
A full rebuild takes 97 ms over 40,000 components, so doing it per block
or per quote would be visible in solve latency.

State changes cannot alter whether a component qualifies, so
`updated_components` are ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tamaralipows
tamaralipows force-pushed the tnl/feat/propamm-fallback-quoting branch 3 times, most recently from 85be9ba to ba735e7 Compare August 6, 2026 19:50
tamaralipows added a commit to propeller-heads/tycho that referenced this pull request Aug 6, 2026
Adds `PropAMMFallbackExecutor` and the `propammfallback:{venue}`
protocol family. Same calldata as #1212's `PropAMMExecutor`, so both
share `PropAMMSwapEncoder`; only the call target differs.

`PropAMMExecutor` calls the venue directly, so a stale maker quote
reverts the route. That is what makes integrator simulations fail on
routes that execute fine in a Titan block. Titan's PropAMMRouter wraps
the venue call in try/catch and falls back to a single-hop Uniswap V3
pool at `resolvedFee(tokenIn, tokenOut)`.

The router address is a constant, not a constructor argument. There is
one deployment, it sits behind a UUPS proxy so upgrades keep the
address, and a wrong value would route every swap to an arbitrary
contract.

`amountOutMin` is 0 on the router call: any non-zero value makes the
Uniswap fallback revert on price for the trades it exists to rescue. The
TychoRouter's route-level `minAmountOut` is the binding check, so the
quoting side must lower it — see propeller-heads/fynd#402.

Gas: the family is ProtocolWillDebit (input transfer plus approval) and
carries `PROPAMM_FALLBACK_OVERHEAD_GAS` for the router itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tamaralipows added a commit to propeller-heads/tycho that referenced this pull request Aug 6, 2026
Adds `PropAMMFallbackExecutor` and the `propammfallback:{venue}`
protocol family. Same calldata as #1212's `PropAMMExecutor`, so both
share `PropAMMSwapEncoder`; only the call target differs.

`PropAMMExecutor` calls the venue directly, so a stale maker quote
reverts the route. That is what makes integrator simulations fail on
routes that execute fine in a Titan block. Titan's PropAMMRouter wraps
the venue call in try/catch and falls back to a single-hop Uniswap V3
pool at `resolvedFee(tokenIn, tokenOut)`.

The router address is a constant, not a constructor argument. There is
one deployment, it sits behind a UUPS proxy so upgrades keep the
address, and a wrong value would route every swap to an arbitrary
contract.

`amountOutMin` is 0 on the router call: any non-zero value makes the
Uniswap fallback revert on price for the trades it exists to rescue. The
TychoRouter's route-level `minAmountOut` is the binding check, so the
quoting side must lower it — see propeller-heads/fynd#402.

Gas: the family is ProtocolWillDebit (input transfer plus approval) and
carries `PROPAMM_FALLBACK_OVERHEAD_GAS` for the router itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tamaralipows tamaralipows changed the title feat(core): price the PropAMMRouter's Uniswap V3 retry feat(core): set min_amount_out from the pAMM fallback amount Aug 6, 2026
tamaralipows and others added 2 commits August 6, 2026 18:55
`SolverWorker` computes `fallback_amount_out` for every route it
produces and stamps it on the `Route`. The encoder reads it to derive
`min_amount_out`. A route with no `propammrouter:` leg is left alone.

Routes the fallback cannot price are dropped, not returned: without a
fallback amount there is no justifiable `min_amount_out` for them. That
covers a pair with no Uniswap V3 pool at the router's fee tier, and
split routes until they are supported.

`SharedFallbackFees` reaches the worker through `WorkerPoolConfig`, so
every worker reads the tiers the `FallbackFeeFetcher` refreshes. The
fetcher runs on Ethereum only, since the PropAMMRouter is deployed
there; on other chains every pair resolves through the compiled-in
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The encoder floors `min_amount_out` at the amount the route delivers
when its pAMM legs fall back to Uniswap V3. `amount_out` in the response
stays the pAMM quote; only the floor moves.

Without this the contract-side fallback does not help: the route stops
reverting inside the venue and starts reverting on the router's own
price check instead.

`EncodingOptions::cover_propamm_fallback` turns it off, and defaults to
on. A caller that submits through Titan sets it to `false`: the maker's
quote always lands there, so the tighter bound protects them better.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tamaralipows
tamaralipows force-pushed the tnl/feat/propamm-fallback-quoting branch 2 times, most recently from 063a04a to 2dd51c9 Compare August 6, 2026 23:20
`fallback_amount_out` replays the route with each `propammfallback:` leg
pointing at its Uniswap V3 fallback pool, instead of walking the swaps
itself.

`replay_route` already threads split fractions and post-swap pool states,
so a split route prices correctly and two legs on one fallback pool see
it deplete. Split routes are no longer dropped.

`FallbackAmountOut::SplitNotSupported` is replaced by `NotPriceable`,
which covers any route the substituted simulation rejects.

Also renames the protocol prefix to `propammfallback:`, matching
propeller-heads/tycho#1278, orders the module outward-facing first, and
drops the `cover_propamm_fallback` encoding option. Nobody submits pAMM
routes through Titan today, so the opt-out was speculative; with it gone
the wire format and the OpenAPI spec are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tamaralipows
tamaralipows force-pushed the tnl/feat/propamm-fallback-quoting branch from 2dd51c9 to 5fe4b91 Compare August 6, 2026 23:32

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the file I've reviewed the least. Please use extra care here

@tamaralipows
tamaralipows marked this pull request as ready for review August 6, 2026 23:39
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