Skip to content

feat(core): add rfq_swap_replacer built-in post-processor - #624

Merged
carloszanella merged 24 commits into
mainfrom
cz/feat/rfq-swap-replacer
Oct 14, 2026
Merged

carloszanella merged 24 commits into
mainfrom
cz/feat/rfq-swap-replacer

Conversation

@carloszanella

@carloszanella carloszanella commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary: PR body describes the feature and the important decisions well. I made Codex write it because Claude writes like a regard. Tested locally, the post processing takes basically no time. To enable liquorice we need to add it to the post processor protocol list and disable it from the solver so the quotes are not used partially.

Business logic

Feature

The new built-in post-processor rfq_swap_replacer runs when a worker pool lists it in its post-processors. It replaces a swap, a segment between two route tokens, or the whole order with RFQ swaps from components that the market tracks, using both direct RFQ components (rfq:) and RFQ components through the fallback router (fallback:rfq:). It keeps a change only when the net amount out after gas beats that of the route it received. Worker pools with no post-processors do not change; worker pools with other post-processors get only the failure fallback described below.

  • The new worker pool key algorithm_exclude_protocols removes protocols from the algorithm's graph and its fallback pricing, while keeping them visible to post-processors. For example, ["rfq:bebop"] means Bebop enters a route only through the replacer, so the replacer's gain is measured alone. The existing key exclude_protocols keeps its meaning: the worker pool never routes through those protocols.

Business decisions

  • Partial fills. An RFQ swap can replace a swap in the middle of a route only if the protocol accepts a smaller amount in than it quoted. The code keeps an allow-list in PARTIAL_FILL_PROTOCOLS (fynd-core/src/post_process/rfq.rs), which currently holds rfq:bebop and rfq:hashflow. Every other RFQ protocol can replace only the whole route or swaps that start at the sell token, because only those swaps receive the exact amount in that the quote was for.
  • Failures. This fallback applies to every worker pool with post-processors. If a processed route fails the worker's checks, including the fallback pricing of a fallback: leg, the worker returns the algorithm's route instead of a failed quote. It logs a warning with the worker pool, the algorithm, the post-processors that made the route, and the error. It also increments worker_pool_post_processed_route_rejected_total{pool}.
  • Deadlines. The search stops at the post-process deadline, 10 ms before the task deadline. The replacer checks the deadline before each candidate, but a simulation that has started always runs to the end.
  • Prices. The replacer uses indicative RFQ state. The binding quote at encode time can be worse or can fail, so a replaced route can fill worse than the algorithm's route or fail at encoding.
  • Unchanged routes and skipped components. The replacer leaves routes unchanged when they have zero gas or the market read fails. It skips components that the request excludes or the worker pool's drop rule removes.
  • Reserved name. An embedder cannot register a custom post-processor with the reserved name rfq_swap_replacer.
  • Public getter. The new public getter is OrderQuote::post_processed_by(). The field uses #[serde(skip)], so the HTTP response and the OpenAPI do not change.

Observability

  • worker_pool_post_processor_outcomes_total{pool,post_processor,outcome} records the outcomes replaced, kept, timed_out, and skipped.
  • worker_pool_post_processor_gain_bps{pool,post_processor} is a histogram of the gain with its own buckets.
  • worker_router_best_quote_pool, worker_router_won_volume_gas_token and worker_router_unpriced_won_quotes_total gain the label post_processed_by. Its value is the post-processor names joined with +, or none. Existing series split by this label; a sum by pool gives the old totals.
  • worker_pool_post_processed_route_rejected_total{pool} increments when a processed route fails the worker's checks.
  • The warning log records the worker pool, the algorithm, the post-processors that made the rejected route, and the error.

🤖 Generated with Claude Code

@carloszanella
carloszanella added this pull request to stack #625 October 11, 2026 16:10
@carloszanella
carloszanella force-pushed the cz/feat/rfq-swap-replacer branch from 90bd6a8 to 6887f1c Compare October 11, 2026 16:39
@carloszanella carloszanella changed the title feat(core): add RFQ swap replacer post-processor feat(core): add rfq_swap_replacer built-in post-processor Oct 11, 2026

@Troshchk Troshchk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you!
Left some comments and 2 qs:

  1. We only support rfq: now, should we insert fallback:rfq instead?
  2. Can we somehow limit which rfqs are used in per hop swap(with partial fills supported) and whole route swap(partial fills do not matter

Comment thread fynd-core/src/encoding/encoder.rs Outdated
Comment thread fynd-core/src/post_process/rfq.rs Outdated
Comment thread fynd-core/src/worker_pool/worker.rs
Comment thread fynd-core/src/post_process/rfq.rs
@carloszanella
carloszanella force-pushed the cz/feat/rfq-swap-replacer branch 2 times, most recently from e5b822a to 18e4e84 Compare October 13, 2026 09:50
Base automatically changed from cz/feat/route-post-processing to main October 13, 2026 16:36
@carloszanella
carloszanella force-pushed the cz/feat/rfq-swap-replacer branch 2 times, most recently from 351c134 to 46732ec Compare October 13, 2026 23:09

@Troshchk Troshchk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you! A couple of qs

Comment thread fynd-core/src/post_process/pipeline.rs
Comment thread fynd-core/src/types/quote.rs
Comment thread fynd-core/src/solver.rs

@Troshchk Troshchk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you!

carloszanella and others added 18 commits October 14, 2026 16:30
WHY:
The encoder checks whether a route has an exclusive leg, and the RFQ
replacer needs the same check. One method on Route keeps the rule in one
place.

HOW:
- add Route::has_exclusive_leg: any swap carries a committed amount
- the encoder calls it in place of its own has_exclusive_leg function,
  and the function goes away

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
The router finds an RFQ leg by removing the fallback prefix and testing
for the RFQ prefix. The RFQ replacer needs the same test. Two copies of
one rule can drift apart, so the rule lives in one place.

HOW:
- add strip_fallback_prefix and is_rfq_protocol to the protocol registry
- has_rfq_leg in the router calls is_rfq_protocol

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- an rfq: protocol is an RFQ protocol
- an RFQ protocol behind the fallback router is an RFQ protocol
- the fallback pAMM protocol is not an RFQ protocol
- an on-chain protocol is not an RFQ protocol

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A post-processor tries to improve a route. When its route broke a check,
the worker failed the whole quote, so a faulty or over-eager processor
cost a quote the algorithm had already found. A failed improvement must
leave the quote as the algorithm made it.

HOW:
- check the algorithm's route first, as before post-processors existed;
  an invalid algorithm route still fails the quote
- then run the post-processors on that route, only when the pool has
  post-processors
- PostProcessors::run returns the last route a post-processor returned
  with the names of the post-processors that returned one, or None when
  none replaced the route, so the worker keeps the algorithm's route
  without a clone
- check the processed route with route validation, the request route
  filter and the pool checks
- price the fallback legs of a processed route in the same step, so a
  processed route with a leg that has no fallback fails like any other
  check; the algorithm's route is priced only when no post-processor
  replaced it
- on a failure, log the reason and the names at warn, count it in
  worker_pool_post_processed_route_rejected_total{pool}, and quote the
  algorithm's route
- state the new order and the fallback in the post_process module and
  trait docs

ADDITIONAL NOTES:
The tests do not build until the next commit, which updates them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the pipeline returns the last route a post-processor returned, with the
  names of the post-processors that returned one, in config order
- it returns None when every post-processor keeps the route, when one
  runs past the deadline, and when the deadline has already passed
- a post-processor that runs past the deadline does not drop the route
  of an earlier post-processor
- each rejected processed route quotes the algorithm's route: disconnected
  swaps, a protocol the request excludes, a protocol the pool excludes,
  an exclusive component and a net amount out above the amount out
- a rejected processed route increments the rejected-route counter with
  the pool label
- a processed route with a fallback leg and no fallback pool is rejected
  and counted
- a processed route with a fallback leg that has a pool gets its fallback
  amount in the quote
- the request a post-processor gets carries the state label and the
  exclusions, and the algorithm's route does not use the excluded pool

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A post-processor sees the whole market in initialize, including
components its pool drops. A route through one of them fails the worker
check, and the worker then throws away every improvement in that route.
The post-processor needs the pool's rule to skip those components.

HOW:
- add an optional pool drop rule to PostProcessRequest, set with
  with_pool_drops, and drops_component to ask it; with no rule set, it
  drops nothing
- the worker sets the rule from its liquidity_scope and
  exclude_protocols, the same rule its own check uses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the request a post-processor gets drops a component of an excluded
  protocol and keeps one the pool allows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
An RFQ component can quote a pair better than the swaps the algorithm
chose, but the algorithm scores paths before it simulates them and can
miss it. A pool needs a step that tries RFQ components on the route the
algorithm returned and keeps them when the net amount out rises. Every
deployment that indexes RFQ protocols can use it, so it ships with
fynd-core like the built-in algorithms.

HOW:
- index the rfq: components by token: initialize builds the index from
  the market, and market events add and remove components
- under the market read lock, copy one swap template per RFQ component
  that touches two route tokens and that the request does not exclude
  and the pool does not drop; search with the lock released
- build targets from the route: each swap, keeping its split, and each
  segment of swaps between two tokens that every path passes through,
  which includes the whole order
- re-simulate the whole route with the RFQ swap in place of each
  target, and price the gas like the router: the old gas cost times the
  new gas over the old gas
- re-simulate the original route first, so a candidate must beat that
  net amount out, not the algorithm's own figure
- apply the best candidate and search again, until no candidate raises
  the net amount out, every swap had a round, or the deadline passes;
  the deadline is checked before each simulation
- keep the route (return None) for buy orders, routes with an
  exclusive leg, routes with no gas, a market read that fails, and a
  result that is not above the net amount out of the algorithm's route
- split the simulation loop of replay_route into simulate_swaps, which
  also returns the amounts and gas of each swap; replay_route does not
  change
- add Swap::retarget to set the pair and split of a template swap
- PostProcessorRegistry::resolve finds rfq_swap_replacer with no
  registration; a custom registration of a built-in name is refused, and
  the unknown-name error lists the built-in names too
- say in the solver docs that a pool may list a built-in name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a hop is replaced, and the swaps after it are re-simulated
- the route does not change when the RFQ pays less
- the whole order is replaced by one RFQ swap
- two parallel swaps are replaced by one RFQ swap
- one leg of a split route is replaced and keeps its split
- two hops are replaced in two rounds
- one component is not used for two legs
- the better of two components for one pair is used
- a component the request excludes is not used
- a component the pool drops is not used
- a candidate equal to the re-simulated original does not win
- a result below the net amount out of the algorithm's route is not used
- a cheap RFQ swap is taken after the gas cost, a costly one is not
- an added RFQ component becomes usable, a removed one is not used, and
  a component of another protocol is not indexed
- an event before initialize leaves the replacer empty
- a route with an exclusive leg, a route with no gas, a passed deadline
  and an unknown state label leave the route unchanged
- cut tokens and segments of a sequential route, a split-and-merge
  route and a split before a hop
- the built-in name resolves with an empty registry
- a custom registration of the built-in name is refused

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A pool can route an RFQ protocol through the fallback router, with the
protocol system fallback:rfq:bebop. The replacer ignored these
components, so a pool that indexes the RFQ protocols this way got no
replacement.

HOW:
- the replacer indexes a component when is_rfq_protocol is true for its
  protocol system, and the local check goes away
- the docs say that the server can index rfq: or fallback:rfq: protocols

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A signed RFQ quote holds for the amount that was quoted. A replacement
swap that takes a different amount, because earlier swaps change what
reaches it, fails when the maker checks it. Only the sell token has an
amount that matches the quote. A protocol that accepts smaller amounts
is safe at any point of the route.

HOW:
- add PARTIAL_FILL_PROTOCOLS, which lists rfq:bebop, and fills_partially,
  which also accepts the fallback:rfq: form
- an RFQ swap fits a target only when it fills partially or when the
  target starts at the sell token
- say so in the docs of the replacer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a protocol that fills partially replaces a swap after the sell token,
  also behind the fallback router
- a protocol that does not fill partially does not replace a swap after
  the sell token, also behind the fallback router
- a protocol that does not fill partially replaces a swap at the sell
  token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
Operators cannot see what post-processors do. A post-processor that
never replaces a route, always times out, or replaces routes with a loss
looks the same as one that works. They need counts and the size of the
gain to tune a pool.

HOW:
- count each run of a post-processor in
  worker_pool_post_processor_outcomes_total{pool,post_processor,outcome}
  with the outcomes replaced, kept, timed_out and skipped
- record the gain of each replacement in basis points of the input net
  amount out in worker_pool_post_processor_gain_bps{pool,post_processor};
  record nothing when the input net amount out is not positive
- add the buckets of the gain histogram to the metrics exporter in
  serve.rs, finer below 100 bps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a post-processor that replaces the route is counted as replaced, and
  one that keeps it as kept
- a post-processor that runs past the deadline is counted as timed_out
- every post-processor after an expired deadline is counted as skipped
- a post-processor that runs past the deadline keeps the outcome of an
  earlier one
- the gain in bps of a gain, a gain under 1 bps and a loss
- no gain is recorded for a zero or negative input net amount out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
The win counter and the won volume say which pool and algorithm won, but
not whether a post-processor changed the winning route. Operators cannot
tell how much of the flow the post-processors win.

HOW:
- add OrderQuote::post_processed_by, which holds the names of the
  post-processors that replaced the route, in config order; it is not
  serialized
- the worker sets it from the processed route
- record_win adds the post_processed_by label to the win counter, the
  unpriced counter and the won volume gauge; the label joins the names
  with + and reads none when no post-processor replaced the route

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the win counter and the volume metric carry the label none when no
  post-processor replaced the route
- they carry the names joined with + when post-processors replaced it,
  for a priced quote and for an unpriced quote
- the quote names the post-processor that replaced the route
- the quote names no post-processor after a rejected processed route

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A pool needs a way to hide a protocol from its algorithm and still show
it to its post-processors. The next commit adds the rule. This commit
adds the setting and passes it from the pool config to each worker.

HOW:
- add algorithm_exclude_protocols to PoolConfig, with its getter and
  with_ method, and to PoolEntry
- add it to WorkerPoolConfig and WorkerPoolBuilder, and pass it through
  the worker spawn parameters to the worker
- add the field and its setter to SolverWorker
- add a commented example pool to the config template of fynd-rpc

ADDITIONAL NOTES:
The setting has no effect yet. The next commit adds the rule. The test
code does not build until the commit after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
A pool can now hide an RFQ protocol from its algorithm, so the algorithm
does not pick it, and still let rfq_swap_replacer use it. With
exclude_protocols the post-processors lost the protocol too, because the
pool drop rule removes it from the market the worker feeds them.

HOW:
- the worker merges exclude_protocols and algorithm_exclude_protocols
  into algorithm_exclusions
- initialize_graph builds the graph with algorithm_exclusions; the
  post-processors still see the whole market, minus what the pool drops
- process_event filters the event for the graph with algorithm_exclusions
  and the event for the post-processors with the pool rule only, and
  builds two events only when a pool has both a post-processor and the
  setting
- the pricing of the fallback legs of a route uses algorithm_exclusions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
carloszanella and others added 6 commits October 14, 2026 16:30
- a component at start is out of the graph when the setting names its
  protocol, and in the graph without the setting
- a component added by an event is out of the graph when the setting
  names its protocol, and the post-processors still get the event
- a post-processor can put a component of that protocol into the quote
- the fallback pricing of the algorithm's route leaves out the protocol
- the pool config parses the setting, and its absence reads as None
- the worker test setup uses the setters of the exclusion lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
Operators need to know that fynd-core ships rfq_swap_replacer and how to
enable it. The docs also said post-processors run before the worker
checks the route and that a bad processed route fails the quote; both
are no longer true.

HOW:
- add a Post-processors section with an example pool to the server
  configuration guide, and say which settings must allow rfq: protocols
- document algorithm_exclude_protocols, the post-processor metrics and
  the post_processed_by label in the guide
- say in the guide, ARCHITECTURE.md, fynd-core/CLAUDE.md and CODEBASE.md
  that the worker checks the algorithm's route first and quotes it when
  a processed route fails the checks
- describe simulate_swaps in the replay.rs row of fynd-core/CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
Hashflow signed quotes also accept a smaller amount in than the quoted
amount. The replacer used Hashflow only for swaps that start at the sell
token, so it missed Hashflow swaps later in the route.

HOW:
- add rfq:hashflow to PARTIAL_FILL_PROTOCOLS
- name Hashflow next to Bebop in the post-processor docs

ADDITIONAL NOTES:
test_rfq_partial_fill uses rfq:hashflow as its exact-fill protocol and
fails until the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a Hashflow swap after the sell token is replaced
- rfq:metric is the protocol without partial fills in the exact-fill cases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHY:
The replacer skipped routes with an exclusive leg, but it never sees
one. Only the worker pool router sets a committed amount, after the
workers return their quotes, and post-processors run inside the worker.
The check was dead code.

HOW:
- remove the has_exclusive_leg check from RfqSwapReplacer::process
- remove its test, which set a committed amount by hand
- drop "without an exclusive leg" from the replacer docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…route

WHY:
The error for an invalid algorithm route logged only the algorithm. Two
worker pools can run the same algorithm, so the log did not say which
worker pool failed. The error logged the worker pool before this branch.

HOW:
- add worker_pool to the "algorithm produced an invalid route" error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@carloszanella
carloszanella force-pushed the cz/feat/rfq-swap-replacer branch from 2581dc9 to febd093 Compare October 14, 2026 15:30
@carloszanella
carloszanella merged commit 4c04f91 into main Oct 14, 2026
18 checks passed
@carloszanella
carloszanella deleted the cz/feat/rfq-swap-replacer branch October 14, 2026 15: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.

2 participants