How it works
Three commands, three chapters. Each one explains what just happened on your machine.
0 · Compute
npx runfcx provision --host root@YOUR_IP --data-dir /mnt/fcx
You rent one Vultr server with a 1000 GB disk (10 minutes). Its firewall allows SSH from your IP and UDP 3382 from anywhere. Everything else stays closed.
provision logs in with runfcx's own SSH key, checks the CPU, memory and disk, and remembers the server. When you're done, destroy the server and delete its disk in Vultr: a stopped server still bills.
1 · Read node
npx runfcx node
Read node or validator. Validators agree on the order of messages and make blocks. A read node keeps a full copy and follows along. It can't make blocks. It can take your messages and gossip them to the validators.
Shards. The network splits users across shards 1 and 2. Shard 0 holds what everyone shares. Your node keeps all three.
Snapshots. Replaying from block 1 takes weeks. So the node downloads a snapshot first (about 400 GB), unpacks it, then catches up block by block.
$ runfcx status Phase: downloading Shard: 1 chunk 1156/2672 ETA 0h56m Disk: 108 GB used, 790 GB free RAM: 0 GB used
Why 1000 GB is enough. The node's own restore keeps every downloaded chunk and every unzipped archive until all shards are unpacked. On our run one shard took 222 GB of chunks, a 261 GB archive and 261 GB unpacked, so the peak lands near 1.6 TB, and our first try on a 1000 GB disk ran out. runfcx restores the snapshot itself before the node starts. It streams each shard's chunks straight into the database, with no archive on disk, and deletes the chunks once that shard is in. It also picks up where it stopped. Our run finished at 557 GB.
Ports. Only gossip (3382/udp) is public. The API (3381) listens on the server itself. runfcx reaches it through an SSH tunnel.
If a shard starts from block 1. A known bug (snapchain#1044) can skip shard 0's snapshot. runfcx status says so and links the fix.
2 · Client
npx runfcx login --wallet
FID and custody address
Your account is a number, the FID. One Ethereum address owns it: the custody address. Whoever holds that key controls the account.
A test account
Make a second account in the Farcaster app for this. Its recovery phrase is in the app's settings. Use it only here.
Storage
Every account rents storage for its casts, likes and follows. An account without storage can't post. New accounts in the app come with it.
App keys
Apps never get your custody key. They get an app key: a separate key your custody address approves once. runfcx makes one on your laptop and asks your wallet to sign two messages. No gas, no transaction.
The key can only cast, like, recast and follow. It expires 30 days after its last use. runfcx logout revokes it.
Messages
A cast, a like or a follow is a small signed message. Your node checks the signature, then gossips it to the validators. runfcx then watches for it to land in a block, and sends it again if it doesn't.
The feed
Nodes have no home feed. runfcx builds it: who you follow, what each of them posted lately, newest first.
Which app posted it
Every app key records the FID of the app that asked for it. runfcx reads that for each cast, so you can filter:
runfcx clients runfcx feed --only-client 9152 runfcx feed --exclude-client 9152
Build your own client
The code below is copied from runfcx at build time.
Build a feed
const fids = [...new Set([me, ...(await node.following(me))])];
const per = await pool(fids, 8, (fid) => node.castsByFid(fid, { pageSize: o.limit, startTimestamp: Math.max(0, since) }));
// Filter here too: not every node applies startTimestamp.
// castsByFid also returns CAST_REMOVE messages for deleted casts; keep only casts with a body.
const casts = per.flat().filter((c) => c.data.castAddBody && c.data.timestamp >= since).sort((a, b) => b.data.timestamp - a.data.timestamp);
Add an app key
const { privateKey, publicKey } = await newAppKey();
const r = await makeKeyAddBody({ fid: BigInt(fid), key: Buffer.from(publicKey.slice(2), "hex"), scopes: SCOPES,
ttl: KEY_TTL, nonce, deadline: fcNow() + 3600 }, custody);
if (r.isErr()) throw new Error(/reject|denied|4001/i.test(r.error.message) ? "You rejected the request in your wallet. Nothing was added." : r.error.message);
const k: AppKey = { fid, privateKey: bytesToHexString(privateKey)._unsafeUnwrap(), publicKey, requestFid: fid, addedAt: Math.floor(Date.now() / 1000) };
// KEY_ADD is signed by the new key itself: proof we hold it.
const m = await makeKeyAdd(r.value, net(fid), appSigner(k));
if (m.isErr()) throw new Error(m.error.message);
Sign a cast
export async function castWrite(k: AppKey, text: string, o: { replyTo?: CastRef; channel?: string } = {}): Promise<Write> {
const size = Buffer.byteLength(text);
if (size > 320) throw new Error(`Cast is too long (${size} bytes, max 320).`);
const message = await unwrap(makeCastAdd({ text, type: CastType.CAST, embeds: [], embedsDeprecated: [], mentions: [], mentionsPositions: [],
parentCastId: o.replyTo && { fid: o.replyTo.fid, hash: bytes(o.replyTo.hash) }, parentUrl: o.channel }, opts(k), appSigner(k)));
return { what: "cast", message, landed: async (n) => (await n.castById(k.fid, hashOf(message))) !== null };
}
Send it and confirm
for (let t = 1; t <= tries; t++) {
try { await node.submit(bytes); }
catch (e) {
if (!(e instanceof NodeError) || e.status >= 500) throw e;
// On a resubmit, a refusal can just mean the first copy landed meanwhile.
if (!isDuplicate(e)) {
if (t > 1 && await w.landed(node)) return { hash: hashOf(w.message), tries: t };
throw new Error(`Your node rejected the ${w.what}: ${e.message}`);
}
}
for (let waited = 0; waited <= waitMs; waited += pollMs) {
if (await w.landed(node)) return { hash: hashOf(w.message), tries: t };
await sleep(pollMs);
}
}
Find the app behind a cast
export function requestFidOf(s: SignerInfo): number {
if (s.requestFid) return s.requestFid;
const meta = s.onChainEvent?.signerEventBody?.metadata;
if (!meta) return 0;
// Onchain keys carry the same struct, ABI-encoded and base64'd in the JSON.
try {
const [m] = decodeAbiParameters(GASLESS_SIGNED_KEY_REQUEST_METADATA_ABI, `0x${Buffer.from(meta, "base64").toString("hex")}`);
return Number(m.requestFid);
} catch { return 0; } // metadata we can't read: show "unknown app" instead of failing the feed
}
Revoke the key
// Self-revoke: the key signs its own removal. It uses the app nonce of the FID that requested it.
const nonce = ((await node.signersByFid(k.fid, k.requestFid)).requesterFidNonces[String(k.requestFid)] ?? 0) + 1;
const body = makeKeyRemoveBodySelfRevoke({ key: Buffer.from(k.publicKey.slice(2), "hex"), nonce, deadline: fcNow() + 3600 });
if (body.isErr()) throw new Error(body.error.message);
const m = await makeKeyRemove(body.value, net(k.fid), appSigner(k));
if (m.isErr()) throw new Error(m.error.message);
await submitAndConfirm(node, { what: "KEY_REMOVE", message: m.value, landed: async (n) => (await n.signer(k.fid, k.publicKey)) === null }, o.write);