Skip to content

feat!: hooks for fee-on-transfer token support - #593

Open
kayibal wants to merge 16 commits into
mainfrom
feat/fee-token-hooks
Open

kayibal wants to merge 16 commits into
mainfrom
feat/fee-token-hooks

Conversation

@kayibal

@kayibal kayibal commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Adds the hooks that code built on fynd needs to quote fee-on-transfer tokens: override quote amounts, read the simulation's event logs, simulate on the quote's own block, and follow the market read-only. With these hooks, a caller can learn each token's tax from simulations and correct the quotes.

Quotes

  • OrderQuote::set_amount_in and set_amount_out are public.
    • set_amount_out moves amount_out_net_gas by the same difference. A net output already at zero stays at zero.
    • set_amount_in drops the transaction, the fee breakdown and the simulation result, because they were built for the old amount.
  • OrderQuote::retract(status) withdraws a solved quote. It keeps only the status and the input amount.
  • EncodingOptions::with_slippage replaces the slippage and keeps the other options.

Simulation

  • SimulationResult::Success carries logs: Vec<EventLog>, every event the call emitted, undecoded. The field is #[serde(skip)], so the API response does not change.
  • New variants:
    • SimulationResult::Reverted for a call that ran and reverted. Failure now means only that the simulation did not run.
    • SimulationResult::BlockUnavailable for a node that does not have the quote's block.
    • Both reach the wire as failure.
  • SimulationResult is #[non_exhaustive], like QuoteStatus.
  • A quote is simulated on the block it was priced on (quote.block().number()), not on the latest block. The revert trace uses the same block.
  • When the node answers "header not found" (or "block not found" / "unknown block"), the simulation is asked again after 500 ms, and after twice the previous wait each time. The retries stop when the waits add up to two block times. All attempts together stay within the simulation request timeout (3 s by default).
  • The simulation metrics (quote_simulations_total, quote_simulation_deviation_bps, the gas histograms) carry a purpose label: quote or fee_token_sample.
    • simulate_quotes records quote. The new simulate_quotes_for takes a SimulationPurpose.
    • The two Grafana simulation panels filter on purpose="quote".
  • Token layout discovery, for the balance overrides of a simulated sender:
    • It takes an out-of-gas probe as a miss.
    • It finds the balance slot of a reflection token, whose stored balance is scaled by a rate (BalanceEncoding::Scaled).

Market access (behind experimental)

  • AppState::market_reader() returns MarketReader, which can read the market data but not write it.
  • AppState::subscribe_market_events() returns a receiver of the feed's MarketEvents. SolverParts::market_events() gives the subscribe-only MarketEvents handle it comes from.

Breaking changes

  • SimulationResult has new variants, a new field, and is now #[non_exhaustive]. Downstream matches need a fallback arm.
  • set_amount_in and set_amount_out change more than the one amount (see above).

Testing

  • Unit tests for each change.
    • Mocked RPC tests for block pinning and the retry timing, using wiremock, which is now a dev-dependency of fynd-core.
    • Layout discovery tests for accounts without code, out of gas, and scaled balances.
    • Metrics label tests.
  • The whole workspace passes clippy with -D warnings. The three panic-abort tests (test_simulate_path_contains_simulation_panic, test_water_fill_contains_simulation_panic, test_get_amount_out_guarded_converts_panic_to_error) abort locally on macOS, on main too; CI runs them.
  • Run against live Ethereum, Base and Robinhood nodes. Quotes and simulations of about 100 fee tokens, including reflection tokens.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Breaking API Changes (Intentional)

Breaking API changes detected and declared in the PR title.
release-plz derives the bump from commits: verify a !-marked commit is in this PR so the release PR shows a minor bump (breaking changes on 0.x.x bump the minor).

semver-checks output
Checking fynd-bench-harness v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   0.072s] 196 checks: 196 pass, 58 skip
     Summary no semver update required
    Finished [   0.141s] fynd-bench-harness
    Checking fynd-core v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   1.232s] 196 checks: 193 pass, 3 fail, 0 warn, 58 skip

--- failure enum_marked_non_exhaustive: enum marked #[non_exhaustive] ---

Description:
A public enum has been marked #[non_exhaustive]. Pattern-matching on it outside of its crate must now include a wildcard pattern like `_`, or it will fail to compile.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#attr-adding-non-exhaustive
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_marked_non_exhaustive.ron

Failed in:
  enum SimulationResult in fynd-core/src/types/quote.rs:1505
  enum SimulationResult in fynd-core/src/types/quote.rs:1505
  enum SimulationResult in fynd-core/src/types/quote.rs:1505

--- failure enum_no_repr_variant_discriminant_changed: enum variant had its discriminant change value ---

Description:
The enum's variant had its discriminant value change. This breaks downstream code that used its value via a numeric cast like `as isize`.
        ref: https://doc.rust-lang.org/reference/items/enumerations.html#assigning-discriminant-values
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_no_repr_variant_discriminant_changed.ron

Failed in:
  variant SimulationResult::Failure 1 -> 2 in fynd-core/src/types/quote.rs:1523
  variant SimulationResult::Failure 1 -> 2 in fynd-core/src/types/quote.rs:1523
  variant SimulationResult::Failure 1 -> 2 in fynd-core/src/types/quote.rs:1523

--- failure enum_struct_variant_field_added: pub enum struct variant field added ---

Description:
An enum's exhaustive struct variant has a new field, which has to be included when constructing or matching on this variant.
        ref: https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_struct_variant_field_added.ron

Failed in:
  field logs of variant SimulationResult::Success in fynd-core/src/types/quote.rs:1515
  field logs of variant SimulationResult::Success in fynd-core/src/types/quote.rs:1515
  field logs of variant SimulationResult::Success in fynd-core/src/types/quote.rs:1515

     Summary semver requires new major version: 3 major and 0 minor checks failed
    Finished [   2.097s] fynd-core
    Checking fynd-test-fixtures v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   0.087s] 196 checks: 196 pass, 58 skip
     Summary no semver update required
    Finished [   0.126s] fynd-test-fixtures
    Checking fynd-rpc v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   0.118s] 196 checks: 196 pass, 58 skip
     Summary no semver update required
    Finished [   0.186s] fynd-rpc
    Checking fynd-rpc-types v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   0.154s] 196 checks: 196 pass, 58 skip
     Summary no semver update required
    Finished [   0.236s] fynd-rpc-types
    Checking fynd-client v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   0.174s] 196 checks: 196 pass, 58 skip
     Summary no semver update required
    Finished [   0.281s] fynd-client
    Checking fynd-tools-common v0.112.2 -> v0.112.2 (no change; assume minor)
     Checked [   0.074s] 196 checks: 196 pass, 58 skip
     Summary no semver update required
    Finished [   0.103s] fynd-tools-common

@kayibal kayibal changed the title feat: hooks for fee-on-transfer token support feat!: hooks for fee-on-transfer token support Sep 30, 2026
@kayibal
kayibal force-pushed the feat/fee-token-hooks branch from 2956e0d to 5058098 Compare September 30, 2026 22:56
Comment thread fynd-core/src/simulation/token_layout.rs Outdated
Comment thread fynd-core/src/simulation/token_layout.rs Outdated
Comment thread fynd-core/src/simulation/simulator.rs Outdated
@kayibal
kayibal force-pushed the feat/fee-token-hooks branch from f06f814 to 030386d Compare October 13, 2026 13:40
kayibal and others added 16 commits October 14, 2026 14:48
WHY:
A service that shadows /v1/quote can change what the solver sees, but it cannot change the
quote that comes back. For a fee-on-transfer token, the route is solved for the amount that
arrives, but the calldata must pull the full amount. The caller also cannot refuse a route
that the solver found, because the status setter is private to the crate.

HOW:
- Add `OrderQuote::set_amount_in`, and make `set_amount_out` public.
- Add `OrderQuote::retract(status)`. It removes the route, the transaction, the fee
  breakdown, the simulation result, the surplus and the price impact. It sets the output and
  the gas to zero, as the no-route placeholder of the router does. `amount_in` does not
  change, because it is the amount of the order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A route override gets `AppState`, but it cannot read the market that the quotes are solved
on. The field is `pub(crate)` also when the `experimental` feature is on, and the builder
gives no other access. An override that must know the token registry cannot get it.

HOW:
- Add `AppState::market_data()`, behind the `experimental` feature, the same as the field.
  The field does not move and the default build does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A simulation reports only the amount the router call returned. It does not show what each
account sent and received. For a token that takes a fee on transfer, that is the data a caller
needs to find the tax: the pool gets less than the sender paid. `eth_simulateV1` returns the
logs, but the simulator discards them. It also reports a revert and a simulation that did not
run as the same failure, so a caller cannot tell a route that does not execute from a node
that did not answer.

HOW:
- Add `TokenTransfer` (token, from, to, amount).
- `SimulationResult::Success` gets `transfers`: the ERC-20 `Transfer` events of the call, in
  the sequence of emission. The simulator decodes them from the `eth_simulateV1` logs and
  skips all other events.
- Add `SimulationResult::Reverted`. `Failure` is now only for a simulation that did not run.
- The field has `#[serde(skip)]`, and the fynd-rpc-types conversion maps `Reverted` to the
  wire `Failure`. The API response does not change.
- The winning-protocols trace log shows `reverted` for a revert. Before, it showed `failed`.

ADDITIONAL NOTES:
There is no new router API. A caller continues to use `simulate_quotes` and reads the
transfers from `OrderQuote::simulation_result`.

Breaking change: `SimulationResult` has the new variant `Reverted`, and `Success` has the new
field `transfers`. A downstream `match` or pattern on `SimulationResult` must change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A caller that encodes one quote of a request with a different floor must build new
`EncodingOptions`. `EncodingOptions::new` sets all other options to their defaults, so the
caller loses the transfer type, the permit, the client fee and the watermark of the request.
A fee-on-transfer output token needs this: the receiver gets less than the router pays out,
so the floor must be lower than the amount the router expects.

HOW:
- Add `EncodingOptions::with_slippage`. It changes only the slippage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
Layout discovery could not fund a reflection token (KISHU and its copies), so its sells could
not be simulated. Such a token loops over a list in `balanceOf`. The sentinel written into the
length of that list made the call run out of gas, and discovery stopped on the error. Also,
such a token stores its balance multiplied by a rate near 10^51 and divides in `balanceOf`, so
no sentinel is ever read back from the correct slot.

HOW:
- Take an out-of-gas answer as "not this slot", as a revert already is.
- When no view places the balance, write a large value into each traced slot that is a mapping
  for the holder. A slot that reads back smaller but not zero, and reads back twice as much for
  twice the value, is a scaled balance with rate `value / answer`.
- Move the choice of the traced slots into `candidates`, so that the sentinel probe and the
  scaled probe use the same slots.
- `TokenLayout` records the encoding of the balance, and `balance_word` gives the word that
  funds it: the balance itself, or the balance times the rate, capped below `U256::MAX`.

ADDITIONAL NOTES:
The scaled probe runs only when the sentinel probes found no slot, so a token that is resolved
today makes no additional call. Discovery stays cached for each token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
A simulation kept only the ERC-20 `Transfer` events of the call. For a reflection token that is
not enough: the token takes its fee without an event, and its `Transfer` shows only the net
amount. The gross amount a pool sent is only in the pool's own `Swap` event, which the simulator
discarded. A caller could not find the tax of these tokens from the simulation.

HOW:
- Replace `TokenTransfer` with `EventLog` (address, topics, data): one log as the node returned
  it, not decoded.
- `SimulationResult::Success` carries `logs`, all the events of the call in emission order, in
  place of `transfers`.
- Remove the `Transfer` decoder. The caller decodes the events it needs.

ADDITIONAL NOTES:
The field keeps `#[serde(skip)]`. The API response does not change.

Breaking change: `TokenTransfer` is removed, and the field of `SimulationResult::Success` is now
`logs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
The simulator ran every quote on the latest block, but the solver priced the quote on the block in
`OrderQuote::block`. When a block landed between the two, the pools had moved, and the simulated
amount was different from the quote by that move. On Base, with a block every 2 seconds, this
made quotes look a few basis points wrong in one round and exact in the next. A caller that
compares a simulation with its quote could not tell that drift from a real error.

HOW:
- `SimulatedCall` gets `block`. A quote's simulation uses the number of the quote's block.
- `eth_simulateV1` and the `debug_traceCall` that names a revert both run on that block, so the
  trace still sees the state of the simulation.
- Add a test that a node asked for a different block does not answer. It uses `wiremock`, which
  is now a dev-dependency of fynd-core.

ADDITIONAL NOTES:
The block is given by number, not by hash, so a test quote with a placeholder hash still
simulates. A node that does not have the block yet refuses with "header not found", and the
simulation is a failure. On Ethereum this occurred 8 times in about 70 simulations: the Tycho
feed was one block ahead of the node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
A simulation runs on the block the quote was priced on. The Tycho feed can have that block before
the RPC node does, and the node then refuses with "header not found". On Ethereum this happened 8
times in about 70 simulations. Each one was reported as a failed simulation, like a node that
does not answer. A caller that counts failures against a token could disable it for a delay of
the node.

HOW:
- On "header not found" (also "block not found" and "unknown block", for other clients), ask
  again after 500 ms, then after double the previous wait each time. Stop when the waits add up to
  two block times. Other errors are not asked again.
- Keep all attempts and waits within the request timeout (3 s by default). Each attempt gets only
  the time that remains, and a wait that leaves no time for one more attempt ends the retries. A
  node that is behind thus cannot hold a quote longer than a node that does not answer.
- Take the block time from the chain in `QuoteSimulator::new`. `with_block_time` sets it for a
  simulator built from a provider.
- Add `SimulationResult::BlockUnavailable` for a block that did not come in time. It is not a
  failure of the route or of its tokens, and the caller can ignore it. On the wire it is still a
  `failure`.

ADDITIONAL NOTES:
With the default timeout the node gets about 2.5 s to catch up, which is less than two block
times on Ethereum. A block that arrives later gives `BlockUnavailable`.

Breaking change: `SimulationResult` has the new variant `BlockUnavailable`. A downstream
exhaustive `match` on it does not compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
This branch adds two outcomes to `SimulationResult` (`Reverted`, `BlockUnavailable`) and the
`logs` field to `Success`. The enum is public and exhaustive, so each added outcome breaks every
downstream `match` on it, now and each time an outcome is added again.

HOW:
- Mark `SimulationResult` `#[non_exhaustive]`, as `QuoteStatus` already is. Downstream matches
  handle future outcomes with a fallback arm.
- The wire conversion gets that arm and sends an unknown outcome as a `failure`.
- The doc comment now says the simulation runs on the quote's block, not the latest one.

ADDITIONAL NOTES:
The variants are not marked `#[non_exhaustive]`, so a downstream crate can still build them, for
example in tests. Matches inside fynd-core stay exhaustive, so a new outcome still has to be
handled there.

Breaking change: a downstream `match` on `SimulationResult` needs a fallback arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
`set_amount_in` and `set_amount_out` are public for fee-on-transfer callers, but they changed only
the one amount. After `set_amount_out`, `amount_out_net_gas` still showed the old output, so a
taxed buy reported a net-of-gas output above what the receiver gets, and even above `amount_out`.
After `set_amount_in` on an encoded quote, the calldata still pulled the old amount.

HOW:
- `set_amount_out` moves `amount_out_net_gas` by the same difference, because the gas cost does
  not change. A net output at zero stays at zero: it hides how far the gas cost exceeds the
  output, so it cannot be raised without a guess.
- `set_amount_in` drops the transaction, the fee breakdown and the simulation result. A setter
  cannot encode again, and calldata for the old amount must not stay on the quote.

ADDITIONAL NOTES:
`set_amount_out` leaves the transaction and the fee breakdown as they are. They describe what
the router enforces, and for a taxed buy the router must still expect the untaxed output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
`AppState::market_data()` returned the `MarketData` every solve reads, and with it `write()`.
A route override that only needs to read the market, such as the token registry, could change
the shared state or hold its write lock and stop every solve.

HOW:
- Add `MarketReader`, a cheap clone of `MarketData` that offers only `read()`.
  `MarketData::reader` makes one.
- `AppState::market_reader()` replaces `market_data()` and returns that handle.
- The test writes through the feed's own `MarketData` and reads it back through the handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
The simulation metrics count every simulation as a client quote. A service that simulates
quotes for itself, such as samples of a fee-on-transfer token, adds reverts and large
deviations that clients never get. The dashboards of client quotes then show wrong values.

HOW:
- Add `SimulationPurpose` with `Quote` and `FeeTokenSample`, exported at the crate root.
- Add `WorkerPoolRouter::simulate_quotes_for`, which takes a purpose. `simulate_quotes`
  calls it with `Quote`, so its callers do not change.
- Give `quote_simulations_total`, `quote_simulation_deviation_bps` and the two gas
  histograms a `purpose` label, with the value `quote` or `fee_token_sample`.
- Show only `purpose="quote"` in the Grafana panels of simulation outcomes and deviation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
A route override that keeps its own view of the market (for example, the tokens and pools a
route must not use) could only read the whole market again to see a change.
On a large market with a new pool in most blocks, that is a full scan on a request path, once per
block. The feed already broadcasts which pools each update adds and removes, but only fynd's own
components can subscribe.

HOW:
- Add `MarketEvents`, a handle on the feed's broadcast that can subscribe but cannot send.
- `SolverParts::market_events` gives one out, and fynd-rpc keeps it in `AppState`.
- `AppState::subscribe_market_events` (experimental) returns a new receiver. Events sent before
  the call do not arrive, so a caller reads the market after it subscribes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
The docs build failed: the doc comment of the public `TokenLayout::balance_word` linked to the
private `MAX_SCALED_WORD`, and the build treats that as an error.

HOW:
- Give the cap in the text (just below 2^250) and remove the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
The doc comments of this branch told the history of the work: the tokens and nodes that
showed a problem, and why other designs were not used. A reader needs to know what an item
does now. The history made the comments long and hid that data.

HOW:
- Start each doc comment with one sentence that says what the item is or does.
- Keep only the data that a caller needs: what changes, what stays the same, units, errors.
- Remove token names, node names, chain names and measured counts.
- Remove the doc comments of tests whose name says what they assert.

ADDITIONAL NOTES:
Only comments change. The code is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
WHY:
The doc comments of the new and changed functions were dense and did not name the
arguments, the return value or the errors. A reviewer had to read the body to know what a
function takes and gives back.

HOW:
- Start each doc comment with one plain sentence that says what the function does.
- Add `# Arguments`, `# Returns` and `# Errors` sections, as other fynd-core functions do.
- Write the steps of a probe or a retry as a list or as short sentences.

ADDITIONAL NOTES:
Only comments change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDriANMezML2o1C6T9vP2o
@kayibal
kayibal force-pushed the feat/fee-token-hooks branch from 030386d to b8bb685 Compare October 14, 2026 12:58
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