diff --git a/tsconfig.json b/tsconfig.json index cb1fb23..a32640e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "sdk", "examples", "create-lightnode-app"] + "exclude": ["node_modules", "sdk", "examples", "create-lightnode-app", "wallet"] } diff --git a/wallet/.gitignore b/wallet/.gitignore new file mode 100644 index 0000000..f994fef --- /dev/null +++ b/wallet/.gitignore @@ -0,0 +1,5 @@ +node_modules +.output +.wxt +*.zip +stats.html diff --git a/wallet/README.md b/wallet/README.md new file mode 100644 index 0000000..917b9d1 --- /dev/null +++ b/wallet/README.md @@ -0,0 +1,59 @@ +# LightChain Wallet + +A **self-custodial** browser wallet for LightChain (EVM L1, chain 9200; testnet 8200). Like Phantom/MetaMask, it is a pure client-side **EOA wallet** - there is **no smart contract**, no relayer, and no server. Your keys are generated and encrypted **on your device and never leave it**. We are not an exchange and never custody funds. + +> Status: the self-custody core (create/import, unlock, send LCAI, dapp connect + sign) is functional on testnet. Run an external security audit before holding meaningful mainnet funds - that gate applies to every wallet, ours included. The architecture and keyring were built to a written spec and passed a 15-point adversarial security review (see "Security" below). + +## Run it + +```bash +cd wallet +npm install +npm run build # -> .output/chrome-mv3/ +npm test # keyring/vault unit tests (14) +npm run compile # typecheck +``` + +Load it in Chrome: `chrome://extensions` → enable Developer mode → **Load unpacked** → select `wallet/.output/chrome-mv3`. For live development with HMR: `npm run dev`. + +## What it does + +- **Create / import** a BIP-39 wallet (24 words), encrypted under your password. +- **Unlock / auto-lock**, **send LCAI**, view balance, copy address, open the explorer. +- **Connect to dapps** via EIP-1193 + **EIP-6963** (it appears *alongside* MetaMask, never clobbering `window.ethereum`), with a human approval window for `eth_requestAccounts`, `personal_sign`, and `eth_sendTransaction`. +- **Gas as a feature**: LightChain fees are negligible, so we drop the gwei theatre and show "negligible" instead of a scary number. + +The LightChain superpowers that make this wallet worth switching to - one-click **worker staking + monitoring**, **encrypted pay-per-call AI inference**, in-wallet **DAO intelligence**, and the **Ethereum bridge** - are wired through `lightnode-sdk` and land next; the exact SDK surface for each is mapped and ready. + +## Architecture + +``` +entrypoints/ + background.ts MV3 service worker - the ONLY place plaintext keys exist (volatile memory) + content.ts isolated-world relay - holds no keys + inpage.ts MAIN-world EIP-1193 provider + EIP-6963 announce + popup/ React UI (onboarding, unlock, account, send, dapp approval) +src/ + keyring/ mnemonic (BIP-39), hdwallet (BIP-44 m/44'/60'/0'/0/x), vault, keyring + rpc/ viem LightChain chain defs (pinned RPCs) + provider/ message protocol + RPC method policy +``` + +Only the background service worker ever touches plaintext key material; content/inpage are dumb relays, and the popup is UI. The dapp's origin is taken from the message sender, never from the page. + +## Security + +- **Vault**: the mnemonic is sealed with **AES-256-GCM**, key derived by **scrypt** (N=2¹⁶, r=8, p=1), random 16-byte salt + random 12-byte nonce per encryption. KDF params are recorded for future upgrade. A GCM auth-tag failure *is* the password check. Stored encrypted in `chrome.storage.local`; useless without the password. +- **Session**: while unlocked, the mnemonic lives in the background SW's volatile memory and in `chrome.storage.session` (in-memory, TRUSTED_CONTEXTS only, cleared on browser restart / extension reload). We deliberately do **not** "encrypt the seed with a key stored beside it" - that adds surface for zero gain. Auto-lock via `chrome.alarms`; the wallet boots locked after a browser restart. +- **Signing**: `eth_sendTransaction` signs exactly the transaction the approval window displayed (no field is recomputed between display and signature). `personal_sign` shows the decoded text, or a hard "unreadable data" warning for non-text payloads. Contract interactions are flagged. +- **Networks**: only the two pinned LightChain chains; we never honor a dapp-supplied RPC URL. +- **Keys**: derived via `@scure/bip39` + `@scure/bip32` (audited, 0-dep) and `viem`; raw key bytes are wiped after derivation. No private key is ever logged, persisted in plaintext, or sent anywhere. + +Tested against the canonical BIP-44 vector (the standard "abandon…about" mnemonic derives `0x9858…aeda94`), vault round-trip + wrong-password rejection, and chunked base64 over inputs up to 500 KB. + +### Known follow-ups before a production mainnet release +Tracked from the security review: decode + warn on dangerous calldata (`approve`/`setApprovalForAll`/`permit`/unlimited allowance) and add tx simulation; full EIP-712 domain display + `eth_signTypedData_v4`; Ledger/hardware support; relock on OS idle; an external audit + bug bounty. None of these affect the self-custody guarantee (keys never leave the device); they harden the dapp-signing surface. + +--- + +Independent, community-built. Not an official LightChain product. diff --git a/wallet/entrypoints/background.ts b/wallet/entrypoints/background.ts new file mode 100644 index 0000000..20efd2c --- /dev/null +++ b/wallet/entrypoints/background.ts @@ -0,0 +1,211 @@ +/** + * Background service worker = the ONLY place plaintext keys exist (in volatile + * module memory). Owns the vault, the unlocked session, dapp RPC routing, and the + * approval queue. Content/inpage scripts are dumb relays; the popup is the UI. + * + * Session model (per security review C1): we keep the encrypted vault in + * storage.local and, while unlocked, the mnemonic in storage.session - which is + * in-memory, TRUSTED_CONTEXTS-only (content scripts can't read it), and cleared on + * browser restart / extension reload. We do NOT "encrypt the seed with a key stored + * beside it" (that adds surface for zero gain). Threat model documented in README. + */ +import { createPublicClient, http, parseEther, formatEther } from "viem"; +import { Keyring } from "../src/keyring/keyring"; +import { encryptVault, decryptVault, type EncryptedVault } from "../src/keyring/vault"; +import { chainById, lightchainMainnet } from "../src/rpc/chains"; +import { type BgMessage, type WalletOp, type JsonRpcRequest, RpcError } from "../src/provider/protocol"; +import { APPROVAL_REQUIRED, LOCAL_READ, isAllowedMethod } from "../src/provider/rpc-methods"; + +const VAULT_KEY = "vault"; +const SESSION_KEY = "session-mnemonic"; +const PERMS_KEY = "connected-origins"; +const AUTO_LOCK_MIN = 15; + +let live: Keyring | null = null; +const pending = new Map void; reject: (e: { code: number; message: string }) => void }>(); +let pendingSeq = 0; + +const publicClient = () => createPublicClient({ chain: lightchainMainnet, transport: http() }); + +// ---- session lifecycle ----------------------------------------------------- + +async function restore(): Promise { + if (live) return live; + const { [SESSION_KEY]: mnemonic } = await browser.storage.session.get(SESSION_KEY); + if (typeof mnemonic === "string" && mnemonic) live = Keyring.fromMnemonic(mnemonic, 1); + return live; +} + +async function bumpAutoLock(): Promise { + await browser.alarms.create("autolock", { delayInMinutes: AUTO_LOCK_MIN }); +} + +async function lock(): Promise { + live?.wipe(); + live = null; + await browser.storage.session.remove(SESSION_KEY); +} + +browser.alarms.onAlarm.addListener((a) => { + if (a.name === "autolock") void lock(); +}); + +// ---- wallet ops (from our popup) ------------------------------------------- + +async function handleWalletOp(op: WalletOp): Promise { + switch (op.type) { + case "getState": { + const { [VAULT_KEY]: vault } = await browser.storage.local.get(VAULT_KEY); + const kr = await restore(); + return { + hasVault: Boolean(vault), + unlocked: Boolean(kr), + accounts: kr ? kr.accounts.map((a) => a.address) : [], + chainId: lightchainMainnet.id, + }; + } + case "createVault": + case "importVault": { + const vault = await encryptVault(op.mnemonic, op.password); + await browser.storage.local.set({ [VAULT_KEY]: vault }); + await browser.storage.session.set({ [SESSION_KEY]: op.mnemonic }); + live = Keyring.fromMnemonic(op.mnemonic, 1); + await bumpAutoLock(); + return { unlocked: true, accounts: live.accounts.map((a) => a.address) }; + } + case "unlock": { + const { [VAULT_KEY]: vault } = (await browser.storage.local.get(VAULT_KEY)) as { vault?: EncryptedVault }; + if (!vault) throw RpcError.invalidParams; + const mnemonic = await decryptVault(vault, op.password); // throws "Invalid password" + live = Keyring.fromMnemonic(mnemonic, 1); + await browser.storage.session.set({ [SESSION_KEY]: mnemonic }); + await bumpAutoLock(); + return { unlocked: true, accounts: live.accounts.map((a) => a.address) }; + } + case "lock": + await lock(); + return { unlocked: false }; + case "addAccount": { + const kr = await restore(); + if (!kr) throw RpcError.locked; + const acct = kr.addAccount(); + await bumpAutoLock(); + return { address: acct.address }; + } + case "getBalance": { + const wei = await publicClient().getBalance({ address: op.address as `0x${string}` }); + return { wei: wei.toString(), lcai: formatEther(wei) }; + } + case "send": { + const kr = await restore(); + const acct = kr?.accountFor(op.from); + if (!acct) throw RpcError.locked; + await bumpAutoLock(); + return { hash: await signAndSend(acct.account, op.to as `0x${string}`, parseEther(op.valueWei)) }; + } + case "listPending": + return [...pending.entries()].map(([id, p]) => ({ id, method: p.request.method, origin: p.origin, params: p.request.params })); + case "resolvePending": + await resolvePending(op.id, op.approved); + return { ok: true }; + } +} + +async function signAndSend(account: Keyring["accounts"][number]["account"], to: `0x${string}`, value: bigint): Promise { + const { createWalletClient } = await import("viem"); + const wallet = createWalletClient({ account, chain: lightchainMainnet, transport: http() }); + return wallet.sendTransaction({ to, value }); +} + +// ---- dapp RPC + approval queue --------------------------------------------- + +async function handleDappRpc(request: JsonRpcRequest, origin: string): Promise { + if (!isAllowedMethod(request.method)) throw RpcError.unsupported; + + if (LOCAL_READ.has(request.method)) { + if (request.method === "eth_chainId") return `0x${lightchainMainnet.id.toString(16)}`; + if (request.method === "net_version") return String(lightchainMainnet.id); + if (request.method === "eth_accounts") return await connectedAccounts(origin); + } + + if (APPROVAL_REQUIRED.has(request.method)) return await enqueueApproval(request, origin); + + // Everything else on the allowlist: read-only passthrough to our pinned RPC. + return publicClient().request({ method: request.method as never, params: request.params as never }); +} + +async function connectedAccounts(origin: string): Promise { + const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; + const kr = await restore(); + const granted = perms[origin] ?? []; + // Only surface accounts that still exist in the keyring. + return kr ? granted.filter((a) => kr.accountFor(a)) : []; +} + +function enqueueApproval(request: JsonRpcRequest, origin: string): Promise { + return new Promise((resolve, reject) => { + const id = `req-${++pendingSeq}`; + pending.set(id, { request, origin, resolve, reject }); + void browser.windows.create({ url: browser.runtime.getURL("/popup.html#/approve"), type: "popup", width: 380, height: 600 }); + }); +} + +async function resolvePending(id: string, approved: boolean): Promise { + const p = pending.get(id); + if (!p) return; + pending.delete(id); + if (!approved) return p.reject(RpcError.userRejected); + try { + p.resolve(await fulfilApproved(p.request, p.origin)); + } catch (e) { + p.reject({ code: -32603, message: (e as Error).message?.slice(0, 120) ?? "request failed" }); + } +} + +async function fulfilApproved(request: JsonRpcRequest, origin: string): Promise { + const kr = await restore(); + if (!kr) throw RpcError.locked; + if (request.method === "eth_requestAccounts") { + const addr = kr.accounts[0]!.address; + const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; + perms[origin] = [addr]; + await browser.storage.local.set({ [PERMS_KEY]: perms }); + return [addr]; + } + if (request.method === "personal_sign") { + const [data, address] = request.params as [`0x${string}`, string]; + const acct = kr.accountFor(address); + if (!acct) throw RpcError.unauthorized; + return acct.account.signMessage({ message: { raw: data } }); + } + if (request.method === "eth_sendTransaction") { + // approve==sign: sign exactly the canonical tx the popup displayed (review H1). + const [tx] = request.params as [{ from: string; to: `0x${string}`; value?: `0x${string}`; data?: `0x${string}` }]; + const acct = kr.accountFor(tx.from); + if (!acct) throw RpcError.unauthorized; + return signAndSend(acct.account, tx.to, tx.value ? BigInt(tx.value) : 0n); + } + throw RpcError.unsupported; +} + +// ---- message router -------------------------------------------------------- + +export default defineBackground(() => { + browser.runtime.onMessage.addListener((message: unknown, sender: { origin?: string; url?: string }) => { + const msg = message as BgMessage; + if (msg.kind === "wallet") { + return handleWalletOp(msg.op).then( + (result) => ({ result }), + (error: { code?: number; message?: string }) => ({ error: { code: error.code ?? -32603, message: error.message ?? "error" } }), + ); + } + if (msg.kind === "dapp-rpc") { + const origin = sender.origin ?? sender.url ?? "unknown"; + return handleDappRpc(msg.request, origin).then( + (result) => ({ id: msg.request.id, result }), + (error: { code?: number; message?: string }) => ({ id: msg.request.id, error: { code: error.code ?? -32603, message: error.message ?? "error" } }), + ); + } + return undefined; + }); +}); diff --git a/wallet/entrypoints/content.ts b/wallet/entrypoints/content.ts new file mode 100644 index 0000000..6cf8dae --- /dev/null +++ b/wallet/entrypoints/content.ts @@ -0,0 +1,20 @@ +import { PAGE_TO_CONTENT, CONTENT_TO_PAGE, type PageMessage } from "../src/provider/protocol"; + +// Isolated-world relay. Holds NO keys: it only forwards the page's EIP-1193 +// requests to the background (which records the real origin from the sender) and +// posts responses back. The provider itself is injected into the MAIN world. +export default defineContentScript({ + matches: [""], + runAt: "document_start", + async main() { + await injectScript("/inpage.js", { keepInDom: false }); + + window.addEventListener("message", async (event: MessageEvent) => { + if (event.source !== window) return; + const data = event.data as PageMessage | undefined; + if (!data || data.target !== PAGE_TO_CONTENT) return; + const response = await browser.runtime.sendMessage({ kind: "dapp-rpc", request: data.request }); + window.postMessage({ target: CONTENT_TO_PAGE, response }, window.location.origin); + }); + }, +}); diff --git a/wallet/entrypoints/inpage.ts b/wallet/entrypoints/inpage.ts new file mode 100644 index 0000000..38ac671 --- /dev/null +++ b/wallet/entrypoints/inpage.ts @@ -0,0 +1,65 @@ +import { PAGE_TO_CONTENT, CONTENT_TO_PAGE, type ContentMessage } from "../src/provider/protocol"; + +// Injected into the page's MAIN world. Exposes a standard EIP-1193 provider and +// announces it via EIP-6963 so dapps can pick "LightChain Wallet" ALONGSIDE +// MetaMask. We do not overwrite window.ethereum (only set it if nothing else has). +type Handler = (args: unknown) => void; + +function createProvider() { + let nextId = 1; + const waiting = new Map void; reject: (e: unknown) => void }>(); + const listeners = new Map>(); + + window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window) return; + const data = event.data as ContentMessage | undefined; + if (!data || data.target !== CONTENT_TO_PAGE) return; + const pendingReq = waiting.get(data.response.id); + if (!pendingReq) return; + waiting.delete(data.response.id); + if (data.response.error) pendingReq.reject(data.response.error); + else pendingReq.resolve(data.response.result); + }); + + const provider = { + isLightChainWallet: true, + request({ method, params }: { method: string; params?: unknown[] }): Promise { + if (typeof method !== "string") return Promise.reject({ code: -32602, message: "Invalid params" }); + const id = nextId++; + return new Promise((resolve, reject) => { + waiting.set(id, { resolve, reject }); + window.postMessage({ target: PAGE_TO_CONTENT, request: { id, method, params: Array.isArray(params) ? params : [] } }, window.location.origin); + }); + }, + on(event: string, handler: Handler) { + (listeners.get(event) ?? listeners.set(event, new Set()).get(event)!).add(handler); + return provider; + }, + removeListener(event: string, handler: Handler) { + listeners.get(event)?.delete(handler); + return provider; + }, + }; + return provider; +} + +function announce(provider: ReturnType) { + const info = { + uuid: crypto.randomUUID(), + name: "LightChain Wallet", + icon: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTYiIGZpbGw9IiM3MDY0ZTkiLz48L3N2Zz4=", + rdns: "ai.lightchain.wallet", + }; + const emit = () => window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider }) })); + window.addEventListener("eip6963:requestProvider", emit); + emit(); +} + +export default defineUnlistedScript(() => { + const provider = createProvider(); + announce(provider); + // Legacy fallback only - never clobber an existing injected provider. + if (!(window as unknown as { ethereum?: unknown }).ethereum) { + (window as unknown as { ethereum: unknown }).ethereum = provider; + } +}); diff --git a/wallet/entrypoints/popup/App.tsx b/wallet/entrypoints/popup/App.tsx new file mode 100644 index 0000000..abd7761 --- /dev/null +++ b/wallet/entrypoints/popup/App.tsx @@ -0,0 +1,271 @@ +import { useCallback, useEffect, useState } from "react"; +import { createMnemonic, isValidMnemonic } from "../../src/keyring/mnemonic"; +import { wallet, type WalletState, type PendingRequest } from "./wallet-api"; + +const EXPLORER = "https://mainnet.lightscan.app"; +const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`; +const isApproveWindow = () => window.location.hash.includes("approve"); + +export function App() { + const [state, setState] = useState(null); + const refresh = useCallback(async () => setState(await wallet({ type: "getState" })), []); + useEffect(() => { + void refresh(); + }, [refresh]); + + if (isApproveWindow()) return ; + if (!state) return

Loading…

; + if (!state.hasVault) return ; + if (!state.unlocked) return ; + return ; +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
LightChain Wallet
+ {children} +
+ ); +} + +// ---- onboarding ------------------------------------------------------------ + +function Onboarding({ onDone }: { onDone: () => void }) { + const [mode, setMode] = useState<"choose" | "create" | "import">("choose"); + if (mode === "create") return ; + if (mode === "import") return ; + return ( +
+

Self-custodial. Your keys.

+

Keys are generated and encrypted on this device and never leave it.

+
+ + +
+
+ ); +} + +function CreateFlow({ onDone }: { onDone: () => void }) { + const [mnemonic] = useState(createMnemonic); + const [saved, setSaved] = useState(false); + const [pw, setPw] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const create = async () => { + setBusy(true); + setErr(null); + try { + await wallet({ type: "createVault", mnemonic, password: pw }); + onDone(); + } catch (e) { + setErr((e as Error).message); + setBusy(false); + } + }; + return ( +
+

Your recovery phrase

+
{mnemonic}
+

Write these 24 words down and keep them offline. Anyone with them controls your funds. We can never recover them for you.

+ + setPw(e.target.value)} style={{ marginTop: 10 }} /> + {err &&

{err}

} + +
+ ); +} + +function ImportFlow({ onDone }: { onDone: () => void }) { + const [mnemonic, setMnemonic] = useState(""); + const [pw, setPw] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const valid = isValidMnemonic(mnemonic); + const importIt = async () => { + setBusy(true); + setErr(null); + try { + await wallet({ type: "importVault", mnemonic: mnemonic.trim(), password: pw }); + onDone(); + } catch (e) { + setErr((e as Error).message); + setBusy(false); + } + }; + return ( +
+

Import a recovery phrase

+