# 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](../sovereign-tier.md#fail-closed-origin-policy).

## Run it locally first (recommended)

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):

```bash
# 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

```bash
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 **immutable** —
`DeployTestnet.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

```bash
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):

```bash
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

```bash
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

```bash
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:

```bash
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:

```bash
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.
