diff --git a/src/components/scenario-panel.tsx b/src/components/scenario-panel.tsx index 3123688..67c4575 100644 --- a/src/components/scenario-panel.tsx +++ b/src/components/scenario-panel.tsx @@ -14,6 +14,7 @@ import { PaymentPrismsScenario, LnurlVerifyScenario, WrappedInvoicesScenario, + L402Scenario, } from "./scenarios"; import { BitcoinConnectButtonScenario, @@ -54,6 +55,8 @@ export function ScenarioPanel() { return ; case "wrapped-invoices": return ; + case "l402": + return ; case "bitcoin-connect-button": return ; case "connect-wallet": diff --git a/src/components/scenarios/index.ts b/src/components/scenarios/index.ts index e31149b..e773af5 100644 --- a/src/components/scenarios/index.ts +++ b/src/components/scenarios/index.ts @@ -12,3 +12,4 @@ export { PaymentForwardingScenario } from "./payment-forwarding"; export { PaymentPrismsScenario } from "./payment-prisms"; export { LnurlVerifyScenario } from "./lnurl-verify"; export { WrappedInvoicesScenario } from "./wrapped-invoices"; +export { L402Scenario } from "./l402"; diff --git a/src/components/scenarios/l402.tsx b/src/components/scenarios/l402.tsx new file mode 100644 index 0000000..d7009f9 --- /dev/null +++ b/src/components/scenarios/l402.tsx @@ -0,0 +1,396 @@ +import { useState } from "react"; +import { Loader2, Lock, Unlock, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useWalletStore, useTransactionStore } from "@/stores"; +import { WALLET_PERSONAS } from "@/types"; + +const SERVER_URL = "https://alby-l402-proxy-server.vercel.app/"; +const CONFIG_URL = "https://alby-l402-proxy-server.vercel.app/api/configure"; + + +export function L402Scenario() { + return ( +
+ + +
+ ); +} + +function BobPanel() { + const [isFetching, setIsFetching] = useState(false); + const [data, setData] = useState(null); + const [statusText, setStatusText] = useState(""); + const [paymentRequired, setPaymentRequired] = useState<{ url: string, pct: string, offerId: string, offerDetails: Record, fullResponse: Record } | null>(null); + + const { getWallet, getNWCClient, setWalletBalance } = useWalletStore(); + const { addTransaction, updateTransaction, addFlowStep, updateFlowStep, addBalanceSnapshot } = useTransactionStore(); + + const handleFetchInitial = async () => { + setIsFetching(true); + setData(null); + setPaymentRequired(null); + setStatusText("Initiating GET request to Alice's server..."); + + const aliceWallet = getWallet("alice"); + + try { + if (!aliceWallet?.connectionString) { + setStatusText("Error: Alice must connect her wallet in the navbar to serve L402 invoices."); + setIsFetching(false); + return; + } + + setStatusText("fetching bitcoin price..."); + + const keyResp = await fetch("https://alby-l402-proxy-server.vercel.app/api/config-key"); + const { publicKey } = await keyResp.json(); + + const pemHeader = "-----BEGIN PUBLIC KEY-----"; + const pemFooter = "-----END PUBLIC KEY-----"; + const pemContents = publicKey.substring( + publicKey.indexOf(pemHeader) + pemHeader.length, + publicKey.indexOf(pemFooter) + ).replace(/\s+/g, ''); + + const binaryDerString = window.atob(pemContents); + const binaryDer = new Uint8Array(binaryDerString.length); + for (let i = 0; i < binaryDerString.length; i++) { + binaryDer[i] = binaryDerString.charCodeAt(i); + } + + const cryptoKey = await window.crypto.subtle.importKey( + "spki", + binaryDer.buffer, + { + name: "RSA-OAEP", + hash: "SHA-256" + }, + true, + ["encrypt"] + ); + + const encodedNwcUrl = new TextEncoder().encode(aliceWallet.connectionString); + const encryptedBuffer = await window.crypto.subtle.encrypt( + { name: "RSA-OAEP" }, + cryptoKey, + encodedNwcUrl + ); + + const encryptedBase64 = window.btoa( + String.fromCharCode(...new Uint8Array(encryptedBuffer)) + ); + + await fetch(CONFIG_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ encryptedNwcUrl: encryptedBase64 }) + }); + const initialFlowId = addFlowStep({ + fromWallet: "bob", + toWallet: "alice", + label: "GET /", + direction: "right", + status: "pending", + snippetIds: [], + }); + + let response; + try { + response = await fetch(SERVER_URL); + } catch { + setStatusText("Error reaching remote server. Make sure the deployed backend is active."); + updateFlowStep(initialFlowId, { status: "error", label: "Connection Refused" }); + setIsFetching(false); + return; + } + + const bodyResp = await response.json(); + + if (response.status === 402) { + setStatusText("Server responded with 402 Payment Required!"); + + const offers = bodyResp.offers; + const offer = offers?.[0]; + + if (!offer || !bodyResp.payment_context_token || !bodyResp.payment_request_url) { + throw new Error("L402 Response missing valid offers or payment context token"); + } + + updateFlowStep(initialFlowId, { + label: "402 Payment Required", + direction: "left", + status: "success", + }); + + setPaymentRequired({ + url: bodyResp.payment_request_url, + pct: bodyResp.payment_context_token, + offerId: offer.id, + offerDetails: offer, + fullResponse: bodyResp + }); + } else if (response.ok) { + updateFlowStep(initialFlowId, { + label: "200 OK", + direction: "left", + status: "success", + }); + setData(JSON.stringify(bodyResp, null, 2)); + setStatusText("Data fetched successfully."); + } else { + throw new Error(bodyResp.error || "Unexpected server error"); + } + + } catch (error) { + console.error("L402 flow failed:", error); + setStatusText(`Error: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsFetching(false); + } + }; + + const handlePayAndFetch = async () => { + if (!paymentRequired) return; + + const bobClient = getNWCClient("bob"); + + if (!bobClient) { + setStatusText("Please connect Bob's wallet using the navbar to pay the L402 invoice!"); + return; + } + + setIsFetching(true); + setStatusText(`Requesting specific payment details...`); + + try { + const paymentReqResponse = await fetch(paymentRequired.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + offer_id: paymentRequired.offerId, + payment_method: 'lightning', + payment_context_token: paymentRequired.pct + }) + }); + + const paymentReqData = await paymentReqResponse.json(); + + if (!paymentReqData.payment_request?.lightning_invoice) { + throw new Error("Failed to receive valid lightning invoice from server."); + } + + const invoiceToPay = paymentReqData.payment_request.lightning_invoice; + const updatedPct = paymentReqData.payment_context_token; + + setStatusText(`Paying 402 invoice to access resource...`); + + const paymentTxId = addTransaction({ + type: "payment_sent", + status: "pending", + fromWallet: "bob", + toWallet: "alice", + amount: undefined, + description: `L402 Automated Payment for API`, + snippetIds: ["pay-invoice"], + }); + + const paymentFlowId = addFlowStep({ + fromWallet: "bob", + toWallet: "alice", + label: `Paying invoice...`, + direction: "right", + status: "pending", + snippetIds: ["pay-invoice"], + }); + + const paymentResult = await bobClient.payInvoice({ invoice: invoiceToPay }); + const preimage = paymentResult.preimage; + + updateTransaction(paymentTxId, { + status: "success", + description: `L402 Automated Payment complete. Preimage: ${preimage}`, + }); + + updateFlowStep(paymentFlowId, { + label: "Payment confirmed", + status: "success", + }); + + const aliceClient = getNWCClient("alice"); + + const bobBalance = await bobClient.getBalance(); + const bobBalanceSats = Math.floor(bobBalance.balance / 1000); + setWalletBalance("bob", bobBalanceSats); + addBalanceSnapshot({ walletId: "bob", balance: bobBalanceSats }); + + if (aliceClient) { + const aliceBalance = await aliceClient.getBalance(); + const aliceBalanceSats = Math.floor(aliceBalance.balance / 1000); + setWalletBalance("alice", aliceBalanceSats); + addBalanceSnapshot({ walletId: "alice", balance: aliceBalanceSats }); + } + + setStatusText("Payment successful. Replying with L402 token..."); + const finalFlowId = addFlowStep({ + fromWallet: "bob", + toWallet: "alice", + label: "GET /api/bitcoin-price (with L402)", + direction: "right", + status: "pending", + snippetIds: [], + }); + + const retryResponse = await fetch(SERVER_URL, { + headers: { + "Authorization": `L402 ${updatedPct}` + } + }); + + const retryBody = await retryResponse.json(); + + if (retryResponse.ok) { + updateFlowStep(finalFlowId, { + label: `200 OK`, + direction: "left", + status: "success", + }); + + setData(JSON.stringify(retryBody, null, 2)); + setStatusText("Data fetched successfully."); + setPaymentRequired(null); + } else { + throw new Error(retryBody.error || "Failed to fetch data with L402 token"); + } + } catch (error) { + console.error("L402 payment flow failed:", error); + setStatusText(`Payment Error: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsFetching(false); + } + }; + + return ( + + + + {WALLET_PERSONAS.bob.emoji} + Bob: Client Application + + + +
+

+ Bob acting as a client application wants to fetch a premium resource from Alice's API. +

+ {!paymentRequired ? ( + + ) : ( +
+
+
+ + L402 Payment Required! +
+
+                  {JSON.stringify(paymentRequired.fullResponse, null, 2)}
+                
+
+ + +
+ )} +
+ + {statusText && ( +
+ {statusText} +
+ )} + + {data && ( +
+
+ + Successfully fetched resource! +
+
+              {data}
+            
+
+ )} +
+
+ ); +} + +function AlicePanel() { + return ( + + + + {WALLET_PERSONAS.alice.emoji} + Alice: L402 Server + + + +
+ +
+ + Endpoint: /api/bitcoin-price +
+
+ +
+ + +

+ This server backend is automatically configured using Alice's wallet to receive Lightning payments. +

+
+
+
+ ); +} diff --git a/src/data/scenarios.ts b/src/data/scenarios.ts index 71121a7..b762eb9 100644 --- a/src/data/scenarios.ts +++ b/src/data/scenarios.ts @@ -639,6 +639,38 @@ The flow: Seller encrypts secret with preimage, publishes ciphertext + plaintext }, ], }, + { + id: "l402", + title: "L402 Payment Required", + description: "Access an API endpoint that requires payment via the L402 protocol.", + education: + "L402 (formerly LSAT) is a protocol standardizing authentication and micropayments using the Lightning Network. When a client requests a resource without paying, the server replies with a 402 Payment Required status, a Macaroon, and a Lightning invoice. The client pays the invoice, gets the preimage, and uses the Macaroon + preimage in the Authorization header to authenticate.", + complexity: "advanced", + requiredWallets: ["alice", "bob"], + icon: "🪙", + snippetIds: ["make-invoice", "pay-invoice"] satisfies SnippetId[], + prompts: [ + { + title: "Premium Weather API", + description: + "Build a weather application that pays for detailed weather forecasts using the L402 protocol.", + prompt: `Build a weather application where the client pays for each premium weather report or forecast using L402. + +Requirements: +- A dashboard showing basic current weather for free (e.g. city name and current temp) +- A "Premium Forecast" button to see a detailed 7-day hourly forecast +- When the client requests premium data, the server responds with HTTP 402 + Invoice + Macaroon +- The client automatically pays the invoice over Lightning +- The client gets the preimage and retries the request with Authorization: L402 : +- The premium weather data is then displayed on the dashboard with beautiful weather cards +- Display a real-time log of the L402 automated negotiation (Request -> 402 -> Pay -> Retry -> 200) +- Use React and TypeScript +- Write tests using vitest and playwright. Take screenshots and review the screenshots. + +The flow: Client requests premium forecast → Server returns 402 + Invoice → Client pays invoice → Client requests data with auth → Server returns premium weather data.`, + }, + ], + }, // { // id: "transaction-history", // title: "Transaction History",