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..35f4978 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +20 + diff --git a/README.md b/README.md index 0214c1d..dea145c 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,67 @@ npm run build ## WASM Modules WASM binaries are **pre-built and included** in `extension/lib/`. 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..5fb23d8 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -13,6 +13,7 @@ import { ALARM_NAMES, AUTOLOCK_MINUTES, STORAGE_KEYS, + SESSION_STORAGE_KEYS, USER_ACTIVITY_METHODS, UI_CONSTANTS, APPROVAL_CONSTANTS, @@ -25,6 +26,10 @@ import type { SignRawTxRequest, } from '../shared/types'; +function isRecord(x: unknown): x is Record { + return typeof x === 'object' && x !== null; +} + const vault = new Vault(); let lastActivity = Date.now(); let autoLockMinutes = AUTOLOCK_MINUTES; @@ -55,13 +60,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); } /** @@ -275,7 +379,7 @@ async function createApprovalPopup( focused: true, }); - approvalWindowId = newWindow.id || null; + approvalWindowId = newWindow?.id ?? null; } finally { isCreatingWindow = false; } @@ -325,24 +429,30 @@ 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([ +const initPromise = (async () => { + 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 +499,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); @@ -621,6 +733,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 +743,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 +753,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 +763,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: @@ -1233,6 +1355,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 +1373,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/manifest.json b/extension/manifest.json index e61e198..bca8934 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.1", "description": "Iris Wallet - Browser Wallet for Nockchain", "icons": { "16": "icons/icon16.png", 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/WalletPermissionsScreen.tsx b/extension/popup/screens/WalletPermissionsScreen.tsx index ea1d395..4d2ae28 100644 --- a/extension/popup/screens/WalletPermissionsScreen.tsx +++ b/extension/popup/screens/WalletPermissionsScreen.tsx @@ -14,8 +14,12 @@ export function WalletPermissionsScreen() { }, []); async function loadApprovedOrigins() { - const stored = await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS]); - const origins = stored[STORAGE_KEYS.APPROVED_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') : []; setApprovedOrigins(origins); } diff --git a/extension/popup/screens/approvals/SignRawTxScreen.tsx b/extension/popup/screens/approvals/SignRawTxScreen.tsx index db0eb0a..639fdd8 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -8,6 +8,7 @@ import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; import { nickToNock, formatNock } from '../../../shared/currency'; +import { extractMemo } from '../../utils/memo'; interface NoteItemProps { note: any; @@ -90,7 +91,7 @@ export function SignRawTxScreen() { return null; } - const { id, origin, rawTx, notes, spendConditions, outputs } = pendingSignRawTxRequest; + const { id, origin, rawTx, notes, outputs } = pendingSignRawTxRequest; useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_SIGN_RAW_TX); @@ -123,6 +124,7 @@ export function SignRawTxScreen() { const totalFeeNocks = nickToNock(totalFeeNicks); const formattedFee = formatNock(totalFeeNocks); + const memo = extractMemo({ rawTx, outputs }); const bg = 'var(--color-bg)'; const surface = 'var(--color-surface-800)'; @@ -211,6 +213,23 @@ export function SignRawTxScreen() { )} + {/* Memo (optional) */} + {memo && ( +
+ +
+

+ {memo} +

+
+
+ )} + {/* Network Fee */}