Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Farcaster / Warpcast (wrapcast)

Farcaster is a decentralized social protocol; Warpcast is its best-known client. Rustiz publishes through Neynar, which manages the Farcaster signer. The connect flow is not OAuth: the server creates a Neynar signer, signs a SignedKeyRequest with the app’s custody mnemonic, shows the human an approval link/QR code, and polls until the signer is approved in the Farcaster app.

  • Rust source: crates/rustiz-providers/src/farcaster.rs
  • Upstream port: farcaster.provider.ts
  • Editor: Normal
  • Connection mode: signer
  • Identifier is wrapcast, not farcaster.

Overview

PropertyValue
Identifierwrapcast
Display nameFarcaster
Connection modesigner (server-created Neynar signer, human approval)
Editornormal
Channel kindsocial
Comments / threadsAdvertised as no: supports_comments is left false, even though comment() is implemented. The engine truncates extra thread entries and MCP validation reports “does not support thread replies”.
Pending / async publishingNo. supports_pending = false; post() calls Neynar and returns Posted
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)800 characters, hard-coded
Web3 flagis_web3 = true
ScopesNone
Redirect URINone; the approval URL is a farcaster:// deep link

Server Environment Variables

required_env() returns four variables:

#![allow(unused)]
fn main() {
&[
    "NEYNAR_CLIENT_ID",
    "NEYNAR_APP_FID",
    "NEYNAR_APP_MNEMONIC",
    "NEYNAR_SECRET_KEY",
]
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
NEYNAR_CLIENT_IDYes—Neynar app client id. Only used by the legacy generate_auth_url() (client id, two vertical bars, state) and the readiness check; the headless signer flow does not use it.
NEYNAR_APP_FIDYes—Farcaster FID of the app account whose custody mnemonic signs the signer request. Parsed as i64; a non-number is a configuration error.
NEYNAR_APP_MNEMONICYes—BIP-39 mnemonic of the app account’s custody address. Signs the EIP-712 SignedKeyRequest. Secret.
NEYNAR_SECRET_KEYYes—Neynar API key, sent in the api_key header by the Rust code
NEYNAR_BASE_URLNohttps://api.neynar.comNeynar API origin; trailing slash trimmed
NEYNAR_SPONSOR_SIGNERSNounsetWhen exactly "true", adds sponsor: {sponsored_by_neynar: true} to the signed-key registration. Neynar charges the developer’s credits.
JWT_SECRETNo direct useemptyNot read by the Farcaster flow. The signer uuid is stored as the integration token; the pending {signerUuid, approvalUrl} row is plaintext. Listed only because the rest of the server needs it.

rustiz providers --status requires all four variables and reports wrapcast as missing until they are set. NEYNAR_CLIENT_ID is required by that check even though the headless flow does not use it; set any non-empty value if your Neynar app does not expose one (for example, the client id shown in the dashboard).

The fallback API key inside api_key() is the placeholder 00000000-000-0000-000-000000000000. It is only used when NEYNAR_SECRET_KEY is unset, which provider_status() normally prevents.

Step-by-Step Developer App Setup

1. Create a Neynar account and app

  1. Sign in at https://dev.neynar.com.
  2. Create an app. Neynar returns a Client ID and a Secret Key. The secret key is the API key used for every /v2/farcaster/... call.
  3. Note the app’s FID and the custody mnemonic of the Farcaster account used as the app. Neynar’s app settings expose the FID; the mnemonic is the seed phrase of that FID’s custody address. If the FID was created in another wallet, use that wallet’s mnemonic instead. The mnemonic is used only to sign signer requests; it is not a user mnemonic.
  4. Put the values in the environment:
NEYNAR_CLIENT_ID="…"
NEYNAR_APP_FID="12345"
NEYNAR_APP_MNEMONIC="word word word …"
NEYNAR_SECRET_KEY="…"
  1. Restart rustiz serve, rustiz worker and rustiz mcp; they read the configuration at startup.

2. What the app FID and mnemonic are for

Farcaster requires an app to prove it controls an FID before a signer can be registered for a user. Rustiz derives the app’s Ethereum key from the mnemonic at m/44'/60'/0'/0/0 (BIP-39 seed, empty passphrase, BIP-32) and signs the EIP-712 payload:

Domain:
  name:              "Farcaster SignedKeyRequestValidator"
  version:           "1"
  chainId:           10
  verifyingContract: 0x00000000FC700472606eD4Fa22623AcF62c60553

Type:
  SignedKeyRequest(uint256 requestFid, bytes key, uint256 deadline)

The signature is 65 bytes (r, s, v+27), 0x-prefixed. If the mnemonic is wrong, Neynar rejects the signed-key registration with a 4xx; the connect call fails before a human sees an approval link.

The implementation is a small self-contained BIP-39/BIP-32 + secp256k1 + Keccak-256 signer; it does not use viem or an Ethereum SDK.

3. Redirect URI

There is no redirect URI. start_farcaster() in crates/rustiz-server/src/connect.rs never calls generate_auth_url(). The human approval is a deep link into the Farcaster app:

farcaster://signed-key-request?token=0x…

The provider also renders the link as a PNG QR data URL. No domain, no FRONTEND_URL callback and no redirectmeto.com are involved.

4. Scopes

Farcaster/Neynar does not use OAuth scopes here. info.scopes is empty. check_scopes() is never called. Access is controlled by the Neynar API key and the approved signer’s permissions.

5. Sandbox / testing vs production

There is no Farcaster “sandbox” and no app review. The gates are:

  • Signer approval. Every user must approve the signer once in the Farcaster app. That is an onchain transaction; Neynar charges for it (or the developer sponsors it with NEYNAR_SPONSOR_SIGNERS=true and pays in credits). Neynar’s own documentation warns: “While testing please reuse the signer, it costs money to approve a signer.”
  • Neynar plan/credits. Read and write endpoints are metered by the Neynar plan. A free/dev plan is enough to test with a few accounts; production volume needs a paid plan.
  • App FID. A production app should use a dedicated Farcaster account/FID for the app, not a personal account, because the custody mnemonic signs every signer request.

For tests:

  1. Connect once with a test Farcaster account and keep the resulting channel.
  2. Do not delete/reconnect repeatedly: each connect attempt creates a new signer and can cost another approval.
  3. If you already have an approved Neynar signer, skip signer creation with customFields.signerUuid (see below).

Connecting a Channel (CLI & MCP)

Flow A — create a new signer (normal)

CLI

rustiz channels connect wrapcast

The command prints a JSON document (stdout) with:

{
  "connected": false,
  "pending": true,
  "connectionId": "fc_…",
  "approvalUrl": "farcaster://signed-key-request?token=0x…",
  "qrCode": "data:image/png;base64,…",
  "message": "Give this link (or QR code) to the human: …"
}

The CLI does not wait for the approval, because there is no browser callback. Give the approvalUrl or qrCode to the human, then poll:

rustiz channels connect-status fc_…

The connection id lives for 30 minutes. Polling does not extend the TTL; if it expires, start again (and pay for a new signer).

MCP connectChannelTool

{ "provider": "wrapcast" }

Returns the same connectionId, approvalUrl and qrCode. Poll with:

{ "connectionId": "fc_…" }

integrationSchema reports:

{
  "type": "signer",
  "headless": true,
  "requiresHuman": true,
  "requires": [
    "NEYNAR_CLIENT_ID",
    "NEYNAR_APP_FID",
    "NEYNAR_APP_MNEMONIC",
    "NEYNAR_SECRET_KEY"
  ]
}

Flow B — reuse an approved signer (customFields.signerUuid)

If a Neynar signer was already created and approved (for example, by another Neynar app using the same API key), pass its uuid:

{
  "provider": "wrapcast",
  "customFields": {
    "signerUuid": "19d0c5fd-9b33-4a48-a0e2-bc7b0555baec"
  }
}

CLI:

printf '%s' '{"signerUuid":"19d0c5fd-9b33-4a48-a0e2-bc7b0555baec"}' \
  | rustiz channels connect wrapcast --fields -

The server:

  1. Requires NEYNAR_SECRET_KEY.
  2. Calls GET /v2/farcaster/signer?signer_uuid=….
  3. If the status is approved and the response has an fid, builds the same base64 user payload and connects immediately.
  4. If the signer is not approved, returns an actionable error such as That Farcaster signer is not approved yet (status: pending_approval). Open its approval link in the Farcaster app first.

This path only checks NEYNAR_SECRET_KEY. It does not need NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC or NEYNAR_CLIENT_ID at runtime. The normal signer-creation path calls provider_status() and requires all four, so in practice set all four.

Signer approval details

The server’s sequence (Farcaster::create_signer()):

  1. POST /v2/farcaster/signer with {} → signer_uuid, public_key, status: generated.
  2. deadline = now + 24 hours.
  3. Sign the EIP-712 SignedKeyRequest with the app mnemonic.
  4. POST /v2/farcaster/signer/signed_key with:
{
  "signer_uuid": "…",
  "app_fid": 12345,
  "deadline": 1760000000,
  "signature": "0x…",
  "sponsor": { "sponsored_by_neynar": true }
}

sponsor is present only when NEYNAR_SPONSOR_SIGNERS=true.

  1. Read signer_approval_url and signer_uuid; build qrCode = qr_data_url(approval_url) (PNG data URL, 4 px modules, 4-module margin).
  2. Store {signerUuid, approvalUrl} in the oauth_states row as code_verifier, provider="wrapcast", TTL 30 minutes.

When the human approves in the Farcaster app, the server polls:

  1. GET /v2/farcaster/signer?signer_uuid=….
  2. If status != "approved", return pending (or an error if revoked).
  3. If approved, read fid.
  4. GET /v2/farcaster/user/bulk?fids=<fid> to get username, display_name, pfp_url.
  5. Build a base64 JSON payload:
{
  "signer_uuid": "…",
  "fid": 12345,
  "username": "alice",
  "display_name": "Alice",
  "pfp_url": "https://…"
}
  1. authenticate() decodes it and stores:
    • id = FID
    • access_token = signer_uuid
    • refresh_token = ""
    • expires_in = 200 years
    • username = Farcaster username
    • name = display name
    • picture = pfp URL

The signer uuid is the credential used for every cast. There is no token refresh.

Do not send customFields without signerUuid

connect.rs treats any customFields object as the credential path. For wrapcast, the object must contain signerUuid (or signer_uuid); otherwise the flow returns Farcaster customFields needs signerUuid of an already-approved Neynar signer.

Post Settings & Media Validation

settings_schema()

The field is named subreddit for historical reasons (copied from Reddit), but it holds Farcaster channels:

{
  "type": "object",
  "properties": {
    "subreddit": {
      "type": "array",
      "description": "Farcaster channels to cast into (from the subreddits tool). Leave empty to cast on the user's timeline.",
      "items": {
        "type": "object",
        "properties": {
          "value": {
            "type": "object",
            "properties": {
              "id": { "type": "string", "description": "Channel id." }
            },
            "required": ["id"],
            "additionalProperties": true
          }
        },
        "required": ["value"],
        "additionalProperties": true
      }
    }
  },
  "additionalProperties": true
}

There is no required at the top level. subreddit may be omitted, null or an empty array; all three mean “post to the user’s timeline”.

validate_settings() rules

  1. If subreddit is present and not null, it must be an array.
  2. Each entry must be an object.
  3. Each entry must have a value that is an object.
  4. value.id must be a string.
  5. Extra properties are allowed at every level.

It does not check that the channel exists, that the id is non-empty, or that the user can post in it. Neynar returns the error at publish time.

check_validity()

Only one rule:

#![allow(unused)]
fn main() {
if posts.iter().any(|items| {
    items.iter().any(|item| item.path.contains("mp4"))
}) {
    return Ok(Some("Can only accept images".into()));
}
}
  • Any media path containing mp4 (case-sensitive) rejects the whole post.
  • No check on image count, size, format or URL reachability.
  • The check runs for every entry in the thread, even though threads are effectively disabled (supports_comments = false).

Media requirements

Farcaster casts support up to 2 embeds, each a URL up to 256 bytes. Rustiz does not upload media: it maps every MediaContent.path to:

{ "url": "https://cdn.example/image.png" }

and sends them in embeds to POST /v2/farcaster/cast. Consequences:

  • The URL must already be publicly reachable; a local file path will not work.
  • Rustiz does not cap embeds at 2. Three or more media items produces a request Neynar/Farcaster rejects.
  • Alt text, thumbnails and video are ignored.
  • Images are the only supported type; check_validity() blocks .mp4, but a .MP4 (uppercase) bypasses it and is sent as a URL embed.
  • A cast can have text plus embeds; an empty text with embeds may be accepted, but Rustiz always sends text (possibly empty).

Channels

  • cast_ids() reads posts.first().settings["subreddit"].
  • null, a non-array, or an empty array → one cast with no channel_id (timeline).
  • A non-empty array → one cast per value.id, each with channel_id.
  • post_id and release_url are comma-joined across channels. The engine stores the whole string; comment() splits it on commas to reply to every cast.
  • If one channel cast fails, the whole post() returns an error. Casts already published to earlier channels remain live; there is no rollback.
  • The same applies to replies: comment() sends one reply per parent id and stops at the first failure.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

functions() declares one function:

FunctionParamsReturnsNotes
subreddits{ "word": "search term" }[{"title":"…","name":"…","id":"…"}]GET /v2/farcaster/channel/search?q=<word>&limit=10, maps Neynar channels

MCP:

{
  "integrationId": "int_…",
  "methodName": "subreddits",
  "dataSchema": [{ "key": "word", "value": "developers" }]
}

Use the returned id as subreddit[].value.id. There is no channelSetupTool: is_between_steps is false, so the channel is selected per post, not at connect time.

Implementation Notes & Gotchas

  • Threads are implemented but not advertised. comment() is a full implementation (reply to each parent cast with optional embeds), yet info.supports_comments is never set to true. The engine truncates a multi-entry post to the first entry, and MCP validatePostTool reports “Farcaster does not support thread replies”. If you need threads, set info.supports_comments = true in Farcaster::new(); the code path already exists.
  • post() only reads the first PostDetails. Even if called directly with several entries, it publishes one cast per configured channel for the first entry and ignores the rest. The engine truncates the chain first because supports_comments = false.
  • NEYNAR_CLIENT_ID is dead weight in the headless flow. It is required by provider_status() and only used by generate_auth_url(), which returns "{client_id}||{state}" — not a URL and never called by connect.rs. Set it, but do not expect it to affect signer creation.
  • Neynar header mismatch. The Rust code sends the API key as api_key. Current Neynar OpenAPI documents the security scheme as x-api-key. Neynar has historically accepted api_key; if every call returns 401, change the header to x-api-key in neynar_get() / neynar_post().
  • Trailing slashes. The code uses /v2/farcaster/signer and /v2/farcaster/signer/signed_key without a trailing slash. Neynar’s docs show /v2/farcaster/signer/ and /v2/farcaster/signer/signed_key/. Most gateways normalize this; a 404 on signer creation is the symptom.
  • Signer creation is costly and not deduplicated. Every connect attempt calls POST /v2/farcaster/signer and registers a new signed key. There is no lookup of an existing signer before creating one. Reconnecting a channel therefore costs another approval unless you use customFields.signerUuid. Neynar explicitly warns to reuse signers while testing.
  • Approval TTL is 30 minutes and is not refreshed. poll_farcaster() does not call put_pending() again, unlike Telegram. If the human misses the window, the state row is gone and a retry starts a new signer.
  • create_signer() does not validate Neynar’s response. It reads signer_uuid and signer_approval_url with unwrap_or_default(). A malformed/partial response produces a pending connection with an empty approval URL (and a QR code for an empty string) or an empty signer uuid that can never be polled. Check the raw Neynar response if the first connect call looks wrong.
  • signer_status() can get stuck. It only returns an approval code when status == "approved" and the response contains fid. If Neynar reports approved without fid (rare, e.g. partial indexing), the server keeps returning {status:"approved", code:null} and the connect poll never completes. Restarting the connection is the only recovery.
  • Revoked signers. poll_farcaster() detects status == "revoked", deletes the pending state and returns an actionable error. The customFields shortcut does not special-case revoked; it says “not approved yet”.
  • Rate limits are plan-based. Neynar’s published tiers are roughly: Free (limited), Starter 300 RPM / 5 RPS per endpoint with a 500 RPM global cap, Growth 600 RPM / 10 RPS with 1,000 RPM global, Scale 1,200 RPM / 20 RPS with 2,000 RPM global. max_concurrent_job = 3 is conservative but does not reserve credits. Signer writes and active signers consume credits on top of request limits.
  • No signer refresh. refresh_token() returns an empty token with expires_in: 0; the signer uuid is the long-lived credential. If the user revokes the signer in the Farcaster app, the next cast fails with Neynar’s error and the channel must be reconnected with a new signer (or a different approved signerUuid).
  • No idempotency. POST /v2/farcaster/cast is called without Neynar’s idem field. A retry after a timeout can double-cast. The engine’s retry policy is the only protection.
  • 800 characters is a Postiz limit, not a protocol limit. Farcaster casts are limited by bytes (historically 320 bytes, now 1,024 bytes for standard casts), not by a flat 800 characters. The engine counts chars(); a cast of 800 emoji can exceed the byte limit, and a cast of 800 ASCII characters may be fine. There is no byte check.
  • No byte/embed validation. Each embed max 256 bytes and max 2 embeds are Farcaster rules; Rustiz checks neither. A long image URL or a third image fails at Neynar.
  • Media is not uploaded. embeds are URLs, so the media must be hosted publicly before publishing. This differs from Bluesky/Mastodon, where Rustiz uploads bytes.
  • subreddits is a search, not a list. It returns at most 10 channels for the query. There is no “my channels” call. If the user does not know the channel name, they must search for it.
  • is_web3 = true. connect.rs special-cases wrapcast before the generic web3 rejection, so it is supported. Other web3 providers are not.
  • No analytics / mentions. supports_analytics, supports_post_analytics and supports_mentions are all false; there are no overrides.
  • Release URL. cast_urls() builds https://warpcast.com/{author username}/{cast hash} from the Neynar response. If the response omits the author username, the URL contains //; the hash is still the post id.
  • Multiple channels produce comma-joined ids. Any consumer that expects a single cast hash must split on commas. The engine’s comment() already does.

References