# Full run: the 3 commands > Rent a Hetzner server, start your own node and open the app with 3 commands. **Set up with AI:** one prompt has Claude Code, Codex or Cursor run these 3 commands with you. [Get the prompt](https://sudharshanx.github.io/runfarcaster/ai-assistant/). Three commands rent a Linux server at Hetzner, start your own Snapchain read node on it, and open a web app on the same machine that shows your feed from that node. With an app key, the app also posts, likes and follows through your node ([Post with an app key](https://sudharshanx.github.io/runfarcaster/first-cast/)). Vouchers do this Full run. FID, read node, snapshot, `blockDelay`, `KEY_ADD`, custody address, Caddy and sslip.io are in the [Glossary](https://sudharshanx.github.io/runfarcaster/glossary/). :::note[Set up first] - A Hetzner account with an empty project, and an API token for that project. [Server and node](https://sudharshanx.github.io/runfarcaster/server-and-node/) shows how. In an empty project the scripts cannot touch a server, volume or firewall that someone else made. - Room in your Hetzner volume quota for 1000 GB. New accounts may rent 1024 GB of volumes in total, across all projects, so a second node usually needs a higher limit ([Hetzner limits](https://sudharshanx.github.io/runfarcaster/server-and-node/#hetzner-limits)). - An SSH key uploaded to Hetzner. `hcloud ssh-key list` shows its name. - A computer with bash, `hcloud`, `jq`, `ssh`, `curl` and Node.js 22.12 or later (22.x, 24.x or 26+). To install Node.js 24 with [nvm](https://github.com/nvm-sh/nvm#installing-and-updating), run `nvm install 24 && nvm use 24`, or download it from [nodejs.org](https://nodejs.org/en/download). Node.js 20.19 can run `npm run dev`, but not the tests that `deploy-client.sh` runs. Get the code, set the token, then set the server options. The token prompt hides what you type. Replace `my-key` with your SSH key's name. ```bash title="your computer" git clone https://github.com/sudharshanx/runfarcaster cd runfarcaster printf 'Hetzner token: '; stty -echo; read -r HCLOUD_TOKEN; stty echo; echo; export HCLOUD_TOKEN MY_IP=$(curl -4 -s https://ifconfig.me) && echo "$MY_IP" export NAME=snapchain-read-node SSH_KEY=my-key SERVER_TYPE="" VOLUME_GB=1000 WATCHDOG=0 SSH_SOURCE_CIDRS="$MY_IP/32" ``` `NAME` names the server; the volume and the firewall are named after it, and `teardown.sh` deletes by it. `SERVER_TYPE=""` lets the script pick the server type. `WATCHDOG=0` keeps the server until you delete it. `SSH_SOURCE_CIDRS` lets only your address reach SSH. These settings last for this terminal only; in a new terminal, run the last 3 lines again. ::: ## 1. Rent a server If the project already has a server named `$NAME`, or the volume `$NAME-data`, `provision.sh` stops and changes nothing. That server may be someone else's. For a second node, pick another name (for example `export NAME=snapchain-read-node-2`) and keep it for teardown. To continue your own earlier run, use `REUSE=1 ./provision.sh`. ```bash title="your computer" IP=$(./provision.sh) && echo "$IP" ``` **You will see** `[provision] server type …` with the type it picked, then `[provision] done. Next …` and the server's IP address. **Time:** under a minute. Hetzner bills the server by the hour until you delete it. The tested type is cax31: with IPv4 and the 1000 GB volume, $0.164 an hour (our estimate, since the volume is listed by the month) and at most $102.29 a month. If a type isn't available to rent in `LOCATION`, step 1 stops and lists the types that are, cheapest first. cpx42 (x86, 16 GB RAM) comes to $0.255 an hour, at most $159.29 a month. **cpx42 has not been tested.** What step 1 does: [Server and node](https://sudharshanx.github.io/runfarcaster/server-and-node/#what-step-1-does). Our cax31 run cost $2.31 up to sync (14 h 06 m). After that it keeps costing $0.164 an hour, $3.93 a day, until you run `CONFIRM="$NAME" ./teardown.sh` ([When you are done](#when-you-are-done)). :::tip[Lost `$IP`?] `$IP` only lives in the terminal where you set it. In a new terminal, run the `export NAME=…` line from **Set up first** again, then `IP=$(hcloud server ip "$NAME")`. ::: How the server type is picked With `SERVER_TYPE=""`, `provision.sh` tries cx43, cax31, cpx42 and ccx23 in that order and rents the first one for sale in `LOCATION` with at least 16 GB of RAM. It logs `skipping …` for each type it can't rent there. If none is for sale, it stops with `none of [...] is usable`, lists the types you can rent there, and creates nothing. Run step 1 again with one of them in `SERVER_TYPE`, or with another place in `LOCATION` (fsn1, nbg1, hel1, ash, hil or sin). To see the pick and the price first, without creating anything: `ESTIMATE_ONLY=1 SERVER_TYPE="" VOLUME_GB=1000 ./provision.sh`. A trial server that deletes itself Turn the watchdog on and set a price limit and the hours you expect to use: ```bash title="your computer" IP=$(WATCHDOG=1 MAX_USD=8 EXPECTED_HOURS=24 ./provision.sh) && echo "$IP" ``` provision.sh then starts `watchdog.sh` in the background on your computer, before it creates anything. It deletes the server, the volume and the firewall at the first of these: - 24 hours after it started, even if the node is healthy (`WATCHDOG_MAX_WALL_SECS` changes this, for example `172800` for 48 hours), - when the bill would reach `MAX_USD`, - when the node has made no progress for 2 hours. Our cax31 sync took 14 h 06 m, so the 24-hour default leaves just under 10 hours with a synced node. If the watchdog cannot start (for example `HCLOUD_TOKEN` is missing), step 1 stops with an error and creates nothing. Its output is in `.provision-state/watchdog.out` and its log in `.provision-state/watchdog.log`. The progress check reads the database size and block heights over SSH with `SSH_KEY_FILE`, the private half of the key you uploaded to Hetzner. It defaults to `~/.ssh/id_ed25519` if that file exists. Without a key file only the price and time limits apply. If SSH stops working (for example your home IP changed), the watchdog logs a warning and keeps the server. To keep the node after all, stop the watchdog without deleting anything: `touch .provision-state/watchdog.disarm`. A 2 TB volume instead Run step 1 as `IP=$(VOLUME_GB=2000 ./provision.sh)`. It needs a Hetzner volume limit above 2000 GB and has not been tested. With cax31 it lists at $178.99 a month, $153.40 of that for the volume, or an estimated $0.286 an hour. Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: step 1, rent the server on Hetzner Cloud. 1. Check: hcloud version, jq --version, ssh -V, curl --version, and that HCLOUD_TOKEN is set without printing it: [ -n "$HCLOUD_TOKEN" ] && echo set. Run hcloud ssh-key list and ask me which key name to use. 2. Find my public IP: MY_IP=$(curl -4 -s https://ifconfig.me) and show it with echo "$MY_IP". If it is empty, stop. 3. Show the price, creating nothing: ESTIMATE_ONLY=1 SERVER_TYPE="" VOLUME_GB=1000 ./provision.sh Tell me which server type it picked and the price. If it says "none of [...] is usable", show me the types it lists and ask me which SERVER_TYPE (and LOCATION, if I want another place) to use. The guide has been tested on cax31 only. If it picked another type, tell me so, because other types have not been tested. 4. Use an empty Hetzner project. Set the name: export NAME=snapchain-read-node. 5. When I say yes, run (add LOCATION= if I picked one): IP=$(SSH_KEY= SERVER_TYPE="" VOLUME_GB=1000 WATCHDOG=0 SSH_SOURCE_CIDRS="$MY_IP/32" ./provision.sh) Expected: "[provision] cost guard OK", then "[provision] WATCHDOG=0: no watchdog started", then a "[provision] done. Next" line on stderr, and the IPv4 address on stdout. It takes under a minute. Known errors: "this project already has: ..." means a server, volume or firewall with that name exists and may be someone else's; nothing was changed. Ask me: another NAME for a new node, or REUSE=1 only if it is my own earlier run. "would pass the 1024 GB account quota" means the volume quota is full. "volumes size limit exceeded (resource_limit_exceeded)" means the account's volume limit (1024 GB for new accounts); keep VOLUME_GB=1000. "not available in right now" or "none of [...] is usable" means those types aren't available to rent there; the error lists what is, cheapest first. "unknown LOCATION" lists the valid names. Tell me: the server keeps running, and Hetzner bills it by the hour, until I delete it with teardown.sh. If I say I only want a trial run: run step 1 without WATCHDOG=0 and with MAX_USD=8 EXPECTED_HOURS=24. Then provision.sh starts watchdog.sh, which deletes everything after 24 h, when the bill would pass MAX_USD, or after 2 h without progress. To keep the node after all: touch .provision-state/watchdog.disarm. Its log: .provision-state/watchdog.log. Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## 2. Start the node This uses `$IP` from step 1, in the same terminal. ```bash title="your computer" ssh root@$IP \ "SNAPCHAIN_AGREE_NO_REWARDS=yes MIN_FREE_GB=900 bash -s" \ < node.sh ``` **You will see** `[node.sh] Node is in sync.`, then a health check with `sync: SYNCED`. **Time:** on a server with 8 cores and 16 GB of RAM (Hetzner cax31), loading the snapshot took 8 h 34 m in our run and catching up 5 h 15 m more. A faster disk or network shortens both. Times on cpx42 and other types have not been measured. What step 2 does: [Server and node](https://sudharshanx.github.io/runfarcaster/server-and-node/#what-step-2-does). Right after step 1, SSH can refuse the connection for a minute while the server boots. Wait and run it again. On the first connection SSH asks you to confirm the server's fingerprint; type `yes`. If SSH drops, or to check progress The node keeps going on the server. Reconnect and wait for it again: ```bash IP=$(hcloud server ip "$NAME") # only in a new terminal, after export NAME=… ssh root@$IP 'bash -s wait' < node.sh ``` To check progress: ```bash ssh root@$IP 'curl -s 127.0.0.1:3381/v1/info' | jq '.shardInfos[] | {shardId, blockDelay}' ``` Synced means every shard shows a `blockDelay` in single digits ([Getting Started](https://snapchain.farcaster.xyz/getting-started)). The API does not answer while the snapshot loads. Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: step 2, start the Snapchain read node on the server (1000 GB volume). 1. Use $IP from step 1. Only in a new terminal: IP=$(hcloud server ip "$NAME") 2. ssh root@$IP "SNAPCHAIN_AGREE_NO_REWARDS=yes MIN_FREE_GB=900 bash -s" < node.sh If SSH says "Connection refused" right after step 1, the server is still booting: wait a minute and run it again. node.sh installs Docker, installs two disk helpers because the disk is under 1500 GB, runs the official bootstrap and waits up to 24 h for sync. Expected: "[node.sh] Data disk ... arming disk-guard and snapshot-space-saver.", then "[node.sh] ... SYNCING shard 0: height=... blockDelay=..." lines, then "[node.sh] Node is in sync." and a health check with "sync: SYNCED". The HTTP API does not answer while the snapshot loads; that is normal. On cax31, our run took 14 h 06 m from renting the server to synced. If SSH drops, the node keeps going. Continue waiting with: ssh root@$IP 'bash -s wait' < node.sh Check: ssh root@$IP 'curl -s 127.0.0.1:3381/v1/info' | jq '.shardInfos[] | {shardId, blockDelay}' (synced = every blockDelay in single digits). Port 3381 is closed to the internet, so check over SSH. Repeated "InsufficientPeers" warnings that mention "read-node-peers" are fine while blockDelay stays in single digits. If blockDelay keeps growing, tell me: the validators may have stopped making blocks. Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## 3. Open the app ```bash title="your computer" ./deploy-client.sh "$IP" ``` **You will see** `[deploy-client] OK: https:// (client) …`. Open that address: the status at the top says **Connected to your node**. **Time:** under a minute with a warm npm cache. The first build takes longer. Step 3 can run before step 2 finishes, in a second terminal, while the node is still loading its snapshot (set `NAME` and `IP` there as in **Lost `$IP`?**). The script then ends with `[deploy-client] OK: app is up at https://. The node isn't answering yet`, and the app shows **Node still starting** until the node's API answers. To post, add an app key: [Post with an app key](https://sudharshanx.github.io/runfarcaster/first-cast/). What step 3 does: [Server and node](https://sudharshanx.github.io/runfarcaster/server-and-node/#what-step-3-does). To build your own app, bot or analytics job on the node, start with [Build on your node](https://sudharshanx.github.io/runfarcaster/build-on-your-node/). Use your own domain Point the domain at `$IP` and pass it as a second argument: `./deploy-client.sh "$IP" node.example.com`. Without one, the script uses `.sslip.io`, a free DNS service run by someone else. That is fine for reading. To add a key with your recovery phrase, use the local copy from [Post with an app key](https://sudharshanx.github.io/runfarcaster/first-cast/). Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: step 3, put the web app in front of the node. Needs Node.js 22.12+ (22.x), 24.x or 26+ on my computer. Run: ./deploy-client.sh "$IP" (a DNS name of mine that points at $IP can go as the 2nd argument; without it the script uses .sslip.io) Expected last line: "[deploy-client] OK: https:// (client) and https:///v1/info (node, proxied)". Under a minute with a warm npm cache. It can run while the node is still loading its snapshot: then it ends with "[deploy-client] OK: app is up at https://. The node isn't answering yet", and the app shows "Node still starting" until the node answers. Then I open the address. The status at the top should say "Connected to your node". If I started the watchdog in step 1, remind me that it deletes the node 24 h after step 1. To keep it: touch .provision-state/watchdog.disarm. If it fails: ports 80 and 443 must be open (provision.sh opens them). Look at: ssh root@$IP 'cd /opt/snapchain-client && docker compose logs caddy'. For a specific SSH key: SSH_OPTS="-i " ./deploy-client.sh "$IP". Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` :::caution[When you are done: delete everything] ```bash title="your computer" CONFIRM="$NAME" ./teardown.sh ``` This deletes the server, the volume and the firewall, with the node's database. It keeps the SSH key you uploaded, and running it twice is safe. If `provision.sh` in this folder recorded a different ID for the server named `$NAME`, that server is not the one you created, so `teardown.sh` stops and deletes nothing. `OTHER_SERVER_OK=1` overrides this. Read the script first: [teardown.sh](https://github.com/sudharshanx/runfarcaster/blob/main/teardown.sh). `teardown.sh` ran against real servers on 20 Oct 2026 and left the project empty. Check the [Hetzner console](https://console.hetzner.cloud/) afterwards anyway. ::: --- # How Farcaster works > Check each Farcaster idea with a command against your own node. Updated on 18 October 2026 Tested on Hetzner cax31 (ARM64), Ubuntu 24.04, Snapchain v0.14.3, mainnet. Adding an app key and posting a cast have been tested on mainnet; likes, recasts and follows against a mock node only. See [test status](https://sudharshanx.github.io/runfarcaster/proof/#test-status). Each idea below comes with a command to run against the node and app from the [Full run](https://sudharshanx.github.io/runfarcaster/quickstart/). **Reading** needs no account: - [Snapchain messages](#some-parts-live-onchain): casts, likes, follows and profile fields, and which parts live onchain instead. - [Read nodes and validators](#shards-validators-and-your-node): what your node stores and what it leaves to the validators. - [The HTTP API](#read-with-the-http-api) the app reads from. **Posting** needs an account and a key: - [FIDs and custody addresses](#account-number-and-owner-address): the account number and the address that owns it. - [App keys](#app-keys-are-gasless-now): the key the app signs with, and how it gets added without gas. - [Storage rent](#storage-rent): the space an account pays for before it can post. - [Usernames](#usernames-fnames-and-ens-names): fnames and ENS names, and how they map to an FID. ## Where a post goes from a read node A read node downloads every block the validators make and keeps a full copy of the messages. It takes no part in making blocks, so it cannot add a cast to the network by itself. Posting still works through it: 1. The app signs the cast with the app key, then posts the bytes to `POST /v1/submitMessage` on your node. 2. The node checks the message against its own copy of the state. If it is valid and new, the node gossips it to the validators on the `mempool` topic and keeps nothing. 3. A validator puts the message in a block. Your node gets that block like any other, and the app finds the cast there. [Your node passes messages on](#your-node-passes-messages-on) has the sources, what an HTTP `200` means and how the app checks that the cast landed. Adding an app key takes the same path. The app sends a gasless `KEY_ADD` message, signed by the custody address, through `submitMessage` like a cast, so you need no ETH and no OP Mainnet connection. [App keys are gasless now](#app-keys-are-gasless-now) has its three signatures and the onchain way it replaced. ## How the parts connect Caddy serves the app on port 443 and passes `/v1` requests to the node on port 3381. The node talks to the validators over UDP port 3382. The validators read FIDs, custody addresses, storage and onchain keys from OP Mainnet and put those events in blocks. ## Point the commands at your node You need: - A synced node from the Full run, or any Snapchain node whose HTTP API you can reach. - `curl` and `jq`. - To post: a Farcaster account. Reading needs no account. Set `NODE` to your app's address. Caddy passes `/v1` to the node: ```bash export NODE=https:// ``` Port 3381 is closed to the internet by default. To reach it from your computer directly, open an SSH tunnel in another terminal, then use `NODE=http://127.0.0.1:3381`: ```bash ssh -N -L 3381:127.0.0.1:3381 root@$IP ``` ## App keys are gasless now An app posts for you with an **app key**. The docs also call it a *signer*. It is an ed25519 key pair. Since engine version V16 went live on mainnet on 2026-05-07, you add one by sending a `KEY_ADD` **message** to a node. Before that you had to send a transaction on OP Mainnet and pay for it in ETH. Sources: [writing-messages guide](https://snapchain.farcaster.xyz/guides/writing-messages), and the V16 date and `GaslessSigners` feature in [`src/version/version.rs`](https://github.com/farcasterxyz/snapchain/blob/fc87f2686472598143062a00e1c2b2633b2d87ab/src/version/version.rs). A `KEY_ADD` holds three signatures: | Signed by | What | Why | |---|---|---| | Custody address (EIP-712, domain `Farcaster KeyAdd`, chainId 1) | `KeyAdd(fid, key, keyType, scopes, ttl, nonce, deadline)` | You approve this key, with these permissions, for this long | | Custody address of `requestFid`, the app's FID (same domain) | `SignedKeyRequest(requestFid, key, deadline)` | Says which app asked for the key. The RunFarcaster app uses your own FID, so your custody key signs both. | | The new app key | The message envelope | Shows the app has the private half | What the node checks (`key.rs` at v0.14.3, see SOURCES.md): - `nonce` is above `currentUserNonce` from `GET /v1/signersByFid`. The app sends `currentUserNonce + 1`. - `ttl` is between 1 second and 90 days. Each use of the key moves the expiry forward by the ttl again. - At most 16 scopes. `KEY_ADD` and `KEY_REMOVE` can never be scopes. At most 1000 gasless keys per FID. - `deadline` counts seconds since 2021-01-01 UTC (Farcaster time). ```bash title="Your nonce and keys" curl -s "$NODE/v1/signersByFid?fid=" \ | jq '{currentUserNonce, gaslessSignerCount, gaslessSignerLimit, signers: [.signers[] | {source, scopes, ttl}]}' ``` Scopes and a lifetime only exist on gasless keys. For keys added onchain the node sends no `ttl`, so jq prints `null`; `scopes` may be empty or missing. This output has not been checked against a live onchain key. The app asks for 3 scopes, `CAST_ADD`, `REACTION_ADD` and `LINK_ADD`, for 7 days by default. The example in the official guide asks for 9 scopes and 30 days ([Docs issues #9](https://sudharshanx.github.io/runfarcaster/docs-issues/)). ```ts title="client/src/signer.ts (condensed)" const { currentUserNonce } = await api.signersByFid(fid); const body = await makeKeyAddBody( { fid: BigInt(fid), key, scopes, ttl, nonce: currentUserNonce + 1, deadline: nowFc + 3600 }, new ViemLocalEip712Signer(custodyAccount), // custody key signs the two EIP-712 payloads ); const msg = await makeKeyAdd(body.value, { fid, network: FarcasterNetwork.MAINNET }, appSigner); // app key signs the envelope ``` Call `makeKeyAddBody`. The older `getSignedKeyRequestMetadata` signs for the onchain contract, and the node rejects that inside a `KEY_ADD`. :::danger[Your recovery phrase] The two EIP-712 signatures need your custody key, which comes from your recovery phrase. Use a test account, on your own computer, as in [Post with an app key](https://sudharshanx.github.io/runfarcaster/first-cast/). Only paste a seed phrase into software from developers you trust, or code you have read, and prefer a delegated signer where you can. Never paste it into an AI assistant. How the app handles it: [What the app does with your recovery phrase](https://sudharshanx.github.io/runfarcaster/first-cast/#what-the-app-does-with-your-recovery-phrase). ::: Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: answer a question about the guide. Use only these sources, and say which one each claim comes from: - https://snapchain.farcaster.xyz/node-types (read nodes and validators; read nodes pass messages on and keep no mempool) - https://snapchain.farcaster.xyz/guides/writing-messages (gasless KEY_ADD, live on mainnet since engine version V16 on 2026-05-07) - https://snapchain.farcaster.xyz/reference/httpapi/httpapi (HTTP API) - The repo files SOURCES.md and client/src/*.ts Question: How does a gasless KEY_ADD work, which three signatures does it carry, and how do nonce and TTL work? Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## Your node passes messages on A read node validates each message sent to `POST /v1/submitMessage`, publishes it to the validators on the `mempool` gossip topic, and does not hold on to it. Sources: [node-types: message submission](https://snapchain.farcaster.xyz/node-types#message-submission-and-the-mempool), and in the code, `submit_message_internal` in `src/network/server.rs` and `ReadNodeMempool` in `src/mempool/mempool.rs`. An HTTP `200` means the node found the message valid and sent it on, not that a validator has put it in a block. To find out, ask for the message back. `/v1/info` on a read node shows `mempoolSize: 4294967295` for shards 1 and 2, a placeholder value. This optional check sends 64 random bytes, so the node rejects it and nothing is posted: ```bash title="Optional" head -c 64 /dev/urandom | curl -s -X POST "$NODE/v1/submitMessage" \ -H 'Content-Type: application/octet-stream' --data-binary @- # HTTP 400: Invalid protobuf data: Unable to decode message ``` What the app does after you press Post, Like, Recast or Follow ([client/src/pending.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/pending.ts)): 1. Shows it right away, marked **Sending**. 2. Sends the signed message. On `200` it shows **Waiting for a block**. 3. Every 4 seconds, asks the node for it by hash (`castById`, `reactionById`, `linkById`). 4. If the node still does not have it 30 seconds after the last send, sends the identical message again. It has the same hash, so it cannot appear twice. 5. After 4 sends it marks the message **Failed** and says why. A `4xx` answer fails it at once, since an invalid message stays invalid. How long a valid message takes to appear has not been measured. Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: answer a question about the guide. Use only these sources, and say which one each claim comes from: - https://snapchain.farcaster.xyz/node-types (read nodes and validators; read nodes pass messages on and keep no mempool) - https://snapchain.farcaster.xyz/guides/writing-messages (gasless KEY_ADD, live on mainnet since engine version V16 on 2026-05-07) - https://snapchain.farcaster.xyz/reference/httpapi/httpapi (HTTP API) - The repo files SOURCES.md and client/src/*.ts Question: What does a 200 from submitMessage on a read node mean, and why should a client query the message back and retry? Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## Some parts live onchain | Part | Where | What | |---|---|---| | Accounts | Contracts on OP Mainnet (IdRegistry, KeyRegistry, StorageRegistry) | Account numbers (FIDs), owner addresses, storage, keys added onchain | | Messages | Snapchain | Casts, likes and recasts, follows, profile fields, verifications, gasless `KEY_ADD` | | Apps | Anywhere | Apps like this one, reading from whichever node they choose | Validators read the Farcaster contracts on OP Mainnet and put those events in blocks. Your node gets them inside the blocks, with all messages, so it needs no OP Mainnet connection of its own. Your account is not tied to an app. Any app that can reach a node can show your posts, and any app key you approved can post for you. ## Account number and owner address - **FID** is your account number. It is registered onchain. Profiles, casts and follows all refer to it. - **Custody address** is the Ethereum address that owns the FID. It approves and revokes app keys, and can move the FID to another address. The app derives it from the recovery phrase at the path `m/44'/60'/0'/0/0`. Registering an FID costs gas on OP Mainnet. This guide does not do it. ## Usernames: fnames and ENS names An FID is a number. The `@name` people see is a separate username, and it comes in two kinds ([docs: Usernames](https://docs.farcaster.xyz/learn/what-is-farcaster/usernames), [ENS names](https://docs.farcaster.xyz/learn/architecture/ens-names)): | Kind | Issued by | Cost | Who controls it | |---|---|---|---| | Offchain name (fname), like `@alice` | The Fname Registry, a server run by the Farcaster team, as `alice.fcast.id` | Free | Farcaster can reclaim it, for example after 60 days unused. One per account; it can change once every 28 days. | | Onchain ENS name, like `@alice.eth` | ENS, on Ethereum L1 | A registration fee | Your wallet. Farcaster cannot revoke it. | Both kinds are at most 16 characters: lowercase letters, numbers and hyphens. Your node keeps a **username proof** for each name: the name, the FID, the owner address, a timestamp and a signature. For an fname the Fname Registry signs the proof. An ENS proof needs the FID to have verified the address that owns the name. The profile then points at one valid proof with a `USERNAME` field in its user data. ```bash curl -s "$NODE/v1/userNameProofByName?name=dwr" | jq '{name, fid, type}' curl -s "$NODE/v1/userNameProofsByFid?fid=2" | jq '[.proofs[] | {name, type}]' ``` `type` is `USERNAME_TYPE_FNAME` for an fname and `USERNAME_TYPE_ENS_L1` for a `.eth` name ([Username Proofs API](https://snapchain.farcaster.xyz/reference/httpapi/usernameproof)). ## Storage rent Messages take space on every node, so an account pays rent for it ([docs: Messages](https://docs.farcaster.xyz/learn/what-is-farcaster/messages), [Contracts](https://docs.farcaster.xyz/learn/architecture/contracts)): - Storage is rented in **units**, paid in ETH to the Storage Registry contract on OP Mainnet. Prices are set in USD and change with demand. - A unit **lasts one year**. Each unit allows a fixed number of casts, reactions, follows, profile fields, verifications and username proofs. - **When an account is full** for one kind of message, the node prunes the oldest message of that kind to make room for the new one. Posting keeps working. - **When storage expires**, there is a 30-day grace period. An account that does not renew in time may lose all its messages. - A deleted message still counts toward the limit until newer messages push it out. The per-unit numbers in the official docs are out of date ([Docs issues #20](https://sudharshanx.github.io/runfarcaster/docs-issues/)). Your node returns the current limits, how much is used and how many units the account has: ```bash curl -s "$NODE/v1/storageLimitsByFid?fid=3" | jq '{units, limits: [.limits[] | {name, limit, used}]}' curl -s "$NODE/v1/userDataByFid?fid=3" | jq '.messages[].data.userDataBody' ``` Buying storage costs gas. This guide does not do it; a test account needs at least one unit before it can post. Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: answer a question about the guide. Use only these sources, and say which one each claim comes from: - https://snapchain.farcaster.xyz/node-types (read nodes and validators; read nodes pass messages on and keep no mempool) - https://snapchain.farcaster.xyz/guides/writing-messages (gasless KEY_ADD, live on mainnet since engine version V16 on 2026-05-07) - https://snapchain.farcaster.xyz/reference/httpapi/httpapi (HTTP API) - The repo files SOURCES.md and client/src/*.ts Question: What is the difference between an FID, a custody address, an fname and an app key, and how does storage rent work? Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## Shards, validators and your node | Question | Validator | Your read node | |---|---|---| | Makes blocks and votes on them | Yes | No | | Has every message | Yes | Yes, downloaded from validators | | Takes `submitMessage` | Yes, into its mempool | Yes, and sends it to validators | | Rewards | Not covered here | None | | Delay | Up to date | A little behind, by the time it takes to fetch each block ([node-types](https://snapchain.farcaster.xyz/node-types)) | Messages are split across shards. Shard 0 holds network-wide data, including gasless `KEY_ADD` messages. Shards 1 and 2 split the accounts between them. `numShards` in `/v1/info` counts only the account shards, so it says 2 while `shardInfos` lists 3. Expect a database of about 560 GB; on the tested node the shards took 37, 261 and 259 GiB. ```bash curl -s "$NODE/v1/info" | jq '{version, numShards, shards: [.shardInfos[] | {shardId, maxHeight, blockDelay}]}' ``` `blockDelay` is how many seconds a shard's newest block is behind the clock. When every shard shows single digits, the node is synced ([Getting Started](https://snapchain.farcaster.xyz/getting-started)). A new node first downloads a snapshot. On 2026-10-18 that was 5689 compressed chunks, roughly 500 GB on disk. Then it fetches the newer blocks one by one: on cax31, about 4.3 to 4.6 blocks a second per shard. ## Read with the HTTP API The node answers HTTP on port 3381. It has no "home feed" endpoint, so the app builds one ([client/src/feed.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/feed.ts)): ```bash # 1. Which FIDs does FID 3 follow? curl -s "$NODE/v1/linksByFid?fid=3&link_type=follow&pageSize=5&reverse=true" \ | jq '[.messages[].data.linkBody.targetFid]' # 2. Newest casts from one FID curl -s "$NODE/v1/castsByFid?fid=3&pageSize=3&reverse=true" \ | jq '.messages[] | {fid: .data.fid, text: .data.castAddBody.text, hash}' # 3. The next page: send nextPageToken back as pageToken curl -s "$NODE/v1/castsByFid?fid=3&pageSize=3&reverse=true&pageToken=" ``` The app builds the feed in 3 parts: 1. Load the follow list. 2. For up to 40 of those FIDs, load the newest 10 casts, 6 requests at a time. 3. Sort by time. Times count seconds since 2021-01-01 00:00 UTC. :::caution[Write `reverse=true`] `reverse=1` gets HTTP 400 "provided string was not `true` or `false`", although one table in the docs uses it ([Docs issues #5](https://sudharshanx.github.io/runfarcaster/docs-issues/)). ::: Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: answer a question about the guide. Use only these sources, and say which one each claim comes from: - https://snapchain.farcaster.xyz/node-types (read nodes and validators; read nodes pass messages on and keep no mempool) - https://snapchain.farcaster.xyz/guides/writing-messages (gasless KEY_ADD, live on mainnet since engine version V16 on 2026-05-07) - https://snapchain.farcaster.xyz/reference/httpapi/httpapi (HTTP API) - The repo files SOURCES.md and client/src/*.ts Question: Which Snapchain HTTP API endpoints build a simple home feed, and how does paging with pageToken work? Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## Why the app and the node share one address A page loaded over HTTPS cannot call `http://:3381`; browsers block that as mixed content. GET requests to another address work, because the node sends `Access-Control-Allow-Origin: *`. `submitMessage` does not. Its `Content-Type: application/octet-stream` header makes the browser send an `OPTIONS` request first. The node answers that with 404, so the browser blocks the post. Caddy therefore serves the app and passes `/v1/*` to the node on the same address ([deploy/Caddyfile](https://github.com/sudharshanx/runfarcaster/blob/main/deploy/Caddyfile)): ```text title="deploy/Caddyfile (excerpt)" {$SITE_ADDRESS} { @admin path /v1/mesh /v1/mesh/* handle @admin { respond 404 } handle /v1/* { reverse_proxy {$NODE_UPSTREAM:127.0.0.1:3381} } handle { root * {$CLIENT_ROOT:/srv/client} try_files {path} /index.html file_server } # plus security headers, including a CSP with connect-src 'self' } ``` Through Caddy, `/v1/mesh` (the node's mesh debug view) returns 404. `/v1/currentPeers` stays public: it lists the peers the node is connected to, which are mainnet validators, with the addresses it sees for them. `connect-src 'self'` lets the page send requests only to its own address. If a library in the app were compromised, it could not send your data elsewhere. Caddy fetches its own Let's Encrypt certificate over ports 80 and 443. ## Filters The app's filters work on casts made in any app, because they run on what your node returns ([client/src/filters.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/filters.ts)). | Rule | Effect | |---|---| | Mute FID, mute keyword | Hide casts that match | | Only show FIDs, only show keywords | When set, hide every cast that does not match | When a cast matches both, the mute wins. Keywords ignore case and match anywhere in the text. Filters are saved in your browser, and the feed shows how many casts it hid. --- # Server and node > Set up Hetzner for the scripts; the tested node costs at most $102.29 a month. Set up a Hetzner Cloud account, an API token and an SSH key before step 1. Hetzner is the only tested provider: the scripts call Hetzner's `hcloud` command-line tool, so other clouds need different scripts. ## What you need - A Hetzner Cloud account with an empty project. - An API token for that project, Read & Write. - Your SSH key uploaded to that project. - On your computer: `hcloud`, `jq`, `ssh`, `curl`, and Node.js 22.12 or later (22.x, 24.x or 26+) with npm. - A Hetzner volume limit of at least 1000 GB. - About 14 hours during which the server can run without you. - Only to post: a test Farcaster account that has an FID and paid storage. Reading needs no account. Registering an FID and paying for storage are onchain actions that this guide does not cover; see the [Farcaster docs](https://docs.farcaster.xyz/). ## Hetzner account and project Sign up at Hetzner, verify your email, and enter contact and billing details. Hetzner's guide: [Hetzner Account Getting Started](https://docs.hetzner.com/general/billing-and-account-management/account-getting-started/). In the [Hetzner Cloud console](https://console.hetzner.cloud/), create a new project just for this, for example `snapchain`. A separate project keeps the token's reach small and makes cleanup easy to check: when you are done, the project should be empty. Hetzner explains projects in its [Cloud FAQ](https://docs.hetzner.com/cloud/general/faq/). ## API token Inside the project, create an API token with Read & Write permission. Hetzner's steps: [Generating an API token](https://docs.hetzner.com/cloud/api/getting-started/generating-api-token/). Hetzner shows the token only once. Read it into your shell with a hidden prompt, so it stays out of your shell history: ```bash title="your computer" printf 'Hetzner token: '; stty -echo; read -r HCLOUD_TOKEN; stty echo; echo; export HCLOUD_TOKEN ``` :::danger[Keep the token secret] Anyone with this token can create servers on your bill. Do not commit it, paste it into a chat or an AI assistant, or put it in a file inside the repo. The scripts read it from the environment and never print it. If it leaks, delete it in the console and create a new one. ::: Run that `read` line again in every new terminal. ## Tools | Tool | Why | Install | |---|---|---| | `hcloud` | Talks to Hetzner | [hetznercloud/cli](https://github.com/hetznercloud/cli) (macOS: `brew install hcloud`) | | `jq` | Reads JSON in the scripts | `brew install jq` or `sudo apt install jq` | | `ssh`, `curl` | Reach the server | Already on macOS and most Linux systems | | Node.js 22.12 or later (22.x, 24.x or 26+), npm | Builds the app | `nvm install 24 && nvm use 24` ([nvm](https://github.com/nvm-sh/nvm#installing-and-updating)), or [nodejs.org/en/download](https://nodejs.org/en/download) | ```bash title="check" hcloud version && jq --version && ssh -V && curl --version && node --version && npm --version hcloud server list # works once HCLOUD_TOKEN is set; an empty list is fine ``` Node.js 20.19 can run `npm run dev`, but not the tests that `deploy-client.sh` runs. ## SSH key If you do not have a key yet, make one, then upload the public half to the project: ```bash title="your computer" ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 # skip if the file exists hcloud ssh-key create --name my-key \ --public-key-from-file ~/.ssh/id_ed25519.pub hcloud ssh-key list ``` `my-key` is the name you pass as `SSH_KEY=my-key` in step 1 of the Full run. Keys belong to one project, so upload it to the one you made above. The `hcloud ssh-key create` command is from the [hcloud CLI tutorial](https://github.com/hetznercloud/cli/blob/main/docs/tutorials/create-a-server.md). ## Hetzner limits | Limit | What you see | What to do | |---|---|---| | New accounts may rent at most 1024 GB of volumes in total, across all projects. The official docs ask for 1.5 to 2 TB. Your limits: [console.hetzner.cloud/limits](https://console.hetzner.cloud/limits). | `volumes size limit exceeded (resource_limit_exceeded)`, or from `provision.sh` before it creates anything: `would pass the 1024 GB account quota` | Use `VOLUME_GB=1000`, the tested size. `node.sh` then adds two disk helpers so the snapshot fits. If you already run a node with a 1000 GB volume, a second one needs a higher limit. `provision.sh` only sees the volumes in this project; if Hetzner raised your limit, set `VOLUME_QUOTA_GB` to it. | | A server type isn't available to rent. | `provision.sh` refuses the type and lists what is for sale | See [Server choice](#server-choice). | ## Server choice | Setting | Tested with | Why | |---|---|---| | Server type | `cax31`: ARM64, 8 vCPU, 16 GB RAM, 160 GB disk | The node needs 16 GB of RAM. The Snapchain, statsd and Grafana images all have ARM64 builds. | | Volume | 1000 GB | Fits the new-account limit. The database reached 557.86 GB. | | Location | `fsn1` (Falkenstein) | The script's default. Server and volume must be in the same location. | | Image | Ubuntu 24.04 | The script's default. | Hetzner can't always rent every type in every location. If the type you set isn't available to rent in `LOCATION`, `provision.sh` stops and lists the types that are, cheapest first. With `SERVER_TYPE=""` it rents the first one it can. [Step 1](https://sudharshanx.github.io/runfarcaster/quickstart/#step-1) has the order it tries and what to set when nothing is for sale. ## What it costs For the tested cax31, from Hetzner's price list on 2026-10-18, in USD: | Item | Price | |---|---| | cax31 | $0.040 an hour, at most $24.99 a month | | Public IPv4 | $0.001 an hour, at most $0.60 a month | | 1000 GB volume | $76.70 a month ($0.0767 per GB). Hetzner bills it by the hour but lists only a monthly price. Our estimate is $0.123 an hour (monthly price ÷ 625 hours, the ratio of the server's own monthly and hourly prices) | | Total | $0.164 an hour with the volume estimate. $102.29 a month if you leave it on (each item stops at its monthly price). | Our cax31 run took 14 h 06 m from renting the server to a synced node: $2.31 at that hourly total, not an invoice. cpx42 (x86, 8 vCPU, 16 GB RAM) lists at $0.1314 an hour, at most $81.99 a month. With it instead of cax31, the total is $0.255 an hour and $159.29 a month. **cpx42 has not been tested**, so its sync time is unknown. To see the type it would pick and the price, before creating anything: ```bash title="See the price, create nothing" ESTIMATE_ONLY=1 SERVER_TYPE="" VOLUME_GB=1000 ./provision.sh ``` ## What step 1 does Read the script first: [provision.sh](https://github.com/sudharshanx/runfarcaster/blob/main/provision.sh) - Checks that each entry in `SSH_SOURCE_CIDRS` looks like an address with a prefix, such as `203.0.113.7/32`. An empty `$MY_IP` stops it here. - Checks that `LOCATION` exists. With `SERVER_TYPE=""` it picks the first type for sale there with at least 16 GB of RAM; with a type set, it checks that one. If nothing fits, it lists what is available and creates nothing. - Reads the hourly price from Hetzner's live price list and prints it. It stops before creating anything if that price × `EXPECTED_HOURS` (default 24) is above `MAX_USD` (default 8, in your account's currency). - Creates a firewall, the data volume and the server. The firewall opens SSH (22, only to `SSH_SOURCE_CIDRS`), gossip (3382/udp) and the web ports 80 and 443. The node's own API ports 3381 and 3383 stay closed; the app reaches the node through Caddy. - Starts `watchdog.sh` first, unless you set `WATCHDOG=0`. - Prints the server's IP. If the project already has the server `$NAME`, the volume `$NAME-data` or a firewall `$NAME-fw` applied to a server, it stops before creating or changing anything. With `REUSE=1` it continues with them, and it does not start a second watchdog if one is already running. From the tested run, with cax31 set by hand: ```text [provision] server type cax31: 8 vCPU (shared), 16 GB RAM, 160 GB disk, arm [provision] local disk 160 GB < 2000 GB: adding a 1000 GB volume total ~0.164 /h [provision] cost guard OK: projected <= MAX_USD=2 [provision] WATCHDOG=0: no watchdog started (no automatic spending cap). [provision] done. Next (in a new terminal, get the IP back with: IP=$(hcloud server ip snapchain-read-node)): ``` Check it worked: `hcloud server list` shows `snapchain-read-node` running, and `hcloud volume list` shows `snapchain-read-node-data`. ## What step 2 does Read the script first: [node.sh](https://github.com/sudharshanx/runfarcaster/blob/main/node.sh). It downloads and runs the official [`snapchain-bootstrap.sh`](https://github.com/farcasterxyz/snapchain/blob/main/scripts/snapchain-bootstrap.sh) and prints that file's sha256. - Checks Ubuntu 22.04 or 24.04, RAM and free disk. `MIN_FREE_GB=900` lets it accept the 1000 GB volume, which shows 984 GB. - Installs Docker from Docker's own apt repository. - Writes the two answers the bootstrap would ask for into `~/snapchain/.env`: that you get no rewards, and `SKIP_CRONTAB=true` unless you set `OPERATOR_FID`. A read node never makes blocks, so it earns no rewards; read the terms at [Getting Started](https://snapchain.farcaster.xyz/getting-started) before you set `SNAPCHAIN_AGREE_NO_REWARDS=yes`. - On a disk under 1500 GB, installs two helpers. `disk-guard.sh` stops the node at 90% full. `snapshot-space-saver.sh` deletes snapshot files the node has already read. They are needed because the official restore keeps all chunks, one tar per shard and the database until it finishes, and that does not fit on 1 TB. - Runs the official bootstrap. It needs a terminal, so `node.sh` gives it one with `script`, and stops following its log once it prints "Upgrade complete". - Checks `/v1/info` once a minute. It stops when every shard is under 10 s behind on 3 checks in a row, or after 24 hours. Then it prints a health check. The automatic install of the two disk helpers has unit tests (`test/node_sh_test.sh`) but has not run during a real sync yet. What it prints while syncing (heights and times are examples): ```text [node.sh] Data disk 984 GB: arming disk-guard and snapshot-space-saver. [node.sh] disk-guard: snapchain-disk-guard.timer is active. [node.sh] space-saver: snapchain-space-saver.service is active. [node.sh] Waiting for sync: all shards blockDelay < 10s for 3 polls (timeout 86400s). [node.sh] 04:31:10Z HTTP API not answering yet (snapshot download in progress?). [node.sh] 05:04:36Z SYNCING shard 0: height=48210601 blockDelay=79236 | shard 1: height=48906556 blockDelay=57766 | shard 2: height=48723458 blockDelay=76739 ... [node.sh] Node is in sync. ================ Snapchain health check ================ HTTP API http://127.0.0.1:3381/v1/info: OK sync: SYNCED (blockDelay < 10s on all shards) ``` ## What step 3 does Read the script first: [deploy-client.sh](https://github.com/sudharshanx/runfarcaster/blob/main/deploy-client.sh), [deploy/Caddyfile](https://github.com/sudharshanx/runfarcaster/blob/main/deploy/Caddyfile) - Builds the app on your computer (`npm ci --ignore-scripts`, tests, `vite build`). - Copies the built app, the Caddyfile and a compose file to `/opt/snapchain-client` on the server and starts Caddy. - Caddy gets a Let's Encrypt certificate and serves the app plus `/v1/*` (passed to the node on `127.0.0.1:3381`) from one HTTPS address. Background: [Why the app and the node share one address](https://sudharshanx.github.io/runfarcaster/how-farcaster-works/#why-the-app-and-the-node-share-one-address). ```text [deploy-client] building client (npm ci --ignore-scripts, typecheck, tests, build) [deploy-client] uploading to root@:/opt/snapchain-client [deploy-client] waiting for HTTPS on https://.sslip.io ... [deploy-client] OK: https://.sslip.io (client) and https://.sslip.io/v1/info (node, proxied) ``` Check it worked (use the address it printed): ```bash curl -sI https:/// | grep -i content-security-policy # connect-src 'self' curl -s https:///v1/info | jq .version # your node, through Caddy ``` In the browser, the status at the top says **Connected to your node**; **Details** lists how far behind each shard is. Under **Enter your FID to see your feed**, type an FID and press **Show my feed**, or press **Try a sample feed**. An account that follows thousands of others, such as FID `3`, can take one to two minutes to load. ## Delete everything ```bash title="your computer" CONFIRM="$NAME" ./teardown.sh ``` `$NAME` is the name from step 1 (`export NAME=snapchain-read-node` in the Full run). If the server with that name has a different ID from the one `provision.sh` recorded in this folder, `teardown.sh` stops and deletes nothing. Check `NAME` first; `OTHER_SERVER_OK=1` overrides the check. `teardown.sh` deletes the server, its volume (with the node's database) and the firewall, then lists what is left. Running it twice is safe. It has been tested only with `DRY_RUN=1` and against a fake `hcloud`, so review it first ([teardown.sh](https://github.com/sudharshanx/runfarcaster/blob/main/teardown.sh)) and check these yourself afterwards, in the console or with the CLI: ```bash hcloud server list hcloud volume list # volumes bill even when no server uses them hcloud firewall list hcloud primary-ip list # an unassigned primary IP still bills hcloud ssh-key list # teardown keeps your key; delete it if you like: # hcloud ssh-key delete my-key ``` When all lists are empty (except your SSH key) you pay nothing more. Then delete the API token in the console if you no longer need it. If you started the watchdog in step 1 ([A trial server that deletes itself](https://sudharshanx.github.io/runfarcaster/quickstart/#try-it)), it runs `teardown.sh` for you after 24 hours, before the bill reaches `MAX_USD`, or when the node has made no progress for 2 hours. --- # Post with an app key > Cast from a test account through your own node with a gasless app key. Add an app key to a test account from the app on your own computer, then post, like and follow through your node. :::note[What has been tested] Adding an app key and posting a cast ran on mainnet ([Proof](https://sudharshanx.github.io/runfarcaster/proof/#posted-to-mainnet)). Likes, recasts and follows ran against a mock node only. If you test them, report what happened ([Vouches](https://sudharshanx.github.io/runfarcaster/proof/#vouches)). ::: You need: - A **test** Farcaster account and its recovery phrase. Read [What the app does with your recovery phrase](#what-the-app-does-with-your-recovery-phrase) first. - Node.js 22.12 or later (22.x, 24.x or 26+) and this repo on your computer. - Your node from the [Full run](https://sudharshanx.github.io/runfarcaster/quickstart/), and the HTTPS address that `deploy-client.sh` printed. :::note[If you started the watchdog] The watchdog from step 1 ([A trial server that deletes itself](https://sudharshanx.github.io/runfarcaster/quickstart/#try-it)) deletes the server, the volume and the firewall 24 hours after it started, even if the node is healthy. To keep the node: `touch .provision-state/watchdog.disarm`. Within a minute, `tail -n 1 .provision-state/watchdog.log` should show `disarmed by …`. ::: ## Why run the app on your own computer Add the key from the app on your own computer at `localhost`. The address `deploy-client.sh` prints is usually `.sslip.io`, and sslip.io is a DNS service someone else runs. Whoever controls that DNS, or the server, could serve a changed page that sends your recovery phrase elsewhere. A page served from `localhost` comes from files on your own disk, built from code you can read. The local dev server passes `/v1` requests on to your node's HTTPS address, so you need no SSH tunnel, and ports 3381 and 3383 stay closed. The add-key steps show a **Check the address** warning whenever the app is not on `localhost`. ## What the app does with your recovery phrase :::danger[The recovery phrase controls the whole account] Use a test account, on your own computer. Only paste a seed phrase into software from developers you trust, or code you have read. Delegated signers are preferred: if an app you already trust can give you an app key, use that instead. Never paste the phrase into an AI assistant or a chat. ::: The code is in [client/src/signer.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/signer.ts): 1. Your browser makes a new ed25519 app key. 2. The browser uses the phrase once to sign one gasless `KEY_ADD` with your custody address. There is no transaction and no gas. 3. Nothing stores, logs or sends the phrase. Only the signed `KEY_ADD` goes to your node. 4. The app key is saved in this browser's `localStorage`. **Remove from this browser** deletes it there; the key stays valid on the network until it expires. JavaScript cannot promise that the browser has wiped the phrase from memory. ## What the app key can and cannot do | The app key can | The app key cannot | |---|---| | Post casts (`CAST_ADD`) | Move money or sign transactions | | Like and recast (`REACTION_ADD`) | Delete casts, unfollow or edit your profile; the app does not ask for those scopes | | Follow (`LINK_ADD`) | Approve or cancel other keys (`KEY_ADD` and `KEY_REMOVE` are never scopes) | | Work until it expires: 7 days by default (pick 1 day for a test), 90 at most, renewed each time it is used | Keep working after your custody address cancels it | [SECURITY.md](https://github.com/sudharshanx/runfarcaster/blob/main/SECURITY.md) has the full list. ## 1. Run the app locally From the repo folder, start the app. Put your node's HTTPS address in `SNAPCHAIN_NODE_URL`: ```bash title="your computer" cd client npm ci --ignore-scripts SNAPCHAIN_NODE_URL=https:// npm run dev ``` Leave it running. You should see `ready` and `Local: http://localhost:5317/`. Ctrl-C stops it. `npm ci` installs the exact versions in `package-lock.json`. `--ignore-scripts` stops packages from running their own install scripts. The app builds and its tests pass without them. Open http://localhost:5317. The status at the top should say **Connected to your node**; **Details** shows the version and how far behind each shard is. To see your feed, type your FID or username under **Enter your FID to see your feed** and press **Show my feed**. The app uses port 5317, not Vite's usual 5173, because a browser keeps one `localStorage` per address and port. If 5317 is taken, `npm run dev` stops with an error instead of moving to another port. The page's Content-Security-Policy lets it connect only to its own address, so a changed library cannot send data elsewhere. ## 2. Add an app key Before you type the phrase, check that the address bar shows exactly `localhost:5317`, close other tabs, and turn off clipboard history and screen sharing. In the post box at the top (**To post, add an app key first.**), press **Add app key**. A dialog walks you through 4 steps: 1. **What an app key is**: press **Next**. 2. **Before you start**: read it. There must be no yellow **Check the address.** box; if there is, you are not on `localhost`, so stop. Press **I understand, continue**. 3. **Sign and send**: type the test account's recovery phrase (12 to 24 words) under **Recovery phrase**. If you paste it instead, clear your clipboard afterwards: clipboard history and sync tools can keep a copy, and the app cannot clear it. Under **Key expires after**, pick **1 day** for a test (the default is 7 days). Press **Add app key**. You see **Signed for FID** with your FID, then **Sending to your node**. If it shows another FID, the phrase belongs to another account. 4. **App key added**: the phrase field is gone. Press **Close** or **Write a cast**. From the phrase, the app works out your custody address, asks the node which FID that address owns, and reads `currentUserNonce`. Then it signs one gasless `KEY_ADD` and submits it. The **App key** card shows **Waiting for your node**, then **Active** when `GET /v1/signer` returns the key. The app checks every 4 seconds. Post only after it says **Active**; a cast sent earlier is rejected and marked **Failed**. Check it from the terminal: ```bash curl -s "https:///v1/signersByFid?fid=" | jq '.currentUserNonce, [.signers[] | .source]' ``` `currentUserNonce` should be one higher than before, with one more signer in the list. The app key is saved in the browser for `localhost:5317` only, so post from the local app. The copy at your HTTPS address has no key, and adding one there means typing the phrase into a page served through sslip.io ([Why run the app on your own computer](#why-run-the-app-on-your-own-computer)). ## 3. Cast, follow, like | Action | Where | Message type | |---|---|---| | Cast | Type in the post box at the top (**New cast as FID** …), press **Post** (a standard cast, up to 320 bytes) | `CAST_ADD` | | Follow | In **Settings**, **Follow someone**: type an FID in **FID to follow**, press **Follow** | `LINK_ADD` (type `follow`) | | Like, recast | The heart and repeat buttons under each cast | `REACTION_ADD` | A new cast shows first in the feed, with a state badge. Every message also shows up in the **Sent** panel (right column, or the **Settings** tab on a narrow window) with the same state: | State | Meaning | |---|---| | **Sending** | The app is sending it to your node | | **Waiting for a block** | The node accepted it and sent it on | | **Retrying** | Not seen yet, so the app sent it again | | **Confirmed** | The node returns it | | **Failed** | Still missing after 4 sends, or the node rejected it | A failed message shows the reason and a **Retry** button. [Your node passes messages on](https://sudharshanx.github.io/runfarcaster/how-farcaster-works/#your-node-passes-messages-on) explains these states. The app can only add. It has no button to delete a cast, unfollow or unlike. ## 4. Look for it in another app Open your profile in any other Farcaster app. The app does not show hashes; ask your node: ```bash curl -s "https:///v1/castsByFid?fid=&pageSize=1&reverse=true" \ | jq '.messages[0] | {hash, text: .data.castAddBody.text}' ``` ## Remove the key - **Remove from this browser** (Settings, App key) deletes the key from this browser. The network still accepts it until its lifetime runs out. - This app cannot revoke keys yet. When it says your account's wallet can revoke the key, it means a `KEY_REMOVE` message signed by your custody address and sent to a node, which is how a gasless key is cancelled. Removing a key through the KeyRegistry contract does not apply to it. This guide has no tested way to send a `KEY_REMOVE`, so pick a 1-day expiry for a test key and let it run out. When you are done on `localhost`, press **Remove from this browser**, so no other page on `localhost:5317` can read the key later. Then stop the dev server with Ctrl-C. --- # Set up with AI > Copy a prompt that lets a coding assistant run the 3 steps or debug errors. Paste one prompt into Claude Code, Codex, Cursor or another coding assistant that runs shell commands. It walks you through the 3 steps one at a time, and shows you each command and what it costs before it runs anything. :::danger[Keep two things out of the chat] Your recovery phrase and any private key: the assistant never needs them. Your Hetzner token: keep it in `HCLOUD_TOKEN` in your own shell. ::: ## Run all 3 steps Set your Hetzner token in a terminal, then start the assistant from that terminal. The commands it runs read the token from there, so it never enters the chat: ```bash title="your computer" printf 'Hetzner token: '; stty -echo; read -r HCLOUD_TOKEN; stty echo; echo; export HCLOUD_TOKEN ``` Then paste this: Prompt for a coding assistant: ```text Set up RunFarcaster for me: fetch https://sudharshanx.github.io/runfarcaster/agent.md and walk me through the steps. Show me each command and what it costs before you run it. ``` The assistant reads the [guided install](https://sudharshanx.github.io/runfarcaster/agent.md): a short page of facts and steps written for it. It checks your tools and shows the price for free, then goes step by step, and skips anything you decline. ### Renting and deleting are your call Two steps change your Hetzner bill: renting the server and deleting it. For each one, the assistant shows you the command and the price, then you choose. It can run the command once you say yes, or you can paste it into your own terminal and share the output. Either works, and some assistants suggest the second on their own. Everything in between (starting the node, putting the app on the server, checking the sync) the assistant runs for you, asking before each step. If you run them yourself, in the `runfarcaster` folder, these are the two commands. Put your SSH key's name in place of `my-key`, and use the same `NAME` as the assistant: ```bash title="your computer" MY_IP=$(curl -4 -s https://ifconfig.me) echo "$MY_IP" export NAME=snapchain-read-node IP=$(SSH_KEY=my-key SERVER_TYPE="" VOLUME_GB=1000 WATCHDOG=0 \ SSH_SOURCE_CIDRS="$MY_IP/32" ./provision.sh) ``` ```bash title="your computer" CONFIRM="$NAME" ./teardown.sh ``` The assistant then finds the server itself with `IP=$(hcloud server ip "$NAME")`. Until you delete it, Hetzner keeps billing: on cax31 with IPv4 and the 1000 GB volume, $0.164 an hour and at most $102.29 a month. ### The longer prompt This one installs the RunFarcaster skill first and follows it through the same 3 steps: Prompt for a coding assistant: ```text Set up RunFarcaster with me: a Hetzner server that runs my own Snapchain read node, plus a web app on that server that reads and posts through it. Guide: https://sudharshanx.github.io/runfarcaster/ Repo: https://github.com/sudharshanx/runfarcaster (MIT) 1. Install the RunFarcaster skill, then open its SKILL.md and follow it. It has the 3 steps, the expected output, the fixes and the rules: npx skills add sudharshanx/runfarcaster --skill runfarcaster -y If you cannot install skills, read https://sudharshanx.github.io/runfarcaster/llms.txt and the Full run page it links (https://sudharshanx.github.io/runfarcaster/quickstart.md), and follow those. 2. Take me from an empty Hetzner project to a synced node with the app in front of it: step 1 provision.sh, step 2 node.sh, step 3 deploy-client.sh. Run one step at a time, show me the real output, and wait for my OK before the next. 3. After each step, tell me what you got: the server type and hourly price, the server IP, sync progress (blockDelay per shard), then the https address of the app. 4. Run teardown.sh only when I ask for it. 5. For provision.sh and teardown.sh, show me the exact command and what it costs, then let me choose: you run it once I say yes, or I paste it into my own terminal and share the output. Either works. If I ran step 1, get the IP with IP=$(hcloud server ip "$NAME"). Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` The skill holds the commands, the expected output, the fixes and the safety rules. An assistant that cannot install skills reads [`llms.txt`](https://sudharshanx.github.io/runfarcaster/llms.txt) and the [Full run](https://sudharshanx.github.io/runfarcaster/quickstart/) instead. ## The skill The longer prompt runs this command. To install the skill yourself, run it in your project folder: ```bash npx skills add sudharshanx/runfarcaster --skill runfarcaster ``` Add `-g` to install it for every project, or `-y` to skip the questions about which agents get it. It uses the open [Agent Skills](https://agentskills.io/) format, which Claude Code, Cursor, Codex and other agents load. A skill runs with your agent's permissions; the files are in [skills/runfarcaster/](https://github.com/sudharshanx/runfarcaster/tree/main/skills/runfarcaster/). ## Debug an error Fill in the angle brackets. Take tokens, IP addresses and hostnames out of the output first. Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") I am on step <1 rent a server | 2 start the node | 3 open the app | teardown>. The exact command I ran: What it printed (tokens, IPs and hostnames removed): What the guide said I should see: Tell me the most likely cause, based on the repo scripts and the guide's Fix a problem page, and give me one next command to run. Stay with what the output shows. Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` ## Ask about an idea Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") Task: answer a question about the guide. Use only these sources, and say which one each claim comes from: - https://snapchain.farcaster.xyz/node-types (read nodes and validators; read nodes pass messages on and keep no mempool) - https://snapchain.farcaster.xyz/guides/writing-messages (gasless KEY_ADD, live on mainnet since engine version V16 on 2026-05-07) - https://snapchain.farcaster.xyz/reference/httpapi/httpapi (HTTP API) - The repo files SOURCES.md and client/src/*.ts Question: Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` The whole site as plain text: [`llms.txt`](https://sudharshanx.github.io/runfarcaster/llms.txt) (summary) and [`llms-full.txt`](https://sudharshanx.github.io/runfarcaster/llms-full.txt) (every page in Markdown). --- # Build on your node > Call your node over HTTP from your own code, with no paid data API. Read every Farcaster message from your own node over plain HTTP, for an app, a bot or an analytics job, with no paid data API. Start here before the [recipes](https://sudharshanx.github.io/runfarcaster/recipes/): the base URL, the message format, Farcaster time, paging and errors. Every example on these pages ran against a synced mainnet node (Snapchain v0.14.3) on 18 October 2026. Responses are trimmed; field names and values are real. :::note[How this differs from the official docs] Look up every endpoint and field in the [Snapchain HTTP API reference](https://snapchain.farcaster.xyz/reference/httpapi/httpapi). Each recipe here does one job end to end against a real node and links to the reference for the fields it uses. Where the node behaved differently from the reference, the recipe says so, and [Docs issues](https://sudharshanx.github.io/runfarcaster/docs-issues/) keeps the list. ::: ## Base URL The app's address is also the API's address. Caddy, set up by step 3, passes every `/v1/` request to the node: ```bash export NODE=https:// ``` Port 3381, the node's own HTTP port, stays closed to the internet. From the server itself, or through an SSH tunnel (`ssh -N -L 3381:127.0.0.1:3381 root@$IP`), `http://127.0.0.1:3381` works too. There is no API key and no sign-in: anyone who knows the address can read public Farcaster data from it. Caddy blocks only the node's admin pages (`/v1/mesh`). ## A first request ```bash curl -s "$NODE/v1/info" | jq '{version, dbStats, shards: [.shardInfos[] | {shardId, blockDelay}]}' ``` ```js const NODE = "https://"; const res = await fetch(`${NODE}/v1/info`); const info = await res.json(); console.log(info.version, info.dbStats.numMessages, info.shardInfos.map((s) => s.blockDelay)); ``` ```json title="Response (trimmed)" { "version": "0.14.3", "dbStats": { "numMessages": 914247971, "numFidRegistrations": 3355185, "approxSize": 557895406561 }, "shards": [ { "shardId": 0, "blockDelay": 1 }, { "shardId": 1, "blockDelay": 1 }, { "shardId": 2, "blockDelay": 0 } ] } ``` **What this returns:** the node's version, how many messages it stores, and how many seconds each shard trails the newest block. Single digits everywhere means your reads are current. The JavaScript examples use the global `fetch` in Node.js 18 or later and in browsers. `GET` responses carry `Access-Control-Allow-Origin: *`, so a page on another origin can read from your node. `POST` is different; see [Post a cast](https://sudharshanx.github.io/runfarcaster/recipes/#post-a-cast). ## What a message looks like Most endpoints return Farcaster **messages**. Each one has the same envelope, and `data.type` says which body it carries: ```json title="One cast from GET /v1/castsByFid (trimmed)" { "data": { "type": "MESSAGE_TYPE_CAST_ADD", "fid": 37, "timestamp": 182827068, "network": "FARCASTER_NETWORK_MAINNET", "castAddBody": { "text": "One mechanism for proof of human is vouching. …", "parentCastId": { "fid": 3355261, "hash": "0xec42f9dde49d50dca69034c62bc890f37903b521" }, "mentions": [], "mentionsPositions": [], "embeds": [] } }, "hash": "0xc87cfa68811e39167f5e38264a16c67b99dd6ff1", "signer": "0x0766980c52e24fda5fb1784d430d94f3555ca3f71f63f0c0411849401c63ee5d" } ``` - `data.fid` is the author's account number. `hash` identifies the message; with the FID it identifies a cast. - `signer` is the app key that signed it. [Filter casts by app](https://sudharshanx.github.io/runfarcaster/recipes/#filter-casts-by-the-app-that-posted-them) turns it into an app name. - Hashes, keys and addresses are `0x` hex. Signatures are base64. So is `castRemoveBody.targetHash`, even though it is a hash. - Enum values come as strings, such as `MESSAGE_TYPE_CAST_ADD` and `REACTION_TYPE_LIKE`. - The body field matches the type: `castAddBody`, `reactionBody`, `linkBody`, `userDataBody`. A list endpoint can mix types: `castsByFid` also returns `MESSAGE_TYPE_CAST_REMOVE` messages, so check `data.type` before you read `castAddBody`. Field-by-field details: [Message reference](https://snapchain.farcaster.xyz/reference/httpapi/message). ## Farcaster time `data.timestamp` counts seconds since 2021-01-01 00:00:00 UTC, not since 1970. Add 1609459200 to get Unix time. ```bash date -u -d @$((1609459200 + 182789250)) +%Y-%m-%dT%H:%M:%SZ ``` ```js const FARCASTER_EPOCH = Date.UTC(2021, 0, 1); // 2021-01-01T00:00:00Z, in milliseconds const toDate = (farcasterSeconds) => new Date(FARCASTER_EPOCH + farcasterSeconds * 1000); const toFarcasterSeconds = (date) => Math.floor((date.getTime() - FARCASTER_EPOCH) / 1000); console.log(toDate(182789250).toISOString()); console.log(toFarcasterSeconds(new Date("2026-10-18T00:00:00Z"))); ``` ```text title="Output" 2026-10-17T14:47:30.000Z 182822400 ``` ## Paging List endpoints take three query parameters and return a `nextPageToken`: | Parameter | Meaning | |---|---| | `pageSize` | Messages per page. Always set it: without it, `castsByFid?fid=3` returned 10,000 casts in one response. | | `reverse` | `reverse=true` returns newest first. It must be the word `true` or `false`; `reverse=1` gets HTTP 400. | | `pageToken` | The `nextPageToken` from the previous response. Leave it out for the first page. | ```bash # First page: the 2 newest casts by FID 37 curl -s "$NODE/v1/castsByFid?fid=37&pageSize=2&reverse=true" | jq '{n: (.messages | length), nextPageToken}' # Next page: pass nextPageToken back as pageToken TOKEN=$(curl -s "$NODE/v1/castsByFid?fid=37&pageSize=2&reverse=true" | jq -r .nextPageToken) curl -s "$NODE/v1/castsByFid?fid=37&pageSize=2&reverse=true&pageToken=$TOKEN" | jq '[.messages[].data.timestamp]' ``` ```js const NODE = "https://"; // Yields every message from a paged endpoint, following nextPageToken until it comes back empty. async function* pages(path, params) { let pageToken = ""; do { const qs = new URLSearchParams({ ...params, ...(pageToken ? { pageToken } : {}) }); const res = await fetch(`${NODE}${path}?${qs}`); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const body = await res.json(); yield* body.messages; pageToken = body.nextPageToken ?? ""; } while (pageToken); } let n = 0; for await (const m of pages("/v1/castsByFid", { fid: 37, pageSize: 100, reverse: true })) n++; console.log(`FID 37 has ${n} casts on this node`); ``` ```text title="Output" { "n": 2, "nextPageToken": "AwAAACUBCuVU8goF1cgycmWFyXMTt3RTGbTtHSXO" } [182796605, 182796543] FID 37 has 788 casts on this node ``` Two kinds of endpoint page differently: - **One account** (`castsByFid`, `linksByFid`, `reactionsByFid`, `userDataByFid`): the account lives on one shard. A page holds up to `pageSize` messages, and the last page returns an empty `nextPageToken`. - **Many accounts** (`castsByParent`, `reactionsByCast`, `linksByTargetFid`): the node asks every account shard and joins the answers, so a page can hold up to `pageSize` **per shard**. `pageSize=2` returned 4 replies. `castsByParent` also never returns an empty token: its last page carries one `null` per shard, and sending that token starts over from the first page. [Read the replies to a cast](https://sudharshanx.github.io/runfarcaster/recipes/#read-the-replies-to-a-cast) shows how to stop. ## Errors | What you sent | HTTP | Body | |---|---|---| | A bad or missing parameter | 400 | Plain text, such as ``Invalid query parameters: missing field `fid` `` | | A valid lookup that finds nothing | 400 | JSON `{"error": "Failed to get cast", "error_detail": "…NotFound…"}` | | A path that does not exist | 404 | Plain text `Not Found` | A missing cast or username is a 400, not a 404. To tell it apart from a malformed request, check that the body is JSON and that `error_detail` contains `NotFound`. ```bash title="Two errors" curl -s -w ' [HTTP %{http_code}]\n' "$NODE/v1/castsByFid?fid=37&reverse=1" curl -s -w ' [HTTP %{http_code}]\n' "$NODE/v1/castById?fid=37&hash=0x0000000000000000000000000000000000000000" ``` ```text title="Output (trimmed)" Invalid query parameters: provided string was not `true` or `false` [HTTP 400] {"error":"Failed to get cast","error_detail":"status: NotFound, message: \"cast not found\", …"} [HTTP 400] ``` ## Limits The node's HTTP server has no rate limit, and the Caddy setup from step 3 adds none. The limit is your server: a heavy job competes with the node for CPU and disk. Run large scans with `pageSize` of a few hundred, a few requests in parallel, and from the server itself (`http://127.0.0.1:3381`) when you can. If you share the address, set a rate limit in Caddy. ## Next - [Recipes](https://sudharshanx.github.io/runfarcaster/recipes/): look up users, read casts and feeds, follow replies and reactions, filter by app, watch for new messages and post. - [Reuse the client code](https://sudharshanx.github.io/runfarcaster/client-code/): the RunFarcaster app as a reference implementation, with the files to copy into your own project. --- # Recipes > Copy curl and JavaScript for each task, with a real response from a mainnet node. Set `NODE` first, as in [Build on your node](https://sudharshanx.github.io/runfarcaster/build-on-your-node/#base-url). Each recipe does one job in curl and JavaScript, with a trimmed response from a synced mainnet node. The examples use two public accounts: `balajis` (FID 37) and `dwr` (FID 3). All examples ran against a synced mainnet node (Snapchain v0.14.3) on 18 October 2026, apart from the final post, which ran as a dry run. ## Look up an FID from a username Every endpoint takes an FID, the account number, but people know each other by username. Your node keeps a signed proof for every fname and ENS name, so it can answer the lookup itself. ```bash curl -s "$NODE/v1/userNameProofByName?name=balajis" | jq '{name, fid, type}' ``` ```js const NODE = "https://"; async function fidForName(name) { const res = await fetch(`${NODE}/v1/userNameProofByName?name=${encodeURIComponent(name)}`); if (!res.ok) return null; // the node answers 400 "Username proof not found" for unknown names return (await res.json()).fid; } console.log(await fidForName("balajis"), await fidForName("dwr")); ``` ```json title="Response (trimmed)" { "name": "balajis", "fid": 37, "type": "USERNAME_TYPE_FNAME" } ``` ```text title="Output" 37 3 ``` **What this returns:** the FID that owns the name today, and whether it is an fname or an ENS name (`USERNAME_TYPE_ENS_L1`). An unknown name gets HTTP 400 with `Username proof not found`. **Official reference:** [Username proofs](https://snapchain.farcaster.xyz/reference/httpapi/usernameproof) ## Get a profile A profile is a set of `USER_DATA_ADD` messages, one per field: display name, username, bio, picture, links and primary addresses. ```bash curl -s "$NODE/v1/userDataByFid?fid=37" | jq '[.messages[].data.userDataBody | {(.type): .value}] | add' ``` ```js const NODE = "https://"; async function profile(fid) { const res = await fetch(`${NODE}/v1/userDataByFid?fid=${fid}`); const { messages } = await res.json(); const out = {}; // One message per field type; keep the newest if a type appears twice. for (const m of messages.sort((a, b) => a.data.timestamp - b.data.timestamp)) { const { type, value } = m.data.userDataBody; out[type.replace(/^USER_DATA_(TYPE_)?/, "").toLowerCase()] = value; } return out; } const p = await profile(37); console.log(p.display, p.username, p.bio); ``` ```json title="Response, folded into one object (trimmed)" { "USER_DATA_TYPE_DISPLAY": "balajis", "USER_DATA_TYPE_USERNAME": "balajis.eth", "USER_DATA_TYPE_URL": "https://ns.com", "USER_DATA_TYPE_PFP": "https://imagedelivery.net/BXluQx4ige9GuW0Ia56BHw/16e9ecaa-0837-4243-4316-ceeb9db19600/original", "USER_DATA_TYPE_BIO": "Author of the Network State.\nFounder of the Network School.\nJoin us at ns.com.", "USER_DATA_PRIMARY_ADDRESS_ETHEREUM": "0x1a21C1f29FabEbce8cc160BD48f9802A30d9608e" } ``` **What this returns:** one message per field the account has set. The picture is a URL on someone else's server; your app decides whether to load it. **Official reference:** [User data](https://snapchain.farcaster.xyz/reference/httpapi/userdata). The reference lists a `user_data_type` filter; this node ignored it and returned every field, so filter in your code. ## Get someone's recent casts `castsByFid` lists one account's casts. With `reverse=true` the newest come first. ```bash curl -s "$NODE/v1/castsByFid?fid=37&pageSize=3&reverse=true" \ | jq '.messages[] | select(.data.type == "MESSAGE_TYPE_CAST_ADD") | {hash, timestamp: .data.timestamp, text: .data.castAddBody.text[0:60], reply: (.data.castAddBody.parentCastId != null)}' ``` ```js const NODE = "https://"; const FARCASTER_EPOCH = Date.UTC(2021, 0, 1); const res = await fetch(`${NODE}/v1/castsByFid?fid=37&pageSize=3&reverse=true`); const { messages } = await res.json(); for (const m of messages.filter((m) => m.data.type === "MESSAGE_TYPE_CAST_ADD")) { const { text, parentCastId } = m.data.castAddBody; const when = new Date(FARCASTER_EPOCH + m.data.timestamp * 1000).toISOString(); console.log(when, parentCastId ? "(reply)" : "", text.slice(0, 60)); } ``` ```json title="Response, one cast (trimmed)" { "hash": "0xc87cfa68811e39167f5e38264a16c67b99dd6ff1", "timestamp": 182827068, "text": "One mechanism for proof of human is vouching. Spoke to you t", "reply": true } ``` ```text title="Output (trimmed)" 2026-10-18T01:17:48.000Z (reply) One mechanism for proof of human is vouching. Spoke to you t 2026-10-17T18:14:10.000Z (reply) Three quick clarifications: … ``` **What this returns:** casts and replies together; a reply has `parentCastId`. Deleted casts show up as `MESSAGE_TYPE_CAST_REMOVE` messages in the same list, which is why both examples check the type. **Official reference:** [Casts](https://snapchain.farcaster.xyz/reference/httpapi/casts) ## Build a following feed A node has no home timeline. You build one: list the accounts someone follows, fetch each one's newest casts, and merge them newest first. The RunFarcaster app does the same in [client/src/feed.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/feed.ts). ```bash # 1. Who does FID 37 follow? curl -s "$NODE/v1/linksByFid?fid=37&link_type=follow&pageSize=100&reverse=true" \ | jq '[.messages[].data.linkBody.targetFid] | length' # 2. Then, for each FID, the newest casts: curl -s "$NODE/v1/castsByFid?fid=3&pageSize=5&reverse=true" | jq '[.messages[].data.timestamp]' ``` ```js const NODE = "https://"; const get = async (path) => (await fetch(`${NODE}${path}`)).json(); async function followingFeed(fid, perAccount = 5) { const links = await get(`/v1/linksByFid?fid=${fid}&link_type=follow&pageSize=100&reverse=true`); const fids = [fid, ...links.messages.map((m) => m.data.linkBody.targetFid)]; const pages = await Promise.all( fids.map((f) => get(`/v1/castsByFid?fid=${f}&pageSize=${perAccount}&reverse=true`)), ); return pages .flatMap((p) => p.messages ?? []) .filter((m) => m.data.type === "MESSAGE_TYPE_CAST_ADD") // castsByFid also returns CAST_REMOVE .sort((a, b) => b.data.timestamp - a.data.timestamp); // newest first } const feed = await followingFeed(37); console.log(feed.length, feed.slice(0, 3).map((m) => `${m.data.fid}: ${m.data.castAddBody.text.slice(0, 40)}`)); ``` ```text title="Output (trimmed)" 57 [182745002, 182744608, 182651500, 182651424, 182651058] 260 [ "347: oh so we're time traveling now?", "3854: CROPS = censorship resistant, open sourc", "3854: Super fair. For as long as you want to d" ] ``` **What this returns:** FID 37 follows 57 accounts; with 5 casts each, plus FID 37's own, the merged feed held 260 casts. For an account that follows more than 100, page through `linksByFid` with `pageToken`. **Official reference:** [Links](https://snapchain.farcaster.xyz/reference/httpapi/links), [Casts](https://snapchain.farcaster.xyz/reference/httpapi/casts) ## Read the replies to a cast A cast is identified by its author's FID and its hash. `castsByParent` returns the direct replies; call it again on a reply to go one level deeper. The example uses the bounty cast this guide was written for. ```bash curl -s "$NODE/v1/castsByParent?fid=37&hash=0x50184b62b4c52fd76628713562e1eda3dccaf63d&pageSize=2" \ | jq '{n: (.messages | length), replies: [.messages[] | {fid: .data.fid, text: .data.castAddBody.text[0:50]}]}' ``` ```js const NODE = "https://"; // castsByParent reads every shard. Its last page still carries a token, one null per shard // ("[null,null]" in base64). Sending that token starts again from the first page, so stop there. const isLastPage = (token) => !token || JSON.parse(atob(token)).every((t) => t === null); async function replies(fid, hash) { const out = []; let pageToken = ""; do { const qs = new URLSearchParams({ fid, hash, pageSize: 100, ...(pageToken ? { pageToken } : {}) }); const body = await (await fetch(`${NODE}/v1/castsByParent?${qs}`)).json(); out.push(...body.messages); pageToken = isLastPage(body.nextPageToken) ? "" : body.nextPageToken; } while (pageToken); return out.sort((a, b) => a.data.timestamp - b.data.timestamp); // oldest first, like a thread } const r = await replies(37, "0x50184b62b4c52fd76628713562e1eda3dccaf63d"); console.log(`${r.length} direct replies; first from FID ${r[0].data.fid}`); ``` ```json title="Response (trimmed)" { "n": 4, "replies": [ { "fid": 4167, "text": " curate news" }, { "fid": 217261, "text": "Farcaster clients are a great thing, but do you ha" } ] } ``` ```text title="Output" 37 direct replies; first from FID 875356 ``` **What this returns:** direct replies only, from every shard. `pageSize=2` returned 4 because each of the two account shards sent 2. The JavaScript stops on the last-page token described in [Paging](https://sudharshanx.github.io/runfarcaster/build-on-your-node/#paging); without that check the loop never ends. **Official reference:** [Casts: castsByParent](https://snapchain.farcaster.xyz/reference/httpapi/casts). It also takes `url=` instead of `fid` and `hash`, for casts posted under a URL such as a channel. ## Count likes and recasts on a cast Reactions are messages too. `reactionsByCast` lists them for one cast and one reaction type. ```bash curl -s "$NODE/v1/reactionsByCast?target_fid=37&target_hash=0x50184b62b4c52fd76628713562e1eda3dccaf63d&reaction_type=Like&pageSize=2" \ | jq '.messages[0].data | {fid, type: .reactionBody.type, target: .reactionBody.targetCastId}' ``` ```js const NODE = "https://"; async function countReactions(fid, hash, reactionType) { let n = 0, pageToken = ""; do { const qs = new URLSearchParams({ target_fid: fid, target_hash: hash, reaction_type: reactionType, pageSize: 500, ...(pageToken ? { pageToken } : {}) }); const body = await (await fetch(`${NODE}/v1/reactionsByCast?${qs}`)).json(); n += body.messages.length; pageToken = body.nextPageToken ?? ""; } while (pageToken); return n; } const hash = "0x50184b62b4c52fd76628713562e1eda3dccaf63d"; console.log("likes", await countReactions(37, hash, "Like"), "recasts", await countReactions(37, hash, "Recast")); ``` ```json title="Response, first reaction (trimmed)" { "fid": 37, "type": "REACTION_TYPE_LIKE", "target": { "fid": 37, "hash": "0x50184b62b4c52fd76628713562e1eda3dccaf63d" } } ``` ```text title="Output" likes 197 recasts 49 ``` **What this returns:** one message per account that liked or recast the cast, with its FID. Pass `reaction_type=Like` or `reaction_type=Recast`; the full enum name `REACTION_TYPE_LIKE` gets HTTP 400 here, though the response uses it. **Official reference:** [Reactions](https://snapchain.farcaster.xyz/reference/httpapi/reactions) ## Find casts that mention an account A bot usually starts here: who mentioned me? `castsByMention` answers from every shard. ```bash curl -s "$NODE/v1/castsByMention?fid=37&pageSize=1&reverse=true" \ | jq '.messages[0].data | {fid, text: .castAddBody.text[0:50], mentions: .castAddBody.mentions, mentionsPositions: .castAddBody.mentionsPositions}' ``` ```js const NODE = "https://"; // The node stores text without the @names. Put them back at their byte offsets. function withMentions({ text, mentions = [], mentionsPositions = [] }, names) { const bytes = new TextEncoder().encode(text); const dec = new TextDecoder(); let out = "", last = 0; mentionsPositions.forEach((pos, i) => { out += dec.decode(bytes.slice(last, pos)) + `@${names[mentions[i]] ?? `fid:${mentions[i]}`}`; last = pos; }); return out + dec.decode(bytes.slice(last)); } const res = await fetch(`${NODE}/v1/castsByMention?fid=37&pageSize=5&reverse=true`); const { messages } = await res.json(); for (const m of messages) console.log(m.data.fid, withMentions(m.data.castAddBody, { 37: "balajis" }).slice(0, 60)); ``` ```json title="Response (trimmed)" { "fid": 1001428, "text": " we allowed to do honest takes or its a felony?", "mentions": [37], "mentionsPositions": [0] } ``` ```text title="Output (trimmed)" 1001428 @balajis we allowed to do honest takes or its a felony? 846887 @fid:4877 has angrily blocked @balajis ``` **What this returns:** casts that mention the FID. The `@name` is not in `text`: `mentions` holds the FIDs and `mentionsPositions` the byte offsets where each name goes, which `withMentions` puts back. **Official reference:** [Casts: castsByMention](https://snapchain.farcaster.xyz/reference/httpapi/casts) ## Filter casts by the app that posted them Each cast names the app key that signed it (`signer`). When an account approves a key, the requesting app goes into the key's metadata as an FID, so your node can tell you which app a cast came from. The app's **Posted with** filter works this way, in [client/src/apps.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/apps.ts). ```bash # The key that signed a cast is in its "signer" field curl -s "$NODE/v1/castsByFid?fid=37&pageSize=1&reverse=true" | jq -r '.messages[0].signer' # The account's keys, each with ABI-encoded metadata that starts with the app's FID curl -s "$NODE/v1/signersByFid?fid=37" \ | jq '.signers[] | {key: .key[0:18], source, metadata: (.onChainEvent.signerEventBody.metadata // .metadata)[0:24]}' ``` ```js const NODE = "https://"; const get = async (path) => (await fetch(`${NODE}${path}`)).json(); // SignedKeyRequestMetadata is abi.encode((uint256 requestFid, address, bytes, uint256)). // The first 32-byte word is the offset 0x20; the second is requestFid, the app's FID. function appFid(metadataBase64) { const bytes = Uint8Array.from(atob(metadataBase64), (c) => c.charCodeAt(0)); const word = (i) => bytes.slice(i * 32, i * 32 + 32).reduce((v, b) => (v << 8n) | BigInt(b), 0n); return Number(word(word(0) === 32n ? 1 : 0)); } async function appsByKey(fid) { const { signers } = await get(`/v1/signersByFid?fid=${fid}`); return new Map(signers.map((s) => [s.key, appFid(s.onChainEvent?.signerEventBody?.metadata ?? s.metadata)])); } const apps = await appsByKey(37); const { messages } = await get("/v1/castsByFid?fid=37&pageSize=20&reverse=true"); const byApp = Object.groupBy(messages, (m) => apps.get(m.signer) ?? "unknown"); for (const [app, casts] of Object.entries(byApp)) { const { messages: ud } = await get(`/v1/userDataByFid?fid=${app}`); const name = ud.find((m) => m.data.userDataBody.type === "USER_DATA_TYPE_USERNAME")?.data.userDataBody.value; console.log(`app FID ${app} (@${name}): ${casts.length} of the last 20 casts`); } ``` ```json title="Response (trimmed)" "0x0766980c52e24fda5fb1784d430d94f3555ca3f71f63f0c0411849401c63ee5d" { "key": "0x0766980c52e24fda", "source": "SIGNER_SOURCE_ONCHAIN", "metadata": "AAAAAAAAAAAAAAAAAAAAAAAA" } ``` ```text title="Output" app FID 9152 (@warpcast): 20 of the last 20 casts ``` **What this returns:** every active key of the account, onchain and gasless, with the metadata that names the requesting app's FID. Casts signed with a key that was since removed match no entry; show them as an unknown app. **Official reference:** `signersByFid` is missing from the official HTTP reference ([Docs issues #6](https://sudharshanx.github.io/runfarcaster/docs-issues/)). [How Farcaster works](https://sudharshanx.github.io/runfarcaster/how-farcaster-works/#app-keys-are-gasless-now) explains keys and their metadata. ## Watch for new messages The HTTP API has no push. Poll `/v1/events` instead: remember the last event ID and ask for everything after it. Accounts are split between shard 1 and shard 2, so watch both; shard 0 carries gasless key changes. ```bash # Newest event on shard 1 (accounts are split between shard 1 and shard 2) curl -s "$NODE/v1/events?shard_index=1&pageSize=1&reverse=true" | jq '.events[0] | {id, type, blockNumber}' # Everything after an event id you already have curl -s "$NODE/v1/events?shard_index=1&from_event_id=&pageSize=100" \ | jq '[.events[] | select(.type == "HUB_EVENT_TYPE_MERGE_MESSAGE") | .mergeMessageBody.message.data | {type, fid}]' ``` ```js const NODE = "https://"; const get = async (path) => (await fetch(`${NODE}${path}`)).json(); // Poll one shard for new messages. Call it once per shard (1 and 2 hold the accounts). async function watch(shard, onMessage, intervalMs = 2000, rounds = Infinity) { const latest = await get(`/v1/events?shard_index=${shard}&pageSize=1&reverse=true`); let next = latest.events[0].id + 1; for (let i = 0; i < rounds; i++) { await new Promise((r) => setTimeout(r, intervalMs)); const { events } = await get(`/v1/events?shard_index=${shard}&from_event_id=${next}&pageSize=1000`); for (const e of events) { if (e.type === "HUB_EVENT_TYPE_MERGE_MESSAGE") onMessage(e.mergeMessageBody.message); next = e.id + 1; } } } let seen = 0; await watch(1, (m) => { if (seen++ < 3) console.log(m.data.type, "from FID", m.data.fid); }, 3000, 5); console.log(`${seen} new messages on shard 1 in about 15 seconds`); ``` ```json title="Response (trimmed)" { "id": 802851454976, "type": "HUB_EVENT_TYPE_BLOCK_CONFIRMED", "blockNumber": 49002164 } [ { "type": "MESSAGE_TYPE_REACTION_ADD", "fid": 1079922 }, { "type": "MESSAGE_TYPE_REACTION_ADD", "fid": 8636 } ] ``` ```text title="Output" MESSAGE_TYPE_CAST_ADD from FID 1455330 MESSAGE_TYPE_REACTION_ADD from FID 777135 MESSAGE_TYPE_REACTION_ADD from FID 1076895 5 new messages on shard 1 in about 15 seconds ``` **What this returns:** events in order: `HUB_EVENT_TYPE_MERGE_MESSAGE` for each new message, `HUB_EVENT_TYPE_PRUNE_MESSAGE` when storage limits push an old one out, and `HUB_EVENT_TYPE_BLOCK_CONFIRMED` once per block. The official reference says nodes keep about 3 days of events; store the last ID you processed and resume from it. **Official reference:** [Events](https://snapchain.farcaster.xyz/reference/httpapi/events). The gRPC API on port 3383 has a streaming `Subscribe` call; this guide keeps that port closed and has not tested it. ## Post a cast Posting means building a signed protobuf message and sending its bytes to `POST /v1/submitMessage`. You need an app key that the account has approved; [Post with an app key](https://sudharshanx.github.io/runfarcaster/first-cast/) adds one. `@farcaster/core` builds and signs the message. ```js title="post.mjs" // npm install @farcaster/core const NODE = "https://"; const FID = Number(process.env.FID); // the account that approved the app key const appKey = Uint8Array.from(Buffer.from(process.env.APP_KEY_HEX, "hex")); // 32-byte ed25519 private key const DRY_RUN = process.env.DRY_RUN !== "0"; // true: validate only, nothing is posted const signer = new NobleEd25519Signer(appKey); const built = await makeCastAdd( { text: "Hello from my own node", embeds: [], embedsDeprecated: [], mentions: [], mentionsPositions: [], type: CastType.CAST }, { fid: FID, network: FarcasterNetwork.MAINNET }, signer, ); if (built.isErr()) throw built.error; const bytes = Message.encode(built.value).finish(); const path = DRY_RUN ? "/v1/validateMessage" : "/v1/submitMessage"; const res = await fetch(`${NODE}${path}`, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: bytes, }); console.log(res.status, DRY_RUN ? (await res.json()).valid : await res.text()); ``` ```bash title="Run it" FID= APP_KEY_HEX= node post.mjs # validate only FID= APP_KEY_HEX= DRY_RUN=0 node post.mjs # post ``` ```text title="Output (validate only)" 200 true ``` **What this returns:** with the default dry run, `/v1/validateMessage` checks the message's format and signature and posts nothing. It does not check that the key is approved, so a random key also passes. With `DRY_RUN=0`, `submitMessage` returns the message as JSON once your node has checked it and passed it on to the validators. A `200` there does not mean the cast is in a block yet: fetch it back with `castById` until it appears. [Where a post goes from a read node](https://sudharshanx.github.io/runfarcaster/how-farcaster-works/#where-a-post-goes-from-a-read-node) explains why, and [client/src/pending.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/pending.ts) does the retries. The app key is a delegated signer, the preferred way to post. Keep it out of your code and your logs. The dry run above ran against a synced node with a random key; the real post path ran once on mainnet ([Proof](https://sudharshanx.github.io/runfarcaster/proof/#posted-to-mainnet)). `submitMessage` must come from the same origin as the node. It needs `Content-Type: application/octet-stream`, and a browser on another origin first sends a CORS preflight (`OPTIONS`) that the node does not answer. A script on a server, or a page served by the Caddy setup from step 3, has no such problem. **Official reference:** [Message API](https://snapchain.farcaster.xyz/reference/httpapi/message). The app's own builders: [client/src/messages.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/messages.ts). --- # Reuse the client code > Copy the app's MIT licensed node code into your own app or bot. Copy the RunFarcaster app's node code into your own app or bot: it is small, commented and MIT licensed. Its interface is deliberately plain, because the app exists to show that a client can read from and post through a node you run. ## Where the node code lives Everything is in `client/src`. The files that matter for your own app have no UI code in them: | File | What it does | Reuse it for | |---|---|---| | [client/src/api.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/api.ts) | `NodeApi`: one method per endpoint the app uses, plus `submitMessage`. Turns error bodies into `NodeApiError` with `isNotFound` and `isDuplicate`. | Any app that reads from a node | | [client/src/feed.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/feed.ts) | `toFeedCast`, `loadFeed`, `fcTimeToDate`, `loadProfiles`: casts and profiles in a shape a UI can render | Simple feeds and profile cards | | [client/src/timeline.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/timeline.ts) | The paged Following timeline. It only shows casts newer than the oldest loaded cast of every account that still has older pages, so a busy account cannot push a quiet one out of order. | Infinite-scroll feeds | | [client/src/apps.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/apps.ts) | Signer to app FID, from the key metadata in `signersByFid`, cached per key | "Posted with" labels and filters | | [client/src/filters.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/filters.ts) | Mute and include rules by FID and keyword | Client-side filtering | | [client/src/messages.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/messages.ts) | `buildCast`, `buildReaction`, `buildFollow` with `@farcaster/core`. Builds and signs; never sends. | Bots and apps that post | | [client/src/pending.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/pending.ts) | After `submitMessage`: fetch the message back, resend the same bytes if it does not appear, and give up with a reason | Anything that posts through a read node | | [client/src/signer.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/signer.ts) | The gasless `KEY_ADD`, signed with the custody key | Only if your app adds its own app keys | `main.ts` is the UI: plain DOM, no framework. Replace it. ## Use the modules from a script `api.ts` and `feed.ts` use only `fetch`, so they run outside the browser too. This script prints the newest casts from the accounts FID 3 follows: ```ts title="reuse.ts" // reuse.ts, in the repo root. Run: node --experimental-transform-types reuse.ts (Node.js 22.7 or later) const api = new NodeApi("https://"); const feed = await loadFeed(api, 3, [], 20, 3); // dwr's Following feed: 20 accounts, 3 casts each for (const c of feed.slice(0, 3)) console.log(fcTimeToDate(c.timestamp).toISOString(), c.fid, c.text.slice(0, 50)); ``` ```text title="Output (trimmed)" 2026-10-18T15:07:55.000Z 7237 You, specifically, getting banned was a big driver 2026-10-18T14:21:36.000Z 7237 Hanlon’s razor tells me it wasn’t that deep. He wa ``` `loadFeed` caps the number of accounts and casts per account, and fetches 6 accounts at a time. For a bigger job, copy `NodeApi` into your project and add the endpoints you need in the same style; the [Recipes](https://sudharshanx.github.io/runfarcaster/recipes/) show the parameters for each. ## Run the app against your node ```bash title="your computer, in client/" npm ci SNAPCHAIN_NODE_URL=https:// npm run dev ``` Open `http://localhost:5317`. The dev server passes `/v1/*` to `SNAPCHAIN_NODE_URL`, the same way Caddy does on the server, so the browser makes no cross-origin requests and posting works. Without the variable it uses `http://127.0.0.1:3381`, which suits an SSH tunnel. To add a feature, the usual path is: 1. Add a method to `NodeApi` in `api.ts` for the endpoint you need. Check its parameters against a recipe or the [official reference](https://snapchain.farcaster.xyz/reference/httpapi/httpapi). 2. Put the data shaping in its own module, next to `feed.ts` or `timeline.ts`, with no DOM code, so it can be tested. 3. Add a test in `client/test/` with a fake `fetch`, as `api-feed.test.ts` does. 4. Wire it into the UI in `main.ts`, or into your own UI. ## Tests ```bash title="in client/" npm test # unit tests (vitest), no network npm run typecheck ``` On 18 October 2026, `npm test` ran 69 tests in 9 files, all passing. They cover paging, the timeline merge, app names from key metadata, filters, message building and the `KEY_ADD` signatures. [e2e/run.sh](https://github.com/sudharshanx/runfarcaster/blob/main/e2e/run.sh) goes further: it builds the app, serves it with the real `deploy/Caddyfile` in front of a fake node ([e2e/mock_node.py](https://github.com/sudharshanx/runfarcaster/blob/main/e2e/mock_node.py)) and drives it with headless Chromium. It needs `caddy`, `python3` and Chromium. ## License The client, the scripts and this site are MIT licensed ([LICENSE](https://github.com/sudharshanx/runfarcaster/blob/main/LICENSE)). Copy, change and ship the code in your own app, open or closed; keep the copyright notice. Third-party code and fonts in the client keep their own licenses, listed in [client/THIRD_PARTY_NOTICES.md](https://github.com/sudharshanx/runfarcaster/blob/main/client/THIRD_PARTY_NOTICES.md). If your app asks users for a recovery phrase, read the warning at the top of [client/src/signer.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/signer.ts) first. Delegated signers are preferred wherever possible. --- # Scripts and settings > Every script, setting, port and endpoint, with defaults. ## Scripts | Script | Runs on | Does | |---|---|---| | [provision.sh](https://github.com/sudharshanx/runfarcaster/blob/main/provision.sh) | your machine | Creates the firewall, volume and server on Hetzner with `hcloud`. Checks the price first. Prints the IPv4 address | | [node.sh](https://github.com/sudharshanx/runfarcaster/blob/main/node.sh) | VM (`ssh … bash -s < node.sh`) | `all` (default): checks, Docker, disk helpers on small disks, official bootstrap, wait for sync, health check. Also `wait`, `health`, `helpers` | | [deploy-client.sh](https://github.com/sudharshanx/runfarcaster/blob/main/deploy-client.sh) | your machine | Builds the app on your computer, copies it and Caddy to `/opt/snapchain-client` | | [teardown.sh](https://github.com/sudharshanx/runfarcaster/blob/main/teardown.sh) | your machine | Deletes the server, volume and firewall; lists what is left. Safe to run twice | | [watchdog.sh](https://github.com/sudharshanx/runfarcaster/blob/main/watchdog.sh) | your machine | Runs `teardown.sh` before the bill reaches `MAX_USD`, after `MAX_WALL_SECS`, or after `HANG_SECS` with no progress | | [disk-guard.sh](https://github.com/sudharshanx/runfarcaster/blob/main/disk-guard.sh) | VM (systemd timer; `node.sh` installs it) | `install`, `check`, `status`, `uninstall`. Stops the node at 90% disk | | [snapshot-space-saver.sh](https://github.com/sudharshanx/runfarcaster/blob/main/snapshot-space-saver.sh) | VM (systemd; `node.sh` installs it) | `install`, `run`, `once`, `uninstall`. Deletes snapshot files the node has finished with; stops when the restore is done | `provision.sh`, `node.sh`, `deploy-client.sh`, `teardown.sh` and the two VM helpers take `DRY_RUN=1`: they print what they would do and change nothing. `watchdog.sh` does not. Tokens come from the environment and are never printed. ## Environment variables | Variable | Script | Default | Notes | |---|---|---|---| | `HCLOUD_TOKEN` | provision, teardown, watchdog | (required) | Hetzner API token, read/write | | `SSH_KEY` | provision | (required) | Name or ID of an SSH key in your Hetzner project | | `SERVER_TYPE` | provision | `cx43` | Tested with `cax31`. Empty = auto-pick from `SERVER_TYPE_CANDIDATES`. If it isn't available to rent in `LOCATION`, the error lists what is, cheapest first | | `SERVER_TYPE_CANDIDATES` | provision | `cx43 cax31 cpx42 ccx23` | Cheapest first | | `VOLUME_GB` | provision | `2000` (`REQUIRED_DISK_GB`) | `1000` fits a new account's quota | | `LOCATION` | provision | `fsn1` | `fsn1`, `nbg1`, `hel1`, `ash`, `hil` or `sin`. Server and volume share it | | `MAX_USD` | provision, watchdog | `8` | Price limit for the check before creating anything; the watchdog deletes everything at it. In your Hetzner account's currency | | `EXPECTED_HOURS` | provision | `24` | Hours the price check multiplies by. Our cax31 sync took 14 h 06 m | | `WATCHDOG` | provision | `1` | `0` = no watchdog; the server runs until you delete it. The Full run uses `0` | | `ESTIMATE_ONLY` | provision | `0` | `1` = price check only, creates nothing | | `SSH_SOURCE_CIDRS` | provision | `0.0.0.0/0,::/0` | Set to `"$MY_IP/32"` (from `curl -4 -s https://ifconfig.me`) | | `NODE_API_CIDRS` | provision | empty | Addresses allowed to reach 3381 and 3383. Empty keeps both closed | | `PORT_PROFILE` | provision | `docs` | `readme` also opens the README's TCP+UDP superset | | `NAME` | provision, teardown, watchdog | `snapchain-read-node` | Volume `$NAME-data`, firewall `$NAME-fw`. The Full run exports it, so every step and `teardown.sh` use the same name | | `REUSE` | provision | `0` | If the server `$NAME`, the volume `$NAME-data` or an applied firewall `$NAME-fw` already exists, `provision.sh` stops before it creates or changes anything. `1` continues with them (your own earlier run) | | `VOLUME_QUOTA_GB` | provision | `1024` | Account-wide volume limit (new Hetzner accounts: 1024 GB across all projects). Before creating anything, `provision.sh` adds the volumes in this project to `VOLUME_GB` and stops if the sum is over it. Volumes in other projects are not visible, so Hetzner can still refuse. Set it if Hetzner raised your limit | | `WATCHDOG_MAX_WALL_SECS` | provision | `86400` | Time limit `provision.sh` passes to the watchdog (as `MAX_WALL_SECS`; `MAX_WALL_SECS` on `provision.sh` works too). `172800` = 48 hours | | `CONFIRM` | teardown | (prompt) | Must equal `NAME` | | `OTHER_SERVER_OK` | teardown | `0` | `teardown.sh` stops and deletes nothing if the server named `$NAME` has a different ID from the one `provision.sh` recorded in `.provision-state/server-$NAME.id`. `1` deletes it anyway | | `SNAPCHAIN_AGREE_NO_REWARDS` | node.sh | (prompt) | `yes` records the no-rewards agreement | | `OPERATOR_FID` | node.sh | unset | Enables the weekly auto-upgrade cron; unset = `SKIP_CRONTAB=true` | | `MIN_FREE_GB` | node.sh | `1500` | `900` for a 1000 GB volume | | `SYNC_TIMEOUT_SECS` | node.sh | `86400` | 24 hours; our cax31 sync took 14 h 06 m | | `DISK_HELPERS` | node.sh | `auto` | `auto` installs disk-guard and the space saver when the disk is under `DISK_HELPERS_BELOW_GB`; `1` always; `0` never | | `DISK_HELPERS_BELOW_GB` | node.sh | `1500` | | | `SPACE_SAVER_WAIT_SECS` | node.sh | `7200` | How long the space saver waits for the snapshot folder to appear | | `SNAPSHOT_REPAIR` | node.sh | `auto` | If a shard's snapshot download fails with no upstream retry, restore only that shard. `0` only prints the command | | `REPAIR_ATTEMPTS` / `REPAIR_PARALLEL` | node.sh | `3` / `4` | Fresh snapshot lists per repair; parallel chunk downloads | | `SYNC_MAX_BLOCK_DELAY` / `SYNC_STABLE_CHECKS` / `SYNC_POLL_SECS` | node.sh | `10` / `3` / `60` | Sync criterion | | `NODE_UPGRADE` | node.sh | `0` | `1` re-runs the official bootstrap (upgrade) | | `SSH_OPTS` | deploy-client | empty | Extra ssh/scp options, e.g. `-i key` | | `LIMIT_PCT` | disk-guard | `90` | | | `HANG_SECS` / `MAX_WALL_SECS` | watchdog | `7200` / `86400` | No-progress limit; total time limit. When `provision.sh` starts the watchdog, set the time limit with `WATCHDOG_MAX_WALL_SECS` | | `SSH_KEY_FILE` | watchdog | empty | Private key for the progress check. Empty turns the check off | | `PROGRESS_MIN_BYTES` | watchdog | `1048576` | Database growth that counts as progress; any block height increase also counts | | `UNREACHABLE_IS_HANG` | watchdog | `0` | `1` counts lost SSH as no progress | ## Ports | Port | Purpose | Open in the firewall | |---|---|---| | 3381/tcp | Snapchain HTTP API | No, unless you set `NODE_API_CIDRS`. Caddy reaches it on 127.0.0.1 | | 3382/udp | Gossip (libp2p QUIC) | Yes | | 3383/tcp | Snapchain gRPC API | No, unless you set `NODE_API_CIDRS` | | 80/tcp, 443/tcp | Caddy: Let's Encrypt and the HTTPS client | Yes | | 22/tcp | SSH | Yes, limit with `SSH_SOURCE_CIDRS` | | 3000/tcp | Grafana (default `admin:admin`) | No. Keep it closed | | 8125/udp, 8126/tcp | statsd | No | Docker publishes 3381, 3383, 3000 and the statsd ports on every interface of the server, so the Hetzner firewall is what keeps them private. The app reaches the node through Caddy on 443. To read the API from your computer, use `https:///v1/...` or `ssh -N -L 3381:127.0.0.1:3381 root@$IP`. ## HTTP endpoints the client uses `/v1/info`, `/v1/castsByFid`, `/v1/castById`, `/v1/linksByFid`, `/v1/linkById`, `/v1/reactionById`, `/v1/userDataByFid`, `/v1/signersByFid`, `/v1/signer`, `/v1/onChainIdRegistryEventByAddress`, `POST /v1/submitMessage`. Reference: [HTTP API](https://snapchain.farcaster.xyz/reference/httpapi/httpapi). ## Repo layout | Path | What | |---|---| | `client/` | The web app: Vite and TypeScript, `@farcaster/core` 0.20.0 | | `deploy/` | `Caddyfile`, Caddy compose file, Hetzner firewall rules | | `test/`, `e2e/` | Tests for the scripts; browser tests against a mock node and the live node | | `site/` | This site (Astro Starlight) | | `MEASUREMENTS.md`, `run-log.md` | Numbers and log from the mainnet run | | `DOCS_ISSUES.md`, `SOURCES.md` | Problems found in the official docs; the source for each fact | | `SECURITY.md`, `VOUCHER.md` | How keys are handled; the checklist for vouching | ## Sources - Snapchain v0.14.3, commit `fc87f26`: [github.com/farcasterxyz/snapchain](https://github.com/farcasterxyz/snapchain) - Gasless app keys: [writing-messages](https://snapchain.farcaster.xyz/guides/writing-messages); live since engine version V16 on 2026-05-07 ([`src/version/version.rs`](https://github.com/farcasterxyz/snapchain/blob/fc87f2686472598143062a00e1c2b2633b2d87ab/src/version/version.rs)); the guide was updated in [docs PR #989](https://github.com/farcasterxyz/snapchain/pull/989) - Read nodes pass messages on: [node-types](https://snapchain.farcaster.xyz/node-types#message-submission-and-the-mempool) (documented in [docs PR #1034](https://github.com/farcasterxyz/snapchain/pull/1034)); code: `submit_message_internal` in `src/network/server.rs`, `ReadNodeMempool` in `src/mempool/mempool.rs` - [Getting Started](https://snapchain.farcaster.xyz/getting-started), [managing-a-node](https://snapchain.farcaster.xyz/guides/managing-a-node) - Full list: [SOURCES.md](https://github.com/sudharshanx/runfarcaster/blob/main/SOURCES.md) --- # Glossary > Short definitions of the Farcaster and Snapchain terms this site uses. | Word | What it means here | |---|---| | FID | Your Farcaster account number. It is registered on OP Mainnet. | | Custody address | The Ethereum address that owns an FID. It approves and cancels app keys, and can move the FID to another address. | | App key (signer) | An ed25519 key an app uses to post for an FID, only for its scopes and until it expires. It cannot move money. | | KEY_ADD | A message that approves a new app key. The custody address signs two EIP-712 payloads inside it, and the new app key signs the message. Sending it costs no gas. | | KEY_REMOVE | A message that cancels an app key. The RunFarcaster app does not send it. | | Scope | One kind of message an app key may sign, for example `CAST_ADD`, `REACTION_ADD` or `LINK_ADD`. | | TTL | How long an app key lasts without use: 1 second to 90 days. Each use starts the clock again. | | Nonce | A counter per FID, shared by `KEY_ADD` and custody-signed `KEY_REMOVE`. Each one must use a number above `currentUserNonce`. | | Message | One signed piece of Farcaster data: a cast, like, recast, follow, profile field, verification, or key change. | | Cast | A post. A standard cast holds up to 320 bytes of text. Long casts allow up to 1,024 bytes, and Pro accounts up to 10,000. The RunFarcaster app sends standard casts. | | Storage rent | Space rented onchain in yearly units. It sets how many messages of each kind an FID keeps; past that, the oldest go. After a unit expires there is a 30-day grace period. | | fname | A free offchain username like `@alice`, issued by the Fname Registry as `alice.fcast.id`. Farcaster can reclaim it. | | ENS name | An onchain username like `@alice.eth`, registered on Ethereum L1 and controlled by your wallet. | | Username proof | A signed record that ties a username to an FID and an owner address. Nodes store them. | | Snapchain | The network that puts Farcaster messages in order and stores them, split into shards. | | Shard | One part of Snapchain's data. Shard 0 holds network-wide data, including gasless `KEY_ADD` messages. Shards 1 and 2 split the accounts. | | Validator | A Snapchain node that makes blocks and votes on them. | | Read node | A Snapchain node that copies finished blocks from its peers (5 mainnet validators in October 2026), answers the API and sends on messages it receives. It has no mempool and earns nothing. | | Hub | The older name for a Farcaster node. Some docs and libraries still say hub where they mean a Snapchain node. | | Mempool | Where a validator holds messages until they go in a block. Read nodes do not have one. | | blockDelay | How many seconds a shard's newest block is behind the clock. Single digits on every shard means synced. | | Snapshot | A compressed copy of the database that a new node downloads first. | | Gossip | How nodes pass blocks and messages to each other, over libp2p and QUIC on UDP port 3382. | | Farcaster time | Seconds since 2021-01-01 00:00 UTC. Message timestamps use it. | | Gasless app key | An app key added with a `KEY_ADD` message instead of an OP Mainnet transaction, so it costs no gas. Mainnet has accepted these since engine version V16 (7 May 2026). | | Caddy | The web server `deploy-client.sh` starts on your server. It gets an HTTPS certificate from Let's Encrypt, serves the app, and passes `/v1/*` to the node. | | sslip.io | A public DNS service run by someone else. `.sslip.io` points at that IP, so the app gets an HTTPS address without a domain of your own. | | cax31, cpx42 | Hetzner server type names. cax31 is ARM64 with 8 vCPUs and 16 GB of RAM (the tested type); cpx42 is x86 with 16 GB of RAM (untested). | | Same origin | The page and the API come from one scheme, host and port, so the browser makes no cross-site request. | --- # Fix a problem > Match the error you see to its cause and fix. Search this page for the exact message you see. Each entry has the cause, the fix, and where the problem came up: - **Seen on a mainnet run** means it happened during a full mainnet run of the 3 steps on 18 Oct 2026. The run log has the details: [run-log.md](https://github.com/sudharshanx/runfarcaster/blob/main/run-log.md) - **Found in review** means a tester found it while checking the guide against a live node or the code. - **Reproduced in testing** means it was caused on purpose to record the exact message. ## Step 1: rent a server ### Hetzner refuses the volume **You see:** `volumes size limit exceeded (resource_limit_exceeded)` **Why:** New Hetzner accounts can rent at most 1024 GB of volumes in total. Asking for 2000 GB gives this error. **Fix:** Ask for 1000 GB, as the Full run does. Your limits are at [console.hetzner.cloud/limits](https://console.hetzner.cloud/limits). ```bash VOLUME_GB=1000 ``` **Where:** Seen on a mainnet run, when step 1 asked for 2000 GB. Nothing was created, so it cost nothing. ### The project already has a server with that name **You see:** `this project already has: server snapchain-read-node`, with `Nothing was created or changed.` **Why:** A server named `$NAME`, the volume `$NAME-data`, or a firewall `$NAME-fw` that is applied to a server already exists in this Hetzner project. It may be someone else's node, so `provision.sh` stops before it changes anything. **Fix:** Use an empty Hetzner project. For a second node in the same project, pick another name and keep it for every later step and for teardown: ```bash export NAME=snapchain-read-node-2 ``` If it is your own earlier run, `REUSE=1 ./provision.sh` continues with it. **Where:** Found in review by a first-time tester in a shared project; the check was added to `provision.sh` afterwards. Covered by unit tests, not yet hit on a live run. ### The server type isn't available to rent **You see:** `server type cax31: not available in fsn1 right now.` when you set a type, or `none of [cx43 cax31 cpx42 ccx23] is usable in fsn1.` with `SERVER_TYPE=""`. Both list the types you can rent there, cheapest first. **Why:** Hetzner can't rent you that type in that location right now. Which types it can rent changes over time. **Fix:** Run step 1 again with a type from the list, or with another location. The guide has been tested on cax31 only; times on other types vary. ```bash SERVER_TYPE=cpx42 # or another type from the error LOCATION=nbg1 # fsn1, nbg1, hel1, ash, hil or sin ``` A typo in `LOCATION` gives `unknown LOCATION=…`, followed by the list of valid names. **Where:** Seen on a mainnet run, for cx43 and later for cax31. ### SSH stops working **You see:** SSH to the server hangs, then times out. It worked before. **Why:** The firewall lets SSH in only from the address in `SSH_SOURCE_CIDRS`, and your public address changed. Home connections and some cloud machines do this. **Fix:** Point the SSH rule of the firewall `$NAME-fw` at your new address. This changes only that rule; the server keeps running. ```bash MY_IP=$(curl -4 -s https://ifconfig.me) && echo "$MY_IP" hcloud firewall describe "$NAME-fw" -o json \ | jq --arg ip "$MY_IP/32" '[.rules[] | if .protocol == "tcp" and .port == "22" then .source_ips = [$ip] else . end]' \ | hcloud firewall replace-rules --rules-file - "$NAME-fw" ``` It prints `Firewall Rules for Firewall updated`. Then connect again. If a watchdog runs, its progress check also uses SSH: until the rule is fixed it logs a warning and deletes nothing. **Where:** Seen on a mainnet run: the machine running the steps kept getting new public IPs. Opening SSH to every address, with key-only login, is the other way out. ### `$IP` is empty **You see:** `ssh: Could not resolve hostname : Name or service not known` on Linux, with nothing after `hostname`. **Why:** `$IP` lives only in the terminal where step 1 ran. **Fix:** Set the name from step 1 again, then get the IP: ```bash export NAME=snapchain-read-node IP=$(hcloud server ip "$NAME") ``` **Where:** Found in review, while a tester checked the steps. Reproduced in testing for the exact message. ### The watchdog deletes the node during the sync This only applies if you left out `WATCHDOG=0` in step 1. **You see:** The server is gone. `.provision-state/watchdog.log` gives the reason, for example `wall-clock cap 86400s reached`. **Why:** The watchdog deletes everything 24 hours after step 1, even if the node is healthy. Our cax31 sync took 14 h 06 m. An earlier version would also have deleted it 2 hours into the sync when it had no SSH key file, because it could not see any progress. **Fix:** Stop the watchdog to keep the node. Nothing is deleted. ```bash touch .provision-state/watchdog.disarm ``` The current watchdog turns its progress check off when it has no SSH key file. **Where:** Found in review, by reading the code. ### The watchdog deletes the node to stay under `MAX_USD` This only applies if you left out `WATCHDOG=0` in step 1. **You see:** The server is gone. `.provision-state/watchdog.log` has a line `TEARDOWN triggered: budget: projected >= MAX_USD after s at /h`. **Why:** Every minute the watchdog multiplies the live hourly price of the server, its IPv4 address and the volume by the hours that have started, each counted in full, plus the next check and 15 minutes for the teardown. When that total reaches `MAX_USD`, in your Hetzner account's currency, it runs `teardown.sh`. That deletes the server, the volume and the firewall. The node's database was on the volume, so it is gone too, and a new node loads the snapshot from the start. The watchdog runs on your computer, not on the server. While your computer sleeps, it checks nothing, and Hetzner keeps billing. It measures time by the clock, so after you wake the computer it counts the hours you slept, and it can delete everything at its next check, within 60 seconds. The 24-hour limit works the same way. **Fix:** Run the 3 steps again. To avoid it next time: - Set `MAX_USD` and `EXPECTED_HOURS` for the whole time you want the node. `provision.sh` refuses to start if the price for `EXPECTED_HOURS` would pass `MAX_USD`, so raise both together. - The watchdog also deletes everything 24 hours after step 1. For longer, set `WATCHDOG_MAX_WALL_SECS` in step 1 (`172800` is 48 hours) and raise `MAX_USD` to match. - To keep the node with no limit, use `WATCHDOG=0` in step 1. To stop a running watchdog without deleting anything, create its disarm file. It reads the file at its next check, deletes it and exits: ```bash touch .provision-state/watchdog.disarm ``` **Where:** Found in review, by reading `watchdog.sh`. ### The watchdog never started **You see:** `watchdog did not start (see …/watchdog.out). Nothing was created.` The file says `HCLOUD_TOKEN not set. NOT RUNNING: no spending cap is active.` **Why:** A new terminal does not have your Hetzner token. **Fix:** Set the token in this terminal, then run step 1 again. ```bash printf 'Hetzner token: '; stty -echo; read -r HCLOUD_TOKEN; stty echo; echo; export HCLOUD_TOKEN ``` **Where:** Found in review. The tests now check this message. ### The watchdog would delete everything at its first check This only applies if you left out `WATCHDOG=0` in step 1. **You see:** `WATCHDOG_T0 must be a Unix time in seconds (date +%s), got ''`, or `the watchdog would delete everything at its first check (WATCHDOG_T0= is too far back for MAX_WALL_SECS=86400 / MAX_USD=8). Nothing was created. Unset WATCHDOG_T0 or raise the caps.` **Why:** `WATCHDOG_T0` tells the watchdog when the run started, in seconds since 1970, as `date +%s` prints it. It is only for restarting a watchdog partway through a run. If it is not a whole number, `provision.sh` stops. If it is so far back that the 24-hour cap (`MAX_WALL_SECS`, 86400 seconds) or the `MAX_USD` budget is already reached, the watchdog would run `teardown.sh` at its first check while step 1 is still creating the server, so `provision.sh` stops first. **Fix:** Unset it, then run step 1 again: ```bash unset WATCHDOG_T0 ``` To restart a watchdog for a run that is still going, set `WATCHDOG_T0` to the `date +%s` value from when step 1 ran. If that run is close to a limit, raise `WATCHDOG_MAX_WALL_SECS` or `MAX_USD`. **Where:** Found in testing with a fake `hcloud`; the check was added to `provision.sh` afterwards. Covered by unit tests, not yet hit on a live run. ### `MAX_USD` is below the watchdog's fallback rate This only applies if you left out `WATCHDOG=0` in step 1. **You see:** `MAX_USD= must be above the watchdog's FALLBACK_RATE (0.35/h, used until the server exists), or it deletes everything at its first check. Nothing was created.` **Why:** Until the server exists, the watchdog cannot read its live price, so it uses `FALLBACK_RATE`: 0.35 an hour by default, in your Hetzner account's currency. It counts the first started hour in full, so with `MAX_USD` at or below that rate it would delete everything at its first check. `provision.sh` stops before it creates anything. **Fix:** Raise `MAX_USD` together with `EXPECTED_HOURS`, then run step 1 again. The defaults, `MAX_USD=8 EXPECTED_HOURS=24`, pass for cax31 with the 1000 GB volume: ```bash IP=$(SSH_KEY= SERVER_TYPE="" VOLUME_GB=1000 MAX_USD=8 EXPECTED_HOURS=24 SSH_SOURCE_CIDRS="$MY_IP/32" ./provision.sh) ``` **Where:** Found in testing: the real `watchdog.sh` with `MAX_USD=0.3` and no server yet deleted everything after 0 seconds. The check was added to `provision.sh` afterwards. Covered by unit tests, not yet hit on a live run. ## Step 2: start the node ### The disk fills while the snapshot loads **You see:** The data disk fills up hours into step 2. If `/root/snapchain-DISK-FULL` exists, `disk-guard.sh` stopped the node at 90% full. **Why:** The official restore keeps every downloaded chunk, an unpacked copy of each shard and the database until the end. The snapshot our run downloaded measured 485 to 505 GB, and that does not fit on a 1000 GB volume. **Fix:** On disks under 1500 GB, `node.sh` installs two helpers. `snapshot-space-saver.sh` deletes chunks the node has already read. `disk-guard.sh` stops the node before the disk is full. Check that they run: ```bash ssh root@$IP systemctl status snapchain-space-saver snapchain-disk-guard.timer ``` **Where:** Seen on a mainnet run, during the snapshot restore. The helpers were installed by hand before the disk filled, and the disk peaked at 699 GB (75%). The automatic install in `node.sh` has unit tests but has not run during a real sync yet. ### The node crashes on a 404 while it loads the snapshot **You see:** In the node log, `Failed to download snapshot chunk .rocks.snapshot/shard-0/chunk_0001.bin: HTTP status client error (404 Not Found)`, then a panic. From `node.sh`: `Snapchain could not download the shard 0 snapshot`. The shard number can differ. **Why:** Snapchain reads the snapshot list of every shard once, at the start. It then loads shard 1, shard 2 and shard 0, one after another. Shard 0's snapshot is replaced once a day and the old files are deleted. If that happens before the node reaches shard 0, every download fails with 404. The node tries each chunk 5 times, 10 seconds apart, then crashes. Docker restarts it, but the database folder is no longer empty, so it skips the snapshot and runs without shard 0. **Fix:** `node.sh` does this for you while it waits for sync. It restores only the failed shard from the current snapshot and leaves the other shards alone. To run it yourself: ```bash ssh root@$IP 'bash -s repair-shard 0' < node.sh ``` It downloads the chunks 4 at a time, starts over with the new snapshot list if it gets a 404, and gives up after 3 tries. It needs 200 MiB free for each chunk in the list. It stops the node only to swap in the restored shard. `SNAPSHOT_REPAIR=0` turns the automatic repair off. **Where:** Reported by a tester on a mainnet sync. The repair is tested offline against a fake bucket and a fake Docker. It has not run on a real node yet. Upstream bug: [snapchain #1044](https://github.com/farcasterxyz/snapchain/issues/1044). A tester fixed shard 0 by hand on mainnet with the same steps this command runs. [Read his report](https://github.com/farcasterxyz/snapchain/issues/1044#issuecomment-6326673811). ### The API does not answer for hours **You see:** `HTTP API not answering yet (snapshot download in progress?).` **Why:** The node serves no API while it loads the snapshot. On our cax31 run that took 8 h 34 m. **Fix:** Wait. The used space on the server should keep growing: ```bash ssh root@$IP df -h /root/snapchain ``` **Where:** Seen on a mainnet run, for the whole snapshot restore. ### `node.sh` stops waiting before the node is in sync **You see:** `not in sync after 43200s. Re-run './node.sh wait' later; it is safe to re-run.` **Why:** `node.sh` waits for a set time. It used to be 12 hours, shorter than our cax31 sync (14 h 06 m). It is 24 hours now, so the message says `86400s`. **Fix:** Wait again. The node kept syncing the whole time. ```bash ssh root@$IP 'bash -s wait' < node.sh ``` **Where:** Seen on a mainnet run, with the old 12-hour wait. ### Your computer sleeps or SSH drops **You see:** The terminal running step 2 stops, or SSH disconnects. **Why:** The node runs on the server, not on your computer. **Fix:** Reconnect and wait again: ```bash export NAME=snapchain-read-node # the name from step 1 IP=$(hcloud server ip "$NAME") ssh root@$IP 'bash -s wait' < node.sh ``` **Where:** Seen on a mainnet run. The computer running the steps paused at least 13 times during the sync, and the node kept syncing each time. ### Warnings about peers in the node log **You see:** `InsufficientPeers ("read-node-peers")` warnings, 540 in 30 minutes on our run. `HandshakeTimedOut` to two bootstrap peers every 30 seconds. **Why:** None of the connected peers listen on the `read-node-peers` topic. The two bootstrap peers do not answer. **Fix:** Nothing, while `blockDelay` stays in single digits. If `blockDelay` keeps growing, the validators may have stopped making blocks. The Snapchain docs say read nodes log this warning nonstop during such a halt ([node-types](https://snapchain.farcaster.xyz/node-types)). ```bash ssh root@$IP 'curl -s 127.0.0.1:3381/v1/info' | jq '.shardInfos[] | {shardId, blockDelay}' ``` **Where:** Seen on a mainnet run, for the whole sync. ## Step 3: open the app ### Node.js is too old **You see:** `[deploy-client] ERROR: Node.js 22.12+ (22.x), 24.x or 26+ is required (found: v20.19.2).` **Why:** The app's build tools need Node.js 22.12 or later. **Fix:** Install Node.js 22.12+ or 24: `nvm install 24 && nvm use 24` ([nvm install docs](https://github.com/nvm-sh/nvm#installing-and-updating)), or download it from [nodejs.org](https://nodejs.org/en/download). Node.js 20.19 can run `npm run dev`, but not the tests that `deploy-client.sh` runs. Then check: ```bash node --version ``` **Where:** Reproduced in testing with Node.js 20.19.2. Found in review first, when the site build failed on Node.js 20. ### `SSH_OPTS_ARR[@]: unbound variable` on a Mac **You see:** `./deploy-client.sh: line 28: SSH_OPTS_ARR[@]: unbound variable`, right after `[deploy-client] uploading to root@:/opt/snapchain-client`. **Why:** A Mac runs the script with bash 3.2. If you don't set `SSH_OPTS`, the script has an empty list of SSH options. Bash 3.2 treats an empty list as a missing variable and stops. **Fix:** Get the fixed script with `git pull`, then run step 3 again. Running it twice is safe. On an older copy, set any SSH option to get past it: ```bash SSH_OPTS="-o BatchMode=yes" ./deploy-client.sh "$IP" ``` **Where:** Reported by a tester on a Mac. Reproduced in testing under bash 3.2.57, built from the GNU source, offline. The fix is tested there too, along with the other scripts that run on your computer. ### Docker is not on the server yet **You see:** `bash: line 1: docker: command not found` after `[deploy-client] uploading to root@:/opt/snapchain-client`, or another error from `docker compose`. **Why:** `deploy-client.sh` starts Caddy on the server with `docker compose`. A new server has no Docker until step 2 installs it, so step 3 fails if it runs before step 2 gets that far. **Fix:** Wait for step 2 to install Docker. Its output shows `[node.sh] Installing Docker Engine from download.docker.com ...`, and Docker is ready once `[node.sh] Downloading official bootstrap:` follows. To check from another terminal: ```bash ssh root@$IP docker compose version ``` When that prints a version, run step 3 again. Running it twice is safe. **Where:** Found in review. ### Port 5317 is already in use **You see:** `Error: Port 5317 is already in use` when you run `npm run dev` for the local copy of the app. **Why:** The local copy always uses port 5317, which keeps its saved app key apart from other apps on your computer. If the port is busy, it stops instead of moving. **Fix:** Find the program that uses the port, stop it, then start the app again: ```bash lsof -i :5317 ``` **Where:** Reproduced in testing. Found in review first: the app used to run on port 5173, the default for Vite projects. ### Port 3381 does not answer from outside **You see:** `curl http://:3381/v1/info` hangs, then times out. **Why:** `provision.sh` keeps the node's own ports 3381 and 3383 closed to the internet. An earlier version left them open, so anyone could read the node and send it messages. **Fix:** Ask through the app's address, or open an SSH tunnel: ```bash curl -s https:///v1/info # or ssh -N -L 3381:127.0.0.1:3381 root@$IP # then, in another terminal: curl -s 127.0.0.1:3381/v1/info ``` **Where:** Found in review. The first review reached port 3381 from outside. The second confirmed it now times out. ## Adding an app key ### No FID for your recovery phrase **You see:** In the app: `This recovery phrase doesn't own a Farcaster account that your node knows.` The underlying error is `no FID is registered to custody address 0x…`. **Why:** The app turns your recovery phrase into one Ethereum address, the first account at the path `m/44'/60'/0'/0/0`, and asks your node which FID that address owns. Your node knows of none. The usual causes: - A word is wrong. A different phrase gives a different address. - The address that owns your FID is not that first account, for example a second account your wallet made from the same phrase, or an address the FID was moved to later. The app does not try other accounts. - Your node has not synced. It finds FIDs in its own copy of the chain, so it cannot see an FID registered after the last block it has. **Fix:** Check the words, then check that every shard shows a `blockDelay` in single digits, and try again: ```bash ssh root@$IP 'curl -s 127.0.0.1:3381/v1/info' | jq '.shardInfos[] | {shardId, blockDelay}' ``` If your FID belongs to another account, the app has no way to use it today. Never paste the phrase into an AI assistant or a chat to debug this. **Where:** Found in review, by reading [client/src/signer.ts](https://github.com/sudharshanx/runfarcaster/blob/main/client/src/signer.ts). ## Reading from the node ### `reverse=1` returns 400 **You see:** `provided string was not true or false` **Why:** The node accepts only `true` or `false`. **Fix:** Use `reverse=true`. **Where:** Found in review, on a live node. ### A repeated parameter returns 400 **You see:** `Multiple values for one key: "requesterFids"` **Why:** The node wants list parameters with brackets. **Fix:** Use `requesterFids[]=1&requesterFids[]=2`. **Where:** Found in review, on a live node. ### `mempoolSize` shows 4294967295 **You see:** `"mempoolSize": 4294967295` for shards 1 and 2 in `/v1/info`. **Why:** A read node has no mempool. The number is a placeholder. **Fix:** Nothing. **Where:** Seen on a live node. ## Ask an assistant Paste your exact command and its output, after removing tokens, IPs and hostnames: Prompt for a coding assistant: ```text Context: I am following the RunFarcaster guide (3 commands: a Hetzner server running a Snapchain read node, and a web app that reads and posts through it). Repo: https://github.com/sudharshanx/runfarcaster (MIT). I cloned it to my computer (git clone https://github.com/sudharshanx/runfarcaster, then cd runfarcaster) and run every command from that folder. Scripts: provision.sh (step 1), node.sh (step 2), deploy-client.sh (step 3), teardown.sh, and watchdog.sh (optional). Server name: export NAME=snapchain-read-node (or another name I pick), in every terminal; all steps and teardown.sh use it. In any new terminal, get the server IP again with: IP=$(hcloud server ip "$NAME") I am on step <1 rent a server | 2 start the node | 3 open the app | teardown>. The exact command I ran: What it printed (tokens, IPs and hostnames removed): What the guide said I should see: Tell me the most likely cause, based on the repo scripts and the guide's Fix a problem page, and give me one next command to run. Stay with what the output shows. Rules for you: - Never ask for my recovery phrase (also called a seed phrase) or for any private key, and refuse one if I paste it. Tell me to stop, and to treat that account as exposed. - Never print, log or echo HCLOUD_TOKEN or any other token. Read it from the environment only. - Ask me before you run teardown.sh, delete anything, or change MAX_USD. - Show me real output only. If a command fails, show the exact error and stop. ``` --- # Proof: what we ran > We synced a mainnet node in 14 h 06 m, posted a cast through it, and deleted the server 70 h 04 m after renting it. We ran the 3 steps once, on mainnet, on 2026-10-18. Times are IST (UTC+5:30). The server was a Hetzner cax31 (ARM64, 8 vCPU, 16 GB RAM) in fsn1 with a 1000 GB volume and Ubuntu 24.04. The node ran Snapchain v0.14.3. The full notes are in [MEASUREMENTS.md](https://github.com/sudharshanx/runfarcaster/blob/main/MEASUREMENTS.md) and [run-log.md](https://github.com/sudharshanx/runfarcaster/blob/main/run-log.md). We replaced the server's IP, its Hetzner IDs and our test account with placeholders before publishing them. ## The run, start to finish | Event | Date and time (IST) | |---|---| | Server rented with `provision.sh` | 2026-10-18 01:26 | | Node fully synced, every shard less than 10 s behind | 2026-10-18 15:32 | | Cast posted through the node | 2026-10-18 20:53 | | Server and volume deleted with `teardown.sh` | 2026-10-20 23:30 | The server ran for 70 h 04 m. From 15:32 on 2026-10-18 until shutdown, all 3 shards stayed at the tip. The last count, at 22:56 on 2026-10-20, was 914,276,853 messages in a 558 GB database. The run cost $11.49 ([Cost](#cost)). After `teardown.sh` the Hetzner project was empty. The screenshots on this page are from 2026-10-18. ## Posted to mainnet On 2026-10-18 we added an app key to our own Farcaster account and posted a cast through our node. | What | Time (IST) | |---|---| | Gasless `KEY_ADD` accepted: signer source `OFFCHAIN`, scopes `CAST_ADD`, `REACTION_ADD` and `LINK_ADD`, 1-day expiry, nonce 1 | 20:53:29 | | Cast confirmed by the node | 20:53:47 | The cast says "My first cast on a local node". Hash `0x4b4eb31bb28cf0d6d0583c7fc022a60f0af50ec2`. [See the cast](https://farcaster.xyz/nashrad/0x4b4eb31b). The cast was confirmed 18 seconds after the key. The app ran on `localhost` on our own computer (client commit `7f2b71f`), with `SNAPCHAIN_NODE_URL` pointing at our node through Caddy. We typed the recovery phrase into that browser tab. It never left the browser: only the signed `KEY_ADD` and the cast went to the node. We have not sent a like, recast or follow on mainnet. ## The sync, from the log A logger on our side read the disk and the node's `/v1/info` over SSH every 10 minutes. Some of its lines, with the memory and status fields cut: ```text title="run-log.md (excerpts)" wrap=false 01:38:17 IST progress: du=1.1G df=1% Progress: 14/5689 chunks (0%) (ETA: 4h50m) info=none 03:13:11 IST progress: du=139G df=15% Progress: 1705/5689 chunks (29%) (ETA: 3h43m) info=none 08:37:12 IST progress: du=494G df=53% Progress: 5319/5689 chunks (93%) (ETA: 0h28m) info=none 12:41:33 IST progress: du=556G df=60% info=[{"s":0,"h":48251171,"d":45482},{"s":1,"h":48946704,"d":24543},{"s":2,"h":48763546,"d":43641}] 15:32 IST synced: /v1/info shard 0 h=48305951 blockDelay=1, shard 1 h=48980989 d=1, shard 2 h=48816538 d=4 16:20:25 IST progress: du=556G df=60% info=[{"s":0,"h":48308772,"d":4},{"s":1,"h":48983812,"d":1},{"s":2,"h":48819359,"d":0}] ``` `h` is a shard's block height and `d` its `blockDelay`, in seconds behind. At 12:41 shard 0 was still 45,482 seconds (12 h 38 m) behind. At 15:32 every shard was within 4 seconds: that is the sync time in the table below. At 16:20 it was still at the tip. The sync phase times below come from a logger that read every 10 minutes, so each is accurate to 10 minutes. | Phase | Time | |---|---| | `provision.sh` | 35 s (started 01:26:33) | | Snapshot download | 5 h 24 m for 5689 chunks, 485 to 505 GB by our disk readings, 25 to 26 MB/s | | Snapshot load in total (download, gunzip, untar, shard by shard) | 8 h 34 m | | Catching up after the load | 5 h 15 m, at 4.3 to 4.6 blocks a second per shard | | From renting the server to fully synced (every shard less than 10 s behind) | 14 h 06 m | | `deploy-client.sh`, including the Let's Encrypt certificate | 35 s, measured once | Problems we hit on the way, all on 2026-10-18, IST. Each has a fix on [Fix a problem](https://sudharshanx.github.io/runfarcaster/fix/): | Time | What happened | |---|---| | 01:20 | cx43 was not available to rent, so we rented cax31 | | 01:23 | The 2000 GB volume went over the new-account limit; we used 1000 GB | | 01:35 | SSH was refused: the machine running the steps kept getting new public IPs, so the firewall no longer matched | | 01:44 | The disk was filling during the restore; we installed the two disk helpers by hand | | 01:38 to 10:11 | The HTTP API did not answer while the snapshot loaded | | 13:39 | `node.sh` stopped waiting at its old 12-hour limit; the node kept syncing | | 18:48 | cax31 was not available to rent in fsn1, nbg1 or hel1 when we checked | | Whole sync | The computer running the steps paused at least 13 times; the node kept going | ## The node once synced | Item | Value | Measured with | |---|---|---| | Database | 557.86 GB. Per shard on disk: 37, 261 and 259 GiB | `/v1/info` `dbStats.approxSize`, `du -sh` | | Messages | 914,243,963 messages and 3,355,148 FID registrations | `/v1/info` | | Disk | 557 GB used, 378 GB free (60%) of 984 GB | `df -h` | | Fullest the disk got | 699 GB (75%), during the shard 2 unpack, with `snapshot-space-saver.sh` running | the 10-minute logger, so the real peak may be higher | | Memory on the server | 2.4 to 2.6 GiB used and 12.7 GiB available; most of the rest was file cache | `free -m` | | Memory per container | snapchain 1.53 GiB, statsd 264 MiB, Grafana 68 MiB, Caddy 12 MiB | `docker stats` | | CPU | snapchain 1.3% in one reading once synced; load average 0.7 to 1.5 on 8 vCPU | `docker stats`, `/proc/loadavg` | | Peers | 5, all mainnet validators | `/v1/mesh`, read on the server | | Read time through Caddy | 0.2 s per `/v1/*` request, rounded to 0.1 s | `curl` | ## Cost | Item | Amount | |---|---| | Price | $0.164 an hour: cax31 $0.040, IPv4 $0.001, 1000 GB volume $0.123 (our estimate: $76.70 a month ÷ 625 hours) | | Until synced, 14 h 06 m | $2.31 | | Whole run, 70 h 04 m (2026-10-18 01:26 to 2026-10-20 23:30) | $11.49 | | Left running | $102.29 a month: $24.99 + $0.60 + $76.70, the monthly prices Hetzner stops at | | Invoice | Not arrived yet. The amounts above are estimates from Hetzner's hourly price. | The official AWS guide estimates "roughly $100/month"; that is an estimate, not a run. Not measured: how long a valid message takes to show up, Grafana's peak memory, and what happens over days with inbound 3382/udp closed. A 2-minute test with it closed kept all 5 peers. ## The app on the synced node [screenshot: 375 px phone: the Following feed, '21 casts, 38 replies to people you don't follow hidden', status 'Connected to your node', and the Feed, Post and Settings bar at the bottom] The feed on a 375 px phone [screenshot: Desktop: the Following feed with '21 casts, 38 replies to people you don't follow hidden', status 'Connected to your node', and a side panel 'To post, add an app key first'] Desktop. Open the image for full size. Taken in Chrome against the live node on 2026-10-18 at 22:38 IST, after the app's last layout change (client commit `9b4764c`, 22:15 IST). ## Test status | What | Status | |---|---| | provision.sh on Hetzner (cax31, VOLUME_GB=1000) | Ran live (35 s) | | node.sh: official bootstrap, full mainnet sync, health check, second run skips the bootstrap | Ran live | | deploy-client.sh: Caddy, Let's Encrypt, /v1 on the same address | Ran live | | App reads on the real node (e2e/real-node.mjs) | Ran live, read-only, 17 of 17 | | Node rejects a broken message | Ran live (HTTP 400) | | disk-guard.sh and snapshot-space-saver.sh, installed by hand | Ran live; chunk and tar deletion worked, hole-punching did not; the guard never fired | | node.sh installing those helpers by itself (new) | Unit tests only; not yet used in a live sync | | watchdog.sh | Ran live (old version); new progress check unit-tested | | provision.sh starting the watchdog by itself (new) | Unit tests only | | Gasless KEY_ADD on mainnet | Ran live once (2026-10-18, 1-day key, signed in the browser on localhost) | | Cast on mainnet | Ran live once; confirmed 18 s after the key was added | | Like, recast, follow on mainnet | Not tried (unit tests and a local mock node only) | | Time from sending a message to seeing it | Not measured precisely (one cast: key added to cast confirmed in 18 s) | | teardown.sh against real servers | Ran live once: deleted the server, volume and firewall on 2026-10-20 | | 2 TB volume, x86 cx43 | Not tried | | Inbound 3382/udp closed | 2-minute test only | ## Test results | Suite | Result | What it covers | |---|---|---| | `e2e/real-node.mjs` on the live node | 17 of 17 | Listed below the table | | `e2e/run.sh` (the real Caddyfile, a mock node, headless Chrome) | 72 of 72 | The same app and proxy on one machine: CSP headers and ``, the add-app-key steps, a signed key and cast, feed rules, filters by app, pictures on and off, phone and desktop layout, error states | | Client unit tests (`vitest`) | 69 of 69, 9 files | Messages, key adding, feed, filters, timeline, retry logic, formatting, the app behind each cast | | `test/node_sh_test.sh` | 42 of 42 | Sync rules, bootstrap under a fake terminal, `.env` answers, the new disk-helper install and re-run | | `test/space_saver_test.sh` | 14 of 14 | Chunk and tar clean-up, hole punching, waiting for the snapshot folder | | `test/watchdog_test.sh` | 16 of 16 | Progress check during a sync, a stuck node, SSH loss, money cap, macOS without `timeout`, a missing token written to the log | | `test/teardown_test.sh` | 9 of 9 | Running twice, missing token, wrong confirmation, a server ID that differs from the one `provision.sh` recorded | | `test/provision_test.sh` | 29 of 29 | `SSH_SOURCE_CIDRS` check, the watchdog start (with a stand-in watchdog), no second watchdog, a loud stop when it cannot start, unavailable server types and the `SERVER_TYPE=""` auto-pick, a server or firewall left from an earlier run, the volume quota (with a fake `hcloud`) | | `shellcheck` | No warnings | Every shell script | We ran the shell suites, shellcheck, the client unit tests and `e2e/run.sh` on 2026-10-21 at 00:03 IST, on commit `3e06969`. `e2e/real-node.mjs` ran on 2026-10-18 against the live node, last at 18:07 IST, and can't be rerun now that the server is deleted. What `e2e/real-node.mjs` checked: - HTTPS with CSP `connect-src 'self'` and HSTS. - Status `Connected` (v0.14.3). - 21 real casts in the feed. - Mute by FID hides casts, and the mute survives a reload. - Key and storage lookups. - `/v1/mesh` returns 404. - A broken `submitMessage` returns 400. - No third-party or mixed-content requests. - No key or phrase in browser storage. - No sideways scroll at 375 px or 360 px. - No console errors or CSP violations. The live run used the earlier versions of `node.sh`, `watchdog.sh` and the firewall rules. Commands to rerun the suites: ```bash shellcheck -x -P SCRIPTDIR *.sh test/*.sh e2e/*.sh bash test/node_sh_test.sh bash test/space_saver_test.sh bash test/watchdog_test.sh bash test/teardown_test.sh bash test/provision_test.sh (cd client && npm ci --ignore-scripts && npm run typecheck && npm test) (cd e2e && npm ci && CADDY=caddy CHROME=/path/to/chrome ./run.sh) SITE=https:// CHROME=/path/to/chrome node e2e/real-node.mjs ``` `e2e/real-node.mjs` never pastes a phrase, never creates a key, and never presses Cast, Like, Recast or Follow. ## Independent test Tester Nick ran RunFarcaster on his own Hetzner cax31. Shard 0's snapshot was replaced mid-download, and the node crashed on a 404. He fixed it by hand with the steps `./node.sh repair-shard 0` automates, and the node synced fully about 3 h later. [His report on the Snapchain repo](https://github.com/farcasterxyz/snapchain/issues/1044#issuecomment-6326673811). ## Vouches We are asking at least one other person to run the guide on their own account and post the result on Farcaster. **Nick ([@otterly](https://farcaster.xyz/otterly)), Oct 22, 2026.** Ran the Full run on his own Hetzner cax31 (commit `a608c66`). It synced 914M messages in 19 h 23 m, for $3.30 by his count, and then he deleted the server. Posting wasn't tested. [Read his vouch](https://farcaster.xyz/otterly/0x07aa2e8a). ### How to vouch The checklist is in [VOUCHER.md](https://github.com/sudharshanx/runfarcaster/blob/main/VOUCHER.md). In short: 1. Note the commit you used: `git rev-parse --short HEAD`. 2. Complete the [Full run](https://sudharshanx.github.io/runfarcaster/quickstart/). Write down how long renting the server and the sync took, what Hetzner charged, the server type and the volume size. 3. Open the app from your node and check that a feed loads. 4. Optional, with a test account only: add an app key and send one cast ([Post with an app key](https://sudharshanx.github.io/runfarcaster/first-cast/)). Note the cast hash and whether another app shows it. 5. Run `teardown.sh` and check in the Hetzner console that the project is empty. 6. Post what happened on Farcaster, including anything that broke. Leave your recovery phrase, Hetzner token and server details out of the post. --- # Docs issues > Where the official Farcaster and Snapchain docs are out of date or wrong. The official docs had these outdated or wrong statements when the pages were last read, on 2026-10-18. Code references are to Snapchain v0.14.3 (`fc87f26`). [DOCS_ISSUES.md](https://github.com/sudharshanx/runfarcaster/blob/main/DOCS_ISSUES.md) has, for each problem, the page, what it says, what the check found and how it was checked. ## Snapchain docs and repo | # | Page | Issue | |---|---|---| | 1 | [running-a-node](https://snapchain.farcaster.xyz/guides/running-a-node) | Firewall image opens gossip 3382 on TCP; gossip is QUIC over **UDP** 3382 | | 2 | running-a-node | "Allow SSH from Anywhere", redundant `mkdir snapchain`, manual Docker install the bootstrap already does | | 3 | Getting Started vs README | Disk: 2 TB vs 1.5 TB. Measured: 557.86 GB DB, 699 GB peak with this guide's space saver | | 4 | README | "Ports 3381 - 3383 exposed on both TCP and UDP."; compose publishes only 3381/tcp, 3382/udp, 3383/tcp | | 5 | [HTTP API](https://snapchain.farcaster.xyz/reference/httpapi/httpapi) | Paging example uses `reverse=1` (rejected); JS example uses the wrong page-token parameter | | 6 | HTTP API reference | `/v1/signersByFid` and `/v1/signer` are missing; they return gasless keys and `currentUserNonce` | | 7 | (undocumented) | `requesterFids` needs `[]` brackets over HTTP | | 8 | [writing-messages](https://snapchain.farcaster.xyz/guides/writing-messages) | Example logs the private key | | 9 | writing-messages | Suggests 9 scopes and a 30-day TTL; fewer scopes and a shorter TTL are safer (suggestion) | | 12 | Getting Started | Bootstrap needs a TTY and never returns; not documented | | 13 | managing-a-node | FID 0 needs `SKIP_CRONTAB=true` (now fixed in the docs) | | 14 | managing-a-node | Grafana 3000 published on all interfaces with `admin:admin` (mostly fixed) | | 16 | Getting Started | No mention of provider quotas; Hetzner new accounts cap volumes at 1024 GB | | 17 | Getting Started | Snapshot restore keeps chunks + tar + DB until the end; explains the 1.5 to 2 TB figure but no doc says so | | 29 | writing-messages | Imports from `@farcaster/hub-web` with gRPC `hub-nodejs` code (likely; snippet not run) | | 31 | several | Leftover "Hub"/"Hubble" wording; logo alt text copied from the firewall image | | 32 | [Casts](https://snapchain.farcaster.xyz/reference/httpapi/casts) | `castsByParent` never returns an empty `nextPageToken`: the last page carries `[null,null]` (base64), and sending it starts over ([Recipes](https://sudharshanx.github.io/runfarcaster/recipes/#read-the-replies-to-a-cast)) | | 33 | HTTP API | On endpoints that read every shard (`castsByParent`, `reactionsByCast`, `linksByTargetFid`), `pageSize` applies per shard: `pageSize=2` returned 4 messages | | 34 | [User data](https://snapchain.farcaster.xyz/reference/httpapi/userdata) | `user_data_type` is ignored over HTTP; every field comes back | | 35 | [Reactions](https://snapchain.farcaster.xyz/reference/httpapi/reactions) | `reaction_type` takes `Like`/`Recast`; the enum names the responses use (`REACTION_TYPE_LIKE`) get HTTP 400 | | 36 | (undocumented) | A snapshot replaced during the restore makes the node crash on a 404, then run without that shard | ## docs.farcaster.xyz | # | Page | Issue | |---|---|---| | 10 | [Hello World](https://docs.farcaster.xyz/developers/guides/basics/hello-world) | Adds the app key onchain with ~$10 of OP ETH; gasless `KEY_ADD` replaced that on mainnet with V16 on 2026-05-07 (`src/version/version.rs`; the writing-messages guide was updated in docs PR #989) | | 11 | [Architecture overview](https://docs.farcaster.xyz/learn/architecture/overview) | Lists adding keys as onchain-only; no validators vs read nodes | | 18 | Create an account key | Onchain KeyGateway only | | 19 | Accounts | Keys "managed by the KeyRegistry"; links the deprecated onchain-signers endpoint | | 20 | Messages | "Hub" wording; storage limits and price out of date | | 21 | Get account messages | Uses `apps/hubble`, which no longer exists | | 22 | Apps | Dead Replicator link (now `packages/shuttle`) | | 23 | Signer Requests | Describes onchain registration only (low confidence) | ## hub-monorepo and mirrors | # | Page | Issue | |---|---|---| | 15 | `@farcaster/core` | Needs a `Buffer` polyfill in browsers; ships `@faker-js/faker` as a runtime dependency | | 24 | hub-monorepo README | Describes an `/apps/` folder that does not exist | | 25 | hub-nodejs README | Hubble framing, dead doc links | | 26 | hub-nodejs Builders.md | Documents removed `makeSignerAdd`; omits `makeKeyAdd*` builders | | 27 | hub-web README | Old port 2281, broken axios example, dead link | | 28 | hello-world example | Onchain signer; README names the wrong folder | | 30 | Neynar Snapchain mirror | Still teaches the onchain signer flow | Sources: [SOURCES.md](https://github.com/sudharshanx/runfarcaster/blob/main/SOURCES.md). ## How this guide differs from the official one | Topic | Official guide ([running-a-node](https://snapchain.farcaster.xyz/guides/running-a-node), [Getting Started](https://snapchain.farcaster.xyz/getting-started)) | This guide | |---|---|---| | Server | Set up by hand in the AWS console, with screenshots | `provision.sh` on Hetzner | | Cost | "Roughly $100/month", an estimate | $0.164 an hour and $2.31 until synced (one run, estimated from list prices) | | Time to sync | "A few hours" | About 14 hours on cax31 | | Disk | 2 TB (docs), 1.5 TB (README) | 1000 GB worked with two helper scripts; `node.sh` now installs them (unit-tested only) | | Firewall | Opens 3382/**tcp** and SSH to everyone ([Docs issues #1, #2](https://sudharshanx.github.io/runfarcaster/docs-issues/)) | 3382/udp; SSH only from `SSH_SOURCE_CIDRS`; 3381 and 3383 closed | | Bootstrap | `curl … \| bash`, needs a terminal, keeps running in front of you | The same official script, run by `node.sh`, which then waits for sync | | API keys | Points to a paid API service for speed | None; the app reads from your node | | App keys | hello-world still adds keys onchain with about $10 of OP ETH ([Docs issues #10](https://sudharshanx.github.io/runfarcaster/docs-issues/)) | Gasless `KEY_ADD` (`src/core/validations/key.rs`, live since V16 per `src/version/version.rs`) | | Posting | Older pages send messages to a hosted hub | Your read node sends them on (`submit_message_internal` in `src/network/server.rs`, `ReadNodeMempool` in `src/mempool/mempool.rs`) | | Cleanup | Not covered | `teardown.sh` (not yet run on real servers) | --- # How AI was used > Which parts were written or checked with AI, and which by a person. Check these yourself: - Every measurement on [Proof: what we ran](https://sudharshanx.github.io/runfarcaster/proof/) comes from the live run; the log is `run-log.md` in the repo. The cost is calculated from Hetzner's price list. The test counts come from running the suites on the project's machine. - Every claim about the protocol links to snapchain.farcaster.xyz, docs.farcaster.xyz or the Snapchain source code. `SOURCES.md` lists them. - The pages list what was not done: no like, recast or follow sent on mainnet, `teardown.sh` not run on real servers, no invoice seen.