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.
- 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, 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.