Skip to content

fix(uniswapx-sdk): resolve V3 Dutch orders exactly as the reactor settles them - #705

Open
alanhwu wants to merge 3 commits into
mainfrom
fix/v3-dutch-resolve-matches-reactor
Open

fix(uniswapx-sdk): resolve V3 Dutch orders exactly as the reactor settles them#705
alanhwu wants to merge 3 commits into
mainfrom
fix/v3-dutch-resolve-matches-reactor

Conversation

@alanhwu

@alanhwu alanhwu commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

CosignedV3DutchOrder.resolve() decayed the raw curve and ignored the rest of the signed order, so it could report amounts that differ from — and in the worst case invert — the ones V3DutchOrderReactor settles with. BaseReactor.execute() transfers the reactor's amounts, so a filler that prices an order with resolve() and then fills it pays what the reactor resolved, not what it was shown.

With input {start: 1, max: 1, curve: -999_999} and output {start: 1_000_000, min: 1_000_000, curve: +999_999}, the old resolver reported an input of 1,000,000 and an output of 1, where the reactor settles an input of 1 and an output of 1,000,000.

Fix

resolve() is now a faithful port of _resolve: cosigner overrides → base fee adjustment → bounded decay → exclusivity override.

Divergence Reactor Before
Bounds input to [0, maxAmount], output to [minAmount, uint256.max] neither applied
Base fee adjustment _updateWithGasAdjustment before decay startingBaseFee and both adjustmentPerGweiBaseFee dropped
Rounding separate input/output funcs on relative amounts, opposite directions one shared round-down on absolute amounts
Exclusivity outputs scaled by exclusivityOverrideBps; strict exclusivity rejects not applied
Cosigner amounts reverts when an override worsens the order accepted

decay() also caps blockDelta at uint16 max, as the reactor does.

The rounding branch was inverted, not just imprecise: interpolating absolute amounts flips floor/ceil relative to interpolating the relative amounts. It was off by one against the reactor for inputs on a decaying curve and outputs on an increasing curve — in both cases reading in the filler's favor and settling against it.

MathExt.sol is ported to utils/mathExt.ts and ExclusivityLib.sol to utils/exclusivity.ts. The exclusivity helper is generic over the output shape and over position (timestamp or block), so V1 and V2 — which have the same omission — can adopt it without an import cycle. getBlockDecayedAmount is replaced by decayInput/decayOutput, which take the signed input/output so the bounds cannot be dropped at the call site.

Breaking changes

The public API is unchanged: package.json exposes only "." and utils/index.ts never re-exported ./dutchBlockDecay, so the replaced symbols were unreachable from the published package. V3OrderResolutionOptions gains only optional fields.

The breaks are behavioral, ranked by how likely they are to fire:

  1. Strict exclusivity now throws. resolve({ currentBlock }) with no filler, on an order with a real exclusiveFiller and exclusivityOverrideBps === 0, throws NoExclusiveOverride where it previously returned amounts the reactor would never settle. V3DutchOrderBuilder defaults the bps to 0 (STRICT_EXCLUSIVITY), so any flow that sets an exclusive filler and leaves the default produces this shape. Worth confirming against what the parameterization API actually cosigns before release — callers fix it by passing options.filler, but an unguarded order loop would throw rather than skip one order.
  2. Resolved amounts change. Correct now, but different; anything snapshotting or caching resolve() output will move. For well-formed orders with zero gas adjustment and non-crossing curves the only delta is the ±1 wei rounding.
  3. Throws on nonzero adjustmentPerGweiBaseFee without blockBaseFee, rather than silently returning an unadjusted amount.
  4. Throws on invalid cosigner amounts — only for orders the reactor rejects anyway.

Changeset is marked minor. Under strict semver, resolve() throwing where it previously returned is a major — flagging for a deliberate call. If the exclusivity change is the sticking point, it can be split out so the bounds and base-fee fixes ship without callers auditing their call sites first.

Testing

376 unit tests pass, lint and build clean. New coverage: bound clamping in both directions, base fee adjustment on an empty curve, input/output rounding divergence, uint16 block-delta cap, exclusivity scaling and strict-exclusivity rejection, and all three cosigner validation paths.

V3DutchOrder.ts was already prettier-nonconforming on main (4-space indent), so it keeps the file's existing style rather than burying the diff in a reflow.

🤖 Generated with Claude Code

…tles them

CosignedV3DutchOrder.resolve() decayed the raw curve and ignored the rest of
the signed order, so it could report amounts that differ from - and in the
worst case invert - the ones V3DutchOrderReactor settles with. A filler that
prices an order locally and then calls execute() pays the reactor's amounts,
not the ones it was shown.

Port the reactor's resolution path faithfully:

- Bound the decayed input to [0, maxAmount] and each decayed output to
  [minAmount, uint256.max], including on the no-decay path.
- Apply the base fee adjustment (startingBaseFee, adjustmentPerGweiBaseFee)
  before decaying. V3OrderResolutionOptions gains an optional blockBaseFee,
  required only for orders that use the feature.
- Interpolate the relative curve amounts with separate input and output
  functions that round in opposite directions, both in favor of the swapper.
- Apply the exclusivity override, and reject strictly exclusive orders the
  caller has no rights to fill.
- Reject cosigner overrides that worsen the order for the swapper.

Also cap blockDelta at uint16 max, as the reactor does.

MathExt.sol is ported to utils/mathExt.ts and ExclusivityLib.sol to
utils/exclusivity.ts. getBlockDecayedAmount is replaced by decayInput and
decayOutput, which take the signed input/output so the bounds cannot be
dropped at the call site. None of these are exported from the package root,
so the public API is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alanhwu
alanhwu requested a review from a team as a code owner August 24, 2026 23:11
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

● Reviewed · against 161e65d · 2026-08-25 17:46 UTC · 2 reviews · view run ↗

Note

Approved.

Ports CosignedV3DutchOrder.resolve() to a faithful reproduction of the reactor's _resolve — cosigner overrides, base-fee adjustment, bounded decay, exclusivity override — so a filler prices an order with the same amounts the reactor settles with, and extracts MathExt/ExclusivityLib into reusable TS utils.

Assessment

The core fix holds up: the rounding split (inputs down, outputs up, flipping by decay direction), boundedSub/boundedAdd saturation, the uint16 block-delta cap, and the per-field adjustmentPerGweiBaseFee.isZero() guards on the shared gasDeltaWei all line up with the reactor, and the new tests exercise each branch. The extracted applyExclusivityOverride<T> earns its generic shape by letting V1/V2/V3 and both position types share one implementation without an import cycle. The enumerated behavioral breaks — strict-exclusivity now throwing, resolved amounts moving, throw-on-invalid-cosigner — are deliberate and documented; the semver question (a resolve() that throws where it previously returned is arguably major, changeset marks it minor) is flagged by the author for a conscious call rather than left silent.

One open thread remains worth tracking before release: the test suite asserts hand-copied values rather than reactor-produced ones. The added V3DutchOrderDifferential.spec.ts looks aimed at exactly this, and closing the loop there is what would let you answer Cantina #843 with an oracle rather than a code read.

Iteration history · 2 reviews
2026-08-25 17:46 UTC · ✅ approved · 0 findings · 161e65d · run ↗

(no findings)

2026-08-24 23:21 UTC · ✅ approved · 1 finding · 0f6bce4 · run ↗
  • sdks/uniswapx-sdk/src/utils/dutchBlockDecay.ts:244 — info · correctness

Tip

Teach the reviewer. React 👍 on findings that helped, 👎 on false positives. Reply to push back or add context — we aggregate this weekly to tune the bot.

Comment @request-claude-review to re-run.

@graphite-app
graphite-app Bot requested review from a team and removed request for a team August 24, 2026 23:17
@graphite-app

graphite-app Bot commented Aug 24, 2026

Copy link
Copy Markdown

Graphite Automations

"Request reviewers once CI passes on sdks monorepo" took an action on this PR • (08/24/26)

2 reviewers were added and 1 assignee was added to this PR based on Siyu Jiang (See-You John)'s automation.

Comment thread sdks/uniswapx-sdk/src/utils/dutchBlockDecay.ts

@github-actions github-actions Bot 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.

Note

Approved — see full review in the sticky comment ↑

// decayed output to the signed minAmount. Resolving without those
// bounds reports economics inverted from the ones the reactor settles
// with, which would drain a filler that priced the order locally.
it("bounds a curve that resolves across maxAmount and minAmount", () => {

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.

These assert values copied in by hand rather than values the reactor produced. That is the same condition that let the original divergence exist, so the suite cannot demonstrate the property in the title.

What would pin it is a differential test that generates V3 orders and asserts resolve() equals the on-chain OrderQuoter across a range of blocks, base fees and fillers. The quoter runs the real reactor and returns the resolved order through the callback revert, so it is an independent oracle rather than a second copy of the same assumption.

This matters outside the repo too. Cantina asked on finding #843 whether remediation makes every hard-quote-created V3 order resolve identically in the published SDK and the reactor. Without an oracle test the strongest answer I can give them is a code read.

* claim exclusive filling rights; without it the order resolves for an
* arbitrary filler.
* @return the input and outputs the reactor would transfer
* @throws when the reactor would reject the order outright, rather than

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.

This says it throws when the reactor would reject the order outright, but resolve() checks neither the deadline nor the cosignature, and the reactor checks both before it touches any amounts. An expired order with a garbage cosignature returns amounts here and reverts on chain.

I would soften the comment rather than add the checks. resolve() is a pricing function and validity belongs elsewhere. As written the wording promises more than the code does.

}

/** Mirrors solmate's FixedPointMathLib.mulDivDown. */
export function mulDivDown(

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.

mulDivDown and mulDivUp multiply at arbitrary precision, where solmate reverts once x * y exceeds uint256. The same gap exists in the exclusivity scaling and in gasDeltaWei, where the base fee difference can go negative and SafeCast.toInt256 would revert.

Low severity, because the reactor reverts atomically in all of these cases, so there is no wrong settlement. The cost is a filler pricing an order that looks fillable and then burning gas on a transaction that can only revert. A note in the code seems better than reproducing Solidity overflow semantics in TypeScript.

curve: NonlinearDutchDecay,
currentRelativeBlock: number
): [number, number, BigNumber, BigNumber] {
const { relativeBlocks, relativeAmounts } = curve;

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.

Two pre-existing problems in the packed representation these values come from, neither introduced here, but worth recording while this is open.

encodeRelativeBlocks ors each value in at i * 16 with no uint16 mask, so anything above 65535 writes into the next slot. [65536, 5] packs to a value that both the reactor and decodeRelativeBlocks read back as [0, 5], replacing a curve point without error. V3DutchOrderBuilder checks ordering but not range, so nothing rejects it.

decodeRelativeBlocks also calls .toNumber() on the whole packed value before masking, which throws NUMERIC_FAULT once the packed value passes the JS safe integer range. That happens at five curve points, where the reactor supports sixteen, so multi-point curves cannot be parsed at all.

I checked whether the first one can mislead a filler through the order feed and it cannot. decodeRelativeBlocks masks with 0xffff, matching Uint16ArrayLibrary.getElement, so anything arriving as encodedOrder bytes resolves identically on both sides. The gap only exists for an order object built in memory and never round tripped.

@@ -0,0 +1,22 @@
---
'@uniswap/uniswapx-sdk': minor

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.

Registering a view on the question you raised. resolve() throwing where it previously returned is a major under strict semver, and three of the four behavioral breaks are new throws.

Splitting exclusivity out is not needed for release safety though. The parameterization API never cosigns a strictly exclusive V3 order. The RFQ path sets exclusivityOverrideBps to 25, and the open order path sets exclusiveFiller to the zero address, which hasFillingRights short circuits. So nothing published today can reach the NoExclusiveOverride throw.

Replace hand-written expectations with an OrderQuoter differential test, and
confirm it catches each divergence by re-introducing them individually.

Also: reproduce solmate/SafeCast overflow reverts, fix the uint16 range bugs
in relativeBlocks encode/decode, guard getEndAmount on empty curves, scope the
resolve() doc to pricing, and bump to major.

Co-Authored-By: Claude Opus 4.8 <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.

2 participants