Oracle06

Attestations & registry

Every verdict is signed as EIP-712 typed data by the CompliR attester key, so anyone can check it without asking CompliR. The same signature is what the on-chain registry verifies when the attestation is mirrored to Arc, and the decisionHash inside it links back to the replayable ledger entry.

The struct

eip-712
EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
  name = "CompliR", version = "1", chainId = 5042, verifyingContract = AttestationRegistry

Attestation(address subject,uint8 tier,uint8 score,bytes32 decisionHash,uint64 issuedAt,uint64 validUntil)
  typehash = 0x86d4b56447401a0aa7aea88cef207cd6ff21bb50dd72f46b5e5baccf75e854db

tier: UNKNOWN=0 · LOW=1 · MEDIUM=2 · HIGH=3
decisionHash = keccak256(utf8(canonical(decision core)))   // sorted keys, no whitespace

The TypeScript signer (src/lib/attest/eip712.ts) and the Solidity registry must stay byte-identical; two Foundry tests pin the typehash and accept a viem-signed attestation on the Arc chain id.

Validity per tier

LOW
valid 7 days from issuedAt
MEDIUM
valid 24 hours
HIGH
valid 1 hour

Expiry is the whole point: a contract reading isAllowed never pays on a stale verdict, and a watcher re-screen refreshes the attestation before a watched LOW address runs out. attestation.validUntil always equals typedData.message.validUntil.

isAllowed on-chain

solidity
interface ICompliR {
    enum Tier { UNKNOWN, LOW, MEDIUM, HIGH }
    function tierOf(address subject) external view returns (Tier tier, uint64 validUntil, bytes32 decisionHash);
    function isAllowed(address subject, Tier maxTier) external view returns (bool);
    // isAllowed == tier != UNKNOWN && tier <= maxTier && validUntil > block.timestamp
    event Attested(address indexed subject, Tier tier, uint64 validUntil, bytes32 decisionHash);
    event TierChanged(address indexed subject, Tier from, Tier to);
    event Anchored(bytes32 indexed root, uint256 fromSeq, uint256 toSeq);
}

tierOf returns the raw stored tier; callers must check validUntil themselves. isAllowed folds the three conditions into one call and is what GuardedTreasury uses. UNKNOWN (never attested, or revoked) never passes.

Registry on Arc mainnet: 0xA82EDb5e111c31C63E06EF0007f2fa1a9e7EB30d live · verified. verifyingContract in the EIP-712 domain is this address; every verdict is mirrored with attest() (first one: tx) and registryTx fills in once it lands. Also published at /api/v1/health.

Verify offline

ts
import { CompliR } from "@complir/sdk";

const ok = await CompliR.verifyAttestation(verdict.attestation, {
  expectedAttester: "0x…",      // from GET /api/v1/health → signer, or the registry's attester()
  expectedChainId: 5042,
  // ignoreExpiry: true          // when auditing history
});
// checks: primaryType, domain name/version, field list, tier ∈ {1,2,3}, score 0–100,
// validUntil > issuedAt and == att.validUntil, bytes32 decisionHash, then viem verifyTypedData.

The MCP tool verify_attestation does the same check and returns the decoded subject, tier, score, decisionHash and validUntil. Three steps make a full audit:

  1. Recover the signer from typedData + signature and compare with the published attester.
  2. Call tierOf(subject) on the registry: the stored decisionHash must equal typedData.message.decisionHash (once the registry is live).
  3. Open GET /api/v1/decision/:decisionId (or /ledger → /log/:id): the payload hashes into the chain and its batch anchor tx is on Arc.

Mirroring and anchoring

  • attest() is permissionless. The signature is the authority, so any relayer may submit it. With SIGNER_MODE=local the attester key sends the tx and pays USDC gas; with SIGNER_MODE=circle the Circle ops wallet relays it (Gas Station-sponsored when a mainnet policy is active) and falls back to local on any failure.
  • Writes never block a verdict. The registry write is fired after the response; registryTx is updated in the store when it lands and shows on the attestation endpoint and in webhooks.
  • Duplicate protection. The contract rejects a second write of the same decisionHash (DecisionHashUsed), so a relay timeout followed by a local retry is safe.
  • anchorRoot() is onlyAttester. Every ANCHOR_EVERY_N (default 50) ledger entries, their Merkle root is written with the sequence range and an ANCHOR entry is appended. See Ledger & reports.