BM-2957: feat(contracts): decouple verification via BoundlessRouter#1982
BM-2957: feat(contracts): decouple verification via BoundlessRouter#1982jonastheis wants to merge 57 commits into
Conversation
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).
willpote
left a comment
There was a problem hiding this comment.
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
…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.
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.
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.
There was a problem hiding this comment.
Overall this looks good, I don't have a great picture of what the net-gas impact is, since we don't really have an apples-to-apples comparison anymore in the gas tests anymore.
I believe we discussed that its lower overall, which is good. If you have the numbers, if you could add to the PR description, otherwise happy to test on staging and see on a real network.
I think there is still opportunity for further gas savings by potentially, but think we can leave for future:
1/ Removing class layer, class metadata from router, each verifier map to a specific assessor:
// single slot
struct VerifierEntry {
address impl; // 20 bytes
bytes4 interfaceTag; // 4 bytes
bytes4 requiredAssessorSelector; // 4 bytes
uint32 gasLimit; // 4 bytes
}
Think this would save at least 2 SLOADS (no more classes[…] cold SLOAD and the requiredAssessorClass cold SLOAD), and probably some more gas from other overheads. We lose some nice-to-have class stuff, that imo is not too important.
2/ Moving the router into BoundlessMarket contract - I think we didn't consider this originally due to contract size issues, but I wonder if it would be possible following the other changes we are making. Would save Market -> Router external call + calldata passing to external contract, + UUPS overhead on the router
…ers (#2016) ## Summary Extract a `private _submitRoot(setVerifier, root, seal)` helper from the five `submitRoot*` external entrypoints. The optimizer was duplicating the entire `IRiscZeroSetVerifier.submitMerkleRoot` call setup (~140 B per site: selector push, ABI-encode for `bytes seal`, `CALL`, return path) at each site. With five call sites, the optimizer keeps the helper factored. ABI / selectors / calldata layout unchanged — brokers call the same five functions with the same inputs. ## Result `BoundlessMarket` runtime: **30,165 B → 29,456 B (−709 B)**.
I think the second SLOAD should be warm as well. In |
The market fulfillment ABI now takes FulfillmentBatch[] (and ProofRequestBatch[] for the price path) in place of flat (Fulfillment[], AssessorReceipt). - Add SlimRequest::from_request and an assessor_seal helper for building the batched payloads - Rewrite FulfillmentTx to carry the full requests, fills, assessor seal and prover; build the batches inside the fulfill methods - Drop the AssessorReceipt re-export and the imageInfo getter - Regenerate bytecode.rs after the contract changes
… image id from ELF The on-chain Fulfillment no longer carries the request id/digest, so build_fulfillments emits the slim Fulfillment and attaches the full ProofRequest to each OrderFulfillmentArtifact for the submitter to derive the SlimRequest and key order tracking. The market's imageInfo() getter is gone; the assessor image id is now derived from the configured assessor ELF (local path or default URL) via compute_image_id instead of an on-chain call.
…selector from config The submitter now collects the fulfilled requests alongside the fills, keys order tracking on the request id (the on-chain Fulfillment no longer carries it), and builds FulfillmentTx from the batched API instead of an AssessorReceipt. The router assessor selector is a per-deployment registration that can't be derived, so it is added to MarketConfig and threaded into the RISC0 backend, which prepends it to the inner assessor seal (selector ++ inner seal) in build_fulfillments.
…e assessor image id from ELF OrderFulfilled (the abi-encoded FFI payload) now carries a FulfillmentBatch instead of fills + AssessorReceipt, and OrderFulfiller.fulfill returns the router assessor seal. The prover fulfill command and boundless-ffi gain an --assessor-selector flag/arg, threaded into the fulfiller. The assessor image id is derived from the configured ELF via compute_image_id (the market imageInfo getter is gone). BoundlessFulfillment is built without id/requestDigest, and the requestor status timeline reads requestDigest from the request rather than the ProofDelivered event.
…to slimmed Fulfillment Slimming the Fulfillment struct dropped requestDigest from the ProofDelivered event payload (id stays available as the indexed requestId). Re-add requestDigest as a top-level event param so event consumers keep the same data, and update the 25 contract-test expectEmit assertions accordingly. Indexer: read requestDigest from the event and thread it (with requestId) into add_proofs, which no longer reads them off the Fulfillment. Remove add_assessor_receipts, which had no production caller and indexed AssessorReceipt data that no longer exists on-chain.
…te fixtures to batched API The Rust integration tests fulfill on-chain, which under router decoupling requires a deployed and configured BoundlessRouter. test-utils now deploys the router (UUPS proxy + initialize, assessor + default-verifier classes with their ClassMetadata/interface ids, the R0 assessor and verifier adapters, and the class entries), wires the market to it, and mock_singleton produces the slimmed Fulfillment plus the full router assessor seal. All fulfillment fixtures across e2e, broker, indexer, slasher and CLI tests are migrated to the batched FulfillmentTx/FulfillmentBatch API. Drop the router-runtime additional Foundry profile: it made lib-dependency contracts emit only profile-suffixed artifacts, which dropped the verifier bytecode from the generated bytecode.rs. With a single profile every target contract emits a canonical artifact again. build.rs additionally generates the router + adapter bytecode and the corrected BoundlessMarket(router, collateralToken) constructor. Gas snapshots regenerated for the router at the default optimizer setting.
localnet-deploy.sh now runs DeployRouter and the R0 verifier/assessor registration scripts around Deploy.s.sol, then records boundless-router and r0-assessor-selector in deployment.toml. - Deploy.s.sol / Manage.s.sol: read the router from deployment.toml (env override kept) - Config.s.sol + update_deployment_toml.py: carry boundless-router + r0-assessor-selector - Deploymnet.t.sol: batched fulfillment, router checks, sign the set-verifier selector - broker.localnet.toml: assessor selector the localnet broker prepends
The PR removes the legacy bench entries, so the :v2 suffix had nothing to coexist with and the comment claiming side-by-side review was wrong. Rename the bench snapshot labels (and regenerate BoundlessMarketBench.json) without the suffix and correct the comment.
The SlimRequest, FulfillmentBatch, and ProofRequestBatch NatSpec aligned continuation lines under the tag (8 spaces). The sol! macro forwards NatSpec verbatim as Rust doc comments, and a blank line followed by >=4-space-indented text is an indented code block that rustdoc compiles as a doctest, so the pseudocode and prose failed to compile (rust-lint cargo test --doc). Dedent continuations to base indentation (matching the existing convention in e.g. Offer.sol) and wrap the pseudocode in fenced text blocks. Regenerate the committed boundless-market artifact copies to match.
OrderFulfiller::initialize derived the assessor image id by downloading the
remote default guest, but the Rust test harness deploys R0BoundlessAssessorAdapter
with the locally-built ASSESSOR_GUEST_ID. The two ELFs differ, so the assessor was
proven under an image the adapter does not verify against and SetVerifier reverted
with VerificationFailed during fulfillment.
Thread an assessor image URL through OrderFulfiller::initialize; production keeps
ASSESSOR_DEFAULT_IMAGE_URL while tests point it at file://{ASSESSOR_GUEST_PATH} so
the proven image matches the deployed one. Also set the local set-builder and
assessor guest paths in the bench broker config, matching the broker's own test
harness.
Set assessor_selector in the broker test configs (was zero, which the router rejects with ZeroSelectorReserved), and register the groth16/blake3/fake-receipt verifier entries in deploy_router (previously only the set-verifier selector was registered, so those seals reverted with EntryUnknown). deploy_router now adds one R0BoundlessVerifierAdapter per selector under the verifier class, mirroring the entries setup_verifiers registers in the existing RiscZeroVerifierRouter.
a71ad71 to
808383c
Compare
Summary
Decouple per-class verification dispatch from
BoundlessMarketby introducing a governance-controlledBoundlessRouter. 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).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 viarequiredAssessorClass, never selected as a verifier class.Contracts in this PR
src/router/BoundlessRouter.solentries+classes), namespace invariants, permanent tombstoning, governance-gated mutations. UUPS-upgradeable.src/router/interfaces/IBoundlessRouter.solsrc/router/interfaces/IBoundlessVerifier.solsrc/router/interfaces/IBoundlessAssessor.solsrc/router/interfaces/IBoundlessJointVerifierAssessor.solsrc/router/adapters/R0BoundlessVerifierAdapter.solsrc/router/adapters/R0BoundlessAssessorAdapter.solproverarg.src/types/SlimRequest.solsrc/types/FulfillmentBatch.sol{ requests, fills, assessorSeal, prover }.src/types/ProofRequestBatch.solsrc/BoundlessMarket.sol(BoundlessRouter, collateralToken).fulfill / priceAndFulfill / submitRootAndFulfill*takeFulfillmentBatch[].AssessorReceiptgone; request-derived data sourced from binding-verified slim payloads.src/IBoundlessMarket.solscripts/Deploy.Router.s.solscripts/Manage.Router.s.solRegisterR0Verifier/RegisterR0Assessor/RemoveEntry.Plus updates to
scripts/Deploy.s.sol,scripts/Manage.s.sol,src/libraries/BoundlessMarketLib.sol,src/types/Fulfillment.sol; deletion ofsrc/types/AssessorReceipt.sol; regeneratedcrates/boundless-market/src/contracts/artifacts/+bytecode.rs.Tests
test/router/BoundlessRouter.registry.t.soladdClass/instantiate/removeClass/removeEntryinvariants.test/router/BoundlessRouter.dispatch.t.solverifyBatchdispatch tree: class resolution, signed-selector matching, per-fill catch, assessor seam.test/router/RouterTestBase.soltest/router/BenchBase.soltest/router/RouterBench.t.soltest/router/AdapterBench.t.soltest/mocks/RouterMocks.solNullRiscZeroVerifier.test/router/R0BoundlessAssessorAdapter.t.soltest/router/R0AssessorImageRotation.t.solThe 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
SlimRequestcarries predicate, callback, and selector in full plusimageUrlHash,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 frompriceRequest). 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.proverarg. Threaded through the router into bothIBoundlessJointVerifierAssessor.verifyJointandIBoundlessAssessor.verifyAssessoras 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.entries[selector]pins a concrete impl;classes[classId]groups conformant adapters with shared interface tag, required assessor class, and gas defaults. Both maps share abytes4namespace; tombstoning is permanent so an EIP-712-signed request can never be silently repointed by a later registration.removeClassrefuses to tombstone a class with live entries (entriesPerClasscounter is the guard).BoundlessRouter.verifyBatch(FulfillmentBatch, bytes32[])andIBoundlessAssessor.verifyAssessor(FulfillmentBatch, bytes32[])share an ABI tail, so the assessor staticcall reuses verbatim calldata via a singlecalldatacopy— skipping ABI re-encoding for the batch payload.R0BoundlessAssessorAdapteris immutable per image id. To rotate: deploy a fresh adapter at a new selector under the existingR0_ASSESSORclass, run both in parallel during broker transition, thenremoveEntry(oldSelector)to tombstone. The lock binds no assessor selector, so brokers holding locks can switch selectors at fulfillment time without re-locking.IBoundlessJointVerifierAssessorinterface ships as part of the seam taxonomy; no production impl in this PR. The dispatch path is fully covered byRouterMocks.NullJoint/RevertingJointin the test suite.Benchmarks
Full cost analysis (on-chain gas + off-chain proving) of this architecture — guest-based vs on-chain assessor, and the new stack's overhead vs
main: Assessor cost analysis (Google Doc).Main results:
submitRoot. The guest assessor is always submitted as a set-builder inclusion, so it always submits a root — 1 Groth16 (best case) or 2 (explicit-Groth16, worst case). The on-chain assessor is 1 Groth16, no root, always.mainis localized to fulfillment (~+40% onsubmitRootAndFulfill, R0 assessor both sides; ~+13% real). Request / lock / slash / deposit / withdraw are neutral (±1k gas), bytecode is slightly smaller, and the legacyfallbackadds ~0. The overhead is the router indirection (≈18k of it cold registry SLOADs) + slim-request reconstruction; FulfillLib extraction is gas-neutral. Notably, moving the assessor on-chain roughly buys back this overhead.This PR (#1982) is the base of the stack and is followed by all the others: #2020 (forward legacy ABI via fallback) → #2022 (extract fulfill chain into FulfillLib), plus #2005 (native on-chain assessor).