docs / guide

view as .md

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 GrantorRegistryDevnetGrantorRegistry with one difference: status() reports every tenant Inactive once block.timestamp passes devnetExpiry (expiresAt in the config). Restart just devnet for a fresh 24 hours. Chain state is in-memory anvil, so a restart also wipes whatever tenants/agents/vouches you set up. (LIFETIME_SECS overrides the default.)
  • The registry address changes on every run. Each start deploys from a freshly generated key, so registry and adminKey differ every time. Re-read the emitted config after a restart — don't hardcode addresses from a previous run.
  • network: "grantor-local-devnet" + chainId: 31337 are in the config so your own code can guard against pointing anything but a dev build at this chain — check network (or chainId) before wiring up something that could reach a real environment.
  • Reachable only from this machine. The docker run maps -p 127.0.0.1:8545:8545, so the published port is bound to host loopback. (anvil itself binds 0.0.0.0 inside 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#

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.