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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
save-exact=true
2 changes: 2 additions & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
20

64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
144 changes: 135 additions & 9 deletions extension/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ALARM_NAMES,
AUTOLOCK_MINUTES,
STORAGE_KEYS,
SESSION_STORAGE_KEYS,
USER_ACTIVITY_METHODS,
UI_CONSTANTS,
APPROVAL_CONSTANTS,
Expand All @@ -25,6 +26,10 @@ import type {
SignRawTxRequest,
} from '../shared/types';

function isRecord(x: unknown): x is Record<string, unknown> {
return typeof x === 'object' && x !== null;
}

const vault = new Vault();
let lastActivity = Date.now();
let autoLockMinutes = AUTOLOCK_MINUTES;
Expand Down Expand Up @@ -55,13 +60,112 @@ const REQUEST_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes
*/
let isRpcConnected = true;

type UnlockSessionCache = {
key: number[];
};

let sessionRestorePromise: Promise<void> | null = null;

async function clearUnlockSessionCache(): Promise<void> {
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<void> {
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<void> {
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<void> {
if (!vault.isLocked()) {
return;
}

if (!sessionRestorePromise) {
sessionRestorePromise = restoreUnlockSession().finally(() => {
sessionRestorePromise = null;
});
}

await sessionRestorePromise;
}

/**
* Load approved origins from storage
*/
async function loadApprovedOrigins(): Promise<void> {
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<string>(origins);
}

/**
Expand Down Expand Up @@ -275,7 +379,7 @@ async function createApprovalPopup(
focused: true,
});

approvalWindowId = newWindow.id || null;
approvalWindowId = newWindow?.id ?? null;
} finally {
isCreatingWindow = false;
}
Expand Down Expand Up @@ -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<string, unknown>;

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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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 });

Expand All @@ -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:
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion extension/popup/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[STORAGE_KEYS.BALANCE_HIDDEN];
setBalanceHidden(typeof raw === 'boolean' ? raw : Boolean(raw));
});
}, []);

Expand Down
8 changes: 6 additions & 2 deletions extension/popup/screens/WalletPermissionsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading