@toon-protocol/townhouse
v0.1.0-rc5
Published
TOON Townhouse — host-native orchestrator + dashboard for Docker-containerized TOON nodes
Maintainers
Readme
@toon-protocol/townhouse
Host-native orchestrator and dashboard for Docker-containerized TOON nodes (Town, Mill, DVM) behind a shared standalone connector.
Local Dev Loop (Townhouse Dev Stack)
Stories 21.9–21.13 (dashboard views) and 21.8.5 (design system) must be developed against the Townhouse dev stack, not against mocks or the SDK E2E topology (D21-009).
One-command boot
./scripts/townhouse-dev-infra.sh upThis command:
- Builds
toon:town,toon:mill,toon:dvmDocker images (Docker layer cache applies on subsequent runs) - Starts Anvil (EVM), Solana test-validator, and Mina lightnet chain devnets
- Deploys Mock USDC to Anvil and the Solana payment-channel program
- Starts the standalone connector with all 5 child peers registered
- Starts 5 child nodes with deterministic Nostr keys
- Polls each child's
/healthendpoint until ready (60 s timeout per node) - Prints a success banner listing every endpoint URL
- Writes
.env.townhouse-devat the workspace root
First run (no cached images): ~5 minutes (dominated by image pulls). Subsequent runs: ~90 seconds (cached images, warm Docker daemon).
Endpoint banner
On success, the script prints every endpoint grouped by category. Copy these URLs into your browser or curl to verify the stack manually:
Connector http://127.0.0.1:28080
town-01 relay ws://127.0.0.1:28700
town-01 health http://127.0.0.1:28100
town-02 relay ws://127.0.0.1:28710
town-02 health http://127.0.0.1:28110
mill-01 health http://127.0.0.1:28200 (EVM↔Solana)
mill-02 health http://127.0.0.1:28210 (EVM↔Mina)
dvm-01 health http://127.0.0.1:28400
Anvil RPC http://127.0.0.1:28545
Solana RPC http://127.0.0.1:28899
Mina GraphQL http://127.0.0.1:28085
Mina Accounts http://127.0.0.1:28181
SOCKS5 socks5://127.0.0.1:28050Host-Fastify integration via .env.townhouse-dev
The script writes .env.townhouse-dev at the workspace root. Story 21.8.5 wires a pnpm dev:docker script in packages/townhouse-web that sources this file at startup so the host-side Fastify API knows the connector admin URL and child node addresses without any manual configuration.
The contract (env var names the Fastify API reads):
TOWNHOUSE_CONNECTOR_ADMIN_URL— connector admin base URLTOWNHOUSE_DEV_TOWN_01_RELAY,TOWNHOUSE_DEV_TOWN_02_RELAY— relay WebSocket URLsTOWNHOUSE_DEV_TOWN_0{1,2}_HEALTH,TOWNHOUSE_DEV_MILL_0{1,2}_HEALTH,TOWNHOUSE_DEV_DVM_01_HEALTH— BLS health URLsTOWNHOUSE_DEV_ANVIL_RPC,TOWNHOUSE_DEV_SOLANA_RPC,TOWNHOUSE_DEV_MINA_GRAPHQL— chain RPC URLsSOLANA_PROGRAM_ID,MINA_ZKAPP_ADDRESS,TOON_USDC_ADDRESS— deployed contract addressesTOWNHOUSE_DEV_WALLET_MNEMONIC— DEV ONLY BIP-39 test-vector-zero mnemonic (abandon … about); read byapi-server.mjsto auto-initialize theWalletManagerwithout runningtownhouse init. This is the publicly known test vector — NEVER use in production. The dev API loop rejects any other value at startup so a developer who pastes a real mnemonic by accident gets a loud error rather than silent address derivation.
When the dev mnemonic is loaded, the API loop also writes ~/.townhouse/wallet.enc (if absent) encrypted with the documented dev password townhouse-dev. This makes POST /wallet/reveal exercisable against the live dev stack — open the wallet view, click "Reveal seed phrase", enter townhouse-dev, see the 12-word mnemonic. The on-disk file is never overwritten if it already exists, so an operator who later runs the production townhouse init flow keeps their real wallet.
.env.townhouse-dev is git-ignored. Never commit it.
TURBO_TOKEN
TURBO_TOKEN is used by the DVM container for Arweave uploads via Turbo. It is passed through from the host environment.
- Working on Town/Mill views (21.9–21.11): No
TURBO_TOKENneeded. The DVM starts in disabled-upload mode and its health endpoint still responds 200. - Working on DVM views (21.12) or upload flows: Set
TURBO_TOKENin your shell before runningup.
The script logs a warning (not an error) when TURBO_TOKEN is unset, then continues.
Teardown
./scripts/townhouse-dev-infra.sh down # Stop containers + remove .env.townhouse-dev
./scripts/townhouse-dev-infra.sh down-v # Same + delete named volumes (fresh state next run)
./scripts/townhouse-dev-infra.sh status # Show container state + health summaryUse down-v when you want a completely fresh channel/data state on the next up.
Port allocation
All ports are 127.0.0.1 only (never 0.0.0.0). Full table also in CLAUDE.md "Townhouse Dev Stack (28xxx)".
| Host Port | Service | |-----------|---------| | 28080 | Connector admin | | 28050 | SOCKS5 proxy | | 28100 | town-01 BLS health | | 28110 | town-02 BLS health | | 28200 | mill-01 BLS health (EVM↔Solana) | | 28210 | mill-02 BLS health (EVM↔Mina) | | 28400 | dvm-01 BLS health | | 28700 | town-01 relay WebSocket | | 28710 | town-02 relay WebSocket | | 28545 | Anvil JSON-RPC | | 28899 | Solana RPC | | 28900 | Solana WebSocket | | 28085 | Mina GraphQL | | 28181 | Mina accounts manager |
What this stack is NOT
- Not a production deployment. The Townhouse production compose (
docker-compose-townhouse.yml) describes one operator's actual node. This file describes a contributor's rig. Do not confuse them. - Not the SDK E2E topology. The SDK E2E stack (
docker-compose-sdk-e2e.yml/scripts/sdk-e2e-infra.sh) uses embedded connectors inside SDK peers. The Townhouse dev stack uses a standalone connector fronting separate child nodes — the production Townhouse shape. - Not for performance testing. Boot-and-smoke only. Performance tuning is out of scope for this stack; it belongs in a dedicated story.
- Not multi-tenant. The 5 child nodes use deterministic dev keys that never change across
up/down/upcycles. They are NOT for use as real TOON nodes.
Running E2E Tests (Story 21.16)
There are two test harnesses with different purposes:
1. Dev stack integration (contributor dev loop)
Uses townhouse-dev-infra.sh (multi-peer fixtures, deterministic keys, 28xxx ports).
See "Local Dev Loop" above.
./scripts/townhouse-dev-infra.sh up
pnpm --filter @toon-protocol/townhouse test:integration -- dev-stack-smoke2. Real-CLI E2E (operator-facing lifecycle — Story 21.16)
Uses townhouse-test-infra.sh (image pre-warm only; tests run the real CLI).
# One-time image cache warm-up (pulls connector image + builds toon:{town,mill,dvm})
bash scripts/townhouse-test-infra.sh up
# Run the integration suite (CLI lifecycle + config propagation)
RUN_DOCKER_INTEGRATION=1 pnpm --filter @toon-protocol/townhouse test:integration
# Or: combined script (up + test + down)
pnpm --filter @toon-protocol/townhouse test:e2e:dockerKey differences from the dev stack:
| | Dev stack (townhouse-dev-infra.sh) | Real-CLI E2E (townhouse-test-infra.sh) |
|---|---|---|
| Starts containers? | Yes (multi-peer compose) | No (tests run the real CLI) |
| Keys | Deterministic dev keys | Fresh wallet per test (mkdtempSync) |
| Topology | 2 Town + 2 Mill + 1 DVM + SOCKS5 | 1 Town + 1 Mill + 1 DVM |
| Port range | 28xxx | 9400 (API), 9401 (connector admin) |
| Audience | Dashboard developers | CI / publish gate validation |
Diagnostic runbook: see the header comment in scripts/townhouse-test-infra.sh.
Playwright SPA tests (mock-driven + real-stack):
# Mock-driven specs (transport flip, config change) — no real stack needed
pnpm --filter @toon-protocol/townhouse-web e2e
# Real-stack lifecycle spec — requires townhouse up to be running
TOWNHOUSE_E2E_REAL_STACK=1 pnpm --filter @toon-protocol/townhouse-web e2e:realCompose Templates (npm tarball, Story 45.2)
The published @toon-protocol/townhouse package ships two Docker Compose templates:
| Profile | File in tarball | Purpose |
|---------|-----------------|---------|
| hs | dist/compose/townhouse-hs.yml | Operator-facing apex boot — digest-pinned GHCR images |
| dev | dist/compose/townhouse-dev.yml | Contributor dev stack — local toon:* build images |
Port collision warning. The HS template binds canonical ports (
127.0.0.1:9401,:28090,:7100,:3100,:3200,:3400); the contributor dev stack binds 28xxx-namespaced equivalents (28080:9401, 28100:3100, 28110:3100, 28200:3200, 28210:3200, 28400:3400, 28700:7100, 28710:7100). HS-mode and the dev stack (scripts/townhouse-dev-infra.sh) must not run concurrently on the same machine — host:9401, host:3100, host:3200, host:3400, host:7100 will conflict. The HS template's single-tenant defaults are intentional for the apex operator path (Story 45.4townhouse hs up); open an enhancement issue if multi-tenant bindings become a real need.
API
import { loadComposeTemplate, materializeComposeTemplate } from '@toon-protocol/townhouse';
// Read the rendered YAML for a profile (read-only, returns a string).
const yaml = loadComposeTemplate('hs');
// Write the compose file + image-manifest.json to ~/.townhouse/ (side-effecting).
// Both output files are written with mode 0o600 (NFR8 — operator-secret).
const { composePath, manifestPath } = materializeComposeTemplate('hs');
// composePath → ~/.townhouse/compose/townhouse-hs.yml
// manifestPath → ~/.townhouse/image-manifest.jsonBoth functions accept an optional options object:
interface ComposeLoaderOptions {
townhouseHome?: string; // Override ~/.townhouse/ write target (useful in tests)
distDir?: string; // Override dist/ read root (useful in tests)
}image-manifest.json schema
The manifest pinning every image to a content-addressed sha256: digest:
{
"schemaVersion": 1,
"townhouseVersion": "0.1.0",
"builtAt": "<ISO timestamp>",
"images": {
"townhouse-api": { "name": "ghcr.io/toon-protocol/townhouse-api", "tag": "0.1.0", "digest": "sha256:..." },
"town": { "name": "ghcr.io/toon-protocol/town", "tag": "0.1.0", "digest": "sha256:..." },
"mill": { "name": "ghcr.io/toon-protocol/mill", "tag": "0.1.0", "digest": "sha256:..." },
"dvm": { "name": "ghcr.io/toon-protocol/dvm", "tag": "0.1.0", "digest": "sha256:..." },
"connector": { "name": "ghcr.io/toon-protocol/connector", "tag": "3.4.1", "digest": "sha256:..." }
}
}Full schema source: scripts/build-image-manifest.mjs (lines 44–67).
Dev stack compose (canonical source)
The package-local packages/townhouse/compose/townhouse-dev.yml is the canonical source of the dev template. It is shipped verbatim in the npm tarball (no digest substitution — uses local toon:* image tags).
For backward compatibility, docker-compose-townhouse-dev.yml at the repo root is preserved and continues to be used by scripts/townhouse-dev-infra.sh. A follow-up story will route the script through the package-local copy.
Running the townhouse as a hidden service (laptop)
docker-compose-townhouse-hs.yml brings up the full operator stack —
apex connector + town + mill + dvm + (optional) Anvil + Solana + EVM
faucet — with the connector publishing a .anyone hidden service via
the Anyone Protocol overlay. External peers reach the townhouse only
through that .anyone address; the laptop never exposes anything to
the public clearnet.
# 1. Build the local node images (one-time, until they're on ghcr)
docker compose -f docker-compose-townhouse.yml --profile town --profile mill --profile dvm build
# 2. Pick a chain profile (localnet is the default — copy when ready to switch)
cp .env.townhouse-hs.example .env
# 3. Boot the HS stack. Profiles select what runs:
# --profile localnet bundles anvil + solana (skip for real testnets)
# --profile town/mill/dvm child nodes
# --profile faucet EVM ETH + Mock USDC faucet UI on :3500
docker compose -f docker-compose-townhouse-hs.yml \
--profile localnet --profile town --profile mill --profile dvm --profile faucet up -d
# 4. Wait ~30-90s for anon to bootstrap and publish the descriptor.
# Then read your published .anyone address:
docker compose -f docker-compose-townhouse-hs.yml exec connector \
cat /var/lib/anon/hs/hostname
# → eag2qnhil4vpvfo2eu3qtqj3rzzkrzbmboivwwbbgzr4svfvjigoxpad.anyone
# 5. Share that address with peers. They reach you over Tor at:
# wss://<address>.anyone/btpChain configuration
Mill and the faucet read chain endpoints from environment variables, with
localnet defaults. Override via .env to switch profiles — see
.env.townhouse-hs.example for the four supported shapes:
| Profile | EVM | Solana | Mock USDC |
|---|---|---|---|
| localnet (default) | bundled Anvil at anvil:8545 | bundled validator at solana:8899 | pre-deployed in both bundled images |
| Akash devnet | anvil lease URL from deploy/akash/leases.json | solana lease URL from same | baked into akash-anvil + akash-solana images (same addresses as localnet) |
| Public testnet | Sepolia (infura.io/v3/<KEY>) | api.devnet.solana.com | real Circle testnet USDC contracts |
| Mainnet | mainnet RPC | api.mainnet-beta.solana.com | real Circle mainnet USDC — disable the faucet profile |
Variables consumed: EVM_RPC_URL, EVM_CHAIN_ID, EVM_USDC_ADDRESS,
SOLANA_RPC_URL, SOLANA_USDC_MINT. Setting these in .env configures
the laptop compose AND the Akash deploy (scripts/akash-deploy.sh
townhouse) identically.
Faucet workflow
EVM ETH + Mock USDC — bundled faucet service runs at
http://127.0.0.1:3500. Operator pastes their address, gets ETH for gas
and Mock USDC for transfers. Rate-limited 1 request per address per hour
by default (override via FAUCET_RATE_LIMIT_HOURS). The faucet uses
well-known Anvil dev keys — only meaningful against localnet or the
Akash devnet; harmless against testnets/mainnet (transactions just fail).
Solana SOL + Mock USDC — the standalone EVM faucet container does NOT yet handle Solana. Two paths until that gap closes:
- Dashboard panel: the townhouse host API (
pnpm --filter @toon-protocol/townhouse-web dev+ the host-side townhouse API) exposes a Faucet panel that does both EVM and Solana drips throughPOST /api/faucet. Best for live operator use. - Script:
scripts/faucet-sol-usdc.mjs <recipient>from the host — talks to whateverSOLANA_RPC_URLresolves to inleases.json.
Both options use the bootstrap-baked Mock USDC mint
(6GbdrVghwNKTz9raga7y3Y4qqX5Zgg3AC4d48Kt7C59Q) and faucet authority
keypair at infra/solana/keys/faucet-authority.json.
Persistence
The townhouse-hs-anon named docker volume preserves
hs_ed25519_secret_key across docker compose down cycles — the
.anyone address is stable for as long as the volume exists. Delete the
volume to rotate the address.
What's exposed on the host (loopback only)
- Connector admin:
127.0.0.1:9401— no auth, never expose publicly - Anvil RPC:
127.0.0.1:8545(localnet profile) - Solana RPC:
127.0.0.1:8899, WS127.0.0.1:8900(localnet profile) - Town Nostr relay clearnet:
127.0.0.1:7100— direct local clients bypass the HS, handy for debugging - EVM faucet UI:
127.0.0.1:3500(faucet profile)
Akash deployment of the same stack
deploy/akash/townhouse.sdl.yaml deploys apex + town + mill + dvm +
faucet to Akash with the same architecture. Chain devnets stay as
separate Akash leases (clearnet) — see scripts/akash-deploy.sh's
cmd_townhouse (TODO) for the leases.json wiring. The faucet's HTTP
port is the SDL's "one global service" validator scaffolding;
operationally, external peers reach the townhouse only via the .anyone
hidden service.
Package overview
The @toon-protocol/townhouse package provides:
- DockerOrchestrator — manages container lifecycle for Town/Mill/DVM nodes
- ConnectorConfigGenerator — generates connector peer config from node identities
- ConnectorAdminClient — typed HTTP client for the connector admin API (
/health,/admin/peers,/admin/metrics.json) - HD wallet management — BIP-44 key derivation per node type (story 21.4)
- Fastify REST/WebSocket metrics API — host-side API for the dashboard (story 21.8)
See packages/townhouse/src/index.ts for the full public API surface.
Transport configuration
The transport block selects how the connector reaches peers (outbound) and
how peers reach the connector (inbound).
transport:
mode: direct # 'direct' | 'ator'
socksProxy: socks5h://proxy.ator.io:9050 # required when mode='ator'
externalUrl: wss://my-connector.example/btp # see below
hiddenService: # optional — connector publishes its own .anyone HS
dir: /var/lib/anon/hs
port: 3000
startupTimeoutMs: 60000 # optional
stopTimeoutMs: 10000 # optional
externalUrl: wss://forced.anyone/btp # optional override of "auto"mode: 'direct' — clearnet TCP, no overlay. Default for development.
mode: 'ator' — outbound BTP through the Anyone Protocol (ATOR) overlay
via SOCKS5. Requires either externalUrl (operator-managed anon binary
external to the connector) OR hiddenService (connector manages its own
anon binary in-process and publishes a .anyone hidden service). Without
one of these the connector rejects the manifest at boot — the validator
catches this case before deploy.
hiddenService (Story 35.5 of the connector repo) — when set, the
connector boots @anyone-protocol/anyone-client in-process, spawns the
anon binary, and publishes a v3 hidden service. The keypair lives at
dir; persist that path on a mounted volume to keep the .anyone address
stable across redeploys, or delete it to rotate. The connector reads
${dir}/hostname after publish and advertises wss://<hostname>.anyone/btp
to peers (you can override with an explicit externalUrl if needed).
Wire-format note (silent-bug fix in this story): the previous shape
emitted transport: { mode: 'ator', socksProxy } to the connector image,
but the connector at 3.3.x reads a discriminated union keyed on type
('direct' | 'socks5'). The unknown mode field was silently discarded,
defaulting to direct — operators toggling ATOR got direct traffic anyway.
The current generator emits the correct type: 'socks5' shape with
externalUrl, managed, and managedOptions per the connector contract.
