ERC-8274: AI Inference Proof Verification

One thing the committed_at discussion has surfaced: the current verificationDigest preimage — keccak256(abi.encode(taskId, agentId, inputHash, outputHash, valid, agentProofProfile)) — carries no timestamp, and neither does the VerificationCompleted event. The “when” of a verification event is only recoverable by tracing back to the block that included the event, not from the digest itself.

For most uses this is fine — the event log is immutable and the block timestamp is independently checkable. But if the digest is meant to serve as a self-contained audit anchor (as Damonzwicker’s OCP framing implies), a timeless digest means any commit-before-outcome check has to go outside the digest to establish ordering.

There’s a case for closing it: adding block.timestamp to the preimage and a timestamp field to VerificationCompleted would make the digest fully self-contained — any observer could establish “when” from the digest fields alone, without tracing back to the block. The current design requires two steps (recompute digest + look up block timestamp) where one could suffice.

The view here is that adding it is worth the small preimage change — a self-contained digest is strictly more useful than one that requires a block lookup to establish ordering. Would welcome any pushback if there’s a reason to keep it out.

2 Likes

@babyblueviper1 Reviewed the PR — the committed_at + judgment_type coupling is clean, and the two-anchor distinction (ERC-8263 for verdict, ERC-8281/OCP for input) is exactly the clarification the spec needed. Merged. Thanks for the surgical delta — the production data refresh is a nice bonus.

2 Likes

Thanks for merging it, Jimmy.

Per the standing obligation, I ran the same-day conformance pass on the merged text against the live reference implementation (api.babyblueviper.com/ledger). It conforms, after one fix on our side:

  • committed_at — already served, as the tier-2 on-chain committedAt sourced from the ERC-8263 proofHash leg (the truthanchor_8263 block), kept distinct from the ERC-8281/OCP input anchor exactly as the merged normative text specifies.

  • judgment_type — this is what I fixed. The ledger now serves judgment_type=outcome_verifiable on every attestation/judgment entry. All of our verdicts grade against a realized on-chain outcome (settlement on the public Hyperliquid account plus signed outcome digests), so they are Type A; we issue no Type B consensus_weighted verdicts.

The Type A invariant is checkable in production: entry 19 (the on-chain commit-reveal example) has committed_at 1781103864 < revealed_at 1781103876, both on Sepolia, via getCommit(19, …). 26 signed entries, 10W/9L across 19 settled, losses published by design.

No further changes on the verification side. Reference data, not a required format, as the spec notes.

2 Likes

@JimmyShi22 — the timestamp addition raised in #123 makes sense. Including block.timestamp makes the verification record self-contained with respect to
when the verification occurred.

One related boundary question before the draft moves further: should verificationDigest also be domain-separated by chain and verifier deployment?

The current construction is:

keccak256(abi.encode(
taskId,
agentId,
inputHash,
outputHash,
valid,
agentProofProfile,
block.timestamp
))

Since the preimage does not include block.chainid or address(this), two IAgentVerifier deployments—or equivalent deployments on different chains—can
produce the same digest from identical fields.

This is not necessarily ambiguous when the digest is always accompanied by its event inclusion proof, because the chain and emitting contract are then
known externally. But the draft also allows verificationDigest to be used independently as an audit anchor, for example as ERC-8183’s job.reason, and
describes it as unique per verification event.

Is the intended uniqueness only local to a (chain, IAgentVerifier) context, with that context always supplied separately? Or should the digest explicitly
include:

block.chainid,
address(this)

A related case is two otherwise identical failed attempts within the same block timestamp. If the digest is intended as an event identifier rather than
only a semantic commitment, would a per-verification nonce also be needed?

I’m not suggesting that the event log itself loses its chain or emitter context—the question is specifically about the guarantees of verificationDigest
when it is stored or transported on its own. Would value your view on the intended scope.

1 Like

Real gap, and it’s not theoretical the moment verificationDigest is used standalone (which the draft explicitly allows via ERC-8183’s job.reason).

The two questions you’re asking are genuinely separate and shouldn’t get solved by the same field:

1. Domain separation (chainid/address(this)) — yes, this needs to go in. The current preimage is collision-safe only inside its accompanying event-inclusion proof, where chain+emitter are supplied externally. The instant it’s detached and used as a bare audit anchor, two IAgentVerifier deployments (different chains, or two instances on the same chain) can produce byte-identical digests from coincidentally-identical (taskId, agentId, inputHash, outputHash, valid, agentProofProfile, timestamp) tuples — and nothing in the digest itself tells a consumer which deployment vouched for it. That’s exactly what domain separation exists to close (same reasoning as EIP-712’s domainSeparator).

2. The nonce/uniqueness question is a different axis, not solved by (1) — domain separation stops cross-deployment collisions, it does nothing for two distinct failed attempts inside the same deployment with identical inputs at the same timestamp. If verificationDigest needs to double as an event identifier (not just a semantic commitment), that needs its own uniqueness source — a monotonic per-verifier nonce or the tx/log index — independent of whatever you decide on chainid/address.

Worth being precise about which one verificationDigest is actually for, because bundling both concerns into one field tends to produce a hash that’s neither a clean semantic commitment nor a reliable identifier. We hit an adjacent version of this on our own decision_ref (a content-addressed verdict hash) — we deliberately kept it scoped to “identity of the judgment,” and never let it double as a global event identifier; it always ships bound to its own signed event, which carries the pubkey/context that would otherwise need to be baked into the hash. Different construction, same underlying principle: don’t make one field answer two different questions.

1 Like

@HaoXuan40404 Thanks for catching this — it’s a real gap, and @babyblueviper1 you’re right that domain separation and uniqueness are two different axes that shouldn’t be solved by the same field.

I think domain separation does belong in the preimage. The moment verificationDigest can be detached from its event inclusion proof and used standalone (as the draft already allows via ERC-8183’s job.reason), a consumer has no way to know which deployment on which chain produced it.

One nuance worth raising: the verification layers in our stack — specifically OCP (ERC-8281) — are designed to be chain-agnostic. OCP’s record(bytes32) + event log pattern works on any chain; recompute doesn’t care what chain type the record lives on. So using Solidity’s native address type would tie the digest to EVM in a way that breaks that chain-agnostic property.

I’d propose two fields with chain-neutral types:

  • instanceId: bytes — identifies the verifier deployment. On Ethereum: abi.encodePacked(address(this)).
  • chainId: bytes — identifies the chain. On Ethereum: abi.encode(block.chainid).

Both are bytes at the spec level; EVM implementations use the encoding above. Non-EVM deployments use their own identifiers. The base preimage becomes:

keccak256(abi.encode(
    taskId, agentId, inputHash, outputHash,
    valid, agentProofProfile, block.timestamp,
    instanceId, chainId
))

On uniqueness / nonce — I’d keep it OPTIONAL. The base preimage gives you a semantic commitment (“this verification event, on this chain, by this deployment”). If someone also needs it to double as a per-deployment event identifier, they can fold in a monotonic nonce — but the spec shouldn’t force that on deployments that only need the commitment.

Curious what you both think before touching the draft. And thanks again for the careful read — this is exactly the kind of thing worth catching before the spec moves further :folded_hands:

The bytes instanceId / bytes chainId split is the right call, and for a reason beyond chain-agnosticism: it also keeps the field forward-compatible with verifier deployments that don’t have a canonical “address” at all (a TEE enclave measurement, a threshold-signing committee’s aggregate pubkey) — abi.encodePacked(address(this)) is one valid encoding, not the only one, which matches how we treat our own verifier_pubkey (a stable identifier checked against a rotation manifest, not assumed to be a single fixed on-chain address forever).

On the optional nonce: agree with keeping it out of the base preimage, and I’d go further — don’t standardize the nonce’s source even as an optional extension. The moment two failed attempts at the same timestamp need to be told apart, the deployment already has something better than a manufactured nonce: the event’s own inclusion proof (tx hash + log index, or whatever the chain-agnostic equivalent is). Minting a separate nonce field risks becoming a second, weaker identity for the same event that can drift from the “real” one if a re-org or replay ever produces two events with the same digest+nonce pair. If verificationDigest needs to double as an identifier, the honest fix is “bind it to its inclusion proof,” not “add another field to the hash” — same principle as why our own decision_ref never tries to be a global event id on its own, it always ships attached to the signed event that already carries a real one (a NIP-01 event id, in our case).

Thanks both — I agree with the split: the base verificationDigest should be a domain-bound semantic commitment, while exact occurrence identity should come
from the corresponding inclusion proof rather than a mandatory nonce.

Before changing the draft, I think four details are worth making explicit:

  1. chainId and instanceId need canonical namespaces and byte encodings; bytes alone does not prevent different ecosystems from assigning the same byte
    string different meanings.
  2. instanceId should identify the outer verifier/result issuer, while agentProofProfile identifies the inner proof backend and configuration. Otherwise
    enclave measurements or committee keys may overlap with agentProofProfile.
  3. VerificationCompleted should carry both new fields so the digest remains independently recomputable from the event.
  4. If nonce is excluded, wording such as “unique per event” should be replaced with “domain-bound semantic commitment,” and consumers requiring exact
    occurrence identity should be required to pair it with an inclusion proof.

Point 2 (instanceId = outer issuer, agentProofProfile = inner backend) maps to something we already keep separate in our own construction: `source_class` classifies WHO/HOW a verdict was issued (self-attested / independent-mediator / etc.) and is a distinct field from `policy_version` (which rubric/config produced the verdict) – we learned the same lesson the hard way, collapsing issuer-identity and method-configuration into one field made it impossible to tell “a different party judged this” from “the same party judged it under a different config.” Same shape as your enclave-measurement-vs-agentProofProfile overlap concern, just one layer up the stack. Agree with all four points, including 4 – “domain-bound semantic commitment” is the honest description once nonce is out of the base preimage, and coupling exact-occurrence claims to the inclusion proof rather than the hash keeps the digest doing one job.

Thanks — that source_class / policy_version distinction is a useful parallel and matches the separation I had in mind.

It sounds like we’re aligned on the semantic model: instanceId identifies the outer issuer, agentProofProfile identifies the inner verification profile, and
exact occurrence identity comes from the inclusion proof.

Happy to review the draft delta once it’s pushed, especially the canonical encodings, event fields, and updated uniqueness wording.

All four land, and the last one is the one I’d flag as load-bearing, not just wording: “domain-bound semantic commitment” instead of “unique per event” isn’t a softer claim written differently, it’s the honest name for what the field actually guarantees once nonce is optional. Calling it “unique” when uniqueness depends on an optional field a consumer might not have is the exact gap that bites later — a verifier that always includes the inclusion proof never notices, one that occasionally strips it for a lighter audit trail finds out the hard way.

On canonical namespaces/encodings (point 1): worth being exhaustive rather than closing this off after the obvious cases. We hit the same problem naming our own verifier identity – abi.encodePacked(address(this)) covers a contract-based verifier fine, but silently fails for a TEE enclave measurement or a threshold-signing committee’s aggregate pubkey, neither of which has a canonical on-chain address. What worked for us: enumerate the encoding by verifier class (contract-address / enclave-measurement / committee-pubkey / …) rather than assuming one shape and bolting on exceptions later – costs a small tag byte, buys real forward-compat.

instanceId/agentProofProfile split (point 2) is the right cut for exactly the reason you gave – an enclave measurement IS a proof backend’s config, and forcing it to also serve as the outer issuer identity is where two unrelated axes collide again.

@HaoXuan40404 Pushed the draft delta — thanks again for the thorough review.

Changes:

  • verificationDigest preimage now includes instanceId (bytes) and chainId (bytes). On Ethereum: abi.encodePacked(address(this)) and abi.encode(block.chainid). Other verifier classes encode per their own canonical form.
  • Wording updated: “unique per event” → “domain-bound semantic commitment.” Exact occurrence identity stays with the inclusion proof, not the digest.
  • @babyblueviper1’s verifier-class encoding approach folded in — instanceId encoding is determined by verifier class, identified from agentProofProfile. Same pattern as your source_class / policy_version split on the verdict side.

Let me know if the canonical encoding and event field changes read cleanly. Happy to iterate :folded_hands:

Commit: feat(erc-8274): add domain separation to verificationDigest · JimmyShi22/ERCs@9e87261 · GitHub

@babyblueviper1 Your point on nonce vs inclusion proof in #133 really got to the heart of it — a per-contract nonce deduplicates but doesn’t survive a reorg, so it’s not the full answer. Digging into why led to something that feels like a genuine EVM-level gap.

Contracts can’t access txHash or log index during execution — those values simply don’t exist in the Solidity environment. So a credential generated and hashed inside the contract can’t include them, and there’s no way to make it reorg-safe from within the EVM alone. That seems like a protocol limitation worth surfacing, not just an application-layer tradeoff.

Started a discussion to see if others have run into this: Deterministic Random Number in EVM for Independent Recomputability — feels like this could become an EIP.

@TMerlini You’d hit this from the OCP consumer side too — the {chainId, instanceId, digest} pattern gets you two dimensions, but the third one (reorg-safe uniqueness inside the contract) is the one the EVM doesn’t hand you. Same gap, just seen from the receipt layer.

@HaoXuan40404 The domain-separation analysis in #130 is what kicked this whole line of thought off — really appreciated :folded_hands:

Reads cleanly, and the encoding-by-verifier-class landing in the actual commit (not just agreed-in-principle) is the part that matters – checked 9e87261 directly. Tying instanceId’s encoding to agentProofProfile rather than assuming one shape is exactly the forward-compat move; a threshold-signing committee’s aggregate pubkey or an enclave measurement both fall out of that cleanly without a future amendment.

“Domain-bound semantic commitment” over “unique per event” is the more honest name – glad it read that way rather than as pedantry. That’s the kind of wording fix that only pays off months later when someone builds against the spec text literally.

Replied on the EVM-gap thread you opened (t/29098/13 for the pointer, t/25098/344 for a related worked reference on the 8274/8350 composition side) – the reframe I’d offer there: does your system need the identifier synchronously inside the same tx, or can the canonical version exist only post-finality? If the latter, you may not need a new opcode at all.

@babyblueviper1 @JimmyShi22 Thanks — I checked 9e87261 directly.

The main delta reads cleanly: instanceId and chainId are present in the preimage, event, and reference implementation, and “domain-bound semantic commitment” now accurately describes what the digest guarantees. The original detached-digest issue looks resolved.

One remaining canonical-decoding question:

The draft says that instanceId encoding is determined by the verifier class, and that consumers identify that class from agentProofProfile. But proofProfile() is currently defined as an opaque bytes32 deployment fingerprint; there is no normative class tag or mapping that lets a detached consumer decode the class from that value alone.

There also seems to be a small boundary issue with the TEE example. As noted in #133, an enclave measurement describes backend configuration and may be shared by multiple enclave instances, so it appears to belong in agentProofProfile rather than serving as the outer issuer identity.

Would it be cleaner for instanceId itself to be self-describing, for example:

  • contract verifier: classTag || canonical contract address
  • TEE verifier: classTag || attestation/signing identity
  • committee verifier: classTag || aggregate public key

with the enclave measurement remaining part of agentProofProfile?

Similarly, if chainId: bytes is intended to be chain-neutral beyond EVM chains, should it include a normative namespace plus canonical chain reference?

That would let a detached consumer interpret both domain fields without relying on an external profile registry. Everything else in the commit looks resolved from my side.

I’ll keep the synchronous-vs-post-finality occurrence-identity question in the separate EVM thread.

Both proposals are right, and both are cheap to adopt because instanceId and chainId are already bytes, not bytes32 – there’s no fixed-width budget fight to resolve first.

Self-describing instanceId: agree, and I’d pin the exact shape rather than leave “classTag” abstract – a fixed 1-byte class enum prefix, then the type payload, no varint/length-prefix needed since decoding a known class’s payload length is unambiguous once you have the class:

  • 0x01 || address (contract verifier, 20 bytes after the tag)
  • 0x02 || attestation/signing identity (TEE verifier)
  • 0x03 || aggregate pubkey (committee verifier)

That removes the external-registry dependency entirely – a detached consumer decodes instanceId[0] and knows how to parse the rest, no agentProofProfile lookup needed just to know the shape. agentProofProfile still does its real job (distinguishing deployments/circuit versions/enclave measurements within a class), it just stops being asked to also carry shape information it was never defined to carry.

TEE boundary: agreed cleanly – proofProfile()'s own definition already says it exists to “distinguish between… two tee/nitro verifiers with different enclave measurements,” so the enclave measurement belongs there by the spec’s own definition, not as the TEE instanceId’s identity. instanceId should be the instance-specific attestation/signing identity (what makes this instance callable), agentProofProfile the shared backend config (what makes it trustworthy). Two different questions, shouldn’t collapse into one field.

chainId namespacing: agree it should be explicit rather than implicit-EVM. Rather than invent a new namespace format, I’d look at CAIP-2 (namespace:reference, e.g. eip155:1 for Ethereum mainnet, solana:5eykt4... for Solana) – it’s a real, existing spec for exactly this problem (adopted by WalletConnect and some multichain wallet tooling), not something I’d claim broad ecosystem consensus on, but reusing an existing namespace format beats inventing a new one, and it means a consumer that already parses CAIP-2 elsewhere gets this for free.

Net effect of all three: a detached consumer holding just (agentProofProfile, instanceId, chainId) – no external registry, no prior context – can fully decode who verified this and where. That’s the same bar this ERC’s own audit-trail framing is already reaching for; these three fixes just close the last gaps where “self-describing” quietly meant “self-describing if you already have the registry.”

Happy to draft the exact Solidity + spec-text diff for the classTag enum + CAIP-2 chainId if that’s useful – small, additive, no breaking change to the existing digest fields.

Thanks — this resolves the conceptual boundary for me, and a concrete Solidity + spec-text diff would be useful.

One small serialization detail worth capturing in the diff: 0x02 and 0x03 identify broad verifier classes, but attestation/signing identities and aggregate public keys can have different lengths and canonical encodings depending on the cryptographic scheme.

Would it make sense to use either:

  • classTag || schemeTag || canonicalPayload, or
  • scheme-specific class tags with normative payload lengths and encodings?

That would preserve the no-registry property while keeping decoding unambiguous across different TEE and committee key schemes.

For chainId, specifying the exact byte encoding of the CAIP-2 identifier — for example UTF-8 bytes of eip155:1 — would also make recomputation deterministic. Since this replaces the current abi.encode(block.chainid) representation, it may be worth noting that the Draft digest encoding changes even though the ABI field type remains bytes.

Other than that, the direction reads cleanly. Happy to review the diff once it is posted.

Going with classTag || schemeTag || canonicalPayload — the tag namespace should scale with the cryptography, not fork the class. A TEE using SGX ECDSA-P256 attestation and a TEE using AWS Nitro’s secp256k1 attestation are both still class 0x02 in every way a consumer cares about (hardware-attested proof); they only differ in payload length/encoding. Folding that difference into new top-level classTags means the class enum grows every time a new hardware/curve combo ships, and a consumer that only wants to ask “is this hardware-attested” has to enumerate every scheme variant to answer a class-level question. A 1-byte schemeTag keeps that separation without growing the class enum:

// instanceId encoding: classTag (1 byte) || schemeTag (1 byte) || canonicalPayload
//
// classTag 0x01 — contract verifier
//   schemeTag 0x00 (reserved, no scheme variance)      payload: 20-byte address
//
// classTag 0x02 — TEE / hardware attestation
//   schemeTag 0x01 — SGX, ECDSA-P256 identity          payload: 33 bytes (compressed pubkey)
//   schemeTag 0x02 — AWS Nitro, secp256k1 identity     payload: 33 bytes (compressed pubkey)
//   (new hardware/curve combos register a new schemeTag under 0x02, not a new classTag)
//
// classTag 0x03 — threshold-signing committee
//   schemeTag 0x01 — BLS12-381 aggregate pubkey        payload: 48 bytes (compressed G1)
//   schemeTag 0x02 — Ed25519 multisig root             payload: 32 bytes

Spec-text diff against the current instanceId: bytes bullet:

- **`instanceId: bytes`** — identifies the verifier deployment. On Ethereum:
- `abi.encodePacked(address(this))`. Other verifier classes (TEE enclave measurement,
- threshold-signing committee aggregate pubkey) use their own canonical encoding; the
- encoding is determined by the verifier class, and a consumer identifies the class
- from `agentProofProfile`.
+ **`instanceId: bytes`** — self-describing verifier-instance identifier, encoded as
+ `classTag (1 byte) || schemeTag (1 byte) || canonicalPayload`. `classTag` identifies
+ the verifier category; `schemeTag` identifies the cryptographic scheme within that
+ category (a new scheme registers a new `schemeTag`, not a new `classTag`);
+ `canonicalPayload`'s length and encoding are normatively fixed per
+ `(classTag, schemeTag)` pair. On Ethereum: `classTag = 0x01, schemeTag = 0x00`,
+ payload = `abi.encodePacked(address(this))` (20 bytes). A detached consumer decodes
+ `instanceId[0:2]` and knows how to parse the remainder — no external registry, no
+ lookup against `agentProofProfile` needed just to learn the shape.
+ `agentProofProfile` keeps its original, narrower job — distinguishing deployments
+ *within* a fixed (class, scheme) pair (circuit version, enclave measurement) — and
+ is no longer asked to also carry class information it was never defined to carry.

For chainId, pinning the exact bytes rather than leaving the CAIP-2 encoding implicit:

- **`chainId: bytes`** — identifies the chain. On Ethereum: `abi.encode(block.chainid)`.
+ **`chainId: bytes`** — identifies the chain as the UTF-8 byte encoding of its CAIP-2
+ identifier (`namespace:reference`, e.g. `eip155:1` for Ethereum mainnet). On Ethereum:
+ `bytes(string.concat("eip155:", Strings.toString(block.chainid)))`.

Flagging plainly since you raised it: this is a digest-encoding change, not a documentation clarification. chainId moves from abi.encode(block.chainid) (32-byte, zero-padded uint256) to UTF-8 CAIP-2 bytes (variable-length ASCII) — the bytes ABI type stays the same, but every digest computed after adopting CAIP-2 encoding is a different value than one computed today under the current wording. Should land as one atomic edit with a version note, not a silent follow-up that breaks digests computed against the current draft text.

Reference snippet for both together:

library InstanceId {
    function encodeEthereumContract(address verifier) internal pure returns (bytes memory) {
        return abi.encodePacked(bytes1(0x01), bytes1(0x00), verifier);
    }
}

library ChainId {
    function encodeCAIP2Eth(uint256 chainid) internal pure returns (bytes memory) {
        return bytes(string.concat("eip155:", Strings.toString(chainid)));
    }
}

Happy to open this as an actual PR against the draft if that’s easier to review than a forum diff.

Opened it as a real PR rather than leaving it as a forum diff: feat(erc-8274): self-describing instanceId (classTag||schemeTag) + CAIP-2 chainId by babyblueviper1 · Pull Request #3 · JimmyShi22/ERCs · GitHub — instanceId classTag/schemeTag encoding + CAIP-2 chainId, reference implementation updated to match (dependency-free decimal-ASCII helper, no new imports). Happy to adjust anything before merge.

@HaoXuan40404 @babyblueviper1 Really appreciated the deep thinking on the instanceId encoding — the classTag/schemeTag breakdown and the CAIP-2 chainId proposal were both sharp. It was exactly that depth that made me step back and realise the field itself was asking the wrong question.

instanceId should do one thing: identify which verifier deployment instance emitted this verification. That’s it. On Ethereum: abi.encodePacked(address(this)). On Solana: the program address in its canonical encoding. The proof backend type (TEE / zkML / opML / …) and its specific configuration are already covered by proofSystem() and agentProofProfile — they don’t need to also live in instanceId.

So the narrowed definition would be:

instanceId = the deployment instance’s canonical identifier, encoded per-chain. On Ethereum, that’s abi.encodePacked(address(this)). No classTag, no schemeTag, no enclave measurement or committee pubkey here. One job, one encoding.

The profile-level richness stays where it already is — in agentProofProfile — and the issuer-identity vs proof-backend separation stays clean.

Would this narrower scope make sense to you both? Happy to take an updated PR and merge quickly.