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.
- Consent -- the Merkle root of a signed consent plus the per-cell
consentRefcovering each data-collection scope; revocations flip a ref fromActivetoRevoked. (anchorBatch/revoke) - 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, emittingCompanyDidRegistered/CompanyDidRotated/CompanyDidRevoked) - 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. (anchorIdentityacting as document anchor, emittingIdentityAnchored) - Access-log epoch roots -- a tamper-evident root over the periodic access-log
epoch, equipping the Loi 25 audit-trail requirement. (
anchorAuditRoot, emittingAuditRootAnchored) Each epoch root is also signed at seal time with the company's registered DID key, additively; see epoch signing and delegation. - 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, emittingCookieConsentRootAnchored)
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
- Epoch signing and delegation: the DID key that signs each epoch root, and the DNS-bound root passkey delegation.
- 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.