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

Four kinds of data are written to Base Sepolia. 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, satisfying the Loi 25 audit-trail requirement. (AuditRootAnchored)

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 Ownable, and indexed events the off-chain indexer decodes verbatim. There is no ether, no external call, and no delegatecall, so reentrancy is structurally impossible.

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.

registerCompanyDid / rotateCompanyDid / revokeCompanyDid

These methods form the company DID registry introduced in the 2026-07-04 redeployment. registerCompanyDid stores the operational DID string, signing-key fingerprint (keyId), 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.

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.

State and events

enum State { None, Active, Revoked, Superseded }

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

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 IdentityAnchored(bytes32 indexed commitment, uint64 timestamp);
event AuditRootAnchored(bytes32 indexed epochRoot, 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 RelayerSet(address indexed relayer, bool allowed);

State.None is the default, so an unknown ref reads back as None. Superseded is reserved for re-anchor/renewal supersession. Two open read methods, registry(consentRef) and stateOf(consentRef), let any verifier query a ref's state directly.

The off-chain indexer decodes the full event set: AnchorBatch, ConsentAnchored, ConsentRevoked, IdentityAnchored, AuditRootAnchored, CompanyDidRegistered, CompanyDidRotated, CompanyDidRevoked, and RelayerSet. 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>

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.

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 registry is live on Base Sepolia (chain id 84532) at 0x3532a9DfF3C910152543afCa49De7c66113C5312. Base mainnet (8453) is not yet deployed.

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

Next

  • IPFS document integrity: where the anchored document CID comes from, and why IPFS is an anchored copy, not the did:web resolution path.
  • Verify a receipt end to end against the anchored root.
  • DID documents: the company and citizen DID document shapes a verifier resolves.