feat(chat): Solana wallets — connect, sign in, and pay - #24
Merged
Conversation
Adds Wallet Standard (Phantom/Solflare/Backpack) connect + Sign In With Solana to the /chat playground, next to the existing wagmi/viem EVM path. API shapes were checked against the Solana MCP server (mcp.solana.com) and the installed .d.ts, not from memory. Stack: @solana/kit@8 + @solana/react@8 + @solana/kit-plugin-wallet@0.18 + @solana/kit-plugin-rpc. Not wallet-adapter (legacy per Kit's own migration guide) and not @solana/client/@solana/react-hooks (pinned to kit ^5). @solana/react peer-deps @tanstack/react-query@^5, already used here, so one QueryClient serves both chains. kit-plugin-wallet peers react ^19.2.8, so react/react-dom move 19.2.4 -> 19.2.8. One wallet facade, everything else chain-blind: - src/hooks/use-wallet.ts splits `canPay` (can settle an x402 invoice) from `isConnected` (a wallet is attached). The seven direct wagmi call sites now read the facade; paid gates moved to `canPay`. - BlockRun's 402 offers a single eip155:8453 requirement today, so Solana is identity + balance only. CAN_PAY_CHAINS is the single place to flip when a `solana:` accept appears upstream. - use-x402-payment selects the requirement by the connected chain's CAIP-2 prefix instead of taking accepts[0], which is a coin toss the moment a second entry exists. Sign-in: - src/lib/siws.ts verifies SIWS server-side. Deliberately does not use the library's verifySignIn(input, output) — that needs the caller to reproduce the exact input the wallet was given. Instead it parses what was signed, asserts domain/address/nonce + issuedAt freshness, then requires the bytes to round-trip canonically before the ed25519 check. The public key is derived from the claimed address, never taken from the client. - The SIWS domain is pinned (SIWS_DOMAIN / franklin.run) rather than read off the request Host, so a signature farmed on another origin can't be replayed. - /api/try/auth/verify takes a `chain` discriminator, defaulting to "evm" so the existing client keeps working unchanged. Session storage: - session.ts carries `chain` and exposes a `storageKey`. EVM keys are byte-identical to before (no history orphaned) and legacy tokens without a `chain` field still verify as EVM. Solana hex-encodes the 32-byte pubkey, because franklin-store's safeWallet() lowercases and would otherwise merge two base58 addresses that differ only in case into one namespace. Verified: lint + typecheck + build clean; SIWS accepts a valid proof and rejects wrong nonce / wrong domain / stale issuedAt / foreign signer / address swap; end-to-end sign-in against the dev server mints a session the conversations route accepts, and a replayed proof is rejected; session tokens round-trip on both chains with 500/500 distinct Solana storage keys. Not verified: the connect UI's network picker was not exercised in a browser — React does not hydrate in this environment's preview browser, on unmodified code as well, so the failure is not specific to this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds on the Solana connect work by making those wallets actually useful: a Phantom / Solflare / Backpack user can now buy generations, media, tools and phone numbers, not just hold an identity. The premise that blocked this was wrong. BlockRun's Solana paywall has been live the whole time on sol.blockrun.ai — a separate deployment from blockrun.ai, hardcoded to its own chain. Probing blockrun.ai and finding only eip155:8453 said nothing about it. Its 402 already carries a facilitator feePayer, so payment is gasless: the user needs no SOL. Routing. The host IS the chain — no header or query param switches it on either gateway, and neither ever offers both chains in one accepts array. So /api/blockrun picks its upstream per request from x-blockrun-chain, set client-side from the connected wallet and consumed by the proxy rather than forwarded. Unknown or missing falls back to Base, so a stale bundle behaves exactly as before. Media jobs pin their chain for the whole submit-then-poll run: a wallet switch mid-flight must not send a poll to a gateway that never saw the job. Price labels now read the offer that will actually be signed instead of accepts[0], so the quote and the signature can't disagree. Paying. solana-x402.ts signs the SPL transfer the facilitator expects, ported from createSolanaPaymentPayload() in blockrun-llm-ts — same instruction order, same envelope. Two deliberate differences: the blockhash comes from the 402 rather than an RPC round trip (BlockRun stamps a finalized one in for exactly this), and the fee-nonce guard is kept, because two same-priced payments on one blockhash compile to identical bytes and Solana rejects the second as already-processed. Balance reads go through a new same-origin /api/solana/rpc. BlockRun's RPC is free and keyless but serves no CORS, and a JSON-RPC POST is always preflighted, so the browser could not call it directly. Its own route rather than an entry in the /api/blockrun allowlist: that proxy defaults to the Base host, where /v1/solana/rpc is a paid endpoint, and billing someone for reading their own balance is not a thing to leave one config change away. Verified against production with a throwaway keypair: the facilitator parsed the envelope, decoded the transaction and extracted the payer, failing only on insufficient_funds. Also: declares two deps that only resolved transitively, drops the research .mcp.json, and rewrites the design note around what is true.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets a Phantom / Solflare / Backpack user do everything a Base user can: connect, sign in, see their USDC balance, and pay for generations, media, tools and phone numbers.
Two commits: the first is @spacemandev-git's connect/SIWS/balance work, cherry-picked from his fork. The second adds payments.
The premise that blocked this was wrong
The original design note concluded Solana payments were blocked upstream, because a probe of
blockrun.aireturned a 402 with a singleeip155:8453entry. Butsol.blockrun.aiis a separate deployment hardcoded to its own chain, and it has been serving a Solana paywall the whole time — withextra.feePayeralready set, so the user needs no SOL for gas.Routing: the host is the chain
Neither gateway switches chains on a header or query param (
?network=solana,x-payment-network,x-blockrun-chain,accept-paymentwere all tried againstblockrun.ai; every one still returned Base), and neither ever offers both chains in oneacceptsarray. So/api/blockrunnow picks its upstream per request fromx-blockrun-chain, set client-side from the connected wallet and consumed by the proxy rather than forwarded. Anything unrecognised — including a missing header from a cached older bundle — falls back to Base, so existing clients behave exactly as before.Two things that needed care:
accepts[0]while the other selected properly.Paying
src/lib/solana-x402.tsbuilds the SPL transfer the facilitator expects, ported fromcreateSolanaPaymentPayload()inblockrun-llm-ts/src/x402.ts— same instruction order, same envelope. Keep the two in step. Two deliberate differences:confirmedhash races the facilitator's own RPC pool and settles asBlockhashNotFound.A new same-origin RPC proxy
BlockRun's Solana RPC is free and keyless but serves no CORS, and a JSON-RPC POST is always preflighted — so the browser could not read a balance at all.
/api/solana/rpcforwards to it.It is its own route rather than an entry in the
/api/blockrunpath allowlist on purpose: that proxy defaults to the Base host, where/v1/solana/rpcis a paid endpoint ($0.0005/call). Billing someone for looking at their own balance should not be one config change away.Verified
npm run typecheck,npx eslint src,npm run buildall clean./chatrenders with no console errors.insufficient_funds. Format accepted end to end.Still needs a human
useWallet(), which is where a regression would hide.Notes
TRANSACTION_FEE_USD = 0.001, Solana zeroed its service fee ("PayAI is the fee payer"). Same catalog underneath.@solana/wallet-standard-chainsand@solana/wallet-standard-features, which were imported directly but only resolved transitively..mcp.jsonand rewrites the design note around what is actually true.use-phone-call.ts'spaidPoststill doesn't callonSpend, so $5 number purchases stay missing from the usage ledger (pre-existing).npm run lintreports ~1584 errors from the stale.worktrees/copy, which is gitignored but not eslint-ignored (also pre-existing) —npx eslint srcis clean.franklin.run is on Cloud Run, so merging does not deploy.
npm run deployafter.