@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.
- package/.github/workflows/artifacts.yml +33 -0
- package/.github/workflows/ci.yml +68 -0
- package/.github/workflows/publish-sdk.yml +35 -0
- package/.nvmrc +1 -0
- package/.prettierignore +5 -0
- package/.prettierrc +8 -0
- package/LICENSE +22 -0
- package/README.md +117 -0
- package/extension/background/index.ts +1500 -0
- package/extension/content/index.ts +59 -0
- package/extension/icons/rose.svg +27 -0
- package/extension/icons/rose128.png +0 -0
- package/extension/icons/rose16.png +0 -0
- package/extension/icons/rose256.png +0 -0
- package/extension/icons/rose32.png +0 -0
- package/extension/icons/rose48.png +0 -0
- package/extension/icons/rose512.png +0 -0
- package/extension/inpage/index.ts +86 -0
- package/extension/manifest.json +48 -0
- package/extension/popup/Popup.tsx +94 -0
- package/extension/popup/Router.tsx +121 -0
- package/extension/popup/assets/arrow-down-icon.svg +3 -0
- package/extension/popup/assets/arrow-left-icon.svg +3 -0
- package/extension/popup/assets/arrow-right-icon.svg +3 -0
- package/extension/popup/assets/arrow-up-icon.svg +3 -0
- package/extension/popup/assets/arrow-up-right-icon.svg +3 -0
- package/extension/popup/assets/checkmark-icon.svg +3 -0
- package/extension/popup/assets/checkmark-pencil-icon.svg +3 -0
- package/extension/popup/assets/checkmark-success-icon.svg +3 -0
- package/extension/popup/assets/clock-icon.svg +3 -0
- package/extension/popup/assets/close-x-icon.svg +3 -0
- package/extension/popup/assets/copy-icon.svg +6 -0
- package/extension/popup/assets/explorer-icon.svg +3 -0
- package/extension/popup/assets/eye-off-icon.svg +3 -0
- package/extension/popup/assets/eye-open-icon.svg +4 -0
- package/extension/popup/assets/feedback-icon.svg +3 -0
- package/extension/popup/assets/green-status-dot.svg +3 -0
- package/extension/popup/assets/info-icon.svg +3 -0
- package/extension/popup/assets/iris-logo-40.svg +27 -0
- package/extension/popup/assets/iris-logo-96.svg +27 -0
- package/extension/popup/assets/iris-logo-blue.svg +27 -0
- package/extension/popup/assets/iris-logo-no-eye.svg +27 -0
- package/extension/popup/assets/iris-logo-orange.svg +27 -0
- package/extension/popup/assets/iris-logo.svg +27 -0
- package/extension/popup/assets/key-icon.svg +3 -0
- package/extension/popup/assets/lock-icon-yellow.svg +3 -0
- package/extension/popup/assets/lock-icon.svg +3 -0
- package/extension/popup/assets/pencil-edit-icon.svg +3 -0
- package/extension/popup/assets/permissions-icon.svg +3 -0
- package/extension/popup/assets/receipt-icon.svg +5 -0
- package/extension/popup/assets/refresh-icon.svg +3 -0
- package/extension/popup/assets/settings-gear-icon.svg +8 -0
- package/extension/popup/assets/settings-icon.svg +3 -0
- package/extension/popup/assets/theme-icon.svg +3 -0
- package/extension/popup/assets/trash-bin-icon.svg +3 -0
- package/extension/popup/assets/trend-down-arrow.svg +5 -0
- package/extension/popup/assets/trend-up-arrow.svg +5 -0
- package/extension/popup/assets/user-account-icon.svg +3 -0
- package/extension/popup/assets/vector-bottom-left.svg +9 -0
- package/extension/popup/assets/vector-left.svg +9 -0
- package/extension/popup/assets/vector-right.svg +9 -0
- package/extension/popup/assets/vector-top-right-rotated.svg +8 -0
- package/extension/popup/assets/vector-top-right.svg +9 -0
- package/extension/popup/assets/wallet-dropdown-arrow.svg +5 -0
- package/extension/popup/assets/wallet-icon-style-1.svg +6 -0
- package/extension/popup/assets/wallet-icon-style-10.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-11.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-12.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-13.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-14.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-15.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-2.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-3.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-4.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-5.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-6.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-7.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-8.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-9.svg +8 -0
- package/extension/popup/components/AccountIcon.tsx +78 -0
- package/extension/popup/components/AccountSelector.tsx +246 -0
- package/extension/popup/components/Alert.tsx +48 -0
- package/extension/popup/components/ConfirmModal.tsx +81 -0
- package/extension/popup/components/PasswordInput.tsx +49 -0
- package/extension/popup/components/ScreenContainer.tsx +17 -0
- package/extension/popup/components/SiteIcon.tsx +60 -0
- package/extension/popup/components/ThemeToggle.tsx +44 -0
- package/extension/popup/components/icons/ArrowDownLeftIcon.tsx +20 -0
- package/extension/popup/components/icons/ArrowUpRightIcon.tsx +20 -0
- package/extension/popup/components/icons/CheckIcon.tsx +20 -0
- package/extension/popup/components/icons/ChevronDownIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronLeftIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronRightIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronUpIcon.tsx +15 -0
- package/extension/popup/components/icons/CloseIcon.tsx +26 -0
- package/extension/popup/components/icons/CopyIcon.tsx +20 -0
- package/extension/popup/components/icons/EditIcon.tsx +20 -0
- package/extension/popup/components/icons/EyeIcon.tsx +13 -0
- package/extension/popup/components/icons/EyeOffIcon.tsx +13 -0
- package/extension/popup/components/icons/InfoIcon.tsx +20 -0
- package/extension/popup/components/icons/LockIcon.tsx +20 -0
- package/extension/popup/components/icons/PlusIcon.tsx +15 -0
- package/extension/popup/components/icons/ReceiveArrowIcon.tsx +14 -0
- package/extension/popup/components/icons/ReceiveCircleIcon.tsx +20 -0
- package/extension/popup/components/icons/SendPaperPlaneIcon.tsx +18 -0
- package/extension/popup/components/icons/SentArrowIcon.tsx +21 -0
- package/extension/popup/components/icons/SettingsIcon.tsx +26 -0
- package/extension/popup/components/icons/ShieldIcon.tsx +20 -0
- package/extension/popup/components/icons/UploadIcon.tsx +20 -0
- package/extension/popup/components/icons/WalletIcon.tsx +20 -0
- package/extension/popup/contexts/ThemeContext.tsx +105 -0
- package/extension/popup/hooks/useApprovalDetection.ts +128 -0
- package/extension/popup/hooks/useAutoFocus.ts +36 -0
- package/extension/popup/hooks/useAutoRejectOnClose.ts +25 -0
- package/extension/popup/hooks/useClickOutside.ts +33 -0
- package/extension/popup/hooks/useCopyToClipboard.ts +33 -0
- package/extension/popup/hooks/useFavicon.ts +64 -0
- package/extension/popup/hooks/useNumericInput.ts +93 -0
- package/extension/popup/index.html +13 -0
- package/extension/popup/index.tsx +24 -0
- package/extension/popup/screens/AboutScreen.tsx +118 -0
- package/extension/popup/screens/HomeScreen.tailwind.css +85 -0
- package/extension/popup/screens/HomeScreen.tsx +902 -0
- package/extension/popup/screens/KeySettingsPasswordScreen.tsx +164 -0
- package/extension/popup/screens/LockTimeScreen.tsx +155 -0
- package/extension/popup/screens/ReceiveScreen.tsx +149 -0
- package/extension/popup/screens/RecoveryPhraseScreen.tsx +183 -0
- package/extension/popup/screens/SendReviewScreen.tsx +308 -0
- package/extension/popup/screens/SendScreen.tsx +825 -0
- package/extension/popup/screens/SendSubmittedScreen.tsx +193 -0
- package/extension/popup/screens/SettingsScreen.tsx +116 -0
- package/extension/popup/screens/ThemeSettingsScreen.tsx +107 -0
- package/extension/popup/screens/TransactionDetailsScreen.tsx +346 -0
- package/extension/popup/screens/ViewSecretPhraseScreen.tsx +212 -0
- package/extension/popup/screens/WalletPermissionsScreen.tsx +123 -0
- package/extension/popup/screens/WalletSettingsScreen.tsx +381 -0
- package/extension/popup/screens/WalletStylingScreen.tsx +306 -0
- package/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +136 -0
- package/extension/popup/screens/approvals/SignMessageScreen.tsx +140 -0
- package/extension/popup/screens/approvals/SignRawTxScreen.tsx +320 -0
- package/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +167 -0
- package/extension/popup/screens/onboarding/BackupScreen.tsx +254 -0
- package/extension/popup/screens/onboarding/CreateScreen.tsx +273 -0
- package/extension/popup/screens/onboarding/ImportScreen.tsx +676 -0
- package/extension/popup/screens/onboarding/ImportScreenV0.tsx +678 -0
- package/extension/popup/screens/onboarding/ImportSuccessScreen.tsx +236 -0
- package/extension/popup/screens/onboarding/ResumeBackupScreen.tsx +166 -0
- package/extension/popup/screens/onboarding/StartScreen.tsx +142 -0
- package/extension/popup/screens/onboarding/SuccessScreen.tsx +193 -0
- package/extension/popup/screens/onboarding/VerifyScreen.tsx +220 -0
- package/extension/popup/screens/system/LockedScreen.tsx +288 -0
- package/extension/popup/screens/transactions/ReceiveScreen.tsx +84 -0
- package/extension/popup/screens/transactions/SentScreen.tsx +138 -0
- package/extension/popup/store.ts +482 -0
- package/extension/popup/styles.css +246 -0
- package/extension/popup/utils/format.ts +58 -0
- package/extension/popup/utils/formatWalletError.ts +36 -0
- package/extension/popup/utils/memo.ts +299 -0
- package/extension/popup/utils/messaging.ts +16 -0
- package/extension/shared/address-encoding.ts +69 -0
- package/extension/shared/balance-query.ts +123 -0
- package/extension/shared/constants.ts +386 -0
- package/extension/shared/currency.ts +128 -0
- package/extension/shared/first-name-derivation.ts +128 -0
- package/extension/shared/keyfile.ts +58 -0
- package/extension/shared/onboarding.ts +78 -0
- package/extension/shared/price-api.ts +79 -0
- package/extension/shared/rpc-client-browser.ts +315 -0
- package/extension/shared/transaction-builder.ts +443 -0
- package/extension/shared/types.ts +450 -0
- package/extension/shared/utxo-diff.ts +212 -0
- package/extension/shared/utxo-store.ts +548 -0
- package/extension/shared/utxo-sync.ts +343 -0
- package/extension/shared/validators.ts +26 -0
- package/extension/shared/vault.ts +1580 -0
- package/extension/shared/wallet-crypto.ts +77 -0
- package/extension/shared/wasm-utils.ts +76 -0
- package/extension/shared/webcrypto.ts +67 -0
- package/extension/types/wasm.d.ts +13 -0
- package/package.json +39 -0
- package/postcss.config.js +6 -0
- package/rose-extension-dist.zip +0 -0
- package/sdk/README.md +88 -0
- package/sdk/examples/app.ts +166 -0
- package/sdk/examples/index.html +51 -0
- package/sdk/examples/tsconfig.json +15 -0
- package/sdk/examples/tx-builder.html +532 -0
- package/sdk/examples/tx-builder.ts +1766 -0
- package/sdk/package-lock.json +424 -0
- package/sdk/package.json +68 -0
- package/sdk/src/constants.ts +28 -0
- package/sdk/src/errors.ts +74 -0
- package/sdk/src/hooks/index.ts +1 -0
- package/sdk/src/hooks/use-rose.ts +94 -0
- package/sdk/src/index.ts +12 -0
- package/sdk/src/provider.ts +396 -0
- package/sdk/src/transaction.ts +163 -0
- package/sdk/src/types/rose-wasm.d.ts +14 -0
- package/sdk/src/types.ts +97 -0
- package/sdk/src/wasm.ts +13 -0
- package/sdk/tsconfig.json +20 -0
- package/sdk/vite.config.examples.ts +32 -0
- package/tailwind.config.ts +38 -0
- package/tsconfig.json +20 -0
- 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
|
+
}
|