Skip to content

BM-2957: feat(contracts): decouple verification via BoundlessRouter#1982

Draft
jonastheis wants to merge 43 commits into
mainfrom
jonas/router-decoupling
Draft

BM-2957: feat(contracts): decouple verification via BoundlessRouter#1982
jonastheis wants to merge 43 commits into
mainfrom
jonas/router-decoupling

Conversation

@jonastheis
Copy link
Copy Markdown
Contributor

@jonastheis jonastheis commented May 8, 2026

Summary

Decouple per-class verification dispatch from BoundlessMarket by introducing a governance-controlled BoundlessRouter. The market becomes a thin orchestrator: it binds each fill's slim payload to a client-signed request digest, then hands the batch to the router. The router resolves the seal's first-4-byte selector to a registered adapter, dispatches to the right per-fill interface, and tail-calls the class's required assessor adapter once per batch.

Adding new verifier classes — alternate zkVMs, signature-backed joint verifiers, future proof systems — no longer touches BoundlessMarket.sol. New classes plug in by registering an adapter address under a class id.

Architecture

Entry point is BoundlessMarket.fulfill(FulfillmentBatch[]). Each batch carries (SlimRequest[], Fulfillment[], assessorSeal, prover).

BoundlessMarket.fulfill(FulfillmentBatch[])
│
├─ _bindAndCollectDigests
│    reconstruct EIP-712 digest from slim payload
│    verify against lock / priced FulfillmentContext
│
├─ ROUTER.verifyBatch(batch, requestDigests)
│   │
│   ├─ resolve verifier class from seal[0:4]
│   │
│   ├─ per fill  (gas-bounded staticcall, try/catch)
│   │   │
│   │   ├─ verifier class
│   │   │    → IBoundlessVerifier.verify(seal, claimDigest)
│   │   │
│   │   └─ joint class
│   │        → IBoundlessJointVerifierAssessor.verifyJoint(
│   │              slim, fill, requestDigest, prover)
│   │
│   └─ if verifier class
│        → IBoundlessAssessor.verifyAssessor(batch, requestDigests)
│          (calldata tail-call optimization)
│
└─ _settleBatch
     credit prover · refund / charge client · fire callback

Three interfaces define the seam taxonomy:

  • IBoundlessVerifier — per-fill cryptographic check; sees (seal, claimDigest) only. Pairs with a separate assessor seam.
  • IBoundlessJointVerifierAssessor — per-fill combined seal verification + request-claim-prover binding. Skips the assessor seam.
  • IBoundlessAssessor — per-batch fulfillment-check binding. Terminal — referenced by verifier classes via requiredAssessorClass, never selected as a verifier class.

Contracts in this PR

Area File code comment Notes
Router core src/router/BoundlessRouter.sol 274 283 Two-mapping registry (entries + classes), namespace invariants, permanent tombstoning, governance-gated mutations. UUPS-upgradeable.
src/router/interfaces/IBoundlessRouter.sol 5 19 Fulfill-path-only seam for callers.
src/router/interfaces/IBoundlessVerifier.sol 4 15
src/router/interfaces/IBoundlessAssessor.sol 5 54
src/router/interfaces/IBoundlessJointVerifierAssessor.sol 8 46
Adapters src/router/adapters/R0BoundlessVerifierAdapter.sol 17 41 One adapter per pinned R0 selector; immutable underlying verifier.
src/router/adapters/R0BoundlessAssessorAdapter.sol 72 81 Reconstructs today's R0 assessor journal from the slim payload + fills + universal prover arg.
Types src/types/SlimRequest.sol 35 57 Per-fill EIP-712-reconstructable payload (predicate + callback + selector + 3 digests).
src/types/FulfillmentBatch.sol 9 36 { requests, fills, assessorSeal, prover }.
src/types/ProofRequestBatch.sol 6 17 Priced-path companion.
Market rewrite src/BoundlessMarket.sol 593 191 Constructor (BoundlessRouter, collateralToken). fulfill / priceAndFulfill / submitRootAndFulfill* take FulfillmentBatch[]. AssessorReceipt gone; request-derived data sourced from binding-verified slim payloads.
src/IBoundlessMarket.sol 110 220 Interface mirror.
Scripts scripts/Deploy.Router.s.sol 54 29 Router proxy deploy + R0 class registration.
scripts/Manage.Router.s.sol 74 37 RegisterR0Verifier / RegisterR0Assessor / RemoveEntry.

Plus updates to scripts/Deploy.s.sol, scripts/Manage.s.sol, src/libraries/BoundlessMarketLib.sol, src/types/Fulfillment.sol; deletion of src/types/AssessorReceipt.sol; regenerated crates/boundless-market/src/contracts/artifacts/ + bytecode.rs.

Tests

Area File code comment What it covers
Router test/router/BoundlessRouter.registry.t.sol 657 39 All addClass / instantiate / removeClass / removeEntry invariants.
test/router/BoundlessRouter.dispatch.t.sol 593 105 verifyBatch dispatch tree: class resolution, signed-selector matching, per-fill catch, assessor seam.
test/router/RouterTestBase.sol 139 25 Minimal shared fixture (empty router; per-test class/entry setup).
test/router/BenchBase.sol 277 77 Bench fixture with a pre-registered R0 adapter ecosystem.
test/router/RouterBench.t.sol 39 44 Router dispatch overhead measurements.
test/router/AdapterBench.t.sol 60 16 R0 adapter wrapping cost.
test/mocks/RouterMocks.sol 105 58 Null / Reverting verifier / assessor / joint mocks; NullRiscZeroVerifier.
Adapter test/router/R0BoundlessAssessorAdapter.t.sol 184 41 Length / seal-format gating, journal-digest reconstruction, sparse arrays, four tamper variants.
test/router/R0AssessorImageRotation.t.sol 89 60 Parallel old/new selectors; post-tombstone lifecycle on a single locked request.

The rewritten test/BoundlessMarket.t.sol (1979 changed lines) ports the existing market suite onto the new architecture — locked / never-locked / partial-payment paths, smart-contract signatures, callbacks, set-builder R0 fixtures, slim-payload binding coverage.

Key design decisions

  • Slim per-fill payload. SlimRequest carries predicate, callback, and selector in full plus imageUrlHash, inputDigest, offerDigest. The market reconstructs the EIP-712 struct hash from these fields and compares against the digest stored at lock time (or in transient storage from priceRequest). Once the comparison passes, every slim field is bound to the client-signed request — downstream consumers (router, adapters, callback dispatch) trust the payload without re-verification.
  • Universal prover arg. Threaded through the router into both IBoundlessJointVerifierAssessor.verifyJoint and IBoundlessAssessor.verifyAssessor as the address the market will credit / slash. Each adapter binds it via its own mechanism (R0 STARK journal commitment, signature payload, attestation message). The market trusts the value uniformly.
  • Two-namespace registry with permanent tombstoning. entries[selector] pins a concrete impl; classes[classId] groups conformant adapters with shared interface tag, required assessor class, and gas defaults. Both maps share a bytes4 namespace; tombstoning is permanent so an EIP-712-signed request can never be silently repointed by a later registration. removeClass refuses to tombstone a class with live entries (entriesPerClass counter is the guard).
  • Calldata tail-call optimization. BoundlessRouter.verifyBatch(FulfillmentBatch, bytes32[]) and IBoundlessAssessor.verifyAssessor(FulfillmentBatch, bytes32[]) share an ABI tail, so the assessor staticcall reuses verbatim calldata via a single calldatacopy — skipping ABI re-encoding for the batch payload.
  • Image-id rotation = router-level ceremony. Each R0BoundlessAssessorAdapter is immutable per image id. To rotate: deploy a fresh adapter at a new selector under the existing R0_ASSESSOR class, run both in parallel during broker transition, then removeEntry(oldSelector) to tombstone. The lock binds no assessor selector, so brokers holding locks can switch selectors at fulfillment time without re-locking.
  • Joint adapter deferred. The IBoundlessJointVerifierAssessor interface ships as part of the seam taxonomy; no production impl in this PR. The dispatch path is fully covered by RouterMocks.NullJoint / RevertingJoint in the test suite.

jonastheis added 2 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.
@github-actions github-actions Bot changed the title feat(contracts): BoundlessRouter + R0 adapters (Phase A+B) BM-2956: feat(contracts): BoundlessRouter + R0 adapters (Phase A+B) May 8, 2026
@linear
Copy link
Copy Markdown

linear Bot commented May 8, 2026

BM-2956

BM-2957

jonastheis added 3 commits May 8, 2026 21:09
… 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.
@jonastheis jonastheis changed the title BM-2956: feat(contracts): BoundlessRouter + R0 adapters (Phase A+B) feat(contracts): introduce BoundlessRouter and rewrite BoundlessMarket to dispatch through it May 8, 2026
@github-actions github-actions Bot changed the title feat(contracts): introduce BoundlessRouter and rewrite BoundlessMarket to dispatch through it BM-2957: feat(contracts): introduce BoundlessRouter and rewrite BoundlessMarket to dispatch through it May 8, 2026
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.
Comment on lines +31 to +36
function verifyAssessor(
bytes32[] calldata requestDigests,
bytes32[] calldata claimDigests,
address prover,
bytes calldata assessorSeal
) external view;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thinking a bit about this, in the new path, BoundlessMarket._verifySubBatch passes only requestDigests, claimDigests, seals, and signedSelectors to the router. The R0 assessor adapter then reconstructs leaves using env.fulfillmentDataDigests supplied inside the prover-controlled assessor envelope. But the market later decodes callback data from fill.fulfillmentData.
Before, the market computed fill.fulfillmentDataDigest() itself before building assessor leaves. With this change we are losing that binding. A prover can satisfy the assessor against one fulfillment-data digest while passing different fulfillmentData to the callback path.

The market should compute fulfillment-data digests from sb.fills[I] and pass them through the router/assessor interface. So maybe we could extend IBoundlessAssessor.verifyAssessor and BoundlessRouter.verifySubBatch to include bytes32[] fulfillmentDataDigests?

Comment thread contracts/src/BoundlessMarket.sol Outdated
for (uint256 i = 0; i < sb.fills.length; i++) {
Fulfillment calldata fill = sb.fills[i];
ProofRequest calldata request = sb.requests[i];
bytes32 requestDigest = request.eip712Digest();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

after deriving this, the contract should either check fill.requestDigest == requestDigest, as we emit the provided one rather than the one we recompute

Comment thread contracts/src/BoundlessMarket.sol Outdated
Comment on lines +272 to +275
function fulfill(SubBatch[] calldata subBatches) public returns (bytes[] memory paymentError) {
verifyDelivery(subBatches);

// Total fill count across all sub-batches; flatten for the return array.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Previously for fulfill and submitRootAndFulfill it wasn't required for the prover to post the ProofRequest which was intended to save gas on the hot path (LockAndFulfill). It seems now every fulfill path takes SubBatch which includes the full ProofRequest, so the hot path will take a gas hit

Was this required for the refactor? If not maybe we introduce a

struct LockedSubBatch {
    Fulfillment[] fills;
    bytes         assessorSeal;
    address       prover;
}

or something to keep the previous behavior?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It adds indeed quite some gas. I just checked for ~100 requests:

  • Mean ABI-encoded ProofRequest size: ~1024 bytes (sample is dominated by order-generator traffic which is fairly uniform, but it's representative of
    the actual hot-path workload).
  • Calldata cost per fill: ~1024 bytes × ~14 gas/byte (mixed nonzero/zero) ≈ ~14k gas/fill.
  • Plus EIP-712 re-derivation per fill: ~5k gas/fill.
  • Total: ~19k extra gas per fill vs. a LockedSubBatch-shaped hot path.

/// Terminal seam. Classes with this `interfaceTag` are referenced by other
/// classes' `requiredAssessorClass` and MUST never be selected as a verifier
/// class — the router rejects this at `verifySubBatch`.
interface IBoundlessAssessor {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Related to my other comment about passing ProofRequest into all the fulfill endpoints)

Previously the assessor did two things:

1/ proving each claimDigest is a valid answer for its requestDigest, and prover is who produced it.
2/ surfacing the selector and callback.{addr, gasLimit} to the market so it could dispatch the callback and feed the router.

In this PR the market now gets selector/callback from ProofRequest[] in calldata, which is now possible because the full ProofRequest are now also posted in fulfill and submitRootAndFulfill

If we address my other comment and end up making ProofRequest not required, I think we need to change verifyAssessor to return the selectors/callbacks like:

function verifyAssessor(
        bytes32[] calldata requestDigests,
        bytes32[] calldata claimDigests,
        address prover,
        bytes calldata assessorSeal
    ) external view returns (
        bytes4[] memory signedSelectors,
        Callback[] memory callbacks
    );

Copy link
Copy Markdown
Contributor Author

@jonastheis jonastheis May 12, 2026

Choose a reason for hiding this comment

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

2/ surfacing the selector and callback.{addr, gasLimit} to the market so it could dispatch the callback and feed the router.

This is the main reason I wanted to move the proof request to the calldata. I don't think this is a clean separation of concerns.

I get the concern about the gas cost. So there's clear motivation to avoid this. Especially for every request when most not even use the callback.

Extending the interface to return this data is imo also not super clean as this implies that every Assessor implementation needs to pass this data via seal and decode it internally.

Another option might be to store this data on lock request conditionally if the request has a callback and then load it in fullfill. This way the assessor doesn't need to provide it and most requests don't pay for it.

Comment on lines +260 to +272
/// @notice Add a new class. Governance-only.
/// @dev Enforces namespace invariants: `classId` is non-zero, non-tombstoned, and
/// not already in either map. Validates `interfaceTag` is one of the three
/// accepted values and that `requiredAssessorClass` matches the tag's
/// expectations (mandatory for verifier classes; absent otherwise).
function addClass(bytes4 classId, ClassMetadata calldata metadata) external onlyRole(ADMIN_ROLE) {
if (classId == CHAIN_DEFAULT_SENTINEL) revert ZeroSelectorReserved();
if (tombstoned[classId]) revert ClassRemoved(classId);
if (classes[classId].interfaceTag != bytes4(0)) revert ClassInUse(classId);
if (entries[classId].impl != address(0)) revert EntryInUse(classId);

bytes4 tag = metadata.interfaceTag;
if (!_isVerifierTag(tag) && !_isJointTag(tag) && !_isAssessorTag(tag)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't fully understand the need for the class layer in the router, it seems it gives us:

  1. Verifier -> assessor binding, gas limit, schema artifact set once per class.
  2. A distinction between permissionless and non-permissionless verifiers
  3. A way to bulk disable verifiers

But seems to add a lot of code + complexity, especially as verifiers are rarely added / removed so the potential duplication of things like assessor address, verifier etc doesn't seem like a big deal?

Am i missing something as to why this is important?

Copy link
Copy Markdown
Contributor Author

@jonastheis jonastheis May 12, 2026

Choose a reason for hiding this comment

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

The class layer buys us four things (the last 3 you mentioned already):

  1. Sign-many selector semantics. A requestor can sign a class id and accept any entry under that class. Without classes, every requestor pins one specific selector (or signs 0x00 and gets exactly the one default impl) — they can't say "any R0 verifier flavor is fine."
  2. Required-assessor binding. The verifier↔assessor pairing lives on the class, not on each entry. Adding a 4th R0 verifier flavor doesn't risk it being wired to the wrong assessor.
  3. Permissionless-tier convenience. A reserved-prefix policy (we already have RESERVED_PREFIX_MASK = 0xff000000) gates who can register just as well in a flat design. What classes still buy you is bundling the conformance constraints — permissionless registrants inherit (interface tag, required assessor, gas limit, schema) from the class instead of picking those values themselves. Without classes a permissionless registrant could pair their impl with a bogus assessor, but that just self-rugs at verify time, not a soundness issue.
  4. Bulk disable + permissionless gate. One removeClass tombstones the whole group; one permissionlessInstantiate flag gates a whole group.

If we want to simplify, we'd flatten to one mapping: each entry carries its own (assessor, gasLimit, schema, permissionless), shrinks the contract by ~100 LOC, and means adding a 4th R0 verifier means duplicating the assessor pointer in storage — which, as you point out, isn't a big deal at v1 scale.

imo the class is really only necessary if we want to enable the flexibility of 1.

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).
Copy link
Copy Markdown
Contributor

@willpote willpote left a comment

Choose a reason for hiding this comment

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

Overall looks good, will give a more in depth review once we have test / new gas snapshot numbers. For now just feedback on the API design / naming

Comment thread contracts/src/BoundlessMarket.sol Outdated
Comment on lines +249 to +250
ProofRequest[][] calldata priceRequests,
bytes[][] calldata clientSignatures,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thoughts on having a wrapper to 1/ avoid these 2d arrays, 2/ more symmetry to SubBatch?

e.g.

struct ProofRequestBatch {
    requests: ProofRequest[]
    signatures: bytes[]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also how about rename SubBatch -> FulfillmentBatch?

Then API is more intuitive and clearer imo:

priceAndFulfill(
    ProofRequestBatch[] requestBatches;
    FulfillmentBatch[] fulfillmentBatches;
)

jonastheis added 14 commits May 15, 2026 16:48
…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`.
Stand up the router unit-test scaffolding: a shared `RouterTestBase`
deploying an empty UUPS proxy, an extended mock catalog covering all
three interface tags + the ERC-165 / revert / gas-hog edge cases, and
the Section A registry suite (57 tests) covering `addClass`,
`removeClass`, default-class state machine, `instantiate`, and
`removeEntry` plus their error branches.
Section B of the router test plan: 53 tests covering `verifyBatch`
end-to-end — length/shape guards, first-seal resolution, per-fill
verifier and joint dispatch (with multi-fill cache and gas-cap
behavior), mixed-class detection, assessor dispatch, dispatch
ordering, signed-selector resolution (incl. bounded fuzz), and the
load-bearing assessor calldata-forward byte-equality + ABI-stability
check.
Adds an `entriesPerClass` counter incremented on `instantiate` and
decremented on `removeEntry`. `removeClass` now reverts with
`ClassHasEntries(classId, live)` if the counter is non-zero, forcing
admins to remove pinned impls before tombstoning the class. The router
no longer carries entries whose `classId` resolves to a non-live
class — `_classTagOf`'s `ClassRemoved` branch becomes defense in depth.

Also simplifies the verifyBatch per-fill loop header.
The router's NatSpec on `Entry.gasLimit`, `VerifierFailed`, and
`verifyBatch` claimed that the per-fill try/catch isolates sibling
fulfillment batches in the same tx. The catch re-reverts and the
market doesn't wrap `ROUTER.verifyBatch`, so one bad fill reverts
the whole fulfill() call. Tighten the docs to describe what the
try/catch actually buys (gas bound + structured VerifierFailed)
and add a regression test in BoundlessMarket.t.sol that pins the
multi-batch revert behavior.

Also drops the unused `PermissionlessNotAllowed` error.
Adds `InvalidGasLimit` and rejects classes registered with
`defaultGasLimit == 0`. Without this check, any `instantiate` caller
that passes `gasLimit == 0` would silently produce an entry pinned at
`staticcall{gas: 0}` — every dispatch OOGs immediately and the failure
mode only surfaces at first fulfillment. The check closes the
governance fat-finger loop at registration time.
Removes the duplicated NatSpec block at the assessor-dispatch site and
keeps the full explanation at `_forwardCalldataAsStaticCall`, where the
assembly lives. The dispatch site now points at the helper for the
ABI-equality invariant, and the helper carries the load-bearing
framing along with a note that a unit test pins the byte-equality.
Guards against drift between SlimRequestLibrary.reconstructRequestDigest
and ProofRequestLibrary.eip712Digest by mutating each signed field on
the slim payload and asserting _verifyBinding reverts.
  `testFulfillRevertsOnAnyMutatedSlimField` exhaustively covers per-field
  tampering across selector, callback, predicate, imageUrlHash,
  inputDigest, and offerDigest — every EIP-712-bound field except `id`
  itself. Extend it with a 9th case that swaps `slim.id` to another
  locked request's id.

  The new case exercises the binding check's digest comparison against
  a real second lock rather than a never-locked id: both ids exist in
  `requestLocks` with non-zero digests, so a regression that only
  checked `requestLocks[id].requestDigest != 0` would silently accept
  the swap. The digest reconstructed from the original request's other
  fields under the swapped id doesn't match the target lock's stored
  digest, so the comparison still rejects.
Two cleanup items plus a small derivability tweak.

1. Drop wrapped /* */ blocks and TODO(MIGRATE-MARKET) comments now
   that equivalent component-level coverage exists in the router
   suite. Removed:
     - Deprecated-assessor helpers and tests
       (createDeprecatedFillAndSubmitRoot,
       _testFulfillDeprecatedAssessor, testFulfillDeprecatedAssessor)
       — the feature is gone; router-level tombstoning replaces it,
       covered by BoundlessRouter.registry.t.sol.
     - testFulfillRequestWrongSelector and the two
       ApplicationVerificationGasLimit tests — signed-selector
       enforcement and per-fill gas budgets are router concerns,
       covered by BoundlessRouter.dispatch.t.sol.
     - The migration TODO header above BoundlessMarketBasicTest, the
       inline "incrementally unwrapped" pointer, and the stale
       bench/upgrade migration TODO (BoundlessMarketBench and
       BoundlessMarketUpgradeTest already exist).
   No active test bodies changed.

2. Move r0JournalDigest — the R0 assessor guest stand-in that
   reconstructs the AssessorJournal commitment for test fixtures —
   from BoundlessMarket.t.sol to TestUtils.sol alongside the
   existing mockSetBuilder / hashLeaf / mockAssessorSeal helpers.
   Byte-identical output; the BoundlessMarket.t.sol call site
   references the shared symbol.

3. Mark BoundlessMarketTest.setUp() virtual so derived fixtures can
   extend it.
R0BoundlessAssessorAdapter.sol documents the operational pattern for
rotating the assessor guest image: deploy a new adapter pinned to the
new image id, instantiate it under the existing assessor class at a
fresh selector, run both selectors in parallel, then removeEntry the
old selector once brokers have migrated. The mechanism is
router-level tombstoning rather than a time-based deprecated-assessor
flag.

Add a dedicated fixture that inherits from BoundlessMarketTest,
registers a second R0BoundlessAssessorAdapter pinned to a fresh
image id alongside the production one, and covers the two
load-bearing scenarios:

  * Parallel operation: locks settled via either selector succeed
    while both are live.
  * Post-tombstone lifecycle on a single locked request: a stale
    broker on the old selector hits BoundlessRouter.EntryRemoved
    while a migrated broker on the new selector settles the same
    lock — the lock binds no assessor selector, so the path to
    payment survives as long as the required assessor class has
    any live entry.

Helpers reuse TestUtils.r0JournalDigest + mockSetBuilder. A small
_buildBatchFor variant of the base fixture's createFillsAndSubmitRootR0
parameterizes on image id + selector so the same path produces
batches for either adapter.
R0BoundlessAssessorAdapter has only been exercised end-to-end through
the market fixture so far. Add a standalone suite that drives the
adapter directly with a NullRiscZeroVerifier and pins the behavior
the market path obscures:

  * Input shape gating — LengthMismatch on requests/fills/digests
    length divergence; MalformedSeal on assessorSeal under 4 bytes;
    exactly-4-byte seal forwards an empty innerSeal (the lower edge
    of the gate).
  * Inner-seal stripping + journal forwarding — vm.expectCall asserts
    the underlying verify receives the verbatim post-prefix bytes and
    the expected journalDigest, computed via TestUtils.r0JournalDigest
    so the reference reconstruction stays in sync with the adapter.
  * Sparse callback/selector arrays — none/some/all of three fills,
    with non-contiguous indices to exercise the index field as
    distinct from the array position.
  * Tamper detection — independently perturb prover, slim.id,
    fill.claimDigest, and fill.fulfillmentData; assert each shifts
    the journalDigest the adapter forwards, confirming every
    journal-bound field actually binds.

All happy paths use vm.expectCall against the controllable null R0
verifier so failures surface as a divergence between the adapter's
output and TestUtils.r0JournalDigest rather than as a downstream
STARK error.
@jonastheis jonastheis changed the title BM-2957: feat(contracts): introduce BoundlessRouter and rewrite BoundlessMarket to dispatch through it BM-2957: feat(contracts): decouple verification via BoundlessRouter May 22, 2026
Pairs with testFulfillMultiBatchRevertingFillRevertsWholeTx (negative
case: one bad fill kills the tx) by pinning the positive shape: a
single fulfill() call settles three batches that each take a
different dispatch path through the router.

  * batch A — NullVerifier + NullAssessor (mock).
  * batch B — R0 setVerifier + R0 assessor (production path).
  * batch C — NullJoint under a freshly registered joint class
    (no assessor seam; assessorSeal must be empty).

The fixture only registers verifier + assessor classes today, so the
joint path needs a NullJoint entry instantiated inline. The
requestor of batch C signs the joint class id directly so the
signed-selector check matches under that class.

Confirms the router walks each batch's dispatch tree independently
and the market settles every fill in one tx — each client paid
1 ether at lock time, the prover collects 3 ether at fulfillment.
CI's forge fmt --check was failing on five test files added or
touched in this branch. Run forge fmt to bring them in line. No
behavioral changes — pure whitespace and line-wrapping adjustments.
Triaged the inline TODOs left in the router test suite:
  * Most were stale review questions answered by adjacent tests
    (signed-selector depth, joint dispatch happy path, single-class
    rationale, fuzz mechanics) — replaced with brief inline notes
    where context was actually missing, otherwise removed.
  * `test_signedSelector_revertsOnSignedEntryMismatch`: variable name
    was misleading (claimed "same class" but the entry lives in a
    separate class so the entry-mismatch branch is reachable);
    renamed to `otherEntry` and expanded the inline comment to
    explain why a separate class is required.
  * `test_instantiate_revertsForNonAdminOnReservedPrefix_permissionless`:
    expanded the comment to spell out that the reserved-prefix policy
    is entry-selector-only — class ids in the 0x00xxxxxx range can
    perfectly well be permissionless.
  * BenchBase R0 seal: was 200 zero bytes, which understates calldata
    gas (4 gas/byte vs 16 gas/byte for non-zero). Fill with 0xAA so
    the bench numbers match production seal cost.
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