@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,1580 @@
1
+ /// <reference types="chrome" />
2
+ /**
3
+ * Vault: manages encrypted mnemonic storage and wallet state
4
+ */
5
+
6
+ import { encryptGCM, decryptGCM, deriveKeyPBKDF2, rand, PBKDF2_ITERATIONS } from './webcrypto';
7
+ import {
8
+ generateMnemonic,
9
+ deriveAddress,
10
+ deriveAddressFromMaster,
11
+ validateMnemonic,
12
+ } from './wallet-crypto';
13
+ import {
14
+ ERROR_CODES,
15
+ STORAGE_KEYS,
16
+ ACCOUNT_COLORS,
17
+ PRESET_WALLET_STYLES,
18
+ NOCK_TO_NICKS,
19
+ } from './constants';
20
+ import { Account } from './types';
21
+ import { buildMultiNotePayment, type Note } from './transaction-builder';
22
+ import * as wasm from '@nockchain/rose-wasm/rose_wasm.js';
23
+ import { queryV1Balance } from './balance-query';
24
+ import { createBrowserClient } from './rpc-client-browser';
25
+ import type { Note as BalanceNote } from './types';
26
+ import { base58 } from '@scure/base';
27
+ import { initIrisSdkOnce } from './wasm-utils';
28
+ import { getAccountBalanceSummary } from './utxo-sync';
29
+ import {
30
+ getAvailableNotes,
31
+ markNotesInFlight,
32
+ releaseInFlightNotes,
33
+ withAccountLock,
34
+ addWalletTransaction,
35
+ updateWalletTransaction,
36
+ } from './utxo-store';
37
+ import type { StoredNote, WalletTransaction } from './types';
38
+
39
+ /**
40
+ * Convert a balance query note to transaction builder note format
41
+ * @param note - Note from balance query (with Uint8Array names)
42
+ * @returns Note in format expected by transaction builder
43
+ *
44
+ * NOTE: Prefers pre-computed base58 values from the RPC response to avoid WASM init issues
45
+ */
46
+ async function convertNoteForTxBuilder(note: BalanceNote, ownerPKH: string): Promise<Note> {
47
+ // Use pre-computed base58 strings if available (from WASM gRPC client)
48
+ let nameFirst: string;
49
+ let nameLast: string;
50
+ let noteDataHash: string;
51
+
52
+ if (note.nameFirstBase58 && note.nameLastBase58) {
53
+ nameFirst = note.nameFirstBase58;
54
+ nameLast = note.nameLastBase58;
55
+ } else {
56
+ // Fallback: convert bytes to base58
57
+ nameFirst = base58.encode(note.nameFirst);
58
+ nameLast = base58.encode(note.nameLast);
59
+ }
60
+
61
+ if (note.noteDataHashBase58) {
62
+ noteDataHash = note.noteDataHashBase58;
63
+ } else {
64
+ // Fallback - use protoNote for Note.fromProtobuf()
65
+ console.warn('[Vault] No noteDataHashBase58 - relying on protoNote');
66
+ noteDataHash = '';
67
+ }
68
+
69
+ return {
70
+ originPage: Number(note.originPage),
71
+ nameFirst,
72
+ nameLast,
73
+ noteDataHash,
74
+ assets: note.assets,
75
+ protoNote: note.protoNote,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Convert a stored note to transaction builder note format
81
+ * StoredNotes already have base58 strings, so this is a simple field mapping
82
+ * @param note - Note from UTXO store
83
+ * @returns Note in format expected by transaction builder
84
+ */
85
+ function convertStoredNoteForTxBuilder(note: StoredNote): Note {
86
+ return {
87
+ originPage: note.originPage,
88
+ nameFirst: note.nameFirst,
89
+ nameLast: note.nameLast,
90
+ noteDataHash: note.noteDataHashBase58,
91
+ assets: note.assets,
92
+ protoNote: note.protoNote,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Greedy coin selection algorithm
98
+ * Selects notes (largest first) until we have enough to cover amount + fee
99
+ *
100
+ * @param notes - Available notes
101
+ * @param targetAmount - Amount needed (amount + estimated fee)
102
+ * @returns Selected notes, or null if insufficient funds
103
+ */
104
+ function selectNotesForAmount(notes: StoredNote[], targetAmount: number): StoredNote[] | null {
105
+ // Sort by assets descending (largest first)
106
+ const sorted = [...notes].sort((a, b) => b.assets - a.assets);
107
+
108
+ const selected: StoredNote[] = [];
109
+ let total = 0;
110
+
111
+ for (const note of sorted) {
112
+ selected.push(note);
113
+ total += note.assets;
114
+
115
+ if (total >= targetAmount) {
116
+ return selected;
117
+ }
118
+ }
119
+
120
+ // Not enough funds
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Encrypted vault format
126
+ * Encrypts both mnemonic AND accounts for better privacy
127
+ * Prevents address enumeration from disk/backup without password
128
+ */
129
+ interface EncryptedVault {
130
+ version: 1;
131
+ kdf: {
132
+ name: 'PBKDF2';
133
+ hash: 'SHA-256';
134
+ iterations: number;
135
+ salt: number[]; // PBKDF2 salt for key derivation
136
+ };
137
+ cipher: {
138
+ alg: 'AES-GCM';
139
+ iv: number[]; // AES-GCM initialization vector (12 bytes)
140
+ ct: number[]; // Ciphertext (includes authentication tag, contains VaultPayload)
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The decrypted vault payload
146
+ * Everything sensitive lives inside the encrypted vault
147
+ */
148
+ interface VaultPayload {
149
+ mnemonic: string;
150
+ mnemonicV0: string | null;
151
+ accounts: Account[];
152
+ }
153
+
154
+ interface VaultState {
155
+ locked: boolean;
156
+ accounts: Account[];
157
+ currentAccountIndex: number;
158
+ enc: EncryptedVault | null;
159
+ }
160
+
161
+ export class Vault {
162
+ private state: VaultState = {
163
+ locked: true,
164
+ accounts: [],
165
+ currentAccountIndex: 0,
166
+ enc: null,
167
+ };
168
+
169
+ /** Decrypted mnemonic (only stored in memory while unlocked) */
170
+ private mnemonic: string | null = null;
171
+
172
+ /** Decrypted v0 migration seedphrase (only stored in memory while unlocked) */
173
+ private mnemonicV0: string | null = null;
174
+
175
+ /** Derived encryption key (only stored in memory while unlocked, cleared on lock) */
176
+ private encryptionKey: CryptoKey | null = null;
177
+
178
+ /**
179
+ * Check if a vault exists in storage (without decrypting)
180
+ * This is safe to call even after service worker restart
181
+ * @returns true if encrypted vault exists, false if no vault setup yet
182
+ */
183
+ async hasVault(): Promise<boolean> {
184
+ const stored = await chrome.storage.local.get([STORAGE_KEYS.ENCRYPTED_VAULT]);
185
+ return Boolean(stored[STORAGE_KEYS.ENCRYPTED_VAULT]);
186
+ }
187
+
188
+ /**
189
+ * Initialize vault state from storage (load encrypted header without decrypting)
190
+ * Call this on service worker startup or before checking vault existence
191
+ * Safe to call multiple times (idempotent)
192
+ */
193
+ async init(): Promise<void> {
194
+ // If already loaded, do nothing
195
+ if (this.state.enc) return;
196
+
197
+ const stored = await chrome.storage.local.get([
198
+ STORAGE_KEYS.ENCRYPTED_VAULT,
199
+ STORAGE_KEYS.CURRENT_ACCOUNT_INDEX,
200
+ ]);
201
+
202
+ const enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined;
203
+ if (enc) {
204
+ this.state.enc = enc; // Header is safe to keep in memory
205
+ this.state.locked = true; // Still locked
206
+ this.state.accounts = []; // No plaintext accounts in memory
207
+ this.state.currentAccountIndex =
208
+ (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0;
209
+ } else {
210
+ // No vault yet — keep defaults
211
+ this.state.enc = null;
212
+ this.state.locked = true;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Get UI status without revealing secrets
218
+ * Safe to expose to popup for screen routing
219
+ */
220
+ getUiStatus(): { hasVault: boolean; locked: boolean } {
221
+ return {
222
+ hasVault: Boolean(this.state.enc),
223
+ locked: this.state.locked,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Sets up a new vault with encrypted mnemonic
229
+ * @param password - User password for encryption
230
+ * @param mnemonic - Optional mnemonic for importing existing wallet (otherwise generates new one)
231
+ */
232
+
233
+ async setup(
234
+ password: string,
235
+ mnemonic?: string,
236
+ mnemonicV0?: string
237
+ ): Promise<
238
+ { ok: boolean; address: string; mnemonic: string; mnemonicV0: string } | { error: string }
239
+ > {
240
+ // Generate or validate mnemonic
241
+ const words = mnemonic ? mnemonic.trim() : generateMnemonic();
242
+ const wordsV0 = mnemonicV0 ? mnemonicV0.trim() : '';
243
+
244
+ // Validate imported mnemonic
245
+ if (mnemonic && !validateMnemonic(words)) {
246
+ return { error: ERROR_CODES.INVALID_MNEMONIC };
247
+ }
248
+
249
+ if (mnemonicV0 && !validateMnemonic(wordsV0)) {
250
+ return { error: ERROR_CODES.INVALID_V0_MNEMONIC };
251
+ }
252
+
253
+ // Create first account (Wallet 1 at index 0)
254
+ // Use first preset style for consistent initial experience
255
+ const firstPreset = PRESET_WALLET_STYLES[0];
256
+
257
+ const masterAddress = await deriveAddressFromMaster(words);
258
+
259
+ const firstAccount: Account = {
260
+ name: 'Wallet 1',
261
+ address: masterAddress,
262
+ index: 0,
263
+ iconStyleId: firstPreset.iconStyleId,
264
+ iconColor: firstPreset.iconColor,
265
+ createdAt: Date.now(),
266
+ derivation: 'master',
267
+ };
268
+
269
+ // Generate PBKDF2 salt and derive encryption key
270
+ const kdfSalt = rand(16);
271
+ const { key } = await deriveKeyPBKDF2(password, kdfSalt);
272
+
273
+ // Encrypt both mnemonic AND accounts together
274
+ const vaultPayload: VaultPayload = {
275
+ mnemonic: words,
276
+ mnemonicV0: wordsV0,
277
+ accounts: [firstAccount],
278
+ };
279
+ const payloadJson = JSON.stringify(vaultPayload);
280
+ const { iv, ct } = await encryptGCM(key, new TextEncoder().encode(payloadJson));
281
+
282
+ // Store encrypted vault (arrays for chrome.storage compatibility)
283
+ const encData: EncryptedVault = {
284
+ version: 1,
285
+ kdf: {
286
+ name: 'PBKDF2',
287
+ hash: 'SHA-256',
288
+ iterations: PBKDF2_ITERATIONS,
289
+ salt: Array.from(kdfSalt), // PBKDF2 salt
290
+ },
291
+ cipher: {
292
+ alg: 'AES-GCM',
293
+ iv: Array.from(iv), // AES-GCM IV (12 bytes)
294
+ ct: Array.from(ct), // Ciphertext + auth tag (contains VaultPayload)
295
+ },
296
+ };
297
+
298
+ // Only store encrypted vault and current account index
299
+ // Accounts are inside the encrypted vault, not in plaintext
300
+ await chrome.storage.local.set({
301
+ [STORAGE_KEYS.ENCRYPTED_VAULT]: encData,
302
+ [STORAGE_KEYS.CURRENT_ACCOUNT_INDEX]: 0,
303
+ });
304
+
305
+ // Keep wallet unlocked after setup for smooth onboarding UX
306
+ // Auto-lock timer will handle locking after inactivity
307
+ this.mnemonic = words;
308
+ this.mnemonicV0 = wordsV0;
309
+ this.encryptionKey = key; // Cache the key for account operations (rename, create, etc.)
310
+ this.state = {
311
+ locked: false,
312
+ accounts: [firstAccount],
313
+ currentAccountIndex: 0,
314
+ enc: encData,
315
+ };
316
+
317
+ return { ok: true, address: firstAccount.address, mnemonic: words, mnemonicV0: wordsV0 };
318
+ }
319
+
320
+ /**
321
+ * Unlocks the vault with the provided password
322
+ */
323
+ async unlock(
324
+ password: string
325
+ ): Promise<
326
+ | { ok: boolean; address: string; accounts: Account[]; currentAccount: Account }
327
+ | { error: string }
328
+ > {
329
+ const stored = await chrome.storage.local.get([
330
+ STORAGE_KEYS.ENCRYPTED_VAULT,
331
+ STORAGE_KEYS.CURRENT_ACCOUNT_INDEX,
332
+ ]);
333
+ const enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined;
334
+ const currentAccountIndex =
335
+ (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0;
336
+
337
+ if (!enc) {
338
+ return { error: ERROR_CODES.NO_VAULT };
339
+ }
340
+
341
+ try {
342
+ // Re-derive key using stored KDF parameters (critical for forward compatibility)
343
+ const { key } = await deriveKeyPBKDF2(
344
+ password,
345
+ new Uint8Array(enc.kdf.salt),
346
+ enc.kdf.iterations,
347
+ enc.kdf.hash
348
+ );
349
+
350
+ // Decrypt the vault
351
+ const pt = await decryptGCM(
352
+ key,
353
+ new Uint8Array(enc.cipher.iv),
354
+ new Uint8Array(enc.cipher.ct)
355
+ ).catch(() => null);
356
+
357
+ if (!pt) {
358
+ return { error: ERROR_CODES.BAD_PASSWORD };
359
+ }
360
+
361
+ // Parse vault payload (mnemonic + accounts)
362
+ const payload = JSON.parse(pt) as VaultPayload;
363
+ const mnemonic = payload.mnemonic;
364
+ const accounts = payload.accounts;
365
+
366
+ // Store decrypted data in memory (only after successful decrypt)
367
+ this.mnemonic = mnemonic;
368
+ this.mnemonicV0 = payload.mnemonicV0 ?? null;
369
+ this.encryptionKey = key; // Cache key for account operations
370
+
371
+ this.state = {
372
+ locked: false,
373
+ accounts,
374
+ currentAccountIndex,
375
+ enc,
376
+ };
377
+
378
+ const currentAccount = accounts[currentAccountIndex] || accounts[0];
379
+ return {
380
+ ok: true,
381
+ address: currentAccount?.address || '',
382
+ accounts,
383
+ currentAccount,
384
+ };
385
+ } catch (err) {
386
+ return { error: ERROR_CODES.BAD_PASSWORD };
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Unlocks the vault using a cached encryption key (used for session restore)
392
+ */
393
+ async unlockWithKey(
394
+ key: CryptoKey
395
+ ): Promise<
396
+ | { ok: boolean; address: string; accounts: Account[]; currentAccount: Account }
397
+ | { error: string }
398
+ > {
399
+ const stored = await chrome.storage.local.get([
400
+ STORAGE_KEYS.ENCRYPTED_VAULT,
401
+ STORAGE_KEYS.CURRENT_ACCOUNT_INDEX,
402
+ ]);
403
+ const enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined;
404
+ const currentAccountIndex =
405
+ (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0;
406
+
407
+ if (!enc) {
408
+ return { error: ERROR_CODES.NO_VAULT };
409
+ }
410
+
411
+ const pt = await decryptGCM(
412
+ key,
413
+ new Uint8Array(enc.cipher.iv),
414
+ new Uint8Array(enc.cipher.ct)
415
+ ).catch(() => null);
416
+
417
+ if (!pt) {
418
+ return { error: ERROR_CODES.BAD_PASSWORD };
419
+ }
420
+
421
+ const payload = JSON.parse(pt) as VaultPayload;
422
+ const accounts = payload.accounts;
423
+
424
+ this.mnemonic = payload.mnemonic;
425
+ this.mnemonicV0 = payload.mnemonicV0 ?? null;
426
+ this.encryptionKey = key;
427
+
428
+ this.state = {
429
+ locked: false,
430
+ accounts,
431
+ currentAccountIndex,
432
+ enc,
433
+ };
434
+
435
+ const currentAccount = accounts[currentAccountIndex] || accounts[0];
436
+ return {
437
+ ok: true,
438
+ address: currentAccount?.address || '',
439
+ accounts,
440
+ currentAccount,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Returns the cached encryption key (null when locked)
446
+ */
447
+ getEncryptionKey(): CryptoKey | null {
448
+ return this.encryptionKey;
449
+ }
450
+
451
+ /**
452
+ * Helper method to save accounts back to the encrypted vault
453
+ * Called whenever accounts are modified (create, rename, update styling, hide)
454
+ * Requires wallet to be unlocked (encryptionKey must be in memory)
455
+ */
456
+ private async saveAccountsToVault(): Promise<void> {
457
+ if (!this.mnemonic || !this.state.enc || !this.encryptionKey) {
458
+ throw new Error('Cannot save accounts: vault is locked or not initialized');
459
+ }
460
+
461
+ // Re-encrypt mnemonic + accounts together with the key stored in memory
462
+ const vaultPayload: VaultPayload = {
463
+ mnemonic: this.mnemonic,
464
+ mnemonicV0: this.mnemonicV0,
465
+ accounts: this.state.accounts,
466
+ };
467
+ const payloadJson = JSON.stringify(vaultPayload);
468
+ const { iv, ct } = await encryptGCM(this.encryptionKey, new TextEncoder().encode(payloadJson));
469
+
470
+ // Update the encrypted vault with new IV and ciphertext
471
+ const encData: EncryptedVault = {
472
+ version: 1,
473
+ kdf: this.state.enc.kdf, // Reuse same KDF parameters (salt, iterations)
474
+ cipher: {
475
+ alg: 'AES-GCM',
476
+ iv: Array.from(iv),
477
+ ct: Array.from(ct),
478
+ },
479
+ };
480
+
481
+ // Save updated vault to storage
482
+ await chrome.storage.local.set({
483
+ [STORAGE_KEYS.ENCRYPTED_VAULT]: encData,
484
+ });
485
+
486
+ // Update in-memory state
487
+ this.state.enc = encData;
488
+ }
489
+
490
+ /**
491
+ * Locks the vault
492
+ */
493
+ async lock(): Promise<{ ok: boolean }> {
494
+ this.state.locked = true;
495
+ // Clear sensitive data from memory for security
496
+ this.state.accounts = []; // Clear accounts to enforce "no addresses while locked"
497
+ this.mnemonic = null;
498
+ this.mnemonicV0 = null;
499
+ this.encryptionKey = null;
500
+ return { ok: true };
501
+ }
502
+
503
+ /**
504
+ * Resets/deletes the wallet completely (clears all data)
505
+ */
506
+ async reset(): Promise<{ ok: boolean }> {
507
+ // Clear all storage
508
+ await chrome.storage.local.clear();
509
+
510
+ // Reset in-memory state
511
+ this.state = {
512
+ locked: true,
513
+ accounts: [],
514
+ currentAccountIndex: 0,
515
+ enc: null,
516
+ };
517
+ this.mnemonic = null;
518
+ this.mnemonicV0 = null;
519
+ this.encryptionKey = null; // Clear encryption key as well
520
+
521
+ return { ok: true };
522
+ }
523
+
524
+ /**
525
+ * Returns whether the vault is currently locked
526
+ */
527
+ isLocked(): boolean {
528
+ return this.state.locked;
529
+ }
530
+
531
+ /**
532
+ * Gets the current account
533
+ */
534
+ getCurrentAccount(): Account | null {
535
+ const account = this.state.accounts[this.state.currentAccountIndex];
536
+ return account || this.state.accounts[0] || null;
537
+ }
538
+
539
+ /**
540
+ * Gets the current address (only when unlocked)
541
+ */
542
+ getAddress(): string {
543
+ const account = this.getCurrentAccount();
544
+ return account?.address || '';
545
+ }
546
+
547
+ /**
548
+ * Gets all accounts
549
+ */
550
+ getAccounts(): Account[] {
551
+ return this.state.accounts;
552
+ }
553
+
554
+ /**
555
+ * Gets the address safely (even when locked, from storage)
556
+ * NOTE: Accounts are encrypted, so this only works when unlocked
557
+ * This is intentional - better privacy, addresses not accessible without password
558
+ */
559
+ async getAddressSafe(): Promise<string> {
560
+ // If unlocked, return from memory
561
+ if (this.state.accounts.length > 0) {
562
+ const currentAccount =
563
+ this.state.accounts[this.state.currentAccountIndex] || this.state.accounts[0];
564
+ return currentAccount.address;
565
+ }
566
+
567
+ // Accounts are encrypted, cannot read while locked
568
+ return '';
569
+ }
570
+
571
+ /**
572
+ * Gets balance from the UTXO store for an account
573
+ * Returns available balance (excludes in-flight notes)
574
+ */
575
+ async getBalanceFromStore(accountAddress: string): Promise<{
576
+ available: number;
577
+ spendableNow: number;
578
+ pendingOut: number;
579
+ pendingChange: number;
580
+ total: number;
581
+ utxoCount: number;
582
+ availableUtxoCount: number;
583
+ }> {
584
+ return getAccountBalanceSummary(accountAddress);
585
+ }
586
+
587
+ /**
588
+ * Creates a new account by deriving the next index
589
+ */
590
+ async createAccount(
591
+ name?: string
592
+ ): Promise<{ ok: boolean; account: Account } | { error: string }> {
593
+ if (this.state.locked) {
594
+ return { error: ERROR_CODES.LOCKED };
595
+ }
596
+
597
+ if (!this.mnemonic) {
598
+ return { error: ERROR_CODES.NO_VAULT };
599
+ }
600
+
601
+ const nextIndex = this.state.accounts.length;
602
+ const accountName = name || `Wallet ${nextIndex + 1}`;
603
+
604
+ // Use preset style if available, otherwise random
605
+ let iconStyleId: number;
606
+ let iconColor: string;
607
+
608
+ if (nextIndex < PRESET_WALLET_STYLES.length) {
609
+ // Use predetermined style for first 21 wallets
610
+ const preset = PRESET_WALLET_STYLES[nextIndex];
611
+ iconStyleId = preset.iconStyleId;
612
+ iconColor = preset.iconColor;
613
+ } else {
614
+ // After presets exhausted, use random selection
615
+ iconColor = ACCOUNT_COLORS[Math.floor(Math.random() * ACCOUNT_COLORS.length)];
616
+ iconStyleId = Math.floor(Math.random() * 15) + 1;
617
+ }
618
+
619
+ const newAccount: Account = {
620
+ name: accountName,
621
+ address: await deriveAddress(this.mnemonic, nextIndex),
622
+ index: nextIndex,
623
+ iconStyleId,
624
+ iconColor,
625
+ createdAt: Date.now(),
626
+ derivation: 'slip10', // Additional accounts use child derivation
627
+ };
628
+
629
+ const updatedAccounts = [...this.state.accounts, newAccount];
630
+ this.state.accounts = updatedAccounts;
631
+
632
+ // Save accounts to encrypted vault
633
+ await this.saveAccountsToVault();
634
+
635
+ return { ok: true, account: newAccount };
636
+ }
637
+
638
+ /**
639
+ * Switches to a different account
640
+ */
641
+ async switchAccount(
642
+ index: number
643
+ ): Promise<{ ok: boolean; account: Account } | { error: string }> {
644
+ if (this.state.locked) {
645
+ return { error: ERROR_CODES.LOCKED };
646
+ }
647
+
648
+ if (index < 0 || index >= this.state.accounts.length) {
649
+ return { error: ERROR_CODES.INVALID_ACCOUNT_INDEX };
650
+ }
651
+
652
+ this.state.currentAccountIndex = index;
653
+
654
+ await chrome.storage.local.set({
655
+ [STORAGE_KEYS.CURRENT_ACCOUNT_INDEX]: index,
656
+ });
657
+
658
+ return { ok: true, account: this.state.accounts[index] };
659
+ }
660
+
661
+ /**
662
+ * Renames an account
663
+ */
664
+ async renameAccount(index: number, name: string): Promise<{ ok: boolean } | { error: string }> {
665
+ if (this.state.locked) {
666
+ return { error: ERROR_CODES.LOCKED };
667
+ }
668
+
669
+ if (index < 0 || index >= this.state.accounts.length) {
670
+ return { error: ERROR_CODES.INVALID_ACCOUNT_INDEX };
671
+ }
672
+
673
+ this.state.accounts[index].name = name;
674
+
675
+ // Save accounts to encrypted vault
676
+ await this.saveAccountsToVault();
677
+
678
+ return { ok: true };
679
+ }
680
+
681
+ /**
682
+ * Updates account styling (icon and color)
683
+ */
684
+ async updateAccountStyling(
685
+ index: number,
686
+ iconStyleId: number,
687
+ iconColor: string
688
+ ): Promise<{ ok: boolean } | { error: string }> {
689
+ if (this.state.locked) {
690
+ return { error: ERROR_CODES.LOCKED };
691
+ }
692
+
693
+ if (index < 0 || index >= this.state.accounts.length) {
694
+ return { error: ERROR_CODES.INVALID_ACCOUNT_INDEX };
695
+ }
696
+
697
+ this.state.accounts[index].iconStyleId = iconStyleId;
698
+ this.state.accounts[index].iconColor = iconColor;
699
+
700
+ // Save accounts to encrypted vault
701
+ await this.saveAccountsToVault();
702
+
703
+ return { ok: true };
704
+ }
705
+
706
+ /**
707
+ * Hides an account from the UI
708
+ * - Auto-switches to first visible account if hiding current account
709
+ * - Prevents hiding if it's the last visible account
710
+ */
711
+ async hideAccount(
712
+ index: number
713
+ ): Promise<{ ok: boolean; switchedTo?: number } | { error: string }> {
714
+ if (this.state.locked) {
715
+ return { error: ERROR_CODES.LOCKED };
716
+ }
717
+
718
+ if (index < 0 || index >= this.state.accounts.length) {
719
+ return { error: ERROR_CODES.INVALID_ACCOUNT_INDEX };
720
+ }
721
+
722
+ // Check if this is the last visible account
723
+ const visibleAccounts = this.state.accounts.filter(acc => !acc.hidden);
724
+ if (visibleAccounts.length <= 1) {
725
+ return { error: ERROR_CODES.CANNOT_HIDE_LAST_ACCOUNT };
726
+ }
727
+
728
+ // Mark account as hidden
729
+ this.state.accounts[index].hidden = true;
730
+
731
+ let switchedTo: number | undefined;
732
+
733
+ // If hiding the current account, switch to first visible account
734
+ if (this.state.currentAccountIndex === index) {
735
+ const firstVisibleIndex = this.state.accounts.findIndex(acc => !acc.hidden);
736
+ if (firstVisibleIndex !== -1) {
737
+ this.state.currentAccountIndex = firstVisibleIndex;
738
+ switchedTo = firstVisibleIndex;
739
+ await chrome.storage.local.set({
740
+ [STORAGE_KEYS.CURRENT_ACCOUNT_INDEX]: firstVisibleIndex,
741
+ });
742
+ }
743
+ }
744
+
745
+ // Save accounts to encrypted vault
746
+ await this.saveAccountsToVault();
747
+
748
+ return { ok: true, switchedTo };
749
+ }
750
+
751
+ /**
752
+ * Gets the mnemonic phrase (only when unlocked)
753
+ * Requires password verification for security
754
+ */
755
+ async getMnemonic(
756
+ password: string
757
+ ): Promise<{ ok: boolean; mnemonic: string } | { error: string }> {
758
+ if (this.state.locked) {
759
+ return { error: ERROR_CODES.LOCKED };
760
+ }
761
+
762
+ if (!this.state.enc) {
763
+ return { error: ERROR_CODES.NO_VAULT };
764
+ }
765
+
766
+ // Re-verify password before revealing mnemonic
767
+ try {
768
+ const { key } = await deriveKeyPBKDF2(
769
+ password,
770
+ new Uint8Array(this.state.enc.kdf.salt),
771
+ this.state.enc.kdf.iterations,
772
+ this.state.enc.kdf.hash
773
+ );
774
+
775
+ const pt = await decryptGCM(
776
+ key,
777
+ new Uint8Array(this.state.enc.cipher.iv),
778
+ new Uint8Array(this.state.enc.cipher.ct)
779
+ ).catch(() => null);
780
+
781
+ if (!pt) {
782
+ return { error: ERROR_CODES.BAD_PASSWORD };
783
+ }
784
+
785
+ // Parse the vault payload and return only the mnemonic
786
+ const payload = JSON.parse(pt) as VaultPayload;
787
+ return { ok: true, mnemonic: payload.mnemonic };
788
+ } catch (err) {
789
+ return { error: ERROR_CODES.BAD_PASSWORD };
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Signs a message using Nockchain WASM cryptography
795
+ * Derives the account's private key and signs the message digest
796
+ * @returns Object containing signature JSON and public key (hex-encoded)
797
+ */
798
+ async signMessage(params: unknown): Promise<{ signature: string; publicKeyHex: string }> {
799
+ if (this.state.locked || !this.mnemonic) {
800
+ throw new Error('Wallet is locked');
801
+ }
802
+
803
+ // Initialize WASM modules (once per context)
804
+ await initIrisSdkOnce();
805
+
806
+ const msg = (Array.isArray(params) ? params[0] : params) ?? '';
807
+ const msgString = String(msg);
808
+
809
+ // Derive the account's private key based on derivation method
810
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic, '');
811
+ const currentAccount = this.getCurrentAccount();
812
+ // Use the account's own index, not currentAccountIndex (accounts may be reordered)
813
+ const childIndex = currentAccount?.index ?? this.state.currentAccountIndex;
814
+ const accountKey =
815
+ currentAccount?.derivation === 'master'
816
+ ? masterKey // Use master key directly for master-derived accounts
817
+ : masterKey.deriveChild(childIndex); // Use child derivation for slip10 accounts
818
+
819
+ if (!accountKey.privateKey || !accountKey.publicKey) {
820
+ if (currentAccount?.derivation !== 'master') {
821
+ accountKey.free();
822
+ }
823
+ masterKey.free();
824
+ throw new Error('Cannot sign: no private key available');
825
+ }
826
+
827
+ // Sign the message
828
+ const signature = wasm.signMessage(accountKey.privateKey, msgString);
829
+
830
+ // Convert signature to JSON format
831
+ const signatureJson = JSON.stringify({
832
+ c: Array.from(signature.c),
833
+ s: Array.from(signature.s),
834
+ });
835
+
836
+ // Convert public key to hex string for easy transport
837
+ const publicKeyHex = Array.from(accountKey.publicKey)
838
+ .map(b => b.toString(16).padStart(2, '0'))
839
+ .join('');
840
+
841
+ // Clean up WASM memory
842
+ signature.free();
843
+ if (currentAccount?.derivation !== 'master') {
844
+ accountKey.free();
845
+ }
846
+ masterKey.free();
847
+
848
+ // Return the signature JSON and public key
849
+ return {
850
+ signature: signatureJson,
851
+ publicKeyHex,
852
+ };
853
+ }
854
+
855
+ /**
856
+ * Signs a V1 transaction using Nockchain WASM cryptography
857
+ * Derives the account's private key and builds/signs the transaction
858
+ *
859
+ * @param to - Recipient PKH address (base58-encoded digest string)
860
+ * @param amount - Amount in nicks
861
+ * @param fee - Transaction fee in nicks
862
+ * @returns Transaction ID as digest string
863
+ */
864
+ async signTransaction(to: string, amount: number, fee?: number): Promise<string> {
865
+ if (this.state.locked || !this.mnemonic) {
866
+ throw new Error('Wallet is locked');
867
+ }
868
+
869
+ const currentAccount = this.getCurrentAccount();
870
+ if (!currentAccount) {
871
+ throw new Error('No account selected');
872
+ }
873
+
874
+ // Initialize WASM modules
875
+ await initIrisSdkOnce();
876
+
877
+ // Derive the account's private and public keys based on derivation method
878
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic, '');
879
+ // Use the account's own index, not currentAccountIndex (accounts may be reordered)
880
+ const childIndex = currentAccount?.index ?? this.state.currentAccountIndex;
881
+ const accountKey =
882
+ currentAccount.derivation === 'master'
883
+ ? masterKey // Use master key directly for master-derived accounts
884
+ : masterKey.deriveChild(childIndex); // Use child derivation for slip10 accounts
885
+
886
+ if (!accountKey.privateKey || !accountKey.publicKey) {
887
+ if (currentAccount.derivation !== 'master') {
888
+ accountKey.free();
889
+ }
890
+ masterKey.free();
891
+ throw new Error('Cannot sign: keys unavailable');
892
+ }
893
+
894
+ try {
895
+ // Create RPC client
896
+ const rpcClient = createBrowserClient();
897
+ const balanceResult = await queryV1Balance(currentAccount.address, rpcClient);
898
+
899
+ if (balanceResult.utxoCount === 0) {
900
+ throw new Error('No UTXOs available. Your wallet may have zero balance.');
901
+ }
902
+
903
+ // Combine simple and coinbase notes
904
+ const notes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes];
905
+
906
+ // Convert ALL notes to transaction builder format
907
+ // WASM will automatically select the minimum number needed
908
+ const txBuilderNotes = await Promise.all(
909
+ notes.map(note => convertNoteForTxBuilder(note, currentAccount.address))
910
+ );
911
+
912
+ // Build and sign the transaction
913
+ // WASM will automatically select the minimum number of notes needed
914
+ const constructedTx = await buildMultiNotePayment(
915
+ txBuilderNotes,
916
+ to,
917
+ amount,
918
+ accountKey.publicKey,
919
+ accountKey.privateKey,
920
+ fee
921
+ );
922
+
923
+ // Return constructed transaction (for caller to broadcast)
924
+ return constructedTx.txId;
925
+ } finally {
926
+ // Clean up WASM memory (don't double-free master key)
927
+ if (currentAccount.derivation !== 'master') {
928
+ accountKey.free();
929
+ }
930
+ masterKey.free();
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Estimate transaction fee by building (but not broadcasting) a tx via WASM
936
+ * Uses the same path as real sends (buildMultiNotePayment) so it's SW-safe
937
+ *
938
+ * @param to - Recipient PKH address (base58-encoded)
939
+ * @param amount - Amount in nicks
940
+ * @returns Estimated fee in nicks, or { error } if estimation fails
941
+ */
942
+ async estimateTransactionFee(
943
+ to: string,
944
+ amount: number
945
+ ): Promise<{ fee: number } | { error: string }> {
946
+ if (this.state.locked || !this.mnemonic) {
947
+ return { error: ERROR_CODES.LOCKED };
948
+ }
949
+
950
+ const currentAccount = this.getCurrentAccount();
951
+ if (!currentAccount) {
952
+ return { error: ERROR_CODES.NO_ACCOUNT };
953
+ }
954
+
955
+ try {
956
+ // Initialize WASM modules (same as sign/send)
957
+ await initIrisSdkOnce();
958
+
959
+ // Derive keys
960
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic, '');
961
+ const childIndex = currentAccount.index ?? this.state.currentAccountIndex;
962
+ const accountKey =
963
+ currentAccount.derivation === 'master' ? masterKey : masterKey.deriveChild(childIndex);
964
+
965
+ if (!accountKey.privateKey || !accountKey.publicKey) {
966
+ if (currentAccount.derivation !== 'master') {
967
+ accountKey.free();
968
+ }
969
+ masterKey.free();
970
+ return { error: 'Cannot estimate fee: keys unavailable' };
971
+ }
972
+
973
+ try {
974
+ const rpcClient = createBrowserClient();
975
+ const balanceResult = await queryV1Balance(currentAccount.address, rpcClient);
976
+
977
+ if (balanceResult.utxoCount === 0) {
978
+ return { error: 'No UTXOs available. Your wallet may have zero balance.' };
979
+ }
980
+
981
+ const notes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes];
982
+
983
+ // Sort UTXOs largest to smallest (WASM will select which ones to use)
984
+ const sortedNotes = [...notes].sort((a, b) => b.assets - a.assets);
985
+
986
+ // Convert ALL notes to transaction builder format
987
+ // WASM will automatically select the optimal inputs
988
+ const txBuilderNotes = await Promise.all(
989
+ sortedNotes.map(note => convertNoteForTxBuilder(note, currentAccount.address))
990
+ );
991
+
992
+ // Build a tx with fee = undefined → WASM auto-calculates using DEFAULT_FEE_PER_WORD
993
+ // The builder calculates the exact fee needed
994
+ const constructedTx = await buildMultiNotePayment(
995
+ txBuilderNotes,
996
+ to,
997
+ amount,
998
+ accountKey.publicKey,
999
+ accountKey.privateKey,
1000
+ undefined // let WASM auto-calc
1001
+ );
1002
+
1003
+ // Get the calculated fee from the builder
1004
+ return { fee: constructedTx.feeUsed };
1005
+ } finally {
1006
+ if (currentAccount.derivation !== 'master') {
1007
+ accountKey.free();
1008
+ }
1009
+ masterKey.free();
1010
+ }
1011
+ } catch (error) {
1012
+ console.error('[Vault] Fee estimation failed:', error);
1013
+ return {
1014
+ error: 'Fee estimation failed: ' + (error instanceof Error ? error.message : String(error)),
1015
+ };
1016
+ }
1017
+ }
1018
+
1019
+ /**
1020
+ * Estimate the maximum amount that can be sent (for "send max" feature)
1021
+ *
1022
+ * This calculates: maxAmount = totalSpendableBalance - fee
1023
+ * Where fee is calculated for a sweep transaction (all UTXOs → 1 output)
1024
+ *
1025
+ * Uses refundPKH = recipientPKH so WASM creates 1 consolidated output,
1026
+ * giving us the exact fee for a sweep transaction.
1027
+ *
1028
+ * @param to - Recipient PKH address (base58-encoded)
1029
+ * @returns Max sendable amount and fee in nicks, or { error }
1030
+ */
1031
+ async estimateMaxSendAmount(
1032
+ to: string
1033
+ ): Promise<
1034
+ | { maxAmount: number; fee: number; totalAvailable: number; utxoCount: number }
1035
+ | { error: string }
1036
+ > {
1037
+ if (this.state.locked || !this.mnemonic) {
1038
+ return { error: ERROR_CODES.LOCKED };
1039
+ }
1040
+
1041
+ const currentAccount = this.getCurrentAccount();
1042
+ if (!currentAccount) {
1043
+ return { error: ERROR_CODES.NO_ACCOUNT };
1044
+ }
1045
+
1046
+ try {
1047
+ // Initialize WASM modules
1048
+ await initIrisSdkOnce();
1049
+
1050
+ // Derive keys
1051
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic, '');
1052
+ const childIndex = currentAccount.index ?? this.state.currentAccountIndex;
1053
+ const accountKey =
1054
+ currentAccount.derivation === 'master' ? masterKey : masterKey.deriveChild(childIndex);
1055
+
1056
+ if (!accountKey.privateKey || !accountKey.publicKey) {
1057
+ if (currentAccount.derivation !== 'master') {
1058
+ accountKey.free();
1059
+ }
1060
+ masterKey.free();
1061
+ return { error: 'Cannot estimate max: keys unavailable' };
1062
+ }
1063
+
1064
+ try {
1065
+ // Get available (not in-flight) notes from UTXO store
1066
+ const notes = await getAvailableNotes(currentAccount.address);
1067
+
1068
+ if (notes.length === 0) {
1069
+ return { error: 'No spendable UTXOs available.' };
1070
+ }
1071
+
1072
+ const totalAvailable = notes.reduce((sum, note) => sum + note.assets, 0);
1073
+
1074
+ // Convert stored notes to transaction builder format
1075
+ const txBuilderNotes = notes.map(convertStoredNoteForTxBuilder);
1076
+
1077
+ // Build a sweep transaction to get exact fee:
1078
+ // - Set refundPKH = recipientPKH (sweep mode: 1 consolidated output)
1079
+ // - WASM's simpleSpend selects minimum notes needed for the amount
1080
+ // - To force ALL notes to be used, pass an amount that REQUIRES all notes
1081
+ // - We pass (totalAvailable - smallestNote/2) so removing any note would be insufficient
1082
+ const sortedByValue = [...notes].sort((a, b) => a.assets - b.assets);
1083
+ const smallestNote = sortedByValue[0].assets;
1084
+ // Amount that requires all notes: total minus half the smallest note
1085
+ // This ensures WASM cannot satisfy the amount without using every note
1086
+ const estimationAmount = totalAvailable - Math.floor(smallestNote / 2);
1087
+
1088
+ if (estimationAmount <= 0) {
1089
+ return { error: 'Balance too low to send. Need more than fee amount.' };
1090
+ }
1091
+
1092
+ const constructedTx = await buildMultiNotePayment(
1093
+ txBuilderNotes,
1094
+ to,
1095
+ estimationAmount,
1096
+ accountKey.publicKey,
1097
+ accountKey.privateKey,
1098
+ undefined, // let WASM auto-calc fee
1099
+ to // refundPKH = recipient (sweep mode)
1100
+ );
1101
+
1102
+ const fee = constructedTx.feeUsed;
1103
+ const maxAmount = totalAvailable - fee;
1104
+
1105
+ if (maxAmount <= 0) {
1106
+ return { error: 'Balance too low. Fee would exceed available funds.' };
1107
+ }
1108
+
1109
+ return {
1110
+ maxAmount,
1111
+ fee,
1112
+ totalAvailable,
1113
+ utxoCount: notes.length,
1114
+ };
1115
+ } finally {
1116
+ if (currentAccount.derivation !== 'master') {
1117
+ accountKey.free();
1118
+ }
1119
+ masterKey.free();
1120
+ }
1121
+ } catch (error) {
1122
+ console.error('[Vault] Max send estimation failed:', error);
1123
+ return {
1124
+ error:
1125
+ 'Max send estimation failed: ' + (error instanceof Error ? error.message : String(error)),
1126
+ };
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Send a transaction to the network
1132
+ * This is the high-level API for sending NOCK to a recipient
1133
+ *
1134
+ * @param to - Recipient PKH address (base58-encoded digest string)
1135
+ * @param amount - Amount in nicks
1136
+ * @param fee - Transaction fee in nicks
1137
+ * @returns Transaction ID and broadcast status
1138
+ */
1139
+ async sendTransaction(
1140
+ to: string,
1141
+ amount: number,
1142
+ fee?: number
1143
+ ): Promise<{ txId: string; broadcasted: boolean; protobufTx?: any } | { error: string }> {
1144
+ if (this.state.locked || !this.mnemonic) {
1145
+ return { error: ERROR_CODES.LOCKED };
1146
+ }
1147
+
1148
+ const currentAccount = this.getCurrentAccount();
1149
+ if (!currentAccount) {
1150
+ return { error: ERROR_CODES.NO_ACCOUNT };
1151
+ }
1152
+
1153
+ try {
1154
+ // Initialize WASM modules
1155
+ await initIrisSdkOnce();
1156
+
1157
+ // Derive the account's private and public keys based on derivation method
1158
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic, '');
1159
+ // Use the account's own index, not currentAccountIndex (accounts may be reordered)
1160
+ const childIndex = currentAccount?.index ?? this.state.currentAccountIndex;
1161
+ const accountKey =
1162
+ currentAccount.derivation === 'master'
1163
+ ? masterKey // Use master key directly for master-derived accounts
1164
+ : masterKey.deriveChild(childIndex); // Use child derivation for slip10 accounts
1165
+
1166
+ if (!accountKey.privateKey || !accountKey.publicKey) {
1167
+ if (currentAccount.derivation !== 'master') {
1168
+ accountKey.free();
1169
+ }
1170
+ masterKey.free();
1171
+ return { error: 'Keys unavailable' };
1172
+ }
1173
+
1174
+ try {
1175
+ // Create RPC client
1176
+ const rpcClient = createBrowserClient();
1177
+ const balanceResult = await queryV1Balance(currentAccount.address, rpcClient);
1178
+
1179
+ if (balanceResult.utxoCount === 0) {
1180
+ return { error: 'No UTXOs available. Your wallet may have zero balance.' };
1181
+ }
1182
+
1183
+ // Combine simple and coinbase notes
1184
+ const notes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes];
1185
+ const sortedNotes = [...notes].sort((a, b) => b.assets - a.assets);
1186
+
1187
+ // Convert ALL notes to transaction builder format
1188
+ // WASM will automatically select the optimal inputs
1189
+ const txBuilderNotes = await Promise.all(
1190
+ sortedNotes.map(note => convertNoteForTxBuilder(note, currentAccount.address))
1191
+ );
1192
+
1193
+ // Build and sign the transaction
1194
+ // WASM will automatically select the minimum number of notes needed
1195
+ const constructedTx = await buildMultiNotePayment(
1196
+ txBuilderNotes,
1197
+ to,
1198
+ amount,
1199
+ accountKey.publicKey,
1200
+ accountKey.privateKey,
1201
+ fee
1202
+ );
1203
+
1204
+ // Convert to protobuf format for gRPC and broadcast
1205
+ const protobufTx = constructedTx.nockchainTx.toRawTx().toProtobuf();
1206
+ await rpcClient.sendTransaction(protobufTx);
1207
+
1208
+ return {
1209
+ txId: constructedTx.txId,
1210
+ broadcasted: true,
1211
+ protobufTx, // Include protobuf for debugging/export
1212
+ };
1213
+ } finally {
1214
+ // Clean up WASM memory
1215
+ if (currentAccount.derivation !== 'master') {
1216
+ accountKey.free();
1217
+ }
1218
+ masterKey.free();
1219
+ }
1220
+ } catch (error) {
1221
+ console.error('[Vault] Error sending transaction:', error);
1222
+ return {
1223
+ error: `Failed to send transaction: ${error instanceof Error ? error.message : String(error)}`,
1224
+ };
1225
+ }
1226
+ }
1227
+
1228
+ /**
1229
+ * Build, sign, and broadcast a transaction using UTXO store
1230
+ * This is the new preferred method for sending transactions
1231
+ *
1232
+ * Uses the account mutex to prevent race conditions on rapid sends.
1233
+ * Locks notes before building and releases them on failure.
1234
+ *
1235
+ * @param to - Recipient PKH address
1236
+ * @param amount - Amount in nicks
1237
+ * @param fee - Fee in nicks (optional, WASM will calculate if not provided)
1238
+ * @param sendMax - If true, sweep all available UTXOs to recipient (no change back)
1239
+ * @param priceUsdAtTime - USD price per NOCK at time of transaction (for historical display)
1240
+ * @returns Transaction result with txId and wallet transaction record
1241
+ */
1242
+ async sendTransactionV2(
1243
+ to: string,
1244
+ amount: number,
1245
+ fee?: number,
1246
+ sendMax?: boolean,
1247
+ priceUsdAtTime?: number
1248
+ ): Promise<
1249
+ { txId: string; walletTx: WalletTransaction; broadcasted: boolean } | { error: string }
1250
+ > {
1251
+ if (this.state.locked || !this.mnemonic) {
1252
+ return { error: ERROR_CODES.LOCKED };
1253
+ }
1254
+
1255
+ const currentAccount = this.getCurrentAccount();
1256
+ if (!currentAccount) {
1257
+ return { error: ERROR_CODES.NO_ACCOUNT };
1258
+ }
1259
+
1260
+ // Use account lock to prevent race conditions
1261
+ return withAccountLock(currentAccount.address, async () => {
1262
+ // Generate wallet transaction ID upfront
1263
+ const walletTxId = crypto.randomUUID();
1264
+ let selectedNoteIds: string[] = [];
1265
+
1266
+ try {
1267
+ // Initialize WASM modules
1268
+ await initIrisSdkOnce();
1269
+
1270
+ // Derive keys
1271
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic!, '');
1272
+ const childIndex = currentAccount.index ?? this.state.currentAccountIndex;
1273
+ const accountKey =
1274
+ currentAccount.derivation === 'master' ? masterKey : masterKey.deriveChild(childIndex);
1275
+
1276
+ if (!accountKey.privateKey || !accountKey.publicKey) {
1277
+ if (currentAccount.derivation !== 'master') {
1278
+ accountKey.free();
1279
+ }
1280
+ masterKey.free();
1281
+ return { error: 'Keys unavailable' };
1282
+ }
1283
+
1284
+ try {
1285
+ // 1. Get available notes from UTXO store (for state tracking)
1286
+ const availableStoredNotes = await getAvailableNotes(currentAccount.address);
1287
+
1288
+ if (availableStoredNotes.length === 0) {
1289
+ return { error: 'No available UTXOs.' };
1290
+ }
1291
+
1292
+ const totalAvailable = availableStoredNotes.reduce((sum, n) => sum + n.assets, 0);
1293
+
1294
+ // 2. Estimate fee if not provided (rough estimate: 2 NOCK should cover most cases)
1295
+ const estimatedFee = fee ?? 2 * NOCK_TO_NICKS;
1296
+
1297
+ let selectedStoredNotes: typeof availableStoredNotes;
1298
+ let expectedChange: number;
1299
+
1300
+ if (sendMax) {
1301
+ // SEND MAX: Use ALL available UTXOs, no change back to sender
1302
+ selectedStoredNotes = availableStoredNotes;
1303
+ expectedChange = 0; // All goes to recipient (minus fee)
1304
+ } else {
1305
+ // NORMAL: Select only notes needed for amount + fee
1306
+ const targetAmount = amount + estimatedFee;
1307
+ const selected = selectNotesForAmount(availableStoredNotes, targetAmount);
1308
+
1309
+ if (!selected) {
1310
+ return {
1311
+ error: `Insufficient available funds`,
1312
+ };
1313
+ }
1314
+
1315
+ selectedStoredNotes = selected;
1316
+ const selectedTotal = selectedStoredNotes.reduce((sum, n) => sum + n.assets, 0);
1317
+ expectedChange = selectedTotal - amount - estimatedFee;
1318
+ }
1319
+
1320
+ selectedNoteIds = selectedStoredNotes.map(n => n.noteId);
1321
+ const selectedTotal = selectedStoredNotes.reduce((sum, n) => sum + n.assets, 0);
1322
+
1323
+ // 4. Mark notes as in_flight BEFORE building transaction
1324
+ await markNotesInFlight(currentAccount.address, selectedNoteIds, walletTxId);
1325
+
1326
+ // 5. Create wallet transaction record (status: created)
1327
+ const walletTx: WalletTransaction = {
1328
+ id: walletTxId,
1329
+ accountAddress: currentAccount.address,
1330
+ direction: 'outgoing',
1331
+ createdAt: Date.now(),
1332
+ updatedAt: Date.now(),
1333
+ priceUsdAtTime,
1334
+ status: 'created',
1335
+ inputNoteIds: selectedNoteIds,
1336
+ recipient: to,
1337
+ amount,
1338
+ fee: estimatedFee,
1339
+ expectedChange: expectedChange > 0 ? expectedChange : 0,
1340
+ };
1341
+ await addWalletTransaction(walletTx);
1342
+
1343
+ // 6. Convert stored notes to transaction builder format
1344
+ const sortedStoredNotes = [...selectedStoredNotes].sort((a, b) => b.assets - a.assets);
1345
+ const txBuilderNotes = sortedStoredNotes.map(convertStoredNoteForTxBuilder);
1346
+
1347
+ const rpcClient = createBrowserClient();
1348
+
1349
+ // For sendMax: set refundPKH = recipient so all funds go to recipient (sweep)
1350
+ const refundAddress = sendMax ? to : undefined;
1351
+
1352
+ const constructedTx = await buildMultiNotePayment(
1353
+ txBuilderNotes,
1354
+ to,
1355
+ amount,
1356
+ accountKey.publicKey,
1357
+ accountKey.privateKey,
1358
+ fee,
1359
+ refundAddress
1360
+ );
1361
+
1362
+ // 7. Broadcast transaction
1363
+ const protobufTx = constructedTx.nockchainTx.toRawTx().toProtobuf();
1364
+ await rpcClient.sendTransaction(protobufTx);
1365
+
1366
+ // 8. Update tx status to broadcasted
1367
+ walletTx.fee = constructedTx.feeUsed;
1368
+ walletTx.txHash = constructedTx.txId;
1369
+ walletTx.status = 'broadcasted_unconfirmed';
1370
+ await updateWalletTransaction(currentAccount.address, walletTxId, {
1371
+ fee: constructedTx.feeUsed,
1372
+ txHash: constructedTx.txId,
1373
+ status: 'broadcasted_unconfirmed',
1374
+ });
1375
+
1376
+ return {
1377
+ txId: constructedTx.txId,
1378
+ walletTx,
1379
+ broadcasted: true,
1380
+ };
1381
+ } finally {
1382
+ // Clean up WASM memory
1383
+ if (currentAccount.derivation !== 'master') {
1384
+ accountKey.free();
1385
+ }
1386
+ masterKey.free();
1387
+ }
1388
+ } catch (error) {
1389
+ console.error('[Vault V2] Transaction failed:', error);
1390
+
1391
+ // Release in_flight notes on failure
1392
+ if (selectedNoteIds.length > 0) {
1393
+ try {
1394
+ await releaseInFlightNotes(currentAccount.address, selectedNoteIds);
1395
+ await updateWalletTransaction(currentAccount.address, walletTxId, {
1396
+ status: 'failed',
1397
+ });
1398
+ } catch (releaseError) {
1399
+ console.error('[Vault V2] Error releasing notes:', releaseError);
1400
+ }
1401
+ }
1402
+
1403
+ return {
1404
+ error: `Transaction failed: ${error instanceof Error ? error.message : String(error)}`,
1405
+ };
1406
+ }
1407
+ });
1408
+ }
1409
+
1410
+ hasV0Mnemonic(): boolean {
1411
+ return Boolean(this.mnemonicV0);
1412
+ }
1413
+
1414
+ private async pbkdf2SeedSha512(seedphrase: string, passphrase?: string): Promise<Uint8Array> {
1415
+ const normalized = seedphrase.trim().split(/\s+/).join(' ');
1416
+ const salt = `mnemonic${passphrase ?? ''}`;
1417
+ const enc = new TextEncoder();
1418
+ const keyMaterial = await crypto.subtle.importKey(
1419
+ 'raw',
1420
+ enc.encode(normalized),
1421
+ { name: 'PBKDF2' },
1422
+ false,
1423
+ ['deriveBits']
1424
+ );
1425
+ const bits = await crypto.subtle.deriveBits(
1426
+ { name: 'PBKDF2', hash: 'SHA-512', salt: enc.encode(salt), iterations: 2048 },
1427
+ keyMaterial,
1428
+ 64 * 8
1429
+ );
1430
+ return new Uint8Array(bits);
1431
+ }
1432
+
1433
+ async setMnemonicV0(mnemonic: string): Promise<{ ok: true } | { error: string }> {
1434
+ if (this.state.locked || !this.encryptionKey || !this.state.enc) {
1435
+ return { error: ERROR_CODES.LOCKED };
1436
+ }
1437
+
1438
+ this.mnemonicV0 = mnemonic.trim();
1439
+
1440
+ // Re-encrypt the vault payload using the in-memory key (same pattern as account updates).
1441
+ await this.saveAccountsToVault();
1442
+ return { ok: true };
1443
+ }
1444
+
1445
+ async clearMnemonicV0(): Promise<{ ok: true } | { error: string }> {
1446
+ if (this.state.locked || !this.encryptionKey || !this.state.enc) {
1447
+ return { error: ERROR_CODES.LOCKED };
1448
+ }
1449
+
1450
+ this.mnemonicV0 = null;
1451
+
1452
+ await this.saveAccountsToVault();
1453
+ return { ok: true };
1454
+ }
1455
+
1456
+ async signRawTxV0(params: {
1457
+ rawTx: any;
1458
+ notes: any[];
1459
+ spendConditions: any[];
1460
+ derivation: 'master' | 'child0' | 'hard0';
1461
+ }): Promise<any> {
1462
+ if (this.state.locked || !this.mnemonicV0) {
1463
+ throw new Error('Wallet is locked or no v0 seedphrase stored');
1464
+ }
1465
+
1466
+ await initIrisSdkOnce();
1467
+
1468
+ const seed = await this.pbkdf2SeedSha512(this.mnemonicV0);
1469
+ const masterKey = wasm.deriveMasterKey(seed);
1470
+ let key: any = masterKey;
1471
+ if (params.derivation === 'child0') key = masterKey.deriveChild(0);
1472
+ if (params.derivation === 'hard0') key = masterKey.deriveChild(0x80000000);
1473
+
1474
+ if (!key.privateKey) {
1475
+ if (key !== masterKey) key.free();
1476
+ masterKey.free();
1477
+ throw new Error('Cannot sign: no private key available');
1478
+ }
1479
+
1480
+ try {
1481
+ const irisRawTx = wasm.RawTx.fromProtobuf(params.rawTx);
1482
+ const irisNotes = params.notes.map(n => wasm.Note.fromProtobuf(n));
1483
+ const irisSpendConditions = params.spendConditions.map(sc =>
1484
+ wasm.SpendCondition.fromProtobuf(sc)
1485
+ );
1486
+ const builder = wasm.TxBuilder.fromTx(irisRawTx, irisNotes, irisSpendConditions);
1487
+ builder.sign(key.privateKey);
1488
+ const signedTx = builder.build();
1489
+ return signedTx.toRawTx().toProtobuf();
1490
+ } finally {
1491
+ if (key !== masterKey) key.free();
1492
+ masterKey.free();
1493
+ }
1494
+ }
1495
+
1496
+ /**
1497
+ * Sign a raw transaction using rose-wasm
1498
+ *
1499
+ * @param params - Transaction parameters with raw tx jam and notes/spend conditions
1500
+ * @returns Hex-encoded signed transaction jam
1501
+ */
1502
+ async signRawTx(params: {
1503
+ rawTx: any; // Protobuf wasm.RawTx object
1504
+ notes: any[]; // Protobuf Note objects
1505
+ spendConditions: any[]; // Protobuf SpendCondition objects
1506
+ }): Promise<any> {
1507
+ // Returns protobuf wasm.RawTx
1508
+ if (this.state.locked || !this.mnemonic) {
1509
+ throw new Error('Wallet is locked');
1510
+ }
1511
+
1512
+ // Initialize WASM modules
1513
+ await initIrisSdkOnce();
1514
+
1515
+ const { rawTx, notes, spendConditions } = params;
1516
+
1517
+ // Derive the account's private key
1518
+ const masterKey = wasm.deriveMasterKeyFromMnemonic(this.mnemonic, '');
1519
+ const currentAccount = this.getCurrentAccount();
1520
+ const childIndex = currentAccount?.index ?? this.state.currentAccountIndex;
1521
+ const accountKey =
1522
+ currentAccount?.derivation === 'master' ? masterKey : masterKey.deriveChild(childIndex);
1523
+
1524
+ if (!accountKey.privateKey) {
1525
+ if (currentAccount?.derivation !== 'master') {
1526
+ accountKey.free();
1527
+ }
1528
+ masterKey.free();
1529
+ throw new Error('Cannot sign: no private key available');
1530
+ }
1531
+
1532
+ try {
1533
+ // Deserialize wasm.RawTx from protobuf (notes and spend conditions come as protobuf)
1534
+ const irisRawTx = wasm.RawTx.fromProtobuf(rawTx);
1535
+
1536
+ // Notes are already in protobuf format from the SDK
1537
+ const irisNotes = notes.map(n => wasm.Note.fromProtobuf(n));
1538
+
1539
+ // SpendConditions are in protobuf format
1540
+ const irisSpendConditions = spendConditions.map(sc => wasm.SpendCondition.fromProtobuf(sc));
1541
+
1542
+ // Reconstruct the transaction builder
1543
+ const builder = wasm.TxBuilder.fromTx(irisRawTx, irisNotes, irisSpendConditions);
1544
+
1545
+ // Sign
1546
+ builder.sign(accountKey.privateKey);
1547
+
1548
+ // Build signed tx (returns NockchainTx)
1549
+ const signedTx = builder.build();
1550
+
1551
+ // Convert to protobuf for return
1552
+ const protobuf = signedTx.toRawTx().toProtobuf();
1553
+
1554
+ return protobuf;
1555
+ } finally {
1556
+ if (currentAccount?.derivation !== 'master') {
1557
+ accountKey.free();
1558
+ }
1559
+ masterKey.free();
1560
+ }
1561
+ }
1562
+
1563
+ async computeOutputs(rawTx: any): Promise<any[]> {
1564
+ if (this.state.locked || !this.mnemonic) {
1565
+ throw new Error('Wallet is locked');
1566
+ }
1567
+
1568
+ // Initialize WASM modules
1569
+ await initIrisSdkOnce();
1570
+
1571
+ try {
1572
+ const irisRawTx = wasm.RawTx.fromProtobuf(rawTx);
1573
+ const outputs = irisRawTx.outputs();
1574
+ return outputs.map((output: wasm.Note) => output.toProtobuf());
1575
+ } catch (err) {
1576
+ console.error('Failed to compute outputs:', err);
1577
+ throw err;
1578
+ }
1579
+ }
1580
+ }