Develop locally#
Point your app at a local devnet while you build: a real anvil chain, a real registry contract, a real funded tenant — all disposable, all free. No production USDC, no production gas, no production tenant capacity spent while you iterate.
The one command#
just devnet
Equivalent, if you're not using just:
docker run --rm -p 127.0.0.1:8545:8545 -v "$PWD/.grantor:/out" grantor-devnet
This builds (once) and runs a container that starts anvil on 127.0.0.1:8545, deploys a fresh registry + MockUSDC, creates and funds tenant 1 to Active, and writes a dev-config to .grantor/grantor-devnet.json:
{
"network": "grantor-local-devnet",
"rpcUrl": "http://127.0.0.1:8545",
"chainId": 31337,
"registry": "0x9770e9b01684269f089b752eaa9d9936d77f197b",
"mockUsdc": "0x8593e0dcf11ab46bf0dd02e025c1b8146f56030e",
"devTenant": 1,
"adminKey": "0x3f4a…9f0fa",
"allowInsecureOrigin": true,
"expiresAt": 1786077198
}
Leave the container running in a terminal while you develop; Ctrl-C (or docker rm -f) to stop it.
Point a DeedVerifier (or an agent) at it#
Read the four fields you need straight out of the config: rpcUrl, registry, chainId, devTenant. TypeScript, matching the shape in Getting started:
import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
import { Registry } from "../../sdk/verify/ts/src/registry.js";
const cfg = JSON.parse(fs.readFileSync(".grantor/grantor-devnet.json", "utf8"));
const verifier = new DeedVerifier(
cfg.rpcUrl,
Registry.devnet(cfg.registry), // a custom address is only ever legal on a devnet chain id
cfg.chainId,
cfg.devTenant,
"http://localhost:3000", // audience: what deeds must be scoped to
"http://localhost:3000", // origin: what THIS deployment is
300, // max deed TTL, seconds
30, // chain-read cache TTL, seconds
cfg.allowInsecureOrigin, // true — required for a localhost/http origin, see below
Math.floor(Date.now() / 1000),
);
Python, Go and Rust take the same ten arguments, in the same order — see Sovereign tier § Every capability, every language. Registry.devnet(address) is hard-gated to chain ids 31337/1337 — the first chain read self-checks eth_chainId and refuses on any other chain, so this construction is structurally unusable outside a local devnet. A custom registry address on a real chain requires an enterprise license (Registry.dedicated(address, license)) — the standard SDK (Registry.canonical(), until mainnet launch populates the map) cannot express one at all.
cfg.allowInsecureOrigin is true because a local devnet's origin is http://localhost:…. Grantor's fail-closed origin policy refuses a non-HTTPS, non-routable origin by default, so localhost development needs the opt-in. Pass true here; leave it false (the default) on a real deployment.
Use adminKey to set up test fixtures#
adminKey is tenant 1's admin — the same key that deployed the registry — so it can sign anything a real tenant admin could, against your throwaway chain:
Create another tenant (tenant 1 already exists and is Active; make more if your test needs multiple):
PRIVATE_KEY=$ADMIN_KEY CONTRACT_ADDR=$REGISTRY USDC=$MOCK_USDC TIER=1 \
ISSUER_KEY_ID=0x0000000000000000000000000000000000000000000000000000000000000002 \
forge script contracts/script/SetupTenant.s.sol --rpc-url $RPC_URL --broadcast
Register a test agent (agent-zk) against tenant 1's membership tree:
cast send $REGISTRY "registerZkAgent(uint256,uint256)" 1 $COMMITMENT \
--private-key $ADMIN_KEY --rpc-url $RPC_URL
Sign an origin vouch for your dev origin, so authenticate() / ZkAgent.mintDeed's origin provenance check passes locally:
MSG=$(node --input-type=module -e "
import { originVouchBinding } from './sdk/ts/pkg/grantor_sdk_wasm.js';
process.stdout.write(originVouchBinding(1, 'http://localhost:3000',
'http://localhost:3000', 0, $(( $(date -u +%s) + 86400 )), true));")
cast wallet sign --private-key $ADMIN_KEY "$MSG" # strip the 0x → your VOUCH_SIGNATURE
The trailing true is originVouchBinding's own allowInsecureOrigin argument — same reason as above, needed because the vouched origin is http://localhost:….
Good to know#
- It resets after 24 hours. The container runs
GrantorRegistryDevnet—GrantorRegistrywith one difference:status()reports every tenantInactiveonceblock.timestamppassesdevnetExpiry(expiresAtin the config). Restartjust devnetfor a fresh 24 hours. Chain state is in-memoryanvil, so a restart also wipes whatever tenants/agents/vouches you set up. (LIFETIME_SECSoverrides the default.) - The registry address changes on every run. Each start deploys from a freshly generated key, so
registryandadminKeydiffer every time. Re-read the emitted config after a restart — don't hardcode addresses from a previous run. network: "grantor-local-devnet"+chainId: 31337are in the config so your own code can guard against pointing anything but a dev build at this chain — checknetwork(orchainId) before wiring up something that could reach a real environment.- Reachable only from this machine. The
docker runmaps-p 127.0.0.1:8545:8545, so the published port is bound to host loopback. (anvilitself binds0.0.0.0inside the container — it must, since Docker forwards host traffic to the container's bridge IP, not its loopback.) To reach it from elsewhere, change the host-side mapping explicitly, e.g.-p 0.0.0.0:8545:8545.
⚠️ adminKey is a throwaway devnet key#
adminKey is generated fresh, on your machine, every single run, and funded only from anvil's well-known dev account. It is never a production key and holds nothing of value. Do not reuse it for anything real, do not fund it on a real chain, and do not put it anywhere near a production tenant's admin set.
See also#
- Getting started — the same
DeedVerifier/grantorExpresswiring, pointed at a real chain. - Sovereign tier § Fail-closed origin policy — why
allowInsecureOriginexists and when to set it. - Sovereign tier § Origin provenance — what an origin vouch is for.
- Agent tokens — enrolling and authenticating an
agent-zkagent, the full recipe. - Errors — every error code a relying party branches on.