From 591c4c14afe4844ba370c7f299d39634559ac861 Mon Sep 17 00:00:00 2001
From: Roland Bewick
Date: Wed, 28 Jan 2026 23:35:00 +0700
Subject: [PATCH 1/5] feat: wrapped invoices scenario
---
docs/scenarios/wrapped-invoices.md | 244 ++++
src/components/scenario-panel.tsx | 3 +
src/components/scenarios/index.ts | 1 +
src/components/scenarios/wrapped-invoices.tsx | 1014 +++++++++++++++++
src/data/code-snippets.ts | 35 +-
src/data/scenarios.ts | 39 +
src/stores/index.ts | 1 +
src/stores/wrapped-invoice-store.ts | 57 +
8 files changed, 1393 insertions(+), 1 deletion(-)
create mode 100644 docs/scenarios/wrapped-invoices.md
create mode 100644 src/components/scenarios/wrapped-invoices.tsx
create mode 100644 src/stores/wrapped-invoice-store.ts
diff --git a/docs/scenarios/wrapped-invoices.md b/docs/scenarios/wrapped-invoices.md
new file mode 100644
index 0000000..f8a9307
--- /dev/null
+++ b/docs/scenarios/wrapped-invoices.md
@@ -0,0 +1,244 @@
+Title: Wrapped Invoices
+Description: Act as a payment intermediary by wrapping an invoice with a higher amount using the same payment hash.
+Education: Wrapped invoices allow you to act as a non-custodial middleman in a payment flow. By creating a hold invoice with the same payment hash as another invoice (but a higher amount), you can collect a fee for facilitating the payment. Crucially, the payer's funds remain locked in the Lightning network - never in your wallet - until you settle. You must use your own liquidity to pay the original invoice first, receiving the preimage, which you then use to settle the held payment and claim your fee. This non-custodial pattern is the manual equivalent of how Lightning routing works.
+Complexity: Advanced
+
+```txt
+════════════════════════════════════════════════════════════════════════════════
+ FLOW 1: CHARLIE CREATES ORIGINAL INVOICE
+════════════════════════════════════════════════════════════════════════════════
+
+┌──────────────────────────┬──────────────────────────┬──────────────────────────┐
+│ 💼 Alice's Wallet │ 💼 Bob's Wallet │ 💼 Charlie's Wallet │
+│ NWC Connected │ NWC Connected │ NWC Connected │
+├──────────────────────────┼──────────────────────────┼──────────────────────────┤
+│ │ │ │
+│ 10,000 sats │ 10,000 sats │ 10,000 sats │
+│ $9.02 │ $9.02 │ $9.02 │
+│ │ │ │
+│ │ │ Amount (sats) │
+│ │ │ ┌────────────────────┐ │
+│ │ │ │ 1000 │ │
+│ │ │ └────────────────────┘ │
+│ │ │ │
+│ │ │ [Create Invoice] │
+│ │ │ │
+│ │ ┌────────────────────┐ │ │
+│ │ │ Note: Bob will │ │ │
+│ │ │ wrap Charlie's │ │ │
+│ │ │ invoice to earn a │ │ │
+│ │ │ fee - WITHOUT ever │ │ │
+│ │ │ taking custody of │ │ │
+│ │ │ Alice's funds. │ │ │
+│ │ └────────────────────┘ │ │
+│ │ │ │
+└──────────────────────────┴──────────────────────────┴──────────────────────────┘
+
+
+════════════════════════════════════════════════════════════════════════════════
+ FLOW 2: BOB WRAPS THE INVOICE
+════════════════════════════════════════════════════════════════════════════════
+
+Charlie's invoice is created. Bob extracts the payment hash and creates his own
+hold invoice with a higher amount (adding his fee).
+
+┌──────────────────────────┬──────────────────────────┬──────────────────────────┐
+│ 💼 Alice's Wallet │ 💼 Bob's Wallet │ 💼 Charlie's Wallet │
+│ NWC Connected │ NWC Connected │ NWC Connected │
+├──────────────────────────┼──────────────────────────┼──────────────────────────┤
+│ │ │ │
+│ 10,000 sats │ 10,000 sats │ 10,000 sats │
+│ $9.02 │ $9.02 │ $9.02 │
+│ │ │ │
+│ │ Charlie's Invoice │ Invoice Created │
+│ │ ┌────────────────────┐ │ │
+│ │ │ lnbc10u1p5abc... │ │ ┌────────────────────┐ │
+│ │ └────────────────────┘ │ │ lnbc10u1p5abc... │ │
+│ │ │ │ Amount: 1000 sats │ │
+│ │ ▶ Decoded Invoice │ │ 📋 │ │
+│ │ - Amount: 1000 sats │ └────────────────────┘ │
+│ │ - Payment Hash: a1b2c │ │
+│ │ │ Waiting for payment... │
+│ │ Your Fee (sats) │ │
+│ │ ┌────────────────────┐ │ │
+│ │ │ 100 │ │ │
+│ │ └────────────────────┘ │ │
+│ │ │ │
+│ │ [Create Wrapped Invoice]│ │
+│ │ (Hold invoice: 1100 sats│ │
+│ │ same payment hash) │ │
+│ │ │ │
+└──────────────────────────┴──────────────────────────┴──────────────────────────┘
+
+
+════════════════════════════════════════════════════════════════════════════════
+ FLOW 3: ALICE PAYS BOB'S WRAPPED INVOICE
+════════════════════════════════════════════════════════════════════════════════
+
+Bob's wrapped invoice is ready. Alice pays it, and the payment is held in the
+Lightning network (NOT in Bob's wallet - Bob's balance is unchanged).
+
+┌──────────────────────────┬──────────────────────────┬──────────────────────────┐
+│ 💼 Alice's Wallet │ 💼 Bob's Wallet │ 💼 Charlie's Wallet │
+│ NWC Connected │ NWC Connected │ NWC Connected │
+├──────────────────────────┼──────────────────────────┼──────────────────────────┤
+│ │ │ │
+│ 10,000 sats │ 10,000 sats │ 10,000 sats │
+│ $9.02 │ $9.02 │ $9.02 │
+│ │ │ │
+│ Bob's Wrapped Invoice │ 🔒 Held 1,100 sats│ Invoice Created │
+│ ┌────────────────────┐ │ (in network, not here) │ │
+│ │ Paste invoice... │ │ │ ⏳ Waiting 1,000 sats│
+│ └────────────────────┘ │ ┌────────────────────┐ │ │
+│ │ │ Alice's payment is │ │ │
+│ [Pay Invoice] │ │ HELD in network. │ │ │
+│ │ │ Pay Charlie (with │ │ │
+│ │ │ YOUR funds) to get │ │ │
+│ │ │ the preimage. │ │ │
+│ │ └────────────────────┘ │ │
+│ │ │ │
+│ │ [Pay Charlie's Invoice]│ │
+│ │ │ │
+└──────────────────────────┴──────────────────────────┴──────────────────────────┘
+
+
+════════════════════════════════════════════════════════════════════════════════
+ FLOW 4: BOB PAYS CHARLIE, RECEIVES PREIMAGE
+════════════════════════════════════════════════════════════════════════════════
+
+Bob pays Charlie's original invoice. Charlie's wallet reveals the preimage.
+Bob now has the preimage needed to settle Alice's payment.
+
+┌──────────────────────────┬──────────────────────────┬──────────────────────────┐
+│ 💼 Alice's Wallet │ 💼 Bob's Wallet │ 💼 Charlie's Wallet │
+│ NWC Connected │ NWC Connected │ NWC Connected │
+├──────────────────────────┼──────────────────────────┼──────────────────────────┤
+│ │ │ │
+│ 10,000 sats │ 9,000 sats (-1000) │ 11,000 sats (+1000) │
+│ $9.02 │ $8.12 │ $9.92 │
+│ │ │ │
+│ Payment Status: │ 🔒 Held 1,100 sats│ ✅ Paid 1,000 sats│
+│ Pending (Held) │ │ │
+│ │ ✅ Charlie Paid! │ Payment received! │
+│ ┌────────────────────┐ │ │ │
+│ │ Your funds are │ │ Preimage received: │ │
+│ │ locked. Waiting... │ │ ┌────────────────────┐ │ │
+│ └────────────────────┘ │ │ abc123def456... │ │ │
+│ │ └────────────────────┘ │ │
+│ │ │ │
+│ │ [Settle Alice's Payment]│ │
+│ │ (Use preimage to claim) │ │
+│ │ │ │
+└──────────────────────────┴──────────────────────────┴──────────────────────────┘
+
+
+════════════════════════════════════════════════════════════════════════════════
+ FLOW 5: BOB SETTLES, COMPLETES THE FLOW
+════════════════════════════════════════════════════════════════════════════════
+
+Bob uses the preimage to settle Alice's payment. Bob keeps the 100 sat fee.
+
+┌──────────────────────────┬──────────────────────────┬──────────────────────────┐
+│ 💼 Alice's Wallet │ 💼 Bob's Wallet │ 💼 Charlie's Wallet │
+│ NWC Connected │ NWC Connected │ NWC Connected │
+├──────────────────────────┼──────────────────────────┼──────────────────────────┤
+│ │ │ │
+│ 8,900 sats (-1100) │ 10,100 sats (+100 fee) │ 11,000 sats (+1000) │
+│ $8.03 │ $9.11 │ $9.92 │
+│ │ │ │
+│ ✅ Payment Complete │ ✅ Settled 1,100 sats│ ✅ Paid 1,000 sats│
+│ │ │ │
+│ Paid: 1,100 sats │ ┌────────────────────┐ │ Received: 1,000 sats │
+│ │ │ Success! You acted │ │ │
+│ │ │ as intermediary. │ │ │
+│ │ │ Fee earned: 100 │ │ │
+│ │ │ sats │ │ │
+│ │ └────────────────────┘ │ │
+│ │ │ │
+└──────────────────────────┴──────────────────────────┴──────────────────────────┘
+```
+
+# How it works
+
+```txt
+────────────────────────────────────────────────────────────────────────────────
+ How Wrapped Invoices Work
+────────────────────────────────────────────────────────────────────────────────
+
+1. Charlie Creates 2. Bob Wraps 3. Alice Pays Bob
+───────────────────────── ───────────────────────── ─────────────────────────
+Charlie creates a normal Bob decodes the invoice, Alice pays Bob's wrapped
+invoice with amount X. extracts the payment invoice. Payment is HELD
+ hash, creates a hold (not settled yet).
+ invoice for X + fee
+ with SAME payment hash.
+
+4. Bob Pays Charlie 5. Bob Settles
+───────────────────────── ─────────────────────────
+Bob pays Charlie's Bob uses the preimage
+original invoice. Gets from Charlie to settle
+the preimage as proof Alice's held payment.
+of payment. Bob keeps the fee!
+
+
+ ┌─────────────────────────────────┐
+ │ Payment Flow │
+ └─────────────────────────────────┘
+
+ Alice ──────1100 sats──────▶ Bob ──────1000 sats──────▶ Charlie
+ (wrapped invoice) (original invoice)
+
+ Bob keeps 100 sats fee
+
+
+────────────────────────────────────────────────────────────────────────────────
+ Why This Works
+────────────────────────────────────────────────────────────────────────────────
+
+The key insight is that the SAME payment hash is used for both invoices:
+
+ • Charlie's invoice hash: abc123...
+ • Bob's wrapped invoice hash: abc123... (same!)
+
+Since both invoices share the same hash, they require the SAME preimage to
+settle. When Bob pays Charlie, he learns the preimage. He can then use that
+exact preimage to claim Alice's payment.
+
+This is essentially how Lightning routing works - each hop along the route
+uses the same payment hash, allowing atomic multi-hop payments.
+
+
+────────────────────────────────────────────────────────────────────────────────
+ Non-Custodial by Design
+────────────────────────────────────────────────────────────────────────────────
+
+IMPORTANT: Bob NEVER takes custody of Alice's funds!
+
+ 1. Alice pays → funds are HELD (not in Bob's balance)
+ 2. Bob pays Charlie → Bob uses his OWN existing balance
+ 3. Bob settles → Alice's held funds go directly to Bob's balance
+
+At no point does Bob temporarily hold Alice's money. The hold invoice keeps
+Alice's funds locked in the Lightning network until settlement. Bob must use
+his own liquidity to pay Charlie first.
+
+This is a key security property:
+ • If Bob disappears after step 1, Alice's payment times out and refunds
+ • If Bob can't pay Charlie, he can cancel and Alice gets refunded
+ • Bob takes on the risk, not Alice
+
+This non-custodial design is what makes Lightning routing trustless.
+```
+
+# Key Concepts
+
+- **Payment Hash**: A cryptographic hash included in every Lightning invoice. The payer must provide the matching preimage to complete payment.
+- **Preimage**: The secret value that, when hashed, produces the payment hash. Knowing the preimage proves you completed a payment.
+- **Hold Invoice**: An invoice where the recipient controls when (or if) to settle using the preimage.
+- **Atomic Routing**: Since all invoices in the chain use the same hash, either all payments complete or none do.
+- **Non-Custodial**: Bob never holds Alice's funds - they remain locked in the Lightning network until settlement. Bob uses his own liquidity to pay Charlie.
+
+# Use Cases
+
+1. **Non-Custodial Fee Collection**: Earn fees for facilitating payments as an intermediary
+2. **Privacy**: Hide the true recipient of payments behind a "Trampoline service".
diff --git a/src/components/scenario-panel.tsx b/src/components/scenario-panel.tsx
index 29b0987..d177cd8 100644
--- a/src/components/scenario-panel.tsx
+++ b/src/components/scenario-panel.tsx
@@ -13,6 +13,7 @@ import {
PaymentForwardingScenario,
PaymentPrismsScenario,
LnurlVerifyScenario,
+ WrappedInvoicesScenario,
} from "./scenarios";
export function ScenarioPanel() {
@@ -45,6 +46,8 @@ export function ScenarioPanel() {
return ;
case "lnurl-verify":
return ;
+ case "wrapped-invoices":
+ return ;
default:
return null;
}
diff --git a/src/components/scenarios/index.ts b/src/components/scenarios/index.ts
index fc16510..e31149b 100644
--- a/src/components/scenarios/index.ts
+++ b/src/components/scenarios/index.ts
@@ -11,3 +11,4 @@ export { FiatConversionScenario } from "./fiat-conversion";
export { PaymentForwardingScenario } from "./payment-forwarding";
export { PaymentPrismsScenario } from "./payment-prisms";
export { LnurlVerifyScenario } from "./lnurl-verify";
+export { WrappedInvoicesScenario } from "./wrapped-invoices";
diff --git a/src/components/scenarios/wrapped-invoices.tsx b/src/components/scenarios/wrapped-invoices.tsx
new file mode 100644
index 0000000..44861ab
--- /dev/null
+++ b/src/components/scenarios/wrapped-invoices.tsx
@@ -0,0 +1,1014 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import {
+ Loader2,
+ Copy,
+ Check,
+ Lock,
+ ChevronDown,
+ ChevronUp,
+ Zap,
+ ArrowRight,
+} 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,
+ useWrappedInvoiceStore,
+} from "@/stores";
+import { WALLET_PERSONAS } from "@/types";
+import type { Nip47Notification } from "@getalby/sdk/nwc";
+
+export function WrappedInvoicesScenario() {
+ const { areAllWalletsConnected } = useWalletStore();
+ const { reset } = useWrappedInvoiceStore();
+ const allConnected = areAllWalletsConnected(["alice", "bob", "charlie"]);
+
+ // Reset shared state when component mounts
+ useEffect(() => {
+ reset();
+ }, [reset]);
+
+ if (!allConnected) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function CharliePanel() {
+ const [amount, setAmount] = useState("1000");
+ const [isCreating, setIsCreating] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [error, setError] = useState(null);
+
+ const { getNWCClient } = useWalletStore();
+ const { addTransaction, updateTransaction, addFlowStep } =
+ useTransactionStore();
+ const { state, charlieInvoice, setCharlieInvoice, setState } =
+ useWrappedInvoiceStore();
+
+ const createInvoice = async () => {
+ const client = getNWCClient("charlie");
+ if (!client) {
+ setError("Charlie wallet not connected");
+ return;
+ }
+
+ setIsCreating(true);
+ setError(null);
+
+ const satoshi = parseInt(amount);
+ const txId = addTransaction({
+ type: "invoice_created",
+ status: "pending",
+ description: `Charlie creating invoice for ${satoshi} sats...`,
+ snippetIds: ["make-invoice"],
+ });
+
+ try {
+ const response = await client.makeInvoice({
+ amount: satoshi * 1000, // millisats
+ description: "Payment to Charlie",
+ });
+
+ // Decode invoice to get payment hash
+ const decoded = await client.lookupInvoice({
+ invoice: response.invoice,
+ });
+
+ const invoiceData = {
+ invoice: response.invoice,
+ paymentHash: decoded.payment_hash,
+ amount: satoshi,
+ };
+
+ setCharlieInvoice(invoiceData);
+ setState("charlie_invoice_created");
+
+ updateTransaction(txId, {
+ status: "success",
+ amount: satoshi,
+ description: `Charlie created invoice for ${satoshi} sats`,
+ });
+
+ addFlowStep({
+ fromWallet: "charlie",
+ toWallet: "bob",
+ label: `Invoice: ${satoshi} sats`,
+ direction: "left",
+ status: "success",
+ snippetIds: ["make-invoice"],
+ });
+ } catch (err) {
+ console.error("Failed to create invoice:", err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setError(errorMessage);
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Failed to create invoice: ${errorMessage}`,
+ });
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const copyInvoice = async () => {
+ if (!charlieInvoice) return;
+ await navigator.clipboard.writeText(charlieInvoice.invoice);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const isPaid = state === "bob_paid_charlie" || state === "settled";
+
+ return (
+
+
+
+ {WALLET_PERSONAS.charlie.emoji}
+ Charlie: Create Invoice
+
+
+
+ {state === "idle" && (
+ <>
+
+
+ Charlie creates a regular invoice. Bob will wrap it to act as
+ intermediary.
+
+
+
+
+
+ Amount (sats)
+
+ setAmount(e.target.value)}
+ placeholder="1000"
+ disabled={isCreating}
+ />
+
+
+ {error && {error}
}
+
+
+ {isCreating ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Invoice
+ >
+ )}
+
+ >
+ )}
+
+ {state !== "idle" && charlieInvoice && (
+ <>
+
+ Amount:
+
+ {charlieInvoice.amount} sats
+
+
+
+ {!isPaid && (
+ <>
+
+
+ {charlieInvoice.invoice.substring(0, 60)}...
+
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Invoice
+ >
+ )}
+
+
+
+
+
+
+
+ Waiting for payment...
+
+ >
+ )}
+
+ {isPaid && (
+
+
+ ✅
+ Payment received! +{charlieInvoice.amount} sats
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function BobPanel() {
+ const [fee, setFee] = useState("100");
+ const [isWrapping, setIsWrapping] = useState(false);
+ const [isPayingCharlie, setIsPayingCharlie] = useState(false);
+ const [isSettling, setIsSettling] = useState(false);
+ const [showDetails, setShowDetails] = useState(false);
+ const [error, setError] = useState(null);
+ const unsubRef = useRef<(() => void) | null>(null);
+
+ const { getNWCClient, setWalletBalance } = useWalletStore();
+ const {
+ addTransaction,
+ updateTransaction,
+ addFlowStep,
+ updateFlowStep,
+ addBalanceSnapshot,
+ } = useTransactionStore();
+ const {
+ state,
+ charlieInvoice,
+ bobWrapped,
+ receivedPreimage,
+ setState,
+ setBobWrapped,
+ setReceivedPreimage,
+ } = useWrappedInvoiceStore();
+
+ // Refs to access latest state in callbacks
+ const bobWrappedRef = useRef(bobWrapped);
+ useEffect(() => {
+ bobWrappedRef.current = bobWrapped;
+ }, [bobWrapped]);
+
+ const handleNotification = useCallback(
+ (notification: Nip47Notification) => {
+ console.log("=== BOB RECEIVED NOTIFICATION ===");
+ console.log("Type:", notification.notification_type);
+ console.log("Full notification:", JSON.stringify(notification, null, 2));
+ const currentBobWrapped = bobWrappedRef.current;
+
+ if (notification.notification_type === "hold_invoice_accepted") {
+ console.log("Hold invoice accepted notification received");
+ console.log("Expected payment_hash:", currentBobWrapped?.paymentHash);
+ console.log(
+ "Received payment_hash:",
+ notification.notification.payment_hash,
+ );
+
+ if (currentBobWrapped) {
+ setState("alice_paid");
+
+ addTransaction({
+ type: "payment_received",
+ status: "pending",
+ toWallet: "bob",
+ amount: currentBobWrapped.totalAmount,
+ description: `Alice's payment HELD (${currentBobWrapped.totalAmount} sats) - in network, not Bob's wallet`,
+ snippetIds: ["subscribe-hold-notifications"],
+ });
+
+ addFlowStep({
+ fromWallet: "bob",
+ toWallet: "bob",
+ label: `🔒 Alice's ${currentBobWrapped.totalAmount} sats HELD (in network)`,
+ direction: "left",
+ status: "pending",
+ snippetIds: ["subscribe-hold-notifications"],
+ });
+ }
+ }
+ },
+ [addTransaction, addFlowStep, setState],
+ );
+
+ const createWrappedInvoice = async () => {
+ if (!charlieInvoice) return;
+
+ const client = getNWCClient("bob");
+ if (!client) {
+ setError("Bob wallet not connected");
+ return;
+ }
+
+ setIsWrapping(true);
+ setError(null);
+
+ const feeAmount = parseInt(fee) || 100;
+ const totalAmount = charlieInvoice.amount + feeAmount;
+
+ const txId = addTransaction({
+ type: "invoice_created",
+ status: "pending",
+ description: `Bob creating wrapped hold invoice for ${totalAmount} sats...`,
+ snippetIds: ["hold-invoice"],
+ });
+
+ try {
+ // Create hold invoice with SAME payment hash as Charlie's invoice
+ // Note: We don't need a preimage - we'll get it from paying Charlie
+ console.log(
+ "Creating hold invoice with Charlie's payment_hash:",
+ charlieInvoice.paymentHash,
+ );
+ const response = await client.makeHoldInvoice({
+ amount: totalAmount * 1000, // millisats
+ description: `Wrapped invoice (${charlieInvoice.amount} + ${feeAmount} fee)`,
+ payment_hash: charlieInvoice.paymentHash,
+ });
+
+ console.log(
+ "makeHoldInvoice response:",
+ JSON.stringify(response, null, 2),
+ );
+ console.log(
+ "Created wrapped invoice with payment_hash:",
+ charlieInvoice.paymentHash,
+ );
+
+ const wrappedData = {
+ wrappedInvoice: response.invoice,
+ preimage: "", // Will be filled when we pay Charlie
+ paymentHash: charlieInvoice.paymentHash,
+ totalAmount,
+ fee: feeAmount,
+ charlieAmount: charlieInvoice.amount,
+ };
+
+ bobWrappedRef.current = wrappedData;
+ setBobWrapped(wrappedData);
+ setState("bob_wrapped");
+
+ updateTransaction(txId, {
+ status: "success",
+ amount: totalAmount,
+ description: `Bob created wrapped invoice: ${totalAmount} sats (same payment hash)`,
+ });
+
+ addFlowStep({
+ fromWallet: "bob",
+ toWallet: "alice",
+ label: `Wrapped: ${totalAmount} sats (${feeAmount} fee)`,
+ direction: "left",
+ status: "success",
+ snippetIds: ["hold-invoice"],
+ });
+
+ // Subscribe to notifications for when Alice pays
+ console.log(
+ "Subscribing to hold_invoice_accepted notifications for Bob...",
+ );
+ const unsub = await client.subscribeNotifications(handleNotification, [
+ "hold_invoice_accepted",
+ ]);
+ console.log("Subscription established for Bob");
+ unsubRef.current = unsub;
+ } catch (err) {
+ console.error("Failed to create wrapped invoice:", err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setError(errorMessage);
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Failed to create wrapped invoice: ${errorMessage}`,
+ });
+ } finally {
+ setIsWrapping(false);
+ }
+ };
+
+ const payCharlie = async () => {
+ if (!charlieInvoice || !bobWrapped) return;
+
+ const client = getNWCClient("bob");
+ if (!client) return;
+
+ setIsPayingCharlie(true);
+ setError(null);
+
+ const txId = addTransaction({
+ type: "payment_sent",
+ status: "pending",
+ fromWallet: "bob",
+ toWallet: "charlie",
+ amount: charlieInvoice.amount,
+ description: `Bob paying Charlie ${charlieInvoice.amount} sats (using Bob's own funds)...`,
+ snippetIds: ["pay-invoice"],
+ });
+
+ const flowStepId = addFlowStep({
+ fromWallet: "bob",
+ toWallet: "charlie",
+ label: `Paying Charlie ${charlieInvoice.amount} sats...`,
+ direction: "right",
+ status: "pending",
+ snippetIds: ["pay-invoice"],
+ });
+
+ try {
+ const result = await client.payInvoice({
+ invoice: charlieInvoice.invoice,
+ });
+
+ // We get the preimage from paying Charlie!
+ const preimage = result.preimage;
+ setReceivedPreimage(preimage);
+ setState("bob_paid_charlie");
+
+ // Update Bob's balance (decreased by Charlie's amount)
+ const bobBalance = await client.getBalance();
+ const bobBalanceSats = Math.floor(bobBalance.balance / 1000);
+ setWalletBalance("bob", bobBalanceSats);
+ addBalanceSnapshot({ walletId: "bob", balance: bobBalanceSats });
+
+ // Update Charlie's balance
+ const charlieClient = getNWCClient("charlie");
+ if (charlieClient) {
+ const charlieBalance = await charlieClient.getBalance();
+ const charlieBalanceSats = Math.floor(charlieBalance.balance / 1000);
+ setWalletBalance("charlie", charlieBalanceSats);
+ addBalanceSnapshot({
+ walletId: "charlie",
+ balance: charlieBalanceSats,
+ });
+ }
+
+ updateTransaction(txId, {
+ status: "success",
+ description: `Bob paid Charlie ${charlieInvoice.amount} sats - received preimage!`,
+ });
+
+ updateFlowStep(flowStepId, {
+ label: `✅ Paid Charlie, got preimage`,
+ status: "success",
+ });
+
+ addFlowStep({
+ fromWallet: "bob",
+ toWallet: "bob",
+ label: `🔑 Preimage: ${preimage.substring(0, 16)}...`,
+ direction: "right",
+ status: "success",
+ snippetIds: ["pay-invoice"],
+ });
+ } catch (err) {
+ console.error("Failed to pay Charlie:", err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setError(errorMessage);
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Failed to pay Charlie: ${errorMessage}`,
+ });
+
+ updateFlowStep(flowStepId, {
+ label: `Failed to pay Charlie`,
+ status: "error",
+ });
+ } finally {
+ setIsPayingCharlie(false);
+ }
+ };
+
+ const settleAlicePayment = async () => {
+ if (!bobWrapped || !receivedPreimage) return;
+
+ const client = getNWCClient("bob");
+ if (!client) return;
+
+ setIsSettling(true);
+ setError(null);
+
+ const txId = addTransaction({
+ type: "payment_received",
+ status: "pending",
+ toWallet: "bob",
+ amount: bobWrapped.totalAmount,
+ description: `Bob settling Alice's payment with preimage...`,
+ snippetIds: ["hold-invoice-settle"],
+ });
+
+ try {
+ await client.settleHoldInvoice({ preimage: receivedPreimage });
+
+ setState("settled");
+
+ // Update Bob's balance (increased by Alice's payment)
+ const bobBalance = await client.getBalance();
+ const bobBalanceSats = Math.floor(bobBalance.balance / 1000);
+ setWalletBalance("bob", bobBalanceSats);
+ addBalanceSnapshot({ walletId: "bob", balance: bobBalanceSats });
+
+ // Update Alice's balance
+ const aliceClient = getNWCClient("alice");
+ if (aliceClient) {
+ const aliceBalance = await aliceClient.getBalance();
+ const aliceBalanceSats = Math.floor(aliceBalance.balance / 1000);
+ setWalletBalance("alice", aliceBalanceSats);
+ addBalanceSnapshot({ walletId: "alice", balance: aliceBalanceSats });
+ }
+
+ updateTransaction(txId, {
+ status: "success",
+ description: `Bob settled! Earned ${bobWrapped.fee} sats fee`,
+ });
+
+ addFlowStep({
+ fromWallet: "bob",
+ toWallet: "bob",
+ label: `✅ Settled! Fee earned: ${bobWrapped.fee} sats`,
+ direction: "right",
+ status: "success",
+ snippetIds: ["hold-invoice-settle"],
+ });
+
+ // Cleanup
+ if (unsubRef.current) {
+ unsubRef.current();
+ unsubRef.current = null;
+ }
+ } catch (err) {
+ console.error("Failed to settle:", err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setError(errorMessage);
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Failed to settle: ${errorMessage}`,
+ });
+ } finally {
+ setIsSettling(false);
+ }
+ };
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (unsubRef.current) {
+ unsubRef.current();
+ }
+ };
+ }, []);
+
+ const canWrap = state === "charlie_invoice_created" && charlieInvoice;
+
+ return (
+
+
+
+ {WALLET_PERSONAS.bob.emoji}
+ Bob: Wrap Invoice
+
+
+
+ {state === "idle" && (
+
+ Waiting for Charlie to create an invoice...
+
+ )}
+
+ {canWrap && (
+ <>
+
+
Charlie's Invoice Received
+
+ Amount: {charlieInvoice.amount} sats
+
+ Hash: {charlieInvoice.paymentHash.substring(0, 16)}...
+
+
+
+
+
+ Your Fee (sats)
+
+ setFee(e.target.value)}
+ placeholder="100"
+ disabled={isWrapping}
+ />
+
+
+
+
+ Charlie's amount:
+ {charlieInvoice.amount} sats
+
+
+ Your fee:
+ +{fee} sats
+
+
+ Wrapped total:
+ {charlieInvoice.amount + (parseInt(fee) || 0)} sats
+
+
+
+ {error && {error}
}
+
+
+ {isWrapping ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Wrapped Invoice
+ >
+ )}
+
+ >
+ )}
+
+ {state === "bob_wrapped" && bobWrapped && (
+ <>
+
+
Wrapped Invoice Ready
+
+ Total: {bobWrapped.totalAmount} sats ({bobWrapped.fee} fee)
+
+
Same payment hash as Charlie's invoice
+
+
+
+
+
+
+ Waiting for Alice to pay...
+
+ >
+ )}
+
+ {state === "alice_paid" && bobWrapped && (
+ <>
+
+
+ 🔒 Alice's payment HELD ({bobWrapped.totalAmount} sats)
+
+
+ Funds are locked in the network, NOT in your wallet.
+
+ Now pay Charlie with YOUR funds to get the preimage.
+
+
+
+ {error && {error}
}
+
+
+ {isPayingCharlie ? (
+ <>
+
+ Paying Charlie...
+ >
+ ) : (
+ <>
+
+ Pay Charlie {charlieInvoice?.amount} sats
+ >
+ )}
+
+ >
+ )}
+
+ {state === "bob_paid_charlie" && bobWrapped && receivedPreimage && (
+ <>
+
+
✅ Charlie Paid!
+
+ Preimage received. Now settle Alice's payment to claim your fee.
+
+
+
+ {error && {error}
}
+
+
+ {isSettling ? (
+ <>
+
+ Settling...
+ >
+ ) : (
+ <>
+
+ Settle & Claim {bobWrapped.fee} sats fee
+ >
+ )}
+
+ >
+ )}
+
+ {state === "settled" && bobWrapped && (
+
+
🎉 Success!
+
+ You acted as a non-custodial intermediary.
+
+ Fee earned:{" "}
+ {bobWrapped.fee} sats
+
+
+ )}
+
+ {(bobWrapped || receivedPreimage) && (
+ <>
+ setShowDetails(!showDetails)}
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+ >
+ {showDetails ? (
+
+ ) : (
+
+ )}
+ Technical Details
+
+
+ {showDetails && (
+
+ {bobWrapped && (
+
+ payment_hash: {" "}
+ {bobWrapped.paymentHash}
+
+ )}
+ {receivedPreimage && (
+
+ preimage: {" "}
+ {receivedPreimage}
+
+ )}
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function AlicePanel() {
+ const [invoiceInput, setInvoiceInput] = useState("");
+ const [isPaying, setIsPaying] = useState(false);
+ const [error, setError] = useState(null);
+
+ const { getNWCClient, setWalletBalance } = useWalletStore();
+ const {
+ addTransaction,
+ updateTransaction,
+ addFlowStep,
+ updateFlowStep,
+ addBalanceSnapshot,
+ } = useTransactionStore();
+ const { state, bobWrapped } = useWrappedInvoiceStore();
+
+ const invoiceToUse = invoiceInput || bobWrapped?.wrappedInvoice || "";
+
+ const canPay =
+ (state === "bob_wrapped" || state === "alice_paid") && invoiceToUse;
+ const hasPaid =
+ state === "alice_paid" ||
+ state === "bob_paid_charlie" ||
+ state === "settled";
+
+ const handlePay = async () => {
+ if (!invoiceToUse) return;
+
+ const client = getNWCClient("alice");
+ if (!client) return;
+
+ setIsPaying(true);
+ setError(null);
+
+ const amount = bobWrapped?.totalAmount || 0;
+
+ const txId = addTransaction({
+ type: "payment_sent",
+ status: "pending",
+ fromWallet: "alice",
+ toWallet: "bob",
+ amount,
+ description: `Alice paying wrapped invoice for ${amount} sats...`,
+ snippetIds: ["pay-invoice"],
+ });
+
+ const flowStepId = addFlowStep({
+ fromWallet: "alice",
+ toWallet: "bob",
+ label: `Paying ${amount} sats...`,
+ direction: "right",
+ status: "pending",
+ snippetIds: ["pay-invoice"],
+ });
+
+ try {
+ // This will block until the hold invoice is settled or cancelled
+ console.log("Alice starting payment to wrapped invoice...");
+ console.log("Invoice:", invoiceToUse.substring(0, 50) + "...");
+ const paymentPromise = client.payInvoice({ invoice: invoiceToUse });
+ console.log("Alice payment initiated, waiting for settlement...");
+ await paymentPromise;
+ console.log("Alice payment completed!");
+
+ // Update Alice's balance
+ const aliceBalance = await client.getBalance();
+ const aliceBalanceSats = Math.floor(aliceBalance.balance / 1000);
+ setWalletBalance("alice", aliceBalanceSats);
+ addBalanceSnapshot({ walletId: "alice", balance: aliceBalanceSats });
+
+ updateTransaction(txId, {
+ status: "success",
+ description: `Alice paid ${amount} sats (via wrapped invoice)`,
+ });
+
+ updateFlowStep(flowStepId, {
+ label: `✅ Paid ${amount} sats`,
+ status: "success",
+ });
+ } catch (err) {
+ console.error("Failed to pay:", err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setError(errorMessage);
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Payment failed: ${errorMessage}`,
+ });
+
+ updateFlowStep(flowStepId, {
+ label: `Payment failed`,
+ status: "error",
+ });
+ } finally {
+ setIsPaying(false);
+ }
+ };
+
+ const getStatusText = () => {
+ if (state === "settled") return "Payment Complete";
+ if (state === "bob_paid_charlie") return "Pending (Bob settling...)";
+ if (state === "alice_paid") return "Pending (Held in network)";
+ if (isPaying) return "Paying...";
+ return "Not Paid";
+ };
+
+ const getStatusColor = () => {
+ if (state === "settled") return "text-green-600 dark:text-green-400";
+ if (hasPaid) return "text-orange-600 dark:text-orange-400";
+ return "text-muted-foreground";
+ };
+
+ return (
+
+
+
+ {WALLET_PERSONAS.alice.emoji}
+ Alice: Pay Wrapped Invoice
+
+
+
+
+ Payment Status:
+ {getStatusText()}
+
+
+ {!hasPaid && (
+ <>
+ {state === "idle" || state === "charlie_invoice_created" ? (
+
+ Waiting for Bob to create wrapped invoice...
+
+ ) : (
+ <>
+
+
+ Wrapped Invoice
+
+
setInvoiceInput(e.target.value)}
+ placeholder="Paste invoice..."
+ disabled={isPaying}
+ />
+ {bobWrapped && !invoiceInput && (
+
+ Using Bob's wrapped invoice
+
+ )}
+
+
+ {bobWrapped && (
+
+
+ Amount:
+
+ {bobWrapped.totalAmount} sats
+
+
+
+ (includes {bobWrapped.fee} sats intermediary fee)
+
+
+ )}
+
+ {error && {error}
}
+
+
+ {isPaying ? (
+ <>
+
+ Paying...
+ >
+ ) : (
+ <>
+
+ Pay Invoice
+ >
+ )}
+
+ >
+ )}
+ >
+ )}
+
+ {state === "alice_paid" && (
+
+
+ 🔒
+ Your funds are held in the network. Waiting for Bob to complete
+ the flow...
+
+
+ )}
+
+ {state === "bob_paid_charlie" && (
+
+
Bob paid Charlie. Waiting for settlement...
+
+ )}
+
+ {state === "settled" && bobWrapped && (
+
+
+ ✅
+ Payment complete! Paid {bobWrapped.totalAmount} sats.
+
+
+ )}
+
+
+ );
+}
diff --git a/src/data/code-snippets.ts b/src/data/code-snippets.ts
index d1297f4..1669242 100644
--- a/src/data/code-snippets.ts
+++ b/src/data/code-snippets.ts
@@ -49,7 +49,8 @@ export type SnippetId =
| "sign-message"
| "hold-invoice"
| "hold-invoice-settle"
- | "hold-invoice-cancel";
+ | "hold-invoice-cancel"
+ | "wrapped-hold-invoice";
export interface CodeSnippet {
id: SnippetId;
@@ -563,6 +564,38 @@ await client.cancelHoldInvoice({ payment_hash: paymentHash })
console.log('Invoice cancelled! Payer refunded.')`,
category: "advanced",
},
+ {
+ id: "wrapped-hold-invoice",
+ title: "Wrapped Hold Invoice",
+ description:
+ "Create a hold invoice using another invoice's payment hash (for non-custodial intermediary patterns)",
+ code: `// First, decode another invoice to get its payment hash
+const originalInvoice = new Invoice({ pr: 'lnbc...' })
+const paymentHash = originalInvoice.paymentHash
+
+// Create a hold invoice with the SAME payment hash
+// but a higher amount (original + your fee)
+const feeAmount = 100 // sats
+const totalAmount = originalInvoice.satoshi + feeAmount
+
+const response = await bob.makeHoldInvoice({
+ amount: totalAmount * 1000, // millisats
+ description: 'Wrapped invoice',
+ payment_hash: paymentHash, // Use the original invoice's hash
+})
+
+console.log('Wrapped invoice:', response.invoice)
+
+// When someone pays your wrapped invoice:
+// 1. Their payment is HELD (not in your wallet)
+// 2. Pay the original invoice to get the preimage
+// 3. Use the preimage to settle your held payment
+// 4. You keep the fee difference!
+
+// This is non-custodial: you never hold the payer's funds.
+// They remain locked in the network until you settle.`,
+ category: "advanced",
+ },
];
/**
diff --git a/src/data/scenarios.ts b/src/data/scenarios.ts
index 79aefc2..e377c5e 100644
--- a/src/data/scenarios.ts
+++ b/src/data/scenarios.ts
@@ -387,6 +387,45 @@ The flow: Client requests channel → pays hold invoice fee → LSP attempts to
},
],
},
+ {
+ id: "wrapped-invoices",
+ title: "Wrapped Invoices",
+ description:
+ "Act as a non-custodial payment intermediary by wrapping an invoice with a higher amount using the same payment hash.",
+ education:
+ "Wrapped invoices allow you to act as a non-custodial middleman in a payment flow. By creating a hold invoice with the same payment hash as another invoice (but a higher amount), you can collect a fee for facilitating the payment. Crucially, the payer's funds remain locked in the Lightning network - never in your wallet - until you settle. You must use your own liquidity to pay the original invoice first, receiving the preimage, which you then use to settle the held payment and claim your fee. This non-custodial pattern is the manual equivalent of how Lightning routing works.",
+ howItWorks: [
+ {
+ title: "Charlie Creates",
+ description:
+ "Charlie creates a regular invoice. Bob receives it and extracts the payment hash.",
+ },
+ {
+ title: "Bob Wraps",
+ description:
+ "Bob creates a hold invoice with the SAME payment hash but higher amount (adding his fee).",
+ },
+ {
+ title: "Alice Pays Bob",
+ description:
+ "Alice pays Bob's wrapped invoice. Funds are HELD in the network (not in Bob's wallet).",
+ },
+ {
+ title: "Bob Pays Charlie",
+ description:
+ "Bob pays Charlie's original invoice using his OWN funds. He receives the preimage.",
+ },
+ {
+ title: "Bob Settles",
+ description:
+ "Bob uses the preimage to settle Alice's held payment. Bob keeps the fee difference.",
+ },
+ ],
+ complexity: "advanced",
+ requiredWallets: ["alice", "bob", "charlie"],
+ icon: "🎁",
+ snippetIds: ["make-invoice", "wrapped-hold-invoice", "subscribe-hold-notifications", "pay-invoice", "hold-invoice-settle"] satisfies SnippetId[],
+ },
{
id: "decode-bolt11-invoice",
title: "Invoice Decoding",
diff --git a/src/stores/index.ts b/src/stores/index.ts
index cdb084b..6489e46 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -2,4 +2,5 @@ export { useWalletStore } from './wallet-store';
export { useScenarioStore } from './scenario-store';
export { useTransactionStore } from './transaction-store';
export { useHoldInvoiceStore } from './hold-invoice-store';
+export { useWrappedInvoiceStore } from './wrapped-invoice-store';
export { useUIStore } from './ui-store';
diff --git a/src/stores/wrapped-invoice-store.ts b/src/stores/wrapped-invoice-store.ts
new file mode 100644
index 0000000..b9b7ea1
--- /dev/null
+++ b/src/stores/wrapped-invoice-store.ts
@@ -0,0 +1,57 @@
+import { create } from "zustand";
+
+export type WrappedInvoiceState =
+ | "idle"
+ | "charlie_invoice_created"
+ | "bob_wrapped"
+ | "alice_paid"
+ | "bob_paid_charlie"
+ | "settled";
+
+export interface CharlieInvoiceData {
+ invoice: string;
+ paymentHash: string;
+ amount: number;
+}
+
+export interface BobWrappedData {
+ wrappedInvoice: string;
+ preimage: string;
+ paymentHash: string;
+ totalAmount: number;
+ fee: number;
+ charlieAmount: number;
+}
+
+interface WrappedInvoiceStore {
+ state: WrappedInvoiceState;
+ charlieInvoice: CharlieInvoiceData | null;
+ bobWrapped: BobWrappedData | null;
+ receivedPreimage: string | null;
+
+ setState: (state: WrappedInvoiceState) => void;
+ setCharlieInvoice: (data: CharlieInvoiceData | null) => void;
+ setBobWrapped: (data: BobWrappedData | null) => void;
+ setReceivedPreimage: (preimage: string | null) => void;
+ reset: () => void;
+}
+
+export const useWrappedInvoiceStore = create((set) => ({
+ state: "idle",
+ charlieInvoice: null,
+ bobWrapped: null,
+ receivedPreimage: null,
+
+ setState: (state) => set({ state }),
+ setCharlieInvoice: (data) => set({ charlieInvoice: data }),
+ setBobWrapped: (data) => set({ bobWrapped: data }),
+ setReceivedPreimage: (preimage) => set({ receivedPreimage: preimage }),
+
+ reset: () =>
+ set({
+ state: "idle",
+ charlieInvoice: null,
+ bobWrapped: null,
+ receivedPreimage: null,
+ }),
+}));
From 6c79486c4e1d9cdd3d2f5f4e15ba1443cc07bea8 Mon Sep 17 00:00:00 2001
From: Roland Bewick
Date: Fri, 30 Jan 2026 11:36:15 +0700
Subject: [PATCH 2/5] chore: add expert complexity
---
src/components/scenario-info.tsx | 3 ++-
src/data/scenarios.ts | 4 +++-
src/types/scenario.ts | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/components/scenario-info.tsx b/src/components/scenario-info.tsx
index 1e12043..e9ba31d 100644
--- a/src/components/scenario-info.tsx
+++ b/src/components/scenario-info.tsx
@@ -113,11 +113,12 @@ export function ScenarioInfo() {
}
function ComplexityBadge({ complexity }: { complexity: string }) {
- const variants: Record = {
+ const variants: Record = {
simplest: "default",
simple: "default",
medium: "secondary",
advanced: "outline",
+ expert: "destructive",
};
return (
diff --git a/src/data/scenarios.ts b/src/data/scenarios.ts
index e377c5e..6754dfc 100644
--- a/src/data/scenarios.ts
+++ b/src/data/scenarios.ts
@@ -421,7 +421,7 @@ The flow: Client requests channel → pays hold invoice fee → LSP attempts to
"Bob uses the preimage to settle Alice's held payment. Bob keeps the fee difference.",
},
],
- complexity: "advanced",
+ complexity: "expert",
requiredWallets: ["alice", "bob", "charlie"],
icon: "🎁",
snippetIds: ["make-invoice", "wrapped-hold-invoice", "subscribe-hold-notifications", "pay-invoice", "hold-invoice-settle"] satisfies SnippetId[],
@@ -693,6 +693,8 @@ export const scenarios = unorderedScenarios.sort((a, b) => {
return 2;
case "advanced":
return 3;
+ case "expert":
+ return 4;
}
};
return getComplexityIndex(a.complexity) - getComplexityIndex(b.complexity);
diff --git a/src/types/scenario.ts b/src/types/scenario.ts
index 609087f..72b780e 100644
--- a/src/types/scenario.ts
+++ b/src/types/scenario.ts
@@ -1,6 +1,6 @@
import type { SnippetId } from "@/data/code-snippets";
-export type ScenarioComplexity = "simplest" | "simple" | "medium" | "advanced";
+export type ScenarioComplexity = "simplest" | "simple" | "medium" | "advanced" | "expert";
export interface ScenarioPrompt {
title: string;
From 5273abb022051297faf9795d50522e92f251320d Mon Sep 17 00:00:00 2001
From: Roland Bewick
Date: Mon, 2 Feb 2026 19:45:22 +0700
Subject: [PATCH 3/5] chore: allow configuring faucet url via env
---
.env.example | 1 +
.gitignore | 2 ++
src/components/wallet-card.tsx | 6 ++++--
3 files changed, 7 insertions(+), 2 deletions(-)
create mode 100644 .env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..dafb594
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+VITE_FAUCET_URL=https://faucet.nwc.dev
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a547bf3..3b0b403 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+.env
\ No newline at end of file
diff --git a/src/components/wallet-card.tsx b/src/components/wallet-card.tsx
index bb95aeb..0629f66 100644
--- a/src/components/wallet-card.tsx
+++ b/src/components/wallet-card.tsx
@@ -33,6 +33,8 @@ import type { Wallet as WalletType } from "@/types";
import { useWalletStore, useTransactionStore } from "@/stores";
import { useFiatValue } from "@/hooks/use-fiat";
+const FAUCET_URL = import.meta.env.VITE_FAUCET_URL || "https://faucet.nwc.dev";
+
interface WalletCardProps {
wallet: WalletType;
}
@@ -129,7 +131,7 @@ export function WalletCard({ wallet }: WalletCardProps) {
// Create test wallet via faucet API
// Returns plaintext NWC connection secret with lud16 parameter
for (let attempt = 0; ; attempt++) {
- const response = await fetch("https://faucet.nwc.dev?balance=10000", {
+ const response = await fetch(`${FAUCET_URL}?balance=10000`, {
method: "POST",
});
@@ -175,7 +177,7 @@ export function WalletCard({ wallet }: WalletCardProps) {
try {
const response = await fetch(
- `https://faucet.nwc.dev/wallets/${username}/topup?amount=10000`,
+ `${FAUCET_URL}/wallets/${username}/topup?amount=10000`,
{ method: "POST" },
);
if (!response.ok) {
From 493516e5fd360084b7a714df6018d85b6c4469ae Mon Sep 17 00:00:00 2001
From: Roland Bewick
Date: Mon, 2 Feb 2026 20:28:58 +0700
Subject: [PATCH 4/5] chore: improve ux of wrapped invoice scenario
---
src/components/scenarios/hold-invoice.tsx | 17 +-
src/components/scenarios/wrapped-invoices.tsx | 287 +++++++++++++++---
src/stores/wrapped-invoice-store.ts | 3 +-
3 files changed, 255 insertions(+), 52 deletions(-)
diff --git a/src/components/scenarios/hold-invoice.tsx b/src/components/scenarios/hold-invoice.tsx
index c7f12b5..bede8d1 100644
--- a/src/components/scenarios/hold-invoice.tsx
+++ b/src/components/scenarios/hold-invoice.tsx
@@ -93,7 +93,7 @@ function AlicePanel() {
}, [invoiceData]);
const handleNotification = useCallback(
- (notification: Nip47Notification) => {
+ async (notification: Nip47Notification) => {
const currentInvoiceData = invoiceDataRef.current;
if (
notification.notification_type === "hold_invoice_accepted" &&
@@ -103,6 +103,19 @@ function AlicePanel() {
) {
setInvoiceState("held");
+ // Update Bob's balance (funds are now locked)
+ const bobClient = getNWCClient("bob");
+ if (bobClient) {
+ try {
+ const bobBalance = await bobClient.getBalance();
+ const bobBalanceSats = Math.floor(bobBalance.balance / 1000);
+ setWalletBalance("bob", bobBalanceSats);
+ addBalanceSnapshot({ walletId: "bob", balance: bobBalanceSats });
+ } catch (err) {
+ console.error("Failed to update Bob's balance:", err);
+ }
+ }
+
const txId = addTransaction({
type: "payment_received",
status: "pending",
@@ -124,7 +137,7 @@ function AlicePanel() {
heldFlowStepIdRef.current = flowStepId;
}
},
- [addTransaction, addFlowStep, setInvoiceState],
+ [addTransaction, addFlowStep, setInvoiceState, getNWCClient, setWalletBalance, addBalanceSnapshot],
);
const createHoldInvoice = async () => {
diff --git a/src/components/scenarios/wrapped-invoices.tsx b/src/components/scenarios/wrapped-invoices.tsx
index 44861ab..1571ccd 100644
--- a/src/components/scenarios/wrapped-invoices.tsx
+++ b/src/components/scenarios/wrapped-invoices.tsx
@@ -8,6 +8,9 @@ import {
ChevronUp,
Zap,
ArrowRight,
+ XCircle,
+ AlertTriangle,
+ ExternalLink,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -30,16 +33,41 @@ export function WrappedInvoicesScenario() {
reset();
}, [reset]);
+ const previewBanner = (
+
+
+
+
Preview
+
+ This scenario is not fully supported by Alby Hub and NWC. If you need
+ this for your app usecase, please{" "}
+
+ contact Alby
+
+
+
+
+
+ );
+
if (!allConnected) {
- return null;
+ return previewBanner;
}
return (
-
+ <>
+ {previewBanner}
+
+ >
);
}
@@ -52,8 +80,13 @@ function CharliePanel() {
const { getNWCClient } = useWalletStore();
const { addTransaction, updateTransaction, addFlowStep } =
useTransactionStore();
- const { state, charlieInvoice, setCharlieInvoice, setState } =
- useWrappedInvoiceStore();
+ const {
+ state,
+ charlieInvoice,
+ setCharlieInvoice,
+ setState,
+ receivedPreimage,
+ } = useWrappedInvoiceStore();
const createInvoice = async () => {
const client = getNWCClient("charlie");
@@ -128,7 +161,11 @@ function CharliePanel() {
setTimeout(() => setCopied(false), 2000);
};
- const isPaid = state === "bob_paid_charlie" || state === "settled";
+ // Charlie is paid if Bob paid him, or if cancelled after paying (receivedPreimage means Bob paid Charlie)
+ const isPaid =
+ state === "bob_paid_charlie" ||
+ state === "settled" ||
+ (state === "cancelled" && !!receivedPreimage);
return (
@@ -247,6 +284,7 @@ function BobPanel() {
const [isWrapping, setIsWrapping] = useState(false);
const [isPayingCharlie, setIsPayingCharlie] = useState(false);
const [isSettling, setIsSettling] = useState(false);
+ const [isCancelling, setIsCancelling] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [error, setError] = useState(null);
const unsubRef = useRef<(() => void) | null>(null);
@@ -276,7 +314,7 @@ function BobPanel() {
}, [bobWrapped]);
const handleNotification = useCallback(
- (notification: Nip47Notification) => {
+ async (notification: Nip47Notification) => {
console.log("=== BOB RECEIVED NOTIFICATION ===");
console.log("Type:", notification.notification_type);
console.log("Full notification:", JSON.stringify(notification, null, 2));
@@ -293,6 +331,22 @@ function BobPanel() {
if (currentBobWrapped) {
setState("alice_paid");
+ // Update Alice's balance (funds are now locked)
+ const aliceClient = getNWCClient("alice");
+ if (aliceClient) {
+ try {
+ const aliceBalance = await aliceClient.getBalance();
+ const aliceBalanceSats = Math.floor(aliceBalance.balance / 1000);
+ setWalletBalance("alice", aliceBalanceSats);
+ addBalanceSnapshot({
+ walletId: "alice",
+ balance: aliceBalanceSats,
+ });
+ } catch (err) {
+ console.error("Failed to update Alice's balance:", err);
+ }
+ }
+
addTransaction({
type: "payment_received",
status: "pending",
@@ -313,7 +367,14 @@ function BobPanel() {
}
}
},
- [addTransaction, addFlowStep, setState],
+ [
+ addTransaction,
+ addFlowStep,
+ setState,
+ getNWCClient,
+ setWalletBalance,
+ addBalanceSnapshot,
+ ],
);
const createWrappedInvoice = async () => {
@@ -575,6 +636,71 @@ function BobPanel() {
}
};
+ const cancelAlicePayment = async () => {
+ if (!bobWrapped) return;
+
+ const client = getNWCClient("bob");
+ if (!client) return;
+
+ setIsCancelling(true);
+ setError(null);
+
+ const txId = addTransaction({
+ type: "payment_failed",
+ status: "pending",
+ toWallet: "bob",
+ amount: bobWrapped.totalAmount,
+ description: `Bob cancelling Alice's payment...`,
+ snippetIds: ["hold-invoice-cancel"],
+ });
+
+ try {
+ await client.cancelHoldInvoice({ payment_hash: bobWrapped.paymentHash });
+
+ setState("cancelled");
+
+ // Update Alice's balance (refunded)
+ const aliceClient = getNWCClient("alice");
+ if (aliceClient) {
+ const aliceBalance = await aliceClient.getBalance();
+ const aliceBalanceSats = Math.floor(aliceBalance.balance / 1000);
+ setWalletBalance("alice", aliceBalanceSats);
+ addBalanceSnapshot({ walletId: "alice", balance: aliceBalanceSats });
+ }
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Alice's payment cancelled - refunded ${bobWrapped.totalAmount} sats`,
+ });
+
+ addFlowStep({
+ fromWallet: "bob",
+ toWallet: "bob",
+ label: `❌ Cancelled! Alice refunded ${bobWrapped.totalAmount} sats`,
+ direction: "right",
+ status: "error",
+ snippetIds: ["hold-invoice-cancel"],
+ });
+
+ // Cleanup
+ if (unsubRef.current) {
+ unsubRef.current();
+ unsubRef.current = null;
+ }
+ } catch (err) {
+ console.error("Failed to cancel:", err);
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setError(errorMessage);
+
+ updateTransaction(txId, {
+ status: "error",
+ description: `Failed to cancel: ${errorMessage}`,
+ });
+ } finally {
+ setIsCancelling(false);
+ }
+ };
+
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -688,31 +814,48 @@ function BobPanel() {
🔒 Alice's payment HELD ({bobWrapped.totalAmount} sats)
- Funds are locked in the network, NOT in your wallet.
+ Alice's funds are currently locked by Bob.
- Now pay Charlie with YOUR funds to get the preimage.
+ Bob cannot settle Alice's payment without receiving the preimage
+ from paying Charlie.
+
+ Pay Charlie to continue, or cancel to refund Alice.
{error && {error}
}
-
- {isPayingCharlie ? (
- <>
-
- Paying Charlie...
- >
- ) : (
- <>
-
- Pay Charlie {charlieInvoice?.amount} sats
- >
- )}
-
+
+
+ {isPayingCharlie ? (
+
+ ) : (
+ <>
+
+ Pay Charlie
+ >
+ )}
+
+
+ {isCancelling ? (
+
+ ) : (
+ <>
+
+ Cancel (Refund)
+ >
+ )}
+
+
>
)}
@@ -721,29 +864,45 @@ function BobPanel() {
✅ Charlie Paid!
- Preimage received. Now settle Alice's payment to claim your fee.
+ Preimage received. Now settle Alice's payment to claim your fee,
+ or cancel to refund Alice (But Bob already paid Charlie, so Bob
+ will be at a loss!).
{error && {error}
}
-
- {isSettling ? (
- <>
-
- Settling...
- >
- ) : (
- <>
-
- Settle & Claim {bobWrapped.fee} sats fee
- >
- )}
-
+
+
+ {isSettling ? (
+
+ ) : (
+ <>
+
+ Settle (+{bobWrapped.fee})
+ >
+ )}
+
+
+ {isCancelling ? (
+
+ ) : (
+ <>
+
+ Cancel (Refund)
+ >
+ )}
+
+
>
)}
@@ -759,6 +918,24 @@ function BobPanel() {
)}
+ {state === "cancelled" && bobWrapped && (
+
+
❌ Cancelled
+
+ Alice's payment was refunded.
+ {receivedPreimage && (
+ <>
+
+ Note: You already paid Charlie{" "}
+
+ {bobWrapped.charlieAmount} sats
+
+ >
+ )}
+
+
+ )}
+
{(bobWrapped || receivedPreimage) && (
<>
{
if (!invoiceToUse) return;
@@ -895,6 +1073,7 @@ function AlicePanel() {
const getStatusText = () => {
if (state === "settled") return "Payment Complete";
+ if (state === "cancelled") return "Cancelled (Refunded)";
if (state === "bob_paid_charlie") return "Pending (Bob settling...)";
if (state === "alice_paid") return "Pending (Held in network)";
if (isPaying) return "Paying...";
@@ -903,6 +1082,7 @@ function AlicePanel() {
const getStatusColor = () => {
if (state === "settled") return "text-green-600 dark:text-green-400";
+ if (state === "cancelled") return "text-red-600 dark:text-red-400";
if (hasPaid) return "text-orange-600 dark:text-orange-400";
return "text-muted-foreground";
};
@@ -1008,6 +1188,15 @@ function AlicePanel() {
)}
+
+ {state === "cancelled" && bobWrapped && (
+
+
+ ❌
+ Payment cancelled. Your funds have been refunded.
+
+
+ )}
);
diff --git a/src/stores/wrapped-invoice-store.ts b/src/stores/wrapped-invoice-store.ts
index b9b7ea1..1d3c1a6 100644
--- a/src/stores/wrapped-invoice-store.ts
+++ b/src/stores/wrapped-invoice-store.ts
@@ -6,7 +6,8 @@ export type WrappedInvoiceState =
| "bob_wrapped"
| "alice_paid"
| "bob_paid_charlie"
- | "settled";
+ | "settled"
+ | "cancelled";
export interface CharlieInvoiceData {
invoice: string;
From c436e01ab49b554f45260e3e0075834ca179eda4 Mon Sep 17 00:00:00 2001
From: Roland Bewick
Date: Mon, 2 Feb 2026 20:29:08 +0700
Subject: [PATCH 5/5] chore: add notes and warning to wrapped invoice scenario
---
src/components/scenarios/wrapped-invoices.tsx | 4 ++--
src/data/scenarios.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/scenarios/wrapped-invoices.tsx b/src/components/scenarios/wrapped-invoices.tsx
index 1571ccd..57e7471 100644
--- a/src/components/scenarios/wrapped-invoices.tsx
+++ b/src/components/scenarios/wrapped-invoices.tsx
@@ -39,8 +39,8 @@ export function WrappedInvoicesScenario() {
Preview
- This scenario is not fully supported by Alby Hub and NWC. If you need
- this for your app usecase, please{" "}
+ This scenario is not fully supported by Alby Hub. If you need this for
+ your app usecase, please{" "}