fix(uniswapx-sdk): resolve V3 Dutch orders exactly as the reactor settles them - #705
fix(uniswapx-sdk): resolve V3 Dutch orders exactly as the reactor settles them#705alanhwu wants to merge 3 commits into
Conversation
…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>
|
● Reviewed · against Note Approved. Ports AssessmentThe core fix holds up: the rounding split (inputs down, outputs up, flipping by decay direction), One open thread remains worth tracking before release: the test suite asserts hand-copied values rather than reactor-produced ones. The added Iteration history · 2 reviews2026-08-25 17:46 UTC · ✅ approved · 0 findings ·
|
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. |
There was a problem hiding this comment.
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", () => { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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>
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 onesV3DutchOrderReactorsettles with.BaseReactor.execute()transfers the reactor's amounts, so a filler that prices an order withresolve()and then fills it pays what the reactor resolved, not what it was shown.With
input {start: 1, max: 1, curve: -999_999}andoutput {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.[0, maxAmount], output to[minAmount, uint256.max]_updateWithGasAdjustmentbefore decaystartingBaseFeeand bothadjustmentPerGweiBaseFeedroppedexclusivityOverrideBps; strict exclusivity rejectsdecay()also capsblockDeltaatuint16max, 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.solis ported toutils/mathExt.tsandExclusivityLib.soltoutils/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.getBlockDecayedAmountis replaced bydecayInput/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.jsonexposes only"."andutils/index.tsnever re-exported./dutchBlockDecay, so the replaced symbols were unreachable from the published package.V3OrderResolutionOptionsgains only optional fields.The breaks are behavioral, ranked by how likely they are to fire:
resolve({ currentBlock })with nofiller, on an order with a realexclusiveFillerandexclusivityOverrideBps === 0, throwsNoExclusiveOverridewhere it previously returned amounts the reactor would never settle.V3DutchOrderBuilderdefaults the bps to0(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 passingoptions.filler, but an unguarded order loop would throw rather than skip one order.resolve()output will move. For well-formed orders with zero gas adjustment and non-crossing curves the only delta is the ±1 wei rounding.adjustmentPerGweiBaseFeewithoutblockBaseFee, rather than silently returning an unadjusted amount.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,
uint16block-delta cap, exclusivity scaling and strict-exclusivity rejection, and all three cosigner validation paths.V3DutchOrder.tswas already prettier-nonconforming onmain(4-space indent), so it keeps the file's existing style rather than burying the diff in a reflow.🤖 Generated with Claude Code