Skip to content

BM-3078: feat(contracts): run the router market on Taiko via the shared Shanghai profile - #2044

Merged
jonastheis merged 138 commits into
mainfrom
jonas/router-shanghai-port
Jul 7, 2026
Merged

BM-3078: feat(contracts): run the router market on Taiko via the shared Shanghai profile#2044
jonastheis merged 138 commits into
mainfrom
jonas/router-shanghai-port

Conversation

@jonastheis

@jonastheis jonastheis commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Brings the router-decoupling architecture (BoundlessRouter + batched FulfillmentBatch API + legacy-ABI fallback) to the Shanghai-EVM variant (FOUNDRY_PROFILE=shanghai, deployed to Taiko mainnet/staging, chain 167000), so Taiko can run the router market — deployed as an in-place upgrade of the live market 0xb3f5…b28b, preserving the pre-router ABI for existing clients via the delegatecall fallback.

Instead of maintaining contracts/shanghai/ as a full mirror of contracts/src (which had silently drifted), the shanghai profile now compiles the same contracts/src tree, swapping only the few EVM-version-divergent files via per-profile remappings. Net result is ~14k fewer lines.

Why a shared tree

Solidity has no conditional compilation, and a tstore in deployed bytecode reverts on a pre-Cancun chain even if never executed — so the Shanghai variant must be a real separate compile, not a runtime branch. But solc auto-downgrades everything it generates (incl. mcopy) under evm_version=shanghai; only hand-written assembly opcodes need shims, and in the whole router tree that is a single one (tstore/tload in FulfillmentContext). The mirror was therefore almost entirely redundant.

What changes

Shared tree + per-profile remappings. [profile.shanghai] points src/script/test at the shared contracts/{src,scripts,test} and swaps three prefixes:

                    bytes-compat/        boundless-market/         boundless-market-legacy/
[profile.default]   OZ Bytes (mcopy)     contracts/src (tstore)    contracts/src/legacy (Base)
[profile.shanghai]  compat/Bytes (shim)  variants/ (sstore+clear)  legacy/ (Taiko, frozen)
                             └─────────────── shared contracts/src ───────────────┘

Market variant. The Cancun market is left byte-identical. A contracts/shanghai/variants/BoundlessMarket.sol copy carries an explicit FulfillmentContext.clear() — a no-op on transient storage, sstore(0) on persistent storage (required because persistent storage does not auto-clear and must enforce the same single-transaction price-then-fulfill semantics). It is the one contract whose body must differ.

Frozen Taiko legacy. contracts/shanghai/legacy/ freezes the pre-router market deployed on Taiko (impl 0x6c2d…523b), compiled under shanghai. It byte-matches the deployed bytecode (all constructor immutables verified) and shares the new market's storage layout, so the new market can delegatecall it for the legacy ABI across the in-place upgrade. verify-legacy-bytecode.py / verify-storage-layout.py are parameterized (BOUNDLESS_OUT_DIR / BOUNDLESS_LEGACY_SNAPSHOT_DIR) to cover both the Base/Cancun and Taiko/Shanghai legacies.

Mirror removed. The obsolete contracts/shanghai/{src,scripts,test} mirror is deleted; the profile now compiles the shared tree with only the variants/, compat/, and legacy/ deltas.

Out of scope. PoVW (a separate rewards suite whose zkc Supply library uses transient storage) is skipped under shanghai and would need its own port.

Validation

Default-profile bytecode is unchanged (market + adapters byte-identical); the full contract suite passes under FOUNDRY_PROFILE=shanghai; the Taiko legacy bytecode-parity and new-vs-legacy storage-layout checks pass.

How to review

The headline diff (+1257 / −14093, 101 files) is misleading: ~16.7k of the deletions are the old contracts/shanghai/{src,scripts,test} mirror, and the two largest additions are copies — the market variant and the frozen legacy — not new logic. The genuinely hand-written surface is 7 files / ~76 lines.

Review commit-by-commit (the three commits are self-contained):

  1. build the Shanghai market variant from the shared src tree — the shared-src + remapping mechanism (foundry.toml, the variant, Predicate's 1-line import). Start here; the design lives in this commit.
  2. freeze the Taiko legacy market — the frozen legacy + parity-script parameterization.
  3. share scripts/tests … drop the mirror — wiring + the mirror deletion.

Read closely (the real surface): foundry.toml [profile.shanghai] + [profile.default].remappings (the three swapped prefixes, the skip list, the isolated snapshots dir); contracts/src/types/Predicate.sol (1 line); contracts/scripts/{Deploy,Manage}.s.sol (2 lines each); the two verify-*.py parameterizations; contracts/test/BoundlessMarket.t.sol (1 line).

Collapse the two copies to their true diffs:

# Variant market — expect ONLY import rewrites + the one clear() block:
git diff --no-index contracts/src/BoundlessMarket.sol contracts/shanghai/variants/BoundlessMarket.sol

# Frozen legacy — expect renames + sstore FulfillmentContext + import fixes, nothing logical:
git diff -M -C origin/jonas/adopt-router-in-broker...HEAD -- contracts/shanghai/legacy

Reproduce the invariants by running (these are the load-bearing guarantees):

forge build                                              # default bytecode unchanged
RISC0_DEV_MODE=1 FOUNDRY_PROFILE=shanghai forge test     # full suite green (382 tests)
FOUNDRY_PROFILE=shanghai forge build contracts/shanghai/legacy/BoundlessMarketLegacy.sol contracts/shanghai/variants/BoundlessMarket.sol
BOUNDLESS_OUT_DIR=out-shanghai BOUNDLESS_LEGACY_SNAPSHOT_DIR=contracts/shanghai/legacy uv run contracts/scripts/verify-legacy-bytecode.py   # legacy == deployed Taiko impl
BOUNDLESS_OUT_DIR=out-shanghai uv run contracts/scripts/verify-storage-layout.py                                                          # upgrade is storage-safe

These same parity checks now run in CI (the legacy-bytecode-parity job builds the shanghai profile and runs both with the env vars above) and via just check, so the storage-layout invariant that protects the in-place upgrade can't silently drift.

Worth scrutinizing (not mechanical):

  • The clear() call is the one piece of new logic. Sanity-check the rationale: persistent storage must clear-on-consume, or a request priced in tx A could be fulfilled in tx B at a stale price/expiry. The market reads via the non-clearing load (binding check + settle) and clears once in _fulfillAndPay.
  • The variant market is a copy that must track the real market — the one ongoing drift risk to weigh.
  • PoVW is scoped out under shanghai (separate suite, transient zkc Supply lib) — confirm that's acceptable, or it's a follow-up.
  • UpgradeBoundlessMarket auto-resolves LEGACY_IMPL from the proxy's current impl on first upgrade (so it picks up the deployed 0x6c2d…523b).
  • The live-proxy upgradeToAndCall + fallback is the one path not yet exercised by running (a Taiko-fork dry-run is the remaining optional validation).

Related

Builds on the router-decoupling work in jonas/adopt-router-in-broker (this PR targets that branch).

jonastheis added 30 commits May 8, 2026 14:52
Introduces the verification engine that decouples per-class verification
dispatch from BoundlessMarket. The router owns a two-mapping registry
(entries + classes) with namespace invariants, dispatches per-fill
verification on interfaceTag (verifier or joint), and forwards to the
class's required assessor when the verifier class is per-fill. UUPS
upgradeable, governance-gated `addClass` / `instantiate` / `removeClass`
/ `removeEntry`, ERC-165 conformance check at instantiation, gas-capped
adapter calls so a misbehaving impl can self-rug its sub-batch but not
starve sibling sub-batches in the same transaction.

Three seam interfaces:
- IBoundlessVerifier: per-fill cryptographic check (claim digest only).
- IBoundlessJointVerifierAssessor: per-fill combined check + binding.
- IBoundlessAssessor: per-batch binding seam.

Tests will land in a follow-up.
Phase B of the verifier-router-and-assessor-decoupling epic. Adds two
adapter contracts that bridge the existing R0 STARK verification path
into the router's universal seam interfaces, so today's R0 Groth16,
set-inclusion, and Blake3-Groth16 selectors plug into the new dispatch
unchanged.

R0BoundlessVerifierAdapter: thin wrapper around any IRiscZeroVerifier.
One adapter per BoundlessRouter selector entry, each pinned to a
specific underlying verifier — no transitive trust of the upstream R0
router's selector set. Phase C deployment script wires up one adapter
per existing R0 selector under the R0_VERIFIER class.

R0BoundlessAssessorAdapter: reconstructs today's assessor journal
digest verbatim from a seal envelope, then forwards to
IRiscZeroVerifier.verify against the pinned image id. The narrow
IBoundlessAssessor interface stays universal (rds, cds, seal); journal
extras (per-fill id and fulfillmentDataDigest; per-batch callbacks,
selectors, prover) ride inside the seal as an envelope, so other
assessor classes (signature-batch, threshold-attested, future SP1) plug
into the same interface without inheriting R0-STARK-specific fields.
The adapter is fully immutable — image-id rotation happens by deploying
a new adapter and registering a new R0_ASSESSOR selector in parallel,
then tombstoning the old one when ready (mirrors today's
DEPRECATED_ASSESSOR_EXPIRES_AT pattern but managed via governance
rather than an in-contract timestamp). A TODO calls out post-Phase C
guest cleanup (drop redundant journal fields once the market sources
them from the verified ProofRequest) for the next image rotation.

Also includes a one-line whitespace fix on BoundlessRouter.sol from
forge fmt.

Tests will land in a follow-up.
… seams

Widens IBoundlessAssessor.verifyAssessor and IBoundlessJointVerifierAssessor.verifyJoint
to take `address prover` as a universal arg, alongside the existing
request/claim digests and seal. The router's verifySubBatch gains the
arg and forwards it to both per-fill (joint) and per-batch (assessor)
dispatch sites.

The market needs a trusted prover address for crediting and slashing.
Today's R0 STARK assessor binds it via the journal commitment, but
that's adapter-specific — a future signature-based assessor would have
to commit to it in the signing payload, threshold-attested impls in
their committee message, etc. Making `prover` part of the universal
interface forces every adapter to verify the binding via its own
mechanism, so the market can trust the value uniformly across class
types.

R0BoundlessAssessorAdapter: drops `prover` from Envelope (it's now an
arg), uses the arg directly in journal reconstruction. The R0 STARK
fails if the seal was produced against a different prover than the one
passed by the caller.

Tests will land in a follow-up.
…sRouter

Reshapes the market entrypoints around `SubBatch[]` and ProofRequest-
based fulfill, dispatching all verification through the router.

Constructor: drops the verifier / applicationVerifier / assessorId /
deprecatedAssessorId / deprecatedDuration immutables. Now takes
`(BoundlessRouter router, address collateralToken)`. The router holds
the verification engine; image-id rotation is adapter-level.

Entrypoints: `fulfill`, `fulfillAndWithdraw`, `verifyDelivery`, and
the four `submitRootAndFulfill*` variants now take `SubBatch[]`. Each
sub-batch carries its own per-fill `ProofRequest[]` and `Fulfillment[]`
plus a single `bytes assessorSeal` and `address prover`. The market
re-derives `requestDigest` from each request, asserts integrity
against the lock (locked path) or signature (priceAndFulfill, where
`bytes[][] clientSignatures` is provided per sub-batch), and
forwards `(rds, cds, seals, signedSelectors, prover, assessorSeal)`
to `router.verifySubBatch`. `signedSelectors` and per-fill callbacks
are sourced from the verified `ProofRequest`, not from any assessor
journal commitment — `AssessorReceipt` is dropped entirely.

The internal `_fulfillAndPay*` helpers take the verified
`requestDigest` rather than reading `fill.requestDigest`. The new
`MismatchedRequestId` error guards against a `Fulfillment.id` that
disagrees with `request.id`.

`verifyDelivery` is now just a per-sub-batch loop into the router; the
old per-fill merkle reconstruction lives in
`R0BoundlessAssessorAdapter`. The deprecated-assessor try/catch
fallback at the market level is gone — image rotation is handled by
deploying a fresh adapter under a new `R0_ASSESSOR` selector and
manually tombstoning the old one when ready.

Also:
- `imageInfo()`, `setImageUrl()`, and the `imageUrl` storage variable
  are removed (slot reserved as `__deprecated_imageUrl` to preserve
  storage layout for upgrades).
- `BoundlessMarketLib.encodeConstructorArgs` updated to the new shape.
- `Deploy.s.sol` / `Manage.s.sol` updated to read `BOUNDLESS_ROUTER`
  from the env until the deployment.toml schema is updated to carry it.

Tests will land in a follow-up.
…Router

Splits BoundlessRouter operational tooling into its own scripts so the
market scripts stay focused on the market lifecycle.

`Deploy.Router.s.sol` — bootstrap-only. Deploys the router UUPS proxy
and registers the two curated R0 classes:
  - `R0_ASSESSOR` (id 0xAA000002) — terminal assessor seam.
  - `R0_VERIFIER` (id 0xAA000001) — chain default; required assessor
    class is `R0_ASSESSOR`.
Reads `ROUTER_ADMIN` and `DEPLOYER_PRIVATE_KEY` from env.

`Manage.Router.s.sol` — three operations as separate Script contracts:
  - `RegisterR0Verifier`: deploy an `R0BoundlessVerifierAdapter` for
    one R0 selector and `instantiate` it under `R0_VERIFIER`. Looks up
    the underlying impl via the upstream `RiscZeroVerifierRouter`.
  - `RegisterR0Assessor`: deploy an `R0BoundlessAssessorAdapter`
    pinned to one image id and `instantiate` it under `R0_ASSESSOR` at
    a chosen selector. Brokers put that selector in the first 4 bytes
    of the assessor seal.
  - `RemoveEntry`: tombstone an entry (e.g. a deprecated adapter
    after a broker rollover).

The market scripts (`Deploy.s.sol`, `Manage.s.sol`) stay untouched
here — they consume an already-deployed router via the BOUNDLESS_ROUTER
env var. Market upgrades to the router-aware implementation use
`Manage.s.sol::UpgradeBoundlessMarket` after this script set has run
to set up the router infrastructure.

Also: rename the deprecated `__deprecated_imageUrl` storage slot back
to its original `imageUrl` name. The market no longer reads or writes
this field, but keeping the original name preserves the storage
layout for the OZ Upgrades safety check without needing a rename
annotation.
Mirrors the contract-layer rewrite:
- IBoundlessMarket.sol artifact picks up the SubBatch-based entrypoints
  and drops AssessorReceipt + imageInfo.
- SubBatch.sol artifact is new.
- bytecode.rs regenerated for the new market implementation.

CI verifies these checked-in artifacts haven't drifted from the source
contracts, so they need to land alongside the contract changes.
Replace ProofRequest in SubBatch with a slim per-fill payload carrying only
what the market and assessor need at fulfill time. The market reconstructs
each requestDigest from the slim payload and asserts it matches the value
stored at lock time (or via FulfillmentContext for the priced path) before
dispatching, so downstream consumers can trust the payload without re-verification.

Highlights:
- New SlimRequest type + reconstruction library; predicate / callback / selector
  in full, plus pre-computed imageUrlHash / inputDigest / offerDigest.
- SubBatch.requests is now SlimRequest[].
- Fulfillment drops the redundant id and requestDigest fields.
- IBoundlessAssessor.verifyAssessor widened to (SlimRequest[], Fulfillment[],
  requestDigests[], prover, seal); BoundlessRouter.verifySubBatch matches.
- BoundlessMarket inlines verifyDelivery into fulfill, adds explicit _verifyBinding
  before router dispatch, and drops the ProofRequest arg from _fulfillAndPay.
- priceAndFulfill / submitRootAndPriceAndFulfill take a parallel ProofRequest[][]
  for the priced path (slim alone can't verify client signatures).
- R0BoundlessAssessorAdapter fits the new interface, sourcing every journal field
  (ids, callbacks, selectors, fulfillment-data digests) from the trusted slim
  payload; the seal carries only the inner STARK proof.
- FulfillmentLibrary gains a calldata-friendly fulfillmentDataDigest overload.
Native Solidity implementation of IBoundlessAssessor that evaluates each
fill's predicate directly on-chain. No zkVM, no merkle tree, no STARK
proof. The market binds each SlimRequest to a signed lock before dispatch,
so the adapter trusts the supplied predicate.

Per-fill checks:
- Predicate satisfaction via PredicateLibrary.eval (DigestMatch, PrefixMatch,
  ClaimDigestMatch).
- Claim-digest binding: ReceiptClaimLib.ok(imageId, sha256(journal)) must
  reconstruct to fill.claimDigest. Without this, the prover could submit a
  valid seal for a different computation entirely.

Per sub-batch:
- Prover signature: ECDSA over the EIP-712 SubBatchAuth(prover, requestDigests,
  claimDigests) carried in assessorSeal. The adapter recovers the signer and
  asserts it equals the supplied prover address. This is the on-chain
  equivalent of the R0 STARK adapter's prover commitment in the journal.

Ships with a Foundry gas bench measuring per-fill cost across N in
{1, 5, 10, 50, 100} for both DigestMatch and ClaimDigestMatch predicates,
through both direct-call and BoundlessRouter-dispatch paths, plus
regression tests for the four revert paths (binding mismatch, predicate
failure, claim-digest mismatch, prover-signature mismatch).
…ced-path args

Address review feedback on the priced-fulfillment API:

- Rename `SubBatch` -> `FulfillmentBatch` across types, externals, router, and
  market for clearer naming. `verifySubBatch` -> `verifyBatch`,
  `MixedClassWithinSubBatch` -> `MixedClassWithinBatch`,
  `EmptySubBatch` -> `EmptyBatch`, `SubBatchAuth` -> `FulfillmentBatchAuth`.
- Introduce `ProofRequestBatch { ProofRequest[] requests; bytes[] signatures }`
  and update `priceAndFulfill` / `priceAndFulfillAndWithdraw` /
  `submitRootAndPriceAndFulfill*` to take `ProofRequestBatch[]` instead of
  parallel `ProofRequest[][] + bytes[][]` arrays. Symmetric to the
  `FulfillmentBatch[]` argument so the priced-path API reads cleanly:

    priceAndFulfill(
        ProofRequestBatch[] requestBatches,
        FulfillmentBatch[]  fulfillmentBatches
    )
Replace the single OnChainAssessorBench file with three focused files
sharing a common base:

- `BenchBase` (abstract) — router/adapter setup, prover wallet, fixture
  builders, and three harnesses (DirectHarness, RouterHarness,
  MultiCallRouterHarness). Registers three sibling assessor entries
  (OnChainAssessor, R0BoundlessAssessorAdapter via mock IRiscZeroVerifier,
  NullAssessor) under one assessor class to enable cross-adapter
  comparison through the router.
- `AdapterBench` — measures the assessor adapters in isolation
  (direct call, no router). Includes DigestMatch and ClaimDigestMatch
  per-fill gas sweeps for OnChain vs R0, plus a journal-size sweep
  showing how Steel-style large journals affect per-fill cost. Order-
  generator commits a 16-byte journal (crates/order-generator/src/main.rs:356),
  used as the default fixture; 128 B and 512 B variants are also
  measured.
- `RouterBench` — measures the router architecture cost from the
  market's perspective: "what does the market pay per batch to drive
  the verification engine, vs. the absolute minimum it could pay if
  it hardcoded a single assessor adapter and skipped routing entirely?"
  Includes a framing-cost row and a cold-vs-warm comparison.

Harnesses no longer perform the binding check (that's market work;
out of scope for adapter/router measurement). Callers pre-compute
requestDigests once at fixture build time.
- Slim `_classOf` to `_classTagOf`: read only slot 0 of `ClassMetadata`
  instead of copying all 5 slots into memory. The hot path only needs
  `interfaceTag` (and `requiredAssessorClass`, also in slot 0).
- Defer the `tombstoned[]` check to the error path in `_entryOf` and
  `_classTagOf` — a registered selector cannot simultaneously be
  tombstoned (remove clears the value before tombstoning), so the
  happy path skips one SLOAD per lookup.
- Add `signedSel == sealSel` and `signedSel == sealClassId` fast paths
  to `_matchSignedSelector`: the common case now does zero SLOADs.
- Hoist the verifier/joint tag dispatch out of the per-fill loop and
  reuse `firstEntry` for i=0, removing the redundant `_entryOf` call
  for the first fill. Loop counter uses `unchecked { ++i; }`.

B.1 router framing overhead (NullVerifier + NullAssessor):
  N=1   44,252  ->  24,203  (-45%)
  N=10  95,400  ->  68,130  (-29%)
  N=50  330,146 -> 270,783  (-18%)
- Cache the last-seen (sealSel, Entry) across the per-fill loop so a
  batch sharing one selector pays one entry lookup instead of N. This
  is the common case when a single verifier serves a whole batch.
- Use `_isVerifierTag` / `_isJointTag` / `_isAssessorTag` helpers in the
  hot path instead of inlining `type(I).interfaceId` comparisons. Zero
  runtime cost (interface ids are compile-time constants), reads cleaner.
- Tighten `_entryOf`'s error diagnostics so a malformed seal whose first
  4 bytes resolve to a class id or the chain-default sentinel reverts
  with `EntryIsClass` / `ZeroSelectorReserved` instead of the generic
  `EntryUnknown`. Cold path only — no hot-path SLOADs added.

B.1 router framing overhead vs prior commit (NullVerifier + NullAssessor):
  N=10  68,130  ->  62,884  (-7.7%)
  N=50  270,783 -> 240,760  (-11.1%)

B.2 cold/warm vs prior commit:
  N=10  cold 90,740  -> 85,494  (-5.8%);  warm 71,680  -> 66,434  (-7.3%)
  N=50  cold 368,974 -> 338,951 (-8.1%);  warm 342,813 -> 312,790 (-8.8%)
  N=100 cold 740,709 -> 676,445 (-8.7%);  warm 698,781 -> 634,517 (-9.2%)

Per-fill warm cost drops from ~6,700 to ~6,100 gas.
Add `_forwardCalldataAsStaticCall(impl, gasLimit, selector)` and use it
for the per-batch assessor dispatch. The helper writes only the 4-byte
destination selector into scratch memory, then `calldatacopy`s the
entry-point's calldata tail into the outgoing call -- never copying or
re-encoding the args Solidity would otherwise traverse. Reverts bubble
verbatim via `returndatacopy + revert`.

This works inside an `internal` helper because in the EVM calldata
belongs to the current message-call frame, not to a Solidity function;
internal calls are JUMPs within the same frame, so `calldatasize()`
still references the outer (entry-point) calldata -- exactly the bytes
we want to forward.

ABI-stability invariant: `verifyBatch` and `IBoundlessAssessor.verifyAssessor`
must keep byte-identical calldata tails. The OnChainAssessor and
R0BoundlessAssessorAdapter end-to-end tests catch any drift because
those adapters fully decode the forwarded args.

via_ir inlines the helper at the single call site, so the bench numbers
are identical to the equivalent inline-assembly form:

  B.1 N=50 router-framing overhead 270,783 -> 157,506 (-30.7%)
  B.2 N=50 cold 338,951 -> 255,697 (-24.6%); warm 312,790 -> 229,536 (-26.6%)

Cumulative vs pre-optimization baseline at N=50: framing 330,146 -> 157,506
(-52.3%); per-fill warm ~7,500 -> ~4,700 gas.
…_000

The router and its adapters are the hot path on every market settlement
and are deployed once. Bumping their optimizer_runs from the size-tuned
default of 100 to 1_000_000 trades a small bytecode-size increase for
faster runtime. Rest of the project unchanged.

B.1 router framing overhead vs prior commit:
  N=1   21,807  -> 21,309  (-2.3%)
  N=10  45,630  -> 43,377  (-4.9%)
  N=50  157,506 -> 147,453 (-6.4%)

B.2 cold/warm vs prior commit:
  N=10  cold 68,240  -> 65,075  (-4.6%); warm 49,180  -> 46,027  (-6.4%)
  N=50  cold 255,697 -> 241,372 (-5.6%); warm 229,536 -> 215,223 (-6.2%)
  N=100 cold 510,691 -> 482,416 (-5.5%); warm 468,763 -> 440,500 (-5.7%)

Per-fill warm cost: ~4,700 -> ~4,400 gas.

Cumulative vs pre-optimization baseline at N=50:
  B.1 overhead 330,146 -> 147,453 (-55.3%)
  B.2 warm     386,176 -> 215,223 (-44.3%)
Extract the always-passing IBoundlessVerifier / IBoundlessAssessor /
IRiscZeroVerifier mocks from BenchBase into a shared
contracts/test/mocks/RouterMocks.sol so both bench files and future
unit-test files can reuse them.

Move the OnChainAssessor sanity tests (predicate-failure revert,
prover-signature mismatch, claim-digest mismatch, slim-payload
reconstruction parity, single-fill happy path) out of AdapterBench.t.sol
into a dedicated contracts/test/router/adapters/OnChainAssessor.t.sol.
Remove the matching test_router_singleFill_passes from RouterBench.t.sol
(belongs in router unit tests; the bench's own pass/fail is sufficient
sanity here).

Net effect: AdapterBench.t.sol and RouterBench.t.sol now contain only
test_bench_* gas-measurement functions; correctness tests live in
adapter- and router-specific unit files.
Bring the test file's setUp + harness back into compiling shape against
the slim/router architecture. All 133 tests are wrapped in a single
TODO(MIGRATE-MARKET) block comment so they can be ported incrementally
without compile errors blocking the rest of the suite.

Setup now:
- Deploys a BoundlessRouter UUPS proxy.
- Registers NullVerifier under a default verifier class and NullAssessor
  under its required-assessor class. Market state-machine tests don't
  exercise real cryptographic verification; the mocks short-circuit
  verifier + assessor dispatch so each test runs through the production
  fulfill path without paying for a STARK.
- Deploys BoundlessMarket with the new (BoundlessRouter, collateralToken)
  constructor.

Old AssessorReceipt-based helpers (createFills, createFillAndSubmitRoot,
submitRoot, createDeprecatedFills) are also commented out — they relied
on AssessorReceipt + set-builder root inclusion proofs that no longer
exist. A minimal createFulfillmentBatch helper will be added before the
first fulfill test is restored.

Inherited helpers preserved verbatim:
- Client / SmartContractClient / prover funding and snapshotting
- expectMarketBalanceUnchanged, snapshot/expect collateral helpers
- newBatch* (build locked-request batches; locks don't touch fulfill,
  so they port cleanly)
Restore 32 tests that don't depend on the (still TODO) fulfill helper:

- 13 account / admin tests (deposit, depositTo, deposits, withdraw,
  withdrawals, collateral variants, stake withdraw, bytecode size,
  admin role setup).
- 19 lock + submit-request tests covering both the EOA-signed
  lockRequest path and the lockRequestWithSignature path: happy paths,
  already-locked / already-fulfilled, bad client signature, prover
  signature variants (wrong-request, wrong-domain), insufficient
  funds, expired/lock-expired, and the two invalid-request shapes.

Two prover-signature regression tests (testLockRequestWith-
SignatureProverSignatureIncorrectRequest /IncorrectDomain) had
hardcoded recovered-signer addresses that change with deploy nonce.
Switched them to `expectPartialRevert` so they keep their regression
purpose without breaking on contract-layout changes.
`BoundlessMarket._lockRequest` writes the domain-bound `requestHash`
into `RequestLock.requestDigest`, but the post-refactor `_verifyBinding`
was comparing it against the raw EIP-712 struct hash produced by
`SlimRequestLibrary.reconstructRequestDigest`. Result: every locked
fulfill reverted with `RequestIsNotLockedOrPriced` because the two
sides hashed differently.

Fix: keep the slim library producing the pure struct hash (its natural
output), but have the market wrap each reconstruction with
`_hashTypedDataV4` once per fill before comparing. This matches what
both `lockRequest` and `priceRequest` write into storage. The priced
path inside `_verifyBinding` no longer needs its own
`_hashTypedDataV4` call either — both branches compare directly.

To absorb the extra local variables the wrap introduces without
tripping the Yul stack-too-deep limit, `fulfill` now delegates to two
new internal helpers (`_bindAndCollectDigests` and `_settleBatch`).
NatSpec on `SlimRequestLibrary.reconstructRequestDigest` and
`_verifyBinding` updated to document the struct-hash vs. domain-bound
contract.

Also ports the first fulfill helper (`_testFulfillSameBlock`) and three
tests that consume it (`testFulfillLockedRequest`,
`testFulfillLockedRequestWithSig`, `testFulfillNeverLocked`) — these
served as the regression check that caught the binding mismatch. New
test-side helpers (`createFulfillmentBatch`, `_asArray` overloads for
single-element batches) live alongside.
- ClaimDigestMatch fills now post FulfillmentDataType.None with empty
  fulfillmentData, matching the production shape where the journal
  doesn't need to be on-chain. Result: ClaimDigestMatch per-fill cost
  is now perfectly journal-independent (14,375 across 16/128/512 B).
- Pad the journal tail with non-zero bytes (0x80..0xff) so any
  tx-intrinsic gas measurement (4 vs 16 gas per zero/non-zero byte)
  reflects real journals instead of getting the zero-byte discount.
  Inner-frame bench numbers don't move (precompile + memory costs are
  value-independent), but the fixture no longer misleads tx-level
  measurements.
- Add N=2 row to test_bench_adapters and shrink the journalSize sweep
  to n=1 so the cost of journal-length itself isolates cleanly.
Unwrap and migrate the fulfill/slash families of BoundlessMarket.t.sol
to the new FulfillmentBatch + ProofRequestBatch wire shape. Tests retain
their original line positions and call into the existing
_testFulfillSameBlock / _testFulfillRepeatIndex / _testFulfillAlreadyFulfilled
helpers so the diff is bound to body changes, not restructuring.

Brings the suite from 36 to 73 passing tests: ranges + large-journal,
other-prover-fulfills, already-fulfilled, fully-expired, multiple-same-index,
the wasLocked family (incl. stake-rollover, double-fulfill, locker-after-other),
the neverLocked family, the dedicated testSlash* block, and the
invalid-smart-contract-signature path.
Extends BoundlessMarket.t.sol from 73 to 96 passing tests:

  * batch tests (testFulfillLockedRequests, …NoJournal, …AndWithdraw),
  * smart-contract-signature tests (priceRequest + lockRequest +
    priceAndFulfill variants),
  * single-request priceAndFulfill,
  * callback / claim-digest tests (11 ports).

Registers `R0BoundlessVerifierAdapter(setVerifier)` in the router under
setVerifier.SELECTOR() so callback fixtures produce one seal that
satisfies both the router's per-fill verifier dispatch and the
BoundlessMarketCallback re-verify. Modifies `createFills` /
`createFillsAndSubmitRoot` / `createFillAndSubmitRoot` in place to
return `FulfillmentBatch`, build set-builder seals over the slim
payload, and drop the assessor-journal aggregation (selector + callback
now live on `SlimRequest` per fill). The deprecated-assessor helper
variants are gone — replaced by router tombstones.

Tests that don't need callback verification keep using the cheap
NullVerifier path under VERIFIER_ENTRY_SEL.
Brings the suite from 96 to 103 passing tests:

  * `_testSubmitRootAndFulfillSameBlock` + AndWithdraw helpers (in place),
  * `testSubmitRootAndFulfillLockedRequest`, …WithSig, …AndWithdraw,
  * `testSubmitRootAndFulfillNeverLocked` + …ProverNoStake,
  * `testSubmitRootAndPriceAndFulfillLockedRequest`,
  * `testSubmitRootAndFulfill` (2-request batch).

Splits the set-builder fixture back into `createFills` (pure compute,
returns `(FulfillmentBatch, bytes32 root)`) and `createFillsAndSubmitRoot`
(wrapper that also submits the root via setVerifier), mirroring the
original layout. Helpers reuse `_asArray` overloads for singleton calls.

Deprecated-assessor helpers and the matching test are restored wrapped
(not deleted) so the migration retains a paper trail until equivalent
coverage exists at the router-tombstone level.
Wires `R0BoundlessAssessorAdapter(setVerifier, ASSESSOR_IMAGE_ID)` into
the router under `ASSESSOR_R0_SEL = 0x24` (alongside `NullAssessor`)
and adds a broker+guest fixture (`createFillAndSubmitRootR0`,
`createFillsAndSubmitRootR0`) that produces what a broker would hand
the market: per-fill set-builder seals + a journal-bound STARK seal
over the assessor's `(root, callbacks, selectors, prover)` commitment.
The market then drives both adapters end-to-end.

Per-fill construction is now a shared `_buildFillsAndSlim` helper, used
by both `createFills` (NullAssessor path) and the R0 fixture, so the
loop lives in one place.

Ports three tests to the R0 path:
  * testPriceAndFulfillWithSelector — happy-path with signed selector,
  * testFulfillLockedRequestProverAddressNotMatchAssessorReceipt —
    tampered `batch.prover` desyncs the journal digest from the
    broker's seal → setVerifier rejects with `VerificationFailed`,
  * testFulfillShuffleFills — swapped claimDigest/fulfillmentData
    desyncs each per-fill seal from its claim → `VerifierFailed`.

`testFulfillShuffleIds` is dropped (with note): slim-id tampering now
breaks `_verifyBinding` first, already covered by
`testFulfillLockedRequestMultipleRequestsSameIndex`.

`testFulfillRequestWrongSelector` and the two
`*VerificationGasLimit*` tests stay wrapped — selector mismatch is
enforced by `BoundlessRouter._matchSignedSelector` and the per-fill
gas budget is the router entry's `gasLimit`; equivalent coverage
belongs in `BoundlessRouter.t.sol`.

103 → 106 passing tests.
Updates `testUnsafeUpgrade` to the new market constructor
`(BoundlessRouter, collateralToken)` and single-arg `initialize(owner)`.
The pre-upgrade `imageInfo()` invariant is gone with the slim-payload
refactor — both market versions are constructed with the same router
and collateral token, and the test just asserts the implementation
address rotated.

`testGrantAdminRole` is unchanged (admin role lives on the market
directly).
Wires the 20 `testBench*` entrypoints to drive the production
verification path — `R0BoundlessAssessorAdapter` + setVerifier
inclusion proofs — by routing the 3 bench helpers
(`benchFulfill`, `benchFulfillWithSelector`, `benchFulfillWithCallback`)
through `createFillsAndSubmitRootR0` and the new
`fulfill(FulfillmentBatch[])` ABI.

Snapshot labels carry a `:v2` suffix so the new numbers coexist with
the legacy entries in `BoundlessMarketBench.json` for side-by-side
review. Side-by-side: the new fulfill path costs ~40% more per
batch than the legacy market — overhead is dominated by
`_bindAndCollectDigests` (the slim-payload security gain that the
market now does on-chain instead of trusting the assessor STARK),
the adapter's per-fill `AssessorCommitment` reconstruction
(redundant until the next assessor-image rotation, see adapter
NatSpec), larger calldata, and the two-hop market → router → adapter
dispatch. The % delta shrinks with callbacks (+22% at N=32) because
their fixed `verifyIntegrity` cost dilutes the routing overhead.
…verifyAssessor

Aligns `IBoundlessRouter.verifyBatch` and `IBoundlessAssessor.verifyAssessor`
on `(FulfillmentBatch calldata batch, bytes32[] calldata requestDigests)`,
collapsing the previous five-arg form (slim requests, fills, digests,
prover, assessor seal). The market's call site becomes
`ROUTER.verifyBatch(batch, requestDigests)` instead of unpacking the
batch field-by-field.

Both interfaces stay shape-identical so the router can continue to
forward its calldata tail verbatim to the assessor adapter via
`_forwardCalldataAsStaticCall`.

All three adapters (Null, OnChain, R0) and the bench harnesses
(DirectHarness, RouterHarness, MultiCallRouterHarness) updated to match.
A `_makeBatch(slim, fills, prover, seal)` helper in BenchBase keeps the
call sites in AdapterBench / RouterBench / OnChainAssessor.t.sol short.

Trade-off: the market->router hop now copies the FulfillmentBatch struct
into a contiguous top-level calldata layout (previously each inner
calldata field was passed by pointer), costing ~2-3% gas on fulfill.
Acceptable for now -- the loop-in-both-router-and-assessor refactor
that would recover this is a separate, larger change.
Widens `IBoundlessJointVerifierAssessor.verifyJoint` from
`(requestDigest, claimDigest, prover, seal)` to
`(SlimRequest request, Fulfillment fill, bytes32 requestDigest, address prover)`.
The adapter receives the entire per-fill payload — the slim request
(selector, callback, predicate, pre-computed digests) and the full
fulfillment (claimDigest, fulfillmentDataType, fulfillmentData, seal) —
and chooses what its mechanism actually needs.

Rationale: keeps the joint seam future-proof for adapters that need
more than just `(requestDigest, claimDigest)` — e.g. predicate-aware
joint verifiers, attestation paths that bind to the callback, or
journal-reconstructing implementations. Avoids interface churn each
time a new joint mechanism wants visibility into another field.

Router's call site collapses to
`IBoundlessJointVerifierAssessor.verifyJoint(batch.requests[i], batch.fills[i], requestDigests[i], batch.prover)`,
removing the separate `claimDigest` and `seal` extractions.

No production adapter implements the joint interface yet; existing
tests don't exercise this path, so behavior is unchanged for current
fulfillments.
…ndlessRouter

* `forge fmt` over the touched contracts/tests (line wraps in multi-arg
  function signatures, trailing commas, etc.).

* Extract `IBoundlessRouter` (one method: `verifyBatch`). The market
  now depends on the abstract seam; `BoundlessRouter` declares
  `implements IBoundlessRouter`. Admin/registration entry points
  (`addClass`, `instantiate`, `removeClass`, `removeEntry`) stay on the
  concrete contract -- admin tooling only.

* Regenerate Rust artifacts in `crates/boundless-market/src/contracts/`:
  - Copy `SlimRequest.sol`, `FulfillmentBatch.sol`, `ProofRequestBatch.sol`,
    `IBoundlessRouter.sol` into the artifact folder (build.rs).
  - Delete stale `AssessorReceipt.sol` + `SubBatch.sol`.
  - Refresh `Fulfillment.sol`, `IBoundlessMarket.sol`, `bytecode.rs` to
    reflect the new ABI.

Known follow-up: the Rust SDK at `crates/boundless-market/src/contracts/
boundless_market.rs` still uses the old market ABI and has 13 compile
errors (`Fulfillment.id`, `fulfill(fills, receipt)`, arity drift on
`submitRootAndFulfill` / `priceAndFulfill`). Tracked as Phase D
(broker/SDK port); to be tackled in a focused follow-up PR.
Moves `OnChainAssessor` (the native Solidity assessor adapter) and its
unit tests off this branch to keep the audit scope tight. They live on
`jonas/onchain-assessor` (branched from this commit's parent) for a
follow-up PR.

* Delete `contracts/src/router/adapters/OnChainAssessor.sol`.
* Delete `contracts/test/router/adapters/OnChainAssessor.t.sol`.
* `BenchBase`: drop the `OnChainAssessor` adapter wiring, the
  `directOnChain` harness, the `ASSESSOR_ON_CHAIN_SEL` selector, and
  the `_buildOnChainSeal` ECDSA seal builder.
* `AdapterBench`: collapse the side-by-side OnChain vs R0 comparison
  to R0-only.
* `IBoundlessAssessor` / `BoundlessRouter`: trim NatSpec references
  to OnChainAssessor.

R0 adapter remains as the v1 assessor. Router stays pluggable, so a
re-introduction in a future PR only needs a fresh `instantiate` call
against the same `ASSESSOR_CLASS_ID`.
This reverts commit e1c5da1 to re-introduce `OnChainAssessor` and its
unit tests/benches on top of `jonas/router-decoupling`. The parent
branch keeps the OnChain code out of its scope; this branch re-adds it
for review in its own PR.
Base automatically changed from jonas/adopt-router-in-broker to main July 2, 2026 06:22
@github-actions github-actions Bot changed the title feat(contracts): run the router market on Taiko via the shared Shanghai profile BM-3078: feat(contracts): run the router market on Taiko via the shared Shanghai profile Jul 2, 2026
jonastheis added 13 commits July 2, 2026 14:25
…aded event (#2057)

## Summary

Addresses the **Unused Code** audit finding (Info / Maintainability).
Removes two dead constructs from the market contracts that could drift
from the implementation and mislead integrators.

## Changes

- **`contracts/src/BoundlessMarket.sol`** — remove `error
MismatchedRequestId(uint256 expected, uint256 received)`. It was
declared but never referenced anywhere in the codebase.
- **`contracts/src/IBoundlessMarket.sol`** — remove the custom `event
Upgraded(uint64 indexed version)`. It was never emitted by the
implementation: `BoundlessMarket` is UUPS and upgrades emit
OpenZeppelin's standard ERC1967 `Upgraded(address indexed
implementation)` event. The custom event was misleading for integrators
and monitoring tools relying on an event that never fires.
-
**`crates/boundless-market/src/contracts/artifacts/IBoundlessMarket.sol`**
— regenerated (via `build.rs`) so the checked-in interface artifact
stays in sync and passes the CI drift check.

## Out of scope (intentionally left unchanged)

- `contracts/shanghai/src/` — a separate, currently-lagging pre-router
copy compiled under the shanghai EVM profile; not part of the finding's
scope.
- `contracts/src/legacy/IBoundlessMarketLegacy.sol` — a frozen
historical ABI snapshot kept for pre-router client compatibility; the
`Upgraded` declaration is retained there for fidelity to the deployed
legacy interface.

## Verification

- `forge build` (compiles src + scripts + tests): passes.
- `cargo build -p boundless-market`: passes; artifact regenerates with
no additional drift.

Both removals are pure dead-code deletions with no behavioral change.
…2054)

## Summary

Remediation for audit finding **1257** (assessor half): *Class-based
request selectors can accept
attacker-registered fake verifiers.*

A request commits to a verifier **class**, but never to a specific
**assessor** entry — the assessor
is inherited via the verifier class's `requiredAssessorClass`, and the
**prover** selects which entry
in that class to use (via `assessorSeal`). So the requestor has no say
over the assessor. If
governance ever set an assessor class to `permissionlessInstantiate =
true`, anyone could register a
no-op assessor (which only has to pass an ERC-165 check) and route
fulfillment through it —
bypassing the predicate-satisfaction and prover-binding checks the
honest assessor enforces, and
`verifyBatch` would still succeed.

Because the requestor cannot opt out of the assessor, assessor classes
must always be
governance-curated.

## Fix

`addClass` now reverts **`AssessorClassMustBeCurated`** when an
assessor-tagged class sets
`permissionlessInstantiate = true`:

```solidity
} else {
    if (metadata.requiredAssessorClass != bytes4(0)) revert AssessorClassMustBeZero();
    if (_isAssessorTag(tag) && metadata.permissionlessInstantiate) revert AssessorClassMustBeCurated();
}
```

Joint classes (`IBoundlessJointVerifierAssessor`) may remain
permissionless — they are selected
directly by the requestor's signed selector, so the opt-in protection
applies to them.

## Verifier half — not a contract change

The reported verifier case is **intended, opt-in** behavior and is
handled by documentation, not
code: a permissionless verifier class is reachable only when (1)
governance has explicitly enabled
it and (2) the requestor explicitly signs that permissionless
class/selector. That is a deliberate,
caveat-emptor choice; ERC-165 conformance is not evidence of honest
verification. We will recommend
in the docs that requestors sign a specific verifier **entry** selector
(pinning the exact
implementation) rather than a class.

## Changes

- `BoundlessRouter.sol`: new `AssessorClassMustBeCurated` error + the
`addClass` guard.
- Test: `test_addClass_revertsForPermissionlessAssessorClass`; three
existing instantiate/reserved-prefix
tests migrated from a permissionless *assessor* class to a
permissionless *verifier* class (they
  only used a permissionless class incidentally).
- Regenerated `bytecode.rs` (the router's embedded bytecode changed).

Builds on #1982 (router decoupling).
…t-running (#2052)

## Summary

Remediation for an audit finding on `OnChainAssessor`. This description
first frames the **general problem** (proof front-running) and the
**solution space**, then describes the fix shipped here: a commit-reveal
gate on the open fulfillment paths.

## The general problem: proof front-running

A ZK proof / seal is a **bearer artifact**: it attests a *result*, not
*who produced it*. But producing it is the expensive work, and in an
open marketplace whoever submits it on-chain first collects the payment.

So unless the protocol binds the proof to its producer, anyone who
observes a submitted (or pending) proof can copy it, present it as their
own, and steal the fulfillment reward — without doing any of the work.
The gap is the missing, unforgeable link between *the proof* and *the
prover who should be paid*.

Any fix has to establish that binding **before the proof becomes a
public, actionable artifact**. There are only three places to anchor it:

- **Authorize** — pin the eligible prover in on-chain state before the
proof exists (e.g. locking).
- **Hide / delay** — keep the proof unusable-by-others until it's
attributed to the prover (e.g. commit-reveal).
- **Bind-in-proof** — make the proof itself commit to the prover (e.g.
in-circuit, or a trusted attestation).

## Where it applies

By definition the problem exists only on the **open fulfillment paths**,
where the protocol has no prior on-chain binding of the prover:

- **Never-locked** (priced-at-fulfill, e.g. `priceAndFulfill`) →
**vulnerable**: full payment goes to whoever submits.
- **After lock deadline** (was-locked, anyone may fulfill) →
**vulnerable**: whoever submits is recorded as the fulfiller. The
auction price is ~0 here, but the real prize is the **slashing reward**:
the late fulfillment overwrites `lock.prover` with the submitter, so
when `slash` is later called (after the full deadline) that submitter
receives **50% of the defaulted locker's collateral**. So this path is
*not* low-value — the stake reward is typically larger than a single
proof's price, capped at 50% of the locker's collateral.

**Not a problem:**

- **Locked, before lock deadline** → already protected. The lock
pre-authorizes the prover (`lock.prover == batch.prover` is enforced),
so a copied proof can't redirect payment. This is the **authorize**
family, already in place.

(`R0BoundlessAssessorAdapter` is out of scope here: its prover binding
lives in the STARK journal, so rebinding requires regenerating the proof
— a *soft*, proving-time deterrent rather than a hard guarantee.)

## The solution space

Four properties you'd want from a fix:

- **Hard** — security does not depend on the attacker's resources or
speed. (Opposite = *soft*: a margin an attacker erodes with faster
proving or more hashpower — e.g. R0's ~14s, or PoW.)
- **Trustless** — no off-chain party you have to trust.
- **Open** — permissionless: anyone who actually produced a proof can
fulfill and get paid, with no pre-registration (the never-locked
"whoever finishes first wins" model survives).
- **Cheap** — no extra transaction, no added block latency, no recurring
compute.

You can't have all four — each option gives up exactly one:

| Option (family) | Hard | Trustless | Open | Cheap |
|---|:---:|:---:|:---:|:---:|
| **Commit-reveal** (hide / delay) | ✅ | ✅ | ✅ | ❌ +1 tx, +1 block |
| **Lock / claim** (authorize) | ✅ | ✅ | ❌ *closes the set* | ⚠️
+collateral to be grief-safe |
| **Attestation** (bind-in-proof, trusted) | ✅ | ❌ *trust attestor* | ✅
| ✅ |
| **R0 in-circuit / PoW** (bind / delay, soft) | ❌ *soft margin* | ✅ | ✅
| ✅ |

**Commit-reveal is the only option that is hard *and* trustless *and*
open** — it buys all three by spending latency. R0-in-circuit and PoW
are the same idea at the cheap end (a soft time/compute margin an
attacker can erode); attestation is hard + open but reintroduces a
trusted signer; locking is hard + trustless but closes the open set (and
needs collateral to be grief-safe).

## The fix in this PR: commit-reveal on the open paths

Before settling any never-locked or was-locked fill, `fulfill` requires
a prior `commitFulfillment(keccak256(abi.encode(fulfillmentBatches)))`
recorded in a **strictly earlier block**:

```
 block N     commitFulfillment( keccak256(abi.encode(fulfillmentBatches)) )   ← only a hash; seal stays hidden
 block N+1   fulfill / priceAndFulfill / submitRoot…  → commitment at N (< N+1) ✓ → settles

 a front-runner who first sees the seal at N+1 cannot have a block-N commitment for it,
 and cannot fabricate one in the same block (strictly-earlier rule) → copied proof rejected
```

- **Binding the seals** makes the commitment un-precomputable — you
can't commit without already holding the proof.
- The **strictly-earlier-block** rule (`COMMIT_REVEAL_MIN_BLOCKS = 1`)
defeats same-block / sequencer-reordering attempts. One block suffices
on a single sequencer; it's a named constant so it can be raised on
reorg-prone chains.
- Applied **uniformly** to all open-path fulfillment regardless of
assessor — the market can't cheaply tell which assessor a fill used at
settle time, and this also hardens R0's soft proving-time margin. The
**locked-before-deadline path is unaffected and stays single-tx.**

This is the hide/delay anchor with the cheapest possible clock (block
height): hard, trustless, open — paying only ~1 block of latency on the
two open paths.

## Changes

- `BoundlessMarket.sol`: `commitFulfillment`, `_hasOpenPathFill`,
`_consumeCommitment`, and the gate in `fulfill`. New storage slot
`fulfillmentCommitBlock` appended after `imageUrl`.
- `IBoundlessMarket.sol`: `commitFulfillment` +
`MissingFulfillmentCommitment` (so the SDK/broker bindings expose it).
Regenerated SDK artifact + `bytecode.rs`.
- Storage-layout parity preserved: slots 0/1/2 (shared with the legacy
impl via the delegatecall fallback) are unchanged;
`verify-storage-layout.py` passes.
- Contract size: **+394 B** (22,890 B runtime; 1,686 B under the EIP-170
limit).

## Follow-ups (not in this PR)

- **Broker/SDK**: brokers must `commitFulfillment` one block ahead
before fulfilling never-locked / was-locked orders. Separate
broker-crate change.

Builds on #1982 (router decoupling) and #2005 (`OnChainAssessor`).
…ation (#2055)

## Summary

Fixes the audit discussion item *"Error encoding is not compatible with
interface."*

`RequestIsExpired` was declared `RequestIsExpired(RequestId, uint64
deadline)` (2 args) but both
fulfillment sites encode it with only the request id:

```solidity
paymentError = abi.encodeWithSelector(RequestIsExpired.selector, RequestId.unwrap(id));
```

The declared 2-arg shape never matched the emitted 1-arg payload, so
off-chain decoders — notably the
Rust `boundless-market` client — could not decode the error.

## Fix

Drop the unused `deadline` arg from the declaration so it matches what
is actually emitted:

```solidity
error RequestIsExpired(RequestId requestId);
```

Selector changes `0x873fd26b` → `0xfc54471a`. Every encoding site and
test already passes only the id
via `.selector`, so **on-chain behavior is unchanged** and the existing
tests cover it.

## Why drop the arg rather than add it to the encoding

- The deadline was **never actually emitted** at either site, so
dropping it from the declaration
removes nothing that was ever there — it just makes the declaration
honest.
- It is **not available on the priced / never-locked path**: that site
only has the transient
`FulfillmentContext` (`{valid, expired, price}`). Emitting the deadline
there would require adding a
field to that transient struct (+ `priceRequest` storing it) —
disproportionate for an ABI-decodability
  fix, and it would leave the two sites asymmetric otherwise.
- It is a diagnostic-only `paymentError` (surfaced via
`PaymentRequirementsFailed`, not a revert); the
  `requestId` already identifies the request.

(If the team prefers to keep the deadline for consistency with
`RequestLockIsExpired` /
`RequestIsNotExpired`, the alternative is to plumb it through
`FulfillmentContext` — happy to switch.)

## Changes

- `IBoundlessMarket.sol`: `RequestIsExpired` → 1-arg + updated
selector/NatSpec.
- Regenerated SDK artifact + `bytecode.rs`.
- Fixed a stale test comment that referenced the wrong error.

Full suite green (654/654); no settlement-logic change.
The #2052 front-running guard requires open-path fulfillments (never-locked or past-lock-deadline) to be committed one block before the reveal. Implement the off-chain side, which #2052 (contract-only) left to callers:

- SDK: BoundlessMarketService::fulfill() sends commitFulfillment and awaits its receipt before the reveal whenever the batch is on the open path, so the reveal lands >= 1 block later. Adds a commit_fulfillment wrapper and a unit test pinning the commitment preimage to the contract's keccak256(abi.encode(fulfillmentBatches)).

- Pricing: charge the extra commitFulfillment tx (commit_fulfillment_gas_estimate, default 50k) in the lock_expired branch of order pricing.

- deployment-test: commit-then-reveal before the open-path priceAndFulfill (the suite #2052 did not patch).

Claude-Session: https://claude.ai/code/session_01Mx4DvQNNSUFzT43cABVthH
test_request_status_lock_expired_then_slashed fulfilled a was-locked (lock-expired) request via the non-priced submitRootAndFulfill path. Post-#2052 the contract treats was-locked fills as open-path and requires a commitFulfillment one block ahead, so that call now reverts MissingFulfillmentCommitment.

Route the late fulfillment through the open/priced path (with_unlocked_request), matching how the broker and CLI fulfill after lock expiry, so the SDK auto-commits ahead of the reveal. The secondary-fulfillment classification is timestamp-based, so the assertions are unchanged.

Claude-Session: https://claude.ai/code/session_01Mx4DvQNNSUFzT43cABVthH
…arket variant

The #2052 front-running guard added commitFulfillment to IBoundlessMarket and the mainline market, but not to the shanghai variant, breaking the shanghai profile build. Port the full guard (fulfillmentCommitBlock, COMMIT_REVEAL_MIN_BLOCKS, open-path check in fulfill, commitFulfillment/_hasOpenPathFill/_consumeCommitment) verbatim — the scheme uses only plain storage and block.number, so it works unchanged on Shanghai EVM, and the shared contract test suite asserts its behavior under this profile.
@jonastheis
jonastheis marked this pull request as ready for review July 7, 2026 01:10
@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for this team, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@jonastheis
jonastheis merged commit 9f2470a into main Jul 7, 2026
32 of 36 checks passed
@jonastheis
jonastheis deleted the jonas/router-shanghai-port branch July 7, 2026 09:16
jonastheis added a commit that referenced this pull request Jul 8, 2026
…t remapping (#2062)

## Summary

The last two nightly examples runs failed
([2026-07-08](https://github.com/boundless-xyz/boundless/actions/runs/28914585920),
[2026-07-07](https://github.com/boundless-xyz/boundless/actions/runs/28838757981)).
This fixes the code regression behind one of the two failures and makes
the other fail loudly at its actual source.

## Changes

- **Add `bytes-compat/` remapping to
`examples/smart-contract-requestor/remappings.txt`.** #2044 added
`import {Bytes} from "bytes-compat/Bytes.sol"` to
`contracts/src/types/Predicate.sol` and the matching remapping to the
root `foundry.toml`, but the example keeps its own `remappings.txt`,
which was not updated. Since the example imports
`boundless-market/types/Predicate.sol` (directly and via
`ProofRequest.sol`), its forge build broke in the first nightly run on
that commit. The new remapping points at the root OpenZeppelin checkout,
matching how the rest of `contracts/src` resolves OZ imports in this
build. Verified locally with `forge build` and `forge test` in the
example.

- **Use `curl --fail` for the blake3 groth16 artifact download in
`nightly-examples.yml`.** The blake3-groth16 job failed both nights
because `blake3_groth16_artifacts.tar.xz` is gone from the staging
signal-artifacts bucket — the URL now serves a Cloudflare 404 HTML page,
which curl happily saved and tar then rejected with a confusing "File
format not recognized". With `--fail`, the download step itself fails on
HTTP errors.

Note: this PR does **not** fix the blake3-groth16 job — the artifact
needs to be re-uploaded to the bucket (likely deleted by the R2
retention policy; `docker-services.yml` bakes in the same URL and is
affected too). That is an ops action tracked separately.
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.

2 participants