Skip to content

feat: add ZkTlsAttestationHook#46

Open
xiangxiecrypto wants to merge 3 commits into
erc-8183:mainfrom
xiangxiecrypto:feature/zktls-attestation-hook
Open

feat: add ZkTlsAttestationHook#46
xiangxiecrypto wants to merge 3 commits into
erc-8183:mainfrom
xiangxiecrypto:feature/zktls-attestation-hook

Conversation

@xiangxiecrypto
Copy link
Copy Markdown

Summary

Profile A (Simple Policy) hook that cryptographically binds an ERC-8183 job's deliverable to one or more zkTLS attestations of the off-chain HTTPS calls the provider promised to make. The client pins, at fund time, each step's URL / method / body / response shape and any cross-step value bindings; the provider attaches one zkTLS attestation per step at submit; the hook routes every attestation through a pluggable IZkTlsVerifier, enforces the pinned shape, and binds the deliverable to the parsed response.

Use case

When the provider in an ERC-8183 job is an AI agent calling external HTTPS APIs (price oracles, LLMs, fact endpoints, …), the protocol has no on-chain trace of those calls. The evaluator has to either trust the provider's word or duplicate the work themselves. zkTLS closes that gap: a cryptographic protocol allows the agents to prove "this request really happened, this is the response." This hook binds that proof to the on-chain deliverable, with no modification to the ERC-8183 core.

Flow

  1. createJob(provider, evaluator, expiredAt, description, hook=this, …)
  2. fund(jobId, expectedBudget, optParams=abi.encode(AttestationSpec))
    _postFund stores the spec immutably (client-only by core semantics).
  3. Off-chain: provider drives each step's HTTPS call through a zkTLS attestor and collects one Attestation per step.
  4. submit(jobId, deliverable, optParams=abi.encode(Attestation[], bytes))
    _preSubmit:
    • for each step i, IZkTlsVerifier.verifyAttestation(atts[i]) via staticcall, then enforce every pinned-field hash + timestamp window + pinnedAttestor (if set);
    • for each binding, assert the declared bytes appear in both atts[fromStep].data and atts[toStep].request.{url|header|body};
    • bind the deliverable: keccak256(atts[sourceStep].data) == deliverable;
    • if customVerifier != 0, delegate to it for business-level checks via staticcall.
  5. complete / reject — normal flow.

Trust model

The hook validates attestation shape and binding only:

  • Every step produced a valid zkTLS attestation per the pluggable verifier.
  • URLs, methods, bodies, response-resolve arrays, and additionParams hash to what the spec pinned.
  • Static binding values declared in the spec appear in both source and destination attestations.
  • The deliverable hash binds to the source step's parsed data.

The hook does not enforce:

  • Semantic correctness of returned data — use customVerifier for ranges / thresholds / cross-field arithmetic.
  • Dynamic per-execution data flow — bindings carry static expected bytes.
  • Authentication of the external API beyond what the zkTLS verifier itself attests to (TLS PKI + the verifier's attestor set).

Per-job spec is frozen at fund time. The hook is immutable; to change behaviour, deploy a new hook and rotate the whitelist.

Compatibility

  • Profile A — no token custody, no external mutating functions beyond the inherited hook callbacks.
  • Inherits BaseERC8183Hook, implements IERC8183HookMetadata, ERC-165-advertised.
  • requiredSelectors() returns fund + submit — both must be wired together when used behind MultiHookRouter.
  • Single .sol file; all custom structs / interfaces / errors inlined; only imports from this repo are BaseERC8183Hook and IERC8183HookMetadata.
  • Vendor-neutral: the verifier is referenced through an inline IZkTlsVerifier interface; the Attestation struct uses the de facto on-chain layout (the reponseResolve spelling is preserved for ABI compatibility with already-deployed verifiers).
  • forge build compiles cleanly. Adds a row to the README.md Hook Examples table per the contribution rules.

Full toolkit, SDK, tests, and live deployment

For end-to-end validation against a real zkTLS provider (Primus), an off-chain SDK that builds and encodes the AttestationSpec, a reference IAttestationExtensionVerifier, deployment scripts, and a complete test suite (33 unit + 16 integration + 6 fork + on-chain E2E), see the companion toolkit:

👉 https://github.com/xiangxiecrypto/primus-zktls-hook-toolkit

The toolkit holds everything the upstream rules say doesn't belong in a hook PR (tests, fixtures, SDK, vendor-specific wiring), wired against the deployed Primus PrimusZKTLS verifier on Base Sepolia. Five completed on-chain job lifecycles cover single-step, multi-step, LLM, and customVerifier scenarios.

xiangxiecrypto and others added 2 commits May 12, 2026 17:01
Profile A hook that binds a job's deliverable to one or more zkTLS
attestations of the off-chain HTTPS calls the provider promised to make.
Client pins, at fund time, each step's URL/method/body/response shape and
cross-step static value bindings; the provider attaches one attestation
per step at submit; the hook routes each through a pluggable IZkTlsVerifier,
enforces the pinned shape, and binds the deliverable to the parsed
response. Optional IAttestationExtensionVerifier for business-level checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zkTLS attestors (Primus and the rest of the deployed ecosystem) stamp
attestations with millisecond timestamps, but the original check compared
against block.timestamp in seconds — every real attestation tripped the
underflow guard and reverted AttestationStale.

Divide att.timestamp by 1000 before the compare. maxAge stays in seconds
so the spec interface is unchanged. Found by an end-to-end test against
a real Primus attestation on Base Sepolia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xiangxiecrypto xiangxiecrypto changed the title Add ZkTlsAttestationHook feat: add ZkTlsAttestationHook May 14, 2026
@ariessa
Copy link
Copy Markdown
Collaborator

ariessa commented May 14, 2026

Hi @xiangxiecrypto,

Thank you for the hook PR. The lifecycle wiring is correct. The trust posture is open by default in a few places. I would like to see those closed before this lands. None of this needs a rewrite.

Please address the following items:

  1. Bind every attestation to this job. Right now an attestation that satisfies the spec for one job also satisfies it for another. Please require the attestor to embed keccak256(jobId, address(this), block.chainid) inside attConditions or additionParams. Add a bytes32 expectedJobBinding field on RequestStep. Check it on chain with the existing _contains helper.

  2. Reject EOA verifier addresses. An EOA returns success from staticcall with empty returndata. That looks like a pass. Add a _requireContract helper that reverts when extcodesize is zero. Call it in the constructor for zkTlsVerifier. Call it again in _postFund for customVerifier. An ERC165 check on top of that is nice but optional.

  3. Flip the maxAge default. Treating zero as "no check" is the wrong direction. Please reject zero in _postFund. Require a positive value. Add an upper bound such as 24 hours so a long replay window cannot be set by accident.

  4. Replace single attestor pinning with a set check. The current path requires exactly one attestor. That excludes quorum verifiers. Replace pinnedAttestor with address[] allowedAttestors and uint8 minAttestorsRequired. Count how many signers fall inside the allowlist. Revert if the count is below the minimum.

  5. Reject empty optParams in _postFund. Right now an empty value is a silent no op. The funds escrow and the submit path is bricked until refund. Please revert with SpecRequired instead. If you want an opt out mode, use an explicit sentinel byte.

  6. Curate customVerifier through an allowlist. Today the client can supply any address as customVerifier. The provider only finds out it is junk at submit time. Add an Ownable allowlist called trustedExtensionVerifiers. Reject any address that is neither zero nor on the allowlist.

  7. Move the timestamp unit into the spec. The hardcoded division by 1000 only works for millisecond attestors. Add a TimeUnit enum to RequestStep. Pick the divisor based on the enum. Add a small forward skew tolerance of a few seconds so close clock drift does not trip AttestationStale.

  8. Make data bindings dynamic. The static bytes value field can only assert constants the spec author already knew. The multi step pipeline use case is not actually expressible. Add a bytes fromExtractKey field that names a keyName in atts[fromStep].reponseResolve. Extract the value from the source attestation at submit time. Then check the substring is present in the destination.

  9. Add boundary checks to substring matching. Positional agnostic search false positives on adjacent keys. For example "price":"100" matches "price":"1000". Add a _containsBounded variant. Require that the match is bracketed by characters in a small set such as ", ,, }, :, &, and =.

  10. Provide a verifier rotation path. The current immutability asks clients to trust the deployer forever. There is no escape hatch if the verifier needs a fix. Replace the immutable with an Ownable two step rotation. Add proposeZkTlsVerifier that sets a pending address and a ready timestamp. Add activateZkTlsVerifier that promotes the pending address after a delay such as seven days. Snapshot the verifier address into the per job spec at _postFund time. Use the snapshot in _preSubmit.

  11. Reject unsatisfiable specs at config time. A step with all three of methodHash, urlHash, and bodyHash set to zero is almost always a misconfiguration. Require that at least one of the three is non zero in _postFund. The failure should surface at fund time, not at submit time.

  12. Drop the AlreadyValidated check. The core state machine already blocks resubmission. The check at the top of _preSubmit is dead code. Remove it. Keep the envelopeCommitments[jobId] write for indexing.

If any of these items has a design reason I have missed, please reply on the thread before changing the code. I would rather update my review than have you write a fix you do not agree with.

Copy link
Copy Markdown
Collaborator

@ariessa ariessa left a comment

Choose a reason for hiding this comment

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

Please address the comments above

@xiangxiecrypto
Copy link
Copy Markdown
Author

xiangxiecrypto commented May 18, 2026

@ariessa Thanks for the thorough review. Accepting all 12; proceeding with implementation. Quick confirmation of what I'm doing per item, so you can flag any misreads before the push:

  1. RequestStep.expectedJobBinding = keccak256(jobId, address(this), block.chainid). Provider embeds it in additionParams (we already control that field; attConditions is auto-filled by Primus). Hook checks via _contains.

  2. _requireContract (extcodesize). Called in constructor for zkTlsVerifier; in _postFund for customVerifier.

  3. _postFund requires maxAge ∈ [1, 24h].

  4. Replace pinnedAttestor with allowedAttestors[] + minAttestorsRequired. Hook counts signers ∩ allowlist, reverts if below the minimum.

  5. Empty optParams in _postFund reverts SpecRequired. No silent no-op.

  6. Ownable trustedExtensionVerifiers allowlist; customVerifier{address(0)} ∪ allowlist.

  7. RequestStep.timeUnit enum (Seconds | Milliseconds). Divisor follows the enum. Small forward-skew tolerance on the staleness check.

  8. DataBinding.fromExtractKey: at submit, look up the keyName's parsed value from atts[fromStep].data and use it as the substring to check against the destination.

  9. _containsBounded requires the match to be bracketed by ", , } : & =. Used in binding validation.

  10. Two-step rotation on zkTlsVerifier: proposeZkTlsVerifier + 7-day delay + activateZkTlsVerifier. Snapshot the active address into each job's spec at _postFund; _preSubmit reads from the snapshot.

  11. _postFund requires at least one of methodHash / urlHash / bodyHash non-zero per step.

  12. Drop AlreadyValidated. Keep the envelopeCommitments[jobId] write for indexing.

Will push as a single follow-up commit with failing-then-passing tests added for each path. Toolkit (SDK + docs + on-chain demos) will be updated to match in a separate PR on the companion repo.

1.  Cross-job replay defense
    - RequestStep.expectedJobBinding stores keccak256(jobId, hook, chainid)
    - _postFund verifies the spec's binding equals what the hook computes
    - _verifyOneStep checks the ASCII-hex binding appears in att.additionParams

2.  EOA-verifier rejection
    - _requireContract (extcodesize) helper
    - Called in constructor for zkTlsVerifier
    - Called in _postFund for customVerifier (when not address(0))
    - Called in setTrustedExtensionVerifier(verifier, true)
    - Called in proposeZkTlsVerifier

3.  maxAge bounds
    - MIN_MAX_AGE = 1, MAX_MAX_AGE = 24h
    - _postFund rejects out-of-range values per step

4.  Quorum allowlist replaces single pinnedAttestor
    - RequestStep.allowedAttestors[] + minAttestorsRequired
    - MAX_ATTESTORS_PER_STEP = 8 to bound the O(N*M) loop
    - _verifyOneStep counts attestation.attestors ∩ allowedAttestors

5.  Empty optParams in _postFund reverts SpecRequired
    - No more silent no-op leaving the escrow bricked

6.  Owner-curated customVerifier allowlist
    - trustedExtensionVerifiers mapping + onlyOwner setter
    - _postFund rejects ExtensionVerifierNotTrusted when spec sets a
      customVerifier not on the allowlist

7.  TimeUnit enum on RequestStep
    - Seconds | Milliseconds; divisor follows the enum
    - FORWARD_SKEW_TOLERANCE (30s) on the staleness check
    - Two-sided check: rejects both far-past and far-future timestamps

8.  Dynamic data bindings
    - DataBinding.fromExtractKey: keyName in atts[fromStep].data
    - _extractFieldValue handles JSON string values incl. escape sequences
    - Static `value` and dynamic `fromExtractKey` are mutually exclusive
    - _postFund enforces exactly one of them is non-empty

9.  _containsBounded with delimiter set
    - Replaces _contains in binding validation
    - Match must be bracketed by " , } : & = / (or by string edges)
    - Defends against false positives like "100" matching inside "1000"

10. Two-step verifier rotation
    - zkTlsVerifier is no longer immutable; owner can rotate
    - proposeZkTlsVerifier sets pending + 7-day activation timestamp
    - activateZkTlsVerifier promotes after the delay
    - Per-job snapshot in AttestationSpec.zkTlsVerifierSnapshot, written
      at _postFund; _preSubmit uses the snapshot so in-flight jobs are
      immune to rotation

11. Reject unsatisfiable specs at config time
    - _postFund requires at least one of methodHash / urlHash / bodyHash
      to be non-zero per step

12. Drop AlreadyValidated check
    - Core state machine already prevents resubmission
    - envelopeCommitments[jobId] write retained for off-chain indexing

Helper changes:
  + Ownable (OZ) — adds owner_ constructor arg
  + jobBindingFor(jobId) public view for SDK/spec-author convenience
  + getVerifierSnapshot(jobId) — splits getSpec to keep stack depth in budget

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants