Skip to content

Return error on duplicate fills - #4844

Open
jmg-duarte wants to merge 2 commits into
mainfrom
jmgd/duplicate-fills
Open

Return error on duplicate fills#4844
jmg-duarte wants to merge 2 commits into
mainfrom
jmgd/duplicate-fills

Conversation

@jmg-duarte

@jmg-duarte jmg-duarte commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Description

Context: https://nomevlabs.slack.com/archives/C0375NV72SC/p1788220867855339

A single solution must settle each order at most once. Until now a solver could list the same order in several trades of one solution, and a driver could repeat an order key in one solution's orders map. Nothing rejected this: the driver forwarded the duplicate trades, and the autopilot collected the map into a HashMap, which silently kept the last entry.

Changes

  • solvers-dto: reject a solution whose trades contain more than one fulfillment for the same order UID.
  • solvers-dto: hand-write Deserialize for SolverResponse so the rejection reason reaches the solver notification instead of the generic "did not match any variant" message from #[serde(untagged)].
  • autopilot: reject a driver solution whose orders map repeats an order UID.
  • Document the rule in the driver and solver OpenAPI specs. Splitting a partially fillable order across several solutions is still allowed.

No breaking API change. Only payloads that were already invalid are now rejected.

How to test

Unit tests in crates/solvers-dto/src/solution.rs and crates/autopilot/src/infra/solvers/dto/solve.rs:

cargo nextest run -p solvers-dto -p autopilot --lib -E 'package(solvers-dto) | test(infra::solvers::dto::solve)'

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Reminder: Please consider backward compatibility when modifying the API specification.
If breaking changes are unavoidable, ensure:

  • You explicitly pointed out breaking changes.
  • You communicate the changes to affected teams (at least Frontend team and SAFE team).
  • You provide proper versioning and migration mechanisms.

Caused by:

@jmg-duarte
jmg-duarte marked this pull request as ready for review September 4, 2026 16:47
@jmg-duarte
jmg-duarte requested a review from a team as a code owner September 4, 2026 16:47
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jmg-duarte's task in 3m 3s —— View job


PR Review: Return error on duplicate fills

  • Read diff across all 4 changed files
  • Verify duplicate-detection logic (both crates)
  • Check the hand-written SolverResponse deserializer for regressions
  • Assess blast radius / behavioral changes
  • Post findings

Overall this is a clean, well-targeted change — the two deserialization hooks are the correct enforcement boundaries, JIT trades are correctly excluded from the fulfillment dedup, the OpenAPI docs are updated, and tests cover both the reject and accept paths. I verified the hand-written SolverResponse::Deserialize preserves the prior variant precedence (solutions before error), so it's not a regression for existing solvers — it just surfaces the real rejection reason instead of the untagged "did not match any variant". OrderUid being Copy makes the post-insert use in the error message sound. itertools::Itertools is in scope, so the test .join(",") compiles.

One design point to consider (posted inline on solve.rs): the check fails deserialization atomically, so one solution with a duplicate order UID rejects the whole driver response — every other valid solution from that driver is dropped too, not just the offending one. That's safer than the old last-wins corruption, but it's a liveness regression vs. filtering only the bad solution. Worth a deliberate decision on the intended granularity.

No blocking correctness bugs found.
· branch jmgd/duplicate-fills

/// A partially fillable order may be split across several solutions, but a
/// single solution settles each order exactly once. Collecting the entries into
/// a map would silently keep only the last of a repeated order.
fn deserialize_orders<'de, D>(

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.

Because the check lives in Solution's deserializer and Response { solutions: Vec<Solution> } is parsed atomically in request_response (serde_json::from_slice), a single solution with a duplicated order UID makes the entire driver response fail — every other (valid) solution from that same driver is discarded for the auction, not just the offending one.

Previously the last-wins HashMap silently corrupted the executed amounts, which is worse for accounting, so rejecting is the right call. But dropping all of a driver's solutions is a liveness regression vs. filtering out only the bad solution. Is failing the whole response the intended granularity, or would it be preferable to skip just the offending solution (validate after deserialization, drop that one, keep the rest)?

The same all-or-nothing applies on the solvers-dto side (SolverResponse::Solutions { solutions: Vec<Solution> }), though there the blast radius is a single solver engine's response.

@MartinquaXD

Copy link
Copy Markdown
Contributor

serde_as apparently already has something out of the box for this: https://docs.rs/serde_with/latest/serde_with/rust/maps_duplicate_key_is_error/index.html
Probably best to slap that onto all maps we deserialize to avoid hidden surprises everywhere instead of just in this case here specifically.

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