@nockchain/rose 0.1.4-nightly.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.github/workflows/artifacts.yml +33 -0
  2. package/.github/workflows/ci.yml +68 -0
  3. package/.github/workflows/publish-sdk.yml +35 -0
  4. package/.nvmrc +1 -0
  5. package/.prettierignore +5 -0
  6. package/.prettierrc +8 -0
  7. package/LICENSE +22 -0
  8. package/README.md +117 -0
  9. package/extension/background/index.ts +1500 -0
  10. package/extension/content/index.ts +59 -0
  11. package/extension/icons/rose.svg +27 -0
  12. package/extension/icons/rose128.png +0 -0
  13. package/extension/icons/rose16.png +0 -0
  14. package/extension/icons/rose256.png +0 -0
  15. package/extension/icons/rose32.png +0 -0
  16. package/extension/icons/rose48.png +0 -0
  17. package/extension/icons/rose512.png +0 -0
  18. package/extension/inpage/index.ts +86 -0
  19. package/extension/manifest.json +48 -0
  20. package/extension/popup/Popup.tsx +94 -0
  21. package/extension/popup/Router.tsx +121 -0
  22. package/extension/popup/assets/arrow-down-icon.svg +3 -0
  23. package/extension/popup/assets/arrow-left-icon.svg +3 -0
  24. package/extension/popup/assets/arrow-right-icon.svg +3 -0
  25. package/extension/popup/assets/arrow-up-icon.svg +3 -0
  26. package/extension/popup/assets/arrow-up-right-icon.svg +3 -0
  27. package/extension/popup/assets/checkmark-icon.svg +3 -0
  28. package/extension/popup/assets/checkmark-pencil-icon.svg +3 -0
  29. package/extension/popup/assets/checkmark-success-icon.svg +3 -0
  30. package/extension/popup/assets/clock-icon.svg +3 -0
  31. package/extension/popup/assets/close-x-icon.svg +3 -0
  32. package/extension/popup/assets/copy-icon.svg +6 -0
  33. package/extension/popup/assets/explorer-icon.svg +3 -0
  34. package/extension/popup/assets/eye-off-icon.svg +3 -0
  35. package/extension/popup/assets/eye-open-icon.svg +4 -0
  36. package/extension/popup/assets/feedback-icon.svg +3 -0
  37. package/extension/popup/assets/green-status-dot.svg +3 -0
  38. package/extension/popup/assets/info-icon.svg +3 -0
  39. package/extension/popup/assets/iris-logo-40.svg +27 -0
  40. package/extension/popup/assets/iris-logo-96.svg +27 -0
  41. package/extension/popup/assets/iris-logo-blue.svg +27 -0
  42. package/extension/popup/assets/iris-logo-no-eye.svg +27 -0
  43. package/extension/popup/assets/iris-logo-orange.svg +27 -0
  44. package/extension/popup/assets/iris-logo.svg +27 -0
  45. package/extension/popup/assets/key-icon.svg +3 -0
  46. package/extension/popup/assets/lock-icon-yellow.svg +3 -0
  47. package/extension/popup/assets/lock-icon.svg +3 -0
  48. package/extension/popup/assets/pencil-edit-icon.svg +3 -0
  49. package/extension/popup/assets/permissions-icon.svg +3 -0
  50. package/extension/popup/assets/receipt-icon.svg +5 -0
  51. package/extension/popup/assets/refresh-icon.svg +3 -0
  52. package/extension/popup/assets/settings-gear-icon.svg +8 -0
  53. package/extension/popup/assets/settings-icon.svg +3 -0
  54. package/extension/popup/assets/theme-icon.svg +3 -0
  55. package/extension/popup/assets/trash-bin-icon.svg +3 -0
  56. package/extension/popup/assets/trend-down-arrow.svg +5 -0
  57. package/extension/popup/assets/trend-up-arrow.svg +5 -0
  58. package/extension/popup/assets/user-account-icon.svg +3 -0
  59. package/extension/popup/assets/vector-bottom-left.svg +9 -0
  60. package/extension/popup/assets/vector-left.svg +9 -0
  61. package/extension/popup/assets/vector-right.svg +9 -0
  62. package/extension/popup/assets/vector-top-right-rotated.svg +8 -0
  63. package/extension/popup/assets/vector-top-right.svg +9 -0
  64. package/extension/popup/assets/wallet-dropdown-arrow.svg +5 -0
  65. package/extension/popup/assets/wallet-icon-style-1.svg +6 -0
  66. package/extension/popup/assets/wallet-icon-style-10.svg +8 -0
  67. package/extension/popup/assets/wallet-icon-style-11.svg +8 -0
  68. package/extension/popup/assets/wallet-icon-style-12.svg +8 -0
  69. package/extension/popup/assets/wallet-icon-style-13.svg +8 -0
  70. package/extension/popup/assets/wallet-icon-style-14.svg +8 -0
  71. package/extension/popup/assets/wallet-icon-style-15.svg +8 -0
  72. package/extension/popup/assets/wallet-icon-style-2.svg +8 -0
  73. package/extension/popup/assets/wallet-icon-style-3.svg +8 -0
  74. package/extension/popup/assets/wallet-icon-style-4.svg +8 -0
  75. package/extension/popup/assets/wallet-icon-style-5.svg +8 -0
  76. package/extension/popup/assets/wallet-icon-style-6.svg +8 -0
  77. package/extension/popup/assets/wallet-icon-style-7.svg +8 -0
  78. package/extension/popup/assets/wallet-icon-style-8.svg +8 -0
  79. package/extension/popup/assets/wallet-icon-style-9.svg +8 -0
  80. package/extension/popup/components/AccountIcon.tsx +78 -0
  81. package/extension/popup/components/AccountSelector.tsx +246 -0
  82. package/extension/popup/components/Alert.tsx +48 -0
  83. package/extension/popup/components/ConfirmModal.tsx +81 -0
  84. package/extension/popup/components/PasswordInput.tsx +49 -0
  85. package/extension/popup/components/ScreenContainer.tsx +17 -0
  86. package/extension/popup/components/SiteIcon.tsx +60 -0
  87. package/extension/popup/components/ThemeToggle.tsx +44 -0
  88. package/extension/popup/components/icons/ArrowDownLeftIcon.tsx +20 -0
  89. package/extension/popup/components/icons/ArrowUpRightIcon.tsx +20 -0
  90. package/extension/popup/components/icons/CheckIcon.tsx +20 -0
  91. package/extension/popup/components/icons/ChevronDownIcon.tsx +15 -0
  92. package/extension/popup/components/icons/ChevronLeftIcon.tsx +15 -0
  93. package/extension/popup/components/icons/ChevronRightIcon.tsx +15 -0
  94. package/extension/popup/components/icons/ChevronUpIcon.tsx +15 -0
  95. package/extension/popup/components/icons/CloseIcon.tsx +26 -0
  96. package/extension/popup/components/icons/CopyIcon.tsx +20 -0
  97. package/extension/popup/components/icons/EditIcon.tsx +20 -0
  98. package/extension/popup/components/icons/EyeIcon.tsx +13 -0
  99. package/extension/popup/components/icons/EyeOffIcon.tsx +13 -0
  100. package/extension/popup/components/icons/InfoIcon.tsx +20 -0
  101. package/extension/popup/components/icons/LockIcon.tsx +20 -0
  102. package/extension/popup/components/icons/PlusIcon.tsx +15 -0
  103. package/extension/popup/components/icons/ReceiveArrowIcon.tsx +14 -0
  104. package/extension/popup/components/icons/ReceiveCircleIcon.tsx +20 -0
  105. package/extension/popup/components/icons/SendPaperPlaneIcon.tsx +18 -0
  106. package/extension/popup/components/icons/SentArrowIcon.tsx +21 -0
  107. package/extension/popup/components/icons/SettingsIcon.tsx +26 -0
  108. package/extension/popup/components/icons/ShieldIcon.tsx +20 -0
  109. package/extension/popup/components/icons/UploadIcon.tsx +20 -0
  110. package/extension/popup/components/icons/WalletIcon.tsx +20 -0
  111. package/extension/popup/contexts/ThemeContext.tsx +105 -0
  112. package/extension/popup/hooks/useApprovalDetection.ts +128 -0
  113. package/extension/popup/hooks/useAutoFocus.ts +36 -0
  114. package/extension/popup/hooks/useAutoRejectOnClose.ts +25 -0
  115. package/extension/popup/hooks/useClickOutside.ts +33 -0
  116. package/extension/popup/hooks/useCopyToClipboard.ts +33 -0
  117. package/extension/popup/hooks/useFavicon.ts +64 -0
  118. package/extension/popup/hooks/useNumericInput.ts +93 -0
  119. package/extension/popup/index.html +13 -0
  120. package/extension/popup/index.tsx +24 -0
  121. package/extension/popup/screens/AboutScreen.tsx +118 -0
  122. package/extension/popup/screens/HomeScreen.tailwind.css +85 -0
  123. package/extension/popup/screens/HomeScreen.tsx +902 -0
  124. package/extension/popup/screens/KeySettingsPasswordScreen.tsx +164 -0
  125. package/extension/popup/screens/LockTimeScreen.tsx +155 -0
  126. package/extension/popup/screens/ReceiveScreen.tsx +149 -0
  127. package/extension/popup/screens/RecoveryPhraseScreen.tsx +183 -0
  128. package/extension/popup/screens/SendReviewScreen.tsx +308 -0
  129. package/extension/popup/screens/SendScreen.tsx +825 -0
  130. package/extension/popup/screens/SendSubmittedScreen.tsx +193 -0
  131. package/extension/popup/screens/SettingsScreen.tsx +116 -0
  132. package/extension/popup/screens/ThemeSettingsScreen.tsx +107 -0
  133. package/extension/popup/screens/TransactionDetailsScreen.tsx +346 -0
  134. package/extension/popup/screens/ViewSecretPhraseScreen.tsx +212 -0
  135. package/extension/popup/screens/WalletPermissionsScreen.tsx +123 -0
  136. package/extension/popup/screens/WalletSettingsScreen.tsx +381 -0
  137. package/extension/popup/screens/WalletStylingScreen.tsx +306 -0
  138. package/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +136 -0
  139. package/extension/popup/screens/approvals/SignMessageScreen.tsx +140 -0
  140. package/extension/popup/screens/approvals/SignRawTxScreen.tsx +320 -0
  141. package/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +167 -0
  142. package/extension/popup/screens/onboarding/BackupScreen.tsx +254 -0
  143. package/extension/popup/screens/onboarding/CreateScreen.tsx +273 -0
  144. package/extension/popup/screens/onboarding/ImportScreen.tsx +676 -0
  145. package/extension/popup/screens/onboarding/ImportScreenV0.tsx +678 -0
  146. package/extension/popup/screens/onboarding/ImportSuccessScreen.tsx +236 -0
  147. package/extension/popup/screens/onboarding/ResumeBackupScreen.tsx +166 -0
  148. package/extension/popup/screens/onboarding/StartScreen.tsx +142 -0
  149. package/extension/popup/screens/onboarding/SuccessScreen.tsx +193 -0
  150. package/extension/popup/screens/onboarding/VerifyScreen.tsx +220 -0
  151. package/extension/popup/screens/system/LockedScreen.tsx +288 -0
  152. package/extension/popup/screens/transactions/ReceiveScreen.tsx +84 -0
  153. package/extension/popup/screens/transactions/SentScreen.tsx +138 -0
  154. package/extension/popup/store.ts +482 -0
  155. package/extension/popup/styles.css +246 -0
  156. package/extension/popup/utils/format.ts +58 -0
  157. package/extension/popup/utils/formatWalletError.ts +36 -0
  158. package/extension/popup/utils/memo.ts +299 -0
  159. package/extension/popup/utils/messaging.ts +16 -0
  160. package/extension/shared/address-encoding.ts +69 -0
  161. package/extension/shared/balance-query.ts +123 -0
  162. package/extension/shared/constants.ts +386 -0
  163. package/extension/shared/currency.ts +128 -0
  164. package/extension/shared/first-name-derivation.ts +128 -0
  165. package/extension/shared/keyfile.ts +58 -0
  166. package/extension/shared/onboarding.ts +78 -0
  167. package/extension/shared/price-api.ts +79 -0
  168. package/extension/shared/rpc-client-browser.ts +315 -0
  169. package/extension/shared/transaction-builder.ts +443 -0
  170. package/extension/shared/types.ts +450 -0
  171. package/extension/shared/utxo-diff.ts +212 -0
  172. package/extension/shared/utxo-store.ts +548 -0
  173. package/extension/shared/utxo-sync.ts +343 -0
  174. package/extension/shared/validators.ts +26 -0
  175. package/extension/shared/vault.ts +1580 -0
  176. package/extension/shared/wallet-crypto.ts +77 -0
  177. package/extension/shared/wasm-utils.ts +76 -0
  178. package/extension/shared/webcrypto.ts +67 -0
  179. package/extension/types/wasm.d.ts +13 -0
  180. package/package.json +39 -0
  181. package/postcss.config.js +6 -0
  182. package/rose-extension-dist.zip +0 -0
  183. package/sdk/README.md +88 -0
  184. package/sdk/examples/app.ts +166 -0
  185. package/sdk/examples/index.html +51 -0
  186. package/sdk/examples/tsconfig.json +15 -0
  187. package/sdk/examples/tx-builder.html +532 -0
  188. package/sdk/examples/tx-builder.ts +1766 -0
  189. package/sdk/package-lock.json +424 -0
  190. package/sdk/package.json +68 -0
  191. package/sdk/src/constants.ts +28 -0
  192. package/sdk/src/errors.ts +74 -0
  193. package/sdk/src/hooks/index.ts +1 -0
  194. package/sdk/src/hooks/use-rose.ts +94 -0
  195. package/sdk/src/index.ts +12 -0
  196. package/sdk/src/provider.ts +396 -0
  197. package/sdk/src/transaction.ts +163 -0
  198. package/sdk/src/types/rose-wasm.d.ts +14 -0
  199. package/sdk/src/types.ts +97 -0
  200. package/sdk/src/wasm.ts +13 -0
  201. package/sdk/tsconfig.json +20 -0
  202. package/sdk/vite.config.examples.ts +32 -0
  203. package/tailwind.config.ts +38 -0
  204. package/tsconfig.json +20 -0
  205. package/vite.config.ts +60 -0
@@ -0,0 +1,1500 @@
1
+ /// <reference types="chrome" />
2
+ /**
3
+ * Service Worker: Wallet controller and message router
4
+ * Handles provider requests from content script and popup UI
5
+ */
6
+
7
+ import { Vault } from '../shared/vault';
8
+ import { isNockAddress } from '../shared/validators';
9
+ import { initIrisSdkOnce } from '../shared/wasm-utils';
10
+ import {
11
+ PROVIDER_METHODS,
12
+ INTERNAL_METHODS,
13
+ ERROR_CODES,
14
+ ALARM_NAMES,
15
+ AUTOLOCK_MINUTES,
16
+ STORAGE_KEYS,
17
+ SESSION_STORAGE_KEYS,
18
+ USER_ACTIVITY_METHODS,
19
+ UI_CONSTANTS,
20
+ APPROVAL_CONSTANTS,
21
+ RPC_ENDPOINT,
22
+ } from '../shared/constants';
23
+ import type {
24
+ TransactionRequest,
25
+ SignRequest,
26
+ ConnectRequest,
27
+ SignRawTxRequest,
28
+ } from '../shared/types';
29
+
30
+ function isRecord(x: unknown): x is Record<string, unknown> {
31
+ return typeof x === 'object' && x !== null;
32
+ }
33
+
34
+ const vault = new Vault();
35
+ // Ensure WASM is initialized once per service worker context.
36
+ // Some background flows (message routing, tx handling) require WASM to be ready.
37
+ const wasmInitPromise = initIrisSdkOnce();
38
+ let lastActivity = Date.now();
39
+ let autoLockMinutes = AUTOLOCK_MINUTES;
40
+ let manuallyLocked = false; // Track if user manually locked (don't auto-unlock)
41
+ let approvalWindowId: number | null = null; // Track the approval popup window for reuse
42
+ let isCreatingWindow = false; // Prevent race condition when creating window
43
+ let currentRequestId: string | null = null; // Currently displayed request
44
+ let requestQueue: Array<{
45
+ id: string;
46
+ type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx';
47
+ }> = []; // Queued requests
48
+
49
+ /**
50
+ * In-memory cache of approved origins
51
+ * Loaded from storage on startup, persisted on changes
52
+ */
53
+ let approvedOrigins = new Set<string>();
54
+
55
+ /**
56
+ * Request expiration time (5 minutes)
57
+ * Prevents replay attacks on approval requests
58
+ */
59
+ const REQUEST_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes
60
+
61
+ /**
62
+ * RPC connection status
63
+ * Updated by popup via REPORT_RPC_STATUS when actual gRPC calls succeed/fail
64
+ */
65
+ let isRpcConnected = true;
66
+
67
+ type UnlockSessionCache = {
68
+ key: number[];
69
+ };
70
+
71
+ let sessionRestorePromise: Promise<void> | null = null;
72
+
73
+ async function clearUnlockSessionCache(): Promise<void> {
74
+ try {
75
+ await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE);
76
+ } catch (error) {
77
+ console.error('[Background] Failed to clear unlock cache:', error);
78
+ }
79
+ }
80
+
81
+ async function persistUnlockSession(): Promise<void> {
82
+ const sessionStorage = chrome.storage.session;
83
+ if (!sessionStorage || vault.isLocked()) {
84
+ return;
85
+ }
86
+
87
+ const encryptionKey = vault.getEncryptionKey();
88
+ if (!encryptionKey) {
89
+ return;
90
+ }
91
+
92
+ try {
93
+ const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', encryptionKey));
94
+ await sessionStorage.set({
95
+ [SESSION_STORAGE_KEYS.UNLOCK_CACHE]: Array.from(rawKey),
96
+ });
97
+ } catch (error) {
98
+ console.error('[Background] Failed to persist unlock session:', error);
99
+ }
100
+ }
101
+
102
+ async function restoreUnlockSession(): Promise<void> {
103
+ const sessionStorage = chrome.storage.session;
104
+ if (!sessionStorage) {
105
+ return;
106
+ }
107
+
108
+ const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]);
109
+ const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as UnlockSessionCache['key'] | undefined;
110
+
111
+ if (!cached || cached.length === 0) {
112
+ return;
113
+ }
114
+
115
+ // Respect manual lock - never auto-unlock if user explicitly locked
116
+ if (manuallyLocked) {
117
+ await clearUnlockSessionCache();
118
+ return;
119
+ }
120
+
121
+ // Respect auto-lock timeout window
122
+ if (autoLockMinutes > 0) {
123
+ const idleMs = Date.now() - lastActivity;
124
+ if (idleMs >= autoLockMinutes * 60_000) {
125
+ await clearUnlockSessionCache();
126
+ return;
127
+ }
128
+ }
129
+
130
+ try {
131
+ const key = await crypto.subtle.importKey(
132
+ 'raw',
133
+ new Uint8Array(cached),
134
+ { name: 'AES-GCM' },
135
+ false,
136
+ ['encrypt', 'decrypt']
137
+ );
138
+ const result = await vault.unlockWithKey(key);
139
+ if ('error' in result) {
140
+ await clearUnlockSessionCache();
141
+ }
142
+ } catch (error) {
143
+ console.error('[Background] Failed to restore unlock session:', error);
144
+ await clearUnlockSessionCache();
145
+ }
146
+ }
147
+
148
+ async function ensureSessionRestored(): Promise<void> {
149
+ if (!vault.isLocked()) {
150
+ return;
151
+ }
152
+
153
+ if (!sessionRestorePromise) {
154
+ sessionRestorePromise = restoreUnlockSession().finally(() => {
155
+ sessionRestorePromise = null;
156
+ });
157
+ }
158
+
159
+ await sessionRestorePromise;
160
+ }
161
+
162
+ /**
163
+ * Load approved origins from storage
164
+ */
165
+ async function loadApprovedOrigins(): Promise<void> {
166
+ const stored = (await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS])) as Record<
167
+ string,
168
+ unknown
169
+ >;
170
+ const raw = stored[STORAGE_KEYS.APPROVED_ORIGINS];
171
+ const origins = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [];
172
+ approvedOrigins = new Set<string>(origins);
173
+ }
174
+
175
+ /**
176
+ * Save approved origins to storage
177
+ */
178
+ async function saveApprovedOrigins(): Promise<void> {
179
+ await chrome.storage.local.set({
180
+ [STORAGE_KEYS.APPROVED_ORIGINS]: Array.from(approvedOrigins),
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Add an origin to the approved list
186
+ */
187
+ async function approveOrigin(origin: string): Promise<void> {
188
+ approvedOrigins.add(origin);
189
+ await saveApprovedOrigins();
190
+ }
191
+
192
+ /**
193
+ * Remove an origin from the approved list
194
+ */
195
+ async function revokeOrigin(origin: string): Promise<void> {
196
+ approvedOrigins.delete(origin);
197
+ await saveApprovedOrigins();
198
+ }
199
+
200
+ /**
201
+ * Check if origin is approved for provider method access
202
+ */
203
+ function isOriginApproved(origin: string): boolean {
204
+ // Allow file:// protocol for local testing in development only
205
+ if (import.meta.env.DEV && origin.startsWith('file://')) {
206
+ return true;
207
+ }
208
+
209
+ // Check if origin is in approved list
210
+ return approvedOrigins.has(origin);
211
+ }
212
+
213
+ function cancelPendingRequest(requestId: string, code?: number, message?: string): void {
214
+ const request = pendingRequests.get(requestId);
215
+ if (!request) {
216
+ return;
217
+ }
218
+ pendingRequests.delete(requestId);
219
+ request.sendResponse({
220
+ error: { code: code || 4001, message: message || 'Request was cancelled' },
221
+ });
222
+ if (currentRequestId == requestId) {
223
+ currentRequestId = null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Check if a request timestamp has expired
229
+ * @param timestamp - Request creation timestamp
230
+ * @returns true if expired, false if still valid
231
+ */
232
+ function isRequestExpired(timestamp: number): boolean {
233
+ return Date.now() - timestamp > REQUEST_EXPIRATION_MS;
234
+ }
235
+
236
+ /**
237
+ * Pending approval requests
238
+ * Maps request ID to the request data and response callback
239
+ */
240
+ interface PendingRequest {
241
+ request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest;
242
+ sendResponse: (response: any) => void;
243
+ origin: string;
244
+ needsUnlock?: boolean; // Flag indicating request is waiting for wallet unlock
245
+ }
246
+
247
+ const pendingRequests = new Map<string, PendingRequest>();
248
+ // v0 migration provider methods (string-literal; not yet in published rose-sdk)
249
+ const MIGRATE_V0_GET_STATUS = 'nock_migrateV0GetStatus';
250
+ const MIGRATE_V0_SIGN_RAW_TX = 'nock_migrateV0SignRawTx';
251
+
252
+ /**
253
+ * Type guard to check if a request is a ConnectRequest
254
+ */
255
+ function isConnectRequest(
256
+ request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest
257
+ ): request is ConnectRequest {
258
+ return 'timestamp' in request && !('message' in request) && !('to' in request);
259
+ }
260
+
261
+ /**
262
+ * Type guard to check if a request is a SignRequest
263
+ */
264
+ function isSignRequest(
265
+ request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest
266
+ ): request is SignRequest {
267
+ return 'message' in request;
268
+ }
269
+
270
+ /**
271
+ * Type guard to check if a request is a SignRawTxRequest
272
+ */
273
+ function isSignRawTxRequest(
274
+ request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest
275
+ ): request is SignRawTxRequest {
276
+ return 'rawTx' in request && 'notes' in request && 'spendConditions' in request;
277
+ }
278
+
279
+ /**
280
+ * Type guard to check if a request is a TransactionRequest
281
+ */
282
+ function isTransactionRequest(
283
+ request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest
284
+ ): request is TransactionRequest {
285
+ return 'to' in request;
286
+ }
287
+
288
+ /**
289
+ * Create an approval popup window (or reuse existing one)
290
+ * Uses MetaMask pattern: single popup window for all approval requests
291
+ * Queues requests if user is currently viewing another request
292
+ */
293
+ async function createApprovalPopup(
294
+ requestId: string,
295
+ type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx'
296
+ ) {
297
+ // If user is currently viewing a different request, queue this one
298
+ if (currentRequestId !== null && currentRequestId !== requestId) {
299
+ // Check if already in queue to prevent duplicates
300
+ const alreadyQueued = requestQueue.some(r => r.id === requestId);
301
+ if (!alreadyQueued) {
302
+ requestQueue.push({ id: requestId, type });
303
+ }
304
+ return;
305
+ }
306
+
307
+ // Mark this request as currently displayed
308
+ currentRequestId = requestId;
309
+
310
+ let hashPrefix: string;
311
+ if (type === 'connect') {
312
+ hashPrefix = APPROVAL_CONSTANTS.CONNECT_HASH_PREFIX;
313
+ } else if (type === 'transaction') {
314
+ hashPrefix = APPROVAL_CONSTANTS.TRANSACTION_HASH_PREFIX;
315
+ } else if (type === 'sign-raw-tx') {
316
+ hashPrefix = APPROVAL_CONSTANTS.SIGN_RAW_TX_HASH_PREFIX;
317
+ } else {
318
+ hashPrefix = APPROVAL_CONSTANTS.SIGN_MESSAGE_HASH_PREFIX;
319
+ }
320
+ const popupUrl = chrome.runtime.getURL(`popup/index.html#${hashPrefix}${requestId}`);
321
+
322
+ // Try to reuse existing approval window
323
+ if (approvalWindowId !== null) {
324
+ try {
325
+ const existingWindow = await chrome.windows.get(approvalWindowId);
326
+
327
+ // Window still exists - update it with new request
328
+ if (existingWindow.tabs && existingWindow.tabs[0]?.id) {
329
+ await chrome.tabs.update(existingWindow.tabs[0].id, { url: popupUrl });
330
+ await chrome.windows.update(approvalWindowId, { focused: true });
331
+ return; // Done - reused existing window
332
+ } else {
333
+ await chrome.windows.remove(approvalWindowId);
334
+ approvalWindowId = null;
335
+ }
336
+ } catch {
337
+ // Window was closed or doesn't exist, create new one
338
+ approvalWindowId = null;
339
+ }
340
+ }
341
+
342
+ // Prevent race condition: if window is being created, wait and retry
343
+ if (isCreatingWindow) {
344
+ await new Promise(resolve => setTimeout(resolve, 100));
345
+ return createApprovalPopup(requestId, type);
346
+ }
347
+
348
+ // Create new approval window
349
+ isCreatingWindow = true;
350
+ try {
351
+ const width = UI_CONSTANTS.POPUP_WIDTH;
352
+ const height = UI_CONSTANTS.POPUP_HEIGHT;
353
+
354
+ // Calculate position near top-right corner (where extension icon typically is)
355
+ // Get the current window to determine screen bounds
356
+ let left = 100;
357
+ let top = 100;
358
+
359
+ try {
360
+ const currentWindow = await chrome.windows.getCurrent();
361
+ if (currentWindow.left !== undefined && currentWindow.width !== undefined) {
362
+ // Position near top-right of current window
363
+ // Place it slightly inward from the edge for better UX
364
+ const marginFromRight = 20;
365
+ const marginFromTop = 80; // Below browser chrome/toolbar
366
+
367
+ left = currentWindow.left + currentWindow.width - width - marginFromRight;
368
+ top = currentWindow.top !== undefined ? currentWindow.top + marginFromTop : 80;
369
+
370
+ // Ensure it's not off-screen (minimum 0)
371
+ left = Math.max(0, left);
372
+ top = Math.max(0, top);
373
+ }
374
+ } catch (err) {
375
+ // If we can't get current window, use safe default position
376
+ console.warn('Could not determine window position, using defaults');
377
+ }
378
+
379
+ const newWindow = await chrome.windows.create({
380
+ url: popupUrl,
381
+ type: 'popup',
382
+ width,
383
+ height,
384
+ left,
385
+ top,
386
+ focused: true,
387
+ });
388
+
389
+ approvalWindowId = newWindow?.id ?? null;
390
+ } finally {
391
+ isCreatingWindow = false;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Process next request in queue after current request is resolved
397
+ * Called when user approves or rejects a request
398
+ */
399
+ function processNextRequest() {
400
+ if (currentRequestId !== null) {
401
+ cancelPendingRequest(currentRequestId);
402
+ }
403
+
404
+ if (requestQueue.length > 0) {
405
+ while (true) {
406
+ const next = requestQueue.shift()!;
407
+ if (!pendingRequests.has(next.id)) {
408
+ continue;
409
+ }
410
+ createApprovalPopup(next.id, next.type);
411
+ break;
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Emit a wallet event to all tabs
418
+ * This notifies dApps of wallet state changes (account switches, network changes, etc.)
419
+ */
420
+ async function emitWalletEvent(eventType: string, data: unknown) {
421
+ const tabs = await chrome.tabs.query({});
422
+
423
+ for (const tab of tabs) {
424
+ if (tab.id) {
425
+ try {
426
+ await chrome.tabs.sendMessage(tab.id, {
427
+ type: 'WALLET_EVENT',
428
+ eventType,
429
+ data,
430
+ });
431
+ } catch (error) {
432
+ // Tab might not have content script, ignore
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ // Initialize auto-lock setting, load approved origins, vault state, connection monitoring, and schedule alarms.
439
+ // IMPORTANT: this promise is awaited by message and alarm handlers to prevent race conditions on SW start.
440
+ const initPromise = (async () => {
441
+ await wasmInitPromise;
442
+ const stored = (await chrome.storage.local.get([
443
+ STORAGE_KEYS.AUTO_LOCK_MINUTES,
444
+ STORAGE_KEYS.LAST_ACTIVITY,
445
+ STORAGE_KEYS.MANUALLY_LOCKED,
446
+ ])) as Record<string, unknown>;
447
+
448
+ const storedMinutes = stored[STORAGE_KEYS.AUTO_LOCK_MINUTES];
449
+ autoLockMinutes = typeof storedMinutes === 'number' ? storedMinutes : Number(storedMinutes) || 0;
450
+
451
+ // Load persisted lastActivity (survives SW restarts), fallback to now if not set
452
+ const storedLastActivity = stored[STORAGE_KEYS.LAST_ACTIVITY];
453
+ lastActivity =
454
+ typeof storedLastActivity === 'number'
455
+ ? storedLastActivity
456
+ : Number(storedLastActivity) || Date.now();
457
+
458
+ // Load persisted manuallyLocked state
459
+ manuallyLocked = Boolean(stored[STORAGE_KEYS.MANUALLY_LOCKED]);
460
+
461
+ await loadApprovedOrigins();
462
+ await vault.init(); // Load encrypted vault header to detect vault existence
463
+ await restoreUnlockSession(); // Rehydrate unlock state if still within auto-lock window
464
+
465
+ // Only schedule alarm if auto-lock is enabled, otherwise ensure any stale alarm is cleared
466
+ if (autoLockMinutes > 0) {
467
+ scheduleAlarm();
468
+ } else {
469
+ chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK);
470
+ }
471
+ })();
472
+
473
+ // Clean up approval window ID when window is closed
474
+ chrome.windows.onRemoved.addListener(windowId => {
475
+ if (windowId === approvalWindowId) {
476
+ approvalWindowId = null;
477
+ // If user closed window, process next request in queue
478
+ processNextRequest();
479
+ }
480
+ });
481
+
482
+ /**
483
+ * Track user activity for auto-lock timer
484
+ * Only counts user-initiated actions, not passive polling
485
+ * Persists to storage so it survives service worker restarts
486
+ */
487
+ async function touchActivity(method?: string) {
488
+ if (method && USER_ACTIVITY_METHODS.has(method as any)) {
489
+ lastActivity = Date.now();
490
+ // Persist to storage (await to ensure it's saved)
491
+ await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity });
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Check if message is from popup/extension page (not content script)
497
+ * Extension pages have chrome-extension:// URLs; content scripts have web URLs
498
+ */
499
+ function isFromPopup(sender: chrome.runtime.MessageSender): boolean {
500
+ // Check if the sender URL is from our extension
501
+ const url = sender.url || '';
502
+ const extensionId = chrome.runtime.id;
503
+ return url.startsWith(`chrome-extension://${extensionId}/`);
504
+ }
505
+
506
+ /**
507
+ * Handle messages from content script and popup
508
+ */
509
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
510
+ (async () => {
511
+ await initPromise;
512
+ await ensureSessionRestored();
513
+ const { payload } = msg || {};
514
+ await touchActivity(payload?.method);
515
+
516
+ // Guard: internal methods (wallet:*) can only be called from popup/extension pages
517
+ if (payload?.method?.startsWith('wallet:') && !isFromPopup(_sender)) {
518
+ sendResponse({ error: ERROR_CODES.UNAUTHORIZED });
519
+ return;
520
+ }
521
+
522
+ switch (payload?.method) {
523
+ // Provider methods (called from injected provider via content script)
524
+ case PROVIDER_METHODS.CONNECT:
525
+ const connectOrigin = _sender.url || _sender.origin || '';
526
+
527
+ // Check if origin is already approved
528
+ if (!isOriginApproved(connectOrigin) || vault.isLocked()) {
529
+ // Clear any existing pending unlock requests from same origin to prevent duplicates
530
+ for (const [existingId, existingData] of pendingRequests.entries()) {
531
+ if (existingData.origin === connectOrigin) {
532
+ cancelPendingRequest(existingId);
533
+ }
534
+ }
535
+
536
+ // Origin not approved - show connection approval popup
537
+ const connectRequestId = crypto.randomUUID();
538
+ const connectRequest: ConnectRequest = {
539
+ id: connectRequestId,
540
+ origin: connectOrigin,
541
+ timestamp: Date.now(),
542
+ };
543
+
544
+ // Store pending request with response callback
545
+ pendingRequests.set(connectRequestId, {
546
+ request: connectRequest,
547
+ sendResponse,
548
+ origin: connectRequest.origin,
549
+ });
550
+
551
+ // Create approval popup
552
+ await createApprovalPopup(connectRequestId, 'connect');
553
+
554
+ // Response will be sent when user approves/rejects
555
+ return;
556
+ }
557
+
558
+ // Origin approved - return address
559
+ sendResponse({
560
+ pkh: vault.getAddress(),
561
+ grpcEndpoint: RPC_ENDPOINT,
562
+ });
563
+
564
+ // Emit connect event when dApp connects successfully
565
+ await emitWalletEvent('connect', { chainId: 'nockchain-1' });
566
+ return;
567
+
568
+ case PROVIDER_METHODS.SIGN_MESSAGE:
569
+ // Validate origin
570
+ const signMessageOrigin = _sender.url || _sender.origin || '';
571
+ if (!isOriginApproved(signMessageOrigin)) {
572
+ sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } });
573
+ return;
574
+ }
575
+
576
+ if (vault.isLocked()) {
577
+ sendResponse({ error: ERROR_CODES.LOCKED });
578
+ return;
579
+ }
580
+
581
+ // Create sign message approval request
582
+ const newSignRequestId = crypto.randomUUID();
583
+ const signRequest: SignRequest = {
584
+ id: newSignRequestId,
585
+ origin: signMessageOrigin,
586
+ message: payload.params?.[0] || '',
587
+ timestamp: Date.now(),
588
+ };
589
+
590
+ // Store pending request with response callback
591
+ pendingRequests.set(newSignRequestId, {
592
+ request: signRequest,
593
+ sendResponse,
594
+ origin: signRequest.origin,
595
+ });
596
+
597
+ // Create approval popup
598
+ await createApprovalPopup(newSignRequestId, 'sign-message');
599
+
600
+ // Response will be sent when user approves/rejects
601
+ return;
602
+
603
+ case MIGRATE_V0_GET_STATUS: {
604
+ const origin = _sender.url || _sender.origin || '';
605
+ if (!isOriginApproved(origin)) {
606
+ sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } });
607
+ return;
608
+ }
609
+ if (vault.isLocked()) {
610
+ sendResponse({ error: ERROR_CODES.LOCKED });
611
+ return;
612
+ }
613
+ sendResponse({ ok: true, hasV0Mnemonic: vault.hasV0Mnemonic() });
614
+ return;
615
+ }
616
+
617
+ case MIGRATE_V0_SIGN_RAW_TX: {
618
+ const origin = _sender.url || _sender.origin || '';
619
+ if (!isOriginApproved(origin)) {
620
+ sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } });
621
+ return;
622
+ }
623
+ if (vault.isLocked()) {
624
+ sendResponse({ error: ERROR_CODES.LOCKED });
625
+ return;
626
+ }
627
+ const rawTxParams = payload.params?.[0];
628
+ if (
629
+ !rawTxParams ||
630
+ !rawTxParams.rawTx ||
631
+ !rawTxParams.notes ||
632
+ !rawTxParams.spendConditions
633
+ ) {
634
+ sendResponse({ error: { code: -32602, message: 'Invalid params' } });
635
+ return;
636
+ }
637
+ const derivation = rawTxParams.derivation || 'master';
638
+ const outputs = await vault.computeOutputs(rawTxParams.rawTx);
639
+
640
+ const signRawTxId = crypto.randomUUID();
641
+ const signRawTxRequest: any = {
642
+ id: signRawTxId,
643
+ origin,
644
+ rawTx: rawTxParams.rawTx,
645
+ notes: rawTxParams.notes,
646
+ spendConditions: rawTxParams.spendConditions,
647
+ outputs: outputs,
648
+ timestamp: Date.now(),
649
+ signWith: 'v0',
650
+ v0Derivation: derivation,
651
+ };
652
+
653
+ pendingRequests.set(signRawTxId, {
654
+ request: signRawTxRequest,
655
+ sendResponse,
656
+ origin: signRawTxRequest.origin,
657
+ });
658
+
659
+ await createApprovalPopup(signRawTxId, 'sign-raw-tx');
660
+ return;
661
+ }
662
+
663
+ case PROVIDER_METHODS.SIGN_RAW_TX:
664
+ // Validate origin
665
+ const signRawTxOrigin = _sender.url || _sender.origin || '';
666
+ if (!isOriginApproved(signRawTxOrigin)) {
667
+ sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } });
668
+ return;
669
+ }
670
+
671
+ if (vault.isLocked()) {
672
+ sendResponse({ error: ERROR_CODES.LOCKED });
673
+ return;
674
+ }
675
+
676
+ const rawTxParams = payload.params?.[0];
677
+ if (
678
+ !rawTxParams ||
679
+ !rawTxParams.rawTx ||
680
+ !rawTxParams.notes ||
681
+ !rawTxParams.spendConditions
682
+ ) {
683
+ sendResponse({ error: { code: -32602, message: 'Invalid params' } });
684
+ return;
685
+ }
686
+
687
+ const outputs = await vault.computeOutputs(rawTxParams.rawTx);
688
+
689
+ // Create sign raw tx approval request
690
+ const signRawTxId = crypto.randomUUID();
691
+ const signRawTxRequest: SignRawTxRequest = {
692
+ id: signRawTxId,
693
+ origin: signRawTxOrigin,
694
+ rawTx: rawTxParams.rawTx,
695
+ notes: rawTxParams.notes,
696
+ spendConditions: rawTxParams.spendConditions,
697
+ outputs: outputs,
698
+ timestamp: Date.now(),
699
+ };
700
+
701
+ // Store pending request with response callback
702
+ pendingRequests.set(signRawTxId, {
703
+ request: signRawTxRequest,
704
+ sendResponse,
705
+ origin: signRawTxRequest.origin,
706
+ });
707
+
708
+ // Create approval popup
709
+ await createApprovalPopup(signRawTxId, 'sign-raw-tx');
710
+
711
+ // Response will be sent when user approves/rejects
712
+ return;
713
+
714
+ case PROVIDER_METHODS.SEND_TRANSACTION:
715
+ // Validate origin
716
+ const sendTxOrigin = _sender.url || _sender.origin || '';
717
+ if (!isOriginApproved(sendTxOrigin)) {
718
+ sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } });
719
+ return;
720
+ }
721
+
722
+ if (vault.isLocked()) {
723
+ sendResponse({ error: ERROR_CODES.LOCKED });
724
+ return;
725
+ }
726
+ const { to, amount, fee } = payload.params?.[0] ?? {};
727
+ if (!isNockAddress(to)) {
728
+ sendResponse({ error: ERROR_CODES.BAD_ADDRESS });
729
+ return;
730
+ }
731
+
732
+ // Create transaction approval request
733
+ const txRequestId = crypto.randomUUID();
734
+ const txRequest: TransactionRequest = {
735
+ id: txRequestId,
736
+ origin: sendTxOrigin,
737
+ to,
738
+ amount,
739
+ fee,
740
+ timestamp: Date.now(),
741
+ };
742
+
743
+ // Store pending request with response callback
744
+ pendingRequests.set(txRequestId, {
745
+ request: txRequest,
746
+ sendResponse,
747
+ origin: txRequest.origin,
748
+ });
749
+
750
+ // Create approval popup
751
+ await createApprovalPopup(txRequestId, 'transaction');
752
+
753
+ // Response will be sent when user approves/rejects
754
+ return;
755
+
756
+ case PROVIDER_METHODS.GET_WALLET_INFO:
757
+ // Validate origin
758
+ const getInfoOrigin = _sender.url || _sender.origin || '';
759
+ if (!isOriginApproved(getInfoOrigin)) {
760
+ sendResponse({ error: { code: 4100, message: 'Unauthorized origin' } });
761
+ return;
762
+ }
763
+
764
+ if (vault.isLocked()) {
765
+ sendResponse({ error: ERROR_CODES.LOCKED });
766
+ return;
767
+ }
768
+
769
+ sendResponse({
770
+ pkh: vault.getAddress(),
771
+ grpcEndpoint: RPC_ENDPOINT,
772
+ });
773
+ return;
774
+
775
+ // Internal methods (called from popup)
776
+ case INTERNAL_METHODS.SET_AUTO_LOCK:
777
+ const newMinutes = payload.params?.[0];
778
+ autoLockMinutes = typeof newMinutes === 'number' ? newMinutes : Number(newMinutes) || 0;
779
+
780
+ await chrome.storage.local.set({
781
+ [STORAGE_KEYS.AUTO_LOCK_MINUTES]: autoLockMinutes,
782
+ });
783
+ // Start or stop alarm based on setting
784
+ if (autoLockMinutes > 0) {
785
+ // Reset activity timestamp when enabling auto-lock
786
+ lastActivity = Date.now();
787
+ await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity });
788
+ scheduleAlarm();
789
+ } else {
790
+ // Clear alarm when disabling auto-lock
791
+ chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK);
792
+ }
793
+ sendResponse({ ok: true });
794
+ return;
795
+
796
+ case INTERNAL_METHODS.UNLOCK:
797
+ const unlockResult = await vault.unlock(payload.params?.[0]); // password
798
+ sendResponse(unlockResult);
799
+
800
+ // Emit connect event when unlock succeeds
801
+ if ('ok' in unlockResult && unlockResult.ok) {
802
+ // Clear manual lock flag when successfully unlocked
803
+ manuallyLocked = false;
804
+ await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false });
805
+ await persistUnlockSession();
806
+ await emitWalletEvent('connect', { chainId: 'nockchain-1' });
807
+ }
808
+ return;
809
+
810
+ case INTERNAL_METHODS.LOCK:
811
+ // Set manual lock flag - user explicitly locked, don't auto-unlock
812
+ manuallyLocked = true;
813
+ await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true });
814
+ await vault.lock();
815
+ await clearUnlockSessionCache();
816
+ sendResponse({ ok: true });
817
+
818
+ // Emit disconnect event when wallet locks
819
+ await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet locked' });
820
+ return;
821
+
822
+ case INTERNAL_METHODS.RESET_WALLET:
823
+ // Reset the wallet completely - clears all data
824
+ await vault.reset();
825
+ await clearUnlockSessionCache();
826
+ manuallyLocked = false;
827
+ sendResponse({ ok: true });
828
+
829
+ // Emit disconnect event
830
+ await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet reset' });
831
+ return;
832
+
833
+ case INTERNAL_METHODS.SETUP:
834
+ // params: password, mnemonic (optional). If no mnemonic, generates one automatically.
835
+ const setupResult = await vault.setup(
836
+ payload.params?.[0],
837
+ payload.params?.[1],
838
+ payload.params?.[2]
839
+ );
840
+ sendResponse(setupResult);
841
+
842
+ if ('ok' in setupResult && setupResult.ok) {
843
+ manuallyLocked = false;
844
+ await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false });
845
+ await persistUnlockSession();
846
+ }
847
+ return;
848
+
849
+ case INTERNAL_METHODS.GET_STATE:
850
+ // Initialize vault state from storage before checking status
851
+ // This ensures hasVault is accurate even after service worker restart
852
+ await vault.init();
853
+
854
+ const uiStatus = vault.getUiStatus();
855
+ sendResponse({
856
+ locked: uiStatus.locked,
857
+ hasVault: uiStatus.hasVault,
858
+ address: await vault.getAddressSafe(),
859
+ accounts: vault.getAccounts(),
860
+ currentAccount: vault.getCurrentAccount(),
861
+ });
862
+ return;
863
+
864
+ case INTERNAL_METHODS.GET_ACCOUNTS:
865
+ sendResponse({
866
+ accounts: vault.getAccounts(),
867
+ currentAccount: vault.getCurrentAccount(),
868
+ });
869
+ return;
870
+
871
+ case INTERNAL_METHODS.SWITCH_ACCOUNT:
872
+ const switchResult = await vault.switchAccount(payload.params?.[0]);
873
+ sendResponse(switchResult);
874
+
875
+ // Emit accountsChanged event to all tabs if successful
876
+ if ('ok' in switchResult && switchResult.ok) {
877
+ await emitWalletEvent('accountsChanged', [switchResult.account.address]);
878
+ }
879
+ return;
880
+
881
+ case INTERNAL_METHODS.RENAME_ACCOUNT:
882
+ sendResponse(await vault.renameAccount(payload.params?.[0], payload.params?.[1]));
883
+ return;
884
+
885
+ case INTERNAL_METHODS.UPDATE_ACCOUNT_STYLING:
886
+ sendResponse(
887
+ await vault.updateAccountStyling(
888
+ payload.params?.[0],
889
+ payload.params?.[1],
890
+ payload.params?.[2]
891
+ )
892
+ );
893
+ return;
894
+
895
+ case INTERNAL_METHODS.HIDE_ACCOUNT:
896
+ // params: [accountIndex]
897
+ const hideResult = await vault.hideAccount(payload.params?.[0]);
898
+ sendResponse(hideResult);
899
+
900
+ // Emit accountsChanged event to all tabs if successful
901
+ if ('ok' in hideResult && hideResult.ok) {
902
+ const currentAccount = vault.getCurrentAccount();
903
+ if (currentAccount) {
904
+ await emitWalletEvent('accountsChanged', [currentAccount.address]);
905
+ }
906
+ }
907
+ return;
908
+
909
+ case INTERNAL_METHODS.CREATE_ACCOUNT:
910
+ // params: name (optional)
911
+ const createResult = await vault.createAccount(payload.params?.[0]);
912
+ sendResponse(createResult);
913
+
914
+ // Emit accountsChanged event to all tabs if successful
915
+ // New account is automatically set as current
916
+ if ('ok' in createResult && createResult.ok) {
917
+ await emitWalletEvent('accountsChanged', [createResult.account.address]);
918
+ }
919
+ return;
920
+
921
+ case INTERNAL_METHODS.HAS_V0_MNEMONIC:
922
+ if (vault.isLocked()) {
923
+ sendResponse({ error: ERROR_CODES.LOCKED });
924
+ return;
925
+ }
926
+ sendResponse({ ok: true, has: vault.hasV0Mnemonic() });
927
+ return;
928
+
929
+ case INTERNAL_METHODS.CLEAR_V0_MNEMONIC: {
930
+ if (vault.isLocked()) {
931
+ sendResponse({ error: ERROR_CODES.LOCKED });
932
+ return;
933
+ }
934
+ const res = (await vault.clearMnemonicV0()) as { ok: true } | { error: string };
935
+ sendResponse(res);
936
+ return;
937
+ }
938
+
939
+ case INTERNAL_METHODS.SET_V0_MNEMONIC:
940
+ if (vault.isLocked()) {
941
+ sendResponse({ error: ERROR_CODES.LOCKED });
942
+ return;
943
+ }
944
+ const res = await vault.setMnemonicV0(payload.params?.[0]);
945
+ sendResponse(res);
946
+ return;
947
+ case INTERNAL_METHODS.GET_MNEMONIC:
948
+ // params: password (required for verification)
949
+ sendResponse(await vault.getMnemonic(payload.params?.[0]));
950
+ return;
951
+
952
+ case INTERNAL_METHODS.GET_AUTO_LOCK:
953
+ sendResponse({ minutes: autoLockMinutes });
954
+ return;
955
+
956
+ case INTERNAL_METHODS.REPORT_ACTIVITY:
957
+ // Just acknowledge - activity tracking already handled above
958
+ sendResponse({ ok: true });
959
+ return;
960
+
961
+ case INTERNAL_METHODS.GET_BALANCE_FROM_STORE:
962
+ // Get balance from UTXO store - excludes in-flight notes
963
+ // Optional param: account address. If not provided, uses current account.
964
+ const balanceAccountAddress = payload.params?.[0] || vault.getCurrentAccount()?.address;
965
+ if (!balanceAccountAddress) {
966
+ sendResponse({ error: 'No account selected' });
967
+ return;
968
+ }
969
+ try {
970
+ const storeBalance = await vault.getBalanceFromStore(balanceAccountAddress);
971
+ sendResponse(storeBalance);
972
+ } catch (err) {
973
+ console.error('[Background] Error getting balance from store:', err);
974
+ sendResponse({ error: 'Failed to get balance from store' });
975
+ }
976
+ return;
977
+
978
+ case INTERNAL_METHODS.GET_CONNECTION_STATUS:
979
+ sendResponse({ connected: isRpcConnected });
980
+ return;
981
+
982
+ case INTERNAL_METHODS.REPORT_RPC_STATUS:
983
+ // Popup reports actual gRPC call success/failure
984
+ const rpcHealthy = payload.params?.[0] as boolean;
985
+ if (typeof rpcHealthy === 'boolean' && rpcHealthy !== isRpcConnected) {
986
+ isRpcConnected = rpcHealthy;
987
+ }
988
+ sendResponse({ ok: true });
989
+ return;
990
+
991
+ // Note: GET_WALLET_TRANSACTIONS is called directly from popup context
992
+ // to avoid service worker limitations with dynamic imports
993
+
994
+ // Approval request handlers
995
+ case INTERNAL_METHODS.GET_PENDING_TRANSACTION:
996
+ const getPendingTxId = payload.params?.[0];
997
+ const txPending = pendingRequests.get(getPendingTxId);
998
+ if (txPending && isTransactionRequest(txPending.request)) {
999
+ sendResponse(txPending.request);
1000
+ } else {
1001
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1002
+ }
1003
+ return;
1004
+
1005
+ case INTERNAL_METHODS.GET_PENDING_SIGN_REQUEST:
1006
+ const getPendingSignId = payload.params?.[0];
1007
+ const signPending = pendingRequests.get(getPendingSignId);
1008
+ if (signPending && isSignRequest(signPending.request)) {
1009
+ sendResponse(signPending.request);
1010
+ } else {
1011
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1012
+ }
1013
+ return;
1014
+
1015
+ case INTERNAL_METHODS.GET_PENDING_SIGN_RAW_TX_REQUEST:
1016
+ const getPendingSignRawTxId = payload.params?.[0];
1017
+ const signRawTxPending = pendingRequests.get(getPendingSignRawTxId);
1018
+ if (signRawTxPending && isSignRawTxRequest(signRawTxPending.request)) {
1019
+ sendResponse(signRawTxPending.request);
1020
+ } else {
1021
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1022
+ }
1023
+ return;
1024
+
1025
+ case INTERNAL_METHODS.APPROVE_TRANSACTION:
1026
+ const approveTxId = payload.params?.[0];
1027
+ const approveTxPending = pendingRequests.get(approveTxId);
1028
+ if (approveTxPending && isTransactionRequest(approveTxPending.request)) {
1029
+ const txRequest = approveTxPending.request;
1030
+
1031
+ // Check if request has expired (replay prevention)
1032
+ if (isRequestExpired(txRequest.timestamp)) {
1033
+ cancelPendingRequest(approveTxId, 4003, 'Request expired');
1034
+ sendResponse({ error: 'Request expired' });
1035
+ return;
1036
+ }
1037
+
1038
+ try {
1039
+ // Sign the transaction using the vault
1040
+ const txIdHex = await vault.signTransaction(
1041
+ txRequest.to,
1042
+ txRequest.amount,
1043
+ txRequest.fee
1044
+ );
1045
+
1046
+ approveTxPending.sendResponse({
1047
+ txid: txIdHex,
1048
+ amount: txRequest.amount,
1049
+ fee: txRequest.fee,
1050
+ });
1051
+ cancelPendingRequest(approveTxId);
1052
+ processNextRequest();
1053
+ sendResponse({ success: true });
1054
+ } catch (error) {
1055
+ console.error('Transaction signing failed:', error);
1056
+ approveTxPending.sendResponse({
1057
+ error: {
1058
+ code: 4900,
1059
+ message: error instanceof Error ? error.message : 'Transaction signing failed',
1060
+ },
1061
+ });
1062
+ cancelPendingRequest(approveTxId);
1063
+ processNextRequest();
1064
+ sendResponse({
1065
+ error: error instanceof Error ? error.message : 'Transaction signing failed',
1066
+ });
1067
+ }
1068
+ } else {
1069
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1070
+ }
1071
+ return;
1072
+
1073
+ case INTERNAL_METHODS.REJECT_TRANSACTION:
1074
+ const rejectTxId = payload.params?.[0];
1075
+ const rejectTxPending = pendingRequests.get(rejectTxId);
1076
+ if (rejectTxPending) {
1077
+ cancelPendingRequest(rejectTxId, 4001, 'User rejected the transaction');
1078
+ processNextRequest();
1079
+ sendResponse({ success: true });
1080
+ } else {
1081
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1082
+ }
1083
+ return;
1084
+
1085
+ case INTERNAL_METHODS.APPROVE_SIGN_MESSAGE:
1086
+ const approveSignId = payload.params?.[0];
1087
+ const approveSignPending = pendingRequests.get(approveSignId);
1088
+ if (approveSignPending && isSignRequest(approveSignPending.request)) {
1089
+ const signRequest = approveSignPending.request;
1090
+
1091
+ // Check if request has expired (replay prevention)
1092
+ if (isRequestExpired(signRequest.timestamp)) {
1093
+ cancelPendingRequest(approveSignId, 4003, 'Request expired');
1094
+ sendResponse({ error: 'Request expired' });
1095
+ return;
1096
+ }
1097
+
1098
+ try {
1099
+ const { signature, publicKeyHex } = await vault.signMessage([signRequest.message]);
1100
+ approveSignPending.sendResponse({ signature, publicKeyHex });
1101
+ cancelPendingRequest(approveSignId);
1102
+ processNextRequest();
1103
+ sendResponse({ success: true });
1104
+ } catch (err) {
1105
+ const errorMessage = err instanceof Error ? err.message : 'Failed to sign message';
1106
+ cancelPendingRequest(approveSignId, 4001, errorMessage);
1107
+ processNextRequest();
1108
+ sendResponse({ error: errorMessage });
1109
+ }
1110
+ } else {
1111
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1112
+ }
1113
+ return;
1114
+
1115
+ case INTERNAL_METHODS.REJECT_SIGN_MESSAGE:
1116
+ const rejectSignId = payload.params?.[0];
1117
+ const rejectSignPending = pendingRequests.get(rejectSignId);
1118
+ if (rejectSignPending) {
1119
+ cancelPendingRequest(rejectSignId, 4001, 'User rejected the signature request');
1120
+ processNextRequest();
1121
+ sendResponse({ success: true });
1122
+ } else {
1123
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1124
+ }
1125
+ return;
1126
+
1127
+ case INTERNAL_METHODS.APPROVE_SIGN_RAW_TX:
1128
+ const approveSignRawTxId = payload.params?.[0];
1129
+ const approveSignRawTxPending = pendingRequests.get(approveSignRawTxId);
1130
+
1131
+ if (approveSignRawTxPending && isSignRawTxRequest(approveSignRawTxPending.request)) {
1132
+ const signRawTxRequest = approveSignRawTxPending.request;
1133
+
1134
+ // Check if request has expired (replay prevention)
1135
+ if (isRequestExpired(signRawTxRequest.timestamp)) {
1136
+ cancelPendingRequest(approveSignRawTxId, 4003, 'Request expired');
1137
+ sendResponse({ error: 'Request expired' });
1138
+ return;
1139
+ }
1140
+
1141
+ try {
1142
+ const signature =
1143
+ signRawTxRequest.signWith === 'v0'
1144
+ ? await vault.signRawTxV0({
1145
+ rawTx: signRawTxRequest.rawTx,
1146
+ notes: signRawTxRequest.notes,
1147
+ spendConditions: signRawTxRequest.spendConditions,
1148
+ derivation: signRawTxRequest.v0Derivation || 'master',
1149
+ })
1150
+ : await vault.signRawTx({
1151
+ rawTx: signRawTxRequest.rawTx,
1152
+ notes: signRawTxRequest.notes,
1153
+ spendConditions: signRawTxRequest.spendConditions,
1154
+ });
1155
+ approveSignRawTxPending.sendResponse(signature);
1156
+ cancelPendingRequest(approveSignRawTxId);
1157
+ processNextRequest();
1158
+ sendResponse({ success: true });
1159
+ } catch (err) {
1160
+ console.error('Failed to sign raw transaction:', err);
1161
+ const errorMessage =
1162
+ err instanceof Error ? err.message : 'Failed to sign raw transaction';
1163
+ cancelPendingRequest(approveSignRawTxId, 4001, errorMessage);
1164
+ processNextRequest();
1165
+ sendResponse({ error: errorMessage });
1166
+ }
1167
+ } else {
1168
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1169
+ }
1170
+ return;
1171
+
1172
+ case INTERNAL_METHODS.REJECT_SIGN_RAW_TX:
1173
+ const rejectSignRawTxId = payload.params?.[0];
1174
+ const rejectSignRawTxPending = pendingRequests.get(rejectSignRawTxId);
1175
+ if (rejectSignRawTxPending) {
1176
+ cancelPendingRequest(rejectSignRawTxId, 4001, 'User rejected the signature request');
1177
+ processNextRequest();
1178
+ sendResponse({ success: true });
1179
+ } else {
1180
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1181
+ }
1182
+ return;
1183
+
1184
+ case INTERNAL_METHODS.GET_PENDING_CONNECTION:
1185
+ const getPendingConnectId = payload.params?.[0];
1186
+ const connectPending = pendingRequests.get(getPendingConnectId);
1187
+ if (connectPending && isConnectRequest(connectPending.request)) {
1188
+ sendResponse(connectPending.request);
1189
+ } else {
1190
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1191
+ }
1192
+ return;
1193
+
1194
+ case INTERNAL_METHODS.APPROVE_CONNECTION:
1195
+ const approveConnectId = payload.params?.[0];
1196
+ const approveConnectPending = pendingRequests.get(approveConnectId);
1197
+ if (approveConnectPending && isConnectRequest(approveConnectPending.request)) {
1198
+ const connectRequest = approveConnectPending.request;
1199
+
1200
+ // Check if request has expired (replay prevention)
1201
+ if (isRequestExpired(connectRequest.timestamp)) {
1202
+ cancelPendingRequest(approveConnectId, 4003, 'Request expired');
1203
+ sendResponse({ error: 'Request expired' });
1204
+ return;
1205
+ }
1206
+
1207
+ // Add origin to approved list
1208
+ await approveOrigin(connectRequest.origin);
1209
+
1210
+ // Return wallet info
1211
+ approveConnectPending.sendResponse({
1212
+ pkh: vault.getAddress(),
1213
+ grpcEndpoint: RPC_ENDPOINT,
1214
+ });
1215
+ cancelPendingRequest(approveConnectId);
1216
+ processNextRequest();
1217
+ sendResponse({ success: true });
1218
+
1219
+ // Emit connect event
1220
+ await emitWalletEvent('connect', { chainId: 'nockchain-1' });
1221
+ } else {
1222
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1223
+ }
1224
+ return;
1225
+
1226
+ case INTERNAL_METHODS.REJECT_CONNECTION:
1227
+ const rejectConnectId = payload.params?.[0];
1228
+ const rejectConnectPending = pendingRequests.get(rejectConnectId);
1229
+ if (rejectConnectPending) {
1230
+ cancelPendingRequest(rejectConnectId, 4001, 'User rejected the connection');
1231
+ processNextRequest();
1232
+ sendResponse({ success: true });
1233
+ } else {
1234
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1235
+ }
1236
+ return;
1237
+
1238
+ case INTERNAL_METHODS.GET_PENDING_RAW_TX_REQUEST:
1239
+ const getPendingRawTxId = payload.params?.[0];
1240
+ const rawTxPending = pendingRequests.get(getPendingRawTxId);
1241
+
1242
+ if (rawTxPending && isSignRawTxRequest(rawTxPending.request)) {
1243
+ sendResponse(rawTxPending.request);
1244
+ } else {
1245
+ sendResponse({ error: ERROR_CODES.NOT_FOUND });
1246
+ }
1247
+ return;
1248
+
1249
+ case INTERNAL_METHODS.REVOKE_ORIGIN:
1250
+ const revokeOriginParam = payload.params?.[0];
1251
+ if (
1252
+ revokeOriginParam &&
1253
+ typeof revokeOriginParam === 'object' &&
1254
+ 'origin' in revokeOriginParam
1255
+ ) {
1256
+ await revokeOrigin(revokeOriginParam.origin as string);
1257
+ sendResponse({ success: true });
1258
+ } else {
1259
+ sendResponse({ error: ERROR_CODES.INVALID_PARAMS });
1260
+ }
1261
+ return;
1262
+
1263
+ case INTERNAL_METHODS.SIGN_TRANSACTION:
1264
+ // params: [to, amount, fee]
1265
+ // Called from popup Send screen (not dApp transactions)
1266
+ if (vault.isLocked()) {
1267
+ sendResponse({ error: ERROR_CODES.LOCKED });
1268
+ return;
1269
+ }
1270
+
1271
+ const [signTo, signAmount, signFee] = payload.params || [];
1272
+ if (!isNockAddress(signTo)) {
1273
+ sendResponse({ error: ERROR_CODES.BAD_ADDRESS });
1274
+ return;
1275
+ }
1276
+
1277
+ try {
1278
+ const txid = await vault.signTransaction(signTo, signAmount, signFee);
1279
+ sendResponse({ txid });
1280
+ } catch (error) {
1281
+ console.error('[Background] Transaction signing failed:', error);
1282
+ sendResponse({
1283
+ error: error instanceof Error ? error.message : 'Transaction signing failed',
1284
+ });
1285
+ }
1286
+ return;
1287
+
1288
+ case INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE:
1289
+ // params: [to, amount] - amount in nicks
1290
+ if (vault.isLocked()) {
1291
+ sendResponse({ error: ERROR_CODES.LOCKED });
1292
+ return;
1293
+ }
1294
+
1295
+ const [estimateTo, estimateAmount] = payload.params || [];
1296
+ if (!isNockAddress(estimateTo)) {
1297
+ sendResponse({ error: ERROR_CODES.BAD_ADDRESS });
1298
+ return;
1299
+ }
1300
+
1301
+ if (typeof estimateAmount !== 'number' || estimateAmount <= 0) {
1302
+ sendResponse({ error: 'Invalid amount' });
1303
+ return;
1304
+ }
1305
+
1306
+ try {
1307
+ const result = await vault.estimateTransactionFee(estimateTo, estimateAmount);
1308
+
1309
+ if ('error' in result) {
1310
+ sendResponse({ error: result.error });
1311
+ } else {
1312
+ sendResponse({ fee: result.fee });
1313
+ }
1314
+ } catch (error) {
1315
+ console.error('[Background] Fee estimation error:', error);
1316
+ sendResponse({
1317
+ error: error instanceof Error ? error.message : 'Fee estimation failed',
1318
+ });
1319
+ }
1320
+ return;
1321
+
1322
+ case INTERNAL_METHODS.ESTIMATE_MAX_SEND:
1323
+ // params: [to] - estimates max sendable amount for "send max" feature
1324
+ if (vault.isLocked()) {
1325
+ sendResponse({ error: ERROR_CODES.LOCKED });
1326
+ return;
1327
+ }
1328
+
1329
+ const [maxSendTo] = payload.params || [];
1330
+ if (!isNockAddress(maxSendTo)) {
1331
+ sendResponse({ error: ERROR_CODES.BAD_ADDRESS });
1332
+ return;
1333
+ }
1334
+
1335
+ try {
1336
+ const maxResult = await vault.estimateMaxSendAmount(maxSendTo);
1337
+
1338
+ if ('error' in maxResult) {
1339
+ sendResponse({ error: maxResult.error });
1340
+ } else {
1341
+ sendResponse({
1342
+ maxAmount: maxResult.maxAmount,
1343
+ fee: maxResult.fee,
1344
+ totalAvailable: maxResult.totalAvailable,
1345
+ utxoCount: maxResult.utxoCount,
1346
+ });
1347
+ }
1348
+ } catch (error) {
1349
+ console.error('[Background] Max send estimation error:', error);
1350
+ sendResponse({
1351
+ error: error instanceof Error ? error.message : 'Max send estimation failed',
1352
+ });
1353
+ }
1354
+ return;
1355
+
1356
+ case INTERNAL_METHODS.SEND_TRANSACTION_V2:
1357
+ // params: [to, amount, fee?, sendMax?, priceUsdAtTime?] - amount and fee in nicks
1358
+ // Uses UTXO store for proper note locking and successive transaction support
1359
+ // sendMax: if true, uses all available UTXOs and sets refundPKH = recipient for sweep
1360
+ // priceUsdAtTime: USD price per NOCK at time of transaction (for historical display)
1361
+ if (vault.isLocked()) {
1362
+ sendResponse({ error: ERROR_CODES.LOCKED });
1363
+ return;
1364
+ }
1365
+
1366
+ const [sendToV2, sendAmountV2, sendFeeV2, sendMaxV2, priceUsdAtTimeV2] =
1367
+ payload.params || [];
1368
+ if (!isNockAddress(sendToV2)) {
1369
+ sendResponse({ error: ERROR_CODES.BAD_ADDRESS });
1370
+ return;
1371
+ }
1372
+
1373
+ if (typeof sendAmountV2 !== 'number' || sendAmountV2 <= 0) {
1374
+ sendResponse({ error: 'Invalid amount' });
1375
+ return;
1376
+ }
1377
+
1378
+ try {
1379
+ const v2Result = await vault.sendTransactionV2(
1380
+ sendToV2,
1381
+ sendAmountV2,
1382
+ sendFeeV2, // optional, can be undefined
1383
+ sendMaxV2, // optional, sweep all UTXOs to recipient
1384
+ priceUsdAtTimeV2 // optional, USD price at time of tx
1385
+ );
1386
+
1387
+ if ('error' in v2Result) {
1388
+ sendResponse({ error: v2Result.error });
1389
+ return;
1390
+ }
1391
+
1392
+ sendResponse({
1393
+ txid: v2Result.txId,
1394
+ broadcasted: v2Result.broadcasted,
1395
+ walletTx: v2Result.walletTx,
1396
+ });
1397
+ } catch (error) {
1398
+ console.error('[Background] SendTransactionV2 failed:', error);
1399
+ sendResponse({
1400
+ error: error instanceof Error ? error.message : 'Transaction failed',
1401
+ });
1402
+ }
1403
+ return;
1404
+
1405
+ case INTERNAL_METHODS.SEND_TRANSACTION:
1406
+ // params: [to, amount, fee] - amount and fee in nicks
1407
+ // Called from popup Send screen - builds, signs, and broadcasts transaction
1408
+ if (vault.isLocked()) {
1409
+ sendResponse({ error: ERROR_CODES.LOCKED });
1410
+ return;
1411
+ }
1412
+
1413
+ const [sendTo, sendAmount, sendFee] = payload.params || [];
1414
+ if (!isNockAddress(sendTo)) {
1415
+ sendResponse({ error: ERROR_CODES.BAD_ADDRESS });
1416
+ return;
1417
+ }
1418
+
1419
+ if (typeof sendAmount !== 'number' || sendAmount <= 0) {
1420
+ sendResponse({ error: 'Invalid amount' });
1421
+ return;
1422
+ }
1423
+
1424
+ if (typeof sendFee !== 'number' || sendFee < 0) {
1425
+ sendResponse({ error: 'Invalid fee' });
1426
+ return;
1427
+ }
1428
+
1429
+ try {
1430
+ const result = await vault.sendTransaction(sendTo, sendAmount, sendFee);
1431
+
1432
+ if ('error' in result) {
1433
+ sendResponse({ error: result.error });
1434
+ return;
1435
+ }
1436
+
1437
+ sendResponse({
1438
+ txid: result.txId,
1439
+ broadcasted: result.broadcasted,
1440
+ protobufTx: result.protobufTx, // For dev/debugging - export to file
1441
+ });
1442
+ } catch (error) {
1443
+ console.error('[Background] Transaction sending failed:', error);
1444
+ sendResponse({
1445
+ error: error instanceof Error ? error.message : 'Transaction sending failed',
1446
+ });
1447
+ }
1448
+ return;
1449
+
1450
+ default:
1451
+ sendResponse({ error: ERROR_CODES.METHOD_NOT_SUPPORTED });
1452
+ return;
1453
+ }
1454
+ })();
1455
+ // Required: tells Chrome we'll call sendResponse asynchronously from the IIFE
1456
+ return true;
1457
+ });
1458
+
1459
+ /**
1460
+ * Handle auto-lock alarm
1461
+ */
1462
+ chrome.alarms.onAlarm.addListener(async alarm => {
1463
+ if (alarm.name !== ALARM_NAMES.AUTO_LOCK) return;
1464
+
1465
+ await initPromise;
1466
+ await ensureSessionRestored();
1467
+
1468
+ // Don't auto-lock if set to "never" (0 minutes) - stop the alarm cycle
1469
+ if (autoLockMinutes <= 0) {
1470
+ chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK);
1471
+ return;
1472
+ }
1473
+
1474
+ // Don't auto-lock if user manually locked - respect their choice
1475
+ if (manuallyLocked) {
1476
+ return;
1477
+ }
1478
+
1479
+ const idleMs = Date.now() - lastActivity;
1480
+ if (idleMs >= autoLockMinutes * 60_000) {
1481
+ try {
1482
+ await vault.lock();
1483
+ await clearUnlockSessionCache();
1484
+ // Notify popup to update UI immediately
1485
+ await emitWalletEvent('LOCKED', { reason: 'auto-lock' });
1486
+ } catch (error) {
1487
+ console.error('Auto-lock failed:', error);
1488
+ }
1489
+ }
1490
+ });
1491
+
1492
+ /**
1493
+ * Schedule the auto-lock alarm (runs every minute)
1494
+ */
1495
+ function scheduleAlarm() {
1496
+ chrome.alarms.create(ALARM_NAMES.AUTO_LOCK, {
1497
+ delayInMinutes: 1,
1498
+ periodInMinutes: 1,
1499
+ });
1500
+ }