diff --git a/packages/demo/next.config.mjs b/packages/demo/next.config.mjs index c3f0428b2..cc4b61b29 100644 --- a/packages/demo/next.config.mjs +++ b/packages/demo/next.config.mjs @@ -3,6 +3,24 @@ const nextConfig = { experimental: { optimizePackageImports: ["@ckb-ccc/core", "@ckb-ccc/core/bundle"], }, + // SharedArrayBuffer (required by @nervosnetwork/fiber-js WASM) needs + // Cross-Origin-Opener-Policy: same-origin + COEP on the page. + // + // COOP: same-origin severs window.opener in cross-origin wallet popups + // (JoyID, etc.), so it is applied only to /connected/Fiber where the WASM + // runs. Wallet signing is delegated to /fiber-sign-proxy (a same-origin + // popup with no COOP) that relays the signature back via BroadcastChannel. + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Embedder-Policy", value: "require-corp" }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/packages/demo/package.json b/packages/demo/package.json index b96bf67a2..b03d5e268 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -36,6 +36,7 @@ "@eslint/eslintrc": "^3.3.1", "@headlessui/react": "^2.2.7", "@heroicons/react": "^2.2.0", + "@nervosnetwork/fiber-js": "0.8.1", "@scure/bip32": "^2.0.0", "@scure/bip39": "^2.0.0", "@tailwindcss/postcss": "^4.1.12", diff --git a/packages/demo/src/app/connected/(tools)/Fiber/components.tsx b/packages/demo/src/app/connected/(tools)/Fiber/components.tsx new file mode 100644 index 000000000..a4c77dc5d --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/components.tsx @@ -0,0 +1,583 @@ +import { Button } from "@/src/components/Button"; +import { TextInput } from "@/src/components/Input"; +import { ccc } from "@ckb-ccc/connector-react"; +import { Copy, GitBranch, Send, Terminal, Users } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { hexToCkb } from "./config"; +import type { + FjChannel, + FjGetInvoice, + FjInvoice, + FjPeer, + LogEntry, + LogLevel, + Tab, +} from "./types"; + +// ── Layout primitives ───────────────────────────────────────────────────────── + +export function Card({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function InfoCard({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +// ── Log panel ───────────────────────────────────────────────────────────────── + +const LOG_TEXT: Record = { + info: "text-sky-400", + warn: "text-amber-400", + error: "text-rose-400", + success: "text-emerald-400", +}; + +const LOG_LABEL: Record = { + info: "INFO", + warn: "WARN", + error: "ERR!", + success: "OK ", +}; + +const LOG_LABEL_COLOR: Record = { + info: "text-sky-500", + warn: "text-amber-500", + error: "text-rose-500", + success: "text-emerald-500", +}; + +const FILTERS: { key: LogLevel | "all"; label: string }[] = [ + { key: "all", label: "All" }, + { key: "info", label: "Info" }, + { key: "warn", label: "Warn" }, + { key: "error", label: "Error" }, + { key: "success", label: "OK" }, +]; + +export function LogPanel({ + logs, + onClear, +}: { + logs: LogEntry[]; + onClear: () => void; +}) { + const [filter, setFilter] = useState("all"); + const scrollRef = useRef(null); + const autoScrollRef = useRef(true); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) { + return; + } + autoScrollRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 32; + }, []); + + useEffect(() => { + if (autoScrollRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + const filtered = + filter === "all" ? logs : logs.filter((e) => e.level === filter); + + const counts = (["info", "warn", "error", "success"] as LogLevel[]).reduce( + (acc, lvl) => { + acc[lvl] = logs.filter((e) => e.level === lvl).length; + return acc; + }, + {} as Record, + ); + + return ( +
+ {/* Header */} +
+
+ + + Node Logs + + {logs.length} lines + + Fiber WASM logs → DevTools (F12) Console + +
+
+ {FILTERS.map(({ key, label }) => { + const count = key === "all" ? logs.length : counts[key]; + const active = filter === key; + return ( + + ); + })} +
+ +
+
+ + {/* Log body */} +
+ {filtered.length === 0 ? ( +

+ {filter === "all" ? "No logs yet." : `No ${filter} entries.`} +

+ ) : ( + filtered.map((e) => ( +
+ {e.time} + + {LOG_LABEL[e.level]} + + {e.msg} +
+ )) + )} +
+
+ ); +} + +// ── Tab bar ─────────────────────────────────────────────────────────────────── + +const TABS: { key: Tab; label: string }[] = [ + { key: "peers", label: "Peers" }, + { key: "channels", label: "Channels" }, + { key: "invoices", label: "Invoices" }, + { key: "payments", label: "Payments" }, +]; + +export function TabBar({ + active, + onChange, +}: { + active: Tab; + onChange: (t: Tab) => void; +}) { + return ( +
+ {TABS.map(({ key, label }) => ( + + ))} +
+ ); +} + +// ── Peers tab ───────────────────────────────────────────────────────────────── + +export function PeersTab({ + peers, + onConnect, + onDisconnect, +}: { + peers: FjPeer[]; + onConnect: (addr: string) => Promise; + onDisconnect: (peerId: string) => Promise; +}) { + const [addr, setAddr] = useState(""); + return ( +
+
+
+ +
+
+ +
+
+

+ Connected peers ({peers.length}) +

+ {peers.length === 0 ? ( +

No peers connected.

+ ) : ( + peers.map((p) => ( +
+
+

+ {p.pubkey} +

+

+ {p.address} +

+
+ +
+ )) + )} +
+ ); +} + +// ── Channels tab ────────────────────────────────────────────────────────────── + +export function ChannelsTab({ + channels, + onOpen, + onClose, +}: { + channels: FjChannel[]; + onOpen: (peerId: string, amount: string, isPublic: boolean) => Promise; + onClose: (channelId: string) => Promise; +}) { + const [peerId, setPeerId] = useState(""); + const [amount, setAmount] = useState(""); + const [isPublic, setIsPublic] = useState(true); + + return ( +
+ {/* Open channel form */} +
+

Open Channel

+ + +
+ + +
+
+ + {/* Channel list */} +

+ Channels ({channels.length}) +

+ {channels.length === 0 ? ( +

No channels.

+ ) : ( + channels.map((ch) => ( +
+
+

+ {ch.channel_id} +

+
+ + {ch.state.state_name} + + +
+
+
+ Local: {hexToCkb(ch.local_balance)} CKB + Remote: {hexToCkb(ch.remote_balance)} CKB + + Peer: {ch.pubkey} + +
+
+ )) + )} +
+ ); +} + +// ── Invoices tab ────────────────────────────────────────────────────────────── + +const INVOICE_STATUS_COLOR: Record = { + Paid: "bg-green-100 text-green-700", + Open: "bg-blue-100 text-blue-700", +}; + +export function InvoicesTab({ + onNew, + onCheck, +}: { + onNew: (amount: string, desc: string) => Promise; + onCheck: (paymentHash: string) => Promise; +}) { + const [amount, setAmount] = useState(""); + const [desc, setDesc] = useState(""); + const [invoiceAddr, setInvoiceAddr] = useState(""); + const [hash, setHash] = useState(""); + const [status, setStatus] = useState(""); + + return ( +
+
+

Create Invoice

+ + +
+ +
+ {invoiceAddr && ( +
+

+ {invoiceAddr} +

+ +
+ )} +
+ +
+

+ Check Invoice Status +

+ +
+ + {status && ( + + {status} + + )} +
+
+
+ ); +} + +// ── Payments tab ────────────────────────────────────────────────────────────── + +export function PaymentsTab({ + onSend, +}: { + onSend: (invoice: string) => Promise; +}) { + const [invoice, setInvoice] = useState(""); + const [result, setResult] = useState(""); + + return ( +
+ +
+ +
+ {result && ( +
+

{result}

+
+ )} +
+ ); +} + +// ── Node info grid ──────────────────────────────────────────────────────────── + +function hexToNum(hex: string): string { + return String(Number(ccc.numFrom(hex))); +} + +export function NodeInfoGrid({ + nodeId, + addresses, + peersCount, + channelCount, + pendingChannelCount, +}: { + nodeId: string; + addresses: string[]; + peersCount: string; + channelCount: string; + pendingChannelCount: string; +}) { + return ( +
+
+ + + +
+
+

Node ID

+

{nodeId}

+
+ {addresses.length > 0 && ( +
+

Listening Addresses

+ {addresses.map((a, i) => ( +

+ {a} +

+ ))} +
+ )} +
+ ); +} diff --git a/packages/demo/src/app/connected/(tools)/Fiber/config.ts b/packages/demo/src/app/connected/(tools)/Fiber/config.ts new file mode 100644 index 000000000..46f0bba29 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/config.ts @@ -0,0 +1,30 @@ +import { ccc } from "@ckb-ccc/connector-react"; + +// ── localStorage keys ───────────────────────────────────────────────────────── + +export const LS_MANUAL_CONFIG = "fiber-demo-manual-config"; + +export const SIGN_MESSAGE = "Fiber Demo Node Key v1"; + +export function lsNodeKeyFor(walletAddr: string): string { + return `fiber-demo-nodekey-${walletAddr}`; +} + +export function readLs(key: string, fallback = ""): string { + if (typeof window === "undefined") return fallback; + return localStorage.getItem(key) ?? fallback; +} + +export function writeLs(key: string, value: string): void { + if (typeof window !== "undefined") localStorage.setItem(key, value); +} + +// ── Display helpers ─────────────────────────────────────────────────────────── + +export function hexToCkb(hex: string): string { + return ccc.fixedPointToString(ccc.numFrom(hex), 8); +} + +export function maskKey(key: string): string { + return `${key.slice(0, 10)}···${key.slice(-6)}`; +} diff --git a/packages/demo/src/app/connected/(tools)/Fiber/hooks.ts b/packages/demo/src/app/connected/(tools)/Fiber/hooks.ts new file mode 100644 index 000000000..753ab2de4 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/hooks.ts @@ -0,0 +1,532 @@ +import { ccc } from "@ckb-ccc/connector-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { lsNodeKeyFor, readLs, writeLs } from "./config"; +import type { + CkbRpcScript, + CkbRpcTransaction, + FiberInstance, + FjChannel, + FjGetInvoice, + FjInvoice, + FjNodeInfo, + FjOpenChannel, + FjPayment, + FjPeer, + LogEntry, + LogLevel, +} from "./types"; + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +async function resolveLockCellDeps( + client: ccc.Client, + lock: ccc.Script, +): Promise { + const infos = await Promise.all( + Object.values(ccc.KnownScript).map((ks) => client.getKnownScript(ks)), + ); + for (const info of infos) { + if (info.codeHash !== lock.codeHash || info.hashType !== lock.hashType) { + continue; + } + const deps = await client.getCellDeps(...info.cellDeps); + return deps.map((dep) => ({ + dep_type: dep.depType === "depGroup" ? "dep_group" : ("code" as const), + out_point: { + tx_hash: ccc.hexFrom(dep.outPoint.txHash), + index: ccc.numToHex(dep.outPoint.index), + }, + })); + } + return []; +} + +const MAX_LOGS = 500; + +// ── Activity log ────────────────────────────────────────────────────────────── + +type AddLog = (level: LogLevel, msg: string) => void; + +export function useActivityLog() { + const [logs, setLogs] = useState([]); + const idRef = useRef(0); + + const addLog = useCallback((level: LogLevel, msg: string) => { + const id = ++idRef.current; + const time = new Date().toLocaleTimeString("en", { hour12: false }); + setLogs((prev) => [...prev, { id, level, time, msg }].slice(-MAX_LOGS)); + }, []); + + return { logs, addLog, clearLogs: () => setLogs([]) }; +} + +// ── Node identity key ───────────────────────────────────────────────────────── + +export function useNodeKey(signer: ccc.Signer | undefined, addLog: AddLog) { + const [walletAddr, setWalletAddr] = useState(""); + const [storedKey, setStoredKey] = useState(null); + + useEffect(() => { + if (!signer) { + return; + } + let cancelled = false; + signer.getInternalAddress().then((addr) => { + if (cancelled) { + return; + } + setWalletAddr(addr); + const saved = readLs(lsNodeKeyFor(addr)); + if (saved) { + setStoredKey(saved); + addLog("info", `Loaded stored node key for ${addr.slice(0, 12)}…`); + } + }); + return () => { + cancelled = true; + setWalletAddr(""); + setStoredKey(null); + }; + }, [signer, addLog]); + + const deriveKeys = useCallback( + async (message: string): Promise => { + if (!signer || !walletAddr) return null; + addLog("info", `Signing "${message}"…`); + try { + const sig = await signer.signMessage(message); + const sigBytes = ccc.bytesFrom(sig.signature); + const fiberKeyHex = ccc.hashCkb(sigBytes); + setStoredKey(fiberKeyHex); + writeLs(lsNodeKeyFor(walletAddr), fiberKeyHex); + addLog("success", "Node identity key derived and persisted."); + return ccc.bytesFrom(fiberKeyHex); + } catch (e) { + addLog("error", `Key derivation failed: ${errMsg(e)}`); + return null; + } + }, + [signer, walletAddr, addLog], + ); + + // Avoids wallet pop-ups on COOP-restricted pages; uses the already-stored key. + const keysFromStored = useCallback((): Uint8Array | null => { + if (!storedKey) return null; + return ccc.bytesFrom(storedKey); + }, [storedKey]); + + return { walletAddr, storedKey, deriveKeys, keysFromStored }; +} + +// ── Fiber node lifecycle & RPC ──────────────────────────────────────────────── + +export interface StartOptions { + fiberKey: Uint8Array; + dbPrefix: string; + configYaml: string | undefined; +} + +export function useFiberNode(signer: ccc.Signer | undefined, addLog: AddLog) { + const fiberRef = useRef(null); + const [isRunning, setIsRunning] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [nodeInfo, setNodeInfo] = useState(null); + const [peers, setPeers] = useState([]); + const [channels, setChannels] = useState([]); + + useEffect( + () => () => { + fiberRef.current?.stop().catch(() => undefined); + }, + [], + ); + + // Redirect console output to the activity log while the node is running. + useEffect(() => { + if (!isRunning) { + return; + } + const levels = [ + ["log", "info"], + ["warn", "warn"], + ["error", "error"], + ] as const; + const originals = levels.map(([method]) => console[method].bind(console)); + levels.forEach(([method, level], i) => { + console[method] = (...a: unknown[]) => { + originals[i](...a); + addLog(level, a.map(String).join(" ")); + }; + }); + return () => { + levels.forEach(([method], i) => { + console[method] = originals[i]; + }); + }; + }, [isRunning, addLog]); + + function invoke(name: string, args?: unknown[]): Promise { + if (!fiberRef.current) throw new Error("Fiber node is not running"); + return fiberRef.current.invokeCommand(name, args) as Promise; + } + + const logResponse = useCallback( + (method: string, result: unknown) => { + const text = + result === undefined || result === null + ? "null" + : JSON.stringify(result); + addLog("success", `${method} → ${text}`); + }, + [addLog], + ); + + function resetState() { + setIsRunning(false); + setNodeInfo(null); + setPeers([]); + setChannels([]); + } + + const refreshNodeData = useCallback(async () => { + if (!fiberRef.current) { + return; + } + setIsRefreshing(true); + addLog("info", "Refreshing node data…"); + try { + const [info, peerRes, chanRes] = await Promise.all([ + invoke("node_info"), + invoke<{ peers: FjPeer[] }>("list_peers"), + invoke<{ channels: FjChannel[] }>("list_channels", [{}]), + ]); + logResponse("node_info", info); + logResponse("list_peers", peerRes); + logResponse("list_channels", chanRes); + setNodeInfo(info); + setPeers(peerRes.peers ?? []); + setChannels(chanRes.channels ?? []); + } catch (e) { + addLog("error", `Refresh failed: ${errMsg(e)}`); + } finally { + setIsRefreshing(false); + } + }, [addLog, logResponse]); + + const startNode = useCallback( + async (opts: StartOptions) => { + setIsStarting(true); + try { + if (!opts.configYaml) { + addLog( + "warn", + "No YAML config provided — please fill in the Node Configuration before starting.", + ); + return; + } + addLog("info", "Starting fiber node…"); + const { Fiber } = await import("@nervosnetwork/fiber-js"); + const fiber = new Fiber(); + await fiber.start( + opts.configYaml, + opts.fiberKey, + undefined, + undefined, + "info", + opts.dbPrefix, + ); + const readyProbe = await fiber.invokeCommand("list_channels", [{}]); + fiberRef.current = fiber; + logResponse("list_channels (ready probe)", readyProbe); + setIsRunning(true); + await refreshNodeData(); + } catch (e) { + addLog("error", `Node start failed: ${errMsg(e)}`); + throw e; + } finally { + setIsStarting(false); + } + }, + [addLog, logResponse, refreshNodeData], + ); + + const stopNode = useCallback(async () => { + addLog("info", "Stopping fiber node…"); + await fiberRef.current?.stop().catch(() => undefined); + fiberRef.current = null; + resetState(); + addLog("info", "Fiber node stopped."); + }, [addLog]); + + const clearNodeData = useCallback( + async (dbPrefix: string) => { + if (fiberRef.current) { + addLog("info", "Stopping fiber node before clearing data…"); + await fiberRef.current.stop().catch(() => undefined); + fiberRef.current = null; + resetState(); + } + addLog("info", `Deleting IndexedDB databases with prefix "${dbPrefix}"…`); + try { + const dbs = await indexedDB.databases(); + const targets = dbs.filter((d) => d.name?.startsWith(dbPrefix)); + if (targets.length === 0) { + addLog("warn", "No fiber databases found for this wallet."); + return; + } + await Promise.all( + targets.map( + (d) => + new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(d.name!); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => + addLog( + "warn", + `Deletion of "${d.name}" is blocked — close other tabs.`, + ); + }), + ), + ); + addLog( + "success", + `Cleared ${targets.length} database(s). Start the node to begin fresh.`, + ); + } catch (e) { + addLog("error", `Failed to clear data: ${errMsg(e)}`); + } + }, + [addLog], + ); + + const connectPeer = useCallback( + async (address: string) => { + addLog("info", `Connecting to peer: ${address}`); + const connectResult = await invoke("connect_peer", [ + { address, save: true }, + ]); + logResponse("connect_peer", connectResult); + }, + [addLog, logResponse], + ); + + const disconnectPeer = useCallback( + async (peerId: string) => { + addLog("info", `Disconnecting peer: ${peerId.slice(0, 20)}…`); + const disconnectResult = await invoke("disconnect_peer", [ + { pubkey: peerId }, + ]); + logResponse("disconnect_peer", disconnectResult); + }, + [addLog, logResponse], + ); + + const openChannel = useCallback( + async ( + peerId: string, + fundingAmount: string, + isPublic: boolean, + ): Promise => { + if (!signer) throw new Error("No signer connected"); + + addLog("info", `Opening channel with ${peerId.slice(0, 20)}…`); + + const addr = await signer.getRecommendedAddressObj(); + const lock = addr.script; + const lockRpc: CkbRpcScript = { + code_hash: ccc.hexFrom(lock.codeHash), + hash_type: lock.hashType, + args: ccc.hexFrom(lock.args), + }; + + const lockCellDeps = await resolveLockCellDeps(signer.client, lock); + + const openResult = await fiberRef.current!.openChannelWithExternalFunding( + { + pubkey: peerId, + funding_amount: ccc.numToHex(ccc.fixedPointFrom(fundingAmount, 8)), + public: isPublic, + shutdown_script: lockRpc, + funding_lock_script: lockRpc, + funding_lock_script_cell_deps: + lockCellDeps.length > 0 ? lockCellDeps : undefined, + }, + ); + logResponse("open_channel_with_external_funding", { + channel_id: openResult.channel_id, + }); + + // Convert unsigned CKB JSON-RPC tx → ccc.Transaction + const rpc = openResult.unsigned_funding_tx; + const ccTx = ccc.Transaction.from({ + version: rpc.version, + cellDeps: rpc.cell_deps.map((d) => ({ + depType: d.dep_type === "dep_group" ? "depGroup" : "code", + outPoint: { txHash: d.out_point.tx_hash, index: d.out_point.index }, + })), + headerDeps: rpc.header_deps, + inputs: rpc.inputs.map((i) => ({ + previousOutput: { + txHash: i.previous_output.tx_hash, + index: i.previous_output.index, + }, + since: i.since, + })), + outputs: rpc.outputs.map((o) => ({ + capacity: o.capacity, + lock: { + codeHash: o.lock.code_hash, + hashType: o.lock.hash_type, + args: o.lock.args, + }, + type: o.type + ? { + codeHash: o.type.code_hash, + hashType: o.type.hash_type, + args: o.type.args, + } + : undefined, + })), + outputsData: rpc.outputs_data, + witnesses: rpc.witnesses, + }); + + // Populate live-cell data so the signer can compute the signing hash + for (const input of ccTx.inputs) { + const cell = await signer.client.getCell(input.previousOutput); + if (cell) { + input.cellOutput = cell.cellOutput; + input.outputData = cell.outputData; + } + } + + addLog("info", "Signing funding transaction…"); + const signedTx = await signer.signOnlyTransaction(ccTx); + + // Convert signed ccc.Transaction back to CKB JSON-RPC format + const signedRpc: CkbRpcTransaction = { + version: ccc.numToHex(signedTx.version), + cell_deps: signedTx.cellDeps.map((d) => ({ + dep_type: d.depType === "depGroup" ? "dep_group" : "code", + out_point: { + tx_hash: ccc.hexFrom(d.outPoint.txHash), + index: ccc.numToHex(d.outPoint.index), + }, + })), + header_deps: signedTx.headerDeps.map((h) => ccc.hexFrom(h)), + inputs: signedTx.inputs.map((i) => ({ + previous_output: { + tx_hash: ccc.hexFrom(i.previousOutput.txHash), + index: ccc.numToHex(i.previousOutput.index), + }, + since: ccc.numToHex(i.since), + })), + outputs: signedTx.outputs.map((o) => ({ + capacity: ccc.numToHex(o.capacity), + lock: { + code_hash: ccc.hexFrom(o.lock.codeHash), + hash_type: o.lock.hashType, + args: ccc.hexFrom(o.lock.args), + }, + type: o.type + ? { + code_hash: ccc.hexFrom(o.type.codeHash), + hash_type: o.type.hashType, + args: ccc.hexFrom(o.type.args), + } + : undefined, + })), + outputs_data: signedTx.outputsData.map((d) => ccc.hexFrom(d)), + witnesses: signedTx.witnesses.map((w) => ccc.hexFrom(w)), + }; + + addLog("info", "Submitting signed funding transaction…"); + const submitResult = await fiberRef.current!.submitSignedFundingTx({ + channel_id: openResult.channel_id, + signed_funding_tx: signedRpc, + }); + logResponse("submit_signed_funding_tx", submitResult); + return { channel_id: submitResult.channel_id }; + }, + [signer, addLog, logResponse], + ); + + const shutdownChannel = useCallback( + async (channelId: string) => { + addLog("info", `Closing channel ${channelId.slice(0, 18)}…`); + const result = await invoke("shutdown_channel", [ + { channel_id: channelId }, + ]); + logResponse("shutdown_channel", result); + }, + [addLog, logResponse], + ); + + const newInvoice = useCallback( + async (amount: string, description: string): Promise => { + addLog("info", `Creating invoice for ${amount} CKB…`); + const preimage = ccc.hexFrom(crypto.getRandomValues(new Uint8Array(32))); + const result = await invoke("new_invoice", [ + { + amount: ccc.numToHex(ccc.fixedPointFrom(amount, 8)), + currency: "Fibt", + payment_preimage: preimage, + description: description || undefined, + expiry: ccc.numToHex(3600), + final_expiry_delta: ccc.numToHex(9600000), + }, + ]); + logResponse("new_invoice", result); + return result; + }, + [addLog, logResponse], + ); + + const getInvoice = useCallback( + async (paymentHash: string): Promise => { + addLog("info", `Checking invoice ${paymentHash.slice(0, 18)}…`); + const result = await invoke("get_invoice", [ + { payment_hash: paymentHash }, + ]); + logResponse("get_invoice", result); + return result; + }, + [addLog, logResponse], + ); + + const sendPayment = useCallback( + async (invoice: string): Promise => { + addLog("info", "Sending payment…"); + const result = await invoke("send_payment", [{ invoice }]); + logResponse("send_payment", result); + return result; + }, + [addLog, logResponse], + ); + + return { + isRunning, + isStarting, + isRefreshing, + nodeInfo, + peers, + channels, + startNode, + stopNode, + clearNodeData, + refreshNodeData, + connectPeer, + disconnectPeer, + openChannel, + shutdownChannel, + newInvoice, + getInvoice, + sendPayment, + }; +} diff --git a/packages/demo/src/app/connected/(tools)/Fiber/page.tsx b/packages/demo/src/app/connected/(tools)/Fiber/page.tsx new file mode 100644 index 000000000..f47283369 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/page.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { Button } from "@/src/components/Button"; +import { TextInput } from "@/src/components/Input"; +import { useApp } from "@/src/context"; +import { + ChevronDown, + ChevronUp, + Copy, + HelpCircle, + Key, + Play, + RefreshCw, + Settings, + Square, + Trash2, + Wifi, + WifiOff, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { + Card, + ChannelsTab, + InvoicesTab, + LogPanel, + NodeInfoGrid, + PaymentsTab, + PeersTab, + TabBar, +} from "./components"; +import { + LS_MANUAL_CONFIG, + maskKey, + readLs, + SIGN_MESSAGE, + writeLs, +} from "./config"; +import { useActivityLog, useFiberNode, useNodeKey } from "./hooks"; +import type { Tab } from "./types"; + +export default function FiberPage() { + const { signer, createSender } = useApp(); + const { log, error } = createSender("Fiber"); + + // ── Config (persisted) ─────────────────────────────────────────────────────── + const [manualConfig, setManualConfig] = useState(() => + readLs(LS_MANUAL_CONFIG, ""), + ); + const [configOpen, setConfigOpen] = useState(true); + const [activeTab, setActiveTab] = useState("peers"); + + // ── Signing message (editable by the user) ─────────────────────────────────── + const [signMessage, setSignMessage] = useState(SIGN_MESSAGE); + + // ── Hooks ──────────────────────────────────────────────────────────────────── + const { logs, addLog, clearLogs } = useActivityLog(); + const { walletAddr, storedKey, deriveKeys, keysFromStored } = useNodeKey( + signer, + addLog, + ); + const node = useFiberNode(signer, addLog); + + // ── Derived values ─────────────────────────────────────────────────────────── + const dbPrefix = useMemo( + () => `/fiber-demo-${walletAddr.slice(0, 12)}`, + [walletAddr], + ); + + // ── Handlers ───────────────────────────────────────────────────────────────── + const handleStart = async () => { + const fiberKey = keysFromStored() ?? (await deriveKeys(signMessage)); + if (!fiberKey) { + return; + } + try { + await node.startNode({ + fiberKey, + dbPrefix, + configYaml: manualConfig || undefined, + }); + log("Fiber node started"); + } catch { + error("Fiber node failed to start"); + } + }; + + const handleStop = async () => { + await node.stopNode(); + log("Fiber node stopped"); + }; + + // ── Render ─────────────────────────────────────────────────────────────────── + return ( +
+ {/* Config */} + + + {configOpen && ( +
+