Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
5 changes: 5 additions & 0 deletions wallet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.output
.wxt
*.zip
stats.html
59 changes: 59 additions & 0 deletions wallet/README.md
Original file line number Diff line number Diff line change
@@ -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.
211 changes: 211 additions & 0 deletions wallet/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -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<string, { request: JsonRpcRequest; origin: string; resolve: (r: unknown) => void; reject: (e: { code: number; message: string }) => void }>();
let pendingSeq = 0;

const publicClient = () => createPublicClient({ chain: lightchainMainnet, transport: http() });

// ---- session lifecycle -----------------------------------------------------

async function restore(): Promise<Keyring | null> {
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<void> {
await browser.alarms.create("autolock", { delayInMinutes: AUTO_LOCK_MIN });
}

async function lock(): Promise<void> {
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<unknown> {
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<string> {
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<unknown> {
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<string[]> {
const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record<string, string[]> };
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<unknown> {
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<void> {
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<unknown> {
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<string, string[]> };
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;
});
});
20 changes: 20 additions & 0 deletions wallet/entrypoints/content.ts
Original file line number Diff line number Diff line change
@@ -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: ["<all_urls>"],
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);
});
},
});
65 changes: 65 additions & 0 deletions wallet/entrypoints/inpage.ts
Original file line number Diff line number Diff line change
@@ -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<number | string, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
const listeners = new Map<string, Set<Handler>>();

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<unknown> {
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<typeof createProvider>) {
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;
}
});
Loading
Loading