fix(uniswapx-sdk): recoverCosigner uses raw ecrecover, resolve() honors cosigned target-block override - #714
Open
gomesalexandre wants to merge 1 commit into
Conversation
…rs cosigned target-block override Two independent SDK/reactor disagreements in the same package: 1. `recoverCosigner()` on V2DutchOrder and PriorityOrder used `ethers.utils.verifyMessage` (EIP-191 personal_sign), while `CosignerLib.sol` verifies with a raw `ecrecover(data, v, r, s)` over the unprefixed digest. The two schemes can never agree. The correct sibling, V3DutchOrder.recoverCosigner(), already used `recoverAddress` — this brings the other two orders in line with it and with the reactor. The existing V2DutchOrder unit test was circular: it produced its test cosignature via `wallet.signMessage(...)`, the exact inverse of `verifyMessage`, so it was self-consistent with the bug rather than validating against the reactor's real scheme. Fixed to sign with `signDigest`, matching how the repo's own integration tests already sign cosignatures. CosignedPriorityOrder.recoverCosigner() had no test at all; added one. `recoverCosigner()` has no internal caller in the SDK today — it is public API for downstream fillers/quoters. This is not a forgery path; an attacker still needs the real cosigner's private key either way. Which scheme UniswapX's production cosigner service actually emits was not observed here — what's proven is that the SDK's recovery cannot agree with what the reactor verifies, since the reactor is unambiguously raw `ecrecover`. 2. `CosignedPriorityOrder.resolve()` never applied the cosigned `auctionTargetBlock` override before its fillability check, so it threw `OrderNotFillable` on orders the reactor actually fills, throughout the window `[auctionTargetBlock, auctionStartBlock)` — exactly the window cosigning exists to open early. `PriorityOrderReactor.sol#_validateOrder` overrides `auctionStartBlock` with the cosigned `auctionTargetBlock` only when a cosigner is set, the current block precedes the signed start, and the target precedes the signed start too — then checks fillability against that (possibly overridden) effective start. The SDK instead had two independent branches: an unconditional "target in the future" check (fired even with no cosigner or a target past start) and a separate, never-overridden "start in the future" check. Fixed to compute the same effective start the reactor computes, then check once against it. No existing test exercised the override path: the default test fixture uses `cosigner: AddressZero`, so the override could never apply, yet the old code still threw the (Solidity-nonexistent) "Target block in the future" message purely from its own unconditional first branch — fixed that test's expectation to match the corrected single check, and added two new tests exercising the override applying and not applying. All disagreements here are one-directional (SDK too strict, reactor more permissive) — no over-permissive direction exists, so this is a missed- fills bug for SDK-driven fillers during the cosigned early window, not a fund-safety issue. `CosignedPriorityOrder.blockOverrides` also unconditionally reports the cosigned target block regardless of whether the reactor would apply the override — deliberately left unfixed: unlike `resolve()`, this getter has no `currentBlock` parameter, so it structurally cannot replicate the reactor's conditional logic without an API change, which is out of scope here. Verified against Uniswap/UniswapX's real Solidity source (CosignerLib.sol, PriorityOrderReactor.sol, PriorityOrderLib.sol) fetched directly from GitHub. Full uniswapx-sdk suite: 353/353 (was 350 before this change, +3 new tests), tsc clean. Genuine red-before/green-after confirmed by stashing the source fixes with the new/modified tests in place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Two independent SDK/reactor disagreements in
uniswapx-sdk, both in the cosigning path.1.
recoverCosigner()used EIP-191personal_sign, the reactor uses rawecrecoverV2DutchOrder.tsandPriorityOrder.tsboth did:CosignerLib.sol(fetched fromUniswap/UniswapX) verifies cosignatures with a raw, unprefixed digest:verifyMessageapplies the\x19Ethereum Signed Message:\nprefix over the UTF-8 text of the hex digest;ecrecoveroperates on the raw 32-byte digest. These schemes can never agree for the same signature.The correct sibling in the same package already had this right —
V3DutchOrder.recoverCosigner()usesethers.utils.recoverAddress. This PR brings V2DutchOrder and PriorityOrder in line with it (and with the reactor).The existing test was circular.
V2DutchOrder.test.ts's cosigner test produced its test signature viawallet.signMessage(fullOrderHash)— the exact inverse ofverifyMessage— so it was self-consistent with the bug, not validated against the reactor's real scheme. Fixed to sign withsignDigest, matching how this repo's own integration tests (V2DutchOrder.spec.ts,PriorityOrderValidator.spec.ts) already sign cosignatures for real reactor execution.CosignedPriorityOrder.recoverCosigner()had no test at all; added one.Real repro (verified locally before touching code): signed a real digest with
wallet._signingKey().signDigest(...)(the raw ecrecover-compatible scheme), confirmedethers.utils.recoverAddressrecovers the correct signer while the pre-fixrecoverCosigner()(viaverifyMessage) recovers a completely different address. Isolated the defect to the recovery scheme specifically — bothcosignatureHash()implementations were independently re-derived fromPriorityOrderLib.sol/V2DutchOrderReactor.soland match.Honest scoping:
recoverCosigner()has no internal caller in this SDK — it's public API for downstream fillers/quoters. This is not a forgery path; an attacker still needs the real cosigner's private key regardless of which recovery scheme is used. Which direction UniswapX's production cosigner service actually emits wasn't observed here — what's proven is that the SDK'srecoverCosigner()cannot agree with what the reactor verifies, since the reactor is unambiguously rawecrecover.2.
PriorityOrder.resolve()never applied the cosigned target-block overridePriorityOrderReactor.sol#_validateOrderoverrides the signedauctionStartBlockwith the cosignedauctionTargetBlock— but only when a cosigner is set, the current block precedes the signed start, and the target itself precedes the signed start:The SDK instead had two independent, unconditional branches — an early "target in the future" throw with no cosigner/target-vs-start awareness, and a separate "start in the future" throw that was never overridden. Net effect: the SDK threw
OrderNotFillableon orders the reactor actually fills, throughout the entire window[auctionTargetBlock, auctionStartBlock)— exactly the window cosigning exists to open early.Fixed
resolve()to compute the same effectiveauctionStartBlockthe reactor computes, then check fillability once against it — same condition, same order, same comparisons.Verified with an exhaustive sweep (990 combinations: cosigner set/zero × start 98-102 × target 96-104 × current 95-105, comparing the old SDK logic against a faithful port of the reactor's
_validateOrder) before touching code: 165 disagreements, all one-directional (SDK refuses, reactor fills), all requiring a non-zero cosigner — confirming the SDK was correct whenever no cosigner is set, which is exactly why every existing fixture (which all default tocosigner: AddressZero) never caught this.The existing "control" test was itself relying on the bug. Its default fixture has
cosigner: AddressZero, so per the reactor the override can never apply — yet the old code still threw the (Solidity-nonexistent) message "Target block in the future" purely from its own unconditional first branch, unrelated to any real revert reason. Fixed that test's expectation to the corrected single message, and added two new tests: one exercising the override actually applying (cosigner set, target < start, current inside the window — now fills instead of throwing), one exercising it correctly not applying (cosigner set but target ≥ start — still throws).Honest scoping: all 165 disagreements are SDK-too-strict / reactor-more-permissive — there is no over-permissive direction, so this is a missed-fills bug for SDK-driven fillers during the cosigned early window, not a fund-safety issue.
Deliberately left unfixed:
CosignedPriorityOrder.blockOverridesalso unconditionally reports the cosigned target block regardless of whether the reactor would apply the override. Unlikeresolve(), this getter takes nocurrentBlockparameter, so it structurally cannot replicate the reactor's conditional logic without an API change — out of scope for this PR.receipts
Genuine red-before/green-after confirmed by stashing just the source fixes (
PriorityOrder.ts,V2DutchOrder.ts) with the new/modified tests still in place — all 4 affected tests fail with the exact predicted symptoms, restoring the source fixes brings them all back to green.risk
Low — both fixes replace one
etherscall / one conditional with the exact pattern already proven correct elsewhere in the same package (V3DutchOrder'srecoverCosigner, the reactor's own override logic). No change to digest computation, only to signature verification and fillability-check logic.