Skip to content

feat(chat): Solana wallets — connect, sign in, and pay - #24

Merged
VickyXAI merged 2 commits into
masterfrom
feat/solana-wallet
Aug 26, 2026
Merged

feat(chat): Solana wallets — connect, sign in, and pay#24
VickyXAI merged 2 commits into
masterfrom
feat/solana-wallet

Conversation

@VickyXAI

Copy link
Copy Markdown
Contributor

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.ai returned a 402 with a single eip155:8453 entry. But sol.blockrun.ai is a separate deployment hardcoded to its own chain, and it has been serving a Solana paywall the whole time — with extra.feePayer already 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-payment were all tried against blockrun.ai; every one still returned Base), and neither ever offers both chains in one accepts array. So /api/blockrun now picks its upstream per request from x-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:

  • Media jobs pin their chain. Submit-then-poll runs for up to five minutes across many renders; a wallet switch mid-flight must not send a poll to a gateway that never saw the job.
  • Price labels follow the signature. Both the "$0.0013" label and the signer now read the offer selected by network, instead of one reading accepts[0] while the other selected properly.

Paying

src/lib/solana-x402.ts builds the SPL transfer the facilitator expects, ported from createSolanaPaymentPayload() in blockrun-llm-ts/src/x402.ts — same instruction order, same envelope. Keep the two in step. Two deliberate differences:

  • The blockhash comes from the 402, not an RPC round trip. BlockRun stamps a finalized one in for exactly this reason; a confirmed hash races the facilitator's own RPC pool and settles as BlockhashNotFound.
  • The fee-nonce guard is kept. Two same-priced payments on one blockhash compile to byte-identical messages, ed25519 is deterministic, and Solana rejects the second as already-processed. Two same-priced calls in a row is an ordinary chat pattern.

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/rpc forwards to it.

It is its own route rather than an entry in the /api/blockrun path allowlist on purpose: that proxy defaults to the Base host, where /v1/solana/rpc is 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 build all clean.
  • Proxy chain selection, incl. fallback on an unknown header value.
  • Free-model anonymous path unchanged; /chat renders with no console errors.
  • Network chooser and both connect branches driven in a real browser.
  • Payment envelope against production, using a throwaway keypair: the facilitator parsed the envelope, decoded the transaction and extracted the payer, failing only on insufficient_funds. Format accepted end to end.

Still needs a human

  • A real payment from Phantom with USDC and zero SOL, proving the facilitator pays gas.
  • Base regression — this refactors seven wagmi call sites behind useWallet(), which is where a regression would hide.
  • Cross-chain history isolation: sign in on Base, save a chat, switch to Phantom, confirm the history is empty and comes back on switching return.

Notes

  • Pricing differs by a flat $0.001/call in Solana's favour: Base adds TRANSACTION_FEE_USD = 0.001, Solana zeroed its service fee ("PayAI is the fee payer"). Same catalog underneath.
  • Declares @solana/wallet-standard-chains and @solana/wallet-standard-features, which were imported directly but only resolved transitively.
  • Drops the research .mcp.json and rewrites the design note around what is actually true.
  • Left alone deliberately: use-phone-call.ts's paidPost still doesn't call onSpend, so $5 number purchases stay missing from the usage ledger (pre-existing). npm run lint reports ~1584 errors from the stale .worktrees/ copy, which is gitignored but not eslint-ignored (also pre-existing) — npx eslint src is clean.

franklin.run is on Cloud Run, so merging does not deploy. npm run deploy after.

spacemandev-git and others added 2 commits August 25, 2026 21:44
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.
@VickyXAI
VickyXAI merged commit 390f08d into master Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants