diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..37af1c69f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,33 @@ +{ + "permissions": { + "allow": [ + "Bash(python3:*)", + "Bash(node:*)", + "Bash(npx tsc:*)", + "Bash(pnpm run:*)", + "Bash(mv:*)", + "Bash(pnpm:*)", + "Bash(npx copyfiles:*)", + "Bash(npx tsx:*)", + "Bash(npx prettier:*)", + "Bash(npx eslint:*)", + "Bash(grep -v \"^$\")", + "Bash(mkdir -p /Users/vimchain/Cryptape/ccc/.claude/skills)", + "Bash(mv /Users/vimchain/.claude/skills/ccc-code-style.md /Users/vimchain/Cryptape/ccc/.claude/skills/)", + "Bash(mv /Users/vimchain/.claude/skills/skip-format-build.md /Users/vimchain/Cryptape/ccc/.claude/skills/)", + "Bash(npm show:*)", + "Bash(npm pack:*)", + "Read(//private/tmp/**)", + "Bash(tar -tzf nervosnetwork-fiber-js-0.8.0.tgz)", + "Bash(grep:*)", + "Bash(npm view *)", + "Skill(update-config)", + "Bash(npm info *)", + "Bash(tar -tzf nervosnetwork-fiber-js-0.8.1.tgz)", + "Bash(npm pack *)", + "Bash(mkdir fiber-old *)", + "Bash(tar -xzf nervosnetwork-fiber-js-0.7.1.tgz -C fiber-old)", + "Bash(curl -s \"https://raw.githubusercontent.com/ckb-devrel/ccc/50d657beea36de3ebbd80ee88209842644daef34/packages/core/src/molecule/entity.ts\")" + ] + } +} diff --git a/.claude/skills/ccc-code-style.md b/.claude/skills/ccc-code-style.md new file mode 100644 index 000000000..8cc6db623 --- /dev/null +++ b/.claude/skills/ccc-code-style.md @@ -0,0 +1,141 @@ +--- +name: ccc-code-style +description: This skill should be used whenever generating, editing, or reviewing TypeScript code in the CCC (CKBers' Codebase) monorepo — especially packages/core, packages/fiber, or any SDK package. Activates when the user asks to add features, fix bugs, refactor code, or write new modules in this project. +version: 1.0.0 +--- + +# CCC Code Style — `packages/core` Convention + +All new code in this repo MUST conform to the conventions derived from `packages/core`. Apply every rule below before finalising any edit. + +--- + +## 1. Comment Style + +### JSDoc (mandatory for every exported symbol) +- Use `/** … */` blocks above every exported function, class, type, and constant. +- Include `@param name - description` (dash separator, no type annotation — TypeScript carries the type). +- Include `@returns description` when the return value is non-obvious. +- Include `@example` blocks for conversion helpers and public utilities. +- Include `@see OtherSymbol - one-line note` when a related function exists. +- Mark all public API symbols with `@public`. + +```typescript +/** + * Converts a hex string to a Uint8Array. + * + * @param hex - A valid ccc.Hex string starting with "0x". + * @returns The decoded bytes. + * @example + * const bytes = bytesFromHex("0xdeadbeef"); + * @see hexFrom - Convert bytes back to a hex string + * @public + */ +export function bytesFromHex(hex: Hex): Bytes { … } +``` + +### Inline comments +- Use only for non-obvious decisions: V8 optimisations, algorithm tradeoffs, 2's-complement edge cases. +- Never explain what the code does when it is already readable; explain *why*. + +### No non-English comments. + +--- + +## 2. Naming Conventions + +| Kind | Convention | Examples | +|---|---|---| +| Classes | PascalCase, descriptive suffix | `SignerCkbPrivateKey`, `FiberClient` | +| Abstract / readonly variants | Append `Readonly` | `SignerBtcPublicKeyReadonly` | +| Functions — converters | verb-first `*From` / `*To` | `hexFrom()`, `bytesTo()`, `numFromBytes()` | +| Functions — predicates | `is` prefix | `isHex()`, `isConnected()` | +| Functions — unsafe fast-path | `*Unsafe` suffix | `bytesLenUnsafe()` | +| Types — domain | PascalCase | `Hex`, `Bytes`, `Num` | +| Types — input unions | `*Like` suffix | `HexLike`, `NumLike`, `BytesLike` | +| String enums | PascalCase name, string values | `enum SignerType { EVM = "EVM" }` | +| Union type aliases | PascalCase | `export type HashType = "type" \| "data"` | +| Private/protected fields | trailing underscore | `client_` | +| Files | kebab-case matching export | `signerCkbPrivateKey.ts` | +| Advanced/internal files | `.advanced.ts` suffix | `hash.advanced.ts` | +| Numbers | `bigint` exclusively (`Num = bigint`) | `0n`, `100n` | + +--- + +## 3. Code Structure + +### File layout (top → bottom) +1. `import type { … }` — type-only imports first +2. `import { … }` — value imports (relative paths, `.js` extension for ESM) +3. Type aliases and interfaces +4. Constants +5. Exported classes / functions (public API) +6. Internal helpers + +### Class layout (top → bottom) +1. `public readonly` properties +2. `protected` / `private` properties (trailing underscore) +3. `constructor` +4. Getters / property accessors +5. Concrete public methods (grouped by feature) +6. Abstract / overridable methods +7. Static factory / utility methods +8. Protected `_*` internal helpers + +### Export pattern +- Every package re-exports via `index.ts` barrel: `export * from "./module.js"` +- Unstable / advanced APIs live in a parallel `.advanced.ts` file and are exposed via the `advanced` export entry point (`cccA` namespace). +- Use `export type { … }` for type-only re-exports. + +--- + +## 4. Problem-Breakdown Principles + +### Conversion pairs +Every domain type gets explicit `*From()` (parse input → domain type) and `*To()` (domain type → output) functions. Never collapse the two directions into one overloaded function. + +### `*Like` input union pattern +Public functions accept a union input type (`HexLike`, `NumLike`) and return the strict domain type. This eliminates overloads and lets callers pass any reasonable format. + +```typescript +export type HexLike = Hex | string | Uint8Array | ArrayBuffer | ArrayLike; +export function hexFrom(val: HexLike): Hex { … } +``` + +### Validated vs. unsafe pairs +When a function is called in hot paths and its input is already validated upstream, provide an `*Unsafe` variant that skips validation. Document the precondition and the performance reason in JSDoc. + +### Abstract base class + subclass per chain +Define behaviour contracts with `abstract class`. Extend per-blockchain: `SignerCkbPrivateKey`, `SignerEvmPublicKey`. Use the template-method pattern: abstract leaf methods, concrete orchestration in the base. + +### Factory methods +Prefer `static from(input: XLike): X` for synchronous construction and `static async fromString(s: string, client: Client): Promise` for network-dependent construction. Group multiple factories by input kind: `Address.fromScript()`, `Address.fromKnownScript()`. + +### Endianness / encoding variants +For multi-format outputs, define named variants: `numLeToBytes()`, `numBeToBytes()`. Let the "default" wrapper call the canonical one. + +### Codec abstraction (Molecule) +Serialization concerns live in a `mol.Codec` wrapper. Domain types do not contain raw encode/decode logic inline; they delegate to a codec object. + +### Error messages +Always include the attempted value: `throw Error(\`Invalid hex string: \${v}\`)`. Check preconditions in constructors (fail fast). Never swallow errors silently. + +### Async patterns +- Use `async` / `await` throughout; no raw `.then()` chains. +- Parallelise independent async operations with `Promise.all([…])`. +- Internal async helpers are `protected async _methodName()`. + +--- + +## 5. Checklist Before Finishing + +- [ ] Every exported symbol has a JSDoc block with `@public`, `@param`, `@returns`. +- [ ] No non-English comments. +- [ ] Input types use `*Like` unions where the caller might reasonably pass multiple formats. +- [ ] Numeric values use `bigint` / `Num`; never `number` for blockchain quantities. +- [ ] Conversion functions follow `*From` / `*To` naming; predicates use `is*`. +- [ ] Private/protected fields use trailing underscore (`client_`). +- [ ] Hot-path variants suffixed `*Unsafe` with precondition documented. +- [ ] Advanced/internal exports separated into `.advanced.ts`. +- [ ] File name matches the primary export in kebab-case. +- [ ] New barrel entries added to the nearest `index.ts`. diff --git a/.claude/skills/ccc-conversions.md b/.claude/skills/ccc-conversions.md new file mode 100644 index 000000000..fe55f0e3d --- /dev/null +++ b/.claude/skills/ccc-conversions.md @@ -0,0 +1,34 @@ +--- +name: ccc-conversions +description: Enforce use of CCC conversion utilities over manual hex/byte/number implementations in this monorepo +version: 1 +--- + +# CCC Conversion Utilities — Mandatory Usage + +When writing or modifying code in this repository, always prefer the conversion utilities +exported from `@ckb-ccc/*` packages over manual implementations. + +## Preferred utilities + +| Need | Use | Never write | +|------|-----|-------------| +| hex string → bigint | `ccc.numFrom(hex)` | `BigInt(hex)`, `parseInt(hex, 16)` | +| number/bigint → hex string | `ccc.numToHex(n)` | `` `0x${n.toString(16)}` `` | +| hex string → `Uint8Array` | `ccc.bytesFrom(hex)` | `Buffer.from(hex.slice(2), "hex")` | +| `Uint8Array` → hex string | `ccc.hexFrom(bytes)` | manual loop / `Buffer.toString("hex")` | +| decimal string → hex | `ccc.numToHex(BigInt(s))` | `` `0x${BigInt(s).toString(16)}` `` | +| CKB hash | `ccc.hashCkb(bytes)` | custom SHA256/blake2b calls | + +## Rules + +1. **Import source**: import `ccc` from whichever `@ckb-ccc/*` package is already a + dependency of the file (e.g. `@ckb-ccc/connector-react` in demo pages, + `@ckb-ccc/core` in library code). +2. **No raw `BigInt(hex)`** for hex-encoded numbers — use `ccc.numFrom(hex)`. +3. **No manual hex construction** (template literals with `.toString(16)`) — use `ccc.numToHex`. +4. **No `Buffer` / `TextDecoder` for byte↔hex** — use `ccc.bytesFrom` / `ccc.hexFrom`. +5. **Keep display wrappers thin**: if a helper converts a CCC type to a display string + (e.g. `hexToCkb`), it must still delegate the parsing step to a CCC utility. +6. Exception: pure string operations like `maskKey` (slicing, truncating) have no CCC + equivalent and may remain manual. diff --git a/.claude/skills/curly-braces-required.md b/.claude/skills/curly-braces-required.md new file mode 100644 index 000000000..d9b4c64b9 --- /dev/null +++ b/.claude/skills/curly-braces-required.md @@ -0,0 +1,56 @@ +--- +name: curly-braces-required +description: This skill applies to every task in this project. It mandates that Claude must ALWAYS wrap control flow bodies (`if`, `else`, `else if`, `for`, `while`, `do`) in curly braces `{}`, even when the body is a single statement. Single-line forms like `if (x) return;` are NEVER acceptable. +version: 1.0.0 +--- + +# Curly Braces Required on All Control Flow Bodies + +Every `if`, `else`, `else if`, `for`, `while`, and `do` body **must** be enclosed in `{}`, regardless of how many statements it contains. + +## Forbidden patterns + +```ts +if (condition) return; +if (condition) doSomething(); +for (const x of xs) process(x); +while (running) tick(); +``` + +## Required patterns + +```ts +if (condition) { + return; +} + +if (condition) { + doSomething(); +} + +for (const x of xs) { + process(x); +} + +while (running) { + tick(); +} +``` + +## Applies to + +- All TypeScript / JavaScript files in this repo +- All branches: `if`, `else if`, `else`, ternary bodies are exempt (they are expressions, not statements), but every statement-level branch needs braces +- Early returns, `continue`, `break`, `throw` — no exceptions + +## When editing existing code + +If you touch a file that already contains brace-free control flow in the lines you are modifying or in immediately surrounding context, fix those occurrences in the same edit. Do not reformat unrelated lines far from your change. + +## This rule is unconditional + +Do not omit braces even if: +- The body is `return;`, `continue;`, or `break;` — one of the shortest possible statements. +- The original code you are based on uses the brace-free style. +- A linter or formatter would accept the brace-free form. +- The surrounding code uses the brace-free style consistently. diff --git a/packages/demo/next.config.mjs b/packages/demo/next.config.mjs index c3f0428b2..64d8a2f37 100644 --- a/packages/demo/next.config.mjs +++ b/packages/demo/next.config.mjs @@ -3,6 +3,27 @@ const nextConfig = { experimental: { optimizePackageImports: ["@ckb-ccc/core", "@ckb-ccc/core/bundle"], }, + // SharedArrayBuffer (@nervosnetwork/fiber-js WASM) requires a cross-origin + // isolated document: Cross-Origin-Opener-Policy + Cross-Origin-Embedder-Policy. + // + // COOP: same-origin breaks window.opener for cross-origin wallet popups, + // so it is scoped to /connected/Fiber only; signing flows use a dedicated + // same-origin route without these headers where needed (e.g. sign proxy). + // + // COEP credentialless isolates without requiring CORP on every subresource + // (unlike require-corp), which suits the demo shell + CDN assets. + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Cross-Origin-Resource-Policy", value: "cross-origin" }, + { 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..314120cf9 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/components.tsx @@ -0,0 +1,793 @@ +import { Button } from "@/src/components/Button"; +import { TextInput } from "@/src/components/Input"; +import { ccc } from "@ckb-ccc/connector-react"; +import { + ChevronDown, + ChevronRight, + Copy, + GitBranch, + Loader2, + Network, + Send, + Terminal, + Users, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { hexToCkb } from "./config"; +import type { + FjChannel, + FjGetInvoice, + FjGraphChannel, + FjGraphNode, + 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" }, + { key: "graph", label: "Graph" }, +]; + +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}

+
+ )} +
+ ); +} + +// ── Graph tab ───────────────────────────────────────────────────────────────── + +function formatTimestamp(hex: string): string { + try { + let ms = Number(BigInt(hex)); + if (ms < 1e10) ms *= 1000; + return new Date(ms).toLocaleDateString("en", { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return "—"; + } +} + +export function GraphTab({ + nodes, + graphChannelsByNode, + isLoadingChannels, + onFetch, +}: { + nodes: FjGraphNode[]; + graphChannelsByNode: Map; + isLoadingChannels: boolean; + onFetch: () => Promise; +}) { + const [search, setSearch] = useState(""); + const [expandedPubkey, setExpandedPubkey] = useState(null); + + const filtered = + search.trim() === "" + ? nodes + : nodes.filter((n) => + n.pubkey.toLowerCase().includes(search.trim().toLowerCase()), + ); + + return ( +
+
+
+ +
+
+ +
+
+ +

+ + Network nodes ( + {search.trim() !== "" && filtered.length !== nodes.length + ? `${filtered.length} / ${nodes.length}` + : nodes.length} + ) + {isLoadingChannels && ( + <> + · + + + loading channels + + + )} +

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

+ {nodes.length === 0 + ? "Press Fetch to load network nodes." + : "No nodes match the search."} +

+ ) : ( +
+ {/* Header */} +
+ Name + + Pubkey + + Min CKB + Joined +
+ {filtered.map((n) => { + const nodeChannels = graphChannelsByNode.get(n.pubkey) ?? []; + const hasChannels = nodeChannels.length > 0; + const isExpanded = expandedPubkey === n.pubkey; + + return ( +
+ {/* Node row */} +
+ hasChannels && + setExpandedPubkey(isExpanded ? null : n.pubkey) + } + > + {hasChannels ? ( + + {isExpanded ? ( + + ) : ( + + )} + + ) : ( + + )} + + {n.node_name || "—"} + + + + {n.pubkey} + + {hasChannels && ( + + {nodeChannels.length} + + )} + + + {hexToCkb(n.auto_accept_min_ckb_funding_amount)} CKB + + + {formatTimestamp(n.timestamp)} + +
+ + {/* Expanded channel sub-table — visually nested inside the node row */} + {isExpanded && hasChannels && ( +
+ {/* spacers mirror the parent row's chevron + name columns */} + + + {/* card — wide enough to fit a full 66-char pubkey on one line */} +
+
+ Other Node + + Capacity + + + Created + +
+ {nodeChannels.map((ch) => { + const otherEnd = + ch.node1 === n.pubkey ? ch.node2 : ch.node1; + return ( +
+ + {otherEnd} + + + {hexToCkb(ch.capacity)} CKB + + + {formatTimestamp(ch.created_timestamp)} + +
+ ); + })} +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} + +// ── 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..1d27fabe3 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/config.ts @@ -0,0 +1,107 @@ +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"; + +/** Editor default when `LS_MANUAL_CONFIG` is absent; keep in sync with `./config/fiber.toml`. */ +export const DEFAULT_FIBER_MANUAL_CONFIG = `# This configuration file only contains the necessary configurations for the testnet deployment. +# All options' descriptions can be found via fnn --help and be overridden by command line arguments or environment variables. +fiber: + # standalone_watchtower_rpc_url: http://127.0.0.1:23456 + # disable_built_in_watchtower: true + listening_addr: "/ip4/127.0.0.1/tcp/8238" + bootnode_addrs: + - "/dns4/thrall.fiber.channel/tcp/443/wss/p2p/Qmes1EBD4yNo9Ywkfe6eRw9tG1nVNGLDmMud1xJMsoYFKy" + - "/dns4/onyxia.fiber.channel/tcp/443/wss/p2p/QmdyQWjPtbK4NWWsvy8s69NGJaQULwgeQDT5ZpNDrTNaeV" + - "/dns4/bottle.fiber.channel/tcp/443/wss/p2p/QmXen3eUHhywmutEzydCsW4hXBoeVmdET2FJvMX69XJ1Eo" + announce_listening_addr: false + announced_addrs: + # If you want to announce your fiber node public address to the network, you need to add the address here, please change the ip to your public ip accordingly. + # - "/ip4/YOUR-FIBER-NODE-PUBLIC-IP/tcp/8228" + chain: testnet + # lock script configurations related to fiber network + # https://github.com/nervosnetwork/fiber-scripts/blob/main/deployment/testnet/migrations/2025-02-28-111246.json + scripts: + - name: FundingLock + script: + code_hash: 0x6c67887fe201ee0c7853f1682c0b77c0e6214044c156c7558269390a8afa6d7c + hash_type: type + args: 0x + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0x3cb7c0304fe53f75bb5727e2484d0beae4bd99d979813c6fc97c3cca569f10f6 + - cell_dep: + out_point: + tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7 # ckb_auth + index: 0x0 + dep_type: code + - name: CommitmentLock + script: + code_hash: 0x740dee83f87c6f309824d8fd3fbdd3c8380ee6fc9acc90b1a748438afcdf81d8 + hash_type: type + args: 0x + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0xf7e458887495cf70dd30d1543cad47dc1dfe9d874177bf19291e4db478d5751b + - cell_dep: + out_point: + tx_hash: 0x12c569a258dd9c5bd99f632bb8314b1263b90921ba31496467580d6b79dd14a7 #ckb_auth + index: 0x0 + dep_type: code + +rpc: + # By default RPC only binds to localhost, thus it only allows accessing from the same machine. + # Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. + # Please strictly limit the access to only trusted machines. + listening_addr: "127.0.0.1:8237" + +ckb: + rpc_url: "https://testnet.ckbapp.dev/" + udt_whitelist: + - name: RUSD + script: + code_hash: 0x1142755a044bf2ee358cba9f2da187ce928c91cd4dc8692ded0337efa677d21a + hash_type: type + args: 0x878fcc6f1f08d48e87bb1c3b3d5083f23f8a39c5d5c764f253b55b998526439b + cell_deps: + - type_id: + code_hash: 0x00000000000000000000000000000000000000000000000000545950455f4944 + hash_type: type + args: 0x97d30b723c0b2c66e9cb8d4d0df4ab5d7222cbb00d4a9a2055ce2e5d7f0d8b0f + auto_accept_amount: 1000000000 + +services: + - fiber + - rpc + - ckb +`; + +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..86f5f8e11 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/hooks.ts @@ -0,0 +1,617 @@ +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, + FjGraphChannel, + FjGraphNode, + 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); + // Opaque string from the wallet (hex, base64, etc.): hash UTF-8 bytes so + // we never interpret the encoding—only the exact string matters. + const fiberKeyHex = ccc.hashCkb(ccc.bytesFrom(sig.signature, "utf8")); + 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([]); + const [graphNodes, setGraphNodes] = useState([]); + const [graphChannelsByNode, setGraphChannelsByNode] = useState< + Map + >(new Map()); + const [isLoadingChannels, setIsLoadingChannels] = useState(false); + + 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([]); + setGraphNodes([]); + setGraphChannelsByNode(new Map()); + setIsLoadingChannels(false); + } + + 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], + ); + + const fetchGraphChannels = useCallback(async () => { + if (!fiberRef.current) return; + setIsLoadingChannels(true); + setGraphChannelsByNode(new Map()); + addLog("info", "Fetching graph channels…"); + try { + let after: string | undefined; + const limit = 100; + let total = 0; + while (true) { + const params: Record = { limit: ccc.numToHex(limit) }; + if (after) params.after = after; + const res = await invoke<{ + channels: FjGraphChannel[]; + last_cursor: string; + }>("graph_channels", [params]); + const batch = res.channels ?? []; + total += batch.length; + if (batch.length > 0) { + setGraphChannelsByNode((prev) => { + const next = new Map(prev); + for (const ch of batch) { + next.set(ch.node1, [...(next.get(ch.node1) ?? []), ch]); + next.set(ch.node2, [...(next.get(ch.node2) ?? []), ch]); + } + return next; + }); + } + if (batch.length < limit) break; + after = res.last_cursor; + if (!after || after === "0x") break; + } + addLog("success", `Fetched ${total} graph channel(s).`); + } catch (e) { + addLog("error", `Failed to fetch graph channels: ${errMsg(e)}`); + } finally { + setIsLoadingChannels(false); + } + }, [addLog]); + + const fetchGraphNodes = useCallback(async () => { + addLog("info", "Fetching network graph nodes…"); + try { + const all: FjGraphNode[] = []; + let after: string | undefined; + const limit = 100; + while (true) { + const params: Record = { + limit: ccc.numToHex(limit), + }; + if (after) params.after = after; + const res = await invoke<{ nodes: FjGraphNode[]; last_cursor: string }>( + "graph_nodes", + [params], + ); + const batch = res.nodes ?? []; + all.push(...batch); + if (batch.length < limit) break; + after = res.last_cursor; + if (!after || after === "0x") break; + } + setGraphNodes(all); + addLog("success", `Fetched ${all.length} graph node(s).`); + // Start channel loading asynchronously — does not block node graph display + void fetchGraphChannels(); + } catch (e) { + addLog("error", `Failed to fetch graph nodes: ${errMsg(e)}`); + } + }, [addLog, fetchGraphChannels]); + + return { + isRunning, + isStarting, + isRefreshing, + nodeInfo, + peers, + channels, + graphNodes, + graphChannelsByNode, + isLoadingChannels, + startNode, + stopNode, + clearNodeData, + refreshNodeData, + connectPeer, + disconnectPeer, + openChannel, + shutdownChannel, + newInvoice, + getInvoice, + sendPayment, + fetchGraphNodes, + }; +} 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..262b14ed5 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/Fiber/page.tsx @@ -0,0 +1,347 @@ +"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, + GraphTab, + InvoicesTab, + LogPanel, + NodeInfoGrid, + PaymentsTab, + PeersTab, + TabBar, +} from "./components"; +import { + DEFAULT_FIBER_MANUAL_CONFIG, + 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, DEFAULT_FIBER_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 && ( +
+