<!-- MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. -->
<!-- summary: Run COLLAB on your machine: Foundry and Python, a local chain with demo DAOs, the dev server, private sandboxes, test wallets and common pitfalls. -->
# Local development

This page sets up a machine to work on COLLAB: the tools to install, a local GenesisL1-like chain with the demo DAOs, the development server with the production's clean URLs, private sandboxes for tests, and the test wallets, with the pitfalls people meet. It is for developers; nothing here touches GenesisL1 or needs Node.js, which COLLAB does not use anywhere.

## What to install

| Tool | Version | Used for |
|---|---|---|
| Foundry: `forge`, `cast`, `anvil` | a current release (`foundryup`, see https://book.getfoundry.sh/) | building and testing the contracts, the local chain, `cast` reads |
| solc | 0.8.28, pinned in `foundry.toml` | forge downloads it, or use `--offline --use /path/to/solc-0.8.28` |
| Python | 3.10 or later; 3.11.4 or later to build a release ([Building a release](release-build.md#before-you-build)) | every tool in `tools/`, `server/` and `deploy/` |
| Pillow | 10.1 or later (`python3-pil`) | the prerender's Open Graph images, `tools/make_assets.py` |
| keccak-256 for Python | pycryptodome 3.20 or later (`python3-pycryptodome`), or OpenSSL 3.2 or later | the prerender and `tools/seo/` |
| Playwright for Python, with Chromium | | the end-to-end tests and screenshots |

```bash
curl -L https://foundry.paradigm.xyz | bash && foundryup      # Foundry, from book.getfoundry.sh
sudo apt install python3 python3-pil python3-pycryptodome      # or: python3 -m pip install -r tools/seo/requirements.txt
python3 -m pip install playwright && python3 -m playwright install chromium
```

The Solidity libraries ship in `lib/` (OpenZeppelin Contracts and Contracts Upgradeable 5.1.0, Solady 0.1.26, forge-std 1.9.4), trimmed in the release archive to what COLLAB compiles, and `remappings.txt` is the only remapping source. The tools look for Foundry's binaries on `PATH`, or in `FOUNDRY_BIN` (a folder holding `anvil`, `cast` and `forge`) when it is set; the end-to-end tests also look in `~/.foundry/bin`, where `foundryup` installs them.

## Build the contracts

```bash
forge build --skip test --skip script        # the contracts alone
forge build test/Governance.t.sol test/Profile.t.sol    # then a few test files at a time
forge test
```

A first `forge build` of everything compiles with via-IR in one solc run, which can need more than 5 GB of memory. Building the contracts first and then a few test files per run keeps it well under 2 GB, and produces the same artifacts; `tools/build_release.py` does exactly that, four files per run (`FORGE_BATCH`). On a machine without network access, add `--offline --use /path/to/solc-0.8.28` (the release build reads them from `FORGE_FLAGS`). [Testing](testing.md) covers the test suites.

## The local chain

`tools/local_chain.py` starts anvil as a GenesisL1-like chain and deploys everything on it:

```bash
python3 tools/local_chain.py up --seed          # chain 29 on 127.0.0.1:8545, demo DAOs, web/config.local.js
python3 tools/local_chain.py warp 3600          # move the chain clock forward an hour, and mine a block
python3 tools/local_chain.py up --port 8546     # another port; --solc PATH (or SOLC_PATH) for forge's --use
```

`up` runs anvil with chain id 29, 12 accounts of 1,000,000 L1 each, a block gas limit of 1,000,000,000 (GenesisL1's), a code size limit of 49,152 bytes and the `paris` hardfork. It then deploys stand-ins for the GenesisL1 contracts COLLAB integrates with: a wL1-compatible WETH9 (`MockWL1` from `test/mocks/Mocks.sol`, compiled by `forge create`) and, from the committed bytecode in `externals/bin/`, two CIPNFT contracts and the GL1F and GL1F Crypto collections. It writes their lists to `deployments/integrations.local.json`, deploys COLLAB with `script/Deploy.s.sol` (`EXT_WL1`, `EXT_INTEGRATIONS`, and `DEPLOY_OUT=deployments/local.json`, so a local deployment never overwrites `deployments/29.json`), and writes `web/config.local.js` with `tools/write_web_config.py`. With `--seed` it runs `script/SeedDemo.s.sol` in four phases, with the clock moved forward between them, so the demo has proposals in every state. The anvil's process id and log are in `deployments/anvil.pid` and `deployments/anvil.log`; stop it by that id when you are done.

The demo data (five DAOs with slugs, logos, descriptions, proposals, tokens, pools, a sealed listing and models) is listed in [The web app](../web/DEVELOPING.md#demo-data-in-the-snapshot). The upstream contracts in `externals/` are pinned copies of the live GenesisL1 ones, compiled with their own settings (solc 0.8.20); rebuild them only when they change, with `SOLC=/path/to/solc-0.8.20 python3 tools/build_externals.py`.

## Private sandboxes

A sandbox is a private anvil loaded with a snapshot of the seeded chain, plus its own dev server. Several people or test runs can work at once without moving each other's clock or spending each other's balances, and every sandbox starts from the same state, so addresses, DAOs and proposals are identical everywhere.

```bash
python3 tools/dev_sandbox.py snapshot                                  # once, from the seeded chain on 8545
python3 tools/dev_sandbox.py up mine --rpc-port 8601 --web-port 8701   # your copy
python3 tools/dev_sandbox.py warp mine 3600                            # mine: block 130 at 1793581064
python3 tools/dev_sandbox.py reset mine                                # back to the snapshot
python3 tools/dev_sandbox.py status                                    # every sandbox, running or stopped
python3 tools/dev_sandbox.py down mine
```

`snapshot` takes `--from URL` (default `http://127.0.0.1:8545`) and writes `build/dev/seed-state.hex` with `seed-state.meta.json` (block and time). `up` starts anvil with the same flags as the local chain, loads the snapshot in one `anvil_loadState` request, sets the clock just past the snapshot's time, then starts `tools/serve_local.py` against it, and records the ports and process ids in `build/dev/<name>.json` (logs beside it). The end-to-end runner uses a sandbox of its own, `e2e` on ports 8690 and 8790 unless you pass others.

A seeded 2.2 chain is about 2.1 MB of hex, more than anvil's default request limit of 2 MiB, so `up` starts anvil with `--no-request-size-limit`; if you load a snapshot into an anvil of your own, pass the flag too. A failed `up` stops the anvil it started.

## The development server

```bash
python3 tools/serve_local.py --port 8080                     # web/ with the clean paths, against config.local.js's RPC
python3 tools/serve_local.py --port 8080 --rpc http://127.0.0.1:8601
python3 tools/serve_local.py --no-rewrites                   # a static host without rewrites (404.html and fallback.js)
python3 tools/serve_local.py --build-only                    # a static copy in build/local-web/
```

It serves `web/` directly, so edits show on reload, with the routes of production: `/dao/<slug or address>[/…]`, `/votes`, `/tokens[/<address>]`, `/admin[/…]`, `/swap`, `/pools`, `/locks`, the docs pages (`/docs/<slug>` and its `.md`, once `python3 tools/build_docs.py --write` has written them) and the other entries, and `/deploy` answers 301 to `/admin/deploy`. Unknown paths get `404.html` with status 404. It generates `/config.js` from `web/config.local.js` with the sandbox's RPC, no explorer and a local price endpoint (`/dev/price`, a fixed demo price in CoinGecko's format), so nothing leaves the machine, and rewrites each page's `connect-src` to allow the sandbox and any `http://127.0.0.1:*` endpoint, to try a custom RPC in Settings. Every response carries the security headers of production and `Cache-Control: no-store`.

| Option | Effect |
|---|---|
| `--undeployed` | config.js says `deployed: false` with no addresses: the site before the operator deploys |
| `--site FILE` | a site.json, published in config.js as `site` (alpha strip, announcement, featured and hidden DAOs, links) |
| `--data DIR` | serves `DIR` at `/data/` (`.png`, `.json` and `.txt` only) and makes this server the configured site (`siteUrl` is its own origin, so `onSite()` is true and pages read `/data/`) |
| `--prerender DIR` | serves `DIR` before `web/` for every path except `/docs/…` (`/x/y` tries `DIR/x/y/index.html`, then `DIR/x/y`), as nginx serves the prerender's output; also makes it the configured site |

With or without `--data`, `/data/price.json` answers a fresh demo price when there is no file, and any other missing `/data/` file (and a missing `/og/network.json`) answers 204, as the server does for a cache file not written yet, so dev and test runs log no 404. To see the prerendered pages and the cache on your machine:

```bash
python3 tools/prerender.py --rpc http://127.0.0.1:8601 --deployment deployments/local.json \
    --site http://127.0.0.1:8080 --web web --out /tmp/pre --data /tmp/data --lock /tmp/pre.lock
python3 tools/serve_local.py --port 8080 --rpc http://127.0.0.1:8601 --prerender /tmp/pre --data /tmp/data
```

## Test wallets

The local chain's accounts are anvil's development accounts #0 to #11 (the well-known mnemonic `test test test test test test test test test test test junk`), each with 1,000,000 L1; the demo's USDC address is in `deployments/demo.json`. Their keys are public knowledge: use them on local chains only, and never send real funds to them.

- **In a browser wallet.** Add a network with RPC `http://127.0.0.1:8545` (or your sandbox's port) and chain id 29, and import one of the development keys; account #0's is `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`. A wallet that already knows chain 29 as GenesisL1 may need the local RPC as a separate network.
- **In tests.** `tools/e2e/harness.py` injects an EIP-1193 and EIP-6963 test wallet ("Test Wallet", rdns `dev.collab.testwallet`) that forwards to the sandbox, where anvil's accounts are unlocked, and marks the cookie notice as seen (`cookie_notice=True` shows it). `Browser(base, rpc, account)` opens a context; `b.go(path)` loads a page, `b.nav(path)` follows an in-app link without a load, `b.shot(name)` saves a screenshot to `build/screenshots/`, and `b.errors` collects console errors and warnings.

```bash
python3 tools/e2e/harness.py /daos 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266   # one page, a screenshot, its console
```

Run on its own, the harness opens `http://127.0.0.1:8080` with the wallet on the chain at `http://127.0.0.1:8545`, so start `serve_local.py --port 8080` against that chain first; inside a sandbox, use `Browser(base, rpc, account)` from Python.

## Common pitfalls

- **Memory on the first build.** One via-IR solc run over every source can need more than 5 GB. Build in batches as above, before the first `local_chain.py up` too: its `forge create` and `forge script` compile whatever is not built yet.
- **No snapshot.** `dev_sandbox.py up` before `snapshot` stops with "no snapshot: run `dev_sandbox.py snapshot` against a seeded chain first". Take the snapshot from a seeded chain, after `local_chain.py up --seed`.
- **The request limit.** A snapshot loaded into an anvil without `--no-request-size-limit` fails, because it is over 2 MiB. Sandboxes started by an older `dev_sandbox.py` must go down and up; `reset` reloads the state into the same anvil, with its old flags.
- **No config.local.js.** `serve_local.py` builds `/config.js` from `web/config.local.js`, which `local_chain.py up` writes. Without it the server cannot start.
- **Chain id 29 on two networks.** The local chain uses GenesisL1's chain id on purpose, so the app behaves as on mainnet. Keep `deployments/local.json` and `web/config.local.js` for local work; neither is ever part of a release.
- **Time.** anvil's clock moves only with blocks. Use `warp` to end a voting period or a timelock; the app reads time from the chain (`chainNow()`), not from your computer.
- **Ports.** Pick your own ports when others run sandboxes on the same machine, and check `dev_sandbox.py status` first. Stop what you start, by its process id.
- **Generated blocks.** After changing a page's imports, run `python3 tools/shell_html.py --write`; after changing `descifi.html`, run `python3 tools/prerender.py --static-pages`; after editing the docs, `python3 tools/build_docs.py --write`, and after adding or removing a docs page, `python3 tools/prerender.py --static-sitemap` (web/sitemap.xml and robots.txt). `python3 tools/lint_web.py` fails on stale shells, copies and docs pages, and on a sitemap that lists other docs pages.
- **The browser for tests.** `tools/e2e/harness.py` launches Chromium from a fixed path, `/opt/pw-browsers/chromium-1194/chrome-linux/chrome`; on another machine, change `executable_path` in `_chromium()` to your Playwright Chromium.
- **Sealed-data tests.** The `sealed` scenarios drive the reference CIPNFT dapp as the other side of each trade: point `CIPNFT_REF` at a checkout of the CIPNFT site.
- **Clean URLs need a domain root.** Every path in the pages is root-absolute, so the site cannot be served from a subfolder.

## Related

- [Developer overview](developers.md): the repository and where to start.
- [The web app](../web/DEVELOPING.md): the rules every page and view follows.
- [Testing](testing.md): forge, the end-to-end suites and the server tests.
- [Reading COLLAB from the chain](reading-the-chain.md): `cast` against your sandbox.
- [Data formats](data-formats.md): config.js, site.json and the cache files.
