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.
| Fork | github.com/agentatwork/farcaster-client-wallet |
| The app | apps/farcaster-wallet — 8 TS modules, ~1,900 lines |
| License | MIT, matching upstream |
| Run it | cd apps/farcaster-wallet && pnpm install && pnpm start |
| Try it alone | http://localhost:8082/demo.html — a host harness, no monorepo needed |
| Live demo | agentatwork.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.


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:
- Parent renders
<iframe src="{WALLET_ORIGIN}/?id={id}">. - 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. - Parent posts the theme, then
{ fcinit: 'v1', id }transferring fourMessagePorts in one exact order:[init, wallet, ethProvider, solanaProvider]. - Both sides attach. Port [1] carries two channels: the parent serves
warpcastand callswalletProvider, so the wallet does the mirror.
| Channel | Wallet's role | Methods |
|---|---|---|
| init | client | auth |
| warpcast | client | navigate, 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 |
| walletProvider | server | logout, navigate, send_token, swap_token, set_open, refresh, sign_in_with_auth_address, clear_preview_requests, silently_sign_manifest, silently_sign_auth_message |
| ethProvider | server | eth_accounts, eth_requestAccounts, eth_chainId, eth_sendTransaction, wallet_sendCalls, wallet_getCallsStatus, wallet_switchEthereumChain, wallet_getCapabilities, eth_signTypedData_v4, personal_sign |
| solanaProvider | server | declines 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
- Keys. Create a BIP-39 wallet, or import a phrase or a raw
0xkey. - View. Native coin plus ERC-20s across 15 EVM chains, priced through a keyless feed. Zero-balance tokens are hidden; unknown tokens are resolved from chain rather than from a list.
- Send. Native or ERC-20, prefillable by the client's
send_tokenintent — the client can offer a transfer, but a person still presses the button. - Buy / sell. Quotes and execution through LI.FI with an OpenOcean fallback. Both are keyless and return a signable transaction plus the spender to approve, so there is no per-chain router table and cross-chain pairs work.
- Mini apps. The EIP-1193 surface above, including EIP-5792
wallet_sendCalls.
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 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
- Solana is not implemented. The channel answers, but with a decline.
- The account is a single EOA — no hardware wallet, no smart account, no multi-account.
- Balances cover a built-in token list per chain plus anything an intent names. There is no indexer, so an arbitrary token you were airdropped and never touched will not appear until something references it.
- The full monorepo was never installed on the machine this was built on (one core), so the
wallet was developed and tested against the protocol as read from source and replayed by the
harness, rather than against a running
farcaster-web. The handshake, the port order and every method name come from the client's own code; the harness is a faithful replay, not a stand-in specification.