Skip to content

feat: announcement-anchored Fusion auction, volume-ladder fill pricing, and signature-free maker flows - #430

Closed
deacix wants to merge 45 commits into
masterfrom
cursor/fusion-announcement-anchored-auction-9252
Closed

feat: announcement-anchored Fusion auction, volume-ladder fill pricing, and signature-free maker flows#430
deacix wants to merge 45 commits into
masterfrom
cursor/fusion-announcement-anchored-auction-9252

Conversation

@deacix

@deacix deacix commented Aug 2, 2026

Copy link
Copy Markdown
Member

Important

Being restructured per review (Vadim's split + Stepan's layering comments): the registrator change is extracted to #435 (the only limit-order-protocol piece); the auction mechanics have landed as fusion-protocol#223 (a standalone extension inheriting the auction base extracted from SimpleSettlement, fill ladder included); DelegatedMaker is parked pending a home decision. This PR stays open as the reference/discussion umbrella until the split PRs land, then closes unmerged. Detailed replies to each review comment are being posted on the threads.

Implements PT1-724 — the announcement-anchored Dutch auction — plus fill-size-dependent pricing over the order's volume ladder, and the one-transaction / no-signature / no-escrow maker flow built on top of them.

Everything now lands here. #432 (DelegatedMaker) and #433/#434 (two unrelated repo fixes) have been merged into this branch, so this is the single PR to review. Three new contracts, plus two small pre-existing bugs fixed along the way.

Where to start reviewing

The diff is 31 files and about five thousand lines, but the trust-bearing surface is 751 lines across three contracts, best read in dependency order:

  1. contracts/helpers/OrderRegistrator.sol (83 lines) — the anchor's only write path: maker-only, signature-free, idempotent, hashing locally against the protocol's ERC-5267 domain.
  2. contracts/extensions/FusionAnchoredAuction.sol (422 lines) — every pricing rule: the settlement-parity time curve, anchoring, the volume ladder with its monotonicity enforcement, exclusivity re-anchoring and the announcement deadline. Stateless views plus a revert-only post-interaction; it holds no funds and no allowances.
  3. contracts/helpers/DelegatedMaker.sol (246 lines) — the one custody-adjacent contract: a shared presigning maker concentrating standing allowances, with a just-in-time pull. Read it against the receiver and fee-shape paragraphs below; this is where review time is best spent.

Everything else is evidence rather than trust surface: the 87 owned tests in test/ mirror this description's claims one-for-one, scripts/ holds the measurement harnesses behind every number and chart here, contracts/mocks/SimpleSettlementMock.sol is a test-only mirror of the deployed settlement, the docs/ pages are generated, and the two pre-existing repo fixes are a one-line deploy-script correction plus a two-line test guard.

Why

A Fusion auction starts at an absolute timestamp baked into the order extension when it is built. A maker that signs slowly — a multisig collecting signatures — misses its own auction window, and the order degrades to the floor price. DAPP-6724 dropped its no-contract-change phase 1 on 2026-07-31 precisely because that floor-price execution is a bad deal for users, so the contract work is now the path forward. Prior art: UniswapX solves the same staleness problem by letting a trusted cosigner set decayStartTime/decayStartBlock at broadcast; the announcement-anchored design achieves that re-anchoring trustlessly.

Taking amount for a full fill over wall-clock time. Baked at build time the curve reaches the floor at 30 minutes, before a 45-minute multisig can sign, and then sits at the floor indefinitely. Anchored, the order is unfillable until announced at 45 minutes, then runs the same full curve, and its floor tail is ended by the announcement deadline at 80 minutes.

The scenario this PR exists for. A Safe taking 45 minutes to co-sign: today the curve decays while owners are still signing, so the order is at the floor by the time it can be filled — a free option for resolvers (the red band, running on unbounded). Anchored, it is unfillable until announced, then runs the same full curve an EOA gets. Both paths bottom out at the same floor, the order's own proportional rate, since the rate bump is unsigned and can only price against the taker; what the optional announcement deadline changes is how long the order stays fillable down there (the short green segment, ended at the X). The initial bump is exaggerated to 5% for legibility; production bumps are usually below 1%.

How the flows fit together

Flow diagram: three creation flows — DelegatedMaker with an approval or permit, a Safe MultiSend batch, and the classic EOA — all reaching the registrator as the maker of the order, then the fill path showing the presign check, the just-in-time pull, proceeds routed to the creator directly or via a fee taker, and the post-interaction

Registration and fill authorization are separate checks the core protocol keeps separate, so each maker type gets one flow:

  • EOA, primary: one DelegatedMaker.createOrder transaction — presign, broadcast and anchor together, nothing signed, no escrow, funds pulled just in time at fill. createOrderWithPermit folds the EIP-2612 allowance into the same transaction, and fees are collected through the verified custom-receiver shape (below).
  • Safe: one co-signed execTransaction delegatecalling the official MultiSend — an inner delegatecall to the already-deployed SignMessageLib marks the digest (the ERC-1271 presign later empty-signature fills validate against), and an inner call to registerOrder writes the anchor, since the registrator sees the Safe itself as sender. Nothing signed off-chain, no bespoke helper, and no new Safe delegatecall target: the only delegatecall surface is SignMessageLib, which the dApp already uses.
  • Classic EOA: sign the order off-chain for the fill (the router requires it), self-announce on-chain, distribute the signature through the orderbook API as Fusion does today — the on-chain event is no longer the signature channel.

Two pruning passes

After the feature set stabilized, two deliberate pruning passes removed everything with an overlapping sibling or a missing use case, so what ships is one mechanism per job:

  • One fill-pricing encoding. The earlier parametric linear rule is gone; the fill curve is the only encoding, and a linear schedule is simply a curve with no interior points (pinned by test — the splitting-cost numbers below are what the linear rule would produce for a fresh order).
  • One deadline, the unskippable one. The getter-side post-auction deadline is gone; the announcement deadline in the post-interaction blob is the fill-by mechanism, because amount getters can be skipped by mis-assembly and a post-interaction cannot. Quotes no longer revert past the deadline — the fill does, which resolver fill-simulations catch — and an order that wants the deadline without resolver exclusivity carries the anchored exclusivity blob with an empty whitelist.
  • One registrator event. OrderAnnounced(bytes32 indexed orderHash, uint256 timestamp, Order order, bytes extension) fires exactly once, on the first registration: it is both the anchor signal and the broadcast resolvers read presigned orders from. Repeat registrations are silent no-op successes; the block number was redundant with the log itself, and announcedAtBlock is gone.
  • No bespoke Safe helper. SignAndAnnounce is deleted in favor of the MultiSend batch above — one contract fewer and one security-review item fewer.
  • No auction start delay. An anchored auction starts at max(announcedAt, builtStartTime); the anchored flag carries no field, so there is nothing for the SDK to compute. An absolute built start can still push the auction past its announcement.
  • Self-provisioned allowances. approveRouter is gone; the pull tops up the protocol's allowance when it runs short. This also fixed a latent defect — a one-shot max approval would silently deplete for tokens that decrement even infinite allowances, eventually bricking fills.
  • Dense flag namespaces: auction flags are 0x01 ANCHORED / 0x02 FILL_CURVE; exclusivity flags are 0x01 ANCHORED / 0x02 ANNOUNCEMENT_DEADLINE.

What changed

OrderRegistrator records the timestamp each order was first announced at. Registration is maker-only, signature-free and idempotent: registerOrder(order, extension) reverts unless msg.sender is the order's maker, takes no signature because the transaction itself is the maker's proof of intent — strictly stronger provenance than "someone held a valid signature" — and on the first call writes the anchor and emits OrderAnnounced with the full order payload; a repeat call is a silent success that changes nothing, so an anchored auction can never be shifted. The signature check had become circular in every contract-maker flow anyway (a Safe marks signedMessages in the same transaction that registers, so an ERC-1271 check would read back state the caller just wrote); removing it drops the ECDSA dependency and the ERC-1271 roundtrip at registration. The order hash is computed locally, from the ERC-5267 domain captured at construction, rather than through a cross-contract hashOrder call that carries the whole order — the fork tests pin the local hash bit-for-bit against the live router's — and the call returns (orderHash, firstRegistration) so contract callers need no separate lookup.

DelegatedMaker (new helper) is the EOA flow — one transaction per order, no signature, no escrow. One shared contract is the maker for all of its users (CoW Protocol's presign plus its VaultRelayer just-in-time pull, as prior art): createOrder records the caller as the order's owner (the presign read by isValidSignature), validates that the order names this contract as maker, the remaining invalidator (the bit-invalidator nonce space would be shared across users) and a pre-interaction routed to this contract, then announces — anchoring the auction clock at the create block, through a single registerOrder call whose returned first-registration flag doubles as the duplicate check. Funds stay in the owner's wallet until the fill: the pre-interaction pulls exactly the filled amount through the owner's standing allowance immediately before the maker-asset transfer, topping up the protocol's own allowance for the maker asset whenever it runs short, so custody time is zero and the contract holds no balances between transactions. createOrderWithPermit folds the owner's EIP-2612 allowance into the create transaction itself — owner pinned to the caller, failures swallowed the way the protocol treats maker permits, so a front-run permit changes nothing and a wrong one leaves an order that a later approval revives. cancelOrder deletes the presign and invalidates at the protocol as the maker — both load-bearing, since the core validates ERC-1271 only on the first fill (OrderMixin._fillContractOrder) — and the duplicate check reads the registrator's ever-registered state, so a cancelled order cannot be re-created into a presigned-but-protocol-dead shell. The contract concentrates standing allowances (the same trust shape as the router itself) and has no owner powers over user funds.

Proceeds must route to the creator, one way or the other. The default rule is receiver == creator. A non-creator receiver is accepted in exactly one shape — fee collection: the order's post-interaction must target the receiver itself (which is FeeTaker's own distribution condition, order.receiver.get() == address(this)), carry the custom-receiver flag, and name the creator in the custom-receiver slot, with the post-interaction maker trait set. That guarantees a conforming FeeTaker-layout settlement forwards the net to the wallet that funded the order; anything else — a zero receiver, missing hook trait, short fee bytes, a foreign target or a foreign custom receiver — reverts InvalidFeeReceiver, because each of those strands or diverts the taking asset (FeeTaker would otherwise pay the maker's share to order.maker, the shared contract, which deliberately cannot withdraw). The fee contract itself is the creator's choice and trust; naming a hostile one harms only the creator, whose funds alone back the order. This deliberately couples DelegatedMaker to one fee layout, pinned by tests against a real FeeTaker and the live mainnet Settlement. Open product decision: if delegated orders can launch feeless, this whole validation collapses to the one-line receiver == creator rule and the only hardcoded dependency on another contract's byte layout disappears; re-adding it later is purely additive.

FusionAnchoredAuction is a new extension; every feature is opt-in per order through a flags byte, and with no flags set it prices exactly like the deployed settlement's auction:

  • Anchored start (0x01) at max(announcedAt, builtStartTime) — the auction starts the moment the maker announces, which is the product configuration, and an absolute built start can still defer it. The anchor fixes stale timing only: the curve's levels are baked at build time, which is what the announcement deadline bounds. Resolver exclusivity is re-anchored the same way in the post-interaction, since a late announcement leaves absolute exclusivity windows already elapsed and would let every resolver in at once. Announcing and filling can even share a block: the maker announces and a resolver fills later in the same block, priced at the very top of the curve.
  • Fill pricing over the volume ladder (0x02). A fill is priced by its own share of what remains: the remainder is a fresh ladder for every fill, so a tenth of what is left prices at the 1/10 row whether it is the first fill or the fifth, and whoever completes the order — in one sweep or as the last of many fills — pays the plain auction price. This is the team review's finding folded in: the earlier revision indexed the curve by the fill's cumulative end on the original amount, which under-paid makers on late partials (with 80% filled, a fill of 10% of the original priced at the nearly-free 9/10 row despite being half of everything left — 0.40% instead of 2.00% on the chart's curve). The remainder share is never smaller than the cumulative end was, so on a non-increasing curve the maker is never paid less, and a fresh order's pricing is unchanged — the quote matrix still maps row-for-row. The premium is enforced non-increasing along the ladder, lazily — one comparison per visited point, validating exactly the prefix a fill is priced on (NonMonotonicFillCurve); a rising stretch would pay takers to split, so it reverts instead of pricing, and enforcement makes the initial premium the provable worst case the making-amount estimate anchors on. The path-independent alternative — integrating the ladder over each fill's span, as RangeAmountCalculator and gradual Dutch auctions do — was considered and rejected: path independence makes splitting cost exactly what sweeping costs, removing the full-fill incentive this feature exists to create.
  • Announcement deadline (exclusivity flag 0x02 + 3 bytes) — the fill-by mechanism for anchored orders, bounding the floor-price tail an absolute expiry cannot once the start is anchored (order expiry is a uint40 in makerTraits enforced by the protocol core, which an extension cannot move). It rides in the post-interaction because a post-interaction is not skippable: an order whose amount data was mis-assembled prices at its plain ratio and skips every getter-side check, but still dies at its deadline. Requires the anchored bit and fails closed (InvalidFlagCombination); conceptually the delay is duration + the tail window the maker tolerates.

The fill curve over the volume ladder: the quote matrix hit exactly at each row and interpolated in between, next to the single-row schedule with no interior points — both falling to zero for a fill that takes the whole remainder

One encoding for every schedule, drawn over the share of the remainder a fill takes. The matrix hits each quote row exactly at its share (the rungs) and interpolates in between; a curve with no interior points (dashed) is the one-parameter schedule. Both end at zero, so completion is never penalized.

Bar chart comparing the total premium paid for one sweep, two halves, four quarters, ten tenths and a 30/30/40 split, under the quote matrix and a single-row curve

The incentive this creates, each fill priced by its share of what remains. Splitting always costs the takers more than sweeping, and a sweep pays nothing extra; in the 30/30/40 case the fill that completes the order pays no premium at all. Superadditivity is pinned twice in tests: exact values for hand-picked splits, and a seeded sweep of random partitions priced through the contract's own views over both curve shapes.

Where this matters most: a thin book with real price impact

The feature earns its keep on low-liquidity pairs. Sweeping such an order moves the price a lot, so the quoter has to price the whole order at the full-impact rate — and today that single rate is what every fill pays, whatever its size. A resolver who takes a tenth bears only a fraction of the impact but still pays the rate that was set to make a full sweep viable, and keeps the difference.

Two panels for a low-liquidity pair. Left: the value a resolver keeps as a share of the fill, against fill size. Under flat pricing it starts at 2.66 percent for a tenth-sized fill and falls to zero at a full sweep; under the volume ladder it is zero at every size. Right: extra proceeds for the maker by fill pattern — unchanged for one sweep, plus 0.59 percent for two halves, plus 1.22 for four quarters, plus 2.00 for ten tenths, and plus 2.74 percent when a resolver cherry-picks ten percent and stops

A pair where a full sweep costs 4% in impact. Today a resolver filling a tenth of the order keeps 2.66% of that fill purely because the slice was small; the ladder prices that slice at its own size and the money stays with the maker — and the remainder-based semantics extend that to late slices too, which is why ten tenths now recapture 2.00% (they recaptured 1.16% under the earlier cumulative indexing). Sweeping is unaffected — the completing fill never reads the curve — so the mechanism only bites the cherry-picking it is meant to discourage. Every "this PR" number is getTakingAmount on a real order; the impact model (impact growing with the square root of size) is the stated assumption, and the matrix here is set to the full modelled edge, which is the upper bound of what can be recaptured. A production quoter would leave resolvers some margin, so real recapture sits below these numbers.

Composition rather than inheritance

The settlement lives in fusion-protocol (still publishing the @1inch/limit-order-settlement npm package), and its master pins solc 0.8.23 with zksolc 1.4.1 — verified against the repo's hardhat.config.js and contract pragmas — while cross-chain-swap pins pragma solidity 0.8.23 too. This repo is on 0.8.30, so a settlement subclass written here could not be inherited downstream without upgrading the compiler in two repos, and a floating pragma on one file would not help because all of its imports pin 0.8.30.

Instead this is a standalone amount getter and post-interaction, referenced by address from the order extension and chained after whatever settlement is deployed, through dispatch points that already exist (AmountGetterBase forwards to IAmountGetter(tail[0:20]), FeeTaker._postInteraction forwards to IPostInteraction(tail[0:20]) — and fusion-protocol's Settlement._postInteraction only adds the priority-fee gate before calling straight through to that forwarding). No code and no compiler work in the other repos. Anchored orders carry a neutralized outer curve so the settlement's build-time auction contributes a factor of one; inlining it into SimpleSettlement later is optional cleanup, not a prerequisite.

This holds for Fusion+ as well, verified against cross-chain-swap source: BaseEscrowFactory._postInteraction forwards a chained post-interaction (between its whitelist data and the escrow immutables) and leaves the amount-getter chain untouched, so cross-chain orders can adopt the anchored auction through the same composition. Note its fee layout differs from same-chain Settlement — it calls FeeTaker._getFeeAmounts directly, so it carries no surplus-fee fields.

One interaction to know about, pinned empirically by fork test 5: the fill premium counts toward the settlement's surplus fee. Anything a fill pays above the scaled estimated taking amount is taxed at the surplus percentage, the estimate scales linearly with fill size while the premium does not, so the quoter cannot pad it away exactly — either the protocol takes its surplus share of the premium (the default) or the quoter sets the estimate with that in mind.

Both halves of the composition argument are tested rather than asserted, twice over: against SimpleSettlementMock — a line-for-line mirror of fusion-protocol master's _getRateBump, _getAuctionBump and whitelist walk, kept as a mock only because the real contract's compiler pins prevent importing it — and against the actually deployed settlement on a mainnet fork (below).

What composition costs in gas

Measured, not estimated: every number below is receipt.gasUsed from a real fill, once against the live mainnet Settlement on a fork and once against the local mirror of it, with identical orders on both sides of each pair. Reproduce with the committed harnesses: scripts/gas-comparison.js (local), scripts/gas-comparison-fork.js (fork, needs MAINNET_RPC_URL), scripts/price-impact.js (the thin-book charts) and scripts/fill-curve-data.js (the ladder and splitting charts).

Two panels of measured gas. Left: cost of one fill. Against the live mainnet Settlement, 92,089 gas today versus 107,410 with the anchored auction chained behind it, plus 15,321 or 16.6 percent. Against the local mirror, 121,694 versus 136,746, plus 15,052 or 12.4 percent. Right: new per-order costs that do not exist today — announcing an order costs 31,132 gas and DelegatedMaker createOrder costs 82,552.

Chaining the anchored auction behind the settlement costs about 15.0–15.3k gas per fill — the figure barely moves between the two measurements (+15,321 live, +15,052 local), because it is the cost of one extra external call plus parsing the auction blob. The percentage differs only because the baselines do: the live-settlement order was configured with zero fees, so its baseline does less work.

The rest of the picture, from the same measurements:

  • The fill ladder is nearly free. Adding it to a chained order costs 1,365 gas on a partial fill and 558 on a full one — the completing fill skips the curve walk entirely, so the only cost is parsing.
  • Standalone, the new auction is cheaper than today's settlement fill (104,513 vs 121,694), because it does no fee distribution. That is not the migration path for Fusion orders, which need fees, so the chained number above is the honest one to plan against.
  • The announcement is the real new cost, and it is per order rather than per fill: 31,132 gas for a lean order, rising with extension size (59,139 for the fee-carrying order in the local run, since the event logs the full payload). Today this step does not exist — it is what buys the anchor.
  • DelegatedMaker.createOrder costs 82,552 gas, but it is not an extra transaction: it replaces the off-chain signature and the separate approval, so that flow goes from "approve, sign, wait" to one transaction. Its first fill carries a further one-time ~24k of allowance self-provisioning; steady-state fills run at 100,715.

A dedicated optimization round produced these numbers (they started ~6–12% higher): the helpers hash orders locally from the ERC-5267 domain instead of a cross-contract hashOrder round trip, registerOrder returns (orderHash, firstRegistration) so createOrder collapsed from four external calls to one, and the fixed-point scaling takes AmountCalculatorLib's 128-bit-guarded fast path with a full-width mulDiv fallback that tests pin at the 2^128 boundary. The one accepted toll is +92 gas on a plain announcement, paying for the return values. What deliberately stayed, with its measured price: the event payload (4.4–6k, it is the trustless broadcast), the registrator's extension validation (521, keeps every broadcast payload fillable), and the lazy allowance check (~2.5–2.9k per DelegatedMaker fill, the insurance that allowance-decrementing tokens cannot brick fills). The structural ~15k chaining cost is recoverable only by inlining the auction into the settlement — a fusion-protocol follow-up.

Two pre-existing repo fixes folded in

Both were found while getting this branch green, both are unrelated to the feature, and both were briefly separate PRs (#433, #434) now merged here:

  • deploy/deploy-Permit2Proxy.js never destructured getNamedAccounts, so the script throws at runtime and ESLint reports no-undef. Since lint:js and lint:sol share one yarn lint, that single error kept master's lint job red. The corrected signature matches every other script in deploy/; I checked the other seven for the same mismatch and this was the only one.
  • yarn test failed under --parallel. hardhat-tracer's recorder lives in the main process, so under mocha's parallel workers it collects nothing, findTrace gets an empty list, and two wip tests died on Cannot read properties of undefined (reading 'top'). CI never saw it because CI runs the non-parallel yarn test:ci. The two opcode-count assertions already carried a guard for the analogous coverage case, so this extends it with tracingAvailable() (process.env.MOCHA_WORKER_ID === undefined, chosen by probing both modes; === undefined because worker zero reports "0"). Upgrading to hardhat-tracer 3.4.0 was tried first and reproduces the same failures, so no dependency bump is included. The assertions are skipped, not defeated: corrupting an expected count to MSTORE: 999 still fails the serial run while parallel stays green. AGENTS.md documented both of these as durable workarounds and has been updated, since neither is true any more.

Tests and review

87 unit tests across the three suites this branch owns (FusionAnchoredAuction 60, DelegatedMaker 18, OrderRegistrator 9), all against LimitOrderProtocol with expected prices computed independently in JS:

  • registrator: announcement storage, idempotent repeats (the anchor never moves and nothing fires twice), maker-only access control (a stranger's registration reverts AccessDenied, with nothing a signature could do about it), the payload-carrying OrderAnnounced, the returned (orderHash, firstRegistration) pair with the locally computed hash pinned bit-for-bit against hashOrder on the protocol, and contract-maker announcements through both the SafeOrderBuilder delegatecall and the MultiSend batch — whose digest marking is verified against the Safe's own ERC-1271 view;
  • parity with the settlement auction (a multi-point curve with a gas bump, at ten points in time, in both getter directions) and composition behind it;
  • unanchored behavior: pre-start, linear decay, piecewise points including points already passed, and the gas bump;
  • anchored: unannounced reverts, a resolver holding a perfectly valid fill signature still cannot start the clock, a stale built start is ignored, a later built start wins, an announcement six hours after the build still prices at the top of the curve, maker-announce and resolver-fill in the same block, the maker's expiry and cancellation both outrank the announcement, and every optional field at once — anchoring, fill curve and the post-interaction deadline — in both fill directions;
  • fill curve: every decile priced exactly at its matrix row for a fresh order, successive fills each priced by their share of what remains, the review's scenario pinned explicitly — after 80% fills, a 10%-of-original fill charges the 5/10 row, not the nearly-free 9/10 row its cumulative end once bought, interpolation between rows and past the last row, the premium riding on the running time curve and offset by the gas bump, completion at the plain curve price, a vanishing first fill at the full initial premium, the single-row one-parameter schedule, a matrix at the count byte's ceiling of 255 rows, oversized taking-amount fills capped and repriced at the full-fill price, orders that forbid partial fills, dust-sized rounding, and the conservative estimate on the taking-amount path proven no better than the exact fixed point;
  • full-width amounts: the fixed-point fast path and its mulDiv fallback pinned to the identical formula on both sides of the 2^128 boundary, in both getter directions and through the ladder's share measurement;
  • monotonicity: a hump-shaped matrix rejected in both fill directions, a rising first row caught on the walk's first comparison, and the laziness pinned — a fill priced entirely on the legal prefix goes through with a broken later stretch, the fill that reaches the rising row reverts, and the completing fill never reads the curve at all;
  • adversarial fills: a taker presenting forged cheaper auction bytes (or none at all) never reaches the pricing, because the salt commits to the extension hash; a spent order yields nothing to any crafted follow-up fill; and a seeded sweep over random fill sizes and remainders proves the maker's floor — the plain proportional rate — holds in both getter directions over both curve shapes, with direction round-trips manufacturing nothing beyond two wei of ceiling-division dust;
  • a property test: a seeded sweep of random partitions of the order, priced through the contract's own views over both curve shapes, none of which may undercut a single sweep;
  • exclusivity: anchored windows, the whitelist's own max() semantics, staggered whitelisted resolvers, an empty anchored whitelist, non-whitelisted takers waiting out every window, onward post-interaction chaining, the announcement deadline at its boundary and strictly past it, the deadline flag without the anchored bit failing closed, a mis-assembled order (no amount data routed through the auction at all) still bounded by the post-interaction deadline, and the deadline enforced through a chained settlement;
  • DelegatedMaker: the one-transaction lifecycle (create → anchored at the create block → filled with the empty signature, wallet untouched until the fill, the shared contract holding nothing before or after, and the protocol allowance self-provisioned on the first fill); exact just-in-time pulls across partial fills with proceeds landing on the owner directly; every creation rule rejected (wrong maker, zero receiver, bit-invalidator traits, missing pre-interaction hook, foreign pre-interaction target, duplicate hash, cancel-then-recreate); fee collection in the full production shape — fee taker as receiver paying the creator as custom receiver, anchored auction through the fee taker's getters, exclusivity through its post-interaction tail, the pull on the same order — plus five fee-shape reverts (missing flag, foreign custom receiver, foreign target, short bytes, missing hook trait) with a positive control; both permit paths — the allowance folded into one create transaction, and a front-run permit swallowed with the order still created and fillable; cancellation of a half-filled order killing the rest at the protocol (the first-fill-only ERC-1271 nuance); pull authorization (non-protocol callers and ownerless orders both refused); and two users on the shared maker isolated from each other's orders and cancellations.

5 mainnet-fork integration tests (yarn test:fork, opt-in via MAINNET_RPC_URL or FORK_RPC_URL, skipped otherwise so CI is unaffected), with only the new contracts deployed on top of live state:

  • an announced order priced and filled through the live Aggregation Router V6 — the registration takes no signature while the fill still validates the maker's EIP-712 signature against the router's own domain, the cleanest demonstration that removal is registration-side only; the helpers' constructors also read the live router's ERC-5267 domain here, proving the local hashing deploys against production;
  • composition behind the live Fusion settlement (0x2Ad5004c60e16E54d5007C80CE329Adde5B51Ef5, the address recorded in fusion-protocol's deployments/mainnet/Settlement.json), including its surplus-fee fields and priority-fee validation, with its curve neutralized so the chained anchored auction owns the price and the chained exclusivity still rejects an early resolver;
  • the multisig flow on the live Safe 1.3.0 contracts: the Safe's owner EOA cannot start the clock (AccessDenied), then one co-signed MultiSend batch through the live MultiSend and SignMessageLib deployments marks the digest and anchors the auction, and the order fills with the empty ERC-1271 signature;
  • the DelegatedMaker flow end to end through the live router: allowance, one createOrder transaction, and a fill that pulls just in time — nothing signed, nothing escrowed, the protocol allowance self-provisioned inside the fill;
  • fee collection through the live Settlement: the settlement is the order's receiver and pays the creator as its custom receiver, with a deliberate surplus fee set — the fill's curve premium lands above the scaled estimate and the settlement takes exactly surplusFee% of it, pinning the surplus-on-premium interaction with a real number.

Review trail: Bugbot on eleven rounds with one finding across all of them — in the remainder-ladder round it caught the JS pricing mirror omitting the gas-bump offset the contract applies, fixed in the same round (the initial implementation, fill-curve parsing, cumulative-ladder arithmetic, maker-only authorization, fee-shape and permit, simplification, hard-cut, the DelegatedMaker split, the two repo fixes, the gas-optimization round, and the remainder-ladder round were otherwise clean); Slither 0.11.6 at solc 0.8.30 (only the timestamp-comparison and default-false-local patterns inherent to a Dutch auction, plus known false positives inside OpenZeppelin's Math.mulDiv); checked against OpenZeppelin's published audits of the settlement refactor and LOP v4 diff (no contradicted assumptions; the 10-byte whitelist-address comparison is a known, accepted trade-off there and is noted in the NatSpec here); checked against the Solidity known-bugs list — the repo's 0.8.30 + viaIR configuration falls inside the affected ranges of SOL-2026-1 (transient delete, fixed 0.8.34) and SOL-2026-2 (mutual-recursion spill, fixed 0.8.36), but neither trigger pattern exists in this codebase, so neither is reachable here.

Correction to an earlier revision of this description, which suggested a repo-wide bump to solc ≥0.8.36 as a follow-up hygiene ticket: that upgrade is not available to this repo and the suggestion should not be acted on. zksolc supports solc only up to 0.8.30 (per the ZKsync compiler toolchain docs, whose solc fork tops out at 0.8.30-1.0.2), and zkSync is a live target here — deployments/zksync/FeeTaker.json records zksolc 1.4.0 with solc 0.8.23, and WethUnwrapper.json records zksolc 1.3.10 with solc 0.8.19. The repo's 0.8.30 pin therefore sits exactly on zksolc's ceiling, and moving past it would make these sources uncompilable for zkSync Era. Closing SOL-2026-1/2 by upgrading has to wait for zksolc to support a newer solc, or would require a split compiler configuration per target — a real architectural change rather than hygiene. Neither bug is reachable in this code, so nothing here is blocked on it.

Verification of the consolidated branch: yarn lint clean; yarn test (parallel) and yarn test:ci (serial) both 257 passing, 0 failing; yarn coverage 244 passing with 100% statements, branches, functions and lines on all three contracts this PR ships — including the full-width fallback branches; yarn test:fork 5 passing against live mainnet state. Generated docs/ pages are regenerated for the changed contracts.

Resolved decision: registration is maker-only and signature-free

An earlier revision left registration permissionless for anyone holding a valid signature, which let the first caller start the auction clock on orders whose signature was public. That is now closed: only the maker's own transaction registers and anchors, which eliminates the early clock-setting vector outright instead of bounding it, and the signature went with it because a maker-sent transaction authenticates more strongly than any signature check (which had become circular for contract makers anyway). Accepted costs, deliberately: no relayer-paid broadcast or anchoring; no atomic third-party announce-and-fill (one block of latency instead); contract makers that implement ERC-1271 but cannot make arbitrary calls cannot use anchored orders; and a plain-EOA maker who self-announces distributes the fill signature off-chain, since the event is maker-authenticated broadcast rather than a signature channel — or uses DelegatedMaker and has no signature anywhere. Self-announced-but-unfillable orders are self-sabotage only; resolvers simulate before filling.

Follow-ups for other teams

  • Relayer and resolver support for the encoding: the quoter's volume matrix maps row-for-row onto a fresh order's fill curve; after a partial fill the same rows read as fractions of the remainder. Rollout can start with a low-premium single-row curve while fill rates are watched. Indexers key everything on OrderAnnounced, which fires exactly once per order and carries the timestamp and the full order payload.
  • Quoter decision on the surplus-fee interaction (fork test 5 demonstrates it): treat the fill premium as surplus and let the protocol take its share, or pad the estimated taking amount knowing the padding cannot be exact across fill sizes.
  • Quoter decision on how much of the price-impact edge the ladder should capture: the chart above sets the matrix to the full modelled edge, which leaves a partial filler no margin at all. Production rows should sit below that so partial fills stay worth doing.
  • The dApp's Safe flow becomes one MultiSend batch (SignMessageLib delegatecall + registerOrder call) — one co-signing ceremony, built from components the dApp already trusts, with no new delegatecall target to security review.
  • Fees on DelegatedMaker orders couple it to FeeTaker's custom-receiver layout by design; if the layout ever moves, the offsets in _createOrder move with it.
  • fusion-protocol (the settlement repo), if the curve is ever inlined there: the surplus fee in SimpleSettlement._getFeeAmounts would read the partial-fill premium as surplus and tax it, so the estimated taking amount needs scaling by the same factor. Inlining would also recover most of the ~15k gas the chained call costs. Its master has no anchored-auction work yet, so this branch remains the reference implementation for the team's split.
  • Redeploying the registrator requires a new create3 salt; the deploy script fails loudly rather than colliding with the address the announcement-less deployment occupies, and the interface break — two-argument registerOrder, now returning (orderHash, firstRegistration), and the single payload-carrying OrderAnnounced — has no deployed victim since the legacy deployment keeps serving legacy semantics. Anchored orders only work when announced through the new deployment. DelegatedMaker ships with its own create3 salt and deploy script; deploy order is registrator first, then the auction and the helper.
  • Native ETH gets its own PR: ETH has no allowance for a just-in-time pull, so the NativeOrderFactory escrow-per-order pattern stays the native answer, with the clone's initialization calling registerOrder so the anchor is maker-written in the create transaction.
  • EIP-7702 is the eventual per-user alternative to the shared DelegatedMaker: a delegated EOA can hold the presign locally with no allowance concentration and the same one-transaction UX, and needs nothing from this repo.
  • Anyone tracking the solc upgrade path: it is gated on zksolc, not on this repo (see the correction above).
Open in Web Open in Cursor 

cursoragent and others added 4 commits August 2, 2026 09:13
Store the timestamp and block number of the first registration of each order,
keyed by order hash. The announcement is written once and never moved, so an
auction anchored to it cannot be shifted by a later registration, while a
repeated registration of an unchanged order still succeeds and re-emits its
event as SafeOrderBuilder relies on.

Redeploying the registrator needs an explicit create3 salt, since the default
one resolves to the address the announcement-less deployment occupies.

Co-authored-by: Sergej Kunz <info@deacix.de>
A Dutch auction that can start from the moment an order was announced on-chain
rather than from a timestamp baked in at build time, so a maker that signs
slowly — a multisig collecting signatures — gets the price curve a prompt maker
gets instead of decaying to the auction floor before it can submit.

Three features, each opt-in per order through a flags byte:

- an anchored start at max(announcement + delay, built start), which also
  re-anchors resolver exclusivity so a late announcement does not dissolve it;
- fill-scaled pricing, where a taker sweeping what is left pays the auction
  price and a smaller fill gets only its share of the discount, at a strength
  the order chooses;
- a deadline relative to the auction's end, which bounds the floor-price tail
  that an absolute order expiry cannot express once the start is anchored.

The contract is a standalone amount getter and post-interaction referenced by
address from the order extension, so it composes with any deployed settlement
version rather than requiring one to inherit from it.

Co-authored-by: Sergej Kunz <info@deacix.de>
Also pin the gas price in the gas-bump test, as the fee history the provider
infers one from predates the base fee the test forces, which fails under
solidity-coverage.

Co-authored-by: Sergej Kunz <info@deacix.de>
The auction has to reproduce the pricing of the SimpleSettlement contract in
limit-order-settlement when none of its flags are set, and has to compose
behind it when they are — neither could be checked here, because that contract
compiles at an older Solidity version and cannot be imported.

Add a mock mirroring its auction and whitelist, and test against it: identical
prices across a multi-point curve at ten points in time in both getter
directions, and a chained fill where the settlement's neutralized curve
contributes nothing while its fees and the chained exclusivity still apply.

Also reject an unset registrator when deploying the auction, since it is
immutable and every anchored fill reads it.

Co-authored-by: Sergej Kunz <info@deacix.de>
@deacix
deacix marked this pull request as ready for review August 2, 2026 14:11
cursoragent and others added 12 commits August 2, 2026 14:19
The fill-scaling premium is superadditive under splitting — each later part
faces a smaller remainder, so its share of it is priced no better — which is
what makes the penalty meaningful at all. Pin it with fills at the same
auction state, and tighten the packing comment on the registrator slot.

Co-authored-by: Sergej Kunz <info@deacix.de>
The script calls getNamedAccounts without destructuring it from the
hardhat-deploy arguments, so it would throw the moment it ran, and the
undefined reference has kept the CI lint job red since it merged.

Co-authored-by: Sergej Kunz <info@deacix.de>
Three mainnet-fork tests, opt-in through MAINNET_RPC_URL (or FORK_RPC_URL)
and skipped otherwise, with only the two new contracts deployed on top of
live state:

- an announced order priced and filled through the live Aggregation Router
  V6, signed against its own EIP-712 domain, which differs from the locally
  deployed protocol's;
- composition behind the live Fusion settlement at
  0x2Ad5004c60e16E54d5007C80CE329Adde5B51Ef5, including its surplus-fee
  fields and priority-fee validation, with its curve neutralized so the
  chained anchored auction owns the price and the chained exclusivity still
  holds;
- the multisig flow on the live Safe 1.3.0 contracts: a Safe marks the
  order digest through SignMessageLib, the order is announced with an empty
  ERC-1271 signature, and the auction prices from that announcement.

The pricing mirror shared by the unit and fork suites moves to
test/helpers/fusionAuction.js.

Co-authored-by: Sergej Kunz <info@deacix.de>
Boundary instants of the auction and of the post-auction window, a
zero-duration step curve, a zero-delay point re-anchoring the curve, a gas
bump that swallows the whole rate bump, gas estimates without a gas price,
every optional field parsed at once in both fill directions, third-party
announcements, the maker expiry outranking the announcement, a rising curve
releasing no discount to withhold, the gas bump coming off the scaled bump,
oversized taking-amount fills capped and repriced, orders that forbid
partial fills, dust-sized rounding, the whitelist max() semantics, staggered
whitelisted resolvers, and an empty anchored whitelist.

Branch coverage of both contracts reaches 100%.

Co-authored-by: Sergej Kunz <info@deacix.de>
Also record in the exclusivity docs that whitelisted addresses compare by
their lowest ten bytes, the same size-versus-grinding trade-off the
settlement contracts already make.

Co-authored-by: Sergej Kunz <info@deacix.de>
Fusion quotes are built from a depth matrix carrying different rates for
1/10, 2/10 and so on of the swap amount, and that relationship is not
linear — so the linear rule alone cannot encode what the quoter knows. A
new fill-curve flag carries the matrix on-chain: a piecewise premium over
the share of the remainder a fill takes, built exactly like the auction's
own time curve, one point per matrix row, hit exactly at its share and
interpolated linearly in between so there are no cliffs to game. The
premium rides on top of the time curve, a full sweep still pays the plain
curve price, and a single-row curve reproduces the linear rule. The two
encodings are mutually exclusive per order.

The conservative making-amount estimate anchors on the curve's true peak,
so it stays no better than exact even for a matrix that peaks at an
interior row. Branch coverage stays at 100%.

Co-authored-by: Sergej Kunz <info@deacix.de>
Both fill-pricing modes move from a per-fill share of the remainder to the
cumulative end of the fill on the order's original amount. The quote's
matrix is indexed by cumulative volume — the 2/10 row is the rate at two
tenths of the amount — so the rows are consumed in order: the first tenth
prices at the 1/10 row, the next tenth at the 2/10 row, and successive
fills walk down the ladder instead of re-measuring each fill against a
shrinking remainder.

First fills price exactly as before, whoever completes the order still
pays the plain auction price, and splitting still always costs more than
sweeping — all pinned. For the linear rule the change is one denominator.
The path-independent alternative, integrating the ladder over each fill's
span the way RangeAmountCalculator and gradual Dutch auctions do, was
considered and rejected: path independence removes the full-fill incentive
this feature exists to create.

Co-authored-by: Sergej Kunz <info@deacix.de>
Six additions: an announce-and-fill in the same block, the atomic pattern
a resolver actually uses, proven by receipts sharing a block number; the
matrix's implied final segment between its last row and completion; the
gas bump coming off a matrix premium; a matrix order chained behind the
fee taker with fees; a matrix at the count byte's ceiling of 255 rows;
and superadditivity as a property — a seeded sweep of random partitions
of the order, priced through the contract's own views in both encodings,
none of which may undercut a single sweep.

Co-authored-by: Sergej Kunz <info@deacix.de>
Three adversarial pins: a taker presenting forged or stripped auction
bytes never reaches the pricing, because the salt commits to the extension
hash; a spent order yields nothing to any crafted follow-up fill; and a
seeded sweep over random fill sizes, remainders and both encodings proves
the maker's floor — the plain proportional rate — holds in both getter
directions, with direction round-trips manufacturing nothing beyond two
wei of ceiling-division dust.

Co-authored-by: Sergej Kunz <info@deacix.de>
Registration turns signature-free and maker-only: registerOrder(order,
extension) authenticates by msg.sender alone, writes the anchor once and
emits OrderAnnounced exactly once; permissionless broadcast goes with the
signature filter it depended on. SafeOrderBuilder drops its now-redundant
empty signature, and the new SignAndAnnounce helper gives Safes the same
one-execution mark-and-announce flow without the oracle logic.

The fill curve is enforced non-increasing lazily inside the premium walk
(NonMonotonicFillCurve), which makes the initial premium the worst case
and deletes the max-premium scan. The exclusivity blob gains an optional
announcement deadline (flag 0x04 + 3 bytes) that reverts AuctionExpired
past announcedAt + delay and fails closed without the anchored bit —
defense in depth against getter-side mis-assembly.

Co-authored-by: Sergej Kunz <info@deacix.de>
A shared contract is the order's maker for all of its users: createOrder
records the caller as the order's owner (the ERC-1271 presign read by
empty-signature fills), validates maker/receiver/traits/pre-interaction
target, refuses ever-registered hashes (including cancel-then-recreate),
and announces through the registrator — anchoring the auction clock in
the same transaction. preInteraction pulls exactly the filled amount
from the owner just in time, so custody time is zero and proceeds land
on the owner directly. approveRouter grants the protocol the contract's
own allowance per token, permissionlessly. cancelOrder deletes the
presign and invalidates at the protocol, both load-bearing since the
core validates ERC-1271 only on the first fill.

Covered by a lifecycle/validation/cancellation/isolation suite, an
anchored-matrix composition test, and a fourth mainnet-fork test running
the whole flow through the live router.

Co-authored-by: Sergej Kunz <info@deacix.de>
…gistered

Co-authored-by: Sergej Kunz <info@deacix.de>
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cursor cursor Bot changed the title feat: announcement-anchored Fusion auction with fill-scaled pricing feat: announcement-anchored Fusion auction with fill-scaled pricing and signature-free maker flows Aug 3, 2026
cursoragent and others added 11 commits August 3, 2026 14:40
FeeTaker pays the maker's share to order.maker unless its fee data names
a custom receiver, and it only takes fees when the order's receiver is
the fee taker itself. For a DelegatedMaker order that maker is the shared
contract, which cannot withdraw, so the receiver rule refuses such an
order rather than let a fill strand proceeds there permanently. Documents
the resulting limitation — these orders price through the auction and take
no fees — and adds the production-shape test with the pull and resolver
exclusivity on the same order.

Co-authored-by: Sergej Kunz <info@deacix.de>
The settlement repo lives at 1inch/fusion-protocol (still publishing the
limit-order-settlement npm package). Verified against its master: solc
0.8.23 / zksolc 1.4.1, SimpleSettlementMock mirrors _getRateBump,
_getAuctionBump and the whitelist walk line for line, the surplus fee
sits in SimpleSettlement._getFeeAmounts, Settlement adds only the
priority-fee gate before forwarding, and deployments/mainnet records the
exact address the fork suite composes behind.

Co-authored-by: Sergej Kunz <info@deacix.de>
A non-creator receiver is now accepted in exactly one shape: the order's
post-interaction targets that receiver (FeeTaker's own distribution
condition) and its fee data carries the custom-receiver flag naming the
creator, so a conforming fee contract forwards the net to the wallet
that funded the order. Anything else reverts InvalidFeeReceiver — the
zero receiver, a missing hook trait, short fee bytes, a foreign target
or a foreign custom receiver each strand or divert proceeds.

createOrderWithPermit folds the EIP-2612 allowance into the create
transaction with the permit owner pinned to the caller and failures
swallowed the way the protocol treats maker permits: a front-run permit
already granted the allowance, and a wrong one leaves the order anchored
but unfillable until an approval revives it.

Covered by the full production-shape unit test (fee taker + anchored
auction getters + exclusivity through the fee taker's tail + the pull),
five fee-shape reverts with a positive control, both permit paths, and a
fifth mainnet-fork test through the live fee-collecting Settlement that
pins the surplus-fee-on-premium number the rollout note warns about.

Co-authored-by: Sergej Kunz <info@deacix.de>
The linear rule was the first iteration of fill-size pricing and the
matrix is what the quoter produces, so the fill curve is now the only
encoding: the fill-scaled flag, its strength byte, _scaleByFill and the
two errors that existed to police two encodings are gone, the AuctionState
struct flattens into plain (auctionBump, gasBump) returns, and FILL_CURVE
takes the freed 0x02 slot. A linear schedule is a curve with no interior
points — the premium falls straight from its initial value to zero at
completion — which the renamed pointless-curve test pins. The three
flag-independent behaviors the linear suite carried (oversized-fill cap,
partial-fills-forbidden inertness, dust rounding) migrate to curve
configurations; the rest had matrix twins. Accepted semantic difference,
deliberate: the linear premium scaled the unreleased time-curve discount
and decayed with the auction, the curve premium is flat over time.

Co-authored-by: Sergej Kunz <info@deacix.de>
The getter-side post-auction deadline duplicated the post-interaction
announcement deadline with a different reference point and a weaker
guarantee — amount getters are skippable by mis-assembly, a
post-interaction is not — so the getter-side flag, its window field and
its branch are gone and the announcement deadline is the fill-by
mechanism, taking the freed 0x02 slot in the exclusivity byte. An order
that wants the deadline without resolver exclusivity carries the anchored
blob with an empty whitelist, which the every-optional-field test now
demonstrates. Two consequences documented in NatSpec: quoting through
the getters no longer reverts past the deadline (the fill does, which
resolver simulations catch), and the delay is conceptually
startDelay + duration + the tolerated tail window.

Co-authored-by: Sergej Kunz <info@deacix.de>
Nothing on-chain reads the announcement block number, and every log
already carries the block it was emitted in, so announcedAtBlock, the
timestamp/block packing and the event's block-number field are gone. The
announcement is a public timestamp mapping whose generated getter is the
interface view, and OrderAnnounced keeps only the indexed hash and the
timestamp — the one field that saves indexers a block lookup.

Co-authored-by: Sergej Kunz <info@deacix.de>
Co-authored-by: Sergej Kunz <info@deacix.de>
SignAndAnnounce duplicated what two already-audited Safe components do
in one co-signed execution: MultiSend delegatecalled by the Safe runs an
inner delegatecall to SignMessageLib (the digest marking that authorizes
later empty-signature fills) and an inner call to registerOrder (the
anchor, since the registrator sees the Safe as sender). Same one-ceremony
UX, one contract fewer, and no new Safe delegatecall target to security
review — the only delegatecall surface is SignMessageLib, which the dApp
already uses. The helper, its deploy script, salt and docs page are gone;
the local and fork Safe tests drive the batch instead, the fork one
against the live 1.3.0 MultiSend and SignMessageLib deployments.

Co-authored-by: Sergej Kunz <info@deacix.de>
approveRouter is gone: preInteraction tops the protocol's allowance up
to the maximum whenever it runs short, so a token's first fill provisions
it and no separate setup step exists to forget. This also fixes a latent
defect — the one-shot max approval would silently deplete for tokens that
decrement even infinite allowances, eventually bricking fills until
someone re-called approveRouter; the lazy check re-provisions
automatically. Costs one warm allowance read per fill.

Co-authored-by: Sergej Kunz <info@deacix.de>
OrderRegistered merged into OrderAnnounced, which now carries the order
and extension payload after the indexed hash and the timestamp: one event
on the first registration is both the anchor signal and the broadcast
resolvers read presigned orders from. Repeat registrations become silent
no-op successes — SafeOrderBuilder re-runs still work, and re-emission
never carried information the permanent first log did not.

Co-authored-by: Sergej Kunz <info@deacix.de>
cursoragent and others added 3 commits August 4, 2026 10:28
The 3-byte announcement-relative start delay had no named use case: an
anchored auction now starts at max(announcedAt, builtStartTime), so the
flag carries no field and the SDK has nothing to compute. An absolute
built start can still push the auction past its announcement, which the
build-time-start test keeps pinning. Two tests that filled exactly at
the announcement instant now fill one second in and price through the
mirror; the exact top-of-curve fill stays pinned by the same-block
announce-and-fill test.

Co-authored-by: Sergej Kunz <info@deacix.de>
Co-authored-by: Sergej Kunz <info@deacix.de>
The hard-cut round's docs-restore step ran git checkout over unmatched
docs paths and silently reverted the page's deletion before it was
staged, so the orphan survived the helper it documented.

Co-authored-by: Sergej Kunz <info@deacix.de>
@cursor cursor Bot changed the title feat: announcement-anchored Fusion auction with fill-scaled pricing and signature-free maker flows feat: announcement-anchored Fusion auction with volume-ladder fill pricing and signature-free maker flows Aug 4, 2026
cursoragent and others added 2 commits August 4, 2026 12:33
Co-authored-by: Sergej Kunz <info@deacix.de>
… flow

Co-authored-by: Sergej Kunz <info@deacix.de>
@cursor cursor Bot changed the title feat: announcement-anchored Fusion auction with volume-ladder fill pricing and signature-free maker flows feat: announcement-anchored Fusion auction with volume-ladder fill pricing and signature-free registration Aug 4, 2026
cursoragent and others added 2 commits August 5, 2026 07:25
deacix added 2 commits August 5, 2026 11:46
feat: DelegatedMaker — one-transaction, no-signature, no-escrow maker flow
test: make yarn test pass under --parallel
cursoragent and others added 2 commits August 5, 2026 09:10
Co-authored-by: Sergej Kunz <info@deacix.de>
Co-authored-by: Sergej Kunz <info@deacix.de>
@cursor cursor Bot changed the title feat: announcement-anchored Fusion auction with volume-ladder fill pricing and signature-free registration feat: announcement-anchored Fusion auction, volume-ladder fill pricing, and signature-free maker flows Aug 5, 2026
cursoragent and others added 7 commits August 5, 2026 13:21
…int math

The registrator and DelegatedMaker compute the protocol's order hash locally, rebuilding the
domain from ERC-5267 data captured at construction, instead of a cross-contract hashOrder call
that carries the whole order. registerOrder returns (orderHash, firstRegistration), collapsing
DelegatedMaker.createOrder from four external calls to one. The auction's fixed-point scaling
takes AmountCalculatorLib's 128-bit-guarded fast path and falls back to full-width mulDiv.

Measured on real fills: createOrder 87,871 -> 82,552; announcement 35,211 -> 31,132 (fork,
lean order) and 62,392 -> 59,139 (local, fee order); every fill about 190-280 cheaper. The
return values cost +92 on a plain announcement.

Co-authored-by: Sergej Kunz <info@deacix.de>
… values

Co-authored-by: Sergej Kunz <info@deacix.de>
Co-authored-by: Sergej Kunz <info@deacix.de>
Team review caught the cumulative-on-original indexing under-paying makers on late partial
fills: with 80% filled, a fill of 10% of the original order priced at the nearly-free 9/10
row despite being half of everything left. Each fill now reads the curve at its own share
of the remaining amount - the remainder is a fresh ladder - which is never a smaller share
than the cumulative end was, so on a non-increasing curve the maker is never paid less.
A first fill's premium still equals the quote row for its size, and the completing fill
still pays no premium at all. The original making amount drops out of pricing entirely.

Co-authored-by: Sergej Kunz <info@deacix.de>
Co-authored-by: Sergej Kunz <info@deacix.de>
Co-authored-by: Sergej Kunz <info@deacix.de>

@SteMak SteMak left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting MVP, however, not the right place to implement it, Fusion functionality is located in fusion-protocol repo
Introduces some breaking changes violating the original architecture intents, particularly, order registrator completely drifts its announcement functionality as signature is not announced anymore
Delegated maker is inefficient by design due to additional token transfer and it is not clear which problem it solves
Some other minor inefficiencies flagged in comments

import { AmountGetterBase } from "./AmountGetterBase.sol";

/**
* @title FusionAnchoredAuction

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fusion extends Limit Order Protocol. Limit Order Protocol is not a correct place to implement Fusion functionality. Architecturally this creates circular dependencies, lowers readability, increases execution flow complexities. While abstraction layers could be neglected during MVP building in production code it is important to have separate objects keeping isolated responsibility

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and restructured accordingly: the auction mechanics now live in fusion-protocol as 1inch/fusion-protocol#223, built the way this comment implies — a move-only commit extracts the settlement's own auction math into an inheritable base, and the anchored extension inherits it. The only limit-order-protocol change left is the registrator's write-once announcedAt (#435), which the extension reads through a locally declared one-view interface.

One nuance that is a requirement rather than a preference: it ships as a separately deployed, per-order opt-in extension because redeploying the deployed settlement is out of scope; folding into a future settlement version stays available whenever one ships anyway. This PR stays open only as the reference/discussion umbrella and closes unmerged.

*
* The recorded announcement is what {FusionAnchoredAuction} anchors an auction to, so it is written
* once and never moved: a repeated registration of the same order is a silent success that changes
* nothing. The announcement is keyed by order hash alone, which already commits to the maker.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imperative comment instead of declarative one

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #435 — the registrator change was re-cut from master with declarative one-line NatSpec.


/// @dev The protocol's order hash, computed locally: the domain is rebuilt from immutables captured
/// at construction (fork-safe via block.chainid), so no cross-contract call carries the order.
function _hashOrder(IOrderMixin.Order calldata order) private view returns (bytes32) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Porting piece of code from one smart contract to another is architecturally incorrect and increases complexity for the future chagnes
Does the change justified by the Gas costs reduction? How much Gas does it save?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in #435 — it calls hashOrder() again. Measured answer to the gas question: the optimization bundle this was part of (local hashing plus collapsing createOrder to a single external call) took the create flow down 6–12%; the hashOrder round-trip alone is roughly 3k per call (cold account access plus re-copying the order as calldata). At announcement frequency that does not justify duplicated domain logic, so it stays out; if wanted later it is a self-contained follow-up measurable with scripts/gas-comparison.js on this branch.

* @notice See {IOrderRegistrator-registerOrder}.
*/
function registerOrder(IOrderMixin.Order calldata order, bytes calldata extension, bytes calldata signature) external {
function registerOrder(IOrderMixin.Order calldata order, bytes calldata extension) external returns (bytes32 orderHash, bool firstRegistration) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not announce signature anymore which makes the order registry to play no announcement role as event info is not enough to execute the order
Completely drifts the contract functionality and purpose breaking the initial intent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and restored in #435: registerOrder(order, extension, signature) is permissionless again, validates ECDSA/ERC-1271 exactly as on master, and emits the signature-carrying OrderRegistered on every call — the event is sufficient-to-fill again. The only delta vs master is announcedAt[orderHash] = block.timestamp on the first registration, write-once so a re-announcement can never move an anchored auction.


// Validate signature
if(!ECDSA.recoverOrIsValidSignature(order.maker.get(), _LIMIT_ORDER_PROTOCOL.hashOrder(order), signature)) revert IOrderMixin.BadSignature();
if (msg.sender != order.maker.get()) revert AccessDenied();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaks EIP-1271 compatibility

Makes multisig sign flow more complex as requires not only order sign but transaction as well

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored with the signature path in #435. A Safe signs once — it marks the digest via SignMessageLib — and the announcement can then be submitted by anyone with empty signature bytes, validated through ERC-1271. Pinned by tests there: third-party relay of a signed announcement, one MultiSend batch (signMessage + registerOrder with 0x), and the negative gate — an empty signature without a presign reverts BadSignature.


IERC20 makerAsset = IERC20(order.makerAsset.get());
if (makerAsset.allowance(address(this), address(_LIMIT_ORDER_PROTOCOL)) < makingAmount) {
makerAsset.forceApprove(address(_LIMIT_ORDER_PROTOCOL), type(uint256).max);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd avoid branching here, justmakerAsset.forceApprove(address(_LIMIT_ORDER_PROTOCOL), makingAmount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one with numbers. The protocol spends the allowance down to zero during each fill, so the unconditional shape pays a fresh zero-to-nonzero SSTORE (20k) plus the approve call on every fill; the branch pays one allowance SLOAD in steady state, because the infinite allowance is never decremented. Measured on this branch's harness, the lazy check costs 2.5–2.9k per fill all-in; the unconditional variant would add roughly 17–20k to the measured 100,715-gas steady-state fill.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you counted for gas refund when the allowance is used fully

if (makerAsset.allowance(address(this), address(_LIMIT_ORDER_PROTOCOL)) < makingAmount) {
makerAsset.forceApprove(address(_LIMIT_ORDER_PROTOCOL), type(uint256).max);
}
makerAsset.safeTransferFrom(owner, address(this), makingAmount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inefficient double-transfer design: maker -> delegated maker -> taker
Can we do smth with this..?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inherent to the shared-maker shape: the protocol itself executes transferFrom(maker → taker) and the maker is the shared contract, so the just-in-time pull must land there first. The alternative that removes the hop — a minimal proxy per user, each proxy its own maker — trades one warm ERC-20 transfer per fill (~7–12k depending on the token) for a clone deployment per user plus a per-user address resolvers must discover. Considered and rejected for now; revisitable if per-user fill counts flip the arithmetic.

postInteractionData[20] & _CUSTOM_RECEIVER_FLAG == 0 ||
address(bytes20(postInteractionData[_CUSTOM_RECEIVER_OFFSET:_CUSTOM_RECEIVER_OFFSET + 20])) != msg.sender
) revert InvalidFeeReceiver();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Costly on-chain verification that maker's request is well formed, cannot it be delegated to off-chain?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is cheap — calldata slicing and comparisons, no storage: a few hundred gas within the measured 82,552-gas createOrder. And it cannot move off-chain without changing what it protects against: the check binds receiver to the creator (or the verified fee-collection shape) at the contract boundary, so a hostile or buggy frontend cannot craft an order that strands or diverts the proceeds of the wallet funding it. Off-chain validation protects only users of that particular frontend.

* creator's choice and trust, and naming a hostile one harms only the creator, whose funds alone back
* the order.
*/
contract DelegatedMaker is IERC1271, IPreInteraction {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear which problem the contract solves. It is a costly wrapper around Limit Order Protocol orders creation which pretends to hide complexity introduced by changes in order registrator but only causes additional Gas costs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, tons of AI'sh comments are a headache: while the half I read is factually correct, they are not helpful and rather distract attention

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted. #435 carries one-line declarative NatSpec only, and the fusion-protocol port (1inch/fusion-protocol#223) follows the same rule; design rationale lives in PR descriptions where it can be skipped.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem it solves is a product requirement from the Fusion side: a one-transaction, no-off-chain-signature, no-escrow flow for ERC-20 makers (prior art: CoW's presign plus its VaultRelayer just-in-time pull). Measured cost of that property: createOrder 82,552 gas — replacing both the separate approval transaction and the off-chain signature — and steady-state fills at 100,715 vs the 121,694 baseline settlement fill.

Whether that trade is worth shipping, and in which repo, is now an explicit open question for the team; the contract is parked and no longer blocks the anchored-auction work (#435 + 1inch/fusion-protocol#223).

* quoter cannot pad it away exactly. Either accept that the protocol takes its surplus share of the
* premium, or set the estimate with that in mind.
*/
contract FusionAnchoredAuction is AmountGetterBase, IPostInteraction {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting MVP, however, has a lot of duplications from existing code. Has to be reorganized to demonstrate real code changes to make a thorough review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — and the duplication was structurally unavoidable in this repo, since the settlement's auction functions are private and live in another codebase; that is the strongest argument for the move. In 1inch/fusion-protocol#223 the same feature is exactly what this comment asks for: a move-only extraction commit first (bodies byte-identical, privateinternal virtual, the untouched 115-test suite pinning behavioral identity), then the anchored feature as a small inheriting extension — reviewable commit-by-commit.

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.

3 participants