diff --git a/app/layout.tsx b/app/layout.tsx index 35108a8..ad6fb59 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,10 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import ClientProviders from "@/components/ClientProviders"; +import dynamic from "next/dynamic"; +const ClientProviders = dynamic(() => import("@/components/ClientProviders"), { + ssr: false, +}); import { Toaster } from "@/components/ui/sonner"; import BitcoinConnectClient from "@/components/bitcoin-connect/BitcoinConnectClient"; import SWUpdater from "@/components/SWUpdater"; diff --git a/components/TopUpPromptModal.tsx b/components/TopUpPromptModal.tsx index d9e7f3c..2570eec 100644 --- a/components/TopUpPromptModal.tsx +++ b/components/TopUpPromptModal.tsx @@ -49,6 +49,7 @@ import { DEFAULT_MINT_URL } from "@/lib/utils"; import { toast } from "sonner"; import { ModalShell } from "@/components/ui/ModalShell"; import CloseButton from "@/components/ui/CloseButton"; +import CryptoDepositPanel from "@/features/wallet/components/CryptoDepositPanel"; import { requestBitcoinConnectProvider, useBitcoinConnectStatus, @@ -103,9 +104,9 @@ const TopUpPromptModal: React.FC = ({ } = useBitcoinConnectStatus(); const [cashuToken, setCashuToken] = useState(""); const [isReceivingToken, setIsReceivingToken] = useState(false); - const [activeTab, setActiveTab] = useState<"lightning" | "token" | "wallet">( - "lightning" - ); + const [activeTab, setActiveTab] = useState< + "lightning" | "crypto" | "token" | "wallet" + >("lightning"); const [nwcCustomAmount, setNwcCustomAmount] = useState(""); const [isPayingWithNWC, setIsPayingWithNWC] = useState(false); const [activePage, setActivePage] = useState<"topup" | "login">( @@ -783,6 +784,7 @@ const TopUpPromptModal: React.FC = ({ { key: "lightning" as const, label: "Lightning" }, { key: "token" as const, label: "Token" }, { key: "wallet" as const, label: "NWC" }, + { key: "crypto" as const, label: "Crypto" }, ]; const topUpTabButtonBase = @@ -989,6 +991,11 @@ const TopUpPromptModal: React.FC = ({ )} + {/* Crypto Tab */} + {activeTab === "crypto" && ( + + )} + {/* Wallet Tab */} {activeTab === "wallet" && (
@@ -1487,7 +1494,7 @@ const TopUpPromptModal: React.FC = ({ }} >
{headerTitle} diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index 59b7a81..9345ad0 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -55,6 +55,7 @@ const ChatContainer: React.FC = ({ return (
{/* Mobile Sidebar Overlay */} {isMobile && isAuthenticated && ( diff --git a/components/ui/ModalShell.tsx b/components/ui/ModalShell.tsx index 1659ac3..939067d 100644 --- a/components/ui/ModalShell.tsx +++ b/components/ui/ModalShell.tsx @@ -1,6 +1,7 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; interface ModalShellProps { @@ -30,7 +31,14 @@ export const ModalShell: React.FC = ({ contentRole = "dialog", contentAriaLabel, }) => { - if (!open) return null; + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + if (!open || !mounted) return null; const shouldStopPropagation = stopPropagation ?? (closeOnAnyClick ? false : true); @@ -46,10 +54,10 @@ export const ModalShell: React.FC = ({ } }; - return ( + return createPortal(
= ({ > {children}
-
+
, + document.body ); }; diff --git a/features/wallet/components/BalanceDisplay.tsx b/features/wallet/components/BalanceDisplay.tsx index ead486c..4c44ff6 100644 --- a/features/wallet/components/BalanceDisplay.tsx +++ b/features/wallet/components/BalanceDisplay.tsx @@ -54,6 +54,7 @@ import BalancePopoverHeader from "@/features/wallet/components/balance/BalancePo import BalanceOverviewTab from "@/features/wallet/components/balance/BalanceOverviewTab"; import BalanceActivityTab from "@/features/wallet/components/balance/BalanceActivityTab"; import BalanceInvoiceTab from "@/features/wallet/components/balance/BalanceInvoiceTab"; +import CryptoDepositPanel from "@/features/wallet/components/CryptoDepositPanel"; /** * User balance and authentication status component with comprehensive wallet popover @@ -112,7 +113,7 @@ const BalanceDisplay: React.FC = ({ const [isPayingInvoice, setIsPayingInvoice] = useState(false); // Receive state - const [receiveTab, setReceiveTab] = useState<"lightning" | "token">( + const [receiveTab, setReceiveTab] = useState<"lightning" | "crypto" | "token">( "lightning" ); const [mintAmount, setMintAmount] = useState(""); @@ -1483,7 +1484,7 @@ const BalanceDisplay: React.FC = ({ {/* Note about msats if using msat unit */} {msatNote} - {/* Sub-tabs for Lightning/Token */} + {/* Sub-tabs for Lightning/Token/Crypto */}
+
{receiveTab === "lightning" && ( @@ -1613,6 +1621,10 @@ const BalanceDisplay: React.FC = ({
)} + + {receiveTab === "crypto" && ( + + )} )} diff --git a/features/wallet/components/CryptoDepositPanel.tsx b/features/wallet/components/CryptoDepositPanel.tsx new file mode 100644 index 0000000..6948360 --- /dev/null +++ b/features/wallet/components/CryptoDepositPanel.tsx @@ -0,0 +1,642 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { + AlertCircle, + Copy, + Loader2, + ChevronDown, + ExternalLink, + CheckCircle, + RefreshCw, +} from "lucide-react"; +import QRCode from "react-qr-code"; +import { toast } from "sonner"; +import { MintQuoteState } from "@cashu/cashu-ts"; + +import { + useCashuWallet, + useCashuStore, + useTransactionHistoryStore, + formatBalance, +} from "@/features/wallet"; +import { useChat } from "@/context/ChatProvider"; +import { createLightningInvoice, mintTokensFromPaidInvoice } from "@/lib/cashuLightning"; +import { useInvoiceSync } from "@/hooks/useInvoiceSync"; +import { createPendingTransaction } from "@/utils/transactionUtils"; +import { + SUPPORTED_TOKENS, + createEvmToLightningSwap, + getQuote, + getSwapStatus, + generateUserId, + isSwapComplete, + isSwapSucceeded, + isSwapFailed, + getStatusMessage, + type EvmToBtcSwapResponse, + type SwapStatus, +} from "@/lib/lendaswap"; + +type SupportedChain = "Polygon" | "Ethereum" | "Arbitrum"; + +type TokenOption = { + chain: SupportedChain; + token: { + tokenId: string; + symbol: string; + name: string; + decimals: number; + }; +}; + +interface CryptoDepositPanelProps { + initialAmount?: number; + className?: string; +} + +const CHAIN_ICONS: Record = { + Polygon: "🟣", + Ethereum: "⟠", + Arbitrum: "🔵", +}; + +const CHAIN_COLORS: Record = { + Polygon: "from-purple-500/20 to-purple-600/10 border-purple-500/30", + Ethereum: "from-blue-500/20 to-blue-600/10 border-blue-500/30", + Arbitrum: "from-sky-500/20 to-sky-600/10 border-sky-500/30", +}; + +const BLOCK_EXPLORERS: Record = { + Polygon: "https://polygonscan.com/address/", + Ethereum: "https://etherscan.io/address/", + Arbitrum: "https://arbiscan.io/address/", +}; + +const CryptoDepositPanel: React.FC = ({ + initialAmount, + className, +}) => { + const options = useMemo(() => { + return (Object.keys(SUPPORTED_TOKENS) as SupportedChain[]).flatMap( + (chain) => + SUPPORTED_TOKENS[chain].map((token) => ({ + chain, + token, + })) + ); + }, []); + + const [selectedOption, setSelectedOption] = useState( + options[0] + ); + const [amount, setAmount] = useState(initialAmount?.toString() || ""); + const [isOptionDropdownOpen, setIsOptionDropdownOpen] = useState(false); + + const [isCreatingSwap, setIsCreatingSwap] = useState(false); + const [swap, setSwap] = useState(null); + const [swapStatus, setSwapStatus] = useState(null); + const [lastStatusAt, setLastStatusAt] = useState(null); + const [quote, setQuote] = useState<{ + exchangeRate: string; + stableAmount: number; + protocolFee: number; + networkFee: number; + } | null>(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const pollIntervalRef = useRef | null>(null); + const pendingTxIdRef = useRef(null); + const quoteIdRef = useRef(null); + + const { updateProofs } = useCashuWallet(); + const cashuStore = useCashuStore(); + const { activeAccount } = useChat(); + const transactionHistoryStore = useTransactionHistoryStore(); + const { addInvoice, updateInvoice } = useInvoiceSync(); + + useEffect(() => { + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, []); + + useEffect(() => { + if (!options.length) return; + setSelectedOption((current) => { + const exists = options.find( + (option) => + option.chain === current.chain && + option.token.tokenId === current.token.tokenId + ); + return exists ?? options[0]; + }); + }, [options]); + + const fetchQuote = useCallback(async () => { + const satsAmount = parseInt(amount); + if (isNaN(satsAmount) || satsAmount <= 0) { + setQuote(null); + return; + } + + try { + console.info("[CryptoDeposit] Fetching quote", { + satsAmount, + from: selectedOption.token.tokenId, + to: "btc_lightning", + }); + const quoteResponse = await getQuote( + selectedOption.token.tokenId, + "btc_lightning", + satsAmount + ); + + const exchangeRate = parseFloat(quoteResponse.exchange_rate); + const stableAmount = (satsAmount / 100_000_000) * exchangeRate; + + console.info("[CryptoDeposit] Quote response", { + exchangeRate: quoteResponse.exchange_rate, + stableAmount, + protocolFee: quoteResponse.protocol_fee, + networkFee: quoteResponse.network_fee, + }); + setQuote({ + exchangeRate: quoteResponse.exchange_rate, + stableAmount, + protocolFee: quoteResponse.protocol_fee, + networkFee: quoteResponse.network_fee, + }); + } catch (err) { + console.info("[CryptoDeposit] Quote error", err); + console.error("Failed to fetch quote:", err); + } + }, [amount, selectedOption.token.tokenId]); + + useEffect(() => { + const debounce = setTimeout(fetchQuote, 500); + return () => clearTimeout(debounce); + }, [fetchQuote]); + + const pollSwapStatus = useCallback( + async (swapId: string) => { + try { + console.info("[CryptoDeposit] Polling swap status", { swapId }); + const status = await getSwapStatus(swapId); + console.info("[CryptoDeposit] Swap status", { + swapId, + status: status.status, + sourceAmount: status.source_amount, + targetAmount: status.target_amount, + }); + setSwapStatus(status.status); + setLastStatusAt(Date.now()); + + if (isSwapComplete(status.status)) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + if (isSwapSucceeded(status.status)) { + const mintUrl = cashuStore.activeMintUrl; + const quoteId = quoteIdRef.current; + const satsAmount = parseInt(amount); + + if (mintUrl && quoteId && satsAmount > 0) { + try { + console.info("[CryptoDeposit] Swap complete, minting", { + mintUrl, + quoteId, + satsAmount, + }); + const proofs = await mintTokensFromPaidInvoice( + mintUrl, + quoteId, + satsAmount + ); + + if (proofs.length > 0) { + console.info("[CryptoDeposit] Minted proofs", { + count: proofs.length, + mintUrl, + quoteId, + }); + await updateProofs({ + mintUrl, + proofsToAdd: proofs, + proofsToRemove: [], + }); + + await updateInvoice(quoteId, { + state: MintQuoteState.PAID, + paidAt: Date.now(), + }); + + if (pendingTxIdRef.current) { + transactionHistoryStore.removePendingTransaction( + pendingTxIdRef.current + ); + } + + setSuccess( + `Successfully received ${formatBalance(satsAmount, "sats")}!` + ); + toast.success( + `Received ${formatBalance(satsAmount, "sats")} via crypto swap!` + ); + } + } catch (err) { + console.info("[CryptoDeposit] Minting error", err); + console.error("Failed to mint tokens:", err); + setError( + "Swap completed but failed to mint tokens. Please check your wallet." + ); + } + } + } else if (isSwapFailed(status.status)) { + setError(`Swap failed: ${getStatusMessage(status.status)}`); + } + } + } catch (err) { + console.info("[CryptoDeposit] Polling error", err); + console.error("Failed to poll swap status:", err); + } + }, + [amount, cashuStore.activeMintUrl, transactionHistoryStore, updateInvoice, updateProofs] + ); + + const handleCreateSwap = async (): Promise => { + const satsAmount = parseInt(amount); + if (isNaN(satsAmount) || satsAmount <= 0) { + setError("Please enter a valid amount"); + return; + } + + if (!cashuStore.activeMintUrl) { + setError("No active mint selected. Please configure your wallet first."); + return; + } + + setIsCreatingSwap(true); + setError(null); + + try { + console.info("[CryptoDeposit] Creating swap", { + satsAmount, + mintUrl: cashuStore.activeMintUrl, + chain: selectedOption.chain, + token: selectedOption.token.tokenId, + }); + const invoiceData = await createLightningInvoice( + cashuStore.activeMintUrl, + satsAmount + ); + + console.info("[CryptoDeposit] Lightning invoice created", { + quoteId: invoiceData.quoteId, + expiresAt: invoiceData.expiresAt, + }); + quoteIdRef.current = invoiceData.quoteId; + + await addInvoice({ + type: "mint", + mintUrl: cashuStore.activeMintUrl, + quoteId: invoiceData.quoteId, + paymentRequest: invoiceData.paymentRequest, + amount: satsAmount, + state: MintQuoteState.UNPAID, + expiresAt: invoiceData.expiresAt, + }); + + const pendingTransaction = createPendingTransaction({ + direction: "in", + amount: satsAmount, + mintUrl: cashuStore.activeMintUrl, + quoteId: invoiceData.quoteId, + paymentRequest: invoiceData.paymentRequest, + }); + + transactionHistoryStore.addPendingTransaction(pendingTransaction); + pendingTxIdRef.current = pendingTransaction.id; + + const userAddress = "0x0000000000000000000000000000000000000000"; + const userId = generateUserId(activeAccount?.pubkey ?? null); + + console.info("[CryptoDeposit] Creating LendaSwap", { + chain: selectedOption.chain, + token: selectedOption.token.tokenId, + userId, + }); + const swapResponse = await createEvmToLightningSwap(selectedOption.chain, { + bolt11_invoice: invoiceData.paymentRequest, + source_token: selectedOption.token.tokenId, + user_address: userAddress, + user_id: userId, + }); + + console.info("[CryptoDeposit] Swap created", { + swapId: swapResponse.id, + status: swapResponse.status, + address: swapResponse.htlc_address_evm, + }); + setSwap(swapResponse); + setSwapStatus(swapResponse.status); + setLastStatusAt(Date.now()); + + void pollSwapStatus(swapResponse.id); + pollIntervalRef.current = setInterval(() => { + pollSwapStatus(swapResponse.id); + }, 5000); + } catch (err) { + console.info("[CryptoDeposit] Swap creation error", err); + console.error("Failed to create swap:", err); + setError( + err instanceof Error ? err.message : "Failed to create swap. Please try again." + ); + } finally { + setIsCreatingSwap(false); + } + }; + + const copyAddress = (): void => { + if (swap?.htlc_address_evm) { + navigator.clipboard.writeText(swap.htlc_address_evm); + toast.success("Address copied to clipboard"); + } + }; + + const handleReset = (): void => { + setSwap(null); + setSwapStatus(null); + setLastStatusAt(null); + setQuote(null); + setError(null); + setSuccess(null); + setAmount(""); + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }; + + const statusLabel = swapStatus ? getStatusMessage(swapStatus) : "Pending"; + const lastCheckedLabel = lastStatusAt + ? new Date(lastStatusAt).toLocaleTimeString() + : "Never"; + + return ( +
+
+ {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + {!swap ? ( +
+
+ +
+ + + {isOptionDropdownOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+
+ +
+ + setAmount(e.target.value)} + placeholder="e.g., 10000" + className="w-full px-4 py-3 rounded-md border border-border bg-muted/30 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {quote && ( +
+
+ You send + + ~{quote.stableAmount.toFixed(2)} {selectedOption.token.symbol} + +
+
+ Exchange rate + + 1 BTC = ${parseFloat(quote.exchangeRate).toLocaleString()} + +
+
+ Network fee + + {quote.networkFee} sats + +
+
+ )} + + +
+ ) : ( +
+
+
+
+ + {statusLabel} + +
+
+ Last check: {lastCheckedLabel} + {!isSwapComplete(swapStatus!) && ( + + )} +
+
+ +
+ +
+ +
+ +
+ +
+
+
+ Send exactly +
+
+ {swap.source_amount.toFixed( + selectedOption.token.decimals > 2 ? 6 : 2 + )}{" "} + {selectedOption.token.symbol} +
+
+ +
+
+ To address on {selectedOption.chain} +
+
+ + {swap.htlc_address_evm} + + +
+
+ + + View on block explorer + + +
+ +
+
You will receive
+
+ {formatBalance(swap.target_amount, "sats")} +
+
+ +
+ +
+ + {!isSwapComplete(swapStatus!) && ( +

+ The swap will update once your USDC/USDT deposit is detected and + confirmed. This typically takes 1-2 minutes. +

+ )} +
+ )} +
+
+ ); +}; + +export default CryptoDepositPanel; diff --git a/features/wallet/components/DepositModal.tsx b/features/wallet/components/DepositModal.tsx index b52b3a6..71ce536 100644 --- a/features/wallet/components/DepositModal.tsx +++ b/features/wallet/components/DepositModal.tsx @@ -30,6 +30,7 @@ import dynamic from "next/dynamic"; import { toast } from "sonner"; import { ModalShell } from "@/components/ui/ModalShell"; import CloseButton from "@/components/ui/CloseButton"; +import CryptoDepositPanel from "@/features/wallet/components/CryptoDepositPanel"; const BCButton = dynamic( () => import("@getalby/bitcoin-connect-react").then((m) => m.Button), @@ -537,6 +538,12 @@ const DepositModal: React.FC = ({
+ + {/* Crypto Deposit Section */} +
+

Via Crypto

+ +
); diff --git a/features/wallet/components/index.ts b/features/wallet/components/index.ts index c167a4d..1bd8d8f 100644 --- a/features/wallet/components/index.ts +++ b/features/wallet/components/index.ts @@ -4,6 +4,7 @@ export { default as BalanceDisplay } from "./BalanceDisplay"; export { default as DepositModal } from "./DepositModal"; +export { default as CryptoDepositPanel } from "./CryptoDepositPanel"; export { default as SixtyWallet } from "./SixtyWallet"; export { default as WalletTab } from "./WalletTab"; export { default as UnifiedWallet } from "./UnifiedWallet"; diff --git a/hooks/useInvoiceChecker.ts b/hooks/useInvoiceChecker.ts index 3be52c0..b3e6ff8 100644 --- a/hooks/useInvoiceChecker.ts +++ b/hooks/useInvoiceChecker.ts @@ -21,11 +21,23 @@ export function useInvoiceChecker() { const checkMintInvoice = useCallback( async (invoice: StoredInvoice) => { try { + console.info("[InvoiceChecker] Checking mint invoice", { + id: invoice.id, + mintUrl: invoice.mintUrl, + quoteId: invoice.quoteId, + amount: invoice.amount, + state: invoice.state, + }); const mint = new Mint(invoice.mintUrl); const wallet = new Wallet(mint); await wallet.loadMint(); const quoteStatus = await wallet.checkMintQuote(invoice.quoteId); + console.info("[InvoiceChecker] Mint quote status", { + id: invoice.id, + quoteId: invoice.quoteId, + state: quoteStatus.state, + }); if ( (quoteStatus.state === MintQuoteState.PAID || @@ -42,12 +54,21 @@ export function useInvoiceChecker() { // Only try to mint if state is PAID (not ISSUED, which means tokens already exist) if (quoteStatus.state === MintQuoteState.PAID) { try { + console.info("[InvoiceChecker] Minting proofs", { + id: invoice.id, + quoteId: invoice.quoteId, + amount: invoice.amount, + }); const proofs = await wallet.mintProofs( invoice.amount, invoice.quoteId ); if (proofs.length > 0) { + console.info("[InvoiceChecker] Minted proofs", { + id: invoice.id, + count: proofs.length, + }); // Add proofs to store cashuStore.addProofs(proofs, `invoice-${invoice.id}`); @@ -83,9 +104,14 @@ export function useInvoiceChecker() { "Error minting tokens for paid invoice:", mintError ); + console.info("[InvoiceChecker] Minting error details", mintError); // Check if tokens were already issued (in case of race condition) try { + console.info("[InvoiceChecker] Rechecking mint quote", { + id: invoice.id, + quoteId: invoice.quoteId, + }); const recheckStatus = await wallet.checkMintQuote( invoice.quoteId ); @@ -96,6 +122,10 @@ export function useInvoiceChecker() { invoice.quoteId ); if (proofs.length > 0) { + console.info("[InvoiceChecker] Recovered proofs", { + id: invoice.id, + count: proofs.length, + }); cashuStore.addProofs(proofs, `invoice-${invoice.id}`); await updateInvoice(invoice.id, { state: MintQuoteState.ISSUED, @@ -123,6 +153,10 @@ export function useInvoiceChecker() { } } } catch (recoveryError) { + console.info( + "[InvoiceChecker] Recovery error", + recoveryError + ); console.error("Failed to recover tokens:", recoveryError); } @@ -140,11 +174,19 @@ export function useInvoiceChecker() { ); try { + console.info("[InvoiceChecker] Attempting issued recovery", { + id: invoice.id, + quoteId: invoice.quoteId, + }); const proofs = await wallet.mintProofs( invoice.amount, invoice.quoteId ); if (proofs.length > 0) { + console.info("[InvoiceChecker] Issued recovery proofs", { + id: invoice.id, + count: proofs.length, + }); cashuStore.addProofs(proofs, `invoice-${invoice.id}`); // Only show success if balance actually increased (tokens were recovered) @@ -178,6 +220,10 @@ export function useInvoiceChecker() { } catch (recoveryError: any) { // Silently ignore "already issued" errors - this is normal if (!recoveryError?.message?.includes("already issued")) { + console.info( + "[InvoiceChecker] Issued recovery error", + recoveryError + ); console.error( "Failed to recover issued tokens:", recoveryError @@ -191,11 +237,21 @@ export function useInvoiceChecker() { } } else if (quoteStatus.state !== invoice.state) { // Just update the state if it changed + console.info("[InvoiceChecker] Updating invoice state", { + id: invoice.id, + from: invoice.state, + to: quoteStatus.state, + }); await updateInvoice(invoice.id, { state: quoteStatus.state }); } return false; } catch (error) { + console.info("[InvoiceChecker] Mint invoice check error", { + id: invoice.id, + quoteId: invoice.quoteId, + error, + }); console.error(`Error checking mint invoice ${invoice.id}:`, error); // Update retry count and next retry time @@ -221,11 +277,23 @@ export function useInvoiceChecker() { const checkMeltInvoice = useCallback( async (invoice: StoredInvoice) => { try { + console.info("[InvoiceChecker] Checking melt invoice", { + id: invoice.id, + mintUrl: invoice.mintUrl, + quoteId: invoice.quoteId, + amount: invoice.amount, + state: invoice.state, + }); const mint = new Mint(invoice.mintUrl); const wallet = new Wallet(mint); await wallet.loadMint(); const quoteStatus = await wallet.checkMeltQuote(invoice.quoteId); + console.info("[InvoiceChecker] Melt quote status", { + id: invoice.id, + quoteId: invoice.quoteId, + state: quoteStatus.state, + }); if ( quoteStatus.state === MeltQuoteState.PAID && @@ -254,6 +322,11 @@ export function useInvoiceChecker() { return false; } catch (error) { + console.info("[InvoiceChecker] Melt invoice check error", { + id: invoice.id, + quoteId: invoice.quoteId, + error, + }); console.error(`Error checking melt invoice ${invoice.id}:`, error); // Update retry count and next retry time @@ -286,6 +359,9 @@ export function useInvoiceChecker() { const pending = getPendingInvoices(); if (pending.length === 0) return; + console.info("[InvoiceChecker] Checking pending invoices", { + count: pending.length, + }); setIsChecking(true); lastCheckRef.current = now; @@ -303,9 +379,14 @@ export function useInvoiceChecker() { (r) => r.status === "fulfilled" && r.value ).length; + console.info("[InvoiceChecker] Pending invoice results", { + successCount, + total: results.length, + }); if (successCount > 0) { } } catch (error) { + console.info("[InvoiceChecker] Pending check error", error); console.error("Error checking pending invoices:", error); } finally { setIsChecking(false); @@ -321,6 +402,7 @@ export function useInvoiceChecker() { // Set up automatic checking interval useEffect(() => { // Check immediately on mount + console.info("[InvoiceChecker] Initial check"); checkPendingInvoices(); // Clean up old invoices on mount diff --git a/lib/lendaswap.ts b/lib/lendaswap.ts new file mode 100644 index 0000000..6686031 --- /dev/null +++ b/lib/lendaswap.ts @@ -0,0 +1,355 @@ +/** + * LendaSwap API Service + * Handles crypto-to-Lightning swaps via the LendaSwap REST API + * @see https://lendasat.com/docs/lendaswap/api-sdk + */ + +const LENDASWAP_API_BASE = "https://apilendaswap.lendasat.com"; + +// Token identifiers +export type TokenId = + | "btc_lightning" + | "btc_arkade" + | "btc_onchain" + | "wbtc_eth" + | "wbtc_pol" + | "wbtc_arb" + | { coin: string }; + +// Supported chains +export type Chain = + | "Polygon" + | "Ethereum" + | "Arbitrum" + | "Lightning" + | "Bitcoin" + | "Arkade"; + +// Token info from API +export interface TokenInfo { + token_id: TokenId; + symbol: string; + chain: Chain; + name: string; + decimals: number; +} + +// Asset pair +export interface AssetPair { + source: TokenInfo; + target: TokenInfo; +} + +// Quote response +export interface QuoteResponse { + exchange_rate: string; + network_fee: number; + protocol_fee: number; + protocol_fee_rate: number; + min_amount: number; + max_amount: number; +} + +// Swap status +export type SwapStatus = + | "pending" + | "clientfundingseen" + | "clientfunded" + | "clientrefunded" + | "serverfunded" + | "clientredeeming" + | "clientredeemed" + | "serverredeemed" + | "clientfundedserverrefunded" + | "clientrefundedserverfunded" + | "clientrefundedserverrefunded" + | "expired" + | "clientinvalidfunded" + | "clientfundedtoolate" + | "clientredeemedandclientrefunded"; + +// EVM to Lightning swap request +export interface EvmToLightningSwapRequest { + bolt11_invoice: string; + source_token: string; + user_address: string; + user_id: string; + referral_code?: string; +} + +// EVM to BTC swap response (used for EVM -> Lightning) +export interface EvmToBtcSwapResponse { + id: string; + status: SwapStatus; + hash_lock: string; + fee_sats: number; + asset_amount: number; + sender_pk: string; + receiver_pk: string; + server_pk: string; + evm_refund_locktime: number; + vhtlc_refund_locktime: number; + unilateral_claim_delay: number; + unilateral_refund_delay: number; + unilateral_refund_without_receiver_delay: number; + network: string; + source_token: TokenId; + target_token: TokenId; + created_at: string; + htlc_address_evm: string; + htlc_address_arkade: string; + user_address_evm: string; + ln_invoice: string; + sats_receive: number; + source_token_address: string; + target_amount: number; + source_amount: number; + approve_tx?: string; + create_swap_tx?: string; + gelato_forwarder_address?: string; + gelato_user_deadline?: string; + gelato_user_nonce?: string; + evm_htlc_claim_txid?: string; + evm_htlc_fund_txid?: string; + bitcoin_htlc_claim_txid?: string; + bitcoin_htlc_fund_txid?: string; + user_address_arkade?: string; +} + +// Get swap response (union type from API) +export interface GetSwapResponse extends EvmToBtcSwapResponse { + direction: "evm_to_btc" | "btc_to_evm" | "btc_to_arkade" | "onchain_to_evm"; +} + +// Error response +export interface ErrorResponse { + error: string; +} + +// Supported stablecoins by chain +export const SUPPORTED_TOKENS: Record< + string, + { tokenId: string; symbol: string; name: string; decimals: number }[] +> = { + Polygon: [ + { tokenId: "usdc_pol", symbol: "USDC", name: "USD Coin", decimals: 6 }, + { tokenId: "usdt0_pol", symbol: "USDT", name: "Tether USD", decimals: 6 }, + ], + Ethereum: [ + { tokenId: "usdc_eth", symbol: "USDC", name: "USD Coin", decimals: 6 }, + { tokenId: "usdt_eth", symbol: "USDT", name: "Tether USD", decimals: 6 }, + ], + Arbitrum: [ + { tokenId: "usdc_arb", symbol: "USDC", name: "USD Coin", decimals: 6 }, + { tokenId: "usdt_arb", symbol: "USDT", name: "Tether USD", decimals: 6 }, + ], +}; + +// Chain endpoint mapping +const CHAIN_ENDPOINTS: Record = { + Polygon: "/swap/polygon/lightning", + Ethereum: "/swap/ethereum/lightning", + Arbitrum: "/swap/arbitrum/lightning", +}; + +/** + * Get available tokens + */ +export async function getTokens(): Promise { + const response = await fetch(`${LENDASWAP_API_BASE}/tokens`); + if (!response.ok) { + let errorMessage = "Failed to fetch tokens"; + try { + const error: ErrorResponse = await response.json(); + if (error.error) errorMessage = error.error; + } catch { + const text = await response.text(); + if (text) errorMessage = text; + } + throw new Error(errorMessage); + } + return response.json(); +} + +/** + * Get supported asset pairs + */ +export async function getAssetPairs(): Promise { + const response = await fetch(`${LENDASWAP_API_BASE}/asset-pairs`); + if (!response.ok) { + let errorMessage = "Failed to fetch asset pairs"; + try { + const error: ErrorResponse = await response.json(); + if (error.error) errorMessage = error.error; + } catch { + const text = await response.text(); + if (text) errorMessage = text; + } + throw new Error(errorMessage); + } + return response.json(); +} + +const readErrorMessage = async ( + response: Response, + fallback: string +): Promise => { + const text = await response.text(); + if (!text) return fallback; + try { + const parsed = JSON.parse(text) as { error?: string } | null; + if (parsed?.error) return parsed.error; + } catch {} + return text; +}; + +/** + * Get a quote for swapping + * @param from Source token (e.g., "usdc_pol") + * @param to Target token (e.g., "btc_lightning") + * @param baseAmount Amount in satoshis (always BTC amount) + */ +export async function getQuote( + from: string, + to: string, + baseAmount: number +): Promise { + const params = new URLSearchParams({ + from, + to, + base_amount: baseAmount.toString(), + }); + + const response = await fetch(`${LENDASWAP_API_BASE}/quote?${params}`); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Failed to get quote")); + } + return response.json(); +} + +/** + * Create an EVM to Lightning swap + * @param chain The EVM chain (Polygon, Ethereum, Arbitrum) + * @param request The swap request + */ +export async function createEvmToLightningSwap( + chain: "Polygon" | "Ethereum" | "Arbitrum", + request: EvmToLightningSwapRequest +): Promise { + // Convert chain name to lowercase for API endpoint + const chainLower = chain.toLowerCase(); + + const response = await fetch(`${LENDASWAP_API_BASE}/swap/${chainLower}/lightning`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Failed to create swap")); + } + + return response.json(); +} + +/** + * Get swap status by ID + * @param id Swap ID + */ +export async function getSwapStatus(id: string): Promise { + const response = await fetch(`${LENDASWAP_API_BASE}/swap/${id}`); + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Failed to get swap status")); + } + return response.json(); +} + +/** + * Generate a random user ID for the swap + * This is used by the API to group swaps, but we generate a new one if not persisted + */ +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +export function generateUserId(existing?: string | null): string { + if (existing) { + if (existing.length === 66 || existing.length === 130) return existing; + if (existing.length === 64) return `02${existing}`; + } + const bytes = new Uint8Array(33); + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + return bytesToHex(bytes); + } + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Math.floor(Math.random() * 256); + } + return bytesToHex(bytes); +} + +/** + * Check if swap is in a terminal state + */ +export function isSwapComplete(status: SwapStatus): boolean { + return [ + "serverredeemed", // Success + "expired", + "clientrefunded", + "clientrefundedserverfunded", + "clientfundedserverrefunded", + "clientfundedtoolate", + "clientinvalidfunded", + ].includes(status); +} + +/** + * Check if swap succeeded + */ +export function isSwapSucceeded(status: SwapStatus): boolean { + return status === "serverredeemed"; +} + +/** + * Check if swap failed + */ +export function isSwapFailed(status: SwapStatus): boolean { + return [ + "expired", + "clientrefunded", + "clientrefundedserverfunded", + "clientfundedserverrefunded", + "clientfundedtoolate", + "clientinvalidfunded", + ].includes(status); +} + +/** + * Get user-friendly status message + */ +export function getStatusMessage(status: SwapStatus): string { + switch (status) { + case "pending": + return "Waiting for deposit..."; + case "clientfundingseen": + return "Deposit detected..."; + case "clientfunded": + return "Deposit confirmed. Processing..."; + case "serverfunded": + return "Processing..."; + case "clientredeeming": + return "Completing swap..."; + case "clientredeemed": + return "Completing swap..."; + case "serverredeemed": + return "Swap complete!"; + case "expired": + return "Swap expired"; + case "clientrefunded": + return "Refunded"; + default: + if (status.includes("refunded")) return "Refunded"; + return "Processing..."; + } +}