Skip to content

Check each Farcaster idea with a command against your own node.

Each idea below comes with a command to run against the node and app from the Full run.

Reading needs no account:

Posting needs an account and a key:

  • FIDs and custody addresses: the account number and the address that owns it.
  • App keys: the key the app signs with, and how it gets added without gas.
  • Storage rent: the space an account pays for before it can post.
  • Usernames: fnames and ENS names, and how they map to an FID.

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 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 has its three signatures and the onchain way it replaced.

Your browseron your laptop or phone
Your server
Your appserved over HTTPS by Caddy
Your read nodea full copy of every message
Validatorsthe Farcaster network; they make the blocks
A validator puts the cast in a block. Your node copies that block like any other, and the cast shows up in your feed.

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.

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:

Terminal window
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:

Terminal window
ssh -N -L 3381:127.0.0.1:3381 root@$IP

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, and the V16 date and GaslessSigners feature in src/version/version.rs.

A KEY_ADD holds three signatures:

Signed byWhatWhy
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 keyThe message envelopeShows 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).
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).

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.

Show prompt
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.

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, 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:

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):

  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.

Show prompt
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.
PartWhereWhat
AccountsContracts on OP Mainnet (IdRegistry, KeyRegistry, StorageRegistry)Account numbers (FIDs), owner addresses, storage, keys added onchain
MessagesSnapchainCasts, likes and recasts, follows, profile fields, verifications, gasless KEY_ADD
AppsAnywhereApps 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.

  • 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.

An FID is a number. The @name people see is a separate username, and it comes in two kinds (docs: Usernames, ENS names):

KindIssued byCostWho controls it
Offchain name (fname), like @aliceThe Fname Registry, a server run by the Farcaster team, as alice.fcast.idFreeFarcaster can reclaim it, for example after 60 days unused. One per account; it can change once every 28 days.
Onchain ENS name, like @alice.ethENS, on Ethereum L1A registration feeYour 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.

Terminal window
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).

Messages take space on every node, so an account pays rent for it (docs: Messages, 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). Your node returns the current limits, how much is used and how many units the account has:

Terminal window
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.

Show prompt
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.
QuestionValidatorYour read node
Makes blocks and votes on themYesNo
Has every messageYesYes, downloaded from validators
Takes submitMessageYes, into its mempoolYes, and sends it to validators
RewardsNot covered hereNone
DelayUp to dateA little behind, by the time it takes to fetch each block (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.

Terminal window
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). 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.

The node answers HTTP on port 3381. It has no “home feed” endpoint, so the app builds one (client/src/feed.ts):

Terminal window
# 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.

Show prompt
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

Section titled “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):

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.

The app’s filters work on casts made in any app, because they run on what your node returns (client/src/filters.ts).

RuleEffect
Mute FID, mute keywordHide casts that match
Only show FIDs, only show keywordsWhen 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.