phunk-client

Writing to the network

Add a gasless app key, then post, like and follow through your own node. Step 9 of the tutorial.

On this page

Reading needs nothing. Writing needs two keys and one rule.

  • The custody key owns the account. It is the Ethereum key behind the seed phrase, and the IdRegistry contract maps its address to the fid.
  • An app key (also called a signer) is an Ed25519 key that signs every cast, like and follow. Each app you sign in to holds its own app key. That is how step 7 could tell apps apart.
  • The rule: a node only accepts a message signed by an app key the account has added.

Adding an app key used to cost a transaction on Optimism. Snapchain now accepts gasless app keys: you send a KEY_ADD message to a node, authorized by a signature from the custody key. No gas, no wallet balance, and it lands in about a second. A gasless key is:

  • scoped: it may only sign the message types listed when it was added.
  • short-lived but sliding: it expires after its TTL (at most 90 days), and every message it signs pushes the expiry out again.
  • unreplayable: each KEY_ADD carries a nonce that must beat the fid's currentUserNonce.

The account also needs storage, which it rents on-chain. An account that already posts has it.

Seed phrases are dangerous. Whoever has one owns the account. Try this with a test account, never your main one. Only paste a seed phrase into software from developers you trust (this one is about 500 lines: src/phunk/write.py). Apps that can should ask a wallet to sign the authorization instead, so the phrase never leaves it.

Install the writing extras (Ed25519, BLAKE3 and Ethereum signing):

pip install 'phunk-client[write] @ git+https://github.com/Ramitphi/phunk-client'

Add an app key#

From the command line. The phrase is read hidden, used once in memory, and dropped. Only the new app key is saved, readable by you alone:

phunk --node http://<your-node>:3381 key add

In Python:

import asyncio, getpass
from phunk import Hub
from phunk.write import Custody, add_app_key

async def main():
    custody = Custody.from_mnemonic(getpass.getpass("seed phrase: "))
    async with Hub("http://<your-node>:3381") as hub:
        fid, key = await add_app_key(hub, custody)
    print(fid, key.public_hex)
    # key.private_hex signs from now on. Store it like a password.

asyncio.run(main())

Under the hood, add_app_key:

  1. looks up the fid that the custody address owns (GET /v1/onChainIdRegistryEventByAddress?address=0x...)
  2. reads currentUserNonce (GET /v1/signersByFid?fid=...)
  3. signs two EIP-712 messages with the custody key, both on the domain {name: "Farcaster KeyAdd", version: "1", chainId: 1}: a SignedKeyRequest (which app is asking for the key) and a KeyAdd (the fid, key, scopes, TTL, nonce and deadline)
  4. wraps them in a KEY_ADD message signed by the new key, which proves you hold it, and posts it to /v1/submitMessage

Afterwards the key shows up in GET /v1/signersByFid with "source": "SIGNER_SOURCE_OFFCHAIN".

Post, like, follow#

phunk post "hello from my own node"
phunk post "gm" --channel https://warpcast.com/~/channel/politics
phunk like 3:0x<cast hash>
phunk follow dwr
from phunk.write import AppKey, Writer

async with Hub("http://<your-node>:3381") as hub:
    me = Writer(hub, fid, AppKey.from_hex(private_hex))
    h = await me.cast("hello @dwr")            # "@dwr" becomes a real mention
    await me.cast("same here", reply_to=(3, "0x<cast hash>"))
    await me.like(3, "0x<cast hash>")
    await me.follow(3)

Each call builds one message, signs it and posts it to /v1/submitMessage. You can post to your own read node: read nodes accept writes and forward them to the validators, which put them in a block.

What a message is#

A message is protobuf. MessageData holds the type, fid, timestamp (Farcaster time), network and the body. Its hash is the first 20 bytes of BLAKE3 over those bytes, and the app key signs the hash:

from phunk import proto
from phunk.write import AppKey

key = AppKey.generate()
msg = key.sign(proto.CAST_ADD, 3, proto.CastAddBody(text="just testing").encode())

To check a message without posting it, ask the node to validate it. This checks the encoding, hash and signature only, so it works even for a key nobody added. Nothing is submitted:

async with Hub() as hub:
    print((await hub.validate(msg))["valid"])   # True

phunk's messages match the official TypeScript library byte for byte; the tests check this (tests/test_write.py).

Your feed, from any app#

A node has no feed endpoint. A client builds one: read the latest casts of everyone you follow and merge them. Because every cast says which key signed it, you can include or hide casts by app:

phunk feed                         # who your key's fid follows, last 24h
phunk feed dwr --hide warpcast     # anyone's, minus one app
phunk feed --only fireflyapp,"own key"
casts = await hub.feed(fid)                    # newest first
apps = [await hub.app_of(c) for c in casts]    # who signed each

A feed costs one request per account followed. To bound it, read only the latest follows (reverse=true on linksByFid):

latest = [f.target_fid async for f in hub.following(fid, limit=100, newest_first=True)]
casts = await hub.feed(fid, follows=latest)    # the 100 most recent follows only

"Sent from" your app#

Clients show where a cast came from ("Sent from Warpcast") by reading the key that signed it. Each key's KEY_ADD names the app that asked for it: the requestFid in its SignedKeyRequest metadata, signed by that app account's custody key. Its username is the name clients show.

phunk key add on its own makes the account its own app, so your casts show your own username. To show a client's name instead:

  1. Make an account for the app and give it a username (an fname).
  2. Run a signing service that holds the app account's custody key and approves new keys. signer/ in this repo is one, ready for Vercel. It only ever sees the new public key.
  3. Point key add at it:
phunk key add --app-signer https://<your-signer>/api/sign-key-request

phunk checks each approval before using it: it must name the app's fid, be signed over this exact key, not be expired, and come from the app account's custody address on the node (/v1/onChainEventsByFid?event_type=EVENT_TYPE_ID_REGISTER).

To set the app account's username, add a key to the app account itself (with its own seed phrase, into a separate key file), then:

PHUNK_KEY_FILE=~/.config/phunk/app-account.json phunk profile --username yourapp

When you're done: revoke the key#

You don't have to revoke a key you stop using: its TTL runs out. Revoke it sooner if a laptop is lost, a key file leaks, or you stop using an app. The key stops working at once. Casts it already signed stay up.

See which keys an account has, and which app asked for each:

phunk key list dwr
# 2023-11-07  on-chain  0xc887f5bf...  warpcast
# 2026-01-24  on-chain  0x27386405...  neynar

Revoke the key saved on this machine. The key signs its own removal, so no seed phrase is needed:

phunk key revoke

Revoke some other gasless key on the account, one you no longer hold. This needs the custody key, so it asks for the seed phrase (test account only):

phunk key revoke --key 0x<public key from key list>

Both send a KEY_REMOVE message to /v1/submitMessage:

  • Self (signatureType: 2): signed by the key being removed. Its nonce must beat the nonce of the app that asked for the key: GET /v1/signersByFid?fid=...&requesterFids[0]=<app fid> returns it in requesterFidNonces. Nodes that don't report it yet answer a wrong nonce with "not greater than stored nonce N"; phunk then retries once with N + 1.
  • Custody (signatureType: 1): carries an EIP-712 KeyRemove {fid, key, nonce, deadline} signed by the custody key, on the same domain as KeyAdd. Its nonce must beat currentUserNonce.
from phunk.write import revoke_key, revoke_key_with_custody

async with Hub("http://localhost:3381") as hub:
    await revoke_key(hub, fid, key)                       # the saved AppKey
    await revoke_key_with_custody(hub, custody, "0x...")  # any gasless key

Only gasless keys can be removed this way. On-chain keys, like the ones in the list above, need a transaction to the Key Registry contract.