Repository navigation
feat(core): add rfq_swap_replacer built-in post-processor - #624
Merged
Merged
Conversation
carloszanella
added this pull request to stack #625
October 11, 2026 16:10
carloszanella
force-pushed
the
cz/feat/rfq-swap-replacer
branch
from
October 11, 2026 16:39
90bd6a8 to
6887f1c
Compare
Troshchk
reviewed
Oct 12, 2026
Troshchk
left a comment
Contributor
There was a problem hiding this comment.
Thank you!
Left some comments and 2 qs:
- We only support rfq: now, should we insert fallback:rfq instead?
- 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
carloszanella
force-pushed
the
cz/feat/rfq-swap-replacer
branch
2 times, most recently
from
October 13, 2026 09:50
e5b822a to
18e4e84
Compare
carloszanella
force-pushed
the
cz/feat/rfq-swap-replacer
branch
2 times, most recently
from
October 13, 2026 23:09
351c134 to
46732ec
Compare
Troshchk
reviewed
Oct 14, 2026
Troshchk
left a comment
Contributor
There was a problem hiding this comment.
Thank you! A couple of qs
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>
- 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
force-pushed
the
cz/feat/rfq-swap-replacer
branch
from
October 14, 2026 15:30
2581dc9 to
febd093
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_replacerruns 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.algorithm_exclude_protocolsremoves 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 keyexclude_protocolskeeps its meaning: the worker pool never routes through those protocols.Business decisions
PARTIAL_FILL_PROTOCOLS(fynd-core/src/post_process/rfq.rs), which currently holdsrfq:bebopandrfq: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.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 incrementsworker_pool_post_processed_route_rejected_total{pool}.10 msbefore the task deadline. The replacer checks the deadline before each candidate, but a simulation that has started always runs to the end.rfq_swap_replacer.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 outcomesreplaced,kept,timed_out, andskipped.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_tokenandworker_router_unpriced_won_quotes_totalgain the labelpost_processed_by. Its value is the post-processor names joined with+, ornone. Existing series split by this label; a sum bypoolgives the old totals.worker_pool_post_processed_route_rejected_total{pool}increments when a processed route fails the worker's checks.🤖 Generated with Claude Code