Skip to content

fix: count single token ERC721 approvals, keep contract wallet signatures, reject zero unitsToFill, pass extraData to the zone - #1058

Merged
ryanio merged 5 commits into
mainfrom
claude/1793395197-community-fixes-oct30
Oct 30, 2026
Merged

ryanio merged 5 commits into
mainfrom
claude/1793395197-community-fixes-oct30

Conversation

@ryanio

@ryanio ryanio commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Five community PRs opened this week. Three of them touch src/seaport.ts and three touch src/utils/fulfill.ts, so they are stacked here with each contributor's commit kept as authored. All five cherry-picked onto main without conflicts, and no follow-up commits were needed.

Fixes #1052 and #1055. Supersedes #1051, #1053, #1054, #1056 and #1057, which need closing once this merges.

Solution

Commits, in merge order (isolated files first, shared files last):

  1. fix: recognize a single token ERC721 approval, not only approval for all #1056 by @nftpoetrist (fixes fulfillOrder rejects a listing created with exactApproval, since a single token ERC721 approval is not counted #1055): approvedItemAmount read only isApprovedForAll, so an ERC721 approved one token at a time (which is what exactApproval: true does) counted as unapproved. A listing created that way was rejected by fulfillOrder and fulfillOrders with "The offerer does not have the sufficient approvals." even though Seaport fills it onchain. Now reads getApproved(id) for ERC721 items when the operator is not approved for all, using the resolved criteria identifier where there is one.
  2. fix: separate the order index from the identifier in ERC721 aggregate keys #1051 by @Ocheretovich: ERC721 aggregation keys appended orderIndex-itemIndex directly after the token id, so id 1 in order 11 and id 11 in order 1 produced the same key and were grouped into one fulfillment. Adds a - separator at both sites (offer and consideration).
  3. fix: keep a contract wallet's 65 byte signature instead of compacting it #1053 by @nftpoetrist (fixes signOrder rewrites a contract wallet's 65 byte signature, so the order fails ERC-1271 #1052): signOrder and signBulkOrder compacted every 65 byte signature to EIP-2098 form. A contract wallet can also return 65 bytes, and Seaport hands the bytes to isValidSignature unchanged when ecrecover does not yield the offerer, so the wallet received bytes it never produced. Now compacts only when the signature recovers to the offerer from the signed digest. A Safe eth_sign signature with v = 31 or 32 no longer throws invalid v.
  4. fix: reject a zero unitsToFill instead of filling the whole order #1054 by @nftpoetrist: unitsToFill of 0 or 0n is falsy and was read as "not set", which fills the whole remaining order, while "0" threw. Now any set unitsToFill that is not greater than zero throws at the top of fulfillOrder and for each entry in fulfillOrders.
  5. fix: pass fulfillOrder extraData to the zone instead of dropping it #1057 by @erkancamli: when an order qualified for the basic route or plain fulfillOrder, a caller's extraData was dropped, because only fulfillAdvancedOrder carries it. A restricted order whose zone reads extraData then reverted. Non-empty extraData now forces the advanced route at both route decisions.

Other call sites, checked for each fix:

  • approvedItemAmount has one caller, getBalancesAndApprovals, which is now passed the criteria. Lookups downstream are keyed by token and identifier, so a per-token 1n is matched to its own identifier.
  • The aggregate key suffix appears at exactly the two sites changed.
  • Signature compaction happens only in signOrder and signBulkOrder, both changed.
  • unitsToFill enters through fulfillOrder and fulfillOrders only, both guarded.
  • The route decisions are the shouldUseBasicFulfill gate in seaport.ts and useAdvanced in fulfillStandardOrder, both changed. fulfillOrders already uses fulfillAvailableAdvancedOrders and passes extraData.

Reachability from opensea-sdk, which depends on @opensea/seaport-js: it calls createOrder and createBulkOrders (so #1053 affects its contract wallet users, and #1056 changes whether an approval action is returned when a token was already approved individually). It does not call fulfillOrder or fulfillOrders, so #1051, #1054 and #1057 only affect direct seaport-js callers.

Impact

Tests

On this branch: npm run lint (tsc plus Biome) exit 0, npm test 299 passing, npm run coverage exit 0 (all files 98.72% lines, approval.ts 100% lines).

Each test was rerun with only its source hunk reverted on this branch, and failed on its own assertion:

Source reverted Failing test Failure
approval.ts, balanceAndApprovalCheck.ts (#1056) 5 cases in execute-approvals.spec.ts Error: The offerer does not have the sufficient approvals. (3 fill cases), expected [ 'approval', 'create' ] to deeply equal [ 'create' ], expected [ 'approval', 'exchange' ] to deeply equal [ 'exchange' ]
fulfill.ts key hunk (#1051) both cases in aggregate-key-collision.spec.ts expected [ [ { orderIndex: 1, ...}, ... ] ] to deeply equal [ [ { orderIndex: 1, ... } ], ... ]
seaport.ts compaction (#1053) 3 cases in sign-order.spec.ts, 1 in create-order.spec.ts expected 64 to equal 65, TypeError: invalid v (argument="v", value=31 ...), expected 99 to equal 100. With the length assertion also removed, the fill reverts with 'signature must be 65 bytes'
seaport.ts guards (#1054), and the fulfillOrders guard alone the number/bigint case in partial-fulfill.spec.ts expected promise to be rejected with an error including 'Units to fill must be greater than 0' but it was fulfilled with { actions: [ ... ] }
seaport.ts route gate alone (#1057) the basic route case in fulfill-extra-data.spec.ts reverted with custom error 'MissingExtraData()'
fulfill.ts useAdvanced alone (#1057) both cases in fulfill-extra-data.spec.ts reverted with custom error 'MissingExtraData()'

Removed or moved guards

None. Every change adds a condition. Diffed each changed function against main: #1056 replaces a single return with the same MAX_INT return plus a fallback that can only turn 0n into 1n; #1053 keeps the length === 132 check and adds conditions in front of compaction; #1057 adds a term to two route conditions.

Release

#1054 adds a throw on input that previously returned, so the next release is a minor: 4.5.0. No version bump is in this PR.

Notes for review

  • fix: recognize a single token ERC721 approval, not only approval for all #1056 adds one getApproved call per ERC721 item when the operator is not approved for all. For a token that does not exist, getApproved reverts, but balanceOf in the same Promise.all already calls ownerOf, which reverts too, so there is no new failure mode there. A contract that implements isApprovedForAll but not getApproved would now throw where it used to return an approval action. ERC-721 requires both, so this was left as is.
  • Each contributor's CI run was approved after screening its diff (only src/ and test/, no new dependencies, no workflow or script changes).

Confidence: high. Each fix is small, has a tripwire test, and the stack passes lint, tests and coverage.

🤖 Generated with Claude Code

nftpoetrist and others added 5 commits October 30, 2026 14:22
approvedItemAmount only read isApprovedForAll, so the approve(operator, id)
that exactApproval itself sends was never counted. A listing created with
exactApproval was then rejected by fulfillOrder (basic and standard route)
and fulfillOrders with "The offerer does not have the sufficient
approvals.", although Seaport fills it onchain, and every later action on
that token asked for the same approval again.

When the operator is not approved for all, check getApproved for an ERC721
item, using the resolved identifier for a criteria item as balanceOf does.
signOrder and signBulkOrder turned every 65 byte signature into its
EIP-2098 compact form, which is only safe for the offerer's own ECDSA
signature. A contract wallet such as a 1-of-1 Safe also returns 65
bytes, Seaport passes it to the wallet's isValidSignature as given, and
the wallet rejects the 64 byte rewrite. A Safe eth_sign signature (v 31
or 32) made ethers throw instead. Compact only when the signature
recovers to the offerer.
The fill paths treat a falsy unitsToFill as "not set" and fill what is
left of the order, so 0 or 0n bought the entire order. The same zero as
the string "0" already threw "Units to fill must be greater than 0" in
mapOrderAmountsFromUnitsToFill. Check it once in fulfillOrder and
fulfillOrders so every zero is rejected the same way.
fulfillOrder takes an extraData argument, but only fulfillAdvancedOrder
can carry it. When the order qualified for the basic route, or for plain
fulfillOrder (no units to fill, no criteria items, no gift recipient),
the SDK picked that route and the caller's extraData never reached
Seaport. Both of those entry points hand the zone an empty extraData.

For an open order that goes unnoticed, but a restricted order whose
zone reads extraData, such as a signed zone that expects its signature
there, reverts in authorizeOrder even though the caller supplied the
right bytes.

Treat non-empty extraData like the other inputs that need the advanced
route: skip the basic route and call fulfillAdvancedOrder. Fills without
extraData are unchanged. fulfillOrders already submits through
fulfillAvailableAdvancedOrders and was not affected.

The new test deploys a zone that rejects empty extraData and records
what it receives, and fulfills a restricted single ERC721 listing (the
basic route) and a restricted two item bundle (the fulfillOrder route)
with extraData. Both reverted with MissingExtraData before this change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants