From e6b71bdfc5ecc675d74885f6346bf98bad6a37a4 Mon Sep 17 00:00:00 2001 From: Martin Marinov Date: Thu, 9 Jul 2026 10:53:46 +0300 Subject: [PATCH] chore: extract the wallet into its own repo The self-custodial browser wallet now lives in its own private repository (marinom2/lightchain-ai-wallet) so it can be audited and funded independently. It was already self-contained (no lightnode-sdk dependency); its full history was preserved via subtree split, and the DAO quorum/voting-power work from #148-#151 was ported there. This removes wallet/ from the monorepo. --- wallet/.gitignore | 5 - wallet/PRIVACY.md | 31 - wallet/PUBLISH.md | 54 - wallet/README.md | 78 - wallet/SECURITY-REVIEW.md | 105 - wallet/STORE.md | 76 - wallet/entrypoints/background.ts | 1110 --- wallet/entrypoints/content.ts | 59 - wallet/entrypoints/inpage.ts | 82 - wallet/entrypoints/popup/App.tsx | 38 - wallet/entrypoints/popup/approve.tsx | 245 - wallet/entrypoints/popup/home.tsx | 518 -- wallet/entrypoints/popup/index.html | 12 - wallet/entrypoints/popup/main.tsx | 10 - wallet/entrypoints/popup/onboarding.tsx | 238 - wallet/entrypoints/popup/shared.tsx | 129 - wallet/entrypoints/popup/sheets-assets.tsx | 493 -- wallet/entrypoints/popup/sheets-chat.tsx | 144 - wallet/entrypoints/popup/sheets-dao.tsx | 165 - wallet/entrypoints/popup/sheets-settings.tsx | 145 - wallet/entrypoints/popup/sheets-swap.tsx | 237 - wallet/entrypoints/popup/sheets-worker.tsx | 123 - wallet/entrypoints/popup/styles.css | 290 - wallet/entrypoints/popup/wallet-api.ts | 25 - wallet/package-lock.json | 7970 ------------------ wallet/package.json | 35 - wallet/public/chains/arbitrum.png | Bin 6824 -> 0 bytes wallet/public/chains/base.png | Bin 677 -> 0 bytes wallet/public/chains/eth.png | Bin 4002 -> 0 bytes wallet/public/chains/lightchain.png | Bin 7366 -> 0 bytes wallet/public/chains/optimism.png | Bin 2927 -> 0 bytes wallet/public/chains/polygon.png | Bin 3013 -> 0 bytes wallet/public/icon/128.png | Bin 13135 -> 0 bytes wallet/public/icon/16.png | Bin 817 -> 0 bytes wallet/public/icon/32.png | Bin 1793 -> 0 bytes wallet/public/icon/48.png | Bin 3233 -> 0 bytes wallet/public/lightnode.png | Bin 8536 -> 0 bytes wallet/public/tokens/usdc.png | Bin 5105 -> 0 bytes wallet/src/keyring/base64.ts | 21 - wallet/src/keyring/hdwallet.ts | 13 - wallet/src/keyring/keyring.test.ts | 71 - wallet/src/keyring/keyring.ts | 56 - wallet/src/keyring/mnemonic.ts | 24 - wallet/src/keyring/vault.ts | 81 - wallet/src/provider/dapp-tx.test.ts | 30 - wallet/src/provider/dapp-tx.ts | 35 - wallet/src/provider/decode-call.test.ts | 67 - wallet/src/provider/decode-call.ts | 111 - wallet/src/provider/lightchain-calls.test.ts | 101 - wallet/src/provider/lightchain-calls.ts | 144 - wallet/src/provider/protocol.ts | 112 - wallet/src/provider/rpc-methods.ts | 37 - wallet/src/provider/typed-data.test.ts | 140 - wallet/src/provider/typed-data.ts | 235 - wallet/src/rpc/bridge.ts | 84 - wallet/src/rpc/chains.ts | 80 - wallet/src/rpc/dao.ts | 52 - wallet/src/rpc/ens.ts | 22 - wallet/src/rpc/gas.test.ts | 28 - wallet/src/rpc/gas.ts | 49 - wallet/src/rpc/governance.test.ts | 72 - wallet/src/rpc/governance.ts | 271 - wallet/src/rpc/history.test.ts | 98 - wallet/src/rpc/history.ts | 199 - wallet/src/rpc/humanize.test.ts | 34 - wallet/src/rpc/humanize.ts | 21 - wallet/src/rpc/inference.test.ts | 94 - wallet/src/rpc/inference.ts | 362 - wallet/src/rpc/nfts.test.ts | 77 - wallet/src/rpc/nfts.ts | 163 - wallet/src/rpc/prices.ts | 52 - wallet/src/rpc/risk.test.ts | 48 - wallet/src/rpc/risk.ts | 48 - wallet/src/rpc/simulate.test.ts | 45 - wallet/src/rpc/simulate.ts | 51 - wallet/src/rpc/spam.test.ts | 48 - wallet/src/rpc/spam.ts | 115 - wallet/src/rpc/swap.test.ts | 49 - wallet/src/rpc/swap.ts | 187 - wallet/src/rpc/tokens.test.ts | 33 - wallet/src/rpc/tokens.ts | 110 - wallet/src/rpc/worker-stats.test.ts | 34 - wallet/src/rpc/worker.ts | 189 - wallet/store-assets/01-home.png | Bin 225305 -> 0 bytes wallet/store-assets/02-networks.png | Bin 218650 -> 0 bytes wallet/store-assets/03-send.png | Bin 209056 -> 0 bytes wallet/store-assets/04-approve.png | Bin 235989 -> 0 bytes wallet/store-assets/05-ecosystem.png | Bin 220250 -> 0 bytes wallet/store-assets/promo-tile-440x280.png | Bin 51883 -> 0 bytes wallet/tsconfig.json | 7 - wallet/vitest.config.ts | 7 - wallet/wxt.config.ts | 34 - 92 files changed, 16458 deletions(-) delete mode 100644 wallet/.gitignore delete mode 100644 wallet/PRIVACY.md delete mode 100644 wallet/PUBLISH.md delete mode 100644 wallet/README.md delete mode 100644 wallet/SECURITY-REVIEW.md delete mode 100644 wallet/STORE.md delete mode 100644 wallet/entrypoints/background.ts delete mode 100644 wallet/entrypoints/content.ts delete mode 100644 wallet/entrypoints/inpage.ts delete mode 100644 wallet/entrypoints/popup/App.tsx delete mode 100644 wallet/entrypoints/popup/approve.tsx delete mode 100644 wallet/entrypoints/popup/home.tsx delete mode 100644 wallet/entrypoints/popup/index.html delete mode 100644 wallet/entrypoints/popup/main.tsx delete mode 100644 wallet/entrypoints/popup/onboarding.tsx delete mode 100644 wallet/entrypoints/popup/shared.tsx delete mode 100644 wallet/entrypoints/popup/sheets-assets.tsx delete mode 100644 wallet/entrypoints/popup/sheets-chat.tsx delete mode 100644 wallet/entrypoints/popup/sheets-dao.tsx delete mode 100644 wallet/entrypoints/popup/sheets-settings.tsx delete mode 100644 wallet/entrypoints/popup/sheets-swap.tsx delete mode 100644 wallet/entrypoints/popup/sheets-worker.tsx delete mode 100644 wallet/entrypoints/popup/styles.css delete mode 100644 wallet/entrypoints/popup/wallet-api.ts delete mode 100644 wallet/package-lock.json delete mode 100644 wallet/package.json delete mode 100644 wallet/public/chains/arbitrum.png delete mode 100644 wallet/public/chains/base.png delete mode 100644 wallet/public/chains/eth.png delete mode 100644 wallet/public/chains/lightchain.png delete mode 100644 wallet/public/chains/optimism.png delete mode 100644 wallet/public/chains/polygon.png delete mode 100644 wallet/public/icon/128.png delete mode 100644 wallet/public/icon/16.png delete mode 100644 wallet/public/icon/32.png delete mode 100644 wallet/public/icon/48.png delete mode 100644 wallet/public/lightnode.png delete mode 100644 wallet/public/tokens/usdc.png delete mode 100644 wallet/src/keyring/base64.ts delete mode 100644 wallet/src/keyring/hdwallet.ts delete mode 100644 wallet/src/keyring/keyring.test.ts delete mode 100644 wallet/src/keyring/keyring.ts delete mode 100644 wallet/src/keyring/mnemonic.ts delete mode 100644 wallet/src/keyring/vault.ts delete mode 100644 wallet/src/provider/dapp-tx.test.ts delete mode 100644 wallet/src/provider/dapp-tx.ts delete mode 100644 wallet/src/provider/decode-call.test.ts delete mode 100644 wallet/src/provider/decode-call.ts delete mode 100644 wallet/src/provider/lightchain-calls.test.ts delete mode 100644 wallet/src/provider/lightchain-calls.ts delete mode 100644 wallet/src/provider/protocol.ts delete mode 100644 wallet/src/provider/rpc-methods.ts delete mode 100644 wallet/src/provider/typed-data.test.ts delete mode 100644 wallet/src/provider/typed-data.ts delete mode 100644 wallet/src/rpc/bridge.ts delete mode 100644 wallet/src/rpc/chains.ts delete mode 100644 wallet/src/rpc/dao.ts delete mode 100644 wallet/src/rpc/ens.ts delete mode 100644 wallet/src/rpc/gas.test.ts delete mode 100644 wallet/src/rpc/gas.ts delete mode 100644 wallet/src/rpc/governance.test.ts delete mode 100644 wallet/src/rpc/governance.ts delete mode 100644 wallet/src/rpc/history.test.ts delete mode 100644 wallet/src/rpc/history.ts delete mode 100644 wallet/src/rpc/humanize.test.ts delete mode 100644 wallet/src/rpc/humanize.ts delete mode 100644 wallet/src/rpc/inference.test.ts delete mode 100644 wallet/src/rpc/inference.ts delete mode 100644 wallet/src/rpc/nfts.test.ts delete mode 100644 wallet/src/rpc/nfts.ts delete mode 100644 wallet/src/rpc/prices.ts delete mode 100644 wallet/src/rpc/risk.test.ts delete mode 100644 wallet/src/rpc/risk.ts delete mode 100644 wallet/src/rpc/simulate.test.ts delete mode 100644 wallet/src/rpc/simulate.ts delete mode 100644 wallet/src/rpc/spam.test.ts delete mode 100644 wallet/src/rpc/spam.ts delete mode 100644 wallet/src/rpc/swap.test.ts delete mode 100644 wallet/src/rpc/swap.ts delete mode 100644 wallet/src/rpc/tokens.test.ts delete mode 100644 wallet/src/rpc/tokens.ts delete mode 100644 wallet/src/rpc/worker-stats.test.ts delete mode 100644 wallet/src/rpc/worker.ts delete mode 100644 wallet/store-assets/01-home.png delete mode 100644 wallet/store-assets/02-networks.png delete mode 100644 wallet/store-assets/03-send.png delete mode 100644 wallet/store-assets/04-approve.png delete mode 100644 wallet/store-assets/05-ecosystem.png delete mode 100644 wallet/store-assets/promo-tile-440x280.png delete mode 100644 wallet/tsconfig.json delete mode 100644 wallet/vitest.config.ts delete mode 100644 wallet/wxt.config.ts diff --git a/wallet/.gitignore b/wallet/.gitignore deleted file mode 100644 index f994fef..0000000 --- a/wallet/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -.output -.wxt -*.zip -stats.html diff --git a/wallet/PRIVACY.md b/wallet/PRIVACY.md deleted file mode 100644 index e45ae69..0000000 --- a/wallet/PRIVACY.md +++ /dev/null @@ -1,31 +0,0 @@ -# LightNode Wallet - Privacy Policy - -LightNode Wallet is a **self-custodial** browser extension. It is built so that we never see your money or your data. - -## What we collect - -**Nothing.** The extension has no backend that we operate, no analytics, and no telemetry. We do not collect, store, or transmit any personal information, wallet addresses, balances, or activity to any server we control. - -## Where your data lives - -- Your recovery phrase is encrypted on your device (AES-256-GCM with a scrypt-derived key) and stored only in your browser's local extension storage. It never leaves your device. Only your password can decrypt it; we cannot recover it. -- Settings (selected network, added tokens, local activity list) are stored locally in your browser. - -## Network requests - -To show balances and broadcast transactions, the extension talks **directly** to public blockchain RPC endpoints (e.g. LightChain, Ethereum, Base, Arbitrum, Optimism, Polygon) and, on the LightChain network, the public worker-registry contracts. To show USD values it also requests coin and token prices from CoinGecko's public API (which sees the token contract addresses, not your wallet address). These requests go from your browser to those public endpoints; they are not routed through any server we run. Public RPC and price providers may log requests under their own policies. - -## Permissions - -- `storage` - keep the encrypted vault and your settings on your device. -- `alarms` - auto-lock after inactivity. -- `notifications` - optional request alerts. -- Page access (content script) - inject the standard wallet provider so websites can request to connect; it holds no keys. - -## Your control - -You can reveal your recovery phrase (password-gated) or remove the wallet from the device at any time in Settings. Removing it deletes the encrypted vault from your browser. - -## Contact - -This is independent, community-built software (not an official LightChain product). Questions: open an issue at the project repository. diff --git a/wallet/PUBLISH.md b/wallet/PUBLISH.md deleted file mode 100644 index c297b00..0000000 --- a/wallet/PUBLISH.md +++ /dev/null @@ -1,54 +0,0 @@ -# One-click install: publish LightNode Wallet to the Chrome Web Store - -A browser extension can only "click → auto-install in the browser" through a web store. Chrome blocks installing extensions from a website download or a `.crx` file, so the **Chrome Web Store** is the only path to the one-click **Add to Chrome** button (and silent auto-updates). - -Everything is automated except the parts that legally require your Google account. Here is exactly what you do, once. - -## One-time setup (~15 minutes) - -### 1. Create a developer account -- Go to https://chrome.google.com/webstore/devconsole, sign in, pay the one-time **$5** fee, accept the agreement, and (recommended for a wallet) complete publisher verification. - -### 2. First upload (creates the item + gives you the Extension ID) -- Build the zip: `cd wallet && npm install && npm run build && npm run zip` (or download it from the latest GitHub Release). -- In the dev console click **New item**, upload `wallet/.output/lightnode-wallet-*-chrome.zip`, fill the listing using `wallet/STORE.md` (copy, category, privacy, the icon at `wallet/public/icon/128.png`, and screenshots), and **Submit for review**. -- Copy the **Item ID** (the long id in the item's URL). That is your `CWS_EXTENSION_ID`. - -After Google approves (a few days for a wallet), the listing is live and the **Add to Chrome** button works. Set `STORE_URL` on the `/wallet` page (one line in `app/wallet/page.tsx`) to the listing URL and the site shows "Add to Chrome" instead of the download. - -### 3. Get API credentials so CI auto-publishes every future version -You only do this once; afterwards every `wallet-v*` tag auto-uploads + publishes. - -1. https://console.cloud.google.com -> create/pick a project. -2. **APIs & Services -> Library** -> enable **Chrome Web Store API**. -3. **APIs & Services -> OAuth consent screen** -> External -> add yourself as a test user. -4. **Credentials -> Create credentials -> OAuth client ID -> Desktop app**. Copy the **Client ID** (`CWS_CLIENT_ID`) and **Client secret** (`CWS_CLIENT_SECRET`). -5. Get a **refresh token** (`CWS_REFRESH_TOKEN`): open this URL (replace CLIENT_ID), approve, copy the `code`: - ``` - https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/chromewebstore&access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob&client_id=CLIENT_ID - ``` - then exchange it: - ``` - curl -s "https://oauth2.googleapis.com/token" \ - -d client_id=CLIENT_ID -d client_secret=CLIENT_SECRET \ - -d code=THE_CODE -d grant_type=authorization_code \ - -d redirect_uri=urn:ietf:wg:oauth:2.0:oob - ``` - Copy the `refresh_token` from the response. - -### 4. Add four GitHub repo secrets -**Settings -> Secrets and variables -> Actions -> New repository secret:** -- `CWS_EXTENSION_ID` (the Item ID from step 2) -- `CWS_CLIENT_ID` -- `CWS_CLIENT_SECRET` -- `CWS_REFRESH_TOKEN` - -## From then on: one command to ship an update - -``` -git tag wallet-v0.2.0 && git push origin wallet-v0.2.0 -``` - -The **Wallet release** workflow rebuilds the download zip, and the **Publish wallet to Chrome Web Store** workflow uploads + publishes the new version automatically. Users get the update silently; new users click **Add to Chrome** once. No "Load unpacked", no developer mode. - -> Note: Google reviews each version. Listing a wallet for real mainnet funds should follow the external security audit (see the wallet README); reference the audit in the listing once done. diff --git a/wallet/README.md b/wallet/README.md deleted file mode 100644 index d40d2d0..0000000 --- a/wallet/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# LightNode Wallet - -A **self-custodial** browser wallet for LightChain (EVM L1, chain 9200; testnet 8200), with pinned support for Ethereum, Base, Arbitrum, Optimism, and Polygon. 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, dapp connect + sign) and the LightChain extras (encrypted AI chat, DAO voting, worker hub, swap, bridge) are functional. 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). - -## Download - -- **Prebuilt zip**: - the download button serves - `lightnode-wallet-chrome.zip` from the newest `wallet-v*` GitHub release. - Unzip it, then `chrome://extensions` → Developer mode → **Load unpacked**. -- **Chrome Web Store**: listing in preparation (one-click install and silent - auto-updates once live). - -## Run it - -```bash -cd wallet -npm install -npm run build # -> .output/chrome-mv3/ -npm test # unit tests (125 across 16 files: keyring, vault, - # provider/typed-data, rpc inference/swap/governance/ - # history/spam and more) -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**, view balances, copy address, open the explorer - across LightChain (mainnet + testnet) and the pinned Ethereum/Base/Arbitrum/Optimism/Polygon RPCs. -- **Encrypted AI chat** (`entrypoints/popup/sheets-chat.tsx` + `src/rpc/inference.ts`): pay-per-call inference against the LightChain gateway with ECDH + AES-256-GCM session encryption and **one consent per session**, not per message. -- **DAO** (`entrypoints/popup/sheets-dao.tsx` + `src/rpc/governance.ts`): reads proposals and live tallies from **both** LCAI governors (Ethereum and LightChain) and casts votes in-wallet. -- **Worker hub**: your worker's status and lifetime stats, plus the network-wide picture and fee split. -- **Swap** (Uniswap V3 on Ethereum: QuoterV2 quotes, SwapRouter02 execution) and **bridge** (LightChain ↔ Ethereum). -- **Scam protection**: token/NFT spam heuristics quarantine flagged assets (never auto-trusted), dangerous calldata is decoded and warned about (`approve` / `setApprovalForAll` / `permit` / unlimited allowances), and an `eth_simulateV1` balance-change preview shows what you send and receive before you sign. -- **NFTs**: a gallery, plus set any owned NFT as your account avatar. -- **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`, `eth_signTypedData_v4`, and `eth_sendTransaction`. Approving a **SIWE** sign-in auto-follows the dapp's stated chain - only for supported chains, and only when the SIWE domain matches the requesting origin. -- **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 - encrypted pay-per-call **AI inference**, in-wallet **DAO voting**, the **worker hub**, and the **Ethereum bridge** - shipped in-wallet and self-contained: the wallet has no dependency on `lightnode-sdk` (see `entrypoints/popup/sheets-*.tsx` and `src/rpc/`). - -## 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, home, send, dapp approval, and the - sheets: assets, chat, DAO, swap, worker, settings) -src/ - keyring/ mnemonic (BIP-39), hdwallet (BIP-44 m/44'/60'/0'/0/x), vault, keyring - rpc/ viem chain defs (pinned RPCs: LightChain + the EVM majors), plus - inference, governance, swap, bridge, tokens/NFTs, spam + risk - heuristics, simulation, history, gas, prices - provider/ message protocol + RPC method policy + typed-data and calldata decoding -``` - -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**: a fixed allowlist of chains (LightChain mainnet/testnet + Ethereum, Base, Arbitrum, Optimism, Polygon), each with a code-pinned RPC; 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 - plus suites over typed-data, calldata decoding, spam/risk heuristics, simulation, swap, governance, and history (125 tests across 16 files). - -### Known follow-ups before a production mainnet release -From the security review, now shipped: dangerous-calldata decode + warnings (`approve`/`setApprovalForAll`/`permit`/unlimited allowance), the `eth_simulateV1` balance-change preview, and `eth_signTypedData_v4` with full EIP-712 domain display. Still open: Ledger/hardware support, relock on OS idle, and 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/SECURITY-REVIEW.md b/wallet/SECURITY-REVIEW.md deleted file mode 100644 index 6a77a1b..0000000 --- a/wallet/SECURITY-REVIEW.md +++ /dev/null @@ -1,105 +0,0 @@ -# LightNode Wallet - Internal Security Review - -**What this is, plainly:** an internal, AI-assisted adversarial security review of the -LightNode Wallet, conducted in five passes whose scopes mirror the *methodologies* of -well-known audit firms (key-lifecycle, browser-extension architecture, signing safety, -anti-phishing UX, and protocol/trust-model review). Every high-impact finding was then -independently re-checked by two skeptics against the real code before any fix landed. - -**What this is NOT:** this was **not** performed by, commissioned from, or endorsed by -Trail of Bits, Cure53, OpenZeppelin, ConsenSys Diligence, NCC Group, Kudelski, or any -other firm. The firm names below describe the *lens* applied, nothing more. The wallet -has **not** had an external, independent human audit. Until it does, treat it as a -testnet-grade preview and do not hold meaningful mainnet funds in it. - -Date: 2026-06-12. Reviewed at commit on `main` after PR #127. - -## Method - -| Lens (modeled on) | Scope | -| --- | --- | -| Cryptography & key lifecycle (Trail of Bits style) | vault, keyring, scrypt/AES-GCM, randomness, secret-exposure window, session crypto | -| Browser-extension architecture (Cure53 style) | MV3 boundaries, content/inpage/SW trust, CSP, EIP-6963, message-sender validation | -| On-chain signing safety (OpenZeppelin style) | calldata integrity, permit/approval decode, chain-id binding, swap/bridge, recognizer | -| Anti-phishing UX (ConsenSys Diligence style) | per-origin permissions, approval-queue races, scam flagging, address poisoning, NFT SSRF | -| Protocol & trust model (NCC Group / Kudelski style) | encrypted-inference crypto, gateway/relay trust, untrusted-data parsing | - -26 findings were raised; the 2 rated high/critical were both confirmed by adversarial -verification (0 refuted). All confirmed findings are fixed below, with regression tests. - -## Findings and remediation - -### HIGH - Encrypted inference was not truly end-to-end (`unauthenticated-worker-key-mitm`) -The gateway is untrusted, but the wallet wrapped each session key to whatever worker -encryption key the gateway returned, with no on-chain binding. A malicious or compromised -proxy could substitute its own key, decrypt the "E2E" prompt, and forge the streamed -answer (it held the session key it chose). - -**Fixed** (`src/rpc/inference.ts`): the wallet now reads the worker's encryption key from -the on-chain `WorkerRegistry.getWorkerEncryptionKey` and requires the gateway's key to -match it byte-for-byte before wrapping the session key (fail closed if absent or -mismatched); it asserts the prepared worker equals the selected worker; and it refuses to -blind-sign the gateway's SIWE challenge unless that challenge names this gateway host and -this account. The "end-to-end encrypted" claim is now backed by an on-chain trust anchor. - -### MEDIUM -- **Privileged wallet channel trusted its sender implicitly** (`background.ts`). The - `kind:"wallet"` message branch (send/swap/bridge/reveal, no per-call approval) did not - verify the sender. Not remotely exploitable today (no `externally_connectable`, CSP-locked - pages), but a latent confused-deputy risk. **Fixed:** the branch now requires the - extension's own id and origin and the absence of a tab; anything else is rejected. -- **Typed-data domain chain was not shown** (`approve.tsx`). A Permit aimed at another chain - was not obvious. **Fixed:** the signing popup always renders the domain chain as a network - name and warns when it differs from the wallet's active chain. -- **"No tokens move" banner ignored msg.value** on payable `createSession` (`lightchain-calls.ts`). - **Fixed:** the banner is value-aware and states the exact amount when value > 0; the - submitJob/registerWorker labels no longer assert an attacker-chosen value "is the fee/stake". -- **Bridge fee re-read could be inflated by a hostile RPC** (`bridge.ts`). **Fixed:** the - execution-time fee must stay within 50% of the quoted fee or the transfer aborts. -- **Scam-token heuristic bypassed by a homoglyph** (`spam.ts`). **Fixed:** symbols are - NFKC-normalized and folded to a Latin skeleton (Cyrillic/Greek/fullwidth) before the - blue-chip impersonation check. -- **NFT images leaked IP / online status** (`sheets-assets.tsx`, `wxt.config.ts`). **Fixed:** - an `img-src 'self' data: https:` CSP, and spam-flagged NFTs no longer auto-load their - remote image (a click opts in). The residual IP leak on opted-in remote images is inherent - to a no-server wallet and is documented. -- **Address-poisoning check skipped dapp transactions** (`approve.tsx`). **Fixed:** the same - recipient-risk assessment used for wallet sends now runs on dapp `eth_sendTransaction` - recipients. -- **SIWE auth signed whatever the gateway returned** (`inference.ts`). **Fixed** as part of - the HIGH remediation (domain/account validation before signing). - -### LOW -- Session keyring not zeroed before re-unlock/re-import (`background.ts`): added a `setLive` - helper that wipes the previous seed first. -- Stack-overflow-prone base64 on large prompts (`inference.ts`): switched to the chunked, - stack-safe encoder. -- `use_dynamic_url` missing despite the comment (`wxt.config.ts`): flag added. -- Permit2 "unlimited" matched only the exact max (`typed-data.ts`): now flags at a high floor. -- SIWE detection was bypassable by whitespace/reorder (`typed-data.ts`): made robust. -- Non-https dapp origins could connect (`background.ts`): now rejected (https + localhost only), - which also removes the http/https grant-key ambiguity. -- Token USD price defaulted to a fake 0 (`background.ts`): unpriced is now absent, not zero. -- `setActiveAccount` index unbounded (`background.ts`): now validated. -- Auto-lock not re-armed on a secret reveal (`background.ts`): reveals now bump the timer. - -### INFO (documented, no code change) -- scrypt N = 2^16 is deliberate (raising to 2^17 risks OOM on low-RAM/MV3 service workers); - the per-blob KDF versioning supports a future bump with lazy re-encrypt. Rationale is now - documented in `vault.ts`. -- Revealed private-key/mnemonic strings are unwipeable JS strings (inherent); the popup - clears them from state promptly. - -## Controls verified correct (not exhaustive) -- Vault: AES-256-GCM with a fresh random salt + nonce per encryption; scryptAsync; - auth-tag failure is the password check; params stored per blob. -- Keys never leave the device and never touch the network; plaintext keys live only in the - background service worker's volatile memory. -- Dapp signing is gated per-origin through a single approval window; calldata is preserved - through to signing; account switches never over-grant; the inpage provider never clobbers - an existing `window.ethereum`. - -## Status -All confirmed findings from this review are remediated with regression tests -(155 wallet tests pass). This internal review **does not replace** an external, -independent audit, which remains the gate before holding meaningful mainnet funds. diff --git a/wallet/STORE.md b/wallet/STORE.md deleted file mode 100644 index d06a368..0000000 --- a/wallet/STORE.md +++ /dev/null @@ -1,76 +0,0 @@ -# Publishing LightNode Wallet to the Chrome Web Store - -This is everything needed to get the wallet listed so users can install it with one click ("Add to Chrome"). Only you can do the actual submission (it needs your Chrome Web Store developer account); this file is the ready-to-paste package. - -## 1. Build the upload zip - -``` -cd wallet -npm install -npm run build -npm run zip # produces wallet/.output/lightnode-wallet--chrome.zip -``` - -Upload that zip in the dashboard. (The same zip is attached to each GitHub Release by `.github/workflows/wallet-release.yml`.) - -## 2. Developer account - -- Go to https://chrome.google.com/webstore/devconsole, sign in, pay the one-time **$5** registration fee, and verify your identity. -- For a wallet, also complete the **publisher verification** (a verified publisher builds trust and reduces impersonation risk). - -## 3. Store listing (copy-paste) - -- **Name:** LightNode Wallet -- **Summary (≤132 chars):** Self-custodial wallet for LightChain and EVM chains. Your keys stay on your device. Worker, AI, and DAO built in. -- **Category:** Productivity (or Developer Tools) -- **Language:** English - -**Description:** - -``` -LightNode Wallet is a self-custodial browser wallet for the LightChain ecosystem and every EVM chain. - -Your keys are generated and encrypted on your device with AES-256-GCM and a scrypt-derived key. They never leave it - no server, no custody, no smart contract. Only your password can unlock the vault. - -What's inside: -- Multi-chain: LightChain, Ethereum, Base, Arbitrum, Optimism, and Polygon, with code-pinned RPCs. -- Send and receive the native coin and ERC-20 tokens; add any token by address; QR receive. -- Multiple accounts from one recovery phrase, with a one-tap account switcher. -- Connect to dapps (EIP-1193 + EIP-6963) with human-readable approvals: dangerous-calldata warnings (unlimited approvals, setApprovalForAll) and EIP-712 typed-data display. -- LightChain-native: see your worker's stake, headroom, and claimable rewards right in the wallet. -- Auto-locks on inactivity and after a browser restart. Reveal your recovery phrase any time (password-gated). - -Independent and community-built. Not an official LightChain product. -``` - -## 4. Privacy (required for wallets) - -In **Privacy practices**, declare honestly: - -- **Single purpose:** "A self-custodial crypto wallet to hold assets and connect to web3 sites." -- **Permission justifications:** - - `storage` - store the password-encrypted vault and settings locally. - - `alarms` - auto-lock the wallet after inactivity. - - `notifications` - (optional) alert on request activity. - - host access via the content script - inject the EIP-1193 provider so sites can request a connection. -- **Data usage:** Check **does NOT collect or transmit** user data. The wallet is non-custodial and sends nothing to any server we run; on-chain reads/writes go directly to public RPCs. No analytics, no tracking. -- **Privacy policy URL:** host `PRIVACY.md` (below) at a public URL, e.g. `https:///wallet/privacy`, and paste that link. - -## 5. Graphics to upload - -Everything is pre-made in `wallet/store-assets/` - upload as-is: - -- **Store icon:** 128×128 PNG - use `wallet/public/icon/128.png`. -- **Screenshots (1280×800, upload in this order):** - 1. `store-assets/01-home.png` - home on LightChain (balance hero, actions, tokens, worker). - 2. `store-assets/02-networks.png` - the network switcher open (multi-chain with official logos). - 3. `store-assets/03-send.png` - the Send sheet with fee preview and recipient check. - 4. `store-assets/04-approve.png` - a dapp approval with the decoded-calldata danger warning. - 5. `store-assets/05-ecosystem.png` - worker, governance, and the bridge on LightChain. -- **Small promo tile:** `store-assets/promo-tile-440x280.png`. - -(If you ever want fresh captures instead: load `wallet/.output/chrome-mv3` unpacked, open the popup, use the expand icon for a full-tab view, and screenshot at 1280×800.) - -## 6. Submit - -Upload the zip, fill the listing + privacy, attach graphics, and submit for review. Wallet reviews can take a few days. Before listing real mainnet funds, get an external security audit (the keyring/signing path) - it's the standard bar for a wallet, and it's worth referencing the audit in the listing once done. diff --git a/wallet/entrypoints/background.ts b/wallet/entrypoints/background.ts deleted file mode 100644 index 6776f9f..0000000 --- a/wallet/entrypoints/background.ts +++ /dev/null @@ -1,1110 +0,0 @@ -/** - * 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, formatUnits, type TypedDataDefinition } from "viem"; -import { Keyring } from "../src/keyring/keyring"; -import { mnemonicToSeed } from "../src/keyring/mnemonic"; -import { derivePrivateKey } from "../src/keyring/hdwallet"; -import { parseTypedData, decodeSignText, siweChainId, siweOriginMismatch } from "../src/provider/typed-data"; -import { DEFAULT_TOKENS, readTokenBalances, fetchTokenMeta, erc20TransferData, discoverTokens, stripControls, type TokenMeta } from "../src/rpc/tokens"; -import { CG_NATIVE, CG_PLATFORM, LCAI_PRICE_CONTRACT, type Prices } from "../src/rpc/prices"; -import { parseTransfers, netChanges, NATIVE_SENTINEL, type SimLog } from "../src/rpc/simulate"; -import { bridgeTransfer, bridgeFee, bridgeSourceBalance } from "../src/rpc/bridge"; -import { daoStatus } from "../src/rpc/dao"; -import { importNft, stillOwned, nftTransferData, safeImageUrl, type NftItem } from "../src/rpc/nfts"; -import { fetchHistory, mergeHistory, type HistoryItem } from "../src/rpc/history"; -import { quoteSwap, executeSwap, type SwapSide } from "../src/rpc/swap"; -import { listProposals, castVoteData, readDaoStats, GOVERNORS } from "../src/rpc/governance"; -import { readWorkerStatus, readNetworkStats, readWorkerLifetime, readWorkerModels, readProtocolParams, withdrawTarget } from "../src/rpc/worker"; -import { readGasTiers, type GasSpeed } from "../src/rpc/gas"; -import { resolveEnsName } from "../src/rpc/ens"; -import { assessTokenRisk, assessNftRisk } from "../src/rpc/spam"; -import { runInference, listChatModels, type ChatModel } from "../src/rpc/inference"; -import { encryptVault, decryptVault, type EncryptedVault } from "../src/keyring/vault"; -import { chainById, isSupportedChain, DEFAULT_CHAIN_ID } from "../src/rpc/chains"; -import { type BgMessage, type WalletOp, type JsonRpcRequest, type ActivityEntry, EVENT_PORT, RpcError } from "../src/provider/protocol"; -import { APPROVAL_REQUIRED, LOCAL_READ, isAllowedMethod } from "../src/provider/rpc-methods"; -import { canonicalizeDappTx } from "../src/provider/dapp-tx"; - -const VAULT_KEY = "vault"; -const SESSION_KEY = "session-mnemonic"; -const PERMS_KEY = "connected-origins"; -const CHAIN_KEY = "selected-chain"; -const COUNT_KEY = "account-count"; -const ACTIVE_KEY = "active-account"; -const NAMES_KEY = "account-names"; -// Per-owner: account A's prune must never touch account B's imports (review). -const NFTS_KEY = (chainId: number, owner: string) => `nfts-${chainId}-${owner.toLowerCase()}`; - -interface WalletSender { id?: string; origin?: string; url?: string; tab?: unknown } - -/** - * True only for our own privileged UI (popup / approval / extension pages): - * same extension id, the extension's own origin, and no originating tab. The - * privileged wallet channel must reject anything else (a content script always - * carries a `tab`; a web page cannot reach chrome.runtime at all without - * externally_connectable, which we do not declare). - */ -function isTrustedWalletSender(sender: WalletSender): boolean { - if (!sender || sender.tab) return false; - if (sender.id !== browser.runtime.id) return false; - const extOrigin = `chrome-extension://${browser.runtime.id}`; - const from = sender.origin ?? sender.url ?? ""; - return from === extOrigin || from.startsWith(`${extOrigin}/`); -} -const AUTOLOCK_KEY = "autolock-min"; -const AUTO_LOCK_CHOICES = [5, 15, 60]; -async function autoLockMinutes(): Promise { - const { [AUTOLOCK_KEY]: m } = await browser.storage.local.get(AUTOLOCK_KEY); - return typeof m === "number" && AUTO_LOCK_CHOICES.includes(m) ? m : 15; -} - -// How many accounts to derive (persisted, so added accounts survive lock/unlock). -async function accountCount(): Promise { - const { [COUNT_KEY]: n } = await browser.storage.local.get(COUNT_KEY); - return typeof n === "number" && n >= 1 ? n : 1; -} -async function activeIndex(max: number): Promise { - const { [ACTIVE_KEY]: i } = await browser.storage.local.get(ACTIVE_KEY); - return typeof i === "number" && i >= 0 && i < max ? i : 0; -} - -let live: Keyring | null = null; -/** Replace the in-memory keyring, zeroing the previous seed bytes first so a - * re-unlock / re-import never leaves an orphaned live seed in module memory. */ -function setLive(kr: Keyring | null): void { - if (live && live !== kr) live.wipe(); - live = kr; -} -const pending = new Map void; reject: (e: { code: number; message: string }) => void }>(); -let pendingSeq = 0; - -// Long-lived ports from each tab's content script. We push EIP-1193 events -// (chainChanged / accountsChanged) through them when the popup changes state. -interface EventPort { - name: string; - postMessage: (msg: unknown) => void; - onDisconnect: { addListener: (cb: () => void) => void }; - sender?: { url?: string; origin?: string }; -} -// port -> the page origin it belongs to, so per-origin events (e.g. a revoke) -// reach only that site instead of broadcasting a disconnect to every tab. -const eventPorts = new Map(); -function emitEvent(event: string, data: unknown, onlyOrigin?: string): void { - for (const [port, origin] of eventPorts) { - if (onlyOrigin && origin !== onlyOrigin) continue; - try { - port.postMessage({ event, data }); - } catch { - eventPorts.delete(port); // the tab went away mid-send - } - } -} - -/** Emit a provider event ONLY to origins the user actually connected: pushing - * accountsChanged/chainChanged to every open tab deanonymizes the user. */ -async function emitToConnected(event: string, data: unknown): Promise { - const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; - for (const origin of Object.keys(perms)) emitEvent(event, data, origin); -} - -// The user's selected network (persisted). Every read/write/sign uses it. -async function selectedChainId(): Promise { - const { [CHAIN_KEY]: id } = await browser.storage.local.get(CHAIN_KEY); - return typeof id === "number" && isSupportedChain(id) ? id : DEFAULT_CHAIN_ID; -} -const clientFor = (id: number) => createPublicClient({ chain: chainById(id), transport: http() }); -const publicClient = async () => clientFor(await selectedChainId()); - -// Contracts allowed to wear blue-chip symbols (the shipped defaults). -const OFFICIAL_TOKEN_ADDRESSES = new Set( - Object.values(DEFAULT_TOKENS).flat().map((t) => t.address.toLowerCase()), -); - -// Tracked tokens per chain = the shipped defaults + any the user added. -const TOKENS_KEY = (chainId: number) => `tokens-${chainId}`; -async function trackedTokens(chainId: number): Promise { - const key = TOKENS_KEY(chainId); - const { [key]: user = [] } = (await browser.storage.local.get(key)) as Record; - return [...(DEFAULT_TOKENS[chainId] ?? []), ...user]; -} - -// ---- 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) setLive(Keyring.fromMnemonic(mnemonic, await accountCount())); - return live; -} - -async function bumpAutoLock(): Promise { - await browser.alarms.create("autolock", { delayInMinutes: await autoLockMinutes() }); -} - -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(); - const accounts = kr ? kr.accounts.map((a) => a.address) : []; - const { [NAMES_KEY]: names = [] } = (await browser.storage.local.get(NAMES_KEY)) as { [NAMES_KEY]?: string[] }; - return { - hasVault: Boolean(vault), - unlocked: Boolean(kr), - accounts, - activeIndex: await activeIndex(accounts.length || 1), - chainId: await selectedChainId(), - names, - autoLockMin: await autoLockMinutes(), - }; - } - case "setChain": { - if (!isSupportedChain(op.chainId)) throw RpcError.invalidParams; - await browser.storage.local.set({ [CHAIN_KEY]: op.chainId }); - await emitToConnected("chainChanged", `0x${op.chainId.toString(16)}`); // connected dapps only - return { chainId: op.chainId }; - } - case "setActiveAccount": { - const max = await accountCount(); - if (!Number.isInteger(op.index) || op.index < 0 || op.index >= max) throw RpcError.invalidParams; - await browser.storage.local.set({ [ACTIVE_KEY]: op.index }); - const kr = await restore(); - const addr = kr?.accounts[op.index]?.address; - if (addr) { - // Grants are PER ORIGIN consents: switching the active account must - // never hand the new account to sites the user only connected with an - // old one. Notify exactly the origins already granted this account. - const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; - for (const [o, grant] of Object.entries(perms)) { - if (grant.some((g) => g.toLowerCase() === addr.toLowerCase())) emitEvent("accountsChanged", [addr], o); - } - } - return { ok: true }; - } - case "setAutoLock": { - if (!AUTO_LOCK_CHOICES.includes(op.minutes)) throw RpcError.invalidParams; - await browser.storage.local.set({ [AUTOLOCK_KEY]: op.minutes }); - await bumpAutoLock(); // re-arm with the new duration immediately - return { ok: true }; - } - case "revealPrivateKey": { - // Same gate as the mnemonic: password-decrypts the vault, derives ONE key. - const { [VAULT_KEY]: vault } = (await browser.storage.local.get(VAULT_KEY)) as { vault?: EncryptedVault }; - if (!vault) throw RpcError.invalidParams; - const max = await accountCount(); - if (!Number.isInteger(op.index) || op.index < 0 || op.index >= max) throw RpcError.invalidParams; - const mnemonic = await decryptVault(vault, op.password); // throws "Invalid password" - const seed = mnemonicToSeed(mnemonic); - const pk = derivePrivateKey(seed, op.index); - const { toHex } = await import("viem"); - const hex = toHex(pk); - pk.fill(0); - seed.fill(0); - await bumpAutoLock(); // an active reveal is activity; do not lock under the user - return { privateKey: hex }; - } - case "revealMnemonic": { - 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" - await bumpAutoLock(); // an active reveal is activity; do not lock under the user - return { mnemonic }; - } - case "removeWallet": { - await lock(); - // Clear everything tied to this wallet's identity, not just the vault: - // stale names/NFT lists must not attach to a different seed imported later. - const all = (await browser.storage.local.get(null)) as Record; - const stale = Object.keys(all).filter((k) => k.startsWith("nfts-") || k.startsWith("tokens-") || k.startsWith("history-") || k.startsWith("disc-meta-") || k.startsWith("hidden-tokens-")); - await browser.storage.local.remove([VAULT_KEY, COUNT_KEY, ACTIVE_KEY, NAMES_KEY, PERMS_KEY, "activity", "addr-labels", "avatars", ...stale]); - return { ok: true }; - } - case "createVault": - case "importVault": { - const vault = await encryptVault(op.mnemonic, op.password); - await browser.storage.local.set({ [VAULT_KEY]: vault, [COUNT_KEY]: 1, [ACTIVE_KEY]: 0 }); - await browser.storage.session.set({ [SESSION_KEY]: op.mnemonic }); - const created = Keyring.fromMnemonic(op.mnemonic, 1); - setLive(created); - await bumpAutoLock(); - return { unlocked: true, accounts: created.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" - const unlocked = Keyring.fromMnemonic(mnemonic, await accountCount()); - setLive(unlocked); - await browser.storage.session.set({ [SESSION_KEY]: mnemonic }); - await bumpAutoLock(); - return { unlocked: true, accounts: unlocked.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 browser.storage.local.set({ [COUNT_KEY]: kr.accounts.length }); - await bumpAutoLock(); - return { address: acct.address }; - } - case "getBalance": { - const wei = await (await publicClient()).getBalance({ address: op.address as `0x${string}` }); - return { wei: wei.toString(), formatted: formatEther(wei) }; - } - case "getTokens": { - const cid = await selectedChainId(); - const [tracked, discovered] = await Promise.all([trackedTokens(cid), discoverTokens(cid, op.address)]); - // Merge: tracked wins on duplicates; discovery is additive. Balances are - // re-read on-chain for ALL of them, so the indexer never sets a number. - // Discovered symbol/decimals are re-read ON-CHAIN once (cached): the - // indexer must not control what the send path divides by. - const hiddenKey = `hidden-tokens-${cid}`; - const { [hiddenKey]: hiddenList = [] } = (await browser.storage.local.get(hiddenKey)) as Record; - const hidden = new Set(hiddenList); - const visibleTracked = tracked.filter((t) => !hidden.has(t.address.toLowerCase())); - const known = new Set(visibleTracked.map((t) => t.address.toLowerCase())); - const fresh = discovered.filter((d) => !known.has(d.address.toLowerCase()) && !hidden.has(d.address.toLowerCase())); - const metaKey = `disc-meta-${cid}`; - const { [metaKey]: metaCache = {} } = (await browser.storage.local.get(metaKey)) as Record>; - let cacheDirty = false; - const verified: TokenMeta[] = []; - for (const d of fresh) { - const k = d.address.toLowerCase(); - let m = metaCache[k]; - if (!m) { - try { - const onchain = await fetchTokenMeta(clientFor(cid), d.address); - m = { ...onchain, symbol: stripControls(onchain.symbol) || "?" }; - metaCache[k] = m; - cacheDirty = true; - } catch { - continue; // unverifiable contract: do not show it at all - } - } - verified.push(m); - } - if (cacheDirty) await browser.storage.local.set({ [metaKey]: metaCache }); - const discoveredSet = new Set(verified.map((v) => v.address.toLowerCase())); - const balances = await readTokenBalances(clientFor(cid), op.address as `0x${string}`, [...visibleTracked, ...verified]); - return balances.map((b) => { - if (!discoveredSet.has(b.address.toLowerCase())) return b; - // Discovered = unsolicited: run the scam heuristics before display. - const risk = assessTokenRisk(b.symbol, b.address, OFFICIAL_TOKEN_ADDRESSES); - return { ...b, discovered: true, ...(risk.spam ? { spam: risk.reason } : {}) }; - }); - } - case "addToken": { - const meta = await fetchTokenMeta(clientFor(op.chainId), op.address); - // Adding by hand reverses a hide: never leave the user in a dead end. - const hiddenKey = `hidden-tokens-${op.chainId}`; - const { [hiddenKey]: hidden = [] } = (await browser.storage.local.get(hiddenKey)) as Record; - if (hidden.includes(meta.address.toLowerCase())) { - await browser.storage.local.set({ [hiddenKey]: hidden.filter((h) => h !== meta.address.toLowerCase()) }); - } - const key = TOKENS_KEY(op.chainId); - const { [key]: list = [] } = (await browser.storage.local.get(key)) as Record; - if (!list.some((t) => t.address.toLowerCase() === meta.address.toLowerCase())) { - await browser.storage.local.set({ [key]: [...list, meta] }); - } - return meta; - } - case "workerStatus": - // Worker contracts live on LightChain mainnet, so read there regardless of - // the selected network. Returns number/bool fields only (clone-safe). - return readWorkerStatus(clientFor(9200), op.address as `0x${string}`); - case "send": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - await bumpAutoLock(); - const hash = await signAndSend(acct.account, op.to as `0x${string}`, parseEther(op.valueWei), undefined, op.speed); - void logActivity({ hash, to: op.to, amount: op.valueWei, symbol: chainById(await selectedChainId()).nativeCurrency.symbol, chainId: await selectedChainId(), ts: Date.now(), from: op.from, kind: "native" }); - return { hash }; - } - case "gasTiers": - return readGasTiers(clientFor(await selectedChainId())); - case "resolveEns": - return { address: await resolveEnsName(op.name) }; - case "quoteSend": { - // Estimate the network fee (gas x fee/gas) before the user signs. - const cid = await selectedChainId(); - const client = clientFor(cid); - const feeSymbol = chainById(cid).nativeCurrency.symbol; - try { - const gas = op.token - ? await client.estimateGas({ account: op.from as `0x${string}`, to: op.token as `0x${string}`, data: erc20TransferData(op.to, op.amount ?? "0", op.decimals ?? 18) }) - : await client.estimateGas({ account: op.from as `0x${string}`, to: op.to as `0x${string}`, value: parseEther(op.valueWei ?? "0"), data: op.data && /^0x[0-9a-fA-F]*$/.test(op.data) ? (op.data as `0x${string}`) : undefined }); - const fees = await client.estimateFeesPerGas().catch(() => null); - const perGas = fees?.maxFeePerGas ?? (await client.getGasPrice()); - return { feeFormatted: formatEther(gas * perGas), feeSymbol }; - } catch { - return { feeFormatted: null, feeSymbol }; - } - } - case "replaceTx": { - // Speed up or cancel a pending tx: rebroadcast with the SAME nonce and a - // fee bumped >=30% (replacement needs >=10%). Cancel = 0-value self-send. - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - const cid = await selectedChainId(); - const client = clientFor(cid); - const orig = await client.getTransaction({ hash: op.hash as `0x${string}` }); - const bump = (v: bigint) => (v * 130n) / 100n; - const base = orig.maxFeePerGas ?? orig.gasPrice ?? (await client.getGasPrice()); - const prio = orig.maxPriorityFeePerGas ?? base / 10n; - const { createWalletClient } = await import("viem"); - const w = createWalletClient({ account: acct.account, chain: chainById(cid), transport: http() }); - await bumpAutoLock(); - const tx = - op.mode === "cancel" - ? { to: op.from as `0x${string}`, value: 0n, nonce: orig.nonce, maxFeePerGas: bump(base), maxPriorityFeePerGas: bump(prio) } - : { to: orig.to ?? (op.from as `0x${string}`), value: orig.value, data: orig.input, nonce: orig.nonce, maxFeePerGas: bump(base), maxPriorityFeePerGas: bump(prio) }; - const replacement = await w.sendTransaction(tx); - // Re-point the local send log at the replacement, or the old hash would - // sit in Activity as "pending" forever (the explorer never indexes it). - const { activity = [] } = (await browser.storage.local.get("activity")) as { activity?: ActivityEntry[] }; - await browser.storage.local.set({ activity: activity.map((e) => (e.hash.toLowerCase() === op.hash.toLowerCase() ? { ...e, hash: replacement } : e)) }); - return { hash: replacement }; - } - case "daoStatus": - return daoStatus(op.chainId, op.address); - case "getNfts": { - // Return the imported list, pruning anything the account no longer owns. - const key = NFTS_KEY(op.chainId, op.owner); - const { [key]: list = [] } = (await browser.storage.local.get(key)) as Record; - const owned = await Promise.all(list.map((n) => stillOwned(clientFor(op.chainId), op.owner as `0x${string}`, n))); - const kept = list.filter((_, i) => owned[i]); - if (kept.length !== list.length) await browser.storage.local.set({ [key]: kept }); - return kept.map((n) => { - const risk = assessNftRisk(n.name, n.collection); - return risk.spam ? { ...n, spam: risk.reason } : n; - }); - } - case "addNft": { - const item = await importNft(clientFor(op.chainId), op.owner as `0x${string}`, op.token as `0x${string}`, op.tokenId); - const key = NFTS_KEY(op.chainId, op.owner); - const { [key]: list = [] } = (await browser.storage.local.get(key)) as Record; - const dup = list.some((n) => n.address.toLowerCase() === item.address.toLowerCase() && n.tokenId === item.tokenId); - if (!dup) await browser.storage.local.set({ [key]: [...list, item] }); - return item; - } - case "removeNft": { - const key = NFTS_KEY(op.chainId, op.owner); - const { [key]: list = [] } = (await browser.storage.local.get(key)) as Record; - await browser.storage.local.set({ [key]: list.filter((n) => !(n.address.toLowerCase() === op.token.toLowerCase() && n.tokenId === op.tokenId)) }); - return true; - } - case "sendNft": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - await bumpAutoLock(); - const data = nftTransferData(op.standard, op.from, op.to, op.tokenId); - const hash = await signAndSend(acct.account, op.token as `0x${string}`, 0n, data); - void logActivity({ hash, to: op.to, amount: "1", symbol: "NFT", chainId: await selectedChainId(), ts: Date.now(), from: op.from, kind: "nft" }); - return { hash }; - } - case "getOrigins": { - const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; - return Object.keys(perms); - } - case "revokeOrigin": { - const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; - const { [op.origin]: _gone, ...rest } = perms; - await browser.storage.local.set({ [PERMS_KEY]: rest }); - emitEvent("accountsChanged", [], op.origin); // ONLY the revoked site sees the disconnect - return true; - } - case "getHistory": { - // Stale-while-revalidate, driven by the popup: refresh=false returns the - // cache instantly (no network); refresh=true (or no cache yet) fetches. - // items=null means UNKNOWN (explorer unreachable, no cache) - never - // conflate that with an empty history. - const cacheKey = `history-${op.chainId}-${op.address.toLowerCase()}`; - const { [cacheKey]: cached = null } = (await browser.storage.local.get(cacheKey)) as Record; - const symbol = chainById(op.chainId).nativeCurrency.symbol; - let base: HistoryItem[] | null = cached; - if (op.refresh || cached === null) { - const fresh = await fetchHistory(op.chainId, op.address, symbol).catch(() => null); - if (fresh) { - // A partial answer (one endpoint 429ed) must not erase cached rows. - base = fresh.complete ? fresh.items : mergeHistory(fresh.items, cached ?? []); - if (fresh.complete) await browser.storage.local.set({ [cacheKey]: base }); - } - } - if (base === null) return { items: null }; - const { activity = [] } = (await browser.storage.local.get("activity")) as { activity?: ActivityEntry[] }; - const indexed = new Set(base.map((h) => h.hash.toLowerCase())); - const me = op.address.toLowerCase(); - const pending: HistoryItem[] = activity - .filter((e) => e.chainId === op.chainId && e.from?.toLowerCase() === me && !indexed.has(e.hash.toLowerCase())) - .map((e) => ({ - hash: e.hash, - direction: "out" as const, - kind: e.kind ?? (e.symbol === symbol ? ("native" as const) : ("token" as const)), - label: e.symbol.replace(/^NFT /, ""), - amount: e.kind === "nft" ? "" : e.amount, - counterparty: e.to, - ts: e.ts, - failed: false, - pending: true, - })); - return { items: mergeHistory(pending, base) }; - } - case "quoteSwap": { - const tIn: SwapSide = { token: op.tokenIn as `0x${string}` | null, decimals: op.decimalsIn }; - const tOut: SwapSide = { token: op.tokenOut as `0x${string}` | null, decimals: op.decimalsOut }; - return { quote: await quoteSwap(op.chainId, tIn, tOut, op.amountIn) }; - } - case "swap": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - await bumpAutoLock(); - const tIn: SwapSide = { token: op.tokenIn as `0x${string}` | null, decimals: op.decimalsIn }; - const tOut: SwapSide = { token: op.tokenOut as `0x${string}` | null, decimals: op.decimalsOut }; - const res = await executeSwap(acct.account, op.chainId, tIn, tOut, op.amountIn, BigInt(op.expectedOutWei), Math.floor(Date.now() / 1000)); - const meta = op.tokenIn ? (await trackedTokens(op.chainId)).find((t) => t.address.toLowerCase() === op.tokenIn!.toLowerCase()) : null; - void logActivity({ hash: res.hash, to: "swap", amount: op.amountIn, symbol: meta?.symbol ?? chainById(op.chainId).nativeCurrency.symbol, chainId: op.chainId, ts: Date.now(), from: op.from, kind: op.tokenIn ? "token" : "native" }); - return res; - } - case "daoStats": - return readDaoStats(op.chainId); - case "getAvatars": { - const { "avatars": avatars = {} } = (await browser.storage.local.get("avatars")) as { avatars?: Record }; - return avatars; - } - case "setAvatar": { - if (!/^0x[0-9a-fA-F]{40}$/.test(op.address)) throw RpcError.invalidParams; - const { "avatars": avatars = {} } = (await browser.storage.local.get("avatars")) as { avatars?: Record }; - const key = op.address.toLowerCase(); - const next = { ...avatars }; - // Only an https/data:image URL that already passed the NFT pipeline. - const safe = op.image ? safeImageUrl(op.image) : null; - if (safe) next[key] = safe; - else delete next[key]; - await browser.storage.local.set({ avatars: next }); - return { ok: true }; - } - case "getProposals": - return { proposals: await listProposals(op.chainId, op.voter) }; - case "castVote": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - const governor = GOVERNORS[op.chainId]; - if (!governor) throw RpcError.invalidParams; - await bumpAutoLock(); - // Vote on the governor's OWN chain regardless of the selected network. - return { hash: await signAndSendOn(op.chainId, acct.account, governor, castVoteData(op.proposalId, op.support)) }; - } - case "networkStats": - return readNetworkStats(clientFor(9200)); - case "workerLifetime": - return { lifetime: await readWorkerLifetime(op.address) }; - case "workerModels": - return { models: await readWorkerModels(op.address) }; - case "protocolParams": - return readProtocolParams(clientFor(9200)); - case "withdrawRewards": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - await bumpAutoLock(); - // JobRegistry lives on LightChain mainnet; send there explicitly. - const t = withdrawTarget(); - return { hash: await signAndSendOn(9200, acct.account, t.to, t.data) }; - } - case "setAccountName": { - const max = await accountCount(); - if (!Number.isInteger(op.index) || op.index < 0 || op.index >= max) throw RpcError.invalidParams; - const { [NAMES_KEY]: names = [] } = (await browser.storage.local.get(NAMES_KEY)) as { [NAMES_KEY]?: string[] }; - // Densify: sparse arrays JSON-serialize holes to null and would leak into the UI. - const next = Array.from({ length: Math.max(names.length, op.index + 1) }, (_, i) => names[i] ?? ""); - next[op.index] = op.name.trim().slice(0, 24); - await browser.storage.local.set({ [NAMES_KEY]: next }); - return true; - } - case "bridgeFee": - return { fee: await bridgeFee(op.direction) }; - case "bridgeBalance": - return { balance: await bridgeSourceBalance(op.direction, op.account as `0x${string}`) }; - case "bridge": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - await bumpAutoLock(); - return bridgeTransfer(acct.account, op.direction, op.amount, op.expectedFeeWei); - } - case "txStatus": { - try { - const r = await (await publicClient()).getTransactionReceipt({ hash: op.hash as `0x${string}` }); - return { status: r.status === "success" ? "confirmed" : "failed" }; - } catch { - return { status: "pending" }; // receipt not mined yet - } - } - case "sendToken": { - const kr = await restore(); - const acct = kr?.accountFor(op.from); - if (!acct) throw RpcError.locked; - await bumpAutoLock(); - const data = erc20TransferData(op.to, op.amount, op.decimals); - const hash = await signAndSend(acct.account, op.token as `0x${string}`, 0n, data, op.speed); - const cid = await selectedChainId(); - const meta = (await trackedTokens(cid)).find((t) => t.address.toLowerCase() === op.token.toLowerCase()); - void logActivity({ hash, to: op.to, amount: op.amount, symbol: meta?.symbol ?? "tokens", chainId: cid, ts: Date.now(), from: op.from, kind: "token" }); - return { hash }; - } - case "addActivity": { - const { activity = [] } = (await browser.storage.local.get("activity")) as { activity?: ActivityEntry[] }; - await browser.storage.local.set({ activity: [op.entry, ...activity].slice(0, 40) }); - return { ok: true }; - } - case "getActivity": { - const { activity = [] } = (await browser.storage.local.get("activity")) as { activity?: ActivityEntry[] }; - return activity.filter((e) => e.chainId === op.chainId); - } - case "getPrices": { - // Best-effort USD prices from CoinGecko's public API (needs the host - // permission). Failures just leave USD blank in the UI. - const native = CG_NATIVE[op.chainId]; - const platform = CG_PLATFORM[op.chainId]; - const out: Prices = { nativeUsd: null, nativeChange24h: null, tokenUsd: {}, tokenChange24h: {} }; - try { - if (native) { - const j = (await (await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${native}&vs_currencies=usd&include_24hr_change=true`)).json()) as Record; - out.nativeUsd = j[native]?.usd ?? null; - out.nativeChange24h = j[native]?.usd_24h_change ?? null; - } else if (op.chainId === 9200 || op.chainId === 8200) { - // LightChain's native LCAI is priced via its Ethereum ERC-20. - const key = LCAI_PRICE_CONTRACT.toLowerCase(); - const j = (await (await fetch(`https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=${key}&vs_currencies=usd&include_24hr_change=true`)).json()) as Record; - out.nativeUsd = j[key]?.usd ?? null; - out.nativeChange24h = j[key]?.usd_24h_change ?? null; - } - if (platform && op.addresses.length) { - const list = op.addresses.map((a) => a.toLowerCase()).join(","); - const j = (await (await fetch(`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${list}&vs_currencies=usd&include_24hr_change=true`)).json()) as Record; - for (const [addr, v] of Object.entries(j)) { - if (typeof v?.usd === "number") out.tokenUsd[addr.toLowerCase()] = v.usd; - if (typeof v?.usd_24h_change === "number") out.tokenChange24h[addr.toLowerCase()] = v.usd_24h_change; - } - } - } catch { - return out; // prices are optional; surface what we have - } - return out; - } - case "simulateTx": { - // eth_simulateV1 with traceTransfers -> the signer's net balance changes, - // so we can preview "you send X / you receive Y" before they approve. The - // from balance is overridden so the sim never fails on funds. - const cid = await selectedChainId(); - const client = clientFor(cid); - const from = (op.from || "0x").toLowerCase(); - try { - const res = (await client.request({ - method: "eth_simulateV1" as never, - params: [ - { - blockStateCalls: [ - { - stateOverrides: { [from]: { balance: "0xffffffffffffffffffffffffffffffff" } }, - calls: [{ from, to: op.to, value: op.value ?? "0x0", data: op.data ?? "0x" }], - }, - ], - traceTransfers: true, - validation: false, - }, - "latest", - ] as never, - })) as Array<{ calls: Array<{ status: string; logs: SimLog[] }> }>; - const call = res?.[0]?.calls?.[0]; - if (!call) return { ok: false }; - const net = netChanges(parseTransfers(call.logs ?? []), from); - const changes: Array<{ symbol: string; formatted: string; direction: "in" | "out" }> = []; - for (const [token, delta] of net) { - const abs = delta > 0n ? delta : -delta; - const direction = delta > 0n ? ("in" as const) : ("out" as const); - let symbol = chainById(cid).nativeCurrency.symbol; - let decimals = 18; - if (token !== NATIVE_SENTINEL) { - const meta = await fetchTokenMeta(client, token).catch(() => null); - if (meta) { - symbol = meta.symbol; - decimals = meta.decimals; - } else { - symbol = `${token.slice(0, 6)}…`; - } - } - changes.push({ symbol, formatted: formatUnits(abs, decimals), direction }); - } - return { ok: true, reverted: call.status !== "0x1", changes }; - } catch { - return { ok: false }; // RPC may not support eth_simulateV1; UI falls back to the decode - } - } - case "getLabels": { - const { "addr-labels": labels = {} } = (await browser.storage.local.get("addr-labels")) as { "addr-labels"?: Record }; - return labels; - } - case "setAddressLabel": { - if (!/^0x[0-9a-fA-F]{40}$/.test(op.address)) throw RpcError.invalidParams; - const { "addr-labels": labels = {} } = (await browser.storage.local.get("addr-labels")) as { "addr-labels"?: Record }; - const key = op.address.toLowerCase(); - const label = op.label.trim().slice(0, 24); - const next = { ...labels }; - if (label) next[key] = label; - else delete next[key]; - await browser.storage.local.set({ "addr-labels": next }); - return { ok: true }; - } - case "removeToken": { - // User-added entries are deleted; defaults and discovered ones are hidden. - const key = TOKENS_KEY(op.chainId); - const { [key]: list = [] } = (await browser.storage.local.get(key)) as Record; - await browser.storage.local.set({ [key]: list.filter((t) => t.address.toLowerCase() !== op.address.toLowerCase()) }); - const hiddenKey = `hidden-tokens-${op.chainId}`; - const { [hiddenKey]: hidden = [] } = (await browser.storage.local.get(hiddenKey)) as Record; - if (!hidden.includes(op.address.toLowerCase())) await browser.storage.local.set({ [hiddenKey]: [...hidden, op.address.toLowerCase()] }); - return { ok: true }; - } - case "knownRecipients": { - // Distinct addresses you've sent to before (across chains) - the trusted - // set the send flow checks new recipients against for address poisoning. - const { activity = [] } = (await browser.storage.local.get("activity")) as { activity?: ActivityEntry[] }; - return [...new Set(activity.map((e) => e.to.toLowerCase()))]; - } - 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 }; - } -} - -/** Record an activity entry the moment a hash exists, regardless of popup life. */ -async function logActivity(entry: ActivityEntry): Promise { - const { activity = [] } = (await browser.storage.local.get("activity")) as { activity?: ActivityEntry[] }; - await browser.storage.local.set({ activity: [entry, ...activity].slice(0, 40) }); -} - -/** Send a tx on an EXPLICIT chain (governance, rewards: not the selected one). */ -async function signAndSendOn(chainId: number, account: Keyring["accounts"][number]["account"], to: `0x${string}`, data: `0x${string}`): Promise { - const { createWalletClient } = await import("viem"); - const wallet = createWalletClient({ account, chain: chainById(chainId), transport: http() }); - return wallet.sendTransaction({ to, data }); -} - -async function signAndSend(account: Keyring["accounts"][number]["account"], to: `0x${string}`, value: bigint, data?: `0x${string}`, speed?: GasSpeed): Promise { - const { createWalletClient } = await import("viem"); - const cid = await selectedChainId(); - const chain = chainById(cid); - const wallet = createWalletClient({ account, chain, transport: http() }); - if (!speed || speed === "normal") return wallet.sendTransaction({ to, value, data }); - const tiers = await readGasTiers(clientFor(cid)); - const t = tiers[speed]; - return wallet.sendTransaction({ to, value, data, maxFeePerGas: BigInt(t.maxFeePerGas), maxPriorityFeePerGas: BigInt(t.maxPriorityFeePerGas) }); -} - -// ---- dapp RPC + approval queue --------------------------------------------- - -/** Only secure origins may drive the wallet: https everywhere, plus http on - * localhost for dapp development. Rejecting other http:// at the boundary also - * collapses the http/https grant-key ambiguity, since a grant can then only - * ever be keyed to a secure origin. */ -function isSecureOrigin(origin: string): boolean { - if (origin.startsWith("https://")) return true; - return /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); -} - -async function handleDappRpc(request: JsonRpcRequest, origin: string): Promise { - if (!isAllowedMethod(request.method)) throw RpcError.unsupported; - // Block insecure-origin dapps outright (a self-custodial wallet has no reason - // to sign for an http:// site, and it removes the scheme-confusion grant gap). - if (!isSecureOrigin(origin)) throw RpcError.unauthorized; - - if (LOCAL_READ.has(request.method)) { - const cid = await selectedChainId(); - if (request.method === "eth_chainId") return `0x${cid.toString(16)}`; - if (request.method === "net_version") return String(cid); - if (request.method === "eth_accounts") return await connectedAccounts(origin); - if (request.method === "wallet_switchEthereumChain") { - // Only sites the user connected may move the wallet between networks. - if ((await connectedAccounts(origin)).length === 0) throw RpcError.unauthorized; - return await switchChain(request); - } - } - - if (APPROVAL_REQUIRED.has(request.method)) { - // Signing methods must come from an origin holding a grant for the signer; - // only the connect handshake itself is exempt. The check reads the STORED - // grant (not the keyring view): a locked wallet must still route to the - // approval window so the user gets the unlock-then-sign flow, and - // fulfilApproved enforces the lock itself. - if (request.method !== "eth_requestAccounts") { - const granted = (await grantedFor(origin)).map((a) => a.toLowerCase()); - const signer = signerOf(request); - if (granted.length === 0 || (signer && !granted.includes(signer.toLowerCase()))) throw RpcError.unauthorized; - } - return await enqueueApproval(request, origin); - } - - // Everything else on the allowlist: read-only passthrough to the selected chain's pinned RPC. - return (await publicClient()).request({ method: request.method as never, params: request.params as never }); -} - -/** The origin's STORED grant, independent of lock state. */ -async function grantedFor(origin: string): Promise { - const { [PERMS_KEY]: perms = {} } = (await browser.storage.local.get(PERMS_KEY)) as { [PERMS_KEY]?: Record }; - return perms[origin] ?? []; -} - -/** The account a dapp request wants to sign with, per method shape. */ -function signerOf(request: JsonRpcRequest): string | null { - if (request.method === "eth_sendTransaction") { - const tx = (request.params?.[0] ?? {}) as { from?: string }; - return typeof tx.from === "string" ? tx.from : null; - } - if (request.method === "personal_sign") { - const a = request.params?.[1]; - return typeof a === "string" ? a : null; - } - if (request.method === "eth_signTypedData_v4") { - const a = request.params?.[0]; - return typeof a === "string" ? a : null; - } - return null; -} - -// Switch the wallet to a code-pinned supported chain, or reject (4902) so the -// dapp knows it is unrecognized. The inpage provider emits chainChanged on success. -async function switchChain(request: JsonRpcRequest): Promise { - const param = (request.params?.[0] ?? {}) as { chainId?: string }; - const id = param.chainId ? Number(param.chainId) : NaN; - if (!isSupportedChain(id)) throw { code: 4902, message: "Unrecognized chain. Add it in the wallet first." }; - await browser.storage.local.set({ [CHAIN_KEY]: id }); - return null; -} - -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)) : []; -} - -// One approval window at a time: new requests queue into it; closing it -// rejects everything still pending (a dapp promise must never hang forever). -let approvalWindowId: number | null = null; -// Synchronous guard: two requests racing before windows.create resolves must -// share ONE window, or the id tracks only the second and closes go unobserved. -let approvalOpening: Promise | null = null; - -function enqueueApproval(request: JsonRpcRequest, origin: string): Promise { - return new Promise((resolve, reject) => { - const id = `req-${++pendingSeq}`; - pending.set(id, { request, origin, resolve, reject }); - if (approvalOpening) return; // a window is being created; it will drain the queue - if (approvalWindowId !== null) { - // Focus the existing window; its queue drains request by request. - void browser.windows.update(approvalWindowId, { focused: true, drawAttention: true }).catch(() => { - approvalWindowId = null; - startApprovalWindow(); - }); - return; - } - startApprovalWindow(); - }); -} - -function startApprovalWindow(): void { - if (approvalOpening) return; - approvalOpening = browser.windows - .create({ url: browser.runtime.getURL("/popup.html#/approve"), type: "popup", width: 380, height: 600 }) - .then((w) => { - approvalWindowId = w?.id ?? null; - // Survive a service-worker restart: a stale approval window left behind - // after the SW died is swept on the next boot (see sweepOrphanWindows). - void browser.storage.session.set({ "approval-window": approvalWindowId }); - }) - .catch(() => { - // The window could not open at all: a hanging dapp promise is worse than - // a rejection, so settle everything that queued for it. - for (const [id, p] of pending) { - pending.delete(id); - p.reject(RpcError.userRejected); - } - }) - .finally(() => { - approvalOpening = null; - }); -} - -/** SW restart lost the in-memory pending map: any approval window left over - * from the previous life shows "no pending requests" forever. Close it. */ -async function sweepOrphanApprovalWindow(): Promise { - const { "approval-window": stale } = (await browser.storage.session.get("approval-window")) as { "approval-window"?: number }; - if (typeof stale === "number" && stale !== approvalWindowId) { - await browser.windows.remove(stale).catch(() => undefined); - await browser.storage.session.remove("approval-window"); - } -} - -browser.windows.onRemoved.addListener((closedId) => { - if (closedId !== approvalWindowId) return; - approvalWindowId = null; - // Dismissing the window IS a rejection for everything still queued. - for (const [id, p] of pending) { - pending.delete(id); - p.reject(RpcError.userRejected); - } -}); - -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" }); - } -} - -/** - * A SIWE sign-in names the chain the dapp lives on; approving it is the - * clearest "I am using this app on that network" signal there is, so the - * wallet follows. Only for supported chains, and only when the stated domain - * really is the requesting origin (a phishing message must not steer the - * wallet anywhere). - */ -async function followSiweChain(data: `0x${string}`, origin: string): Promise { - const text = decodeSignText(data); - if (!text || siweOriginMismatch(text, origin)) return; - const target = siweChainId(text); - if (target == null || !isSupportedChain(target) || target === (await selectedChainId())) return; - await browser.storage.local.set({ [CHAIN_KEY]: target }); - await emitToConnected("chainChanged", `0x${target.toString(16)}`); -} - -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[await activeIndex(kr.accounts.length)]!.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; - const signature = await acct.account.signMessage({ message: { raw: data } }); - await followSiweChain(data, origin); - return signature; - } - if (request.method === "eth_sendTransaction") { - // approve==sign: sign exactly the canonical tx the popup displayed (review H1), - // INCLUDING the calldata - without it every dapp contract call would silently - // become an empty transfer. - const [raw] = request.params as [{ from: string; to?: string; value?: string; data?: string }]; - const acct = kr.accountFor(raw.from); - if (!acct) throw RpcError.unauthorized; - const tx = canonicalizeDappTx(raw); - const hash = await signAndSend(acct.account, tx.to, tx.value, tx.data); - // Dapp txs deserve the same Activity row + Speed up/Cancel as wallet sends. - const cid = await selectedChainId(); - void logActivity({ hash, to: tx.to, amount: formatEther(tx.value), symbol: chainById(cid).nativeCurrency.symbol, chainId: cid, ts: Date.now(), from: raw.from, kind: tx.data ? "token" : "native" }); - return hash; - } - if (request.method === "eth_signTypedData_v4") { - const [address, json] = request.params as [string, string]; - const acct = kr.accountFor(address); - if (!acct) throw RpcError.unauthorized; - const td = parseTypedData(json); - if (!td?.domain || !td.primaryType || !td.types || td.message === undefined) throw RpcError.invalidParams; - // Bind the signature to a supported chain - never sign typed data aimed elsewhere (review H5). - const chainId = td.domain.chainId != null ? Number(td.domain.chainId) : undefined; - if (chainId === undefined || !isSupportedChain(chainId)) throw { code: 4901, message: "Typed data targets an unsupported chain" }; - const types = { ...(td.types as Record) }; - delete types.EIP712Domain; // viem derives the domain type itself - const def = { domain: td.domain, types, primaryType: td.primaryType, message: td.message } as unknown as TypedDataDefinition; - return acct.account.signTypedData(def); - } - throw RpcError.unsupported; -} - -// ---- message router -------------------------------------------------------- - -export default defineBackground(() => { - void sweepOrphanApprovalWindow(); - // First install: open onboarding in a full browser tab (a 360px popup is a - // cramped first impression for seed-phrase setup). - browser.runtime.onInstalled.addListener((details) => { - // Stamp + migrate the storage schema BEFORE anything reads it: future - // versions bump SCHEMA_VERSION and add their migration step here. - void (async () => { - const SCHEMA_VERSION = 1; - const { "schema-version": from = 0 } = (await browser.storage.local.get("schema-version")) as { "schema-version"?: number }; - if (from < SCHEMA_VERSION) { - // v0 -> v1: no shape changes; everything shipped at v1 semantics. - await browser.storage.local.set({ "schema-version": SCHEMA_VERSION }); - } - })(); - if (details.reason !== "install") return; - void browser.tabs.create({ url: browser.runtime.getURL("/popup.html#/expanded") }); - }); - // The chat doubles as the WALLET ASSISTANT: each prompt is grounded with a - // compact guide to the wallet plus live data (latest proposals, the user's - // balance, worker network), so "what is the newest DAO proposal" just works. - let assistantCache: { at: number; text: string } | null = null; - async function assistantContext(address: string): Promise { - if (assistantCache && Date.now() - assistantCache.at < 300000) { - return `${assistantCache.text}\n${await assistantUserLine(address)}`; - } - const [lcProps, ethProps, net] = await Promise.allSettled([ - listProposals(9200), - listProposals(1), - readNetworkStats(clientFor(9200)), - ]); - const lines: string[] = []; - const fmtProps = (label: string, r: PromiseSettledResult>>) => { - if (r.status !== "fulfilled" || !r.value?.length) return; - const rows = r.value.slice(0, 3).map((p, i) => { - const total = p.forVotes + p.againstVotes + p.abstainVotes; - const pct = (n: number) => (total > 0 ? `${Math.round((n / total) * 100)}%` : "0%"); - const results = total > 0 ? ` Results: For ${pct(p.forVotes)}, Against ${pct(p.againstVotes)}, Abstain ${pct(p.abstainVotes)}.` : " No votes yet."; - return `${i + 1}. "${p.title}" [${p.state}].${results}`; - }); - lines.push(`${label} governor proposals, NEWEST FIRST (item 1 is the latest):\n${rows.join("\n")}`); - }; - fmtProps("LightChain", lcProps); - fmtProps("Ethereum", ethProps); - if (net.status === "fulfilled") { - lines.push(`Worker network: ${net.value.totalWorkers}${net.value.capped ? "+" : ""} workers (${net.value.activeWorkers} active), ${net.value.jobsCompleted} jobs completed, ${net.value.totalEarnedLcai.toFixed(0)} LCAI paid out, min stake ${net.value.minStakeLcai} LCAI.`); - } - assistantCache = { at: Date.now(), text: lines.join("\n") }; - return `${assistantCache.text}\n${await assistantUserLine(address)}`; - } - async function assistantUserLine(address: string): Promise { - try { - const cid = await selectedChainId(); - const wei = await clientFor(cid).getBalance({ address: address as `0x${string}` }); - return `The user's selected network is ${chainById(cid).name}; their balance there is ${(Number(wei) / 1e18).toFixed(4)} ${chainById(cid).nativeCurrency.symbol}.`; - } catch { - return ""; - } - } - const ASSISTANT_GUIDE = [ - "You are the LightNode Wallet assistant. Answer briefly and helpfully.", - "About the wallet: a self-custodial browser extension for LightChain and EVM networks (Ethereum, Base, Arbitrum, Optimism, Polygon). Keys never leave the device.", - "Navigation: the home screen has Send, Receive, Swap, AI Chat, and Explorer actions; tabs for Tokens, NFTs, and Activity (with Received/Sent/NFT filters); Worker and Governance cards below open the worker hub and the DAO proposals where the user can vote For/Against/Abstain; the gear icon opens Settings (recovery phrase, private key export, auto-lock, connected sites); the expand icon opens the wallet in a full tab.", - "Swap trades on Uniswap v3 and moves LCAI between Ethereum and LightChain. The worker hub shows stake, claimable rewards (withdrawable in-wallet), and how to become a worker at lightnode.app/onboard.", - ].join(" "); - - browser.runtime.onConnect.addListener((port) => { - // In-wallet AI chat: a long-lived port from OUR popup streams phases and - // decrypted chunks. One explicit session consent in the UI, then each - // message auto-signs its two LightChain txs here (first-party feature, - // never reachable by dapps). - if (port.name === "lc-chat") { - const safePost = (m: unknown) => { - try { - port.postMessage(m); - } catch { - // popup closed mid-stream; the tx already carries the answer fee - } - }; - port.onMessage.addListener((raw: unknown) => { - void (async () => { - const msg = raw as { type: string; from?: string; model?: ChatModel; prompt?: string }; - try { - if (msg.type === "models") { - safePost({ type: "models", models: await listChatModels() }); - return; - } - if (msg.type === "send" && msg.from && msg.model && typeof msg.prompt === "string") { - const kr = await restore(); - const acct = kr?.accountFor(msg.from); - if (!acct) throw new Error("Wallet is locked."); - await bumpAutoLock(); - const grounding = await assistantContext(msg.from).catch(() => ""); - const grounded = `${ASSISTANT_GUIDE}\n\nLive facts:\n${grounding}\n\nUser question: ${msg.prompt}\n\nAnswer:`; - const result = await runInference(acct.account, msg.model, grounded, { - phase: (ph) => safePost({ type: "phase", phase: ph }), - chunk: (t) => safePost({ type: "chunk", text: t }), - }); - void logActivity({ hash: result.submitHash, to: "AI chat", amount: result.feeLcai.toString(), symbol: "LCAI", chainId: 9200, ts: Date.now(), from: msg.from, kind: "native" }); - safePost({ type: "done", feeLcai: result.feeLcai, hash: result.submitHash }); - } - } catch (e) { - safePost({ type: "error", message: (e as Error).message }); - } - })(); - }); - return; - } - if (port.name !== EVENT_PORT) return; - const p = port as unknown as EventPort; - const origin = p.sender?.origin ?? (p.sender?.url ? new URL(p.sender.url).origin : ""); - eventPorts.set(p, origin); - port.onDisconnect.addListener(() => eventPorts.delete(p)); - }); - browser.runtime.onMessage.addListener((message: unknown, sender: WalletSender) => { - const msg = message as BgMessage; - if (msg.kind === "wallet") { - // The wallet channel exposes the crown jewels (send/swap/bridge/reveal) - // with no per-call approval window, so it MUST come from our own - // privileged UI: same extension id, the extension origin, and NO tab - // (popup/extension pages have none; content scripts do). This makes the - // "only the popup speaks kind:wallet" invariant explicit instead of - // implicit, so no future content-script path can reach handleWalletOp. - if (!isTrustedWalletSender(sender)) { - return Promise.resolve({ error: { code: 4100, message: "unauthorized sender" } }); - } - 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 deleted file mode 100644 index 89b2968..0000000 --- a/wallet/entrypoints/content.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { PAGE_TO_CONTENT, CONTENT_TO_PAGE, CONTENT_TO_PAGE_EVENT, EVENT_PORT, 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; - // A dapp promise must NEVER hang: if the service worker died or the - // extension updated underneath us, settle with a provider error. - try { - const response = await browser.runtime.sendMessage({ kind: "dapp-rpc", request: data.request }); - window.postMessage({ target: CONTENT_TO_PAGE, response }, window.location.origin); - } catch { - window.postMessage( - { target: CONTENT_TO_PAGE, response: { id: data.request.id, error: { code: 4900, message: "Wallet disconnected. Reload the page." } } }, - window.location.origin, - ); - } - }); - - // Long-lived port for background -> page provider events (chainChanged / - // accountsChanged). Reconnect with exponential backoff when the MV3 service - // worker recycles; stop entirely once the extension context is invalidated - // (otherwise every open tab wakes the SW in a tight loop forever). - let backoff = 500; - const connectEvents = () => { - let port: ReturnType; - try { - port = browser.runtime.connect({ name: EVENT_PORT }); - } catch { - return; // context invalidated (extension updated/removed): end the loop - } - const connectedAt = Date.now(); - port.onMessage.addListener((m: unknown) => { - backoff = 500; // a live message proves the channel: reset the backoff - const e = m as { event?: string; data?: unknown }; - if (e?.event) window.postMessage({ target: CONTENT_TO_PAGE_EVENT, event: e.event, data: e.data }, window.location.origin); - }); - port.onDisconnect.addListener(() => { - // Provider events are rare: a connection that LIVED for a while was - // healthy even if silent (normal MV3 idle recycling), so do not let - // the backoff creep toward a minute of deafness. - if (Date.now() - connectedAt > 5000) backoff = 500; - const wait = backoff; - backoff = Math.min(backoff * 2, 60000); - setTimeout(connectEvents, wait); - }); - }; - connectEvents(); - }, -}); diff --git a/wallet/entrypoints/inpage.ts b/wallet/entrypoints/inpage.ts deleted file mode 100644 index c9acf67..0000000 --- a/wallet/entrypoints/inpage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { PAGE_TO_CONTENT, CONTENT_TO_PAGE, CONTENT_TO_PAGE_EVENT, 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 "LightNode Wallet" alongside any other wallet. -// 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>(); - const emit = (event: string, data: unknown) => listeners.get(event)?.forEach((h) => h(data)); - - window.addEventListener("message", (event: MessageEvent) => { - if (event.source !== window) return; - const data = event.data as (ContentMessage | { target: typeof CONTENT_TO_PAGE_EVENT; event: string; data: unknown }) | undefined; - if (!data) return; - // Provider events pushed from the wallet (chainChanged / accountsChanged). - if (data.target === CONTENT_TO_PAGE_EVENT) { - emit(data.event, data.data); - return; - } - if (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 = { - isLightNodeWallet: 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: (result) => { - // Standard EIP-1193: notify the dapp the chain changed after a switch. - if (method === "wallet_switchEthereumChain") { - const cid = (Array.isArray(params) ? (params[0] as { chainId?: string })?.chainId : undefined); - if (cid) emit("chainChanged", cid); - } - resolve(result); - }, - 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: "LightNode Wallet", - icon: "data:image/png;base64,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", - rdns: "app.lightnode", - }; - 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 deleted file mode 100644 index 9cc1205..0000000 --- a/wallet/entrypoints/popup/App.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useEffect, useState, type ReactNode } from "react"; -import { wallet, type WalletState } from "./wallet-api"; -import { isApproveWindow, isExpanded } from "./shared"; -import { Onboarding, Unlock } from "./onboarding"; -import { WalletHome } from "./home"; -import { ApproveView } from "./approve"; - -export function App() { - const [state, setState] = useState(null); - const refresh = useCallback(async () => setState(await wallet({ type: "getState" })), []); - useEffect(() => { - void refresh(); - }, [refresh]); - - if (!state) return

Loading…

; - // The approval window must never PRETEND to work while locked: approving - // would throw and silently reject the dapp. Demand the password first. - if (isApproveWindow()) { - if (state.hasVault && !state.unlocked) return ; - return ; - } - if (!state.hasVault) return ; - if (!state.unlocked) return ; - return ; -} - -function Shell({ children }: { children: ReactNode }) { - const expanded = isExpanded(); - useEffect(() => { - if (expanded) document.body.classList.add("expanded-body"); - if (isApproveWindow()) document.body.classList.add("approve-body"); - }, [expanded]); - return
{children}
; -} - -function Brand() { - return
LightNode Wallet
; -} diff --git a/wallet/entrypoints/popup/approve.tsx b/wallet/entrypoints/popup/approve.tsx deleted file mode 100644 index 18c7a21..0000000 --- a/wallet/entrypoints/popup/approve.tsx +++ /dev/null @@ -1,245 +0,0 @@ -/** Dapp approval window: decode, simulate, approve/reject. */ -import { useCallback, useEffect, useState } from "react"; -import { wallet, type PendingRequest, type WalletState } from "./wallet-api"; -import { decodeDangerousCall } from "../../src/provider/decode-call"; -import { recognizeLightChainCall } from "../../src/provider/lightchain-calls"; -import { summarizeTypedData, siweOriginMismatch, siweChainId, decodeSignText } from "../../src/provider/typed-data"; -import { chainById, isSupportedChain, logoFor } from "../../src/rpc/chains"; -import { assessRecipient } from "../../src/rpc/risk"; -import { SEVERITY_CLASS, SUPPORTED_IDS, avatarGradient, short } from "./shared"; - -export function ApproveView() { - const [reqs, setReqs] = useState(null); - const [state, setState] = useState(null); - const [known, setKnown] = useState([]); - const [busy, setBusy] = useState(false); - const load = useCallback(() => { - void wallet({ type: "listPending" }).then(setReqs).catch(() => setReqs([])); - void wallet({ type: "getState" }).then(setState).catch(() => {}); - // Known counterparties feed the same address-poisoning check the send flow uses. - void wallet({ type: "knownRecipients" }).then(setKnown).catch(() => {}); - }, []); - useEffect(() => { - void load(); - }, [load]); - const resolve = async (id: string, approved: boolean) => { - setBusy(true); - try { - await wallet({ type: "resolvePending", id, approved }); - } finally { - setBusy(false); - } - // Re-poll: another request may have queued into this window meanwhile. - const left = await wallet({ type: "listPending" }).catch(() => []); - if (left.length === 0) window.close(); - else load(); // state too: a SIWE approval may just have switched the network - }; - if (!reqs) return

Loading…

; - if (reqs.length === 0) return

No pending requests.

; - const r = reqs[0]!; - // On a dangerous decoded call, flip the emphasis: Reject becomes the primary - // action and Approve loses the inviting gradient. - const txParam = r.method === "eth_sendTransaction" ? ((r.params?.[0] ?? {}) as { from?: string; to?: string; value?: string; data?: string }) : null; - const dangerous = txParam != null && decodeDangerousCall(txParam.data as `0x${string}` | undefined).severity === "danger"; - const chain = state ? chainById(state.chainId) : null; - const signer = txParam?.from ?? state?.accounts[state?.activeIndex ?? 0]; - const signerName = state && signer ? (state.names?.[state.accounts.findIndex((a) => a.toLowerCase() === signer.toLowerCase())]?.trim() || short(signer)) : null; - return ( -
- {r.origin.replace(/^https:\/\//, "")} -

{labelFor(r.method)}

- {reqs.length > 1 &&

Request 1 of {reqs.length}

} - {chain && signer && ( -
- {chain.name} - {signerName} -
- )} - - {txParam && } -
- - -
-
- ); -} - -/** Network-fee preview for dapp transactions, reusing the send-quote path. */ -function FeeEstimate({ tx }: { tx: { from?: string; to?: string; value?: string; data?: string } }) { - const [fee, setFee] = useState(null); - useEffect(() => { - if (!tx.from || !tx.to) return; - let valueEth = "0"; - try { - valueEth = tx.value ? (Number(BigInt(tx.value)) / 1e18).toString() : "0"; - } catch { - return; // unparseable value: skip the estimate, the approval still shows - } - // The estimate must include the calldata: a contract call costs far more - // than the bare transfer the old quote priced. - wallet<{ feeFormatted: string | null; feeSymbol: string }>({ type: "quoteSend", from: tx.from, to: tx.to, valueWei: valueEth, data: tx.data }) - .then((q) => setFee(q.feeFormatted ? `${Number(q.feeFormatted).toLocaleString(undefined, { maximumFractionDigits: 6 })} ${q.feeSymbol}` : null)) - .catch(() => {}); - }, [tx.from, tx.to, tx.value, tx.data]); - if (!fee) return null; - return

Network fee ≈ {fee}

; -} - -interface SimResult { - ok: boolean; - reverted?: boolean; - changes?: { symbol: string; formatted: string; direction: "in" | "out" }[]; -} - -function SimPreview({ tx, mute = false }: { tx: { from?: string; to?: string; value?: string; data?: string }; mute?: boolean }) { - const [sim, setSim] = useState("loading"); - useEffect(() => { - wallet({ type: "simulateTx", from: tx.from ?? "", to: tx.to ?? "", value: tx.value, data: tx.data }) - .then(setSim) - .catch(() => setSim({ ok: false })); - }, [tx.from, tx.to, tx.value, tx.data]); - if (sim === "loading") return

Simulating the outcome…

; - if (!sim.ok) return null; // RPC can't simulate; the decoded action below still shows - if (sim.reverted) return

This transaction is expected to FAIL on-chain - approving it would just waste gas.

; - // No balance movement is NOT a green light when the decoded call is dangerous - // (an unlimited approve moves nothing now, but hands over everything later). - if (!sim.changes?.length) return

Simulated: no balance changes now{mute ? " - see the warning below" : " (e.g. an approval or a no-op)"}.

; - const fmt = (n: string) => Number(n).toLocaleString(undefined, { maximumFractionDigits: 6 }); - return ( -
-
Estimated balance changes
- {sim.changes.map((c, i) => ( -
- - {c.direction === "in" ? "+" : "-"}{fmt(c.formatted)} {c.symbol} - -
- ))} -
- ); -} - -function labelFor(method: string): string { - if (method === "eth_requestAccounts") return "Connect this site to your wallet"; - if (method === "personal_sign") return "Sign a message"; - if (method === "eth_sendTransaction") return "Send a transaction"; - if (method === "eth_signTypedData_v4") return "Sign typed data (EIP-712)"; - return method; -} - -function RequestDetail({ req, activeChainId, own = [], known = [] }: { req: PendingRequest; activeChainId?: number; own?: string[]; known?: string[] }) { - if (req.method === "eth_sendTransaction") { - const tx = (req.params?.[0] ?? {}) as { from?: string; to?: string; value?: string; data?: string }; - const decoded = decodeDangerousCall(tx.data as `0x${string}` | undefined); - // Positive ID of LightChain's own protocol calls. When recognized, the - // reassuring banner replaces the generic "unrecognized contract" warning - - // the native wallet should know its own ecosystem on sight. - const recognized = recognizeLightChainCall(tx.to, tx.data as `0x${string}` | undefined, activeChainId, valueWei(tx.value)); - const hideUnknown = recognized != null && decoded.kind === "unknown"; - // Address-poisoning check on the EFFECTIVE recipient: the decoded - // transfer/transferFrom target when present, else the bare tx.to. Dapp sends - // bypassed this entirely before; run the same assessment the send flow does. - const recipient = decoded.recipient ?? tx.to; - const risk = recipient && recipient.length === 42 ? assessRecipient(recipient, known, own) : null; - return ( -
- {recognized && ( -

- {recognized.contract}: {recognized.action}. {recognized.detail} -

- )} - {risk?.kind === "lookalike" && ( -

This recipient looks like {short(risk.similarTo!)} but is different - a common address-poisoning scam. Verify every character before approving.

- )} - -
- To - {tx.to ? short(tx.to) : "(contract creation)"} -
-
- Amount - {txValueEth(tx.value)} -
- {decoded.kind !== "empty" && !hideUnknown && ( -

{decoded.label}. {decoded.detail}

- )} -
- ); - } - if (req.method === "eth_signTypedData_v4") { - const s = summarizeTypedData(req.params?.[1], SUPPORTED_IDS); - // Always surface the domain chain as a network name: a Permit aimed at a - // different chain than the wallet's network is otherwise invisible. - const domainNet = s.chainId != null ? domainNetworkName(s.chainId) : null; - const crossChain = s.chainId != null && activeChainId != null && s.chainId !== activeChainId; - return ( -
- {s.error ? ( -

{s.error} Reject unless you trust this site.

- ) : ( - <> -

type: {s.primaryType}{s.domainName ? ` · ${s.domainName}` : ""}{domainNet ? ` · ${domainNet}` : ""}

- {crossChain && ( -

This is for {domainNet}, but your wallet is on {chainById(activeChainId!).name}.

- )} - {s.verifyingContract &&

contract: {s.verifyingContract}

} - {s.permit.kind !== "none" && ( -
- {s.permit.unlimited ? "Unlimited signature approval." : "Signature approval."} {s.permit.summary} - {s.permit.spender &&
spender: {s.permit.spender}
} - {s.permit.token &&
token: {s.permit.token}
} - {s.permit.deadline && Number(s.permit.deadline) > 0 &&
valid until {new Date(Number(s.permit.deadline) * 1000).toLocaleString()}
} -
- )} - {!s.chainIdOk &&

Domain chain ({s.chainId ?? "?"}) is not a supported network - reject unless you are sure.

} - {s.warning &&

{s.warning}

} - - )} -
- ); - } - if (req.method === "personal_sign") { - const raw = String(req.params?.[0] ?? ""); - const text = decodeSignText(raw); - if (text == null) return

You are signing unreadable (non-text) data. This can authorize transfers - reject unless you know exactly what it is.

; - const mismatch = siweOriginMismatch(text, req.origin); - const siweTarget = siweChainId(text); - const switchTo = !mismatch && siweTarget != null && SUPPORTED_IDS.includes(siweTarget) && siweTarget !== activeChainId ? chainById(siweTarget) : null; - return ( - <> - {mismatch && ( -

Sign-in domain mismatch. The message claims to be from {mismatch.stated} but the request comes from {mismatch.actual}. This is how lookalike sites steal sessions; reject it.

- )} - {switchTo && ( -

This sign-in is for {switchTo.name} - approving switches the wallet to that network.

- )} -

{text}

- - ); - } - return

This site is requesting access to your account address.

; -} - -function txValueEth(value?: string): string { - try { - const n = value ? Number(BigInt(value)) / 1e18 : 0; - return `${n.toLocaleString(undefined, { maximumFractionDigits: 6 })}`; - } catch { - return "unreadable"; - } -} - -/** A typed-data domain chainId as a network name, or the raw id when unsupported. */ -function domainNetworkName(chainId: number): string { - return isSupportedChain(chainId) ? chainById(chainId).name : `chain ${chainId}`; -} - -/** The tx value in wei (0 when absent or unparseable), for fee-aware labels. */ -function valueWei(value?: string): bigint { - try { - return value ? BigInt(value) : 0n; - } catch { - return 0n; - } -} diff --git a/wallet/entrypoints/popup/home.tsx b/wallet/entrypoints/popup/home.tsx deleted file mode 100644 index 54cbfb7..0000000 --- a/wallet/entrypoints/popup/home.tsx +++ /dev/null @@ -1,518 +0,0 @@ -/** The wallet home: header, hero, actions, tabs, cards. */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { wallet, type WalletState, type WorkerStatusView } from "./wallet-api"; -import { chainById, CHAIN_LIST, explorerFor, logoFor } from "../../src/rpc/chains"; -import type { TokenBalance } from "../../src/rpc/tokens"; -import type { NftItem } from "../../src/rpc/nfts"; -import type { HistoryItem } from "../../src/rpc/history"; -import { portfolioUsd, fmtUsd, type Prices } from "../../src/rpc/prices"; -import { humanizeError } from "../../src/rpc/humanize"; -import { type Asset, type DaoView, Ic, short, fmtBal, tokenLogo, avatarGradient, timeAgo, isExpanded, openFullTab, Change, Avatar } from "./shared"; -import { SendSheet, ReceiveSheet, ImportTokenSheet, ImportNftSheet, NftSheet, NftGrid, AvatarSheet } from "./sheets-assets"; -import { SwapSheet } from "./sheets-swap"; -import { DaoSheet } from "./sheets-dao"; -import { WorkerSheet } from "./sheets-worker"; -import { SettingsSheet } from "./sheets-settings"; -import { ChatSheet } from "./sheets-chat"; - -export function WalletHome({ state, onChange }: { state: WalletState; onChange: () => void }) { - const address = state.accounts[state.activeIndex] ?? state.accounts[0]!; - // undefined = loading, null = unreachable (NEVER shown as zero), string = truth. - const [bal, setBal] = useState(undefined); - const [sheet, setSheet] = useState<"send" | "receive" | "settings" | "swap" | "dao" | "worker" | "chat" | "importToken" | "importNft" | null>(null); - const [nftSel, setNftSel] = useState(null); - const [nfts, setNfts] = useState(undefined); - const [acctOpen, setAcctOpen] = useState(false); - const [copied, setCopied] = useState(false); - const chain = chainById(state.chainId); - const sym = chain.nativeCurrency.symbol; - const explorer = explorerFor(state.chainId); - - const [tokens, setTokens] = useState(undefined); - // undefined = loading, null = explorer unreachable with no cache, [] = truly empty. - const [history, setHistory] = useState(undefined); - const [prices, setPrices] = useState(null); - const [tab, setTab] = useState<"tokens" | "nfts" | "activity">("tokens"); - const [historyFilter, setHistoryFilter] = useState("all"); // lifted: survives tab switches - const [labels, setLabels] = useState>({}); - const [avatars, setAvatars] = useState>({}); - const [avatarFor, setAvatarFor] = useState(null); // address being customized - useEffect(() => { - wallet>({ type: "getLabels" }).then(setLabels).catch(() => {}); - wallet>({ type: "getAvatars" }).then(setAvatars).catch(() => {}); - }, [sheet, avatarFor]); // refresh when a sheet closes (a label/avatar may have just been saved) - const [hideZero, setHideZero] = useState(false); - const [showSpam, setShowSpam] = useState(false); - useEffect(() => { - void browser.storage.local.get("ui-hide-zero").then((r) => setHideZero(Boolean(r["ui-hide-zero"]))); - }, []); - const toggleHideZero = () => { - setHideZero((v) => { - void browser.storage.local.set({ "ui-hide-zero": !v }); - return !v; - }); - }; - const chainId = state.chainId; - // Guards in-flight responses: after an account/chain switch, a slow reply for - // the OLD pair must not overwrite the new view (wrong-chain explorer links). - const epochRef = useRef(0); - const tickRef = useRef(0); - // silent=true refreshes values in place (no skeleton flicker) for live updates. - const loadBal = useCallback((silent = false) => { - const epoch = ++epochRef.current; - const live = () => epochRef.current === epoch; - if (!silent) { - setBal(undefined); - setTokens(undefined); - setPrices(null); - setNfts(undefined); - setHistory(undefined); - } - wallet<{ formatted: string }>({ type: "getBalance", address }) - .then((b) => live() && setBal(b.formatted)) - .catch(() => !silent && live() && setBal(null)); // unreachable, not zero - wallet({ type: "getTokens", address }).then((ts) => { - if (!live()) return; - setTokens((prev) => { - // Prices barely move tick to tick (CoinGecko rate limit), but a token - // that APPEARS mid-session still needs its first quote. - const newAddrs = ts.some((t) => !(prev ?? []).some((p) => p.address.toLowerCase() === t.address.toLowerCase())); - if (!silent || newAddrs || tickRef.current % 4 === 0) { - wallet({ type: "getPrices", chainId, addresses: ts.map((t) => t.address) }).then((p) => live() && setPrices(p)).catch(() => {}); - } - return ts; - }); - }).catch(() => !silent && live() && setTokens(null)); - // Stale-while-revalidate: paint the cache instantly, then fetch fresh. - if (!silent) { - wallet<{ items: HistoryItem[] | null }>({ type: "getHistory", chainId, address }) - .then((r) => live() && r.items !== null && setHistory(r.items)) - .catch(() => {}); - } - wallet<{ items: HistoryItem[] | null }>({ type: "getHistory", chainId, address, refresh: true }) - .then((r) => { - if (!live()) return; - if (r.items !== null) setHistory(r.items); - else if (!silent) setHistory((h) => h ?? null); // unreachable + no cache -> error state - }) - .catch(() => {}); - }, [address, chainId]); - useEffect(() => loadBal(), [loadBal]); - // A dapp can switch the chain while the popup is open: follow it live so the - // header, balances, and history never show mixed-chain data. - useEffect(() => { - const listener = (changes: Record, area: string) => { - if (area === "local" && "selected-chain" in changes) onChange(); - }; - browser.storage.onChanged.addListener(listener); - return () => browser.storage.onChanged.removeListener(listener); - }, [onChange]); - // The user should never have to reopen the popup to see a received payment. - // refreshTick also re-fires the DAO/Worker cards (their balances move too). - const [refreshTick, setRefreshTick] = useState(0); - useEffect(() => { - const t = setInterval(() => { tickRef.current += 1; setRefreshTick(tickRef.current); loadBal(true); }, 15000); - return () => clearInterval(t); - }, [loadBal]); - const loadNfts = useCallback(() => { - setNfts(undefined); - wallet({ type: "getNfts", chainId, owner: address }).then(setNfts).catch(() => setNfts(null)); - }, [chainId, address]); - useEffect(() => { - if (tab === "nfts" && nfts === undefined) loadNfts(); - }, [tab, nfts, loadNfts]); - const usd = (sym: string, amount: number, addr?: string) => { - if (!prices) return null; - const price = addr ? prices.tokenUsd[addr.toLowerCase()] : prices.nativeUsd; - return price ? fmtUsd(price * amount) : null; - }; - const total = prices && typeof bal === "string" ? portfolioUsd(Number(bal), prices, (tokens ?? []).filter((t) => t.balance !== null).map((t) => ({ address: t.address, balance: Number(t.balance) }))) : 0; - - const copy = () => { - void navigator.clipboard.writeText(address); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - }; - const switchChain = (id: number) => void wallet({ type: "setChain", chainId: id }).then(onChange); - const selectAccount = (i: number) => void wallet({ type: "setActiveAccount", index: i }).then(onChange); - const addAccount = () => void wallet({ type: "addAccount" }).then(onChange); - const rename = (i: number, name: string) => void wallet({ type: "setAccountName", index: i, name }).then(onChange); - const acctName = (i: number) => state.names?.[i]?.trim() || `Account ${i + 1}`; - const chg = (addr?: string): number | null => { - if (!prices) return null; - const c = addr ? prices.tokenChange24h[addr.toLowerCase()] : prices.nativeChange24h; - return typeof c === "number" ? c : null; - }; - const assets: Asset[] = [ - { kind: "native", symbol: sym, balance: typeof bal === "string" ? bal : null }, - // Flagged spam never reaches the Send/Swap pickers. - ...(tokens ?? []).filter((t) => !t.spam).map((t) => ({ kind: "token" as const, symbol: t.symbol, address: t.address, decimals: t.decimals, balance: t.balance })), - ]; - - return ( - <> -
-
- - {acctOpen && ( - { setAcctOpen(false); setAvatarFor(a); }} onClose={() => setAcctOpen(false)} /> - )} -
- - - {isExpanded() ? ( - - ) : ( - - )} - - -
- -
-
{chain.name}
-
- {bal === undefined ? ( - - ) : ( - {bal === null ? "--" : fmtBal(bal)} - )} - {sym} -
- {total > 0 &&
≈ {fmtUsd(total)} total
} - -
- -
- - - - - Explorer -
- -
- - - -
- - {tab === "tokens" && ( -
-
- -
- {chain.nativeCurrency.name} -
{sym}{chg() != null && }
-
-
- {bal === undefined ? : bal === null ? -- : {fmtBal(bal)}} - {typeof bal === "string" && usd(sym, Number(bal)) &&
{usd(sym, Number(bal))}
} -
-
- {tokens === undefined && [0, 1].map((i) => ( -
- ))} - {tokens === null &&

Could not refresh token balances. They will retry automatically.

} - {(tokens ?? []).filter((t) => !t.spam).filter((t) => !hideZero || t.balance === null || Number(t.balance) > 0).map((t) => ( -
- {tokenLogo(t.address) ? : {t.symbol.slice(0, 2)}} -
- {t.symbol}{t.discovered && auto} -
{short(t.address)}{chg(t.address) != null && }
-
-
- {t.balance === null ? -- : {fmtBal(t.balance)}} - {t.balance !== null && usd(t.symbol, Number(t.balance), t.address) &&
{usd(t.symbol, Number(t.balance), t.address)}
} -
- -
- ))} - {(tokens ?? []).some((t) => t.spam) && ( -
- - {showSpam && (tokens ?? []).filter((t) => t.spam).map((t) => ( -
- {t.symbol.slice(0, 2)} -
- {t.symbol}likely scam -
{t.spam}
-
- -
- ))} -
- )} -
- - -
-
- )} - {tab === "nfts" && ( - setSheet("importNft")} onOpen={setNftSel} /> - )} - {tab === "activity" && loadBal()} />} - - setSheet("worker")} /> - - setSheet("dao")} /> - - {sheet === "send" && setSheet(null)} onSent={loadBal} />} - {sheet === "receive" && setSheet(null)} />} - {sheet === "settings" && setSheet(null)} onRemoved={onChange} onChanged={onChange} />} - {sheet === "swap" && setSheet(null)} onDone={loadBal} />} - {sheet === "dao" && setSheet(null)} />} - {sheet === "worker" && setSheet(null)} />} - {sheet === "chat" && setSheet(null)} />} - {avatarFor && setAvatarFor(null)} />} - {sheet === "importToken" && setSheet(null)} onDone={() => { setSheet(null); loadBal(); }} />} - {sheet === "importNft" && setSheet(null)} onDone={() => { setSheet(null); loadNfts(); }} />} - {nftSel && setNftSel(null)} onChanged={() => { setNftSel(null); loadNfts(); }} />} - - ); -} - - -function GovernanceCard({ chainId, address, refreshKey, onOpen }: { chainId: number; address: string; refreshKey: number; onOpen: () => void }) { - const [st, setSt] = useState(null); - // Chain/account changed: drop the old chain's data (no stale cross-chain power). - useEffect(() => setSt(null), [chainId, address]); - useEffect(() => { - let live = true; - // refreshKey re-fires this silently: voting power updates without a reopen. - wallet({ type: "daoStatus", chainId, address }).then((r) => { if (live) setSt(r); }).catch(() => {}); - return () => { live = false; }; - }, [chainId, address, refreshKey]); - const power = st && st.supported ? Number(st.votingPower) : null; - return ( -
-
-

Governance

- {power != null && {power.toLocaleString(undefined, { maximumFractionDigits: 2 })} votes} -
- {power != null && power > 0 && st && !st.delegated &&

Not delegated yet, so these votes do not count. Delegate on the DAO.

} - -
- ); -} - -function WorkerCard({ address, refreshKey, onOpen }: { address: string; refreshKey: number; onOpen: () => void }) { - const [s, setS] = useState(null); - useEffect(() => setS(null), [address]); // new account: do not show the old one's stake - useEffect(() => { - let live = true; - wallet({ type: "workerStatus", address }).then((r) => { if (live) setS(r); }).catch(() => {}); - return () => { live = false; }; - }, [address, refreshKey]); - return ( -
-
-

Worker

- {s?.registered && registered} -
- {s?.registered ? ( -
- Claimable - 0 ? "ok" : "addr"}>{s.claimableLcai.toLocaleString(undefined, { maximumFractionDigits: 2 })} LCAI -
- ) : ( -

Run a LightChain AI worker and earn LCAI for inference jobs.

- )} - -
- ); -} -function AccountMenu({ accounts, names, avatars, activeIndex, onSelect, onAdd, onRename, onAvatar, onClose }: { accounts: string[]; names: string[]; avatars: Record; activeIndex: number; onSelect: (i: number) => void; onAdd: () => void; onRename: (i: number, name: string) => void; onAvatar: (address: string) => void; onClose: () => void }) { - const [editing, setEditing] = useState(null); - const [draft, setDraft] = useState(""); - const save = (i: number) => { - onRename(i, draft); - setEditing(null); - }; - return ( - <> -
-
- {accounts.map((a, i) => ( -
- {editing === i ? ( - <> - setDraft(e.target.value)} - onBlur={() => save(i)} - onKeyDown={(e) => { if (e.key === "Enter") save(i); if (e.key === "Escape") setEditing(null); }} /> - - - ) : ( - <> - - {i === activeIndex && } - - - - )} -
- ))} - -
- - ); -} -type HistoryFilter = "all" | "in" | "out" | "nft"; -const HISTORY_FILTERS: { key: HistoryFilter; label: string }[] = [ - { key: "all", label: "All" }, - { key: "in", label: "Received" }, - { key: "out", label: "Sent" }, - { key: "nft", label: "NFTs" }, -]; - -function HistoryRow({ h, explorer, owner, labels, onChanged }: { h: HistoryItem; explorer: string; owner: string; labels: Record; onChanged: () => void }) { - const inbound = h.direction === "in"; - const self = h.direction === "self"; - const [busy, setBusy] = useState(false); - const [rowErr, setRowErr] = useState(null); - const title = h.kind === "contract" ? h.label : self ? `Self transfer · ${h.label}` : `${inbound ? "Received" : "Sent"} ${h.label}`; - const amountClass = h.failed ? "strike" : inbound ? "ok" : ""; - const sign = self ? "" : inbound ? "+" : "-"; - const replace = async (mode: "speedup" | "cancel") => { - setBusy(true); - setRowErr(null); - try { - await wallet({ type: "replaceTx", from: owner, hash: h.hash, mode }); - onChanged(); - } catch (e) { - setRowErr(humanizeError((e as Error).message, h.label)); - } finally { - setBusy(false); - } - }; - const actionable = h.pending && !h.failed && h.direction === "out"; - const body = ( - <> - -
- {title} -
- {self ? "To yourself" : `${inbound ? "From" : "To"} ${h.counterparty ? labels[h.counterparty.toLowerCase()] ?? short(h.counterparty) : "contract"}`} · {timeAgo(h.ts)} - {h.failed && failed} - {h.pending && !h.failed && pending} -
-
- {h.kind === "nft" ? ( - NFT - ) : h.amount ? ( - {sign}{fmtBal(h.amount)} - ) : null} - - ); - if (!actionable) { - return ( - - {body} - - ); - } - // Pending own tx: same row plus inline rescue actions (no explorer-only dead end). - return ( -
- {body} -
- - - View -
- {rowErr &&

{rowErr}

} -
- ); -} - -function HistoryList({ items, explorer, owner, labels, filter, onFilter, onRetry }: { items: HistoryItem[] | null | undefined; explorer: string; owner: string; labels: Record; filter: HistoryFilter; onFilter: (f: HistoryFilter) => void; onRetry: () => void }) { - if (items === undefined) { - return ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- ); - } - if (items === null) { - return ( -
-
- Could not reach the network explorer, so your history is unknown right now. - -
- ); - } - const shown = items.filter((h) => { - if (filter === "all") return true; - if (filter === "nft") return h.kind === "nft"; - return h.direction === filter || h.direction === "self"; - }); - return ( -
-
- {HISTORY_FILTERS.map((f) => ( - - ))} -
- {shown.length === 0 ? ( -
-
- {filter === "all" ? "No activity yet on this network." : `Nothing ${filter === "nft" ? "NFT-related" : filter === "in" ? "received" : "sent"} yet on this network.`} -
- ) : ( - shown.map((h) => ) - )} -
- ); -} -function NetworkSwitcher({ chainId, onSwitch }: { chainId: number; onSwitch: (id: number) => void }) { - const [open, setOpen] = useState(false); - const [hideTestnets, setHideTestnets] = useState(false); - useEffect(() => { - if (open) void browser.storage.local.get("ui-hide-testnets").then((r) => setHideTestnets(Boolean(r["ui-hide-testnets"]))); - }, [open]); - const visibleChains = CHAIN_LIST.filter((c) => !hideTestnets || !c.testnet || c.id === chainId); - return ( -
- - {open && ( - <> -
setOpen(false)} /> -
- {visibleChains.map((c) => ( - - ))} -
- - )} -
- ); -} diff --git a/wallet/entrypoints/popup/index.html b/wallet/entrypoints/popup/index.html deleted file mode 100644 index a64d084..0000000 --- a/wallet/entrypoints/popup/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - LightNode Wallet - - -
- - - diff --git a/wallet/entrypoints/popup/main.tsx b/wallet/entrypoints/popup/main.tsx deleted file mode 100644 index 2d77cdf..0000000 --- a/wallet/entrypoints/popup/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./App"; -import "./styles.css"; - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/wallet/entrypoints/popup/onboarding.tsx b/wallet/entrypoints/popup/onboarding.tsx deleted file mode 100644 index 83f774f..0000000 --- a/wallet/entrypoints/popup/onboarding.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/** First-run + unlock flows. */ -import { useState } from "react"; -import { createMnemonic, isValidMnemonic, normalizeMnemonic } from "../../src/keyring/mnemonic"; -import { wallet, type WalletState } from "./wallet-api"; -import { Ic, avatarGradient, short } from "./shared"; - -export function Onboarding({ onDone }: { onDone: () => void }) { - const [mode, setMode] = useState<"choose" | "create" | "import">("choose"); - if (mode === "create") return setMode("choose")} />; - if (mode === "import") return setMode("choose")} />; - return ( -
-
-

LightNode Wallet

-

A self-custodial wallet for LightChain and EVM networks. Your keys are generated and encrypted on this device, and never leave it.

-
- - -
-

No account, no server, no custody.

-
- ); -} -function BackBar({ title, onBack }: { title: string; onBack: () => void }) { - return ( -
- -

{title}

-
- ); -} -/** Pick 3 random word positions; each gets 6 shuffled candidates from the phrase. */ -function makeQuiz(words: string[]): { pos: number; options: string[] }[] { - const positions: number[] = []; - while (positions.length < 3) { - const p = Math.floor(Math.random() * words.length); - if (!positions.includes(p)) positions.push(p); - } - return positions.sort((a, b) => a - b).map((pos) => { - const options = new Set([words[pos]!]); - while (options.size < Math.min(6, new Set(words).size)) { - options.add(words[Math.floor(Math.random() * words.length)]!); - } - return { pos, options: [...options].sort(() => Math.random() - 0.5) }; - }); -} - -function VerifyStep({ words, onVerified, onBack }: { words: string[]; onVerified: () => void; onBack: () => void }) { - const [quiz] = useState(() => makeQuiz(words)); - const [picked, setPicked] = useState>({}); - const [failed, setFailed] = useState(false); - const allPicked = quiz.every((q) => picked[q.pos]); - const check = () => { - const ok = quiz.every((q) => picked[q.pos] === words[q.pos]); - if (ok) onVerified(); - else { - setFailed(true); - setPicked({}); - } - }; - return ( -
- -

Pick the right word for each position. This is the only proof your backup actually exists.

- {quiz.map((q) => ( -
-
Word #{q.pos + 1}
-
- {q.options.map((w) => ( - - ))} -
-
- ))} - {failed &&

Not quite. Check your written backup and try again.

} - -
- ); -} - -function CreateFlow({ onDone, onBack }: { onDone: () => void; onBack: () => void }) { - const [mnemonic] = useState(createMnemonic); - const [step, setStep] = useState<"phrase" | "verify" | "password" | "ready">("phrase"); - const [address, setAddress] = useState(""); - const [reveal, setReveal] = useState(false); - const [saved, setSaved] = useState(false); - const [pw, setPw] = useState(""); - const [pw2, setPw2] = 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 }); - const st = await wallet({ type: "getState" }); - setAddress(st.accounts[0] ?? ""); - setStep("ready"); - } catch (e) { - setErr((e as Error).message); - setBusy(false); - } - }; - if (step === "ready") return ; - if (step === "verify") return setStep("password")} onBack={() => setStep("phrase")} />; - if (step === "password") { - const mismatch = pw2.length > 0 && pw !== pw2; - return ( -
- setStep("verify")} /> -

It unlocks the wallet on this device. Your recovery phrase stays the master key.

- setPw(e.target.value)} /> - setPw2(e.target.value)} /> - {mismatch &&

The passwords do not match.

} - {err &&

{err}

} - -
- ); - } - return ( -
- -

Write these 24 words down offline and keep them safe. Anyone with them controls your funds, and we can never recover them.

-
- {mnemonic.split(" ").map((w, i) => ( -
{i + 1}{w}
- ))} - {!reveal && } -
- - {err &&

{err}

} - -
- ); -} -function ImportFlow({ onDone, onBack }: { onDone: () => void; onBack: () => void }) { - const [mnemonic, setMnemonic] = useState(""); - const [step, setStep] = useState<"form" | "ready">("form"); - const [address, setAddress] = useState(""); - const [pw, setPw] = useState(""); - const [pw2, setPw2] = useState(""); - const [busy, setBusy] = useState(false); - const [err, setErr] = useState(null); - const valid = isValidMnemonic(mnemonic); - const mismatch = pw2.length > 0 && pw !== pw2; - const importIt = async () => { - setBusy(true); - setErr(null); - try { - await wallet({ type: "importVault", mnemonic: normalizeMnemonic(mnemonic), password: pw }); - const st = await wallet({ type: "getState" }); - setAddress(st.accounts[0] ?? ""); - setStep("ready"); - } catch (e) { - setErr((e as Error).message); - setBusy(false); - } - }; - if (step === "ready") return ; - return ( -
- -

Enter your 12 or 24 word recovery phrase. It is processed only on this device.

-