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 ? (
+
+ {isFetching ? (
+ <>
+
+ Fetching...
+ >
+ ) : (
+ <>
+
+ Fetch Bitcoin Price
+ >
+ )}
+
+ ) : (
+
+
+
+
+ L402 Payment Required!
+
+
+ {JSON.stringify(paymentRequired.fullResponse, null, 2)}
+
+
+
+
+ {isFetching ? (
+ <>
+
+ Paying Invoice...
+ >
+ ) : (
+ <>
+
+ Connect Wallet & Pay {paymentRequired.offerDetails?.amount} sats
+ >
+ )}
+
+
+ )}
+
+
+ {statusText && (
+
+ {statusText}
+
+ )}
+
+ {data && (
+
+
+
+ Successfully fetched resource!
+
+
+ {data}
+
+
+ )}
+
+
+ );
+}
+
+function AlicePanel() {
+ return (
+
+
+
+ {WALLET_PERSONAS.alice.emoji}
+ Alice: L402 Server
+
+
+
+
+
API Resource Config
+
+
+ Endpoint: /api/bitcoin-price
+
+
+
+
+
Fixed L402 Price (sats)
+
+
+ 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",