Skip to content

fix(uniswapx-sdk): recoverCosigner uses raw ecrecover, resolve() honors cosigned target-block override - #714

Open
gomesalexandre wants to merge 1 commit into
Uniswap:mainfrom
gomesalexandre:fix_recovercosigner_ecrecover
Open

gomesalexandre wants to merge 1 commit into
Uniswap:mainfrom
gomesalexandre:fix_recovercosigner_ecrecover

Conversation

@gomesalexandre

Copy link
Copy Markdown

Two independent SDK/reactor disagreements in uniswapx-sdk, both in the cosigning path.

1. recoverCosigner() used EIP-191 personal_sign, the reactor uses raw ecrecover

V2DutchOrder.ts and PriorityOrder.ts both did:

return ethers.utils.verifyMessage(this.cosignatureHash(this.info.cosignerData), this.info.cosignature);

CosignerLib.sol (fetched from Uniswap/UniswapX) verifies cosignatures with a raw, unprefixed digest:

function verify(address cosigner, bytes32 data, bytes memory cosignature) internal pure {
    (bytes32 r, bytes32 s) = abi.decode(cosignature, (bytes32, bytes32));
    uint8 v = uint8(cosignature[64]);
    address signer = ecrecover(data, v, r, s);
    if (cosigner != signer || signer == address(0)) revert InvalidCosignature();
}

verifyMessage applies the \x19Ethereum Signed Message:\n prefix over the UTF-8 text of the hex digest; ecrecover operates 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() uses ethers.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 via wallet.signMessage(fullOrderHash) — the exact inverse of verifyMessage — so it was self-consistent with the bug, not validated against the reactor's real scheme. Fixed to sign with signDigest, 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), confirmed ethers.utils.recoverAddress recovers the correct signer while the pre-fix recoverCosigner() (via verifyMessage) recovers a completely different address. Isolated the defect to the recovery scheme specifically — both cosignatureHash() implementations were independently re-derived from PriorityOrderLib.sol/V2DutchOrderReactor.sol and 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's recoverCosigner() cannot agree with what the reactor verifies, since the reactor is unambiguously raw ecrecover.

2. PriorityOrder.resolve() never applied the cosigned target-block override

PriorityOrderReactor.sol#_validateOrder overrides the signed auctionStartBlock with the cosigned auctionTargetBlock — but only when a cosigner is set, the current block precedes the signed start, and the target itself precedes the signed start:

if (order.cosigner != address(0) && block.number < auctionStartBlock
        && order.cosignerData.auctionTargetBlock < auctionStartBlock) {
    CosignerLib.verify(...);
    auctionStartBlock = order.cosignerData.auctionTargetBlock;
}
if (block.number < auctionStartBlock) revert OrderNotFillable();

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 OrderNotFillable on 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 effective auctionStartBlock the 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 to cosigner: 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.blockOverrides also unconditionally reports the cosigned target block regardless of whether the reactor would apply the override. Unlike resolve(), this getter takes no currentBlock parameter, so it structurally cannot replicate the reactor's conditional logic without an API change — out of scope for this PR.

receipts

$ bun test src/
 353 pass
 0 fail
 1 snapshots, 523 expect() calls
Ran 353 tests across 24 files.   (was 350/0/24 before this change — +3 new tests)

$ bunx tsc -p tsconfig.cjs.json --noEmit
(clean, no output)

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 ethers call / one conditional with the exact pattern already proven correct elsewhere in the same package (V3DutchOrder's recoverCosigner, the reactor's own override logic). No change to digest computation, only to signature verification and fillability-check logic.

…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>
@gomesalexandre
gomesalexandre requested a review from a team as a code owner September 1, 2026 19:36
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