From be247609d6bdf4e4e455d8f4e291bbcf60cd9e8c Mon Sep 17 00:00:00 2001 From: Tom Smith <142233216+tomsmith8@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:58:35 +0100 Subject: [PATCH] Add multi-step top-up flow with QR invoice support Redesign budget modal from single screen to multi-step flow: - Step 1: Balance display with single Top Up button - Step 2: Preset amount tiles (50/100/500/1000) + custom input - Step 3: QR code invoice with copy button and balance polling Sphinx users skip to payment directly. WebLN users pick amount then pay via extension. Manual users (L402 but no wallet) get QR invoice flow via /top_up_lsat with 5-minute polling timeout. Adds qrcode.react, topUpLsat/topUpConfirm API functions, and plan doc for future no-wallet invoice flow via /buy_lsat. --- package-lock.json | 10 + package.json | 1 + plans/invoice-payment-flow.md | 37 ++ src/components/modals/budget-modal.tsx | 463 +++++++++++++++++++------ src/lib/sphinx/index.ts | 3 +- src/lib/sphinx/payment.ts | 36 +- 6 files changed, 436 insertions(+), 114 deletions(-) create mode 100644 plans/invoice-payment-flow.md diff --git a/package-lock.json b/package-lock.json index 40025cf..d9169b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "lsat-js": "^2.0.6", "lucide-react": "^1.7.0", "next": "16.2.2", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.1.2", @@ -8191,6 +8192,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", diff --git a/package.json b/package.json index da6e25a..d299bbb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lsat-js": "^2.0.6", "lucide-react": "^1.7.0", "next": "16.2.2", + "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", "shadcn": "^4.1.2", diff --git a/plans/invoice-payment-flow.md b/plans/invoice-payment-flow.md new file mode 100644 index 0000000..0e3553c --- /dev/null +++ b/plans/invoice-payment-flow.md @@ -0,0 +1,37 @@ +# Invoice Payment Flow (No Wallet, No L402) + +## Problem +Users without Sphinx or a WebLN extension have no way to get an initial L402 token. The current flows all require a browser extension or the Sphinx app to return the payment preimage. + +## Existing Boltwall Endpoints +- `POST /buy_lsat` — returns 402 with `www-authenticate` header containing macaroon + invoice +- `GET /preimage?macaroon=` — returns preimage once invoice is paid +- `POST /top_up_lsat` — generates top-up invoice for existing L402 +- `POST /top_up_confirm` — confirms top-up payment + +## Proposed Flow + +1. User clicks "Top Up" with no wallet detected +2. Frontend calls `POST /buy_lsat` with amount +3. Parse macaroon + invoice from `www-authenticate` header +4. Show invoice as QR code (scannable from any Lightning wallet) +5. Poll `GET /preimage?macaroon=` every 3 seconds +6. Once paid, server returns `{ success: true, preimage: "..." }` +7. Store `L402 macaroon:preimage` in localStorage +8. User is fully authenticated — no extension needed + +## UX Questions (Unresolved) +- Should we show the raw L402 token to the user? They'd need it if they clear localStorage +- Should there be a way to paste/import an L402 token? +- How do we communicate that the token lives in localStorage and is session-tied? +- For CLI/agent users: they manage their own L402s, this flow is humans-only + +## What's Already Built +- Multi-step budget modal with QR code support (qrcode.react installed) +- `topUpLsat()` and `topUpConfirm()` API functions in `src/lib/sphinx/payment.ts` +- Manual top-up flow for users who already have an L402 + +## What's Needed +- Frontend: Add buy_lsat QR flow to the "no wallet, no L402" state in budget modal +- Frontend: Poll `/preimage` endpoint instead of `/balance` for initial purchase +- No backend changes — all endpoints already exist diff --git a/src/components/modals/budget-modal.tsx b/src/components/modals/budget-modal.tsx index 993e935..5afa87b 100644 --- a/src/components/modals/budget-modal.tsx +++ b/src/components/modals/budget-modal.tsx @@ -1,7 +1,8 @@ "use client" -import { useCallback, useState } from "react" -import { Zap } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { Zap, Copy, Check, Loader2, ArrowLeft } from "lucide-react" +import { QRCodeSVG } from "qrcode.react" import { Dialog, DialogContent, @@ -13,66 +14,171 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { useModalStore } from "@/stores/modal-store" import { useUserStore } from "@/stores/user-store" -import { isSphinx, getL402, hasWebLN, payL402 } from "@/lib/sphinx" +import { isSphinx, getL402, hasWebLN, payL402, topUpLsat, topUpConfirm } from "@/lib/sphinx" import { api } from "@/lib/api" +type Step = "balance" | "amount" | "invoice" | "success" + +const PRESET_AMOUNTS = [50, 100, 500, 1000] + export function BudgetModal() { const { activeModal, close } = useModalStore() const { budget, setBudget } = useUserStore() const [loading, setLoading] = useState(false) const [error, setError] = useState("") + const [step, setStep] = useState("balance") + + // Amount & invoice state + const [amount, setAmount] = useState(null) + const [paymentRequest, setPaymentRequest] = useState("") + const [paymentHash, setPaymentHash] = useState("") + const [copied, setCopied] = useState(false) + const intervalRef = useRef | null>(null) + const previousBalanceRef = useRef(0) const sphinxConnected = typeof window !== "undefined" && isSphinx() const weblnAvailable = typeof window !== "undefined" && hasWebLN() + const hasExistingL402 = + typeof window !== "undefined" && !!localStorage.getItem("l402") const formattedBudget = budget !== null && budget !== undefined ? budget.toLocaleString() : "--" - // Top up via Sphinx bridge (L402 flow) - const handleSphinxTopUp = useCallback(async () => { - setLoading(true) + const resetState = useCallback(() => { + if (intervalRef.current) clearInterval(intervalRef.current) + setStep("balance") + setAmount(null) + setPaymentRequest("") + setPaymentHash("") + setCopied(false) setError("") - try { - localStorage.removeItem("l402") - const l402 = await getL402() - if (!l402) { - setError("Payment was not completed.") - return - } - const balance = await api.get<{ balance: number }>("/balance", { - Authorization: l402, - }) - setBudget(balance.balance) - } catch { - setError("Failed to process payment. Try again.") - } finally { - setLoading(false) - } - }, [setBudget]) + setLoading(false) + }, []) - // Top up via WebLN (browser extension like Alby) - const handleWebLNTopUp = useCallback(async () => { - setLoading(true) - setError("") - try { - await payL402(setBudget) + useEffect(() => { + if (activeModal !== "budget") resetState() + }, [activeModal, resetState]) - // Refresh balance after successful payment - const l402 = await getL402() - if (l402) { + useEffect(() => { + return () => { + if (intervalRef.current) clearInterval(intervalRef.current) + } + }, []) + + // Route "Top Up" to the right flow + const handleTopUp = useCallback(async () => { + if (sphinxConnected) { + // Sphinx: trigger directly + setLoading(true) + setError("") + try { + localStorage.removeItem("l402") + const l402 = await getL402() + if (!l402) { + setError("Payment was not completed.") + return + } const balance = await api.get<{ balance: number }>("/balance", { Authorization: l402, }) setBudget(balance.balance) + } catch { + setError("Failed to process payment. Try again.") + } finally { + setLoading(false) + } + return + } + + // WebLN or manual: go to amount step + setStep("amount") + }, [sphinxConnected, setBudget]) + + // Pay with selected amount + const handlePay = useCallback(async () => { + if (!amount || amount < 1 || amount > 10000) { + setError("Enter an amount between 1 and 10,000 sats.") + return + } + + setError("") + setLoading(true) + + if (weblnAvailable) { + // WebLN: pay directly + try { + await payL402(setBudget, amount) + const l402 = await getL402() + if (l402) { + const balance = await api.get<{ balance: number }>("/balance", { + Authorization: l402, + }) + setBudget(balance.balance) + } + setStep("success") + } catch { + setError("Payment was cancelled or failed.") + } finally { + setLoading(false) } + return + } + + // Manual: generate invoice + const stored = localStorage.getItem("l402") + if (!stored) { + setError("No existing L402 token. Connect a wallet first.") + setLoading(false) + return + } + + const { macaroon } = JSON.parse(stored) + + try { + const result = await topUpLsat(macaroon, amount) + setPaymentRequest(result.payment_request) + setPaymentHash(result.payment_hash) + setStep("invoice") + previousBalanceRef.current = budget ?? 0 + + // Poll balance (timeout after 5 minutes) + const l402Token = await getL402() + let attempts = 0 + intervalRef.current = setInterval(async () => { + if (++attempts > 100) { + if (intervalRef.current) clearInterval(intervalRef.current) + setError("Payment not detected. Try again.") + setStep("amount") + return + } + try { + const bal = await api.get<{ balance: number }>("/balance", { + Authorization: l402Token, + }) + if (bal.balance > previousBalanceRef.current) { + if (intervalRef.current) clearInterval(intervalRef.current) + await topUpConfirm(result.payment_hash, macaroon) + setBudget(bal.balance) + setStep("success") + } + } catch { + // ignore polling errors + } + }, 3000) } catch { - setError("Payment was cancelled or failed.") + setError("Failed to generate invoice. Try again.") } finally { setLoading(false) } - }, [setBudget]) + }, [amount, weblnAvailable, budget, setBudget]) + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(paymentRequest) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [paymentRequest]) const handleRefreshBalance = useCallback(async () => { setLoading(true) @@ -93,98 +199,249 @@ export function BudgetModal() { } }, [setBudget]) - const canTopUp = sphinxConnected || weblnAvailable + const canTopUp = sphinxConnected || weblnAvailable || hasExistingL402 return ( close()}> - - Budget + + {step !== "balance" && ( + + )} + {step === "balance" && "Budget"} + {step === "amount" && "Top Up"} + {step === "invoice" && "Pay Invoice"} + {step === "success" && "Budget"} - Manage your Lightning L402 balance. + {step === "balance" && "Manage your Lightning L402 balance."} + {step === "amount" && "Choose an amount to add."} + {step === "invoice" && "Scan or copy the invoice to pay."} + {step === "success" && "Your balance has been updated."}
- {/* Balance display */} -
- -
-

- {formattedBudget} -

-

- satoshis -

-
-
- - {/* Connection status */} -
-
- - {sphinxConnected - ? "Connected via Sphinx" - : weblnAvailable - ? "WebLN detected (Alby, etc.)" - : "No Lightning wallet detected"} - -
- - {error && ( -

{error}

+ {/* Step: Balance */} + {step === "balance" && ( + <> +
+ +
+

+ {formattedBudget} +

+

+ satoshis +

+
+
+ +
+
+ + {sphinxConnected + ? "Connected via Sphinx" + : weblnAvailable + ? "WebLN detected (Alby, etc.)" + : hasExistingL402 + ? "L402 token active" + : "No Lightning wallet detected"} + +
+ + {error && ( +

{error}

+ )} + + + +
+ {canTopUp ? ( + + ) : ( +

+ Install a Lightning wallet extension (like Alby) or connect + via the Sphinx app to top up your balance. +

+ )} + + +
+ )} - + {/* Step: Amount */} + {step === "amount" && ( + <> +
+ {PRESET_AMOUNTS.map((preset) => ( + + ))} +
+ +
+ { + const v = e.target.value.replace(/[^0-9]/g, "") + setAmount(v ? Number(v) : null) + }} + placeholder="Custom amount" + className="h-10 w-full rounded-md border border-border/50 bg-muted/30 px-3 pr-12 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none" + /> + + sats + +
+ + {error && ( +

{error}

+ )} - {/* Actions */} -
- {sphinxConnected && ( - )} + + )} + + {/* Step: Invoice (manual payment) */} + {step === "invoice" && ( + <> +
+
+ +
+ +
+ + {paymentRequest.slice(0, 20)}…{paymentRequest.slice(-8)} + + +
+ +
+ + + Waiting for payment... + +
+
+ + )} + + {/* Step: Success */} + {step === "success" && ( + <> +
+
+ +
+
+

+ Top-up complete +

+

+ {formattedBudget} + + sats + +

+
+
- {weblnAvailable && !sphinxConnected && ( - )} - - {!canTopUp && ( -

- Install a Lightning wallet extension (like Alby) or connect via - the Sphinx app to top up your balance. -

- )} - - -
+ + )}
diff --git a/src/lib/sphinx/index.ts b/src/lib/sphinx/index.ts index becb293..97fc5d5 100644 --- a/src/lib/sphinx/index.ts +++ b/src/lib/sphinx/index.ts @@ -1,4 +1,5 @@ export { isSphinx, isAndroid } from "./detect" export { enable, getSignedMessage, getL402, hasWebLN, payWithWebLN } from "./bridge" -export { payL402, getPrice } from "./payment" +export { payL402, getPrice, topUpLsat, topUpConfirm } from "./payment" +export type { TopUpResponse } from "./payment" export type { IsAdminResponse, SignedMessage } from "./types" diff --git a/src/lib/sphinx/payment.ts b/src/lib/sphinx/payment.ts index a41153d..5e6bf49 100644 --- a/src/lib/sphinx/payment.ts +++ b/src/lib/sphinx/payment.ts @@ -6,14 +6,15 @@ import { api } from "../api" const sphinx = require("sphinx-bridge") export async function payL402( - setBudget: (value: number | null) => void + setBudget: (value: number | null) => void, + amount?: number ): Promise { if (isSphinx()) { await payViaSphinx(setBudget) return } - await payViaWebLN(setBudget) + await payViaWebLN(setBudget, amount) } async function payViaSphinx( @@ -79,7 +80,8 @@ async function payViaSphinx( } async function payViaWebLN( - setBudget: (value: number | null) => void + setBudget: (value: number | null) => void, + amount?: number ): Promise { localStorage.removeItem("l402") @@ -88,22 +90,16 @@ async function payViaWebLN( if (!webln) throw new Error("No WebLN provider available") await webln.enable() - const budgetAmount = 50 + const budgetAmount = amount ?? 50 try { await api.post("/buy_lsat", { amount: budgetAmount }) } catch (error: unknown) { - console.log("[payViaWebLN] caught error:", error instanceof Response, error instanceof Response && error.status) - if (error instanceof Response && error.status === 402) { const header = error.headers.get("www-authenticate") - console.log("[payViaWebLN] www-authenticate header:", header ? `${header.slice(0, 80)}...` : null) - if (!header) throw new Error("No www-authenticate header in 402") const lsat = Lsat.fromHeader(header) - console.log("[payViaWebLN] parsed invoice:", lsat.invoice ? `${lsat.invoice.slice(0, 40)}...` : null) - const payment = await webln.sendPayment(lsat.invoice) if (payment?.preimage) { @@ -123,6 +119,26 @@ async function payViaWebLN( } } +export type TopUpResponse = { + success: boolean + payment_request: string + payment_hash: string +} + +export async function topUpLsat( + macaroon: string, + amount: number +): Promise { + return api.post("/top_up_lsat", { macaroon, amount }) +} + +export async function topUpConfirm( + paymentHash: string, + macaroon: string +): Promise { + await api.post("/top_up_confirm", { payment_hash: paymentHash, macaroon }) +} + export async function getPrice(endpoint: string): Promise { try { const res = await api.get<{