Deploying your own network
The system on a chain of your own, in the order the steps have to happen; the contracts and services deploy to any EVM chain. deploying.md has the longer notes on each step, Operating the network the running system, and Security review the hardening record for the code you are deploying.
1. Prepare the chain and the keys
Pick the chain (RPC_URL, CHAIN_ID). Fund a deployer key and keep it on a hardware wallet or
behind a multisig: it is used once, in step 2, then hands everything to the Timelock. Every key
the network signs, pays and lists with, where each comes from and its exact .env line:
Keys to launch.
The deploy needs addresses on the chain or of your choosing: the chain's USDC
(USDC_ADDRESS; a test network without one sets TESTNET_USD=1 and gets zUSD, the testnet's own dollar, which the deployer
mints with npm run zusd -- mint <address> <amount>), the canonical Permit2 (PERMIT2_ADDRESS,
0x000000000022D473030F116dDEE9F6B43aC78BA3 on every chain), the chain's canonical ERC-4337
EntryPoint (ENTRYPOINT_ADDRESS), the ZAI token (ZAI_ADDRESS), the fee and slash sink
(TREASURY_ADDRESS) and the DisputeManager arbitrator (ARBITRATOR_ADDRESS). Last, who signs
gas sponsorships (SPONSOR_SIGNER_ADDRESS): the key of your sponsorship service. Without it the
deploy skips the paymaster and users pay their own gas.
The token. On a real chain the deploy mints AIOUMToken, capped, with the whole initial
supply to the treasury and the DAO able to mint up to the cap later: ZAI_INITIAL_SUPPLY and
ZAI_CAP, both required, or ZAI_ADDRESS for a token deployed before. Nothing is open-mint
outside the devnet. Tokens reach anyone the way the treasury hands them out: a sale, grants, a
pool on a DEX, the staking pool. When the deployer is the treasury, the deploy fills the faucet's
wallet and the staking pool at once (FAUCET_ADDRESS/FAUCET_ZAI, BACKER_ADDRESS/BACKER_ZAI).
The testnet faucet. npm run faucet gives 1,500 ZAI once per wallet, enough for the own
stake and ten listing bonds. What stops a script from minting addresses and draining it: one drip
per address ever; the address must already hold gas on the chain, which it needs to stake anyway
and which the gas faucets gate by account; a proof of work per claim bound to the address, about
a second of hashing each and hours for thousands; the claim signed by the address; a few claims
per IP a day; and a budget of drips a day for the whole faucet, so a drain is bounded by the day.
The private node claims for its wallet from the app's Stake tab, solving the work itself, so the
wallet's key never leaves it.
2. Deploy the contracts
offchain/src/deploy.ts is env-driven. Setting DEPLOYER_PRIVATE_KEY or USDC_ADDRESS (or
REAL_CHAIN=1) puts it in real-chain mode: no mock tokens, no demo minting, the canonical Permit2
and EntryPoint, and a post-deploy [verify] gate. See
offchain/.env.example for every key.
cd offchain
RPC_URL=... CHAIN_ID=... DEPLOYER_PRIVATE_KEY=0x... \
USDC_ADDRESS=0x... PERMIT2_ADDRESS=0x000000000022D473030F116dDEE9F6B43aC78BA3 ZAI_ADDRESS=0x... \
ENTRYPOINT_ADDRESS=0x... TREASURY_ADDRESS=0x... ARBITRATOR_ADDRESS=0x... \
SPONSOR_SIGNER_ADDRESS=0x... \
npm run deploy
On a real chain the script refuses to run without TREASURY_ADDRESS and ARBITRATOR_ADDRESS
(neither may default to the deployer) or a canonical ENTRYPOINT_ADDRESS (it never deploys a
throwaway EntryPoint). USE_COMMIT_REVEAL_ARBITRATOR=1 deploys the open, staked commit-reveal
jury as the DisputeManager arbitrator. With SPONSOR_SIGNER_ADDRESS set it deploys
AIOUMVerifyingPaymaster, unfunded until step 7. DEPOSIT_FEE_BPS sets the conversion fee on
deposits from other chains and assets (100 = 1%, the default; at most 1,000); the
DepositForwarderFactory deployed with it is written as depositFactory and
depositFeeBps. It finishes by writing offchain/deployments.json, the one file to publish
straight away: the app, the SDK, the services and the subgraph all read it.
3. Hand ownership to the Timelock
Deploy a TimelockController with the guardian multisig as proposer and canceller, and
AIOUMGovernor(token, timelock) with the proposer role, so proposals flow Governor →
Timelock → contracts. Then transfer every governance contract to the Timelock:
cd offchain
DAO_OWNER=0x<timelock> TIMELOCK_ADDRESS=0x<timelock> CONFIRM_HANDOVER=1 npm run handover
TIMELOCK_ADDRESS pins the target: the script refuses a mismatch, 0x0 (which would freeze
every setter forever), the deployer itself, or an address with no contract code. The transfer is
single-step and irreversible: from then on no single key can change the rules an agent's budget
depends on. Renounce the deployer's Timelock admin role so only the Governor can queue, then
verify, in CI and at release:
TIMELOCK_ADDRESS=0x<timelock> npm run verify-deploy
It exits non-zero unless the Timelock owns every governance contract, the arbitrator and treasury are the configured addresses, Permit2 and the EntryPoint are canonical, and no accept-everything paymaster holds a deposit.
From here on, all wiring (setRewarder, setDisputeManager, setRewardAsset,
setPaymentManager, setArbitrator) and every parameter change is a Timelock proposal.
4. Run the services
The services take the work off the person's plate: the node that serves paid calls, the facilitator and bundler that pay gas so an agent never holds ETH, the deposit service that turns what a person sends from any chain into USDC in their AI wallet, the indexer that answers an agent's own searches during a run, the agent runner behind the Deploy Agents pages, the relay, billing, TLS at the edge. One command stands all of them up on a server:
cp offchain/.env.example .env # the chain, DOMAIN, the keys ([Keys to launch](./appendix/launch-keys.md))
scripts/deploy-network.sh install # Foundry, Node 22, every package built, Caddy, mdbook
scripts/deploy-network.sh deploy # contracts, catalogue, one unit per service, TLS, the app
deploy checks that the RPC answers for CHAIN_ID; deploys the contracts when
deployments.json has no entry for that chain (step 2, from the same .env) and otherwise checks
they have code and, with TIMELOCK_ADDRESS set, runs npm run verify-deploy; reads the machine
(npm run hardware:check: CPU, RAM, GPUs, Docker and its NVIDIA runtime, Ollama) so a local
model or a GPU job is listed only when the host meets what its template requires; builds the
catalogue the node lists, every keyless template plus what each key in .env unlocks (npm run catalogue:bootstrap; MCP servers only with AIOUM_CATALOGUE_MCP=verified or all, and never one
marked hostAccess, which an operator lists by hand with allowHost: true on the listing); writes one systemd unit per service (aioum-<name>; a pm2 process where
there is no systemd) carrying only the settings that service reads, its secrets in a file of its
own; writes the Caddyfile for DOMAIN, the app at the apex and node., indexer., agents.,
facilitator., bundler., deposits., backer., relay., billing. in front of the services, certificates
automatic; builds the app with the NEXT_PUBLIC_* addresses; starts everything and waits on each
service's own /live. It ends with the URLs, the services it left out, each with the key that
starts it (on a real chain a service without its key never starts), and the listings skipped for
lack of keys. Run it again after editing .env: only what changed restarts. status, logs <name>, update (pull, rebuild, deploy) and down round it out; DRY_RUN=1 prints every unit and
the Caddyfile it would write. The private node is off by default (PRIVATE_NODE=1 with a
passphrase runs one on the server); the monitor is started by hand.
The same script runs a single node for a provider joining a network that already exists:
SERVICES=multinode with AIOUM_SERVICES_CONFIG pointing at the listings and AIOUM_INDEXER_URL
at the network's directory, which the node announces itself to (Integrate your service
directly).
What each unit carries, and the settings behind it:
| Service | Start | Production settings |
|---|---|---|
| Marketplace node | npm run multinode (:8556) with the catalogue from npm run catalogue:bootstrap | Operator key from a keystore or OPERATOR_PRIVATE_KEY: on a real chain one key serves every listing, holding ZAI for its stake and ETH for gas. AIOUM_NODE_TOKEN on /settle (generated once by the deploy when unset). Every upstream key from .env. Voucher store on persistent disk (AIOUM_NODE_DIR; AIOUM_STORE=sqlite to share one store between instances). Behind TLS. Monitor /health. |
| Facilitator | npm run facilitator (:8547) | Fund the gas key (FACILITATOR_PRIVATE_KEY or RELAYER_PRIVATE_KEY). Set FACILITATOR_API_KEY, FACILITATOR_MAX_AMOUNT and FACILITATOR_RATE_MAX; FACILITATOR_TRUST_PROXY=1 behind a proxy. Run several on distinct keys behind the load balancer; it is non-custodial, so anyone can run one. |
| Bundler | npm run bundler (:8548) | Fund BUNDLER_PRIVATE_KEY (or RELAYER_PRIVATE_KEY). Run several, like the facilitator. Keep the paymaster's EntryPoint deposit funded or sponsored operations revert. |
| Deposit service | npm run deposits (:8551) | Fund DEPOSITS_PRIVATE_KEY, the sweeper key that pays gas for every sweep and holds no user funds. Set LIFI_API_KEY for the route provider (LIFI_API_URL to point at another endpoint). Ledger on persistent disk (AIOUM_DEPOSITS_DIR). Loopback by default; AIOUM_DEPOSITS_HOST=0.0.0.0 only behind TLS, with the app's NEXT_PUBLIC_DEPOSITS_URL pointed at it. It serves every chain the provider delivers to your chain from; on a chain the provider does not deliver to, it says so at start and only USDC sent straight to a deposit address is credited. |
| Staking node | npm run backer (:8552) | Fund BACKER_PRIVATE_KEY: the ZAI the pool delegates behind listings (slashable like any delegation), a little USDC for the sample call it makes through each node, and gas. Set the reviewer: ANTHROPIC_API_KEY, an OpenAI-compatible endpoint (AIOUM_AGENTS_OPENAI_*), a local Ollama, or a judge listing in BACKER_REVIEW_SERVICE; without one it approves nothing. BACKER_MAX_PER_OPERATOR_ZAI caps what one operator gets; BACKER_RUBRIC is what the model reads. Ledger on persistent disk (AIOUM_BACKER_DIR). Loopback by default; AIOUM_BACKER_HOST=0.0.0.0 only behind TLS, with the app's NEXT_PUBLIC_BACKER_URL pointed at it. |
| Discovery indexer | npm run indexer (:8549) | The app's NEXT_PUBLIC_INDEXER_URL; behind TLS. |
| Shared runner | AIOUM_AGENTS_MODE=shared npm run agents | Runs the agents of people who sign in with their wallets; each person's account pays, within a cap they sign, and your fee per run reaches you through its listing (shared-runner template). AIOUM_AGENTS_KEYS_SECRET seals their spending keys at rest: keep it. AIOUM_AGENTS_URL is the address the listing names; behind TLS. Records on persistent disk (AIOUM_AGENTS_DIR); the skills index from npm run skills:index in AIOUM_SKILLS_DIR. A person's own runner needs nothing from you: it runs inside their private node. |
| Paid reviewer | AIOUM_AGENTS_MODE=own AIOUM_AGENTS_REVIEWER=1 npm run agents | Bonds ZAI (AIOUM_REVIEWER_PRIVATE_KEY, AIOUM_REVIEWER_BOND_ZAI) and reviews the listings the chain draws it for, paid in ZAI; a judge listing or a local Ollama scores the calls. |
| Planner listing | a planner-* template in a node's services.json | A marketplace listing that plans for assistants, paid per turn. Anyone may run one; the key stays with its operator. |
| Private node | see Your private node | Expose only over TLS: PRIVATE_NODE_HOST=0.0.0.0 behind Caddy, OS firewall closed to :8770. Keep encrypted, user-controlled backups of secrets.enc, memory.json and jobs.json. |
| Relay | npm run relay (:8553) | AIOUM_RELAY_PRIVATE_KEY, a key of its own (it stakes and settles like a node); AIOUM_RELAY_TARGETS the catalogue node; AIOUM_RELAY_MARGIN_BPS. |
| Billing | npm run billing (:8550) | SPONSOR_SIGNER_KEY (its address is SPONSOR_SIGNER_ADDRESS at deploy, so the manifest has a verifying paymaster), BILLING_TREASURY_KEY, BILLING_ADMIN_TOKEN. |
| TLS | written by deploy from DOMAIN (deploy/Caddyfile is the hand-written shape) | DOMAIN and *.DOMAIN pointed at the host, ports 80/443 open, every other port closed; certificates are automatic. |
| Monitor | npm run monitor (by hand; not a unit yet) | ALERT_WEBHOOK_URL for alerts, TIMELOCK_ADDRESS to watch governance, MONITOR_SCRAPE_TOKEN if /metrics is exposed. |
| The app | npm run build && npm start in dapp/ | NEXT_PUBLIC_APP_URL, the site's public address: the sitemap, robots.txt and link previews name pages by it. NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID from cloud.walletconnect.com, so phones pair by QR (without it only injected wallets connect, and the console reports the placeholder on every page). |
5. Index
subgraph/docker-compose.yml is a complete graph-node + IPFS + Postgres stack. Fill
subgraph/networks.json with the deployed addresses and start block from step 2, then:
cd subgraph
docker compose up -d
npm run codegen && npm run build
npm run create-local && npm run deploy-local
The subgraph serves history and analytics over GraphQL at
http://localhost:8000/subgraphs/name/aioum/aioum-v2; point your own dashboards at it. The
explorer console and the app's Activity tab read events from the RPC and the registry from the
indexer; the Marketplace and the agent runner's mid-run searches read the indexer too, so run it
(step 4) and set NEXT_PUBLIC_INDEXER_URL. The Graph's decentralized network is the alternative
to self-hosting: same graph deploy, different --node.
6. Where your node answers
A listing on the chain says who sells, what, at what price and with what stake. It did not say
where the node answers: the directory learned that from a seed list and from nodes that
announced themselves, so each directory had a list of its own. Now the node writes its address on
chain. EndpointRegistry holds one record per operator, written by that operator's key and by
nobody else: the URL, and the fingerprint of the certificate the node serves. Every directory
reads the chain first; the seed list is a cache for the first probe. A node with its address on
chain is found by any directory anyone runs, and nobody decides who is listed.
No domain needed. A certificate authority issues certificates for names, which is the only
reason a server usually needs one. Here the trust comes from the chain instead. At an https
address on an IP, https://203.0.113.9:8546, the node makes a certificate for itself at first
start, serves it, and writes its SHA-256 fingerprint on chain beside the address. Every client in
the network, the agent runner, the directory, the relay and the staking node, reads that
fingerprint from the chain and accepts the node's certificate only when it matches. Encrypted
and authenticated by the operator's key, with no DNS and no certificate authority anywhere.
deploy-network.sh does this when DOMAIN is unset: the node's address is this server's
public IP, it listens on every interface, and AIOUM_NODE_TLS=self. The keys live in the node's
data folder under tls/; a new certificate means a new fingerprint on chain, written at the next
start. Only port 443 or the node's port needs to be open.
No address at all: behind a relay. A node on a machine at home should never write that
machine's address, because a home address is where you live and the chain never forgets. So a
node can dial out instead. Set AIOUM_NODE_TUNNEL to a relay's tunnel address, the record a
relay writes on chain with its own certificate, and the node keeps one outbound connection to
it, proves its operator key by signing the relay's nonce, and is reached at
https://<operator>.<relay>:<port>, which is what it writes on chain. A buyer's bytes go to the
relay and through the tunnel untouched: the TLS session is end to end to the node's own
certificate, pinned from the chain, so the relay carries what it cannot read or alter, and the
node's address is known to the relay alone. A relay on a bare IP is named
<operator>.<a-b-c-d>.aioum, a form every client in the network resolves itself; a relay with
a domain is <operator>.relay.example.com. Any relay (npm run relay) hosts tunnels on
AIOUM_RELAY_TUNNEL_PORT, 8554, one public TLS port that also fronts its own API, and lists the
nodes behind it at GET /tunnel. The app's "Where your node answers" card offers the relay way
first and the direct address second, with what each one writes on chain said plainly.
A name is for browsers. A browser trusts certificate authorities only, so a node that
people reach from a browser directly needs a name with a CA-issued certificate: point
node.yourdomain.com at the server, run Caddy (deploy/Caddyfile), set
AIOUM_NODE_PUBLIC_URL=https://node.yourdomain.com, and the node writes the name with a zero
fingerprint, checked the ordinary way. The app reaches a pinned node through its own server
for the health probe and the payment challenge, so the marketplace pages work either way.
The node needs AIOUM_NODE_PUBLIC_URL, the address other machines reach it at. It writes the
record when it starts and leaves it alone while unchanged. On a real chain without it the node
writes nothing and says so. The app shows the state under Earn, "Where your node answers": the
saved address, what the chain holds, and whether they agree. Changing it there starts the node
again with the new address.
A node's IP is visible to whoever resolves its name or reads its record, as every server's is, unless it sits behind a relay. Keep every other port closed, and the private node on loopback or behind its own name and token. If an address is attacked, stand the node up elsewhere, or behind a relay: it republishes at boot and every directory follows within a minute, stake and reputation untouched.
ENS and web3 names. An ENS name does not replace any of this. Agents reach a node by the record on chain, which already holds what a name would resolve to, with no mainnet dependency. ENS can be a label an operator adds later. It is not transport.
What is central and what is not. The chain holds listings, stakes, payments, addresses and
certificates. The directory is a cache anyone can run (npm run indexer); the app reads one
through NEXT_PUBLIC_INDEXER_URL, and the subgraph is the on-chain fallback. A relay is an
operator like any other: anyone runs one, a node picks which, and a relay cannot read what it
carries. Nothing else stands between a buyer and a seller.
7. Gasless accounts and budgets
AIOUMAccount, AIOUMAccountFactory and AIOUMVerifyingPaymaster were deployed in step 2.
Your sponsorship service signs time-boxed sponsorships (validUntil / validAfter) for the
operations you pay for; the paymaster rejects every other UserOp, so the deposit is spent only on
what you signed, and rotating sponsorSigner invalidates every outstanding sponsorship. Fund the
paymaster's EntryPoint deposit once the service is up, and let the monitor alert when it falls
below MONITOR_PAYMASTER_MIN_DEPOSIT_WEI.
Session keys are hot, capped and expiring: the on-chain spendCap and validUntil bound what a
leaked key can spend, revokeSessionKey ends one early, and the app keeps them in memory, never
in localStorage. Say so in your own user documentation.
Budgets and session keys has the details.
8. User data on private nodes
The private node's memory is personal context: what the agent has learned about its person, the
secrets it holds for them and the jobs it runs for them. Treat it as user data. It is encrypted
at rest by default, the person can export and delete it, and only the minimal, consented per-call
inputs ever go to marketplace nodes. Its growth is bounded; all it asks is a regular backup on
the same encrypted, user-controlled terms as secrets.enc, memory.json and jobs.json in
step 4.
9. Keys
The node's key is not the owner's key. A node holds a hot signer key (OPERATOR_SIGNER_KEY)
that settles sessions, writes the node's address and certificate, and proves the node to
directories and relays. It can neither stake, register, take down nor withdraw: those stay with
the owner's key, which names the signer on chain once (OperatorSigners) and otherwise never
touches a server. deploy-network.sh does the split when OPERATOR_SIGNER_KEY is in .env: the
owner's key registers the catalogue and names the signer in a run that exits before anything
listens, and the unit runs with the signer alone. The private node does the same for the
selling node it starts: a signer made once in its encrypted store, named by your wallet, and the
wallet's key never in the process that serves buyers. A node compromised is worth its upstream
keys and its transcripts, never its stake: the validator's split of signing and withdrawal keys.
Deployer: hardware wallet or multisig, used once in step 2, hands ownership to the Timelock in step 3 and renounces its admin role there. Facilitator and bundler relayers: funded hot keys that only pay gas and never hold funds, one per instance, rotated and rate-limited (step 4); the deposit sweeper is the same kind, and a forwarder pays only the treasury and the wallet, so it never holds user funds. Sponsor signer: held by the sponsorship service (step 7), rotated through the paymaster owner, a multisig whose only other job is keeping the EntryPoint deposit funded. Node operator: stakes and settles, the operator's own responsibility, capacity equal to stake. User session keys: hot, capped and expiring (step 7). Private-node passphrase: encrypts secrets and memory (step 8), user-held, never logged.
10. Security and operations
The full picture, keys, the pause, the runbook, is one page: Security and operations.
Four habits. Keep forge test, fuzz and invariant suites included, in CI, and extend the
invariant handlers whenever you change payments, staking or disputes; a revenue-share or
attribution split added to settlement changes the money path, so cover it with the invariant
tests (escrow solvency, splits sum to the gross, no dust leak) before shipping. Keep the guardian
multisig as Timelock proposer and canceller through bootstrap, and publish the milestones on
which control widens to the full Governor. Run a bug bounty and keep an incident-response
runbook. Leave the rate limits and in-flight caps on the facilitator and bundler in place; node
capacity is bounded by stake, so a node cannot hold more client money than it can answer for.
Third-party code never runs on the host outside a container: a compute listing runs a buyer's
job under docker run --rm with the listing's limits, and a registry MCP server is verified only
inside a container with nothing of the host mounted (Operating the
network).
Launch checklist
Before the first person hands an agent a budget on your network, every line is ticked.
[ ] Contracts deployed with canonical Permit2, the chain's USDC and the canonical EntryPoint (no mocks)
[ ] Owner = Timelock on every contract (npm run handover); deployer + Timelock admin renounced
[ ] npm run verify-deploy passes, and runs in CI/release
[ ] scripts/deploy-network.sh deploy ends with no FAILED line; nodes behind TLS; voucher store on persistent disk; /health monitored
[ ] Facilitator + bundler funded, hardened, several instances on distinct keys
[ ] Sponsorship service live; paymaster deposit funded; deposit alert set
[ ] Deposit service up: sweeper key funded, route provider key set, NEXT_PUBLIC_DEPOSITS_URL set; card partner keys where cards are taken
[ ] Staking node up: pool funded, a reviewer set, NEXT_PUBLIC_BACKER_URL set
[ ] hardware.json read at deploy; no hostAccess MCP server listed without allowHost; nothing third-party runs outside a container
[ ] Private node over TLS only; secrets/memory/jobs backed up (encrypted, user-controlled)
[ ] Subgraph deployed (addresses + start block); indexer up and NEXT_PUBLIC_INDEXER_URL set; monitor alerting to a webhook
[ ] App built with NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID set; /robots.txt, /sitemap.xml and a link preview checked
[ ] Session-key handling documented for users (hot, capped, expiring)
[ ] Bug bounty + incident runbook in place
[ ] Every node has `AIOUM_NODE_PUBLIC_URL` set, https, and its address on chain (`GET /nodes` on the indexer lists it under `onChain`, with its certificate fingerprint when it serves its own)
[x] Guardian/pause wired: the deploy sets `GUARDIAN_ADDRESS` on every pausable contract, `verify-deploy` checks it; governance decentralization milestones published
See also: Operating the network · deploying.md · SECURITY.md.
Next: Keys to launch — every key the network signs, pays and lists with, where each comes from, what it costs and its exact .env line.