Skip to content

fix: enforce single-token outputs and report cosigner-adjusted amounts to fillers - #693

Open
codyborn wants to merge 2 commits into
mainfrom
fix/v2-output-validation-and-filler-effective-amounts
Open

fix: enforce single-token outputs and report cosigner-adjusted amounts to fillers#693
codyborn wants to merge 2 commits into
mainfrom
fix/v2-output-validation-and-filler-effective-amounts

Conversation

@codyborn

@codyborn codyborn commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Found while reviewing a bug bounty report (#847) against the V2 hard-RFQ path. Two separate problems, both in this repo.

1. Output validation never ran for Dutch V2

OffChainUniswapXOrderValidator.validate() classifies a CosignedV2DutchOrder as OrderType.Dutch_V2, but the branch that calls validateDutchOutputs was gated on orderType == OrderType.Dutch. That condition is never true for a V2 order, so V2 orders reached the repository with no output validation at all: no token address check, no uint256 range checks, no endAmount <= startAmount. Confirmed against the SDK before the fix: a V2 order with startAmount: 1, endAmount: 999 returned {valid: true}.

Fixed the gate, and added the V2 equivalent of the checks V3 already has on cosigner overrides (array length matches outputs, and a non-zero override must be >= startAmount) so we stop storing orders that V2DutchOrderReactor._updateWithCosignerAmounts will revert on.

2. Outputs may no longer span multiple tokens

Every output of an order must now pay outputs[0].token. RFQ prices an order as one (tokenIn, tokenOut, amount) tuple where tokenOut is outputs[0].token, and the cosigner sums output start amounts into a single scalar to compare against the quote, so an order whose outputs span multiple tokens gets priced against a total that mixes assets. Multi-output orders are unaffected, since fee outputs use the same token as the swapper output.

This is wired into all four output validators, so it covers every order type we accept: Dutch V1, Limit (a DutchOrder classified as OrderType.Dutch), Dutch V2, Dutch V3, Priority and Hybrid. There is no on-chain equivalent of this rule, so it is a new hard 400 at POST /order for a shape the reactors would fill. To make that observable, off-chain validation failures now emit an OffchainValidationFailure metric and a log line with the reason, order hash and swapper. Previously the rejection returned a 400 and left no trace on our side. Worth watching that metric after deploy; I'm confident no live integrator sends heterogeneous outputs, but the metric is what would prove it.

3. GET /orders reported the signed amounts, not the fill amounts

input/outputs are what the swapper signed. The reactor substitutes cosignerData.inputOverride and cosignerData.outputOverrides[i] for startAmount whenever they are non-zero, so neither the base fields nor cosignerData on its own tells a filler what a fill actually moves. Dutch V2 and V3 responses now carry effectiveInput and effectiveOutputs with the overrides already applied.

No breaking change. input, outputs and cosignerData are untouched and byte-identical to before; encodedOrder was already complete. The two new fields are additive and optional in both the TS types and the joi response schemas, so existing filler integrations are unaffected. swagger.json documents them (the sync test in test/unit/swagger.test.ts requires it).

Testing

yarn test green, 579 tests. New coverage: V2 same-token rejection, V2 endAmount > startAmount, V2 invalid output token, V2 override < startAmount, V2 override length mismatch, V2 zero-override still valid, V3, Priority and Hybrid same-token rejection, V3 override length mismatch, an input endAmount regression test, and effectiveInput/effectiveOutputs for V2 and V3 including the zero-override fallback and the base fields staying untouched. The invalid V2 fixtures mutate a built order because V2DutchOrderBuilder enforces invariants the reactor does not, which is also what parsing a caller-supplied encodedOrder produces.

Follow-up commit after review

  • The same-token check now runs after each output's own address is validated. Checking first made an invalid outputs[0].token the "expected" token, so [{token: '0xfoo'}, {token: WETH}] named the valid output as the offender and echoed the garbage back as the expected value.
  • Fixed a pre-existing bypass three lines above the V2 gate: an invalid input endAmount returned inputStartAmountValidation, which is known-valid at that point, so the order validated successfully and skipped output validation, the override checks and the hash check. Latent (only non-uint256 values trip it, which ABI decoding cannot produce) but it is the same class of bug this PR closes.

Out of scope

  • The RFQ quote request itself still omits the output vector (HardQuoteRequest.toCleanJSON in uniswapx-parameterization-api sends one tokenOut plus numOutputs, and totalOutputAmountStart raw-sums every startAmount). This PR makes the service reject such an order at POST /order, which stops it reaching fillers, but the parameterization API should reject it before KMS signing too. Separate PR.
  • The legacy untyped GET /orders response (no orderType param) returns raw DynamoDB entities and does not gain the new fields.
  • The filler webhook payload is unchanged. It carries encodedOrder, which already contains every output and the full override array.

🤖 Generated with Claude Code

…s to fillers

Two gaps found while reviewing a bug bounty report against the V2 hard-RFQ path.

Output validation never ran for Dutch V2. `validate()` classifies a
CosignedV2DutchOrder as `OrderType.Dutch_V2`, but the branch that calls
`validateDutchOutputs` was gated on `orderType == OrderType.Dutch`, so V2 orders
reached the repository with no output checks at all: not the token address check,
not the uint256 range checks, not `endAmount <= startAmount`. Fixed the gate, and
added the V2 equivalent of the V3 cosigner-override checks (array length, and a
non-zero override must be >= the signed startAmount) so we stop storing orders
that `V2DutchOrderReactor._updateWithCosignerAmounts` will revert on.

Every output of an order must now pay the same token, across Dutch V1/V2/V3 and
Priority. RFQ quotes an order as one (tokenIn, tokenOut, amount) tuple with
tokenOut taken from `outputs[0].token`, and the cosigner sums output start
amounts into a single scalar to compare against the quote, so an order whose
outputs span multiple tokens gets priced against a total that mixes assets.
Multi-output orders are unaffected: fee outputs use the swapper output's token.

`GET /orders` also reported the signed base amounts while the reactor substitutes
`cosignerData.inputOverride` / `outputOverrides` for `startAmount` whenever they
are non-zero, so neither `input`/`outputs` nor `cosignerData` alone describes what
a fill moves. Dutch V2 and V3 responses now carry `effectiveInput` and
`effectiveOutputs` with the overrides applied. Both are additive and optional:
`input`, `outputs` and `cosignerData` are unchanged, so existing filler
integrations keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codyborn
codyborn requested a review from alanhwu August 7, 2026 20:47
- Run the same-token check after each output's own token address is validated.
  Checking first made an invalid outputs[0].token the "expected" token, so
  `[{token: '0xfoo'}, {token: WETH}]` reported the valid output as the offender
  and echoed the garbage string back as the expected value.
- Return `inputEndAmountValidation` instead of `inputStartAmountValidation` when
  the input endAmount is invalid. The wrong variable is known-valid at that point,
  so an invalid endAmount returned success and skipped output validation, the
  cosigner override checks and the hash check. Latent today because
  `validateInputAmount` only rejects non-uint256 values, which ABI decoding cannot
  produce, but it is the same class of bypass this PR exists to close.
- Cover the hybrid same-token path, which the previous commit enforced without a
  test. The hybrid factory hardcodes a placeholder reactor, so the test points it
  at the real Unichain Sepolia address to get past the reactor check.
- Count and log off-chain validation failures. The rejection becomes a 400 with the
  reason in `detail`, but left no trace on our side, so a rule that starts rejecting
  real traffic was invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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