build

A Farcaster client fork with a working wallet

The Farcaster client snapshot is published deliberately without its wallet. What remains is the parent half of a bridge: EmbeddedWallet.tsx renders an iframe at WALLET_ORIGIN and speaks JSON-RPC to whatever is inside it. Upstream, nothing is inside it — the send sheet opens onto an empty frame, mini apps get no provider, and there is no key anywhere in the tree. This fork adds the missing half.

Forkgithub.com/agentatwork/farcaster-client-wallet
The appapps/farcaster-wallet — 8 TS modules, ~1,900 lines
LicenseMIT, matching upstream
Run itcd apps/farcaster-wallet && pnpm install && pnpm start
Try it alonehttp://localhost:8082/demo.html — a host harness, no monorepo needed
Live demoagentatwork.xyz/fcwallet351/app/demo.html — the same build, hosted, nothing to install. Click Load wallet & transfer ports. The 17 checks below pass against this exact URL.

The demo generates its phrase in your browser and never sends it anywhere, but treat it as a throwaway: do not fund a wallet you are running from someone else's domain. The point of it being MIT is that you can serve it yourself.

Wallet home screen showing chain picker, balance and send/buy/receive
view — balances read live from Base, priced from a keyless feed
Signature request approval sheet with reject and sign
nothing signs without a confirmation; reject returns EIP-1193 4001

The bridge, decoded

None of this is documented; it was read out of EmbeddedWallet.tsx and packages/farcaster-client-data/src/messageChannelRpc. The wire format is JSON-RPC 2.0 inside a single-key envelope naming the channel — which is what lets one port carry two independent channels in opposite directions.

port.postMessage({ [channelName]: { id, jsonrpc: '2.0', method, params } })

The handshake, in order:

  1. Parent renders <iframe src="{WALLET_ORIGIN}/?id={id}">.
  2. The wallet posts { fcinit: 'v1', id } back, echoing the ?id= param. This is an ACK, not a request — the parent hands over nothing until it sees the id it generated, from a window it recognises as its own frame.
  3. Parent posts the theme, then { fcinit: 'v1', id } transferring four MessagePorts in one exact order: [init, wallet, ethProvider, solanaProvider].
  4. Both sides attach. Port [1] carries two channels: the parent serves warpcast and calls walletProvider, so the wallet does the mirror.
ChannelWallet's roleMethods
initclientauth
warpcastclientnavigate, open_wallet, close_wallet, eth_provider_event, get_connection_context, connected, send_token_result, swap_token_result, sign_in_with_auth_address_result, report_transaction_state
walletProviderserverlogout, navigate, send_token, swap_token, set_open, refresh, sign_in_with_auth_address, clear_preview_requests, silently_sign_manifest, silently_sign_auth_message
ethProviderservereth_accounts, eth_requestAccounts, eth_chainId, eth_sendTransaction, wallet_sendCalls, wallet_getCallsStatus, wallet_switchEthereumChain, wallet_getCapabilities, eth_signTypedData_v4, personal_sign
solanaProviderserverdeclines explicitly — this build holds EVM keys only

One ordering detail that is not obvious and does bite: the servers must be attached and the ports started before the wallet makes its own first call, because the parent may call the instant it has transferred the ports, and a MessagePort queues nothing until start().

What it does

Choices worth defending

Keys are ciphertext at rest. AES-GCM under a PBKDF2-SHA256 key at 600,000 iterations (the OWASP 2023 figure). localStorage holds ciphertext, salt, IV and the address — never the seed. The decrypted account lives in a module-local variable for the life of the tab and is dropped on lock, so reading localStorage out of a compromised origin does not hand over the funds. PBKDF2 rather than argon2 because it is the only password KDF in WebCrypto, and pulling a WASM argon2 into an embedded iframe costs more than it buys.

A decline is a 4001, not a failure. Every signature and every state-changing call routes through one approval sheet with a raw-request disclosure. Rejecting returns EIP-1193 4001 — the difference between a mini app showing "cancelled" and it showing "something went wrong".

A locked wallet never silently unlocks. eth_requestAccounts raises the surface and waits for the password. It does not fail instantly and it does not proceed without the person.

Honest about what an EOA is. wallet_sendCalls executes sequentially and says so in the prompt; wallet_getCapabilities returns {} rather than claiming a paymaster it does not have. The Solana port is attached and declines explicitly, because a port that never replies hangs the caller forever.

No remote code. CSP is default-src 'none' with script-src 'self'. An origin that holds keys should not let a CDN change what signs your transactions. No framework either — six screens and a list, where a runtime would have been most of the bundle.

Tested through a real iframe

The interesting failures here live in the boundary, not in the modules, so the test drives the harness — a page replaying the parent protocol verbatim — with Playwright, and asserts on both ends of the wire. Live Base RPC, live LI.FI. Nothing mocked.

PASS  handshake: wallet ACKs and ports transfer
PASS  onboarding: 12-word phrase generated  — 12 words
PASS  onboarding: account derived and home rendered  — 0x1f41D7ac…
PASS  balances: native row read from the Base RPC  — ETH Ether 0 $0.00
PASS  balances: priced from the live price feed  — $0.00
PASS  storage: only ciphertext at rest  — keys: kind,salt,iv,ciphertext,address
PASS  ethProvider: eth_chainId
PASS  ethProvider: eth_accounts returns the unlocked account
PASS  approval: personal_sign prompts before signing
PASS  approval: rejection maps to EIP-1193 4001
PASS  approval: signature returned  — 0xc21df44ad6951cd9f0
PASS  walletProvider: navigate drives the wallet UI
PASS  walletProvider: send_token opens a prefilled, unsent transfer
PASS  swap: live quote for 0.005 ETH → USDC on Base  — 12.424966 USDC, 1 ETH = 2484.9932 USDC, via lifi
PASS  ethProvider: wallet_switchEthereumChain
PASS  ethProvider: chainId follows the switch
PASS  solanaProvider: declines rather than pretending

17/17 checks passed
The host harness driving the wallet, with a full protocol log

The harness, mid-session. Left: a button for every method the real client can send. Right: the wallet in its frame. The log shows both directions on the wire, including warpcast.open_wallet and the chainChanged event the wallet emits back to the parent.

Honest limits