Skip to content

feat(contracts): add OnChainAssessor — native Solidity assessor adapter#2005

Draft
jonastheis wants to merge 11 commits into
jonas/router-decouplingfrom
jonas/onchain-assessor
Draft

feat(contracts): add OnChainAssessor — native Solidity assessor adapter#2005
jonastheis wants to merge 11 commits into
jonas/router-decouplingfrom
jonas/onchain-assessor

Conversation

@jonastheis

@jonastheis jonastheis commented May 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the native Solidity OnChainAssessor adapter — a per-fill predicate-eval + ECDSA-prover-binding implementation of IBoundlessAssessor — and fixes two correctness gaps surfaced while wiring it end-to-end through the market. Stacked on top of #1982 (jonas/router-decoupling).

The adapter

contracts/src/router/adapters/OnChainAssessor.sol is stateless and immutable. Per fulfillment batch it vouches for:

  1. Predicate satisfaction — for each fill, the supplied (claimDigest, fulfillmentData) satisfies the requestor's Predicate (DigestMatch / PrefixMatch / ClaimDigestMatch).
  2. Claim-digest binding — whenever the prover attaches an ImageIdAndJournal payload, (imageId, journal) must reconstruct to fill.claimDigest. Required so a callback dispatched off fulfillmentData can't receive bytes the underlying seal never proved.
  3. Prover bindingbatch.prover is bound via an EIP-712 ECDSA signature over (prover, requestDigests[], claimDigests[]). On-chain equivalent of the R0 STARK adapter's prover journal commitment.

Brokers select between this adapter and R0BoundlessAssessorAdapter by setting the first 4 bytes of assessorSeal to the registered adapter's router selector. The market is unchanged — selection happens at the router seam.

Correctness fixes uncovered while wiring this in

  • Journal-hash convention. Predicate.eval(imageId, journal) was using sha256(abi.encode(journal)), but the off-chain R0 STARK guest, BoundlessMarketCallback, and the broker's claim-digest computation all use sha256(journal) over the raw bytes. The on-chain side never matched a real fill in production. Both call sites in Predicate.sol are now aligned with the canonical convention.
  • ClaimDigestMatch callback binding. The on-chain adapter did not reconstruct the claim digest from (imageId, journal) when the predicate was ClaimDigestMatch and the prover attached an ImageIdAndJournal fulfillment payload (typically to feed a callback). The off-chain assessor guest enforces this binding; the on-chain adapter now matches. Without the guard, a malicious prover could submit a valid seal for the requested claim digest while attaching (imageId, journal) bytes that don't match — and the callback would dispatch unproven data.

Tests

  • contracts/test/router/adapters/OnChainAssessor.t.sol — 26 self-contained unit tests covering each predicate kind, the new ClaimDigestMatch + ImageIdAndJournal reconstruction guard, predicate failures, length / malformed-seal guards, tamper detection on requestDigest / claimDigest / fulfillmentData / prover, ERC-165, the domain separator, plus a regression test pinning the type string against the contract's FULFILLMENT_BATCH_AUTH_TYPEHASH so a silent rename trips loudly.
  • contracts/test/BoundlessMarket.t.sol — wires OnChainAssessor into the market's router as a third assessor entry, adds createFulfillmentBatchOnChain / createFillsAndSubmitRootOnChain helpers (the EIP-712 analogue of createFillsAndSubmitRootR0), and a BoundlessMarketOnChainAssessorTest contract exercising locked-request fulfill, batch fulfill, the ClaimDigestMatch + ImageIdAndJournal callback scenarios (both matching and mismatched journal bytes), and a mixed-adapter batch where one fulfillment goes through OnChainAssessor and another through R0BoundlessAssessorAdapter in the same transaction.
  • contracts/test/router/BenchBase.sol / AdapterBench.t.sol — bench fixture journal-hash aligned with the canonical convention so adapter benches stay green under the fixed Predicate.eval.

This reverts commit e1c5da1 to re-introduce `OnChainAssessor` and its
unit tests/benches on top of `jonas/router-decoupling`. The parent
branch keeps the OnChain code out of its scope; this branch re-adds it
for review in its own PR.
…ack bytes for ClaimDigestMatch

Two related fixes to the on-chain assessor path:

* Predicate.eval(imageId, journal) and OnChainAssessor were hashing
  sha256(abi.encode(journal)) while the off-chain R0 STARK guest,
  BoundlessMarketCallback, and the broker's claim-digest computation use
  sha256(journal) over the raw bytes. The on-chain side never matched a
  real fill. Both call sites now use the canonical convention.

* OnChainAssessor did not reconstruct the claim digest from
  (imageId, journal) when the predicate was ClaimDigestMatch and the
  prover attached an ImageIdAndJournal fulfillment payload (typically to
  feed a callback). The off-chain assessor guest enforces this binding;
  the on-chain adapter now matches it. Without the guard, a malicious
  prover could submit a valid seal for the requested claim digest but
  attach (imageId, journal) bytes that don't match, and the market would
  dispatch a callback with unproven data.
…geHashUtils

* `FULFILLMENT_BATCH_AUTH_TYPE` and `FULFILLMENT_BATCH_AUTH_TYPEHASH` go
  from `internal constant` to `public constant`. Brokers, wallets, and
  tests can now read the canonical typehash directly from the deployed
  adapter instead of redeclaring it, eliminating a class of
  silent-drift bugs where a renamed type string would invalidate every
  pre-computed seal in flight.

* The EIP-712 digest is now built via
  `MessageHashUtils.toTypedDataHash(DOMAIN_SEPARATOR, structHash)`
  instead of inlining `keccak256(abi.encodePacked("\\x19\\x01", ...))`.
  Same bytes, but the magic prefix and assembly live in OZ's audited
  helper rather than this contract.
Registers `OnChainAssessor` as a third assessor entry in the
`BoundlessMarketTest` router setup and adds matching broker-side
fixtures:

* `createFulfillmentBatchOnChain` / `createFillsAndSubmitRootOnChain`
  produce a `FulfillmentBatch` whose `assessorSeal` is a real
  EIP-712 ECDSA signature by the prover wallet over the batch's
  `(prover, requestDigests, claimDigests)` tuple — the on-chain
  equivalent of what the R0 STARK helper produces with a journal
  commitment. The two variants differ only in whether the per-fill
  seal is a `NullVerifier` placeholder or a real set-builder
  inclusion proof (needed for callback tests).

* `_buildOnChainAssessorSeal` sources the typehash and EIP-712 digest
  from the deployed adapter (`FULFILLMENT_BATCH_AUTH_TYPEHASH`,
  `DOMAIN_SEPARATOR`) and `MessageHashUtils`, so the helper stays in
  lock-step with what the contract verifies.

`BoundlessMarketOnChainAssessorTest` exercises the production fulfill
flow through that adapter end-to-end: locked-request happy path, batch
fulfill, the ClaimDigestMatch + ImageIdAndJournal callback scenarios
(both matching and mismatched journal bytes — the latter pins the
adapter's reconstruction guard from blocking unproven callback data),
and a mixed-adapter batch where one fulfillment goes through
`OnChainAssessor` and another through `R0BoundlessAssessorAdapter` in
the same transaction.

Snapshot deltas come from gas changes introduced by the upstream
`MessageHashUtils.toTypedDataHash` swap.
Replaces the prior 6 happy-path / single-revert tests with 26 unit
tests that pin the adapter's behavior end-to-end against the same
contract a broker drives in production. Self-contained: inherits from
`Test` directly, deploys a single `OnChainAssessor` in `setUp`, calls
it directly (no router, no harness wrapper, no shared bench
ecosystem). Fixture builders are inlined below the test methods so
the file reads top-to-bottom.

Coverage:

* Single-fill + multi-fill happy paths for `DigestMatch`,
  `ClaimDigestMatch`, `PrefixMatch`, plus a mixed-predicate batch
  that exercises the per-fill switch.
* `ClaimDigestMatch + ImageIdAndJournal` reconstruction guard, both
  the matching (passes) and mismatched (`ClaimDigestMismatch`)
  paths — the latter pins the divergence guard added in `dd2d7dd1`.
* Predicate-failure shape per predicate kind (wrong journal, wrong
  imageId, prefix that doesn't match).
* `MissingFulfillmentData` for predicates that require a journal.
* Length-mismatch guards on `fills` / `requestDigests`.
* Malformed-seal lengths (selector-only, one byte short, one byte
  long — the adapter requires exactly 4 + 65 bytes).
* Tamper detection on `requestDigest`, `claimDigest`,
  `fulfillmentData`, and `prover` after the prover has signed.
* ERC-165 conformance + a regression test that asserts the contract's
  `FULFILLMENT_BATCH_AUTH_TYPEHASH` still matches the literal type
  string a wallet would sign against — so a silent rename trips
  loudly instead of invalidating every pre-computed seal in flight.
* Domain-separator binding.

Seal construction uses the adapter's public typehash plus
`MessageHashUtils.toTypedDataHash`, so the helper stays in lock-step
with what the contract verifies.
…ention

`BenchBase._makeFill` was producing claim digests with
`sha256(abi.encode(journal))`, which diverged from the off-chain R0
STARK guest, `BoundlessMarketCallback`, and the broker's claim-digest
computation — all of which use `sha256(journal)` over the raw bytes.
The bench harness now uses the canonical hash, matching the on-chain
`Predicate.eval` / `OnChainAssessor` fix from `dd2d7dd1`.

Also drops the stale comment on `AdapterBench.test_bench_journalSize`
that referenced the old hashing pattern.

@willpote willpote left a comment

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.

Overall looks good, few comments

Comment thread contracts/src/router/adapters/OnChainAssessor.sol
Comment thread contracts/src/types/Predicate.sol
Move the (imageId, journal) reconstruction check ahead of the predicate
dispatch so it runs once per fill whenever an ImageIdAndJournal payload
is attached, instead of being duplicated across the ClaimDigestMatch and
DigestMatch/PrefixMatch branches. Behavior-preserving for valid fills;
for invalid fills the selector surfaced when both checks would fail is
now ClaimDigestMismatch rather than PredicateFailed.

Tests:
- digestMatch_wrongJournal / wrongImageId: expect ClaimDigestMismatch to
  match the new ordering.
- prefixMatch_journalDoesNotStartWithPrefix: update fill.claimDigest so
  the reconstruction guard passes, isolating the prefix-eval branch as
  the failure path (the only test exercising PredicateFailed for
  non-ClaimDigestMatch predicates).
- tamper_fulfillmentData_postSigning: rework with PrefixMatch and a
  prefix-preserving new journal so predicate + reconstruction both pass
  and only the per-batch signature catches the mutation.
@linear

linear Bot commented Jun 5, 2026

Copy link
Copy Markdown

BM-3035

…ing merge

The merge of router-decoupling was textually clean but left one semantic
break: ProofDelivered gained a requestDigest arg (4 total), while the
assessor branch's added test still emitted it with 3, failing the build.

- Pass expectedRequestDigest in testFulfillLockedRequest_OnChainAssessor
- Regenerate gas snapshots: drop stale :v2 keys (test code already dropped
  them) and pick up the -9KB impl bytecode / per-call gas shift from the
  router decoupling
- Regenerate the Predicate.sol artifact (sha256(journal), no abi.encode)
  and the assessor-guest Cargo.lock (transitive serde_bytes)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants