diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..cffe8cd --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..dd0cebd --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.6 diff --git a/README.md b/README.md index 0214c1d..c83779c 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,68 @@ npm run build ## WASM Modules -WASM binaries are **pre-built and included** in `extension/lib/`. No build required. +WASM binaries are **pre-built and included** in `@nockbox/iris-sdk`. No build required. + +## Local development: publishing `@nockbox/iris-wasm` / `@nockbox/iris-sdk` to a local npm registry + +When you make changes to `@nockbox/iris-wasm`, **do not use** `file:` dependencies for Iris. Instead, publish to a **local npm registry** and consume via normal semver versions (so the repo can be checked in using npm-style deps). + +### One-time setup (Verdaccio) + +Start a local registry: + +```bash +npm i -g verdaccio +verdaccio --listen 4873 +``` + +Point only the `@nockbox` scope at Verdaccio: + +```bash +npm config set @nockbox:registry http://localhost:4873 +``` + +Create a local registry user and login: + +```bash +npm adduser --registry http://localhost:4873 +npm login --registry http://localhost:4873 +``` + +### Publish workflow + +1. **Publish `@nockbox/iris-wasm`** from your local `iris-wasm` repo checkout: + +```bash +# in the iris-wasm package directory +npm version 0.1.3 --no-git-tag-version +npm publish --registry http://localhost:4873 +``` + +2. **Bump + publish `@nockbox/iris-sdk`** (this repo’s `sdk/`): + +```bash +cd sdk +# update sdk/package.json: +# - "version": "0.1.2" +# - "@nockbox/iris-wasm": "0.1.3" +npm run build +npm publish --registry http://localhost:4873 +``` + +3. **Consume in Iris** using normal npm deps (no `file:`): + +```bash +cd .. +# update package.json: +# - "@nockbox/iris-sdk": "0.1.2" +npm install +``` + +### Verify what you’re using + +```bash +npm view @nockbox/iris-wasm version --registry http://localhost:4873 +npm view @nockbox/iris-sdk version --registry http://localhost:4873 +npm ls @nockbox/iris-sdk @nockbox/iris-wasm +``` diff --git a/extension/background/index.ts b/extension/background/index.ts index 831d37d..45e2dd7 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -6,6 +6,7 @@ import { Vault } from '../shared/vault'; import { isNockAddress } from '../shared/validators'; +import { initIrisSdkOnce } from '../shared/wasm-utils'; import { PROVIDER_METHODS, INTERNAL_METHODS, @@ -13,6 +14,7 @@ import { ALARM_NAMES, AUTOLOCK_MINUTES, STORAGE_KEYS, + SESSION_STORAGE_KEYS, USER_ACTIVITY_METHODS, UI_CONSTANTS, APPROVAL_CONSTANTS, @@ -25,7 +27,14 @@ import type { SignRawTxRequest, } from '../shared/types'; +function isRecord(x: unknown): x is Record { + return typeof x === 'object' && x !== null; +} + const vault = new Vault(); +// Ensure WASM is initialized once per service worker context. +// Some background flows (message routing, tx handling) require WASM to be ready. +const wasmInitPromise = initIrisSdkOnce(); let lastActivity = Date.now(); let autoLockMinutes = AUTOLOCK_MINUTES; let manuallyLocked = false; // Track if user manually locked (don't auto-unlock) @@ -55,13 +64,112 @@ const REQUEST_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes */ let isRpcConnected = true; +type UnlockSessionCache = { + key: number[]; +}; + +let sessionRestorePromise: Promise | null = null; + +async function clearUnlockSessionCache(): Promise { + try { + await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE); + } catch (error) { + console.error('[Background] Failed to clear unlock cache:', error); + } +} + +async function persistUnlockSession(): Promise { + const sessionStorage = chrome.storage.session; + if (!sessionStorage || vault.isLocked()) { + return; + } + + const encryptionKey = vault.getEncryptionKey(); + if (!encryptionKey) { + return; + } + + try { + const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', encryptionKey)); + await sessionStorage.set({ + [SESSION_STORAGE_KEYS.UNLOCK_CACHE]: Array.from(rawKey), + }); + } catch (error) { + console.error('[Background] Failed to persist unlock session:', error); + } +} + +async function restoreUnlockSession(): Promise { + const sessionStorage = chrome.storage.session; + if (!sessionStorage) { + return; + } + + const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]); + const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as UnlockSessionCache['key'] | undefined; + + if (!cached || cached.length === 0) { + return; + } + + // Respect manual lock - never auto-unlock if user explicitly locked + if (manuallyLocked) { + await clearUnlockSessionCache(); + return; + } + + // Respect auto-lock timeout window + if (autoLockMinutes > 0) { + const idleMs = Date.now() - lastActivity; + if (idleMs >= autoLockMinutes * 60_000) { + await clearUnlockSessionCache(); + return; + } + } + + try { + const key = await crypto.subtle.importKey( + 'raw', + new Uint8Array(cached), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + const result = await vault.unlockWithKey(key); + if ('error' in result) { + await clearUnlockSessionCache(); + } + } catch (error) { + console.error('[Background] Failed to restore unlock session:', error); + await clearUnlockSessionCache(); + } +} + +async function ensureSessionRestored(): Promise { + if (!vault.isLocked()) { + return; + } + + if (!sessionRestorePromise) { + sessionRestorePromise = restoreUnlockSession().finally(() => { + sessionRestorePromise = null; + }); + } + + await sessionRestorePromise; +} + /** * Load approved origins from storage */ async function loadApprovedOrigins(): Promise { - const stored = await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS]); - const origins = stored[STORAGE_KEYS.APPROVED_ORIGINS] || []; - approvedOrigins = new Set(origins); + const stored = (await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS])) as Record< + string, + unknown + >; + const raw = stored[STORAGE_KEYS.APPROVED_ORIGINS]; + const origins = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []; + approvedOrigins = new Set(origins); } /** @@ -137,6 +245,9 @@ interface PendingRequest { } const pendingRequests = new Map(); +// v0 migration provider methods (string-literal; not yet in published iris-sdk) +const MIGRATE_V0_GET_STATUS = 'nock_migrateV0GetStatus'; +const MIGRATE_V0_SIGN_RAW_TX = 'nock_migrateV0SignRawTx'; /** * Type guard to check if a request is a ConnectRequest @@ -275,7 +386,7 @@ async function createApprovalPopup( focused: true, }); - approvalWindowId = newWindow.id || null; + approvalWindowId = newWindow?.id ?? null; } finally { isCreatingWindow = false; } @@ -324,25 +435,33 @@ async function emitWalletEvent(eventType: string, data: unknown) { } } -// Initialize auto-lock setting, load approved origins, vault state, connection monitoring, and schedule alarms -(async () => { - const stored = await chrome.storage.local.get([ +// Initialize auto-lock setting, load approved origins, vault state, connection monitoring, and schedule alarms. +// IMPORTANT: this promise is awaited by message and alarm handlers to prevent race conditions on SW start. +const initPromise = (async () => { + await wasmInitPromise; + const stored = (await chrome.storage.local.get([ STORAGE_KEYS.AUTO_LOCK_MINUTES, STORAGE_KEYS.LAST_ACTIVITY, STORAGE_KEYS.MANUALLY_LOCKED, - ]); + ])) as Record; const storedMinutes = stored[STORAGE_KEYS.AUTO_LOCK_MINUTES]; autoLockMinutes = typeof storedMinutes === 'number' ? storedMinutes : Number(storedMinutes) || 0; // Load persisted lastActivity (survives SW restarts), fallback to now if not set - lastActivity = stored[STORAGE_KEYS.LAST_ACTIVITY] ?? Date.now(); + const storedLastActivity = stored[STORAGE_KEYS.LAST_ACTIVITY]; + lastActivity = + typeof storedLastActivity === 'number' + ? storedLastActivity + : Number(storedLastActivity) || Date.now(); // Load persisted manuallyLocked state manuallyLocked = Boolean(stored[STORAGE_KEYS.MANUALLY_LOCKED]); await loadApprovedOrigins(); await vault.init(); // Load encrypted vault header to detect vault existence + await restoreUnlockSession(); // Rehydrate unlock state if still within auto-lock window + // Only schedule alarm if auto-lock is enabled, otherwise ensure any stale alarm is cleared if (autoLockMinutes > 0) { scheduleAlarm(); @@ -389,6 +508,8 @@ function isFromPopup(sender: chrome.runtime.MessageSender): boolean { */ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { (async () => { + await initPromise; + await ensureSessionRestored(); const { payload } = msg || {}; await touchActivity(payload?.method); @@ -479,6 +600,66 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Response will be sent when user approves/rejects return; + case MIGRATE_V0_GET_STATUS: { + const origin = _sender.url || _sender.origin || ''; + if (!isOriginApproved(origin)) { + sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + sendResponse({ ok: true, hasV0Seedphrase: vault.hasV0Seedphrase() }); + return; + } + + case MIGRATE_V0_SIGN_RAW_TX: { + const origin = _sender.url || _sender.origin || ''; + if (!isOriginApproved(origin)) { + sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + const rawTxParams = payload.params?.[0]; + if ( + !rawTxParams || + !rawTxParams.rawTx || + !rawTxParams.notes || + !rawTxParams.spendConditions + ) { + sendResponse({ error: { code: -32602, message: 'Invalid params' } }); + return; + } + const derivation = rawTxParams.derivation || 'master'; + const outputs = await vault.computeOutputs(rawTxParams.rawTx); + + const signRawTxId = crypto.randomUUID(); + const signRawTxRequest: any = { + id: signRawTxId, + origin, + rawTx: rawTxParams.rawTx, + notes: rawTxParams.notes, + spendConditions: rawTxParams.spendConditions, + outputs: outputs, + timestamp: Date.now(), + signWith: 'v0', + v0Derivation: derivation, + }; + + pendingRequests.set(signRawTxId, { + request: signRawTxRequest, + sendResponse, + origin: signRawTxRequest.origin, + }); + + await createApprovalPopup(signRawTxId, 'sign-raw-tx'); + return; + } + case PROVIDER_METHODS.SIGN_RAW_TX: // Validate origin const signRawTxOrigin = _sender.url || _sender.origin || ''; @@ -621,6 +802,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Clear manual lock flag when successfully unlocked manuallyLocked = false; await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + await persistUnlockSession(); await emitWalletEvent('connect', { chainId: 'nockchain-1' }); } return; @@ -630,6 +812,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { manuallyLocked = true; await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true }); await vault.lock(); + await clearUnlockSessionCache(); sendResponse({ ok: true }); // Emit disconnect event when wallet locks @@ -639,6 +822,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case INTERNAL_METHODS.RESET_WALLET: // Reset the wallet completely - clears all data await vault.reset(); + await clearUnlockSessionCache(); manuallyLocked = false; sendResponse({ ok: true }); @@ -648,7 +832,14 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case INTERNAL_METHODS.SETUP: // params: password, mnemonic (optional). If no mnemonic, generates one automatically. - sendResponse(await vault.setup(payload.params?.[0], payload.params?.[1])); + const setupResult = await vault.setup(payload.params?.[0], payload.params?.[1]); + sendResponse(setupResult); + + if ('ok' in setupResult && setupResult.ok) { + manuallyLocked = false; + await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + await persistUnlockSession(); + } return; case INTERNAL_METHODS.GET_STATE: @@ -723,6 +914,40 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case INTERNAL_METHODS.HAS_V0_SEEDPHRASE: + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + sendResponse({ ok: true, has: vault.hasV0Seedphrase() }); + return; + + case INTERNAL_METHODS.SET_V0_SEEDPHRASE: { + const seedphrase = payload.params?.[0]; + const passphrase = payload.params?.[1]; + if (!seedphrase) { + sendResponse({ error: { code: -32602, message: 'Invalid params' } }); + return; + } + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + const res = await vault.setV0Seedphrase({ seedphrase, passphrase }); + sendResponse(res); + return; + } + + case INTERNAL_METHODS.CLEAR_V0_SEEDPHRASE: { + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + const res = await vault.clearV0Seedphrase(); + sendResponse(res); + return; + } + case INTERNAL_METHODS.GET_MNEMONIC: // params: password (required for verification) sendResponse(await vault.getMnemonic(payload.params?.[0])); @@ -918,11 +1143,19 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } try { - const signature = await vault.signRawTx({ - rawTx: signRawTxRequest.rawTx, - notes: signRawTxRequest.notes, - spendConditions: signRawTxRequest.spendConditions, - }); + const signature = + signRawTxRequest.signWith === 'v0' + ? await vault.signRawTxV0({ + rawTx: signRawTxRequest.rawTx, + notes: signRawTxRequest.notes, + spendConditions: signRawTxRequest.spendConditions, + derivation: signRawTxRequest.v0Derivation || 'master', + }) + : await vault.signRawTx({ + rawTx: signRawTxRequest.rawTx, + notes: signRawTxRequest.notes, + spendConditions: signRawTxRequest.spendConditions, + }); approveSignRawTxPending.sendResponse(signature); cancelPendingRequest(approveSignRawTxId); processNextRequest(); @@ -1233,6 +1466,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { chrome.alarms.onAlarm.addListener(async alarm => { if (alarm.name !== ALARM_NAMES.AUTO_LOCK) return; + await initPromise; + await ensureSessionRestored(); + // Don't auto-lock if set to "never" (0 minutes) - stop the alarm cycle if (autoLockMinutes <= 0) { chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); @@ -1248,6 +1484,7 @@ chrome.alarms.onAlarm.addListener(async alarm => { if (idleMs >= autoLockMinutes * 60_000) { try { await vault.lock(); + await clearUnlockSessionCache(); // Notify popup to update UI immediately await emitWalletEvent('LOCKED', { reason: 'auto-lock' }); } catch (error) { diff --git a/extension/lib/README.md b/extension/lib/README.md deleted file mode 100644 index 98cbdf7..0000000 --- a/extension/lib/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# WASM Modules - -This directory contains WebAssembly modules used by the wallet extension. - -## Modules - -### nbx-wasm - -**Source**: `github.com/nockbox/wallet` -**Purpose**: Transaction building, gRPC client, address derivation, first-name derivation, message signing - -## Building & Updating - -### Prerequisites - -```bash -# Install Rust -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - -# Install wasm-pack -cargo install wasm-pack -``` - -### Update nbx-wasm - -```bash -# Clone/update wallet repo (if needed) -git clone https://github.com/nockbox/wallet.git -# or: cd wallet && git pull - -# Build from wallet repo -cd wallet/crates/nbx-wasm -wasm-pack build --target web --out-dir ../../pkg - -# Copy to project (adjust path as needed) -rsync -av --delete ../../pkg/ /path/to/project/extension/lib/nbx-wasm/ - -# Rebuild extension -npm run build -``` - -## What Uses What - -- **Transactions**: nbx-wasm (TxBuilder, GrpcClient) -- **Balance Queries**: nbx-wasm (GrpcClient, SpendCondition.firstName) -- **Address Derivation**: nbx-wasm (hashPublicKey, deriveMasterKeyFromMnemonic) -- **Message Signing**: nbx-wasm (signMessage) - -**Initialization:** - -- nbx-wasm: `extension/shared/wasm-utils.ts` -- First-name derivation: `extension/shared/first-name-derivation.ts` diff --git a/extension/manifest.json b/extension/manifest.json index e61e198..5c42532 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Iris Wallet", "homepage_url": "https://iriswallet.io", - "version": "0.1.0", + "version": "0.1.2", "description": "Iris Wallet - Browser Wallet for Nockchain", "icons": { "16": "icons/icon16.png", diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index ff184a7..d3f2c9d 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -35,6 +35,7 @@ import { WalletSettingsScreen } from './screens/WalletSettingsScreen'; import { WalletStylingScreen } from './screens/WalletStylingScreen'; import { AboutScreen } from './screens/AboutScreen'; import { RecoveryPhraseScreen } from './screens/RecoveryPhraseScreen'; +import { V0MigrationScreen } from './screens/V0MigrationScreen'; export function Router() { const { currentScreen } = useStore(); @@ -82,6 +83,8 @@ export function Router() { return ; case 'recovery-phrase': return ; + case 'v0-migration': + return ; // Transactions case 'send': diff --git a/extension/popup/components/AccountSelector.tsx b/extension/popup/components/AccountSelector.tsx index 66ada89..4d9c9e0 100644 --- a/extension/popup/components/AccountSelector.tsx +++ b/extension/popup/components/AccountSelector.tsx @@ -10,6 +10,7 @@ import { useAutoFocus } from '../hooks/useAutoFocus'; import { useClickOutside } from '../hooks/useClickOutside'; import { INTERNAL_METHODS } from '../../shared/constants'; import { Account } from '../../shared/types'; +import { formatWalletError } from '../utils/formatWalletError'; import { ChevronDownIcon } from './icons/ChevronDownIcon'; import { PlusIcon } from './icons/PlusIcon'; import { UploadIcon } from './icons/UploadIcon'; @@ -70,7 +71,7 @@ export function AccountSelector() { }; syncWallet(updatedWallet); } else if (result?.error) { - alert(`Failed to create account: ${result.error}`); + alert(`Failed to create account: ${formatWalletError(result.error)}`); } setIsOpen(false); @@ -125,7 +126,7 @@ export function AccountSelector() { currentAccount: updatedCurrentAccount, }); } else if (result?.error) { - alert(`Failed to rename account: ${result.error}`); + alert(`Failed to rename account: ${formatWalletError(result.error)}`); } cancelEditing(); diff --git a/extension/popup/screens/HomeScreen.tsx b/extension/popup/screens/HomeScreen.tsx index fb7897a..f97b970 100644 --- a/extension/popup/screens/HomeScreen.tsx +++ b/extension/popup/screens/HomeScreen.tsx @@ -127,7 +127,8 @@ export function HomeScreen() { // Load balance hidden preference on mount useEffect(() => { chrome.storage.local.get([STORAGE_KEYS.BALANCE_HIDDEN]).then(result => { - setBalanceHidden(result[STORAGE_KEYS.BALANCE_HIDDEN] ?? false); + const raw = (result as Record)[STORAGE_KEYS.BALANCE_HIDDEN]; + setBalanceHidden(typeof raw === 'boolean' ? raw : Boolean(raw)); }); }, []); diff --git a/extension/popup/screens/KeySettingsPasswordScreen.tsx b/extension/popup/screens/KeySettingsPasswordScreen.tsx index ffe264f..e0fe690 100644 --- a/extension/popup/screens/KeySettingsPasswordScreen.tsx +++ b/extension/popup/screens/KeySettingsPasswordScreen.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; import { useStore } from '../store'; import { send } from '../utils/messaging'; -import { INTERNAL_METHODS, ERROR_CODES } from '../../shared/constants'; +import { INTERNAL_METHODS } from '../../shared/constants'; import IrisLogo96 from '../assets/iris-logo-96.svg'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { EyeIcon } from '../components/icons/EyeIcon'; import { EyeOffIcon } from '../components/icons/EyeOffIcon'; +import { formatWalletError } from '../utils/formatWalletError'; export function KeySettingsPasswordScreen() { const { navigate, setOnboardingMnemonic } = useStore(); @@ -34,11 +35,7 @@ export function KeySettingsPasswordScreen() { ); if (result?.error) { - setError( - result.error === ERROR_CODES.BAD_PASSWORD - ? 'Incorrect password' - : `Error: ${result.error}` - ); + setError(formatWalletError(result.error)); setPassword(''); } else if (result?.mnemonic) { // Store mnemonic temporarily for viewing diff --git a/extension/popup/screens/RecoveryPhraseScreen.tsx b/extension/popup/screens/RecoveryPhraseScreen.tsx index 9b06917..0fa1f28 100644 --- a/extension/popup/screens/RecoveryPhraseScreen.tsx +++ b/extension/popup/screens/RecoveryPhraseScreen.tsx @@ -9,9 +9,10 @@ import { ScreenContainer } from '../components/ScreenContainer'; import { Alert } from '../components/Alert'; import { PasswordInput } from '../components/PasswordInput'; import { send } from '../utils/messaging'; -import { INTERNAL_METHODS, ERROR_CODES } from '../../shared/constants'; +import { INTERNAL_METHODS } from '../../shared/constants'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { EyeIcon } from '../components/icons/EyeIcon'; +import { formatWalletError } from '../utils/formatWalletError'; export function RecoveryPhraseScreen() { const { navigate } = useStore(); @@ -34,11 +35,7 @@ export function RecoveryPhraseScreen() { ); if (result?.error) { - if (result.error === ERROR_CODES.BAD_PASSWORD) { - setError('Incorrect password'); - } else { - setError(`Error: ${result.error}`); - } + setError(formatWalletError(result.error)); setPassword(''); } else { setMnemonic(result.mnemonic || ''); diff --git a/extension/popup/screens/SendReviewScreen.tsx b/extension/popup/screens/SendReviewScreen.tsx index bf55dd5..fb3d22b 100644 --- a/extension/popup/screens/SendReviewScreen.tsx +++ b/extension/popup/screens/SendReviewScreen.tsx @@ -4,6 +4,7 @@ import { truncateAddress } from '../utils/format'; import { AccountIcon } from '../components/AccountIcon'; import { send } from '../utils/messaging'; import { INTERNAL_METHODS } from '../../shared/constants'; +import { formatWalletError } from '../utils/formatWalletError'; import { nockToNick, formatNock, formatNick } from '../../shared/currency'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { ChevronRightIcon } from '../components/icons/ChevronRightIcon'; @@ -68,7 +69,7 @@ export function SendReviewScreen() { ]); if (result?.error) { - setError(result.error); + setError(formatWalletError(result.error)); setIsSending(false); return; } diff --git a/extension/popup/screens/SendScreen.tsx b/extension/popup/screens/SendScreen.tsx index 4979e29..bae8596 100644 --- a/extension/popup/screens/SendScreen.tsx +++ b/extension/popup/screens/SendScreen.tsx @@ -3,6 +3,7 @@ import { useStore } from '../store'; import { useTheme } from '../contexts/ThemeContext'; import { truncateAddress } from '../utils/format'; import { send } from '../utils/messaging'; +import { formatWalletError } from '../utils/formatWalletError'; import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../shared/constants'; import { formatNock, isDustAmount, MIN_SENDABLE_NOCK } from '../../shared/currency'; import type { Account } from '../../shared/types'; @@ -124,7 +125,7 @@ export function SendScreen() { if (result?.error) { console.error('[SendScreen] Max estimation error:', result.error); - setError(result.error); + setError(formatWalletError(result.error)); setErrorType('general'); setIsSendingMax(false); return; @@ -394,7 +395,7 @@ export function SendScreen() { setErrorType(null); } else if (result?.error) { console.error('[SendScreen] Fee estimation error from vault:', result.error); - setError(result.error); + setError(formatWalletError(result.error)); setErrorType('general'); } else { console.warn('[SendScreen] Fee estimation returned no fee or error:', result); diff --git a/extension/popup/screens/SettingsScreen.tsx b/extension/popup/screens/SettingsScreen.tsx index c4286f5..b64c91e 100644 --- a/extension/popup/screens/SettingsScreen.tsx +++ b/extension/popup/screens/SettingsScreen.tsx @@ -23,6 +23,9 @@ export function SettingsScreen() { function handleLockTime() { navigate('lock-time'); } + function handleV0Migration() { + navigate('v0-migration'); + } function handleAbout() { navigate('about'); } @@ -89,6 +92,7 @@ export function SettingsScreen() { + diff --git a/extension/popup/screens/V0MigrationScreen.tsx b/extension/popup/screens/V0MigrationScreen.tsx new file mode 100644 index 0000000..b43114f --- /dev/null +++ b/extension/popup/screens/V0MigrationScreen.tsx @@ -0,0 +1,269 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useStore } from '../store'; +import { send } from '../utils/messaging'; +import { INTERNAL_METHODS } from '../../shared/constants'; +import IrisLogo96 from '../assets/iris-logo-96.svg'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { validateMnemonic } from '../../shared/wallet-crypto'; +import { formatWalletError } from '../utils/formatWalletError'; + +export function V0MigrationScreen() { + const { navigate } = useStore(); + + const [seedphrase, setSeedphrase] = useState(''); + const [passphrase, setPassphrase] = useState(''); + + const [hasStored, setHasStored] = useState(null); + const [status, setStatus] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + function handleBack() { + navigate('settings'); + } + + const canSave = useMemo(() => { + const normalized = seedphrase.trim().toLowerCase().replace(/\s+/g, ' '); + if (normalized.length === 0) return false; + // BIP39 allows 12–24 words, multiples of 3 (12/15/18/21/24). We rely on validateMnemonic. + return validateMnemonic(normalized); + }, [seedphrase]); + + useEffect(() => { + (async () => { + try { + const res = await send<{ ok?: boolean; has?: boolean; error?: string }>( + INTERNAL_METHODS.HAS_V0_SEEDPHRASE, + [] + ); + if (res?.ok) { + setHasStored(Boolean(res.has)); + } else if ((res as any)?.error) { + setError(formatWalletError((res as any).error)); + } else { + setError('Failed to check v0 seedphrase status'); + } + } catch { + setError('Failed to check v0 seedphrase status'); + } + })(); + }, []); + + async function handleSave() { + // Prevent overwriting while the status is still unknown or already stored. + if (hasStored !== false) { + if (hasStored === null) { + setError('Still checking whether a v0 seedphrase is already stored. Please wait.'); + } else { + setError('A v0 seedphrase is already stored. Remove it to enter a new one.'); + } + return; + } + + setIsLoading(true); + setError(''); + setStatus(''); + try { + const normalized = seedphrase.trim().toLowerCase().replace(/\s+/g, ' '); + if (!validateMnemonic(normalized)) { + setError('Invalid BIP39 seedphrase'); + return; + } + + const res = await send<{ ok?: boolean; error?: unknown }>( + INTERNAL_METHODS.SET_V0_SEEDPHRASE, + [normalized, passphrase || ''] + ); + + if ((res as any)?.error) { + setError(formatWalletError((res as any).error)); + return; + } + + setHasStored(true); + setStatus('v0 seedphrase stored securely in Iris.'); + setSeedphrase(''); + setPassphrase(''); + } catch (err) { + setError('Failed to store v0 seedphrase'); + console.error(err); + } finally { + setIsLoading(false); + } + } + + async function handleClear() { + setIsLoading(true); + setError(''); + setStatus(''); + try { + const res = await send<{ ok?: boolean; error?: unknown }>( + INTERNAL_METHODS.CLEAR_V0_SEEDPHRASE, + [] + ); + + if ((res as any)?.error) { + setError(formatWalletError((res as any).error)); + return; + } + + setHasStored(false); + setStatus('Removed stored v0 seedphrase.'); + } catch (err) { + setError('Failed to remove v0 seedphrase'); + console.error(err); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+ +

+ Upgrade v0 → v1 +

+
+
+ +
+
+ Iris +

+ Store your legacy (v0) seedphrase in Iris so websites can migrate without ever seeing + it. +

+
+ +
+ Status: {hasStored === null ? 'Checking…' : hasStored ? 'Stored' : 'Not stored'} +
+ +
+ {hasStored === true ? ( +
+ A v0 seedphrase is already stored. Remove it to enter a new one. +
+ ) : hasStored === false ? ( + <> + +