Enterprise registries (Registry.dedicated)#
The standard path only ever talks to the one canonical, shared GrantorRegistry. Every SDK surface — RP verifier and holder/agent alike — resolves it automatically from a compiled-in chainId → address map; there is no address to paste anywhere in the standard SDK.
An enterprise dedicated registry is a separate deployment of GrantorRegistry — your own contract instance, tenants, and billing — pointed at from your own SDK configuration instead of the canonical one. Naming a custom registry address anywhere in the SDK requires an operator-signed license grant: a credential Grantor issues, verified offline against operator public keys compiled into the SDK. The right to run your own registry is itself a Grantor grant, checked by the same credential machinery everyone else gets.
If you have not read Getting started yet, start there — this page assumes you already know what a deed is and how DeedVerifier verifies one against a registry; this page is only about which registry.
The registry seam: one type, three variants#
Every surface that takes a registry — DeedVerifier's constructor, ZkAgent.mintDeed, ZkUser.mintUserDeed, delegateZk, authenticate() / signInWithDeed()'s registry option — takes a RegistryRef, built with one of three static constructors (Registry in TS/Go, module-level functions in Python):
| TypeScript | Python | Go | Rust | |
|---|---|---|---|---|
| Standard | Registry.canonical() | registry.canonical() | guard.RegistryCanonical() | RegistryRef::Canonical |
| Local devnet | Registry.devnet(address) | registry.devnet(address) | guard.RegistryDevnet(address) | RegistryRef::Devnet { address } |
| Enterprise | Registry.dedicated(address, license) | registry.dedicated(address, license) | guard.RegistryDedicated(address, licenseJSON) | RegistryRef::Dedicated { address, license } |
A RegistryRef is a JSON string on every FFI boundary (wasm and uniffi alike): {"kind":"canonical"} / {"kind":"devnet","address":"0x…"} / {"kind":"dedicated","address":"0x…","license":{…}}. The three helper functions above just produce that string — they are the ONLY supported way to build one; hand-writing the JSON works too but gains you nothing.
Registry.canonical()— the standard path. Resolves from a compiled-in map, keyed by chain id. Until mainnet launch the map is empty, so a standard-path construction against any real chain errs, naming the chain — this is expected and documented, not a bug; see Develop locally. Devnet chain ids are not map entries either —Registry.canonical()on a devnet chain still errors; use.devnet()there.Registry.devnet(address)— free-form address, hard-gated to chain ids31337/1337. The first chain interaction self-checkseth_chainIdand refuses closed on any other chain — structurally unusable in production. This is what the local-devnet examples use.Registry.dedicated(address, license)— this page.
license is a RegistryLicense — either the parsed object grantor-license issue prints (after JSON.parse/json.loads), or its raw JSON string; both forms work in every language's dedicated() helper.
The license artifact#
A long-lived grant in the same capability grammar the delegation/capabilities vertical uses — no second credential format:
- verb:
dedicated - resource:
registry:{chainId}:{address}— the exact(chain, address)pair the grant authorizes. A multi-chain enterprise gets one grant per chain. - claims:
licensee(an opaque display label chosen at issuance),iat,exp,grace_secs - signature: the operator's ed25519 license key
The grant is a bearer attestation with no secret inside — distribute the one JSON file (env var or config path) to every RP and every agent that needs it.
Ceilings#
Enforced at verification (an over-limit grant refuses outright, whatever its signature says), mirroring how the origin-vouch TTL ceiling is checked where the vouch is consumed:
| Parameter | Value |
|---|---|
max TTL (exp - iat) | 400 days |
grace_secs default | 30 days |
grace_secs ceiling | 90 days |
| pre-expiry warning window | 30 days |
⚠️ A license with a total TTL under 30 days is born expiring_soon and never reports ok. The warning threshold is exp − 30 days, so a shorter license is past it for its whole life. A 10-day-TTL license genuinely is always within 30 days of expiring — expected, but it surprises anyone issuing a short test grant expecting "ok".
Runtime semantics — grace, then hard refusal#
Checked at construction and on every verify/mint call — a pure offline clock comparison, zero added I/O:
| Time | Behavior |
|---|---|
now < exp − 30d | normal ("ok") |
exp − 30d ≤ now < exp | works; "expiring_soon" |
exp ≤ now < exp + grace_secs | works; "grace" (surfaced loudly) |
now ≥ exp + grace_secs | hard refusal — LicenseExpired |
There is no boot-time-only check — a license that lapses mid-deployment is re-checked on the very next call, not just at startup. There is no zero-grace hard cutoff: grace is the negotiated notice-period term, carried in the grant itself, not a global policy. And there is no revocation-before-expiry — the lever is expiry plus renewal, exactly like an origin vouch. Instant kill-switch revocation is a different, un-shipped feature, not something the license mechanism provides today.
LicenseExpired is a distinct, structured error — never confused with BadProof (an attack) or Chain (an RPC problem worth retrying). It fires FIRST, before the presented deed is even inspected: a lapsed license means this deployment cannot authenticate anyone. See Errors for its exact ordering relative to every other check.
Using it#
The verifier (RP side)#
// Imports are by path — @grantor/verify is not yet on npm, matching every
// other snippet on this site (see Getting started).
import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
import { Registry } from "../../sdk/verify/ts/src/registry.js";
const license = JSON.parse(fs.readFileSync("license.json", "utf8"));
const verifier = new DeedVerifier(
process.env.RPC_URL,
Registry.dedicated(process.env.REGISTRY, license),
Number(process.env.CHAIN_ID),
Number(process.env.TENANT_ID),
"https://api.example.com", // audience
"https://api.example.com", // origin
300, // max deed TTL, seconds
30, // chain-read cache TTL, seconds
false, // allowInsecureOrigin
Math.floor(Date.now() / 1000),
);
from grantor_verify import DeedVerifier
from grantor_verify import registry
license = json.load(open("license.json"))
verifier = DeedVerifier(
rpc_url=os.environ["RPC_URL"],
registry_ref=registry.dedicated(os.environ["REGISTRY"], license),
chain_id=int(os.environ["CHAIN_ID"]),
tenant=int(os.environ["TENANT_ID"]),
audience="https://api.example.com",
origin="https://api.example.com",
max_ttl_secs=300,
cache_ttl_secs=30,
allow_insecure_origin=False,
now_unix=int(time.time()),
)
licenseJSON, _ := os.ReadFile("license.json")
ref, err := guard.RegistryDedicated(os.Getenv("REGISTRY"), string(licenseJSON))
if err != nil {
log.Fatal(err)
}
verifier, err := grantor_verify_uniffi.NewDeedVerifier(
os.Getenv("RPC_URL"), ref, chainID, tenantID,
"https://api.example.com", "https://api.example.com",
300, 30, false, uint64(time.Now().Unix()),
)
let license: grantor_sdk_core::RegistryLicense = serde_json::from_str(&license_json)?;
let registry = grantor_sdk_core::RegistryRef::Dedicated { address: registry_address, license };
let verifier = grantor_verify::verifier::DeedVerifier::new(
&rpc_url, ®istry, chain_id, tenant, audience, origin,
max_ttl_secs, cache_ttl_secs, allow_insecure_origin, now_unix,
)?;
A lapsed license (past exp + grace_secs) refuses construction outright. Every subsequent verifyAt re-checks the license at its own now, so a license that was fine at boot but lapses mid-deployment yields LicenseExpired on the very next request.
⚠️ These two lapses surface as DIFFERENT codes, and an operator health check must watch both. A license already lapsed at construction throws "BadConfig" (message starting "license/registry: "), not LicenseExpired — there is no verifier instance yet to carry the runtime code. A license that lapses while the verifier keeps running throws the caller-facing LicenseExpired/503 on the next verifyAt (see Errors). A restart-time health check that only alerts on LicenseExpired silently misses the boot-time case.
newQuorum/new_quorum/NewQuorum (the multi-RPC quorum constructor) takes the identical registryRef/license shape, with rpcUrl: string replaced by rpcUrls: string[].
The holder (agent/user side)#
ZkAgent.mintDeed / ZkUser.mintUserDeed / delegateZk all take the same registryRef (as their second positional argument, after rpcUrl) and a trailing nowUnix — the clock used to resolve the license, exactly like the verifier's constructor:
const deedJson = await agent.mintDeed(
rpcUrl, Registry.dedicated(registryAddress, license), tenant, audience,
origin, challenge, exp, vouchSignature, vouchEpoch, vouchExp,
allowInsecureOrigin, nowUnix,
);
A malformed registryRef (bad JSON, unknown kind) is refused offline, before any chain call, as "BadConfig". A resolution failure — an unreachable RPC's eth_chainId read, or a lapsed/invalid enterprise license — throws "License" (agent shims) or LicenseExpired/BadConfig (verify shims — see Errors).
resolveRegistry — same name, two different shapes#
⚠️ Two packages export a function with this exact name and they are not interchangeable:
grantor-agent-wasm/grantor-agent-uniffi(the holder SDK) export an async, RPC-basedresolveRegistry(registryRef, rpcUrl, nowUnix)— it readseth_chainIdover the network to discover the effective chain, then resolves. This is whatauthenticate()/holder code use, because a holder generally does not already know which chain it is on.grantor-sdk-wasm(the standard/verify-adjacent SDK) exports a synchronous,chainId-basedresolveRegistry(registryRef, chainId, nowUnix)— no network call, because the verifier side already has an explicitchainId(it needs one for the discovery document regardless).
Import the wrong one and you get a Promise where you expected a string, or an unexpected network round-trip. Holding a chainId? Use the sync one. Only an RPC URL? Use the async one.
licenseStatus — introspection, not enforcement#
DeedVerifier exposes licenseStatus(nowUnix), re-evaluated at the nowUnix you pass:
undefined/None/nil— this verifier resolvedCanonicalorDevnet, which carry no license to lapse."ok" | "expiring_soon" | "grace" | "lapsed"— this verifier resolvedDedicated. Poll this for your own monitoring/alerting;DeedGuardalso logs a rate-limited warning (at most once per interval, never per request) once a resolved license enters"grace"or"expiring_soon".
licenseStatus is pure clock arithmetic over the license the verifier already resolved at construction — it never makes a chain call and can report "lapsed" without throwing (unlike verifyAt, which refuses outright once the license has lapsed). Use it to know before your next caller hits LicenseExpired, not to gate anything yourself.
verifyRegistryLicense — the standalone check#
The same license check Registry.dedicated(...) resolution runs internally, exposed as a free function so you can check a license file without wiring up a chain endpoint at all — useful for a CI job, a support script, or the grantor-license verify subcommand below:
import { verifyRegistryLicense } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
const state = verifyRegistryLicense(licenseJson, chainId, registryAddress, nowUnix);
// -> "ok" | "expiring_soon" | "grace", or throws "LicenseExpired" / "BadConfig"
Python: verify_registry_license. Go: VerifyRegistryLicense. Rust: grantor_sdk_core::verify_registry_license.
The grantor-license operator tool#
An operator-only binary (not published anywhere) that issues, tracks, and checks license grants. It never talks to a chain and never phones home — the operator's whole visibility into outstanding licenses comes from a local JSONL ledger it writes to, honoring the product's no-SDK-telemetry rule.
grantor-license keygen --out <path>
Generates a fresh ed25519 operator license key (see the key-ceremony note below — this is NOT the deploy/treasury ceremony). Writes a 0600 hex seed to <path> and prints pubkey: <hex>.
grantor-license issue --key <path> --licensee <label> --chain <id> \
--registry <address> --expires-days <n> [--grace-days <n>] \
[--iat <unix>] [--ledger <path>]
Signs a grant with the operator key, prints the pretty-JSON RegistryLicense to stdout (this is the one file you hand to the customer), and appends a record to the ledger (default license-ledger.jsonl in the current directory). --grace-days defaults to 30; --iat defaults to now.
grantor-license status [--ledger <path>] [--now <unix>]
Reads the ledger and prints one line per issued license — <licensee> chain=<id> registry=<addr> exp=<unix> state=<state> — so the operator knows who to contact before a turnkey engages. A missing ledger is treated as empty; a malformed line is skipped with a stderr warning, not a hard failure.
grantor-license verify --file <path> --chain <id> --registry <address> [--now <unix>]
Full verification (signature, ceilings, time state) of any grant file — state: <state> on success, error: <message> on failure.
Exit codes: status exits 0 for every license state, including lapsed — it is a report, and a report is not a failure. keygen, issue, and verify exit 1 on a genuine problem (malformed/missing required flags, a signature that does not verify, a scope mismatch); verify in particular exits 1 for LicenseExpired the same as any other verification failure — a lapsed license IS the failure verify exists to report, unlike status's survey of everything issued so far.
The dedicated deployment#
Everything above is the SDK-side half — the license artifact, its ceilings, and how a verifier or holder resolves it. The other half lives on-chain: for the productized offering (sub-model A), a Registry.dedicated(address, license) address is a contract deployed specifically for that one enterprise, GrantorRegistryDedicated, not a second tenant squeezed onto the shared GrantorRegistry. The full operator-side runbook (deploy, onboarding, steady-state operation, the kill procedure) is provided directly to the operator as part of the engagement; this section summarizes what that separate contract buys you.
The operator owns the venue; the enterprise owns its tenancy. The operator retains owner (a multisig) and the immutable treasury (a Safe) on the dedicated instance — exactly the two powers it holds on the shared registry. Everything tenant-shaped (tenant creation, funding, agent/user registration, origin vouches, issuer keys) is the enterprise's own, administered on their contract precisely as on the shared one. The only thing a dedicated deployment adds beyond "your own address" is one extra owner power the shared registry deliberately lacks — the kill-switch below.
Two levers, two postures#
| License (this page, above) | Timelocked on-chain kill (dedicated only) | |
|---|---|---|
| Nature | Routine — a renewal ladder: ok → expiring_soon → grace → hard LicenseExpired refusal | Nuclear — a deliberate owner action, not a clock lapsing on its own |
| Where it's checked | Offline, inside the SDK, on the caller's own clock | On-chain, inside status() itself |
| Survives an SDK fork? | No — a determined fork can patch the license check out (see "Enforcement posture" below) | For routine forks, yes — a fork that merely skips the offline license check still dies on the chain read. A fork that also strips the status() billing gate is the same licensee-only seam as patching the license check (see "Enforcement posture" below), covered by the same legal backstop. The kill raises the bar for a fork to succeed, but enforcement ultimately depends on the deployment's own integrity, not on code structure. |
| Reversible? | Renew before lapse, or don't | Cancelable up to the moment it matures (cancelKill()); re-announcing after a cancel restarts the full notice window, never a shortened one |
The license lever is enforcement-as-legal-backstop — bypassable by anyone willing to patch their own copy of the SDK (see "Enforcement posture" below). The kill-switch is not a check the SDK performs at all: it's a fact about what the contract returns, and every verifier's fail-closed behavior on an unreachable or adverse chain read was already load-bearing before this feature existed. So a routine fork that merely skips the offline license check still dies on the chain read. A fork that also strips the status() billing gate is possible, but that is the same licensee-only seam as patching the license check itself — covered by the same legal backstop.
Notice-as-code#
GrantorRegistryDedicated.killNotice is set once in the constructor and is immutable for the life of the contract — floor 7 days, typically negotiated at 30. announceKill() starts the window (killEffectiveAt = block.timestamp + killNotice, emitting KillAnnounced(effectiveAt)); status() is unchanged for every tenant until that timestamp, then reports Inactive for all of them, overriding their individual billing state. cancelKill() cures it. The notice clause an enterprise negotiates thus becomes a value their own monitoring reads back from chain the moment an announcement lands — not a promise dependent on the operator's word. The exact calls and the billing/non-custody guarantees that survive a completed kill are in the operator runbook; the commercial guarantee that matters is that undrawn balance stays withdrawable regardless.
Enforcement posture — read this before relying on it commercially#
Structural API + license-as-legal-backstop, not cryptographic prevention. The SDK runs on the customer's own machine; a determined fork can patch the check out. Accepted deliberately, for the same reason the SDK's license is proprietary rather than open source: the commercial deal rests on the license, not on secrecy. Rejected alternatives (an on-chain endorsement registry, a per-boot ZK membership deed) all cost real complexity for enforcement that stays exactly as bypassable.
Two consequences worth being explicit about:
- The gate lives in
DeedVerifier(and the mint-side equivalents), not underneath them. A Rust integrator who composes the lower-level pieces directly — a bareDeedGuard,verify_deed_claims, a hand-rolledAlloyGate— bypasses the license check entirely, because those functions take a resolved address, not aRegistryRef. This seam is permitted, not a hole to close: it exists for the licensee's own use (composing your own verification pipeline against a registry you're licensed for), not as an invitation to route around the license you didn't buy. - The license clock is enforcement against lapse, never a security boundary. Every timestamp check (
now) is caller-supplied, exactly like every other clock in this SDK (origin vouches, deedexp). It stops an unlicensed or lapsed deployment from continuing to authenticate; it proves nothing about the deployment's honesty otherwise, and should never be described as one.
This is why the dedicated deployment pairs the license with a second, structurally different lever instead of trying to make the license itself unforkable. Stacking a bypassable offline SDK-side check with a chain read every fail-closed verifier already depends on buys something neither buys alone: routine lifecycle management (renewal, warning, grace) stays cheap and transaction-free, while the one power that must survive a hostile fork — cutting off a specific dedicated instance for cause — lives somewhere no SDK patch can reach. Sub-model B (an enterprise's own or a permissioned chain) does not get this pairing for free: if the enterprise controls consensus, the on-chain kill is illusory — they can simply omit the enforcing transaction — so its real levers are the license grant and the signed agreement only (covered in the operator runbook under sub-model B).
admin-sig / smart-wallet verification through a dedicated registry#
verify_admin_deed/verify_admin_deed_smartwallet verify against whatever RegistryRef the DeedVerifier instance was constructed with — including Dedicated. If your enterprise deployment runs its own admin-sig-style dashboard login against your dedicated registry, that verification is license-gated exactly like every other mode.
This does not recreate the pay-page lockout admin-sig was designed to avoid: license renewal is a commercial process between you and Grantor, out-of-band of your own dashboard and not gated behind anything your dashboard's admin must log in to reach. A lapsed tenant billing status on your own registry and a lapsed SDK license to point at that registry are independent things; only the latter is what this page is about.
A DX gotcha: a typo'd address looks like a license error#
If you pass Registry.dedicated(address, license) an address that doesn't exactly match the license's scope — a single wrong hex digit is enough — you won't see an "invalid address" error. Address syntax passes (it's well-formed, just wrong); what fails is the SCOPE comparison in license verification, so the error names the license ("license scope \"registry:1:0x…\" does not cover \"registry:1:0x…\""), not the address you mistyped. If a dedicated construction refuses and the license looks right, diff the address you passed against the one the license was issued for, character for character — the license is very likely fine.
Key ceremony#
The operator license key is generated in its own offline ceremony, separate from the registry deploy/treasury ceremony. The procedure: generate with grantor-license keygen on a non-networked machine, append the pubkey to the SDK's compiled-in OPERATOR_LICENSE_PUBKEYS list, and ship it in the next SDK release. Rotation is a released-SDK event, not a chain transaction — there is no on-chain state for this key at all.
The devnet test key is not an operator key. The SDK ships a well-known, committed-on-purpose test signing key (seed [0x42; 32]) whose signatures are accepted only when the effective chain id is a devnet id (31337/1337) — this is what lets the local-devnet examples exercise the entire dedicated-registry path with zero real ceremony. On any other chain id, verify_registry_license refuses a test-key signature outright, independent of everything else about the license (enforced in CI). Never treat the test key as a template for anything beyond a local devnet.
See also#
- Getting started — the standard path (
Registry.canonical()). - Develop locally —
Registry.devnet(...), and why the canonical map is empty until mainnet launch. - Errors —
LicenseExpired/WrongChainand their exact HTTP status and ordering. - Sovereign tier — the full verification reference.