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<{