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/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-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/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/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/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..57e7471 --- /dev/null +++ b/src/components/scenarios/wrapped-invoices.tsx @@ -0,0 +1,1203 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { + Loader2, + Copy, + Check, + Lock, + ChevronDown, + ChevronUp, + Zap, + ArrowRight, + XCircle, + AlertTriangle, + ExternalLink, +} 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]); + + const previewBanner = ( +
+ +
+

Preview

+

+ This scenario is not fully supported by Alby Hub. If you need this for + your app usecase, please{" "} + + contact Alby + + +

+
+
+ ); + + if (!allConnected) { + return previewBanner; + } + + return ( + <> + {previewBanner} +
+ + + +
+ + ); +} + +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, + receivedPreimage, + } = 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); + }; + + // 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 ( + + + + {WALLET_PERSONAS.charlie.emoji} + Charlie: Create Invoice + + + + {state === "idle" && ( + <> +
+

+ Charlie creates a regular invoice. Bob will wrap it to act as + intermediary. +

+
+ +
+ + setAmount(e.target.value)} + placeholder="1000" + disabled={isCreating} + /> +
+ + {error &&

{error}

} + + + + )} + + {state !== "idle" && charlieInvoice && ( + <> +
+ Amount: + + {charlieInvoice.amount} sats + +
+ + {!isPaid && ( + <> +
+

+ {charlieInvoice.invoice.substring(0, 60)}... +

+ +
+

+ + + + + 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 [isCancelling, setIsCancelling] = 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( + async (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"); + + // 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", + 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, + getNWCClient, + setWalletBalance, + addBalanceSnapshot, + ], + ); + + 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); + } + }; + + 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 () => { + 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)}... +

+
+ +
+ + 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}

} + + + + )} + + {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) +

+

+ Alice's funds are currently locked by Bob. +
+ Bob cannot settle Alice's payment without receiving the preimage + from paying Charlie. +
+ Pay Charlie to continue, or cancel to refund Alice. +

+
+ + {error &&

{error}

} + +
+ + +
+ + )} + + {state === "bob_paid_charlie" && bobWrapped && receivedPreimage && ( + <> +
+

✅ Charlie Paid!

+

+ 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}

} + +
+ + +
+ + )} + + {state === "settled" && bobWrapped && ( +
+

🎉 Success!

+

+ You acted as a non-custodial intermediary. +
+ Fee earned:{" "} + {bobWrapped.fee} sats +

+
+ )} + + {state === "cancelled" && bobWrapped && ( +
+

❌ Cancelled

+

+ Alice's payment was refunded. + {receivedPreimage && ( + <> +
+ Note: You already paid Charlie{" "} + + {bobWrapped.charlieAmount} sats + + + )} +

+
+ )} + + {(bobWrapped || receivedPreimage) && ( + <> + + + {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" || + state === "cancelled"; + + 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 === "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..."; + return "Not Paid"; + }; + + 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"; + }; + + 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... +
+ ) : ( + <> +
+ + 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}

} + + + + )} + + )} + + {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. +

+
+ )} + + {state === "cancelled" && bobWrapped && ( +
+

+ + Payment cancelled. Your funds have been refunded. +

+
+ )} +
+
+ ); +} 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) { 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..3b566a7 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.\n\n⚠️ Important: Bob's wrapped invoice must have a higher min_final_cltv_expiry_delta than Charlie's original invoice. This ensures Bob has enough time to settle Alice's payment after receiving the preimage from paying Charlie. If the deltas are misconfigured, Bob risks Alice's payment timing out before he can settle it.", + 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: "expert", + 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", @@ -654,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/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..1d3c1a6 --- /dev/null +++ b/src/stores/wrapped-invoice-store.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; + +export type WrappedInvoiceState = + | "idle" + | "charlie_invoice_created" + | "bob_wrapped" + | "alice_paid" + | "bob_paid_charlie" + | "settled" + | "cancelled"; + +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, + }), +})); 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;