FR EN

On-chain registry

The chain is Agreely's tamper-evidence layer. It records that a consent existed, that a revocation happened, that a company's identity was registered or rotated, that a consent document existed, and that the access log is intact, in a way no one (including Agreely) can quietly rewrite. It is proof, never the read path.

The chain is proof, not the read path

The synchronous check reads one row in Agreely's backend and never touches the chain, the indexer, or any anchor state. A grant or revoke is authoritative the instant it commits in the database; anchoring happens asynchronously afterward. So a revoke denies on the very next check with zero chain wait, and the chain still gives an auditor independent, tamper-evident proof of what happened.

What is anchored

Five kinds of data are written to Base mainnet (chain id 8453). Every payload is an opaque bytes32 value -- a root, hash, or commitment -- never PII, never a citizen DID string or identifier.

  1. Consent -- the Merkle root of a signed consent plus the per-cell consentRef covering each data-collection scope; revocations flip a ref from Active to Revoked. (anchorBatch / revoke)
  2. Company DID registry -- a mapping from domainHash (keccak256(company-domain)) to the company's operational DID string, signing-key fingerprint, and the IPFS CID of the current did.json. Supports register, rotate, and revoke. (registerCompanyDid / rotateCompanyDid / revokeCompanyDid, emitting CompanyDidRegistered / CompanyDidRotated / CompanyDidRevoked)
  3. Consent document -- keccak256( utf8(cid) ) of the published disclosure document's IPFS CID. An emit-only, edgeless anchor proving the document existed at a point in time, with no link to any consent or citizen. (anchorIdentity acting as document anchor, emitting IdentityAnchored)
  4. Access-log epoch roots -- a tamper-evident root over the periodic access-log epoch, equipping the Loi 25 audit-trail requirement. (anchorAuditRoot, emitting AuditRootAnchored) Each epoch root is also signed at seal time with the company's registered DID key, additively; see epoch signing and delegation.
  5. Cookie-consent epoch roots -- the same construction for the cookie banner's per-website consent log, in its own event namespace so the indexer never confuses them with access-log roots. (anchorCookieRoot, emitting CookieConsentRootAnchored)

Audit roots and cookie roots both carry a companyDidDomainHash attribution tag (the same keccak256(lowercased verified domain) the DID registry uses). It is a read tag, never authorization: it lets anyone query every root anchored for a company in a single eth_getLogs (both fields are indexed) with no off-chain cross-reference.

The citizen DID is NOT anchored

The citizen's identity -- the did:agreely:citizen:... identifier, the DID document, the public keys -- is never written to the chain. Citizen identity is off-chain and pseudonymous by design. Only opaque hashes and roots reach the chain: never a linkable identifier, never PII. The citizen's binding in a receipt is their WebAuthn signature, verifiable through the off-chain citizen DID resolver at /did/{did}, not an on-chain record. This is deliberate: putting a citizen identifier on-chain would break unlinkability and could constitute prohibited processing of personal data under Loi 25.

AgreelyRegistry

AgreelyRegistry is a small Solidity contract: a mapping of opaque keys, an onlyRelayer allowlist gated by Ownable2Step, and indexed events the off-chain indexer decodes verbatim. There is no ether, no external call, and no delegatecall, so reentrancy is structurally impossible. renounceOwnership is disabled, so the contract can never end up ownerless, and ownership transfer is two-step (propose, then accept).

A funded Agreely relayer hot wallet (never the citizen) submits the writes. Each one carries only opaque keys: a root, a commitment, or an edgeless bytes32, never PII, never a DID string.

anchorBatch

function anchorBatch(bytes32 merkleRoot, bytes32[] calldata consentRefs) external onlyRelayer

Anchors one consent: its signed Merkle root plus the per-cell consentRefs it covers. The merkleRoot is stored against each ref so a held receipt can verify cell inclusion on-chain. An already-Active ref is a no-op (no state write, no event), so a redelivered relayer job cannot churn state or duplicate a log. A Revoked ref can be re-anchored to Active, which is the re-grant path. The consentRefs[] argument exists so a future relayer-only coalescing window can batch several consents under one root with zero contract churn.

revoke

function revoke(bytes32 consentRef) external onlyRelayer

Flips an Active ref to Revoked. It reverts if the ref is not Active (never anchored, or already revoked), so a redelivered revoke job is caught by the relayer's pre-flight check rather than emitting a spurious second ConsentRevoked.

anchorIdentity

function anchorIdentity(bytes32 commitment) external onlyRelayer

Anchors an opaque bytes32 commitment. Emit-only: no mapping write, so the anchor never links to any consentRef. Currently routes two kinds of commitment: the consent document anchor (keccak256( utf8(cid) ) for a published disclosure's IPFS CID), and opaque commitments from citizen DID creation or rotation lifecycle events. Neither write carries a DID string, a customer identifier, or any linkable data.

anchorAuditRoot / anchorCookieRoot

function anchorAuditRoot(bytes32 root, bytes32 companyDidDomainHash) external onlyRelayer
function anchorCookieRoot(bytes32 root, bytes32 companyDidDomainHash) external onlyRelayer

Anchor an epoch root, for the access log and for cookie consent respectively. Emit-only, two distinct event namespaces. The second argument is the attribution tag described above: the contract accepts it even when the DID is unknown, revoked, or zero, precisely because it is not authorization. Authorization lives in epoch signing and delegation, off-chain and verifiable.

registerCompanyDid / rotateCompanyDid / revokeCompanyDid / reinstateCompanyDid

These methods form the company DID registry. registerCompanyDid stores the operational DID string, signing-key fingerprint (keyId), the raw public key, and the IPFS CID of the did.json, keyed by domainHash = keccak256(lowercased verified domain). rotateCompanyDid updates the key and CID for an existing domain. revokeCompanyDid marks the domain's registration as revoked. reinstateCompanyDid brings a revoked domain back under a new key (a fresh DNS proof is required off-chain) and emits its own event, distinct from a first-time registration, so a verifier can never mistake a reinstatement for an initial one.

A verifier derives the same domainHash from the receipt's did.json alsoKnownAs (the company domain) and queries the registry to confirm the key, DID string, and did.json CID are durably on-chain.

The on-chain didDocCid is also the anchor that binds the did.json (and therefore the root passkey it publishes under #root-1): a verifier confirms the did.json it reads hashes to that CID. And rotateCompanyDid is how a delegation revocation is realized: rotating the operational key moves it out of the on-chain key window, so epochs it signs after the rotation block no longer verify as authorized. See epoch signing and delegation.

State and events

enum State  { None, Active, Revoked, Superseded }
enum Status { None, Active, Revoked }

struct Anchor {
    bytes32 merkleRoot;
    uint64  anchoredAt;
    uint64  revokedAt;
    State   state;
}

event AnchorBatch(bytes32 indexed merkleRoot, uint256 refCount, uint64 timestamp);
event ConsentAnchored(bytes32 indexed consentRef, bytes32 indexed merkleRoot, uint64 timestamp);
event ConsentRevoked(bytes32 indexed consentRef, uint64 timestamp);
event ConsentReanchored(bytes32 indexed consentRef, bytes32 indexed newMerkleRoot, uint64 previousRevokedAt, uint64 timestamp);
event IdentityAnchored(bytes32 indexed commitment, uint64 timestamp);
event AuditRootAnchored(bytes32 indexed root, bytes32 indexed companyDidDomainHash, uint64 timestamp);
event CookieConsentRootAnchored(bytes32 indexed root, bytes32 indexed companyDidDomainHash, uint64 timestamp);
event CompanyDidRegistered(bytes32 indexed domainHash, string did, bytes32 keyId, string didDocCid, uint64 verifiedAt);
event CompanyDidRotated(bytes32 indexed domainHash, bytes32 newKeyId, string newDidDocCid, uint64 rotatedAt);
event CompanyDidRevoked(bytes32 indexed domainHash, uint64 timestamp);
event CompanyDidReinstated(bytes32 indexed domainHash, string did, bytes32 keyId, bytes32 previousKeyId, string didDocCid, uint64 verifiedAt);
event RelayerSet(address indexed relayer, bool authorized);

State.None is the default, so an unknown ref reads back as None. Superseded is reserved for renewal supersession. Status is the parallel enum for the company DID registry, on the same zero-default convention: an unknown domain reads back as None.

Four open read methods let any verifier query the contract directly: registry(consentRef) and stateOf(consentRef) for a consent, getCompanyDid(domainHash) and companyDidStatus(domainHash) for a company DID.

The off-chain indexer decodes AnchorBatch, ConsentAnchored, ConsentRevoked, IdentityAnchored, AuditRootAnchored, CookieConsentRootAnchored, CompanyDidRegistered, CompanyDidRotated, CompanyDidRevoked, CompanyDidReinstated, and RelayerSet. reanchorConsent / ConsentReanchored exist in the deployed contract (the explicit re-grant path under a new root) but are not used by the application today. The anchor payloads carry only opaque bytes32 values (roots or commitments), never PII.

Company DID model: three layers

Several users have been confused about how company identity works. Here is the plain-language explanation of the two company DIDs and the three verifiable layers that anchor them.

The two DIDs

A company in Agreely has two DIDs at different points in the self-custody spectrum.

Operational DID (active today): did:web:agreely.ca:c:<slug>

This is the DID Agreely hosts on the company's behalf. It is the id in the company's did.json, the issuer on every signed receipt, and the DID string stored in the on-chain registry. Its DID document (the did.json with the company's public key) is served at https://agreely.ca/c/<slug>/did.json. Any verifier resolves it with a plain HTTPS GET, no special infrastructure needed.

Reserved DID (future, not yet active): did:web:<company-domain>

This is a placeholder reserved for a future self-hosted upgrade. Once a company serves their own did.json at https://<company-domain>/.well-known/did.json, they can take full custody of their identifier. Today this DID is not resolved or signed with. It appears only in the operational did.json's alsoKnownAs field, as the domain anchor the verifier uses to derive domainHash. No did.json is served at the company's own domain today. Do not claim the self-hosted model is live; it is reserved for a future sovereignty upgrade.

The company domain is the identity anchor either way: the DNS TXT record, the on-chain domainHash, and the did.json alsoKnownAs all reference it.

Layer 1: DNS TXT record (domain-control proof)

The company publishes one permanent TXT record in their own DNS:

Host:    _agreely.<company-domain>
Value:   agreely-did=did:web:agreely.ca:c:<slug>; key=<sha256-fingerprint>[; root=<0x + keccak of the root COSE key>]

This record is the company's own declaration of which DID and key are theirs. Agreely verifies the record before anything goes on-chain: no valid TXT record means no on-chain registration, no resolvable DID document. The key fingerprint in the TXT (key=...) is the same keyId stored in the on-chain registry, so the DNS declaration, the DID document, and the registry all name one key.

The optional root= field appears only once the company has registered a root passkey: it commits the keccak thumbprint of that root key into the company's own DNS zone, which binds the root to the company (not to a mere Agreely assertion). A company with no root passkey publishes no root= and verifies exactly as before. See epoch signing and delegation.

Layer 2: DID document (did.json)

The did.json is the W3C DID Document containing the company's public signing key. Agreely hosts it at https://agreely.ca/c/<slug>/did.json -- this is the did:web resolution path. An IPFS copy is also pinned via Lighthouse and its CID is recorded on-chain.

IPFS is an anchored copy for durability and integrity, not the resolution path. did:web always resolves via HTTPS. The IPFS pin ensures the did.json snapshot is retrievable and tamper-evident independently of Agreely's server, and the on-chain CID lets a verifier confirm the pinned copy has not changed since registration.

The did.json alsoKnownAs field carries did:web:<company-domain> (the reserved DID). A verifier uses the company domain inside alsoKnownAs to derive domainHash and confirm it matches the on-chain record. This is a domain-binding check, not a DID resolution step.

Layer 3: On-chain registry

When the DNS TXT is verified, Agreely enqueues a registerCompanyDid transaction that stores:

Field Value
domainHash keccak256(lowercased company-domain) -- the opaque domain key
did The operational DID string (did:web:agreely.ca:c:<slug>)
keyId bytes32 SHA-256 fingerprint of the signing key (same as in DNS TXT)
didDocCid IPFS CID of the did.json snapshot at registration time

The verifier derives domainHash from the receipt's alsoKnownAs company domain and queries the registry. A match proves the company registered this key and DID durably on-chain, independent of Agreely's backend.

Deployment

The production registry is live on Base mainnet (chain id 8453):

Chain Base mainnet, chain id 8453
Contract 0x23577fAffA306375028D33a559D0F95Ced9424DB
Explorer basescan.org
Public read RPC https://mainnet.base.org

Production anchors are therefore on a real public network, not a test network. Base Sepolia (chain id 84532) served during the build and stays available for your own integration testing; it is no longer where production anchors live.

The chain is proof, never the read path, and it never gates the synchronous check.

Next