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 and the company's registers are 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
Six kinds of data are written to Base mainnet (chain id 8453). Every
payload is an opaque bytes32 value (a root, a hash, or a 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, revoke, and reinstate. (registerCompanyDid/rotateCompanyDid/revokeCompanyDid/reinstateCompanyDid, emittingCompanyDidRegistered/CompanyDidRotated/CompanyDidRevoked/CompanyDidReinstated) - 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) - Citizen identity commitments: when a citizen DID is created, and each time
its keys are rotated through recovery, an opaque salted commitment
(
keccak256( JCS(claim) || salt32 )over the DID and the thumbprints of its keys). The salt is discarded as soon as the commitment is computed, so the commitment can never be opened, reveals nothing, and links to no consent. (anchorIdentity, emittingIdentityAnchored) - Company epoch roots and declaration seals: a tamper-evident root over each
periodic epoch of the company's access log, of its attestation journal, and of
its sealed registers (incidents, diffusions, the rights-request reception clock,
declared purges), plus the seal of each single declaration (an anonymisation
attestation, a destruction run, a published policy version, an anonymisation
re-evaluation). The access log documents who reached which personal
information, in support of the organisation's accountability (P-39.1 s. 3.1).
(
anchorAuditRoot, emittingAuditRootAnchored) Each 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 filter eth_getLogs for
every root anchored for a company (both fields are indexed) with no off-chain
cross-reference. A company with no registered on-chain DID is tagged with
bytes32(0), which means "unattributed", not "invalid".
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: a citizen identifier on a public chain would break unlinkability, and it could never be corrected or destroyed afterwards. The creation and rotation commitments described above are salted and carry no identifier.
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. Only a never-anchored ref is written: a ref that is
already Active, Revoked, or Superseded is skipped (no state write, no event),
so a redelivered relayer job cannot churn state, duplicate a log, or bring a
revoked consent back. A new grant after a withdrawal is a new consent, anchored
under its own new refs. 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 the salted commitments from citizen DID creation and recovery
rotation. 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: anchorAuditRoot for the company roots and declaration
seals listed above, anchorCookieRoot for cookie consent. 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. The contract refuses to reinstate the exact key
that was revoked.
Agreely's relayer is the only writer, but it does not take the database's word for
any of these four writes. Before signing one, it reads the company's
_agreely.<domain> TXT record over DNS-over-HTTPS and the registry itself:
| Write | What the relayer requires |
|---|---|
registerCompanyDid |
No record on chain yet, and the DNS declares this DID and this key. |
rotateCompanyDid |
An Active record, and either the DNS declares the new key, or (once the DNS publishes root=, only this way) an assertion of the company's root passkey naming the new key. |
revokeCompanyDid |
An Active record, and an assertion of the company's root passkey (agreely.didRevoke.v1) for this domain, that root being the one the DNS publishes. |
reinstateCompanyDid |
A Revoked record under another key, and either the DNS declares this DID and key, or (once the DNS publishes root=, only this way) a root passkey assertion. |
A key the registry ever revoked for a domain never comes back. A root assertion older than 72 hours, or signed before the record's last change, is refused.
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 only
path from Revoked back to Active, 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.
Verifying an emit-only root
registry(root) answers with zeros for an epoch root, a declaration seal, a
document anchor or an identity commitment: those writes are emit-only and
never touch the mapping, which holds consent refs only. A zero answer there does
not mean "not anchored". Read the event log instead:
- Fetch the anchoring transaction's receipt (
eth_getTransactionReceipt) to learn its block number. - Call
eth_getLogson the registry address withfromBlock=toBlock= that block andtopic1= the root. Public RPC endpoints cap the block range ofeth_getLogs(https://mainnet.base.orgrefuses more than 10,000 blocks), so an unbounded query fails. - Check
topic0:keccak256("AuditRootAnchored(bytes32,bytes32,uint64)"), orCookieConsentRootAnchored(bytes32,bytes32,uint64)for a cookie root, orIdentityAnchored(bytes32,uint64)for a document or identity commitment. Hash the signature yourself rather than trusting a precomputed value.topic2is the attribution tag; never swap it withtopic1, both arebytes32.
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:app.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://app.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:app.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://app.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:app.agreely.ca:c:<slug>) |
keyId |
bytes32 SHA-256 fingerprint of the signing key (same as in DNS TXT) |
publicKey |
The raw 32-byte Ed25519 public key |
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):
| Item | Value |
|---|---|
| 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.