diff --git a/Dockerfile.drawbridge b/Dockerfile.drawbridge index 1069e591..13678d9a 100644 --- a/Dockerfile.drawbridge +++ b/Dockerfile.drawbridge @@ -12,8 +12,10 @@ COPY packages/ ./packages/ RUN npm ci RUN npm run build +COPY services/gatekeeper/client ./gatekeeper/client/ COPY services/drawbridge ./drawbridge/ +RUN cd gatekeeper/client && npm ci && npm run build RUN cd drawbridge/server && npm ci && npm run build WORKDIR /app/drawbridge diff --git a/apps/chrome-extension/package-lock.json b/apps/chrome-extension/package-lock.json index d5f17fdb..64e6164d 100644 --- a/apps/chrome-extension/package-lock.json +++ b/apps/chrome-extension/package-lock.json @@ -1,13 +1,16 @@ { "name": "archon-chrome-extension", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "archon-chrome-extension", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", + "dependencies": { + "qrcode.react": "^4.2.0" + }, "devDependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -3073,6 +3076,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3086,7 +3098,6 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index 8f0490ac..01b7bb95 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -34,5 +34,8 @@ "webpack": "^5.103.0", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1" + }, + "dependencies": { + "qrcode.react": "^4.2.0" } } diff --git a/apps/chrome-extension/src/components/BrowserContent.tsx b/apps/chrome-extension/src/components/BrowserContent.tsx index 3a58cbae..dec66583 100644 --- a/apps/chrome-extension/src/components/BrowserContent.tsx +++ b/apps/chrome-extension/src/components/BrowserContent.tsx @@ -4,6 +4,7 @@ import { TabContext, TabList, TabPanel } from "@mui/lab"; import { AccountBalanceWallet, Badge, + Bolt, Email, List, ManageSearch, @@ -26,13 +27,14 @@ import { createTheme, ThemeProvider } from "@mui/material/styles"; import AliasedDIDs from "./AliasedDIDs"; import AssetsTab from "./AssetsTab"; import DmailTab from "./DmailTab"; +import LightningTab from "./LightningTab"; import PollTab from "./PollTab"; function BrowserContent() { const [menuOpen, setMenuOpen] = useState(false); const [didRun, setDidRun] = useState(false); const [refresh, setRefresh] = useState(0); - const { isBrowser } = useWalletContext(); + const { isBrowser, hasLightning } = useWalletContext(); const { currentId, validId } = useVariablesContext(); const { openBrowser, setOpenBrowser } = useUIContext(); const { darkMode } = useThemeContext(); @@ -241,6 +243,17 @@ function BrowserContent() { /> )} + {displayComponent && hasLightning && ( + } + label={menuOpen ? "Lightning" : ""} + value="lightning" + iconPosition="start" + className="sidebarTab" + sx={{ gap: 0.25 }} + /> + )} + {displayComponent && ( } @@ -310,6 +323,12 @@ function BrowserContent() { )} + {displayComponent && hasLightning && ( + + + + )} + {displayComponent && ( diff --git a/apps/chrome-extension/src/components/LightningTab.tsx b/apps/chrome-extension/src/components/LightningTab.tsx new file mode 100644 index 00000000..a494d6e6 --- /dev/null +++ b/apps/chrome-extension/src/components/LightningTab.tsx @@ -0,0 +1,302 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Box, + Button, + CircularProgress, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import { QRCodeSVG } from "qrcode.react"; +import { LightningNotConfiguredError } from "@didcid/common/errors"; +import { DecodedLightningInvoice, LightningPaymentStatus } from "@didcid/keymaster/types"; +import { useWalletContext } from "../contexts/WalletProvider"; +import { useSnackbar } from "../contexts/SnackbarProvider"; + +const LightningTab: React.FC = () => { + const { keymaster } = useWalletContext(); + const { setError, setSuccess } = useSnackbar(); + + const [activeTab, setActiveTab] = useState<"wallet" | "receive" | "send">("wallet"); + + // Wallet sub-tab + const [balance, setBalance] = useState(null); + const [loadingBalance, setLoadingBalance] = useState(false); + const [isConfigured, setIsConfigured] = useState(null); + + // Receive sub-tab + const [receiveAmount, setReceiveAmount] = useState(""); + const [receiveMemo, setReceiveMemo] = useState(""); + const [invoice, setInvoice] = useState(""); + const [loadingInvoice, setLoadingInvoice] = useState(false); + + // Send sub-tab + const [bolt11Input, setBolt11Input] = useState(""); + const [decoded, setDecoded] = useState(null); + const [loadingDecode, setLoadingDecode] = useState(false); + const [loadingPay, setLoadingPay] = useState(false); + const [paymentResult, setPaymentResult] = useState(null); + + const fetchBalance = useCallback(async () => { + if (!keymaster) return; + setLoadingBalance(true); + try { + const result = await keymaster.getLightningBalance(); + setBalance(result.balance); + setIsConfigured(true); + } catch (err: any) { + if (err instanceof LightningNotConfiguredError) { + setIsConfigured(false); + } else { + setError(err); + } + } finally { + setLoadingBalance(false); + } + }, [keymaster, setError]); + + useEffect(() => { + if (activeTab === "wallet") { + fetchBalance(); + } + }, [activeTab, fetchBalance]); + + async function handleSetupLightning() { + if (!keymaster) return; + try { + await keymaster.addLightning(); + setSuccess("Lightning wallet set up successfully"); + await fetchBalance(); + } catch (err: any) { + setError(err); + } + } + + async function handleCreateInvoice() { + if (!keymaster) return; + const amount = parseInt(receiveAmount, 10); + if (!amount || amount <= 0) { + setError("Enter a valid amount in satoshis"); + return; + } + setLoadingInvoice(true); + setInvoice(""); + try { + const result = await keymaster.createLightningInvoice(amount, receiveMemo); + setInvoice(result.paymentRequest); + } catch (err: any) { + setError(err); + } finally { + setLoadingInvoice(false); + } + } + + async function handleDecode() { + if (!keymaster || !bolt11Input.trim()) return; + setLoadingDecode(true); + setDecoded(null); + try { + const result = await keymaster.decodeLightningInvoice(bolt11Input.trim()); + setDecoded(result); + } catch (err: any) { + setError(err); + } finally { + setLoadingDecode(false); + } + } + + async function handlePay() { + if (!keymaster || !bolt11Input.trim()) return; + setLoadingPay(true); + try { + const payment = await keymaster.payLightningInvoice(bolt11Input.trim()); + const status = await keymaster.checkLightningPayment(payment.paymentHash); + setPaymentResult(status); + setSuccess("Payment sent successfully"); + setBolt11Input(""); + setDecoded(null); + } catch (err: any) { + setError(err); + } finally { + setLoadingPay(false); + } + } + + return ( + + setActiveTab(v)} + sx={{ borderBottom: 1, borderColor: "divider", mb: 2 }} + > + + + + + + {activeTab === "wallet" && ( + + {loadingBalance && } + + {!loadingBalance && isConfigured === false && ( + + + No Lightning wallet configured for this identity. + + + + )} + + {!loadingBalance && isConfigured === true && balance !== null && ( + + + Balance: {balance.toLocaleString()} sats + + + + )} + + )} + + {activeTab === "receive" && ( + + setReceiveAmount(e.target.value)} + slotProps={{ htmlInput: { min: 1 } }} + size="small" + /> + setReceiveMemo(e.target.value)} + size="small" + /> + + + + + {invoice && ( + + (e.target as HTMLInputElement).select()} + /> + + + + + + )} + + )} + + {activeTab === "send" && ( + + { + setBolt11Input(e.target.value); + setDecoded(null); + setPaymentResult(null); + }} + multiline + rows={3} + size="small" + /> + + + {decoded && ( + + )} + + + {decoded && ( + + {decoded.amount !== undefined && ( + + Amount: {decoded.amount} + + )} + {decoded.description && ( + + Description: {decoded.description} + + )} + {decoded.network && ( + + Network: {decoded.network} + + )} + {decoded.created && ( + + Created: {decoded.created} + + )} + {decoded.expires && ( + + Expires: {decoded.expires} + + )} + + )} + + {paymentResult && ( + + + Payment Hash: {paymentResult.paymentHash} + + {paymentResult.preimage && ( + + Preimage (Proof): {paymentResult.preimage} + + )} + + )} + + )} + + ); +}; + +export default LightningTab; diff --git a/apps/chrome-extension/src/contexts/WalletProvider.tsx b/apps/chrome-extension/src/contexts/WalletProvider.tsx index 6e113be0..6908e69d 100644 --- a/apps/chrome-extension/src/contexts/WalletProvider.tsx +++ b/apps/chrome-extension/src/contexts/WalletProvider.tsx @@ -9,7 +9,7 @@ import React, { useState, } from "react"; -import GatekeeperClient from "@didcid/gatekeeper/client"; +import DrawbridgeClient from "@didcid/gatekeeper/drawbridge"; import Keymaster from "@didcid/keymaster"; import CipherWeb from "@didcid/cipher/web"; import WalletChrome from "@didcid/keymaster/wallet/chrome"; @@ -21,7 +21,7 @@ import MnemonicModal from "../modals/MnemonicModal"; import { encryptWithPassphrase } from '@didcid/cipher/passphrase'; import WalletJsonMemory from "@didcid/keymaster/wallet/json-memory"; -const gatekeeper = new GatekeeperClient(); +const gatekeeper = new DrawbridgeClient(); const cipher = new CipherWeb(); interface WalletContextValue { @@ -36,6 +36,7 @@ interface WalletContextValue { reloadBrowserWallet: () => Promise; refreshFlag: number; keymaster: Keymaster | null; + hasLightning: boolean; } const WalletContext = createContext(null); @@ -56,6 +57,7 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i const [recoveredMnemonic, setRecoveredMnemonic] = useState(""); const [showRecoverSetup, setShowRecoverSetup] = useState(false); const [refreshFlag, setRefreshFlag] = useState(0); + const [hasLightning, setHasLightning] = useState(false); const keymasterRef = useRef(null); @@ -101,6 +103,7 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i "gatekeeperUrl", ]); await gatekeeper.connect({ url: gatekeeperUrl as string }); + setHasLightning(await gatekeeper.isLightningSupported()); } const buildKeymaster = async (wallet: WalletBase, passphrase: string) => { @@ -305,6 +308,7 @@ export function WalletProvider({ children, isBrowser }: { children: ReactNode, i refreshFlag, isBrowser, keymaster: keymasterRef.current, + hasLightning, }; return ( diff --git a/apps/react-wallet/package-lock.json b/apps/react-wallet/package-lock.json index d1dc21a4..a83ed927 100644 --- a/apps/react-wallet/package-lock.json +++ b/apps/react-wallet/package-lock.json @@ -1,12 +1,12 @@ { "name": "@didcid/react-wallet", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@didcid/react-wallet", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@capacitor-mlkit/barcode-scanning": "^7.3.0", @@ -21,6 +21,7 @@ "@mui/material": "^7.3.5", "@uiw/react-json-view": "^2.0.0-alpha.39", "buffer": "^6.0.3", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -6187,6 +6188,15 @@ "teleport": ">=0.2.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/apps/react-wallet/package.json b/apps/react-wallet/package.json index 16ff3ba7..f20ff7b5 100644 --- a/apps/react-wallet/package.json +++ b/apps/react-wallet/package.json @@ -24,6 +24,7 @@ "@mui/material": "^7.3.5", "@uiw/react-json-view": "^2.0.0-alpha.39", "buffer": "^6.0.3", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/react-wallet/src/BrowserContent.tsx b/apps/react-wallet/src/BrowserContent.tsx index 67235213..0593e88d 100644 --- a/apps/react-wallet/src/BrowserContent.tsx +++ b/apps/react-wallet/src/BrowserContent.tsx @@ -18,6 +18,7 @@ import { TabContext, TabPanel, TabList } from "@mui/lab"; import { AccountBalanceWallet, Badge, + Bolt, Email, Key, List as ListIcon, @@ -38,11 +39,13 @@ import JsonViewer from "./components/JsonViewer"; import { useVariablesContext } from "./contexts/VariablesProvider"; import { useUIContext } from "./contexts/UIContext"; import { useThemeContext } from "./contexts/ContextProviders"; +import { useWalletContext } from "./contexts/WalletProvider"; import { useSafeArea } from "./contexts/SafeAreaContext"; import { createTheme, ThemeProvider } from "@mui/material/styles"; import AliasedDIDs from "./components/AliasedDIDs"; import AssetsTab from "./components/AssetsTab"; import DmailTab from "./components/DmailTab"; +import LightningTab from "./components/LightningTab"; import PollTab from "./components/PollTab"; import AuthTab from "./components/AuthTab"; @@ -54,6 +57,7 @@ function BrowserContent() { const [menuOpen, setMenuOpen] = useState(false); const { isTabletUp } = useThemeContext(); + const { hasLightning } = useWalletContext(); const { currentId, validId } = useVariablesContext(); const { selectedTab, @@ -198,6 +202,12 @@ function BrowserContent() { )} + {displayComponent && hasLightning && ( + + + + )} + {displayComponent && ( @@ -357,6 +367,17 @@ function BrowserContent() { /> )} + {displayComponent && hasLightning && ( + } + label={ + menuOpen ? "Lightning" : "" + } + value="lightning" + iconPosition="start" + /> + )} + {displayComponent && ( } @@ -570,6 +591,17 @@ function BrowserContent() { + {hasLightning && ( + selectFromMore("lightning")} + > + + + + + + )} + selectFromMore("assets")} > diff --git a/apps/react-wallet/src/components/LightningTab.tsx b/apps/react-wallet/src/components/LightningTab.tsx new file mode 100644 index 00000000..a494d6e6 --- /dev/null +++ b/apps/react-wallet/src/components/LightningTab.tsx @@ -0,0 +1,302 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + Box, + Button, + CircularProgress, + Tab, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import { QRCodeSVG } from "qrcode.react"; +import { LightningNotConfiguredError } from "@didcid/common/errors"; +import { DecodedLightningInvoice, LightningPaymentStatus } from "@didcid/keymaster/types"; +import { useWalletContext } from "../contexts/WalletProvider"; +import { useSnackbar } from "../contexts/SnackbarProvider"; + +const LightningTab: React.FC = () => { + const { keymaster } = useWalletContext(); + const { setError, setSuccess } = useSnackbar(); + + const [activeTab, setActiveTab] = useState<"wallet" | "receive" | "send">("wallet"); + + // Wallet sub-tab + const [balance, setBalance] = useState(null); + const [loadingBalance, setLoadingBalance] = useState(false); + const [isConfigured, setIsConfigured] = useState(null); + + // Receive sub-tab + const [receiveAmount, setReceiveAmount] = useState(""); + const [receiveMemo, setReceiveMemo] = useState(""); + const [invoice, setInvoice] = useState(""); + const [loadingInvoice, setLoadingInvoice] = useState(false); + + // Send sub-tab + const [bolt11Input, setBolt11Input] = useState(""); + const [decoded, setDecoded] = useState(null); + const [loadingDecode, setLoadingDecode] = useState(false); + const [loadingPay, setLoadingPay] = useState(false); + const [paymentResult, setPaymentResult] = useState(null); + + const fetchBalance = useCallback(async () => { + if (!keymaster) return; + setLoadingBalance(true); + try { + const result = await keymaster.getLightningBalance(); + setBalance(result.balance); + setIsConfigured(true); + } catch (err: any) { + if (err instanceof LightningNotConfiguredError) { + setIsConfigured(false); + } else { + setError(err); + } + } finally { + setLoadingBalance(false); + } + }, [keymaster, setError]); + + useEffect(() => { + if (activeTab === "wallet") { + fetchBalance(); + } + }, [activeTab, fetchBalance]); + + async function handleSetupLightning() { + if (!keymaster) return; + try { + await keymaster.addLightning(); + setSuccess("Lightning wallet set up successfully"); + await fetchBalance(); + } catch (err: any) { + setError(err); + } + } + + async function handleCreateInvoice() { + if (!keymaster) return; + const amount = parseInt(receiveAmount, 10); + if (!amount || amount <= 0) { + setError("Enter a valid amount in satoshis"); + return; + } + setLoadingInvoice(true); + setInvoice(""); + try { + const result = await keymaster.createLightningInvoice(amount, receiveMemo); + setInvoice(result.paymentRequest); + } catch (err: any) { + setError(err); + } finally { + setLoadingInvoice(false); + } + } + + async function handleDecode() { + if (!keymaster || !bolt11Input.trim()) return; + setLoadingDecode(true); + setDecoded(null); + try { + const result = await keymaster.decodeLightningInvoice(bolt11Input.trim()); + setDecoded(result); + } catch (err: any) { + setError(err); + } finally { + setLoadingDecode(false); + } + } + + async function handlePay() { + if (!keymaster || !bolt11Input.trim()) return; + setLoadingPay(true); + try { + const payment = await keymaster.payLightningInvoice(bolt11Input.trim()); + const status = await keymaster.checkLightningPayment(payment.paymentHash); + setPaymentResult(status); + setSuccess("Payment sent successfully"); + setBolt11Input(""); + setDecoded(null); + } catch (err: any) { + setError(err); + } finally { + setLoadingPay(false); + } + } + + return ( + + setActiveTab(v)} + sx={{ borderBottom: 1, borderColor: "divider", mb: 2 }} + > + + + + + + {activeTab === "wallet" && ( + + {loadingBalance && } + + {!loadingBalance && isConfigured === false && ( + + + No Lightning wallet configured for this identity. + + + + )} + + {!loadingBalance && isConfigured === true && balance !== null && ( + + + Balance: {balance.toLocaleString()} sats + + + + )} + + )} + + {activeTab === "receive" && ( + + setReceiveAmount(e.target.value)} + slotProps={{ htmlInput: { min: 1 } }} + size="small" + /> + setReceiveMemo(e.target.value)} + size="small" + /> + + + + + {invoice && ( + + (e.target as HTMLInputElement).select()} + /> + + + + + + )} + + )} + + {activeTab === "send" && ( + + { + setBolt11Input(e.target.value); + setDecoded(null); + setPaymentResult(null); + }} + multiline + rows={3} + size="small" + /> + + + {decoded && ( + + )} + + + {decoded && ( + + {decoded.amount !== undefined && ( + + Amount: {decoded.amount} + + )} + {decoded.description && ( + + Description: {decoded.description} + + )} + {decoded.network && ( + + Network: {decoded.network} + + )} + {decoded.created && ( + + Created: {decoded.created} + + )} + {decoded.expires && ( + + Expires: {decoded.expires} + + )} + + )} + + {paymentResult && ( + + + Payment Hash: {paymentResult.paymentHash} + + {paymentResult.preimage && ( + + Preimage (Proof): {paymentResult.preimage} + + )} + + )} + + )} + + ); +}; + +export default LightningTab; diff --git a/apps/react-wallet/src/contexts/WalletProvider.tsx b/apps/react-wallet/src/contexts/WalletProvider.tsx index c7c1a5b1..e0c4cd28 100644 --- a/apps/react-wallet/src/contexts/WalletProvider.tsx +++ b/apps/react-wallet/src/contexts/WalletProvider.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react"; -import GatekeeperClient from "@didcid/gatekeeper/client"; +import DrawbridgeClient from "@didcid/gatekeeper/drawbridge"; import Keymaster from "@didcid/keymaster"; import { WalletBase, StoredWallet } from '@didcid/keymaster/types'; import { isWalletEncFile } from '@didcid/keymaster/wallet/typeGuards'; @@ -31,7 +31,7 @@ import { clearSessionPassphrase, } from "../utils/sessionPassphrase"; -const gatekeeper = new GatekeeperClient(); +const gatekeeper = new DrawbridgeClient(); const cipher = new CipherWeb(); interface WalletContextValue { @@ -44,6 +44,7 @@ interface WalletContextValue { handleWalletUploadFile: (uploaded: unknown) => Promise; refreshFlag: number; keymaster: Keymaster | null; + hasLightning: boolean; } const WalletContext = createContext(null); @@ -64,6 +65,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [recoveredMnemonic, setRecoveredMnemonic] = useState(""); const [showRecoverSetup, setShowRecoverSetup] = useState(false); const [refreshFlag, setRefreshFlag] = useState(0); + const [hasLightning, setHasLightning] = useState(false); const keymasterRef = useRef(null); @@ -103,6 +105,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const gatekeeperUrl = localStorage.getItem(GATEKEEPER_KEY) || DEFAULT_GATEKEEPER_URL; localStorage.setItem(GATEKEEPER_KEY, gatekeeperUrl); await gatekeeper.connect({ url: gatekeeperUrl }); + setHasLightning(await gatekeeper.isLightningSupported()); } const buildKeymaster = async (wallet: WalletBase, passphrase: string) => { @@ -340,6 +343,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { handleWalletUploadFile, refreshFlag, keymaster: keymasterRef.current, + hasLightning, }; return ( diff --git a/docker-compose.drawbridge.yml b/docker-compose.drawbridge.yml index fa10a446..4e0ee0e2 100644 --- a/docker-compose.drawbridge.yml +++ b/docker-compose.drawbridge.yml @@ -8,7 +8,7 @@ services: image: ghcr.io/archetech/drawbridge entrypoint: ["/bin/sh", "/scripts/drawbridge-entrypoint.sh"] environment: - - ARCHON_DRAWBRIDGE_PORT=4230 + - ARCHON_DRAWBRIDGE_PORT=4222 - ARCHON_BIND_ADDRESS=${ARCHON_BIND_ADDRESS:-0.0.0.0} - ARCHON_ADMIN_API_KEY=${ARCHON_ADMIN_API_KEY} - ARCHON_GATEKEEPER_URL=http://gatekeeper:4224 @@ -24,7 +24,7 @@ services: - ARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOW=${ARCHON_DRAWBRIDGE_RATE_LIMIT_WINDOW:-60} user: "${ARCHON_UID}:${ARCHON_GID}" ports: - - ${ARCHON_DRAWBRIDGE_PORT:-4230}:4230 + - ${ARCHON_DRAWBRIDGE_PORT:-4222}:4222 volumes: - ./scripts/drawbridge-entrypoint.sh:/scripts/drawbridge-entrypoint.sh:ro - ./data/drawbridge:/data/drawbridge diff --git a/docker-compose.yml b/docker-compose.yml index 61b336ee..ec659e94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,7 +79,6 @@ services: - ARCHON_ADMIN_API_KEY=${ARCHON_ADMIN_API_KEY} - ARCHON_GATEKEEPER_URL=http://gatekeeper:4224 - ARCHON_DISABLE_SEARCH=${ARCHON_DISABLE_SEARCH:-false} - - ARCHON_SEARCH_URL=http://gatekeeper:4224 - ARCHON_NODE_ID=${ARCHON_NODE_ID} - ARCHON_KEYMASTER_DB=${ARCHON_KEYMASTER_DB} - ARCHON_ENCRYPTED_PASSPHRASE=${ARCHON_ENCRYPTED_PASSPHRASE} diff --git a/observability/prometheus/prometheus.yml b/observability/prometheus/prometheus.yml index 85c9ee44..3edef827 100644 --- a/observability/prometheus/prometheus.yml +++ b/observability/prometheus/prometheus.yml @@ -19,7 +19,7 @@ scrape_configs: - job_name: 'drawbridge' static_configs: - - targets: ['drawbridge:4230'] + - targets: ['drawbridge:4222'] metrics_path: '/metrics' - job_name: 'hyperswarm-mediator' diff --git a/packages/gatekeeper/src/drawbridge-client.ts b/packages/gatekeeper/src/drawbridge-client.ts index 0e3de813..5f280178 100644 --- a/packages/gatekeeper/src/drawbridge-client.ts +++ b/packages/gatekeeper/src/drawbridge-client.ts @@ -27,6 +27,15 @@ export default class DrawbridgeClient extends GatekeeperClient implements Drawbr return client; } + async isLightningSupported(): Promise { + try { + const response = await this.axios.get(`${this.API}/lightning/supported`); + return response.data?.supported === true; + } catch { + return false; + } + } + async createLightningWallet(name: string): Promise { try { const response = await this.axios.post(`${this.API}/lightning/wallet`, { name }); diff --git a/packages/gatekeeper/src/gatekeeper-client.ts b/packages/gatekeeper/src/gatekeeper-client.ts index 0a199924..59221e07 100644 --- a/packages/gatekeeper/src/gatekeeper-client.ts +++ b/packages/gatekeeper/src/gatekeeper-client.ts @@ -27,6 +27,7 @@ function throwError(error: AxiosError | any): never { export default class GatekeeperClient implements GatekeeperInterface { protected API: string; + protected baseUrl: string; protected axios: AxiosInstance; // Factory method @@ -42,6 +43,7 @@ export default class GatekeeperClient implements GatekeeperInterface { (axiosModule as AxiosInstance); this.API = VERSION; + this.baseUrl = ''; this.axios = axios.create(); } @@ -53,9 +55,14 @@ export default class GatekeeperClient implements GatekeeperInterface { delete this.axios.defaults.headers.common[header]; } + get url(): string { + return this.baseUrl; + } + async connect(options?: GatekeeperClientOptions) { if (options?.url) { - this.API = `${options.url}${VERSION}`; + this.baseUrl = new URL(options.url).origin; + this.API = `${this.baseUrl}${VERSION}`; } if (options?.apiKey) { diff --git a/packages/gatekeeper/src/types.ts b/packages/gatekeeper/src/types.ts index 44fed6da..ac8ee0ba 100644 --- a/packages/gatekeeper/src/types.ts +++ b/packages/gatekeeper/src/types.ts @@ -197,6 +197,8 @@ export interface LightningPaymentStatus { } export interface DrawbridgeInterface extends GatekeeperInterface { + readonly url: string; + isLightningSupported(): Promise; createLightningWallet(name: string): Promise; getLightningBalance(invoiceKey: string): Promise; createLightningInvoice(invoiceKey: string, amount: number, memo: string): Promise; diff --git a/packages/keymaster/src/keymaster.ts b/packages/keymaster/src/keymaster.ts index 55828f57..0ceec396 100644 --- a/packages/keymaster/src/keymaster.ts +++ b/packages/keymaster/src/keymaster.ts @@ -1770,71 +1770,108 @@ export default class Keymaster implements KeymasterInterface { return drawbridge; } - private getLightningConfig(idInfo: IDInfo): LightningConfig { - const config = idInfo.lightning as LightningConfig | undefined; - if (!config) { + private async getLightningConfig(name?: string): Promise { + const drawbridge = this.requireDrawbridge(); + const url = drawbridge.url; + let wallet = await this.loadWallet(); + let idInfo = await this.fetchIdInfo(name, wallet); + + if (!idInfo.lightning) { throw new LightningNotConfiguredError(`No Lightning wallet configured for ${idInfo.did}`); } + + // Migrate old flat format to per-URL dictionary + if ('walletId' in idInfo.lightning) { + await this.mutateWallet(async (wallet) => { + const idInfo = await this.fetchIdInfo(name, wallet); + if (idInfo.lightning && 'walletId' in idInfo.lightning) { + idInfo.lightning = { [url]: idInfo.lightning as LightningConfig }; + } + }); + wallet = await this.loadWallet(); + idInfo = await this.fetchIdInfo(name, wallet); + } + + const config = (idInfo.lightning as Record)[url]; + if (!config) { + throw new LightningNotConfiguredError(`No Lightning wallet configured for ${idInfo.did} on ${url}`); + } return config; } // Lightning methods async addLightning(name?: string): Promise { + try { + return await this.getLightningConfig(name); + } catch (e: any) { + if (e.type !== LightningNotConfiguredError.type) throw e; + } + const drawbridge = this.requireDrawbridge(); + const url = drawbridge.url; let result!: LightningConfig; await this.mutateWallet(async (wallet) => { const idInfo = await this.fetchIdInfo(name, wallet); - if (idInfo.lightning) { - result = idInfo.lightning as LightningConfig; - return; - } - + const store = (idInfo.lightning || {}) as Record; const walletName = `archon-${idInfo.did.split(':').pop()?.substring(0, 12)}`; const created = await drawbridge.createLightningWallet(walletName); - idInfo.lightning = { + store[url] = { walletId: created.walletId, adminKey: created.adminKey, invoiceKey: created.invoiceKey, - } as LightningConfig; + }; - result = idInfo.lightning as LightningConfig; + idInfo.lightning = store; + result = store[url]; }); return result; } async removeLightning(name?: string): Promise { + // Migrate old format if possible (ignore errors) + try { await this.getLightningConfig(name); } catch { /* ok */ } + + // Try to get URL for targeted removal; fall back to removing all + const gk = this.gatekeeper as { url?: string }; + const url = gk.url ? new URL(gk.url).origin : null; + await this.mutateWallet(async (wallet) => { const idInfo = await this.fetchIdInfo(name, wallet); - delete idInfo.lightning; + + if (!idInfo.lightning) return; + + if (url) { + const store = idInfo.lightning as Record; + delete store[url]; + + if (Object.keys(store).length === 0) { + delete idInfo.lightning; + } + } else { + delete idInfo.lightning; + } }); return true; } async getLightningBalance(name?: string): Promise { const drawbridge = this.requireDrawbridge(); - const wallet = await this.loadWallet(); - const idInfo = await this.fetchIdInfo(name, wallet); - const config = this.getLightningConfig(idInfo); + const config = await this.getLightningConfig(name); return drawbridge.getLightningBalance(config.invoiceKey); } - async createLightningInvoice(amount: number, memo: string, name?: string): Promise { + async createLightningInvoice(amount: number, memo: string = '', name?: string): Promise { if (!amount || amount <= 0) { throw new InvalidParameterError('amount'); } - if (!memo) { - throw new InvalidParameterError('memo'); - } const drawbridge = this.requireDrawbridge(); - const wallet = await this.loadWallet(); - const idInfo = await this.fetchIdInfo(name, wallet); - const config = this.getLightningConfig(idInfo); + const config = await this.getLightningConfig(name); return drawbridge.createLightningInvoice(config.invoiceKey, amount, memo); } @@ -1843,9 +1880,7 @@ export default class Keymaster implements KeymasterInterface { throw new InvalidParameterError('bolt11'); } const drawbridge = this.requireDrawbridge(); - const wallet = await this.loadWallet(); - const idInfo = await this.fetchIdInfo(name, wallet); - const config = this.getLightningConfig(idInfo); + const config = await this.getLightningConfig(name); return drawbridge.payLightningInvoice(config.adminKey, bolt11); } @@ -1854,9 +1889,7 @@ export default class Keymaster implements KeymasterInterface { throw new InvalidParameterError('paymentHash'); } const drawbridge = this.requireDrawbridge(); - const wallet = await this.loadWallet(); - const idInfo = await this.fetchIdInfo(name, wallet); - const config = this.getLightningConfig(idInfo); + const config = await this.getLightningConfig(name); const data = await drawbridge.checkLightningPayment(config.invoiceKey, paymentHash); return { paid: data.paid, diff --git a/sample.env b/sample.env index 139edabf..3590f912 100644 --- a/sample.env +++ b/sample.env @@ -40,7 +40,6 @@ ARCHON_REACT_WALLET_PORT=4228 # CLI ARCHON_GATEKEEPER_URL=http://localhost:4224 ARCHON_KEYMASTER_URL=http://localhost:4226 -ARCHON_SEARCH_URL=http://localhost:4224 # Hyperswarm ARCHON_HYPR_EXPORT_INTERVAL=2 @@ -136,7 +135,7 @@ ARCHON_RTL_PASSWORD=changeme # Drawbridge (L402 API Gateway) # ARCHON_DRAWBRIDGE_L402_ENABLED=true -ARCHON_DRAWBRIDGE_PORT=4230 +ARCHON_DRAWBRIDGE_PORT=4222 ARCHON_DRAWBRIDGE_DEFAULT_PRICE_SATS=10 ARCHON_DRAWBRIDGE_INVOICE_EXPIRY=3600 ARCHON_DRAWBRIDGE_RATE_LIMIT_MAX=100 diff --git a/services/drawbridge/server/src/config.ts b/services/drawbridge/server/src/config.ts index 14ab8110..13d8af76 100644 --- a/services/drawbridge/server/src/config.ts +++ b/services/drawbridge/server/src/config.ts @@ -3,7 +3,7 @@ import dotenv from 'dotenv'; dotenv.config(); const config = { - port: process.env.ARCHON_DRAWBRIDGE_PORT ? parseInt(process.env.ARCHON_DRAWBRIDGE_PORT) : 4230, + port: process.env.ARCHON_DRAWBRIDGE_PORT ? parseInt(process.env.ARCHON_DRAWBRIDGE_PORT) : 4222, bindAddress: process.env.ARCHON_BIND_ADDRESS || '0.0.0.0', gatekeeperURL: process.env.ARCHON_GATEKEEPER_URL || 'http://localhost:4224', adminApiKey: process.env.ARCHON_ADMIN_API_KEY || '', diff --git a/services/drawbridge/server/src/drawbridge-api.ts b/services/drawbridge/server/src/drawbridge-api.ts index afc03308..68a7f914 100644 --- a/services/drawbridge/server/src/drawbridge-api.ts +++ b/services/drawbridge/server/src/drawbridge-api.ts @@ -6,6 +6,8 @@ import pinoHttp from 'pino-http'; import { register, Counter, Gauge, Histogram, collectDefaultMetrics } from 'prom-client'; import { readFile } from 'fs/promises'; import { timingSafeEqual } from 'crypto'; +import path from 'path'; +import { fileURLToPath } from 'url'; import GatekeeperClient from '@didcid/gatekeeper/client'; @@ -528,6 +530,10 @@ async function main() { // --- LNbits Lightning wallet routes --- + v1router.get('/lightning/supported', (_req, res) => { + res.json({ supported: true }); + }); + v1router.post('/lightning/wallet', async (req, res) => { if (!config.lnbitsUrl) { res.status(503).json({ error: 'Lightning (LNbits) not configured' }); @@ -606,6 +612,24 @@ async function main() { // Mount router app.use('/api/v1', v1router); + // Serve gatekeeper web client + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const serveClient = (process.env.ARCHON_GATEKEEPER_SERVE_CLIENT ?? 'true').toLowerCase() === 'true'; + + if (serveClient) { + const clientBuildDir = path.join(__dirname, '../../../gatekeeper/client/build'); + + app.use(express.static(clientBuildDir)); + + app.use((req, res, next) => { + if (!req.path.startsWith('/api')) { + res.sendFile(path.join(clientBuildDir, 'index.html')); + } else { + next(); + } + }); + } + // Prometheus metrics endpoint app.get('/metrics', async (_req, res) => { try { diff --git a/services/gatekeeper/client/package-lock.json b/services/gatekeeper/client/package-lock.json index 9e5eca29..7cec688a 100644 --- a/services/gatekeeper/client/package-lock.json +++ b/services/gatekeeper/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "gatekeeper-client", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gatekeeper-client", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", @@ -15,6 +15,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", @@ -17382,6 +17383,15 @@ "teleport": ">=0.2.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/services/gatekeeper/client/package.json b/services/gatekeeper/client/package.json index 6c86dba2..c7ac2a40 100644 --- a/services/gatekeeper/client/package.json +++ b/services/gatekeeper/client/package.json @@ -10,6 +10,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", diff --git a/services/gatekeeper/client/src/App.js b/services/gatekeeper/client/src/App.js index ab9dc724..db98f1fb 100644 --- a/services/gatekeeper/client/src/App.js +++ b/services/gatekeeper/client/src/App.js @@ -1,7 +1,7 @@ import { Buffer } from 'buffer'; import React, { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; -import GatekeeperClient from '@didcid/gatekeeper/client'; +import DrawbridgeClient from '@didcid/gatekeeper/drawbridge'; import CipherWeb from '@didcid/cipher/web'; import Keymaster from '@didcid/keymaster'; import WalletWeb from '@didcid/keymaster/wallet/web'; @@ -17,11 +17,13 @@ import './App.css'; global.Buffer = Buffer; -const { protocol, hostname } = window.location; -const gatekeeper = new GatekeeperClient(); -await gatekeeper.connect({ url: `${protocol}//${hostname}:4224` }); +const gatekeeperUrl = window.location.origin; +const gatekeeper = new DrawbridgeClient(); +await gatekeeper.connect({ url: gatekeeperUrl }); const cipher = new CipherWeb(); +const hasLightning = await gatekeeper.isLightningSupported(); + function App() { const [isReady, setIsReady] = useState(false); const [modalAction, setModalAction] = useState(null); @@ -278,6 +280,7 @@ function App() { title={'Keymaster Browser Wallet Demo'} challengeDID={challengeDID} onWalletUpload={handleWalletUploadFile} + hasLightning={hasLightning} /> )} diff --git a/services/gatekeeper/client/src/KeymasterUI.js b/services/gatekeeper/client/src/KeymasterUI.js index cf55b137..b06a397a 100644 --- a/services/gatekeeper/client/src/KeymasterUI.js +++ b/services/gatekeeper/client/src/KeymasterUI.js @@ -43,6 +43,7 @@ import { Badge, BarChart, Block, + Bolt, Clear, Create, Groups, @@ -80,6 +81,7 @@ import { } from "@mui/icons-material"; import axios from 'axios'; import { Buffer } from 'buffer'; +import { QRCodeSVG } from 'qrcode.react'; import './App.css'; import PollResultsModal from "./PollResultsModal"; import TextInputModal from "./TextInputModal"; @@ -96,7 +98,7 @@ const DmailTags = { UNREAD: 'unread', }; -function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload }) { +function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightning }) { const [tab, setTab] = useState(null); const [currentId, setCurrentId] = useState(''); const [saveId, setSaveId] = useState(''); @@ -243,6 +245,15 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload }) { message: "", severity: "warning", }); + const [lightningTab, setLightningTab] = useState('wallet'); + const [lightningBalance, setLightningBalance] = useState(null); + const [lightningIsConfigured, setLightningIsConfigured] = useState(null); + const [lightningReceiveAmount, setLightningReceiveAmount] = useState(''); + const [lightningReceiveMemo, setLightningReceiveMemo] = useState(''); + const [lightningInvoice, setLightningInvoice] = useState(''); + const [bolt11Input, setBolt11Input] = useState(''); + const [decodedInvoice, setDecodedInvoice] = useState(null); + const [lightningPaymentResult, setLightningPaymentResult] = useState(null); const pollExpired = pollDeadline ? Date.now() > pollDeadline.getTime() : false; const selectedPollDid = selectedPollName ? aliasList[selectedPollName] ?? "" : ""; @@ -257,6 +268,13 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload }) { // eslint-disable-next-line }, []); + useEffect(() => { + if (tab === 'lightning' && lightningTab === 'wallet') { + fetchLightningBalance(); + } + // eslint-disable-next-line + }, [tab]); + function showAlert(warning) { setSnackbar({ open: true, @@ -282,6 +300,68 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload }) { }); } + async function fetchLightningBalance() { + try { + const result = await keymaster.getLightningBalance(); + setLightningBalance(result.balance); + setLightningIsConfigured(true); + } catch (error) { + if (error?.type === 'Lightning not configured' || error?.message?.includes('not configured')) { + setLightningIsConfigured(false); + } else { + showError(error); + } + } + } + + async function setupLightning() { + try { + await keymaster.addLightning(); + showSuccess('Lightning wallet set up successfully'); + await fetchLightningBalance(); + } catch (error) { + showError(error); + } + } + + async function createLightningInvoice() { + const amount = parseInt(lightningReceiveAmount, 10); + if (!amount || amount <= 0) { + showAlert('Enter a valid amount in satoshis'); + return; + } + try { + const result = await keymaster.createLightningInvoice(amount, lightningReceiveMemo); + setLightningInvoice(result.paymentRequest); + } catch (error) { + showError(error); + } + } + + async function decodeLightningInvoice() { + if (!bolt11Input.trim()) return; + try { + const result = await keymaster.decodeLightningInvoice(bolt11Input.trim()); + setDecodedInvoice(result); + } catch (error) { + showError(error); + } + } + + async function payLightningInvoice() { + if (!bolt11Input.trim()) return; + try { + const payment = await keymaster.payLightningInvoice(bolt11Input.trim()); + const status = await keymaster.checkLightningPayment(payment.paymentHash); + setLightningPaymentResult(status); + showSuccess('Payment sent successfully'); + setBolt11Input(''); + setDecodedInvoice(null); + } catch (error) { + showError(error); + } + } + async function checkForChallenge() { try { if (challengeDID) { @@ -3162,6 +3242,9 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload }) { {currentId && !widget && } /> } + {currentId && !widget && hasLightning && + } /> + } {currentId && } /> } @@ -5399,6 +5482,150 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload }) { /> } + {tab === 'lightning' && + +

+ { + setLightningTab(v); + if (v === 'wallet') fetchLightningBalance(); + }} + indicatorColor="primary" + textColor="primary" + > + + + + + + {lightningTab === 'wallet' && + + {lightningIsConfigured === false && + + No Lightning wallet configured for this identity. +

+ + + } + {lightningIsConfigured === true && lightningBalance !== null && + + + Balance: {lightningBalance.toLocaleString()} sats + +

+ + + } + + } + + {lightningTab === 'receive' && + + setLightningReceiveAmount(e.target.value)} + size="small" + /> + setLightningReceiveMemo(e.target.value)} + size="small" + /> + + + + {lightningInvoice && + + e.target.select()} + /> + + + + + + + + } + + } + + {lightningTab === 'send' && + + { + setBolt11Input(e.target.value); + setDecodedInvoice(null); + setLightningPaymentResult(null); + }} + multiline + rows={3} + size="small" + /> + + + {decodedInvoice && + + } + + {decodedInvoice && + + {decodedInvoice.amount !== undefined && + Amount: {decodedInvoice.amount} + } + {decodedInvoice.description && + Description: {decodedInvoice.description} + } + {decodedInvoice.network && + Network: {decodedInvoice.network} + } + {decodedInvoice.created && + Created: {decodedInvoice.created} + } + {decodedInvoice.expires && + Expires: {decodedInvoice.expires} + } + + } + {lightningPaymentResult && + + Payment Hash: {lightningPaymentResult.paymentHash} + {lightningPaymentResult.preimage && + Preimage (Proof): {lightningPaymentResult.preimage} + } + + } + + } + + } {tab === 'wallet' &&

diff --git a/tests/keymaster/lightning.test.ts b/tests/keymaster/lightning.test.ts index ceb4430d..2bec10c0 100644 --- a/tests/keymaster/lightning.test.ts +++ b/tests/keymaster/lightning.test.ts @@ -39,6 +39,7 @@ beforeEach(() => { // Create a gatekeeper proxy that adds DrawbridgeInterface Lightning methods gatekeeper = Object.create(baseGatekeeper); + gatekeeper.url = 'http://test-drawbridge'; gatekeeper.createLightningWallet = (name: string) => { trackCall('createLightningWallet', name); @@ -83,7 +84,7 @@ describe('addLightning', () => { }); }); - it('should store credentials in wallet IDInfo', async () => { + it('should store credentials in wallet IDInfo keyed by URL', async () => { await keymaster.createId('Bob'); await keymaster.addLightning(); @@ -91,9 +92,11 @@ describe('addLightning', () => { const walletData = await keymaster.loadWallet(); const bobInfo = Object.values(walletData.ids).find(id => id.did); expect(bobInfo?.lightning).toStrictEqual({ - walletId: 'w1', - adminKey: 'admin1', - invoiceKey: 'invoice1', + 'http://test-drawbridge': { + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }, }); }); @@ -170,10 +173,77 @@ describe('addLightning', () => { expect(error).toBeDefined(); } }); + + it('should migrate old flat format to per-URL dictionary', async () => { + await keymaster.createId('Bob'); + + // Manually inject old flat format + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did)!; + bobInfo.lightning = { walletId: 'w1', adminKey: 'admin1', invoiceKey: 'invoice1' }; + await keymaster.saveWallet(walletData, true); + + // addLightning should migrate and return existing config + const config = await keymaster.addLightning(); + + expect(config).toStrictEqual({ + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }); + + // Verify migration persisted as per-URL dictionary + const updated = await keymaster.loadWallet(); + const updatedBob = Object.values(updated.ids).find(id => id.did)!; + expect(updatedBob.lightning).toStrictEqual({ + 'http://test-drawbridge': { + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }, + }); + + // Should not have called createLightningWallet + const walletCalls = calls.filter(c => c.method === 'createLightningWallet'); + expect(walletCalls.length).toBe(0); + }); + + it('should create separate wallets per Drawbridge URL', async () => { + await keymaster.createId('Bob'); + + // Add lightning on first URL + await keymaster.addLightning(); + + // Switch to a different Drawbridge URL + gatekeeper.url = 'http://other-drawbridge'; + gatekeeper.createLightningWallet = (name: string) => { + trackCall('createLightningWallet', name); + return Promise.resolve({ walletId: 'w2', adminKey: 'admin2', invoiceKey: 'invoice2' }); + }; + + const config2 = await keymaster.addLightning(); + expect(config2.walletId).toBe('w2'); + + // Verify both URLs stored + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did)!; + expect(bobInfo.lightning).toStrictEqual({ + 'http://test-drawbridge': { + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }, + 'http://other-drawbridge': { + walletId: 'w2', + adminKey: 'admin2', + invoiceKey: 'invoice2', + }, + }); + }); }); describe('removeLightning', () => { - it('should remove Lightning config from IDInfo', async () => { + it('should remove Lightning config for current URL', async () => { await keymaster.createId('Bob'); await keymaster.addLightning(); @@ -186,6 +256,48 @@ describe('removeLightning', () => { expect(bobInfo?.lightning).toBeUndefined(); }); + it('should only remove config for current URL, preserving others', async () => { + await keymaster.createId('Bob'); + + // Add lightning on two URLs + await keymaster.addLightning(); + + gatekeeper.url = 'http://other-drawbridge'; + gatekeeper.createLightningWallet = () => + Promise.resolve({ walletId: 'w2', adminKey: 'admin2', invoiceKey: 'invoice2' }); + await keymaster.addLightning(); + + // Remove only the second URL + await keymaster.removeLightning(); + + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did)!; + expect(bobInfo.lightning).toStrictEqual({ + 'http://test-drawbridge': { + walletId: 'w1', + adminKey: 'admin1', + invoiceKey: 'invoice1', + }, + }); + }); + + it('should handle old flat format removal', async () => { + await keymaster.createId('Bob'); + + // Manually inject old flat format + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did)!; + bobInfo.lightning = { walletId: 'w1', adminKey: 'admin1', invoiceKey: 'invoice1' }; + await keymaster.saveWallet(walletData, true); + + const ok = await keymaster.removeLightning(); + expect(ok).toBe(true); + + const updated = await keymaster.loadWallet(); + const updatedBob = Object.values(updated.ids).find(id => id.did)!; + expect(updatedBob.lightning).toBeUndefined(); + }); + it('should succeed even if Lightning was not configured', async () => { await keymaster.createId('Bob'); @@ -220,6 +332,19 @@ describe('getLightningBalance', () => { expect(balanceCalls[0].args).toStrictEqual(['invoice1']); }); + it('should return balance for old flat format wallet', async () => { + await keymaster.createId('Bob'); + + // Manually inject old flat format + const walletData = await keymaster.loadWallet(); + const bobInfo = Object.values(walletData.ids).find(id => id.did)!; + bobInfo.lightning = { walletId: 'w1', adminKey: 'admin1', invoiceKey: 'invoice1' }; + await keymaster.saveWallet(walletData, true); + + const result = await keymaster.getLightningBalance(); + expect(result.balance).toBe(1000); + }); + it('should throw when Lightning not configured', async () => { await keymaster.createId('Bob'); @@ -231,6 +356,22 @@ describe('getLightningBalance', () => { expect(error.type).toBe(LightningNotConfiguredError.type); } }); + + it('should throw when Lightning configured on different URL', async () => { + await keymaster.createId('Bob'); + await keymaster.addLightning(); + + // Switch to a different Drawbridge URL + gatekeeper.url = 'http://other-drawbridge'; + + try { + await keymaster.getLightningBalance(); + throw new Error('Expected exception'); + } + catch (error: any) { + expect(error.type).toBe(LightningNotConfiguredError.type); + } + }); }); describe('createLightningInvoice', () => { @@ -256,17 +397,6 @@ describe('createLightningInvoice', () => { } }); - it('should throw for missing memo', async () => { - await keymaster.createId('Bob'); - - try { - await keymaster.createLightningInvoice(100, ''); - throw new Error('Expected exception'); - } - catch (error: any) { - expect(error.type).toBe('Invalid parameter'); - } - }); it('should throw when Lightning not configured', async () => { await keymaster.createId('Bob');