# 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

<PartsDiagram />

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://<name>
```

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=<your-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=<nextPageToken>"
```

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://<ip>: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.
