docs / deploy

view as .md

Verifiable browser demo — Base Sepolia runbook#

This is the demo half of the full developer preview (docs/deploy/base-sepolia-preview.md) — that runbook sequences this deploy generator with just preview-wire (register/SDK/manifest) into one end-to-end operator sequence.

Operator-gated. Everything up to and including this doc was built and fork-tested against Base Sepolia (anvil --fork-url https://sepolia.base.org — a local fork, no real broadcast, no faucet needed). The steps below are the live broadcast, which needs a faucet-funded throwaway key only the operator holds. Nothing in this runbook should be run by an agent unattended.

landing/demo.html runs a complete sovereign-tier login in the browser: a real WebAuthn passkey ceremony mints a deed, and the real, published grantor-verify wasm checks it against a real on-chain read — no server anywhere in the loop. This runbook deploys the Base Sepolia registry + tenants the page reads, ships the page to Cloudflare Pages, and verifies it live.

Deploy generator: landing/demo/deploy.sh (invoked via just demo-deploy). Schema: landing/demo/demo-config.example.json. Both are documented in docs/superpowers/specs/2026-08-04-verifiable-browser-demo-design.md.

demo-config.json's allowInsecureOrigin field is computed by deploy.sh from $ORIGIN, not a fixed value — a localhost/127.0.0.1/::1/http:// origin (the "run it locally first" flow below) yields true; any other (routable https://) origin, including the live Base Sepolia broadcast in step 3 below, yields false. demo-config.example.json models the live broadcast, so it shows false for its https://demo.chaingrantor.com origin — don't copy true into a real deployment's config. This is the same fail-closed origin policy every other surface enforces — see Sovereign tier § Fail-closed origin policy.

Before spending any testnet funds, run the whole demo against a local anvil. This also surfaces the one setup rule that bites everyone once — the origin must match — in a place that costs nothing.

★ The origin rule. WebAuthn binds the passkey to the exact origin the browser served the page from (scheme + host + port), and the verifier checks that origin against demo-config.json's origin byte-for-byte. If the two differ, the deed still mints (WebAuthn's rpId is only the hostname, so it ignores the port), but verification fails with the opaque Malformed: client_data_json.origin. So: the ORIGIN you pass to demo-deploy must equal the origin you open the page from. Note http://localhost:8000 and http://127.0.0.1:8000 are different origins. The page now shows a pre-flight banner naming both when they disagree, so you won't have to decode that error again.

Pick one port and keep it identical across anvil-config-server (8000 here):

# 1. a local chain (leave running in another shell)
anvil

# 2. deploy the registry + tenants + vouch, with ORIGIN = where you'll SERVE.
#    DEPLOYER is anvil's well-known dev account #0 — a public constant, fine
#    for a throwaway local chain, NEVER a real key.
RPC=http://localhost:8545 \
  DEPLOYER=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  TREASURY=0x000000000000000000000000000000000000dEaD \
  ORIGIN=http://localhost:8000 AUDIENCE=http://localhost:8000 \
  CHAIN_ID=31337 EXPLORER_BASE=http://localhost \
  OUT=landing/demo/demo-config.json \
  just demo-deploy

# 3. build the vendored browser wasm (once per checkout / after an SDK change)
just demo-wasm

# 4. serve landing/ from the SAME origin you passed as ORIGIN
cd landing && python3 -m http.server 8000
# open http://localhost:8000/demo.html — click "Verify a real deed"

demo-config.json and landing/demo/vendor/ are git-ignored and regenerated here; each demo-deploy run deploys a fresh registry (tenants are always 1 funded / 2 unfunded), so a stale config from an earlier run — or from the Playwright test, which uses port 4173 and tears its anvil down — points at a chain that no longer exists. Regenerate rather than reuse.

0. Prerequisites#

  • Foundry (forge, cast, anvil), jq, Node — same toolchain as docs/deploy/testnet.md.
  • npm exec --yes --package wrangler reachable (installs wrangler on demand; no local install needed). Auth: npx wrangler login first — the OAuth token is short-lived and cannot refresh in a non-interactive shell — or set CLOUDFLARE_API_TOKEN. Same pseudonymous Cloudflare account the landing site already deploys to (project grantor).

1. Get a throwaway deployer key + fund it#

cast wallet new    # prints a fresh private key + address — save both

This key becomes the deployer, the funded tenant's admin, and the origin vouch signer. Fund it with a small amount of Base Sepolia ETH (a few cents' worth covers the whole deploy — MockUSDC is minted free, only gas costs anything). Pick one faucet:

  • Coinbase Developer Platform faucet — https://portal.cdp.coinbase.com/products/faucet
  • Alchemy Base Sepolia faucet — https://www.alchemy.com/faucets/base-sepolia
  • thirdweb faucet — https://thirdweb.com/base-sepolia-testnet

(Some gate on a small mainnet balance in the same wallet — pick one that doesn't, or park a couple dollars of mainnet ETH there as a gate, unspent.)

Do not reuse a doxxed or previously-identified wallet — same anonymity posture as docs/deploy/testnet.md's "Anonymity notes".

2. Choose a throwaway TREASURY#

GrantorRegistry's treasury constructor argument is immutableDeployTestnet.s.sol refuses to silently default it anywhere except anvil/ganache, so on Base Sepolia you must set it explicitly. For this demo the treasury address is irrelevant (draws just accumulate testnet MockUSDC nobody redeems) — any address you control works, e.g. the deployer address itself or a second cast wallet new. Do not reuse a mainnet treasury candidate here — this deploy is disposable and gets thrown away on redeploy.

3. Run the deploy generator against the real chain#

RPC=https://sepolia.base.org \
  DEPLOYER=0x<your funded throwaway private key> \
  TREASURY=0x<your throwaway treasury address> \
  ORIGIN=https://grantor-ero.pages.dev \
  AUDIENCE=https://grantor-ero.pages.dev \
  CHAIN_ID=84532 \
  EXPLORER_BASE=https://sepolia.basescan.org \
  OUT=landing/demo/demo-config.json \
  just demo-deploy

This deploys MockUSDC + a fresh GrantorRegistry, stands up tenant 1 funded to Active and tenant 2 created-but-unfunded (Inactive), has the deployer (tenant 1's admin) sign the origin vouch for https://grantor-ero.pages.dev, and writes landing/demo/demo-config.json — the fixture demo.html/demo.js read. It ends with:

✓ demo config generated: registry=0x... funded=1(Active) unfunded=2(Inactive)

⚠️ Every run deploys a brand-new registry. nextTenantId always starts at 1, so tenant ids 1/2 are only meaningful relative to this run's registry address — if you re-run Step 3 later (to rotate keys, fix a typo, or redeploy for the chaingrantor.com cutover), demo-config.json gets overwritten to point at the new registry, and the old registry's tenants 1/2 are simply abandoned on-chain (harmless — testnet, no real funds). Do not expect a prior run's tenants to carry forward.

⚠️ The script wipes local broadcast artifacts for this chain id first: deploy.sh runs rm -rf contracts/broadcast/{DeployTestnet.s.sol,SetupTenant.s.sol}/84532 before deploying, so it can parse run-latest.json unambiguously. If this machine has unrelated Base Sepolia (chain id 84532) Foundry broadcast history you care about (e.g. from docs/deploy/testnet.md's own runbook target on the same chain), back it up first — deploy.sh deletes it without asking.

Save two addresses from this run's log for Steps 6 and 8 below — the GrantorRegistry=0x... MockUSDC=0x... line printed at "1/6" (also recoverable later from landing/demo/demo-config.json's registry field, or MockUSDC from contracts/broadcast/DeployTestnet.s.sol/84532/run-latest.json if the log scrolled away — but not from demo-config.json, which does not carry the USDC address):

export REG=$(jq -r .registry landing/demo/demo-config.json)
export USDC=$(jq -r '.transactions[]|select(.transactionType=="CREATE" and .contractName=="MockUSDC").contractAddress' \
  contracts/broadcast/DeployTestnet.s.sol/84532/run-latest.json | head -1)
echo "REG=$REG USDC=$USDC"

4. Build the vendored browser wasm#

just demo-wasm

Builds crates/grantor-sdk-wasm and crates/grantor-verify-wasm with --target web into landing/demo/vendor/sdk and landing/demo/vendor/verify — the exact wasm the page loads client-side. Both landing/demo/vendor/ and landing/demo/demo-config.json are git-ignored (repo-root .gitignore) — they are build/deploy artifacts, not source — but see Step 5: that does not stop them from shipping.

5. Deploy to Cloudflare Pages#

npm exec --yes --package wrangler -- wrangler pages deploy landing \
  --project-name=grantor --branch=main --commit-dirty=true

This is the same underlying command just landing-deploy runs on landing/ (justfile:341) — no separate demo deploy path, same grantor Pages project. (just landing-deploy additionally runs docs-build and an idempotent wrangler pages project create first; neither is required just for the demo files, but running the full just landing-deploy instead of the bare command above is also correct and picks up any pending docs changes too.)

Verified: wrangler pages deploy uploads the working directory's actual filesystem contents, not a git tree — it does NOT consult .gitignore at all. It only skips a small hard-coded list (node_modules, .git, .DS_Store, _worker.js, _redirects, _headers, _routes.json) — confirmed via wrangler pages deploy --help (no --exclude/ignore-file flag exists) and via Cloudflare community/issue reports describing exactly this hard-coded list, distinct from Workers Sites' KV uploader (which does support gitignore-style excludes — a different feature). Neither demo-config.json nor vendor/ matches anything in that list, so as long as Steps 3 and 4 ran on this machine before this command, both ship with the deploy. No force-add, no un-ignoring, no .gitignore edit needed — this matches how landing-deploy already ships landing/demo/ and the docs mirror today without either being force-added.

If you ever deploy from a different machine or a clean checkout, re-run Steps 3–4 there first — the generated files travel with the filesystem, not with git.

6. Verify live#

Open https://grantor-ero.pages.dev/demo.html in a real browser (passkeys need a real WebAuthn authenticator — a platform authenticator or security key; headless/curl-like user agents get Cloudflare's bot filter, and wasm WebAuthn needs an actual browser regardless).

  • Click "Verify a real deed" (tenant 1) — should register a passkey, sign, mint, and verify successfully; the live log should show a real on-chain read reporting Active.
  • Click "See the billing gate reject an unpaid tenant" (tenant 2) — should go through the same ceremony and be refused because the registry reports Inactive.

Independently cross-check the on-chain state yourself (don't just trust the page) — read $REG (exported in Step 3) directly:

cast call $REG "status(uint256)(uint8)" 1 --rpc-url https://sepolia.base.org
# -> 1   (enum Status { Inactive, Active, Grace } — contracts/src/GrantorRegistry.sol:23)
cast call $REG "status(uint256)(uint8)" 2 --rpc-url https://sepolia.base.org
# -> 0

If either the page's verdict or this independent read disagrees with the expected value, stop and treat it as a real finding, not a demo defect.

7. The chaingrantor.com cutover (later)#

The origin vouch, the deed's origin binding, and the WebAuthn RP origin are all bound to the serving origin — https://grantor-ero.pages.dev today. Moving to the real domain is not a DNS-only change:

  1. Register chaingrantor.com per the Phase 0 step in docs/strategy/2026-08-01-launch-runbook.md — the recorded anonymous path (docs/superpowers/specs/2026-08-04-verifiable-browser-demo-design.md, "Operational notes") is Njalla (registers as the legal owner, accepts XMR/BTC) with nameservers pointed at the existing pseudonymous Cloudflare account, then attach the domain to the grantor Pages project. See docs/strategy/2026-07-30-identity-without-entity.md for why the registrar choice matters here (public identification vs. subpoena boundary — a .com stays US-jurisdiction either way; Njalla defeats the former, not the latter).
  2. Re-run Step 3 (demo-deploy) with ORIGIN=https://chaingrantor.com (or the demo's final subdomain, e.g. https://demo.chaingrantor.com) and AUDIENCE set to match — this re-signs the vouch for the new origin and overwrites landing/demo/demo-config.json's origin/audience fields. A vouch signed for grantor-ero.pages.dev will not authenticate requests arriving from chaingrantor.com; this is the origin-binding security property working as designed, not a bug to route around.
  3. Re-run Step 4 (demo-wasm) is not required unless the wasm itself changed — the wasm has no origin baked in — but re-run it anyway if any time has passed, to pick up any intervening SDK changes.
  4. Redeploy (Step 5). Passkeys are fresh-per-visit in this demo, so there is no credential migration step.
  5. Re-verify (Step 6) against the new URL.

8. Funding upkeep#

drawPeriod is permissionless — anyone (or nobody) can call it once a period lapses; nothing auto-renews on its own. The funded tenant's balance drains one period at a time as draws happen, so:

  • Periodically check cast call $REG "status(uint256)(uint8)" 1 --rpc-url https://sepolia.base.org. 1 = Active, 2 = Grace (still authenticating, on notice), 0 = Inactive (demo's "accept" flow starts failing).
  • Before it drops out of Grace, top up: re-run SetupTenant.s.sol against the same registry with a fresh FUND amount (do not re-run the whole demo-deploy generator for a top-up alone — that deploys a brand-new registry per the Step 3 warning above, which would also invalidate the signed vouch and demo-config.json's registry address). ``bash cd contracts PRIVATE_KEY=0x<deployer key> CONTRACT_ADDR=$REG USDC=$USDC TIER=1 FUND=1000000000000 \ ISSUER_KEY_ID=0x0000000000000000000000000000000000000000000000000000000000000001 \ forge script script/SetupTenant.s.sol --rpc-url https://sepolia.base.org --broadcast `` (A watcher for this is out of scope for the demo — manual monitoring for the preview, matching the design doc's operational notes.)

Fork-testing this runbook without spending anything#

Everything above except the real broadcast (Steps 1, 5, 6's live page) can be rehearsed against a local fork of Base Sepolia — no faucet, no real funds, default anvil keys are pre-funded on the fork:

anvil --fork-url https://sepolia.base.org > /tmp/fork.log 2>&1 &
sleep 4
RPC=http://localhost:8545 \
  DEPLOYER=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  TREASURY=0x000000000000000000000000000000000000dEaD \
  ORIGIN=https://grantor-ero.pages.dev AUDIENCE=https://grantor-ero.pages.dev \
  CHAIN_ID=84532 EXPLORER_BASE=https://sepolia.basescan.org \
  OUT=/tmp/demo-config.fork.json \
  just demo-deploy
cat /tmp/demo-config.fork.json
kill %1

Expected: ✓ demo config generated: registry=0x... funded=1(Active) unfunded=2(Inactive), and the emitted JSON has a non-zero registry address, vouch.signature (130 hex chars), and both statuses correct. This proves the generator's logic against Base Sepolia's real deployed state (gas costs, chain quirks, whatever's actually on that chain today) without touching a real key or spending real funds — it does not prove the live broadcast, Cloudflare deploy, or a real-browser WebAuthn ceremony, which remain the operator's to run per Steps 1–6 above.

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