This document covers testnet and mainnet deployment. For local development, see local-development.md; the full local stack runs in Docker and needs no secrets.
If an LLM is doing deployment prep, it should execute the scriptable steps here, inspect outputs, and stop for human help when a step needs secret custody, wallet funding, ENS/DNS/account access, dashboard clicks, or product judgment. The companion testnet-prep.md is only the human/operator blocker list.
Commonality deploys to three independent targets:
| Target | What goes there | How it's deployed |
|---|---|---|
| Ethereum chain | Smart contracts | ./scripts/deploy-contracts.sh <net> |
| Render | AI services, platform API, and indexer (see Indexer on Render) | render.yaml blueprint + git push |
| IPFS + IPNS + ENS + DNS | UI (eight branded SPAs) | scripts/deploy-testnet.sh (one-shot per release) |
Each target has its own cadence and its own blast radius. Don't try to unify them behind one megascript — the separation is the feature.
-
render.yamlis the Render blueprint — but it is generated. The source of truth isrender.yaml.template(service structure) plusdeployments/<network>.env(non-secret values). A gitignored.env.renderfile may contain the Render API key for script/API operations; treat it as a secret. After editing either file, regenerate and commit:node scripts/generate-render-yaml.mjs # defaults to deployments/base-sepolia.env node scripts/generate-render-yaml.mjs deployments/mainnet.env # for mainnet
Do not edit
render.yamldirectly — your changes will be overwritten. Configure only secrets through the Render dashboard (thesync: falseentries that remain after generation). -
docker-compose.ymlis the source of truth for local. -
deployments/<network>.envis the source of truth for deployed contract addresses and other non-secret deployment values. The deploy script writes it and you commit it;generate-render-yaml.mjsreads it to fill inrender.yaml.
Local Docker builds and Render service builds should use the same install inputs:
- Service Dockerfiles use the pinned
node:24.14.1-alpineimage and pin npm to11.16.0inside the image. Do not switch back to a floating Node/npm toolchain unless local Docker and Render are changed together. - Workspace service Dockerfiles copy every workspace
package.jsonbeforenpm ci. This makes Docker installs validate the normal full-workspace lockfile instead of depending on hand-shaped nested per-workspace lockfile entries. - Direct workspace
viemdependencies are intentionally aligned to exact2.53.1. If a workspace needs a differentviem, treat that as a deliberate dependency split and verify Dockernpm cibehavior before committing. - It is OK to run a normal root
npm install; the resultingpackage-lock.jsonshould be committed and should not require manual lockfile edits.
No Terraform, no Kubernetes, no Pulumi. Resist upgrading until you have a concrete reason.
Generate deployment wallets first:
node scripts/generate-wallets.mjsThis creates/updates two gitignored files:
.env.secrets— generated service hot keys plus finder trust secrets. These are the secrets consumed by local/deployed services.deployments/operator-addresses.env— public wallet addresses, x402 payment recipient addresses,CHANNEL_VERIFIER_TRUSTED_SIGNER_ADDRESS,RECURRING_PLEDGE_SCHEDULER_ADDRESS, and UI default-trust env vars.
Move any operator-only secrets to ~/.secrets/commonality/operator.env (or set COMMONALITY_OPERATOR_SECRETS_FILE to another path). Use .env.operator-secrets.example as the reference. This file holds maintenance/setup secrets only: DEPLOYER_PRIVATE_KEY, ENS_OWNER_PRIVATE_KEY, PINATA_JWT, IPNS_PRIVATE_KEY_*, Cloudflare DNS tokens, and Render API keys.
Then fill the remaining non-generated service values in .env.secrets (use .env.secrets.example as the reference):
-
OPENROUTER_API_KEY— LLM access -
VITE_WALLETCONNECT_PROJECT_ID— from cloud.walletconnect.com -
RPC provider URLs, especially
BASE_SEPOLIA_RPC_URL -
noninflammatory attestation policy. For the first testnet, upload seed statements with
npm exec --workspace=fake-data-generation tsx prepareSeedStatements.ts -- --upload, then run./scripts/setup-testnet-ai-policy.mjs. The setup script reads the approvednoninflammatory-civility-topicseed statement's uploaded CID fromfake-data-generation/output/seed-statements.uploads.json; pass--alignment-topic-statement-cid=<CID>only to override it. This configures both the statelesscontent-attesterfallback and theus-politicsbeat-memory/beat-agent rehearsal; add--x-api-bearer-token=<token>or setX_API_BEARER_TOKENfor Twitter/X ingestion. Review the generatedBEAT_MEMORY_BEAT_DEFINITION_JSONbefore public use. -
deployed service/UI URLs once chosen Put operator-only values in
~/.secrets/commonality/operator.env: -
DEPLOYER_PRIVATE_KEY— used by Hardhat deploy/funding scripts; keep small gas balance only -
ENS_OWNER_PRIVATE_KEY— ENS setup only; move fully cold when possible -
PINATA_JWT— for IPFS uploads -
IPNS_PRIVATE_KEY_TESTNET_*(one per UI subdomain) — generated all at once with./scripts/setup-testnet-naming.sh(or one by one with./scripts/setup-ipns-key.sh) -
Optional Cloudflare DNS automation:
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ZONE_ID -
Optional Render automation:
RENDER_API_KEY
.env.secrets, ~/.secrets/commonality/operator.env, and deployments/operator-addresses.env are gitignored/untracked secret material. Never commit secrets.
The human/operator only needs to use a Base Sepolia faucet for DEPLOYER_ADDRESS in deployments/operator-addresses.env. The deployer needs ETH for contract deployment anyway, and the distribution script can use DEPLOYER_PRIVATE_KEY from the operator secrets file to fund the other transaction-sending wallets, including RECURRING_PLEDGE_SCHEDULER_ADDRESS for permissionless standing-pledge execution pokes and ALIGNMENT_TRUST_BOOTSTRAP_ADDRESS for CauseStarter trust writes.
After the faucet transfer lands, inspect the distribution plan:
node scripts/fund-base-sepolia-wallets.mjs --amount 0.005 --dry-runIf the script reports that the deployer balance is sufficient, send the transfers:
node scripts/fund-base-sepolia-wallets.mjs --amount 0.005 --yesThe script refuses to run if the funder cannot cover the per-wallet transfers, estimated transfer gas, and its reserve balance. By default it sends 0.005 ETH to each operational wallet and leaves 0.02 ETH in the deployer; adjust with --amount or --reserve if needed. If you prefer a separate faucet-funded distribution wallet, set FUNDER_PRIVATE_KEY and pass/use that instead; otherwise the deployer is the intended funder.
- Render — one account is fine for both testnet and prod (we'll use separate blueprints per network).
- Pinata — free tier covers a few CIDs.
- ENS — use
commonality.ethon mainnet L1. We use only mainnet ENS: even for testnet UI deploys, the ENS name lives on L1, becauseeth.limo(and most gateways) only resolve mainnet ENS. The dapp's contract network is independent. - DNS provider for
commonality.works— prefer Cloudflare so setup can be API-driven; otherwise any provider that supports TXT and CNAME records works. - RPC provider (Alchemy or Infura) — the public endpoints in
hardhat.config.cjswork for light use, but paid endpoints are worth it for the indexer.
The verifier CLI is installed by this repository's normal npm install as the @adamspitz/verifier dev dependency. Run verifier commands through the npm scripts or with npx verifier-run; see verifier/README.md if the CLI is unavailable.
Before changing public testnet state, refresh the verifier checks you intend to claim for this milestone, then refresh the root dashboard:
verifier-run automated.test-full
COMMONALITY_VERIFIER_ALLOW_E2E_STACK=1 verifier-run artifact.ipfs-domain-smoke
COMMONALITY_VERIFIER_ALLOW_DESTRUCTIVE=1 verifier-run stack.fresh-seeded
COMMONALITY_VERIFIER_ALLOW_RESTART=1 verifier-run stack.restart-consistency
npm run verifier:rootThe old verifier:release-candidate supervisor has been retired; release-candidate is now a readiness label over the specific checks above plus any relevant manual/LLM reports.
The guarded checks may wipe local dev data, restart local services, or run the E2E stack; omit only with an explicit note in the release-candidate report. Missing/stale manual QA synthesis reports should surface as uncertain, not be treated as a pass.
./scripts/deploy-contracts.sh base-sepoliaKeys are read automatically from the operator secrets file — no env pasting needed. The deploy script uses CHANNEL_VERIFIER_TRUSTED_SIGNER_ADDRESS from deployments/operator-addresses.env for the ChannelVerifier trusted signer on non-local networks.
Contract deployment is incremental. ./scripts/deploy-contracts.sh <net> runs hardhat/scripts/deploy-incremental.js, which writes deployments/<net>.contracts-manifest.json alongside deployments/<net>.env. On later runs it reuses a contract when all of these are still true:
- the manifest fingerprint for that contract matches the current bytecode/ABI/constructor arguments;
- the address in
deployments/<net>.envstill has code on-chain; - any constructor dependency address is unchanged.
If a dependency changes, downstream contracts whose constructor arguments contain that address are redeployed too. This is conservative rather than magical: contracts are not upgradeable, and the script will not mutate an already-deployed contract into a new implementation. Commit both deployments/<net>.env and deployments/<net>.contracts-manifest.json after a non-local deployment so the next operator/machine can make the same incremental decision.
For non-local deployments, CONTRACT_ADMIN_ADDRESS must be set in deployments/operator-addresses.env before running deploy (it must be distinct from DEPLOYER_ADDRESS; the deployer holds gas money only). The deploy script initiates admin transfer for ChannelVerifier and ChannelRegistry using Ownable2Step, transfers DelegatableNotes ownership directly, then automatically runs the admin-acceptance helper only when a deployment left pending admin transfers.
That helper reads CONTRACT_ADMIN_PRIVATE_KEY from the operator secrets file, calls ChannelVerifier.acceptOwnership() and ChannelRegistry.acceptOwnership() after verifying the key matches CONTRACT_ADMIN_ADDRESS, and verifies DelegatableNotes.owner() already equals the admin address. If you ever need to repair or re-run just the acceptance step, it is idempotent:
./scripts/accept-admin-ownership.sh base-sepoliaThis writes contract addresses (including CONTRACT_ADMIN_ADDRESS) to deployments/base-sepolia.env and detailed metadata to hardhat/deployments/base-sepolia-<timestamp>.json.
After deploying content-funding contracts, explicitly configure economics before inviting users:
- Base Sepolia testnet intentionally uses the project-owned faucetable dev token (
USDZZZ) so testers can be funded freely. This is a deliberate testnet-only exception, not a production precedent. - Mainnet/production MVP uses USDC. Use only a vetted standard ERC-20 settlement token (no fee-on-transfer/rebasing/callback tokens). Mainnet must not use a project-owned mintable test token.
- Ensure admin ownership is on Adam's cold admin key or Safe multisig before inviting funds:
ChannelVerifier.owner(),ChannelRegistry.owner(), andDelegatableNotes.owner()should all equalCONTRACT_ADMIN_ADDRESS. - Set
CreatorAssuranceContractFactory.thirdPartyMinPurchaseto a meaningful minimum in settlement-token units. - Keep
thirdPartyMaxDurationbounded (default 7 days, matching the default channel veto window) unless there is a deliberate anti-squatting reason to change it.
Commit deployments/base-sepolia.env to git. Services read addresses from it.
Optionally verify on Basescan:
npx hardhat verify --network base-sepolia <address> <constructor-args>First time only:
-
Make sure
render.yamlis up to date:node scripts/generate-render-yaml.mjsand commit if it changed. -
In Render, New → Blueprint, connect to this GitHub repo.
-
Render reads
render.yamland creates the declared web, private, and worker services plus the indexer Postgres database. This includes the persistent-diskcommonality-alignment-trust-bootstrapworker. -
For each service, open its dashboard and set the
sync: falseenv vars. Use the helper script to generate a per-service block you can paste into Environment → Add from .env:node scripts/generate-render-secrets.mjs
It reads
.env.secrets,deployments/operator-addresses.env, anddeployments/base-sepolia.envand prints one block per service.ALIGNMENT_TOPIC_STATEMENT_CIDwill be missing until you runscripts/setup-testnet-ai-policy.mjs— add it to the attesters service afterward.
For the alignment-trust worker, also open its Render Shell and put
ALIGNMENT_TRUST_DENYLISTED_ADDRESS from deployments/operator-addresses.env in
/data/denylist.txt. Funding, pause/resume, denylist editing, and verification
procedures are in alignment-trust-bootstrap/README.md.
Subsequent deploys: just git push. Render rebuilds automatically (autoDeploy: true).
Do not add Render custom domains for each service. Render is compute; Cloudflare is the public edge/naming layer. Deploy the Cloudflare Worker gateway in cloudflare-service-gateway/ so one hostname routes to the Render *.onrender.com service origins:
https://services.testnet.commonality.works/indexer/*https://services.testnet.commonality.works/platform-api/*https://services.testnet.commonality.works/attesters/*https://services.testnet.commonality.works/workers/*
Verify after the Worker is deployed:
curl https://services.testnet.commonality.works/attesters/health
curl https://services.testnet.commonality.works/workers/health
curl https://services.testnet.commonality.works/indexer/graphql
curl https://services.testnet.commonality.works/platform-api/healthThe deployed testnet configuration should use the Cloudflare gateway URLs above. Direct Render *.onrender.com URLs are only a debugging fallback for isolating gateway problems; do not bake them into deployments/base-sepolia.env or release UI bundles when the gateway is healthy.
Before building the UI, set VITE_EVENT_CACHE_URL in .env.secrets to the public base URL of the deployed indexer, for example:
VITE_EVENT_CACHE_URL=https://services.testnet.commonality.works/indexerRun ./scripts/setup-env.sh base-sepolia after wallet generation. It publishes
the chain-scoped VITE_DEFAULT_ALIGNMENT_TRUST_ROOT derived from the dedicated
bootstrap key into both the domain UI and CauseStarter Vite configuration.
The IPFS UI cannot use the local Vite proxy, so this URL is written into ui/.env by scripts/setup-env.sh and emitted into each domain's runtime config.json by the Vite build. scripts/deploy-ui.sh will stop early if VITE_EVENT_CACHE_URL is missing.
We pin each UI build to IPFS (Pinata) and point to it through a stable IPNS name (one per UI subdomain). The ENS contenthash and the Cloudflare UI gateway configuration both reference that IPNS name — and stay unchanged forever. Per-deploy work is a single w3name publish (free, no transaction, no DNS change).
This is testnet-only. On mainnet we pin the ENS contenthash directly to immutable IPFS CIDs (see "Mainnet differences" below), so every UI deploy is an on-chain transaction. That gas friction is intentional — it acts as a deploy-control gate, and there's no IPNS private key whose loss could let someone swap the live UI.
Pinata pin w3name publish Cloudflare UI gateway
build/ ──────────────▶ CID ──────────────────▶ IPNS ─────▶ resolve IPNS → CID
fetch/cache /ipfs/{cid}/...
serve *.testnet.commonality.works
▲
│ unchanged after one-time setup:
│ • Worker env var → IPNS name
│ • ENS contenthash → ipns://<name> (kept for ENS-native resolvers,
│ but nested eth.limo HTTPS is currently not operational; see note below)
The same IPNS name backs the ENS contenthash and the *.testnet.commonality.works Worker route, but only the .works URL is currently expected to work in browsers.
Do this once for testnet, again for mainnet. It costs a few mainnet-ENS transactions then never costs gas again.
-
Generate local naming material:
./scripts/setup-testnet-naming.sh
This is safe/idempotent and does not touch external services. It creates or reuses one IPNS key per UI, appends missing
IPNS_PRIVATE_KEY_TESTNET_*values to the operator secrets file, and writes the public IPNS names todeployments/testnet-ipns.env. -
ENS prerequisite — create subdomains/resolvers (mainnet L1). The script detects whether the parent is wrapped and uses the ENS Name Wrapper when required:
./scripts/create-ens-subdomains.sh --inspect ./scripts/create-ens-subdomains.sh --yes
This creates/updates
testnet.commonality.ethpluscommonality,lazygiving,alignment,tally,content-funding,civility,common-sense-majority, andconceptspaceunder it, each with the ENS public resolver.If
commonality.workshas first been imported into ENS via DNSSEC, the same script can target that ENS name instead:./scripts/create-ens-subdomains.sh --root commonality.works --inspect ./scripts/create-ens-subdomains.sh --root commonality.works --yes
DNSSEC-importing
commonality.worksitself is still a one-time ENS/DNSSEC setup step outside this script; after import, subdomain creation is ordinary ENS automation. If we decide to usecommonality.worksas the ENS root for testnet contenthashes, updateensRootindeployments/testnet-names.jsonfromcommonality.ethtocommonality.worksbefore the next step. -
Set ENS contenthashes automatically after the ENS names/resolvers exist:
./scripts/setup-testnet-naming.sh --ens --yes
This calls
scripts/update-ens.shfor each UI and submits one mainnet transaction per UI name, pointing the ENS contenthash at that UI'sipns://<name>. -
Deploy the Cloudflare UI gateway. The current production path is Worker proxying, not DNSLink CNAMEs. See
cloudflare-ui-gateway/:source ~/.secrets/commonality/operator.env echo "$PINATA_GATEWAY_KEY" | npx wrangler secret put PINATA_GATEWAY_KEY \ -c cloudflare-ui-gateway/wrangler.testnet.toml npx wrangler deploy -c cloudflare-ui-gateway/wrangler.testnet.toml
The Worker resolves each configured w3name IPNS value to a CID, caches IPNS→CID in KV, fetches immutable CID paths through IPFS gateways, and caches successful CID responses at Cloudflare.
-
DNS records: each UI hostname needs a proxied Cloudflare DNS record so the Worker route can receive traffic. The current records are proxied CNAMEs; their target is not semantically important because the Worker intercepts before origin. The old DNSLink TXT records are harmless but no longer the browser-serving mechanism.
After this, alignment.testnet.commonality.works serves whatever the IPNS record currently points at, subject to the Worker's short CID cache TTL.
Known eth.limo limitation for nested testnet subdomains: despite eth.limo's documentation saying nested ENS gateway names should work when the exact subdomain has a resolver and contenthash, https://alignment.testnet.commonality.eth.limo/ currently fails during the TLS handshake with SSL_ERROR_INTERNAL_ERROR_ALERT. We verified on 2026-06-01 that:
alignment.testnet.commonality.ethexists, has a resolver, and has a valid contenthash.- Its IPNS name resolves to a pinned IPFS CID.
- Temporarily changing the ENS contenthash from
ipns://k51qzi5uqu5dgvr67vrtjpjg7x54uqmfgf8zkv83zyju33eqnff6hrledwxhz1to directipfs://QmZc2MNaAAu4ZFFDGRNj5Z3qArxfi6Wipvb36EoWwEAbPjdid not fix the TLS failure after waiting for eth.limo cache/certificate issuance. - The contenthash was restored to IPNS afterward.
Do not repeat that mainnet ENS transaction experiment unless there is new evidence that eth.limo changed its nested-subdomain behavior. Treat *.testnet.commonality.works as the public testnet URL for now; the nested *.testnet.commonality.eth.limo URLs are not operational.
./scripts/deploy-testnet.shThis builds each UI for base-sepolia, pins it to Pinata, and publishes a new IPNS revision under the matching key. No on-chain transaction, no DNS change. Total wall time: a couple of minutes for all eight UIs.
To deploy a subset:
DOMAINS="alignment lazygiving" ./scripts/deploy-testnet.shTo deploy a single UI by hand (for debugging):
./scripts/deploy-ui.sh base-sepolia alignment # → prints CID
./scripts/publish-ipns.sh IPNS_PRIVATE_KEY_TESTNET_ALIGNMENT <cid>Visit https://alignment.testnet.commonality.works to verify. Do not use https://alignment.testnet.commonality.eth.limo as the testnet browser smoke target unless the known nested-subdomain TLS limitation above has been resolved.
After the deployed services/UI are reachable, run the configured testnet verifier smoke and refresh the dashboard:
COMMONALITY_VERIFIER_ENABLE_TESTNET_SMOKE=1 \
COMMONALITY_TESTNET_RPC_URL=https://... \
COMMONALITY_TESTNET_GRAPHQL_URL=https://... \
COMMONALITY_TESTNET_APP_URL=https://alignment.testnet.commonality.works \
verifier-run env.testnet-smoke
npm run verifier:rootSupported testnet host slugs: commonality (default), lazygiving, alignment, tally, content-funding, civility, common-sense-majority, conceptspace. (deploy-testnet.sh still maps lazygiving to the UI build's legacy lazyGiving domain internally.)
If nested ENS subdomains remain unusable on eth.limo, deploy one root IPFS directory containing all eight independently built apps:
./scripts/deploy-testnet-uber-ui.shThe script:
- runs
setup-env.sh base-sepolia, - builds all eight IPFS/hash-router UI domains with path-hosted cross-app links,
- pins each standalone app bundle and records its CID,
- assembles those exact artifacts under
/testnet/<slug>/, - pins the root bundle, and
- writes
deployments/testnet-ui-uber-release.json.
Default cross-app links are relative, so the bundle works when loaded from any root gateway path. To bake absolute links for the intended eth.limo name, run:
UBER_PUBLIC_BASE_URL=https://commonality.eth.limo ./scripts/deploy-testnet-uber-ui.shThe script also publishes the root CID to IPNS_PRIVATE_KEY_TESTNET_UBER_UI when that key exists. Create the key with the normal naming setup script:
./scripts/setup-testnet-naming.shThen set commonality.eth to that uber-bundle IPNS name once:
./scripts/setup-testnet-naming.sh --ens --uber --uber-only --yesAfter that, each ./scripts/deploy-testnet-uber-ui.sh run updates the IPNS record without another ENS transaction. If eth.limo or other public gateways fail to resolve the w3name-backed IPNS record, use the printed direct-CID fallback instead:
./scripts/update-ens.sh commonality.eth <uber-root-cid> --network mainnetThat costs one ENS transaction per uber-bundle release but keeps the same path-hosted URL shape. The resulting URLs are hash-routed paths such as:
https://commonality.eth.limo/testnet/alignment/#/
https://commonality.eth.limo/testnet/tally/#/statements
This does not remove the per-app split-off path: each app CID is pinned and recorded separately, so a future alignment.commonality.works or alignment.commonality.eth.limo name can point directly at the same standalone alignment CID/IPNS record. deployments/testnet-ui-uber-release.json records the uber root CID, root IPNS name (when published), and all standalone app CIDs.
For mainnet we skip the IPNS layer and pin the ENS contenthash directly to immutable IPFS CIDs. Per-deploy flow per UI:
./scripts/deploy-ui.sh mainnet <domain>— build, pin, print CID../scripts/update-ens.sh <domain>.commonality.eth <cid> --network mainnet— submit one on-chain transaction setting the contenthash toipfs://<cid>.
So a full mainnet release is eight on-chain transactions. At typical gas this is roughly $5–20 per release. We accept that cost because:
- Whoever holds
ENS_OWNER_PRIVATE_KEY(which can be a multi-sig) is the only party who can change a live UI — there's no IPNS key file lying around. - A contenthash is immutable; no concept of "the IPNS service returned stale data."
- The transaction record is an auditable deploy log.
The *.commonality.works mainnet subdomains can either (a) keep DNSLink TXT records updated per release to the same CID, or (b) be configured as Cloudflare-proxied CNAMEs to <name>.commonality.eth.limo so they track the ENS contenthash automatically. Option (b) avoids per-release DNS work but requires Cloudflare's CNAME-flattening / TLS termination so the cert is for *.commonality.works. Decide before mainnet.
A dedicated scripts/deploy-mainnet.sh wrapper doesn't exist yet — write it as part of pre-mainnet prep, modeled on deploy-testnet.sh but calling update-ens.sh instead of publish-ipns.sh.
IPNS resolution is cached by gateways (eth.limo, cloudflare-ipfs.com, etc.) for a few minutes after a publish. Users may see the previous CID for that window. This matches how IPFS deploys cached today and is not specific to IPNS.
The Render blueprint now includes both the commonality-indexer web service and a commonality-indexer-db Postgres database.
Set these indexer env vars in the Render dashboard:
PONDER_CHAIN:base-sepoliafor testnet ormainnetfor productionPONDER_RPC_URL_84532orPONDER_RPC_URL_1: RPC URL for the selected chainSTART_BLOCK: block where the deployed contracts start emitting relevant events- All contract addresses from
deployments/<network>.env
The blueprint already wires:
PONDER_SCRIPT=startso hosted deployments useponder startDATABASE_URLfrom the managed Postgres databaseDATABASE_SCHEMA=commonality_base_sepolia_v4for the current Base Sepolia deployment. Use a fresh schema only when intentionally abandoning stale indexed data; otherwise keep the schema stable.PONDER_EXPERIMENTAL_DB=platformso normal Render redeploys of a changed Ponder build can reuse the same production schema instead of failing with "previously used by a different Ponder app".PONDER_ETH_GET_LOGS_BLOCK_RANGE=10for Base Sepolia because the current Alchemy free-tier RPC rejects widereth_getLogsranges. If the RPC plan is upgraded, raising this value can make historical backfill faster.- For the first Render rehearsal,
START_BLOCKis intentionally near the current chain head to avoid free-tier RPC rate limits during backfill. If you need older testnet events, lowerSTART_BLOCKand switch to a fresh Ponder schema (or drop the existing schema) after upgrading RPC capacity. - The indexer declares a small persistent disk even though it does not store application data there. This is an intentional Render workaround, not indexer storage: Render disables zero-downtime/rolling deploys for services with disks, which gives Ponder the stop-before-start deployment behavior it needs for the exclusive
DATABASE_SCHEMAlock. Do not remove this disk just because/dataappears unused unless the indexer has moved to a cleaner singleton-writer deployment model.
If commonality-indexer is healthy but a manual or automatic Render deploy fails, check the deploy logs for:
MigrationError: Failed to acquire lock on schema "...". A different Ponder app is actively using this schema.
This happens because Render web services deploy with rolling/zero-downtime semantics: the new container starts before the old live container stops. ponder start needs an exclusive Postgres schema lock, so the new indexer cannot initialize while the old indexer is still running against the same DATABASE_SCHEMA.
The intended Render fix is the persistent disk declared on commonality-indexer in render.yaml.template: Render services with disks deploy stop-before-start instead of zero-downtime, avoiding two simultaneous Ponder writers on the same schema. This is admittedly a platform-specific workaround rather than a beautiful architecture knob. The disk is operationally significant even though the indexer does not use /data for app state; do not delete it during cleanup/refactoring unless you replace it with another guaranteed singleton deploy strategy.
If the disk is not attached yet, or if a rehearsal needs an emergency dashboard-green workaround, you can still use a fresh schema:
-
Pick a fresh schema name in
render.yaml.template(DATABASE_SCHEMA=commonality_base_sepolia_v<N+1>). -
If abandoning old testnet history is acceptable, bump
START_BLOCK/CONTENT_FUNDING_START_BLOCKindeployments/base-sepolia.envnear the current chain head so the free-tier RPC does not have to backfill much history. -
Regenerate and commit
render.yaml:node scripts/generate-render-yaml.mjs npm run smoke-check
-
Push and let Render auto-deploy.
Do not treat fresh schemas as the long-term production answer. Before a real production deployment, either configure the indexer so the old process is stopped before the new one starts (if Render supports disabling rolling deploys for this service), or split indexing from serving so the singleton writer can deploy separately from read-only HTTP serving.
For local Docker development, the same image still defaults to PONDER_SCRIPT=dev:no-ui and PONDER_CHAIN=hardhat.
All service-specific env vars are documented in each service's README. Quick pointers:
service-host/README.md— unified host for attester/finder/nudger logical servicesplatform-api-service/README.md
The bottom of render.yaml lists which vars are secrets (set in Render dashboard) per service.
.env.secrets ← you fill in once (gitignored)
deployments/<net>.env ← deploy script writes (committed)
│
▼ for local dev: scripts/setup-env.sh <network>
▼ for Render: copy-paste into dashboard once per service
│
service-local .env files ← each service reads its own
setup-env.sh is only used for local development. On Render, env vars live in the dashboard; use scripts/generate-render-secrets.mjs to generate the per-service blocks for bulk-pasting.
- Push to master.
- Render auto-builds and deploys (per-service, independently).
- Check
/healthendpoint afterward.
For mainnet, consider setting autoDeploy: false per service and triggering manual deploys from tagged commits.
Contracts are not upgradeable in this codebase. Redeploying changed contracts means:
./scripts/deploy-contracts.sh <net>incrementally deploys only contracts whose bytecode/ABI/constructor inputs changed, plus downstream contracts whose constructor inputs now point at new addresses. It writes updated addresses and the deployment manifest.- Commit the updated
deployments/<net>.envanddeployments/<net>.contracts-manifest.json. - Regenerate and commit
render.yaml:node scripts/generate-render-yaml.mjs. This fills in the new contract addresses automatically — no Render dashboard edits needed for addresses. - Redeploy UI (addresses are baked into the bundle):
./scripts/deploy-testnet.sh. The IPNS pointer updates automatically; ENS contenthash does not need a new transaction. - If any indexed contract address changed, old indexer data for that contract set is wrong — wipe the indexer Postgres or use a fresh schema and resync from the new
START_BLOCK/contract start block.
This is intentionally high-friction. For testnet it's tolerable; for mainnet, consider adding an audit pass before each redeploy.
./scripts/deploy-testnet.shBuilds, pins, and publishes new IPNS revisions for all UIs. No ENS or DNS changes needed. Gateway caches may serve the previous CID for a few minutes.
Do not deploy to mainnet until all of these are checked. As a final workflow gate, refresh the relevant release-candidate/testnet checks, write the final QA synthesis report, then run:
npm run verifier:root
npm run verifier:stateThe old verifier:full-launch supervisor has been retired; do not interpret an uncertain root result as approval. It means the verifier is missing fresh evidence or a human/LLM sign-off.
- Professional smart-contract audit passed
- Generative / invariant testing complete
- Emergency pause procedures documented
- Separate wallets for deployer, attester(s), nudger(s), verifier, ENS owner — never reuse keys
- Private keys stored in a password manager or hardware wallet, not plaintext
- Mainnet
DEPLOYER_PRIVATE_KEYonly used for deployment, then retired
- Testnet indexer has been running stably for at least a week
- Paid RPC endpoint (Alchemy/Infura) configured — public endpoints will rate-limit the indexer
- Render services moved off
plan: standardonly if load requires it (standard is fine to start) -
autoDeploy: falseon mainnet services; deploy from tagged releases - Postgres add-on has automated backups enabled
- Monitoring: at least Sentry (or similar) on the AI services
- Contracts verified on Basescan
-
deployments/mainnet.envcommitted -
ChannelVerifier.trustedVerifier()matchesCHANNEL_VERIFIER_TRUSTED_SIGNER_ADDRESSfromdeployments/operator-addresses.env, and Renderplatform-api-serviceuses the matchingVERIFIER_PRIVATE_KEY - Tenderly or similar alerts set up for unusual contract activity
- UI builds against mainnet addresses
- Mainnet ENS subdomain tree exists under
commonality.eth, each with the public resolver - CIDs pinned to Pinata
- ENS contenthashes set directly to
ipfs://<cid>(immutable; no IPNS on mainnet) — one tx per subdomain per release -
commonality.worksmainnet subdomains configured (either DNSLink TXT updated per release, or Cloudflare-proxied CNAMEs to*.commonality.eth.limo— decide which) -
scripts/deploy-mainnet.shwritten (modeled ondeploy-testnet.sh) -
ENS_OWNER_PRIVATE_KEYfor mainnet is in a hardware wallet or multi-sig, not plaintext on disk - All eight UIs load via both
<name>.commonality.eth.limoand<name>.commonality.works, wallet connection works on each
- Manual smoke test: create belief, add implication, receive nudge, fund a creator
- Announcement prepared
- Attester and nudger services:
GET /health - Indexer (once deployed):
POST /graphql { "query": "{ _meta { block { number } } }" } - IPFS (Pinata): visit
https://gateway.pinata.cloud/ipfs/<cid> - ENS: visit
https://<name>.eth.limo
Render streams logs per service. For anything beyond casual debugging, add Sentry (or similar). Render log retention is limited.
- Services: Render keeps previous Docker images. In the dashboard: Deploys → pick a previous successful deploy → "Rollback to this deploy."
- UI: Publish a new IPNS revision pointing at the previous CID:
./scripts/publish-ipns.sh IPNS_PRIVATE_KEY_TESTNET_<DOMAIN> <previous-cid>. CIDs are immutable; as long as Pinata still has the old one pinned, rollback is fast (modulo gateway cache lag). No ENS or DNS change needed. - Contracts: no rollback. Deploy a new version and update addresses everywhere.
- Render: 4 services × ~$25/mo standard =
$100/mo, plus the Postgres add-on. Can probably drop several to$7/mo) once load is known.starter( - Render Postgres for indexer: ~$7–20/mo depending on plan.
- Pinata: free tier covers a few CIDs; paid starts at ~$20/mo.
- RPC (Alchemy/Infura): free tier covers light testnet use; production indexer probably needs ~$50/mo.
- Total rough ballpark: $250–400/mo before mainnet gas.
See TODO.md for the prioritized list. Deployment-relevant items:
- Indexer operations hardening — the indexer is already in the Render blueprint; keep monitoring normal redeploys for Ponder schema-lock issues and use the Indexer on Render recovery notes if they recur.
- Second smart-contract audit pass.
- Monitoring / alerting — only Render's built-in logs today.
- Staging environment — Render supports preview environments per PR; worth enabling before the project has real users.
| Symptom | Likely cause | Fix |
|---|---|---|
| "Insufficient funds for gas" | Deployer wallet empty | Send ETH; for Base Sepolia use the Coinbase faucet or bridge from Ethereum Sepolia |
| Service builds but crashes on boot | Missing env var | Check Render logs; compare to .env.example in the service dir |
| Attester never attests | Attester wallet has no ETH, or wrong IMPLICATIONS_CONTRACT_ADDRESS |
Check wallet balance; compare addresses to deployments/<net>.env |
| Indexer not syncing | Wrong START_BLOCK, wrong RPC URL, schema-lock/deploy issue, or stale deployment values |
See Indexer on Render and the Ponder schema-lock trap in that section |
| UI loads but can't read contracts | Stale bundle with old addresses | Rebuild UI with ./scripts/deploy-ui.sh <network> and update ENS |
| ENS update transaction reverts | ENS_OWNER_PRIVATE_KEY doesn't own the name |
Verify ownership at app.ens.domains |
eth.limo returns 404 / "name not found" |
ENS subdomain missing or has no resolver set | In app.ens.domains, ensure the subdomain exists and has the public resolver set; setContenthash on a name with no resolver silently no-ops |
*.commonality.works loads but shows stale content |
Gateway is caching the old IPNS resolution | Wait a few minutes; or force-refresh through cloudflare-ipfs.com/ipns/<name> to confirm IPNS is updated |
publish-ipns.sh fails with "sequence too low" |
Multiple machines published to the same IPNS key out of order | Always publish from a single machine; w3name's resolve then increment flow handles ordering automatically as long as you don't race |
For anything not here, check Render logs first, then the service's own README.