# Local Dev

> Set up and manage local Freenet development environments and interact with a running node. Use when the user wants to test contract changes locally, debug UI issues, run a local node, query connections/diagnostics, inspect the dashboard, use the WebSocket API, or iterate on a Freenet application without deploying to the live network.

- Skill: `freenet/local-dev` (Agent Skill)
- Install (CLI): `npx skillmds@latest add freenet/local-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/freenet/local-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: LGPL-3.0
- Author: freenet (https://skillmd.com/u/freenet)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/freenet/local-dev

---


# Freenet Local Development & Node Interaction

Guidance for running local Freenet nodes, publishing contracts, querying node state, and debugging dApps during development.

## Prerequisites

```bash
which freenet fdev
rustup target add wasm32-unknown-unknown
```

## Architecture

### Ports & Services

| Service | Default Port | Flag | Purpose |
|---------|-------------|------|---------|
| Network (P2P) | 31337 | `--network-port` | Peer-to-peer connections |
| WebSocket API | 7509 | `--ws-api-port` | Client API (UI, CLI tools, fdev) |

### HTTP Endpoints

| Endpoint | Purpose |
|----------|---------|
| `GET /` | Home dashboard (auto-refreshes every 5s) |
| `GET /peer/{address}` | Peer detail page |
| `GET /v1/contract/web/{key}` | Contract web interface |
| `WS /v1/contract/command?encodingProtocol=native` | WebSocket API v1 |
| `WS /v2/contract/command?encodingProtocol=native` | WebSocket API v2 |

### Dashboard

The home dashboard at `http://localhost:7509/` shows:
- Connection status, peer count, own ring location
- Peer table: address, ring location, type (Peer/Gateway), bytes sent/received, connected duration
- External address (NAT traversal result), NAT statistics
- Contract counts (hosted, subscribed, managed)
- Operation stats (GET/PUT/UPDATE/SUBSCRIBE success/failure counts)

Scraping peer data:
```bash
# Get own location
curl -s http://localhost:7509/ | grep -o 'own-loc[^<]*<[^>]*>[^<]*'

# Get peer rows (address, location, type, sent, recv, uptime)
curl -s http://localhost:7509/ | grep -o 'peer-row[^}]*'
```

### Node Data Locations

| Platform | Default Data Path |
|----------|-------------------|
| macOS | `~/Library/Application Support/The-Freenet-Project-Inc.Freenet/` |
| Linux | `~/.local/share/freenet/` |

Contents: `contracts/` (WASM), `delegates/`, `secrets/`, `db/`, `config.toml`

### Log Directory Convention

Choose an appropriate log directory for your OS:
- **macOS**: `~/Library/Logs/freenet-test-node`
- **Linux**: `~/.local/share/freenet-test-node/logs`

The examples below use `$LOG_DIR` as a placeholder. Set it once:
```bash
# macOS:
LOG_DIR=~/Library/Logs/freenet-test-node
# Linux:
LOG_DIR=~/.local/share/freenet-test-node/logs

mkdir -p "$LOG_DIR"
```

## Running Local Nodes

### Single node (simplest)

Your existing node on port 7509 works. Publish test contracts to it directly.

### Isolated test node (won't affect your running node)

**IMPORTANT:** Gateway nodes require `--public-network-address`. Always use
`--log-dir` to isolate logs from your main node.

```bash
freenet network \
  --network-port 31338 \
  --ws-api-port 7510 \
  --ws-api-address 0.0.0.0 \
  --is-gateway \
  --skip-load-from-network \
  --data-dir ~/freenet-test-node/data \
  --public-network-address 127.0.0.1 \
  --log-dir "$LOG_DIR" \
  --log-level debug
```

Persistent data lives in `--data-dir` and logs in `--log-dir`, but the
**gateway bootstrap list is NOT isolated** by `--data-dir` — see
[Isolation pitfalls](#isolation-pitfalls) below before assuming the node
is offline-only. Likewise, `fdev` defaults to port 7509 and will silently
target whichever node owns that port (often the system service, not your
test node).

**WARNING:** Do NOT use `--id` for local dev. It creates ephemeral temp directories
that get wiped on restart, destroying delegate secrets (signing keys, app data).
Use `--data-dir` for persistent isolation instead.

### Isolation pitfalls

#### `--data-dir` does NOT isolate the gateway bootstrap list

`freenet` reads `gateways.toml` from the global config directory regardless
of `--data-dir`:

- **macOS:** `~/Library/Application Support/The-Freenet-Project-Inc.Freenet/gateways.toml`
- **Linux:** `~/.config/freenet/gateways.toml`

On a machine with an existing Freenet install, a "local" test node will
dial real public gateways (e.g. `nova.locut.us`, `vega.locut.us`) and
attempt NAT traversal to live peers — silently joining the public network.

To fully isolate, override `HOME` so the node sees an empty gateway list:

```bash
# macOS
mkdir -p ~/iso-home/Library/Application\ Support/The-Freenet-Project-Inc.Freenet
printf 'gateways = []\n' > ~/iso-home/Library/Application\ Support/The-Freenet-Project-Inc.Freenet/gateways.toml

# Linux
mkdir -p ~/iso-home/.config/freenet
printf 'gateways = []\n' > ~/iso-home/.config/freenet/gateways.toml

# Then launch with HOME overridden. For an isolated *gateway* node
# (--is-gateway, no --gateway flags), expect 0 bootstrap gateways:
HOME=~/iso-home freenet network --is-gateway --skip-load-from-network ...

# For an isolated *peer* node, pass your local gateway(s) explicitly:
HOME=~/iso-home freenet network --gateway "127.0.0.1:31337,$GATEWAY_PUBKEY" ...
```

Note: an empty `gateways.toml` will fail with `missing field 'gateways'`.
The file must contain `gateways = []`.

**Verification:** grep the node log for the initial-join line and confirm
the gateway count matches what you passed:

```bash
grep "Starting initial join procedure" "$LOG_DIR"/freenet.*.log
# Expect: "...with N gateways" where N == number of --gateway flags
# For an isolated gateway node (no --gateway flags), N must be 0.
# If N is higher than expected, isolation is broken.
```

Upstream tracking: [freenet/freenet-core#3980](https://github.com/freenet/freenet-core/issues/3980).

#### `fdev` defaults to port 7509

`fdev` targets `ws://127.0.0.1:7509` unless `--port` is passed. On a dev
machine running a system Freenet service (which owns 7509), `fdev publish ...`
without `--port` silently goes to that node, not your isolated test node.

```bash
# WRONG: silently targets whichever node owns 7509 (often the system service)
fdev publish --code ... contract ...

# RIGHT: always pass --port when targeting a non-default test node
fdev --port 7510 publish --code ... contract ...
```

Symptom of a misdirected publish: `"Signature verification failed: signature error"`
on a fresh publish to the test node, because the system node has stale
contract state from a previous run signed by a different key. If you see
this on a "fresh" test, check which node `fdev` actually hit.

When a publish runs through `cargo-make` (e.g. a `publish-*` task) rather
than `fdev` directly, there is no `--port` flag to pass — the task hardcodes
the default port internally. Override it with the `WS_API_PORT` environment
variable instead:

```bash
# Targets the isolated test node on 7510 instead of the default 7509
WS_API_PORT=7510 cargo make publish-myapp
```

The failure mode for a misdirected `cargo-make` publish is the unhelpful
`put failed after 4 attempts`, which gives no hint that the port was wrong —
so set `WS_API_PORT` whenever the target node isn't on 7509.

#### `--data-dir` does NOT isolate `config.toml` either — use `--config-dir` per node

Two `freenet` processes on the same host that pass the same (or default)
config directory share `config.toml` AND `secrets/transport-keypair.pem`.
Symptoms: second node fails to bind its UDP port, or both nodes use
identical peer IDs and the network refuses the duplicate connection.

For a deterministic multi-node harness on one host (gateway + peer + …),
pass `--config-dir` explicitly to each node, NOT just `--data-dir`:

```bash
freenet network --config-dir /tmp/iso-net/gw/config   --data-dir /tmp/iso-net/gw/data   ...
freenet network --config-dir /tmp/iso-net/peer/config --data-dir /tmp/iso-net/peer/data ...
```

**CI gotcha:** on Linux runners that set `XDG_CONFIG_HOME` (e.g. ubicloud,
sometimes GitHub Actions images), `dirs::config_dir()` returns
`$XDG_CONFIG_HOME` regardless of `HOME` — so the `HOME=~/iso-home …`
trick from the previous section is bypassed. `--config-dir` is the only
flag that wins against `XDG_CONFIG_HOME`. Use it any time the harness
must run identically on dev laptops and CI.

A working reference harness lives at
`scripts/run-isolated-nodes.sh` in the freenet/mail repo — covers up /
down / wipe / status, full state wipe between test runs (avoids day-1
AFT cap carryover in repeated E2E runs), and `FREENET_E2E_KEEP=1` to
leave nodes up for post-mortem.

### Two-node local network

```bash
# Terminal 1: Gateway
freenet network \
  --network-port 31337 \
  --ws-api-port 7509 \
  --is-gateway \
  --skip-load-from-network \
  --data-dir ~/freenet-local-gw/data \
  --public-network-address 127.0.0.1 \
  --log-dir ~/freenet-local-gw/logs \
  --log-level debug

# Terminal 2: Peer (get gateway pubkey first)
GATEWAY_KEY=$(cat ~/.config/Freenet/secrets/local-gw/transport.pub 2>/dev/null || echo "CHECK_PUBKEY")
freenet network \
  --network-port 31338 \
  --ws-api-port 7510 \
  --gateway "127.0.0.1:31337,${GATEWAY_KEY}" \
  --skip-load-from-network \
  --data-dir ~/freenet-local-peer/data \
  --log-dir ~/freenet-local-peer/logs \
  --log-level debug
```

### Mobile testing (phone on same WiFi)

```bash
# Bind WebSocket API to all interfaces (--ws-api-address 0.0.0.0)
freenet network \
  --ws-api-address 0.0.0.0 \
  --ws-api-port 7510 \
  --network-port 31338 \
  --is-gateway \
  --skip-load-from-network \
  --data-dir ~/freenet-mobile-test/data \
  --public-network-address 127.0.0.1 \
  --log-dir ~/freenet-mobile-test/logs \
  --log-level debug

# Phone opens: http://{YOUR_LAN_IP}:7510/v1/contract/web/{CONTRACT_ID}/
# Find LAN IP:
#   macOS:  ifconfig en0 | grep "inet "
#   Linux:  ip addr show wlan0
```

### Multi-instance deployment (10 peers + gateway)

```bash
# Uses deploy-local-gateway.sh from freenet-core
cd /path/to/freenet-core
scripts/deploy-local-gateway.sh --all-instances
```

## Publishing Contracts Locally

### Using fdev

**IMPORTANT:** fdev argument order matters. `--code` and `--parameters` go
before the `contract` subcommand. `--port` goes before `execute`.

```bash
# Publish a contract with webapp
fdev --port 7510 execute put \
  --code target/wasm32-unknown-unknown/release/my_contract.wasm \
  --parameters params.bin \
  contract \
  --webapp-archive target/webapp/webapp.tar.xz \
  --webapp-metadata target/webapp/webapp.metadata

# Get contract ID without publishing
fdev get-contract-id \
  --code target/wasm32-unknown-unknown/release/my_contract.wasm \
  --parameters params.bin
```

### Targeting a specific node

Override the WebSocket port for fdev:
```bash
fdev --port 7510 execute put --code ... contract ...
```

### Querying Node State

```bash
# List connected peers and subscriptions
fdev query

# Get detailed node diagnostics
fdev diagnostics

# Get diagnostics for specific contracts
fdev diagnostics --contract <base58_contract_id>
```

## WebSocket API

### Connection

```
ws://127.0.0.1:7509/v1/contract/command?encodingProtocol=native
```

- **Encoding:** `native` (bincode) or `flatbuffers`
- **Auth:** Send `ClientRequest::Authenticate { token }` after connecting
- **Tokens:** Generated per-connection, base58-encoded 32 bytes. Invalidated on node restart (error prefix: `AUTH_TOKEN_INVALID`).

### Request Types

```rust
pub enum ClientRequest {
    ContractOp(ContractRequest),   // GET, PUT, UPDATE, Subscribe
    DelegateOp(DelegateRequest),   // Delegate operations
    Authenticate { token },         // Auth token
    NodeQueries(NodeQuery),         // Queries (see below)
    Disconnect { cause },           // Close with reason
    Close,                          // Graceful close
}
```

### NodeQuery Variants

| Query | Response | Data |
|-------|----------|------|
| `ConnectedPeers` | `ConnectedPeers { peers }` | `Vec<(peer_id, socket_addr)>` |
| `SubscriptionInfo` | `NetworkDebug(info)` | Subscriptions + connected peers |
| `NodeDiagnostics { config }` | `NodeDiagnostics(response)` | Configurable (see below) |
| `ProximityCacheInfo` | `ProximityCache(info)` | Proximity cache state for update propagation |

### NodeDiagnostics Config

```rust
NodeDiagnosticsConfig {
    include_node_info: bool,           // Peer ID, location, uptime
    include_network_info: bool,        // Active connections, peer list
    include_subscriptions: bool,       // Active subscriptions
    contract_keys: Vec<ContractKey>,   // Specific contracts (empty = all)
    include_system_metrics: bool,      // Connection count, hosting contracts
    include_detailed_peer_info: bool,  // Full peer details
    include_subscriber_peer_ids: bool, // Peer IDs of subscribers per contract
}
```

**Wire-format change (stdlib v0.7.0):** `NodeDiagnosticsResponse.contract_states` is now `HashMap<String, ContractState>` where the key is the Base58-encoded `ContractKey::Display` form (the `instance` field, not the full struct). Previously it was `HashMap<ContractKey, ContractState>` but that serialization broke JSON because the key was a struct. To map back to a typed `ContractKey`, decode the Base58 string and reconstruct. This is a bidirectional bincode break — match node and tooling versions.

```rust
for (key_str, state) in diag.contract_states.iter() {
    // key_str is the Base58 ContractKey instance id
}
```

## Config File Reference

```toml
mode = "network"                      # "network" or "local"
network-address = "0.0.0.0"
network-port = 54761                  # UDP port for peer traffic
ws-api-address = "0.0.0.0"
ws-api-port = 7509                    # HTTP + WebSocket port
min-number-of-connections = 25
max-number-of-connections = 100
transient-budget = 2048               # Max concurrent transient connections (gateway)
transient-ttl-secs = 30              # TTL for unpromoted transient connections
token-ttl-seconds = 86400            # Auth token lifetime
token-cleanup-interval-seconds = 300
log_level = "info"
is_gateway = false
```

## Ring Distance

Each peer has a ring location in [0.0, 1.0). Distance between two locations:

```
distance = min(|a - b|, 1.0 - |a - b|)
```

Max distance is 0.5. Use the dashboard peer table to get locations and compute distances.

## Debugging

### Check node status

```bash
# Node status page
curl -s http://127.0.0.1:7510/

# Active WebSocket connections
lsof -i :7510 -P | grep ESTABLISHED     # macOS
ss -tnp | grep 7510                      # Linux
```

### Check node logs

```bash
# Follow logs (use your --log-dir path)
tail -f "$LOG_DIR"/freenet.$(date +%Y-%m-%d-%H).log

# Filter for contract/delegate events
tail -f "$LOG_DIR"/freenet.*.log | grep -i "delegate\|contract\|websocket\|error\|sign"
```

Each node instance should use `--log-dir` pointing to a unique directory
so logs don't interleave.

### Debugging node logs

Key patterns to search for:

```bash
# Follow all delegate + contract activity
grep -i "delegate\|sign\|update\|put\|subscribe" "$LOG_DIR"/freenet.*.log | tail -50

# Track a specific operation by transaction ID
grep "01KK70QEAR" "$LOG_DIR"/freenet.*.log

# WebSocket lifecycle
grep -i "websocket\|connection\|disconnect\|client" "$LOG_DIR"/freenet.*.log | tail -20
```

### Debugging with Playwright (automated browser testing)

Use the Playwright MCP tools to test the full UI flow without manual interaction:

```
1. browser_navigate → open the contract URL
2. browser_snapshot → see the DOM state
3. browser_click / browser_fill_form → interact with the UI
4. browser_console_messages → check for WASM panics or JS errors
```

Especially useful for reproducing mobile issues on desktop, where console
output is visible. If a flow works in Playwright but not on mobile, the
issue is likely WebSocket suspension or browser caching.

### Mobile-specific debugging

**Browser caching:** Mobile browsers aggressively cache WASM bundles. After
republishing, use a cache-busting URL parameter:
```
http://{IP}:7510/v1/contract/web/{CONTRACT_ID}/?_v={timestamp}
```

Or clear browser cache / force close and reopen. Firefox mobile is
particularly aggressive about caching.

**WebSocket suspension:** Mobile browsers suspend WebSocket connections when:
- Screen locks
- Tab goes to background
- Browser switches to another app
- Heavy WASM computation starves the event loop

Your app should handle reconnection when the tab becomes visible again.
Consider implementing a `visibilitychange` listener that re-establishes
the WebSocket connection.

### Common issues

| Symptom | Cause | Fix |
|---------|-------|-----|
| "Gateway nodes must specify a public network address" | Missing `--public-network-address` | Add `--public-network-address 127.0.0.1` |
| Signing key lost after node restart | Used `--id` (ephemeral temp dir) | Use `--data-dir` for persistent data |
| "Auth token not found" | Stale cached page | Hard refresh or clear browser cache |
| "delegate not found in store" | Legacy delegate migration | Expected on fresh node, non-blocking |
| "Connection reset by peer" | Browser killed WebSocket | Check if page is in background tab |
| "peer connection dropped" on put | Publishing to live node failed | Use isolated test node (`--skip-load-from-network`) |
| Contract not found | Not published to this node | Publish with `fdev --port {PORT}` (see [Isolation pitfalls](#isolation-pitfalls)) |
| "Signature verification failed" on a fresh publish | `fdev` defaulted to port 7509 and hit the system node | Pass `fdev --port {TEST_PORT}` explicitly |
| Test node joins public network despite `--data-dir` | `gateways.toml` is read from global config, not `--data-dir` | Override `HOME` to a sandbox dir with `gateways = []` (see [Isolation pitfalls](#isolation-pitfalls)) |
| Blank page (cached old WASM) | Mobile browser caches aggressively | Clear cache, force close browser, or use `?_v=timestamp` |
| `sed -i` fails on macOS | BSD sed requires backup extension | Use build tools directly instead of sed |
| `cargo make` targets Linux | Cross-compilation for web-container-tool | Build natively: `cargo build --release -p web-container-tool` |
| A delegate's contract GET/PUT/UPDATE/SUBSCRIBE silently does nothing | Node started with `freenet local` | Start with `freenet network` as every recipe here does. See [Delegates need network mode](#delegates-need-network-mode) |

### Delegates need network mode

Every node recipe in this skill uses `freenet network`, and delegates are one of
the reasons to keep it that way. `freenet local` is the mode whose own help text
calls it useful for development, so it is the natural thing to reach for, and it
does not service a delegate's contract requests at all.

The loop that handles `GetContractRequest`, `PutContractRequest`,
`UpdateContractRequest` and `SubscribeContractRequest` from a delegate is
`handle_delegate_with_contract_requests` (`crates/core/src/contract.rs:537`).
Both of its call sites are reached from `contract_handling` (`:1268`), through
`handle_delegate_notification` (`:2076`) and `handle_contract_event` (`:2209`),
and `contract_handling` is spawned from one production site,
`crates/core/src/node/p2p_impl.rs:948`, the network node. `run_local_node` (`crates/core/src/node.rs:5768`) handles
`ClientRequest::DelegateOp` by calling `executor.delegate_request(...)` straight
through (`:5851`), which runs the delegate's `process()` and hands back its
outbound messages without acting on any of them. Nothing reports an error, so
the delegate looks healthy and simply has no effect.

Delegate notification is dead in local mode too, for the same reason:
`send_delegate_contract_notifications` returns immediately when
`delegate_notification_tx` is `None` (`executor_impl.rs:2169-2172`), and that
field is set only by `RuntimePool`, which local mode does not build.
`RequestUserInput` is lost the same way, which is freenet-core#5273.

The V2 delegate host functions are the exception: `ctx.get_contract_state`,
`ctx.put_contract_state` and `ctx.update_contract_state` are wasmtime imports
resolved inside the delegate's own execution, so they do reach the local store
under `freenet local`. `ctx.subscribe_contract` registers and can never fire.

Verified against freenet-core `main` @ `b863ee7c6` on 2026-08-30. The
`dapp-builder` skill's `references/delegate-patterns.md` has the full picture,
including what a delegate's contract access does and does not reach on a real
network node.

## Other Infrastructure

### Docker containers (freenet-core)

```bash
# Gateway container
cd /path/to/freenet-core/docker/freenet-gateway
docker-compose up

# Node container
cd /path/to/freenet-core/docker/freenet-node
docker-compose up
```

### fdev simulation testing

```bash
# Single-process test network (no real networking)
fdev test --gateways 2 --nodes 10 --events 100 --seed 0xDEADBEEF single-process

# With fault injection
fdev test --message-loss 0.1 --latency-min 50 --latency-max 200 single-process
```

## Related Skills

- **dapp-builder**: Design and architect new Freenet dApps
- **telemetry-monitor**: Analyze network telemetry from the central collector
- **release**: Publish production releases

