From 2033ba871eaa546dbe95966ca5f9f235ea7615dd Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Feb 2026 13:21:47 +0200 Subject: [PATCH 01/19] feat: add new Web Crypto implementation util --- src/utils/crypto-native.js | 146 +++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/utils/crypto-native.js diff --git a/src/utils/crypto-native.js b/src/utils/crypto-native.js new file mode 100644 index 0000000..b5f2388 --- /dev/null +++ b/src/utils/crypto-native.js @@ -0,0 +1,146 @@ +// Copyright (C) 2026 Edge Network Technologies Limited +// Use of this source code is governed by a GNU GPL-style license +// that can be found in the LICENSE.md file. All rights reserved. + +/** + * PBKDF2 iteration count for v2 storage format. + * Per OWASP 2023 recommendations, 600,000+ iterations are recommended for PBKDF2-SHA256. + * We use 900,000 for additional security margin. + * + * Note: Key derivation with this iteration count takes 100-300ms. + * This is expected behavior, not a bug. + */ +export const ITERATIONS_V2 = 900000 + +/** + * Convert a Uint8Array (or ArrayBuffer) to a hexadecimal string. + * + * @param {Uint8Array|ArrayBuffer} buffer - The binary data to convert + * @returns {string} Hexadecimal string representation + */ +export function arrayToHex(buffer) { + return Array.from(new Uint8Array(buffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Convert a hexadecimal string to a Uint8Array. + * + * @param {string} hex - The hexadecimal string to convert + * @returns {Uint8Array} Binary data + */ +export function hexToArray(hex) { + const bytes = hex.match(/.{1,2}/g) || [] + return new Uint8Array(bytes.map(byte => parseInt(byte, 16))) +} + +/** + * Derive an AES-GCM encryption key from a password using PBKDF2-SHA256. + * + * Uses the Web Crypto API for native, performant key derivation. + * The derived key can be used for both encryption and decryption. + * + * Note: With 900,000 iterations, this operation takes 100-300ms. + * This is expected behavior for secure key derivation. + * + * @param {string} password - The password to derive the key from + * @param {Uint8Array} salt - 16-byte salt for key derivation + * @param {number} [iterations=ITERATIONS_V2] - Number of PBKDF2 iterations + * @returns {Promise} AES-GCM 256-bit key for encryption/decryption + */ +export async function deriveKey(password, salt, iterations = ITERATIONS_V2) { + const encoder = new TextEncoder() + const passwordBuffer = encoder.encode(password) + + // Import password as raw key material for PBKDF2 + const keyMaterial = await crypto.subtle.importKey( + 'raw', + passwordBuffer, + 'PBKDF2', + false, + ['deriveKey'] + ) + + // Derive AES-GCM key using PBKDF2-SHA256 + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: iterations, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) +} + +/** + * Encrypt JSON-serializable data using AES-GCM with a password-derived key. + * + * Generates random salt and IV for each encryption operation. + * The salt is used for PBKDF2 key derivation, the IV for AES-GCM encryption. + * + * @param {*} data - JSON-serializable data to encrypt + * @param {string} password - Password for encryption + * @returns {Promise<{salt: string, iv: string, ciphertext: string}>} Encrypted data with salt, IV, and ciphertext as hex strings + */ +export async function encrypt(data, password) { + // Generate random salt (16 bytes) and IV (12 bytes) + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + // Derive encryption key from password + const key = await deriveKey(password, salt) + + // Encrypt the JSON-stringified data + const encoder = new TextEncoder() + const plaintext = encoder.encode(JSON.stringify(data)) + + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + plaintext + ) + + // Return all components as hex strings + return { + salt: arrayToHex(salt), + iv: arrayToHex(iv), + ciphertext: arrayToHex(new Uint8Array(ciphertext)) + } +} + +/** + * Decrypt data that was encrypted with the encrypt() function. + * + * Uses the provided salt and IV to derive the same key and decrypt. + * AES-GCM provides authenticated encryption, so tampered data will fail to decrypt. + * + * @param {{salt: string, iv: string, ciphertext: string}} encrypted - Encrypted data object with hex-encoded values + * @param {string} password - Password used for encryption + * @returns {Promise<*>} Original decrypted data + * @throws {Error} If decryption fails (wrong password or tampered data) + */ +export async function decrypt(encrypted, password) { + // Convert hex strings back to binary + const salt = hexToArray(encrypted.salt) + const iv = hexToArray(encrypted.iv) + const ciphertext = hexToArray(encrypted.ciphertext) + + // Derive the same key from password and salt + const key = await deriveKey(password, salt) + + // Decrypt the ciphertext + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext + ) + + // Parse and return the JSON data + const decoder = new TextDecoder() + return JSON.parse(decoder.decode(decrypted)) +} From 5a2f80119f33fd016d1facfcd53b30bfedbf8ebe Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Feb 2026 13:39:04 +0200 Subject: [PATCH 02/19] feat: add new v2 vault storage --- src/utils/storage/v2.js | 433 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 src/utils/storage/v2.js diff --git a/src/utils/storage/v2.js b/src/utils/storage/v2.js new file mode 100644 index 0000000..b011edc --- /dev/null +++ b/src/utils/storage/v2.js @@ -0,0 +1,433 @@ +// Copyright (C) 2026 Edge Network Technologies Limited +// Use of this source code is governed by a GNU GPL-style license +// that can be found in the LICENSE.md file. All rights reserved. + +/** + * v2 Multi-Wallet Vault Storage Module + * + * Stores multiple wallets in a single encrypted vault blob using AES-GCM + * with PBKDF2-SHA256 key derivation (900,000 iterations). + * + * Storage keys (IndexedDB): + * - 'vault': Encrypted vault blob containing all wallet data + * - 'address': Cached active wallet address (for display when locked) + * - 'active-wallet-id': Persisted active wallet ID (readable without password) + * + * Decrypted vault structure: + * { + * wallets: [ + * { + * id: "uuid", + * name: "Wallet Name", + * publicKey: "hex", + * privateKey: "hex" + * } + * ] + * } + */ + +import * as xe from '@edge/xe-utils' +import { store } from './' +import { encrypt, decrypt, ITERATIONS_V2 } from '../crypto-native' +import { get, set, del } from 'idb-keyval' + +const KEY_VAULT = 'vault' +const KEY_ADDRESS = 'address' +const KEY_ACTIVE_WALLET_ID = 'active-wallet-id' + +/** + * Mutex for atomic vault operations. + * Ensures only one vault write operation runs at a time. + */ +let operationLock = Promise.resolve() + +/** + * Execute a function with exclusive vault access. + * + * @param {Function} fn - Function to execute + * @returns {Promise<*>} Result of the function + */ +const withLock = async (fn) => { + const previousLock = operationLock + let resolve + operationLock = new Promise(r => { resolve = r }) + try { + await previousLock + return await fn() + } finally { + resolve() + } +} + +/** + * Decrypt and return the full vault data. + * + * @param {string} password - Password to decrypt the vault + * @returns {Promise<{wallets: Array}|null>} Decrypted vault data, or null if no vault exists + * @throws {Error} If decryption fails (wrong password or corrupted data) + */ +export async function getVault(password) { + const encryptedVault = await get(KEY_VAULT, store) + if (encryptedVault === undefined) { + return null + } + + try { + const vaultData = await decrypt(encryptedVault, password) + if (!vaultData || !Array.isArray(vaultData.wallets)) { + throw new Error('Vault data is malformed: missing wallets array') + } + return vaultData + } catch (error) { + if (error.name === 'OperationError') { + throw new Error('Failed to decrypt vault: incorrect password or corrupted data') + } + throw error + } +} + +/** + * Encrypt and store vault data atomically. + * + * @param {{wallets: Array}} vaultData - Vault data to encrypt and store + * @param {string} password - Password for encryption + * @returns {Promise} + */ +export async function setVault(vaultData, password) { + if (!vaultData || !Array.isArray(vaultData.wallets)) { + throw new Error('Invalid vault data: wallets array is required') + } + + await withLock(async () => { + const encrypted = await encrypt(vaultData, password) + const storedVault = { + version: 2, + iterations: ITERATIONS_V2, + ...encrypted + } + await set(KEY_VAULT, storedVault, store) + }) +} + +/** + * Create a new vault with an initial wallet. + * + * @param {{ publicKey: string, privateKey: string, name?: string }} wallet - Initial wallet + * @param {string} password - Password for encryption + * @returns {Promise<{ id: string, name: string, address: string }>} Created wallet metadata + */ +export async function createVault(wallet, password) { + const walletId = crypto.randomUUID() + const address = xe.wallet.deriveAddress(wallet.publicKey) + + const walletEntry = { + id: walletId, + name: wallet.name || 'Main Wallet', + publicKey: wallet.publicKey, + privateKey: wallet.privateKey + } + + const vaultData = { + wallets: [walletEntry] + } + + await setVault(vaultData, password) + await setActiveWalletId(walletId) + await set(KEY_ADDRESS, address, store) + + return { + id: walletId, + name: walletEntry.name, + address + } +} + +/** + * Get the active wallet ID (does not require password). + * + * @returns {Promise} Active wallet ID, or null if not set + */ +export async function getActiveWalletId() { + const id = await get(KEY_ACTIVE_WALLET_ID, store) + return id || null +} + +/** + * Set the active wallet ID and update cached address. + * + * @param {string} walletId - Wallet ID to set as active + * @param {string} [password] - Password to decrypt vault (needed to update cached address) + * @returns {Promise} + */ +export async function setActiveWalletId(walletId, password) { + await set(KEY_ACTIVE_WALLET_ID, walletId, store) + + // Update cached address if password provided + if (password) { + const vault = await getVault(password) + if (vault) { + const wallet = vault.wallets.find(w => w.id === walletId) + if (wallet) { + const address = xe.wallet.deriveAddress(wallet.publicKey) + await set(KEY_ADDRESS, address, store) + } + } + } +} + +/** + * Get cached address (for display when locked, does not require password). + * + * @returns {Promise} Cached address + */ +export async function getCachedAddress() { + return await get(KEY_ADDRESS, store) +} + +/** + * Get wallet address by ID. + * + * @param {string} password - Password to decrypt vault + * @param {string} [walletId] - Wallet ID (defaults to active wallet) + * @returns {Promise} Wallet address + */ +export async function getAddress(password, walletId) { + const vault = await getVault(password) + if (!vault) return undefined + + const targetId = walletId || await getActiveWalletId() + const wallet = vault.wallets.find(w => w.id === targetId) + return wallet ? xe.wallet.deriveAddress(wallet.publicKey) : undefined +} + +/** + * Get wallet public key by ID. + * + * @param {string} password - Password to decrypt vault + * @param {string} [walletId] - Wallet ID (defaults to active wallet) + * @returns {Promise} Public key hex string + */ +export async function getPublicKey(password, walletId) { + const vault = await getVault(password) + if (!vault) return undefined + + const targetId = walletId || await getActiveWalletId() + const wallet = vault.wallets.find(w => w.id === targetId) + return wallet?.publicKey +} + +/** + * Get wallet private key by ID. + * + * @param {string} password - Password to decrypt vault + * @param {string} [walletId] - Wallet ID (defaults to active wallet) + * @returns {Promise} Private key hex string + */ +export async function getPrivateKey(password, walletId) { + const vault = await getVault(password) + if (!vault) return undefined + + const targetId = walletId || await getActiveWalletId() + const wallet = vault.wallets.find(w => w.id === targetId) + return wallet?.privateKey +} + +/** + * Verify that the provided password can decrypt the vault. + * + * @param {string} password - Password to verify + * @returns {Promise} true if password is correct + */ +export async function comparePassword(password) { + try { + const vault = await getVault(password) + return vault !== null + } catch { + return false + } +} + +/** + * Get all wallets metadata (without private keys). + * + * @param {string} password - Password to decrypt vault + * @returns {Promise>} + */ +export async function getWallets(password) { + const vault = await getVault(password) + if (!vault) return [] + + return vault.wallets.map(w => ({ + id: w.id, + name: w.name, + address: xe.wallet.deriveAddress(w.publicKey) + })) +} + +/** + * Validate that a wallet name is unique (case-insensitive). + * + * @param {Array} wallets - Current wallets array + * @param {string} name - Name to validate + * @param {string} [excludeId] - Wallet ID to exclude (for updates) + * @returns {boolean} true if name is unique + */ +function validateNameUnique(wallets, name, excludeId) { + const normalizedName = name.toLowerCase().trim() + return !wallets.some(w => + w.id !== excludeId && w.name.toLowerCase().trim() === normalizedName + ) +} + +/** + * Add a new wallet to the vault. + * + * @param {{ publicKey: string, privateKey: string, name?: string }} wallet - Wallet to add + * @param {string} password - Password to decrypt/encrypt vault + * @param {{ setActive?: boolean }} [options] - Options + * @returns {Promise<{ id: string, name: string, address: string }>} Created wallet metadata + * @throws {Error} If wallet already exists or name is not unique + */ +export async function addWallet(wallet, password, options = {}) { + const { setActive = true } = options + + const vault = await getVault(password) + if (!vault) { + throw new Error('Vault not found. Create a vault first.') + } + + // Check for duplicate by public key + const existingWallet = vault.wallets.find(w => w.publicKey === wallet.publicKey) + if (existingWallet) { + throw new Error(`This wallet already exists as "${existingWallet.name}"`) + } + + // Generate name if not provided + const name = wallet.name || `Wallet ${vault.wallets.length + 1}` + + // Validate name uniqueness + if (!validateNameUnique(vault.wallets, name)) { + throw new Error('A wallet with this name already exists') + } + + const walletId = crypto.randomUUID() + const address = xe.wallet.deriveAddress(wallet.publicKey) + + const walletEntry = { + id: walletId, + name, + publicKey: wallet.publicKey, + privateKey: wallet.privateKey + } + + vault.wallets.push(walletEntry) + await setVault(vault, password) + + // Set as active if requested + if (setActive) { + await setActiveWalletId(walletId) + await set(KEY_ADDRESS, address, store) + } + + return { + id: walletId, + name: walletEntry.name, + address + } +} + +/** + * Remove a wallet from the vault. + * + * @param {string} walletId - ID of wallet to remove + * @param {string} password - Password to decrypt/encrypt vault + * @returns {Promise<{ walletsRemaining: number, newActiveId: string|null }>} + * @throws {Error} If wallet not found + */ +export async function removeWallet(walletId, password) { + const vault = await getVault(password) + if (!vault) { + throw new Error('Vault not found') + } + + const walletIndex = vault.wallets.findIndex(w => w.id === walletId) + if (walletIndex === -1) { + throw new Error('Wallet not found') + } + + vault.wallets.splice(walletIndex, 1) + await setVault(vault, password) + + // Handle active wallet change + const currentActiveId = await getActiveWalletId() + let newActiveId = currentActiveId + + if (currentActiveId === walletId) { + if (vault.wallets.length > 0) { + // Switch to first remaining wallet + newActiveId = vault.wallets[0].id + const newActiveWallet = vault.wallets[0] + await setActiveWalletId(newActiveId) + await set(KEY_ADDRESS, xe.wallet.deriveAddress(newActiveWallet.publicKey), store) + } else { + // No wallets left + newActiveId = null + await del(KEY_ACTIVE_WALLET_ID, store) + await del(KEY_ADDRESS, store) + } + } + + return { + walletsRemaining: vault.wallets.length, + newActiveId + } +} + +/** + * Update wallet name. + * + * @param {string} walletId - ID of wallet to update + * @param {string} name - New name + * @param {string} password - Password to decrypt/encrypt vault + * @returns {Promise} + * @throws {Error} If wallet not found or name not unique + */ +export async function updateWallet(walletId, name, password) { + const vault = await getVault(password) + if (!vault) { + throw new Error('Vault not found') + } + + const wallet = vault.wallets.find(w => w.id === walletId) + if (!wallet) { + throw new Error('Wallet not found') + } + + if (!validateNameUnique(vault.wallets, name, walletId)) { + throw new Error('A wallet with this name already exists') + } + + wallet.name = name.trim() + await setVault(vault, password) +} + +/** + * Check if a vault exists. + * + * @returns {Promise} + */ +export async function hasVault() { + const encryptedVault = await get(KEY_VAULT, store) + return encryptedVault !== undefined +} + +/** + * Clear the vault (for "forget wallet" functionality). + * + * @returns {Promise} + */ +export async function clearVault() { + await del(KEY_VAULT, store) + await del(KEY_ADDRESS, store) + await del(KEY_ACTIVE_WALLET_ID, store) +} From 50147153561cb2920c1e35ddabbc81f294856c00 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Feb 2026 13:44:35 +0200 Subject: [PATCH 03/19] feat: add storage migration util --- src/utils/storage/migration.js | 131 +++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/utils/storage/migration.js diff --git a/src/utils/storage/migration.js b/src/utils/storage/migration.js new file mode 100644 index 0000000..5e59773 --- /dev/null +++ b/src/utils/storage/migration.js @@ -0,0 +1,131 @@ +// Copyright (C) 2026 Edge Network Technologies Limited +// Use of this source code is governed by a GNU GPL-style license +// that can be found in the LICENSE.md file. All rights reserved. + +/** + * Migration module for transitioning v0/v1 storage to v2 vault format. + * + * Follows write-verify-delete pattern: + * 1. Read existing wallet data using legacy adapters + * 2. Write new v2 vault format with upgraded encryption (900k PBKDF2) + * 3. Verify the new vault can be decrypted correctly + * 4. Delete old storage keys only after verification succeeds + * + * On verification failure, the version number is rolled back. + */ + +import * as v0 from './v0' +import * as v1 from './v1' +import * as v2 from './v2' +import { store, getWalletVersion, setWalletVersion } from './' +import { del } from 'idb-keyval' + +/** + * Check if migration to v2 is needed. + * + * @returns {Promise} true if migration is needed + */ +export async function needsMigration() { + const version = await getWalletVersion() + + // Version 0 is returned when no wallet-version key exists + // Check if there's actually a wallet to migrate + if (version === 0) { + return await v0.hasWallet() + } + + return version === 1 +} + +/** + * Migrate wallet from v0 or v1 format to v2 vault format. + * + * Safe migration following write-verify-delete pattern: + * 1. Reading: Read existing wallet using legacy adapter + * 2. Encrypting: Create v2 vault with new encryption (AES-GCM, 900k PBKDF2) + * 3. Verifying: Decrypt new vault to confirm data integrity + * 4. Cleanup: Delete old storage keys only after verification + * + * On failure, version is rolled back and error is thrown. + * + * @param {string} password - Password for decrypting v1 and encrypting v2 + * @param {function(string): void} [onProgress] - Progress callback + * Called with: 'reading', 'encrypting', 'verifying', 'cleanup' + * @returns {Promise<{ id: string, name: string, address: string }>} Migrated wallet metadata + * @throws {Error} If migration fails at any step + */ +export async function migrateToV2(password, onProgress) { + onProgress?.('reading') + + // 1. Get current version and read with appropriate adapter + const version = await getWalletVersion() + let privateKey, publicKey + + if (version === 0) { + privateKey = await v0.getPrivateKey() + publicKey = await v0.getPublicKey() + } else if (version === 1) { + privateKey = await v1.getPrivateKey(password) + publicKey = await v1.getPublicKey() + } else { + throw new Error('Already on v2 or no wallet exists') + } + + if (!privateKey || !publicKey) { + throw new Error('Could not read existing wallet data') + } + + onProgress?.('encrypting') + + // 2. Create new v2 vault with migrated wallet + const walletMeta = await v2.createVault({ + publicKey, + privateKey, + name: 'Main Wallet' + }, password) + + // 3. Update version + await setWalletVersion(2) + + onProgress?.('verifying') + + // 4. Verify decryption works and data matches + const verifiedPrivateKey = await v2.getPrivateKey(password) + if (verifiedPrivateKey !== privateKey) { + // Rollback on verification failure + await setWalletVersion(version) + throw new Error('Migration verification failed - private key mismatch') + } + + onProgress?.('cleanup') + + // 5. Delete old format keys only after verification + await Promise.all([ + del('p1', store), + del('p2', store), + del('h', store), + del('s', store) + ]) + + return walletMeta +} + +/** + * Get the private key from legacy storage (for emergency recovery). + * + * Use this if migration fails and user needs to export their key. + * + * @param {string} password - Password (only needed for v1) + * @returns {Promise} Private key or undefined + */ +export async function getLegacyPrivateKey(password) { + const version = await getWalletVersion() + + if (version === 0) { + return await v0.getPrivateKey() + } else if (version === 1) { + return await v1.getPrivateKey(password) + } + + return undefined +} From c0a7422ea0f5f5d6421cc936f112dfff26188275 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Feb 2026 14:06:08 +0200 Subject: [PATCH 04/19] feat: update storage/index.js with v2 operations --- src/utils/storage/index.js | 72 +++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/src/utils/storage/index.js b/src/utils/storage/index.js index 80f24fa..aca1659 100644 --- a/src/utils/storage/index.js +++ b/src/utils/storage/index.js @@ -1,9 +1,11 @@ -// Copyright (C) 2022 Edge Network Technologies Limited +// Copyright (C) 2026 Edge Network Technologies Limited // Use of this source code is governed by a GNU GPL-style license // that can be found in the LICENSE.md file. All rights reserved. import * as v0 from './v0' import * as v1 from './v1' +import * as v2 from './v2' +import { needsMigration, migrateToV2, getLegacyPrivateKey } from './migration' import { clear, createStore, del, get, set } from 'idb-keyval' const KEY_UNLOCK_EXPIRY = 'unlock-expiry' @@ -35,6 +37,8 @@ const comparePassword = (password, version) => { return v0.comparePassword(password) case 1: return v1.comparePassword(password) + case 2: + return v2.comparePassword(password) default: throw invalidVersion(version) } @@ -60,16 +64,19 @@ const expire = () => del(KEY_UNLOCK_EXPIRY, store) * The `version` argument can be provided to specify the storage model to use. * If it is undefined, the highest storage version will be selected automatically. * + * @param {string} password Password (required for v2, ignored for v0/v1) * @param {number|undefined} version Storage version * @returns Promise */ -const getAddress = version => { +const getAddress = (password, version) => { if (version === undefined) version = getHighestWalletVersion() switch(version) { case 0: return v0.getAddress() case 1: return v1.getAddress() + case 2: + return v2.getAddress(password) default: throw invalidVersion(version) } @@ -80,7 +87,7 @@ const getAddress = version => { * * @returns number */ -const getHighestWalletVersion = () => 1 +const getHighestWalletVersion = () => 2 /** * Get wallet private key from storage. @@ -98,6 +105,8 @@ const getPrivateKey = (password, version) => { return v0.getPrivateKey() case 1: return v1.getPrivateKey(password) + case 2: + return v2.getPrivateKey(password) default: throw invalidVersion(version) } @@ -109,16 +118,19 @@ const getPrivateKey = (password, version) => { * The `version` argument can be provided to specify the storage model to use. * If it is undefined, the highest storage version will be selected automatically. * + * @param {string} password Password (required for v2, ignored for v0/v1) * @param {number|undefined} version Storage version * @returns Promise */ -const getPublicKey = version => { +const getPublicKey = (password, version) => { if (version === undefined) version = getHighestWalletVersion() switch (version) { case 0: return v0.getPublicKey() case 1: return v1.getPublicKey() + case 2: + return v2.getPublicKey(password) default: throw invalidVersion(version) } @@ -182,6 +194,14 @@ const setWallet = async (keypair, password, version) => { await v1.setPassword(password) await setWalletVersion(1) break + case 2: + await v2.createVault({ + publicKey: keypair.publicKey, + privateKey: keypair.privateKey, + name: 'Main Wallet' + }, password) + await setWalletVersion(2) + break default: throw invalidVersion(version) } @@ -195,18 +215,54 @@ const setWallet = async (keypair, password, version) => { */ const setWalletVersion = v => set(KEY_WALLET_VERSION, v, store) +// Re-export v2 multi-wallet functions +const { + getWallets, + getActiveWalletId, + setActiveWalletId, + getCachedAddress, + createVault, + addWallet, + removeWallet, + updateWallet, + hasVault, + clearVault +} = v2 + export { + // Version-switching functions comparePassword, - empty, - expire, getAddress, getHighestWalletVersion, getPrivateKey, getPublicKey, - getUnlockExpiry, getWalletVersion, - setUnlockExpiry, setWallet, setWalletVersion, + + // Multi-wallet functions (v2) + getWallets, + getActiveWalletId, + setActiveWalletId, + getCachedAddress, + createVault, + addWallet, + removeWallet, + updateWallet, + hasVault, + clearVault, + + // Migration functions + needsMigration, + migrateToV2, + getLegacyPrivateKey, + + // Session management + getUnlockExpiry, + setUnlockExpiry, + expire, + empty, + + // Store access store } From f1d93148004674517af26f0f0654bfb3207593d1 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Feb 2026 14:10:59 +0200 Subject: [PATCH 05/19] feat: simplify v2 storage by removing cached address --- src/utils/storage/index.js | 2 -- src/utils/storage/v2.js | 33 ++------------------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/src/utils/storage/index.js b/src/utils/storage/index.js index aca1659..0fd9dfd 100644 --- a/src/utils/storage/index.js +++ b/src/utils/storage/index.js @@ -220,7 +220,6 @@ const { getWallets, getActiveWalletId, setActiveWalletId, - getCachedAddress, createVault, addWallet, removeWallet, @@ -244,7 +243,6 @@ export { getWallets, getActiveWalletId, setActiveWalletId, - getCachedAddress, createVault, addWallet, removeWallet, diff --git a/src/utils/storage/v2.js b/src/utils/storage/v2.js index b011edc..a7962a7 100644 --- a/src/utils/storage/v2.js +++ b/src/utils/storage/v2.js @@ -32,7 +32,6 @@ import { encrypt, decrypt, ITERATIONS_V2 } from '../crypto-native' import { get, set, del } from 'idb-keyval' const KEY_VAULT = 'vault' -const KEY_ADDRESS = 'address' const KEY_ACTIVE_WALLET_ID = 'active-wallet-id' /** @@ -133,7 +132,6 @@ export async function createVault(wallet, password) { await setVault(vaultData, password) await setActiveWalletId(walletId) - await set(KEY_ADDRESS, address, store) return { id: walletId, @@ -153,35 +151,13 @@ export async function getActiveWalletId() { } /** - * Set the active wallet ID and update cached address. + * Set the active wallet ID. * * @param {string} walletId - Wallet ID to set as active - * @param {string} [password] - Password to decrypt vault (needed to update cached address) * @returns {Promise} */ -export async function setActiveWalletId(walletId, password) { +export async function setActiveWalletId(walletId) { await set(KEY_ACTIVE_WALLET_ID, walletId, store) - - // Update cached address if password provided - if (password) { - const vault = await getVault(password) - if (vault) { - const wallet = vault.wallets.find(w => w.id === walletId) - if (wallet) { - const address = xe.wallet.deriveAddress(wallet.publicKey) - await set(KEY_ADDRESS, address, store) - } - } - } -} - -/** - * Get cached address (for display when locked, does not require password). - * - * @returns {Promise} Cached address - */ -export async function getCachedAddress() { - return await get(KEY_ADDRESS, store) } /** @@ -326,7 +302,6 @@ export async function addWallet(wallet, password, options = {}) { // Set as active if requested if (setActive) { await setActiveWalletId(walletId) - await set(KEY_ADDRESS, address, store) } return { @@ -366,14 +341,11 @@ export async function removeWallet(walletId, password) { if (vault.wallets.length > 0) { // Switch to first remaining wallet newActiveId = vault.wallets[0].id - const newActiveWallet = vault.wallets[0] await setActiveWalletId(newActiveId) - await set(KEY_ADDRESS, xe.wallet.deriveAddress(newActiveWallet.publicKey), store) } else { // No wallets left newActiveId = null await del(KEY_ACTIVE_WALLET_ID, store) - await del(KEY_ADDRESS, store) } } @@ -428,6 +400,5 @@ export async function hasVault() { */ export async function clearVault() { await del(KEY_VAULT, store) - await del(KEY_ADDRESS, store) await del(KEY_ACTIVE_WALLET_ID, store) } From abe2caad98ffabac863a4ce5ab89349e8577bd15 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 5 Feb 2026 14:25:23 +0200 Subject: [PATCH 06/19] feat: interim UI changes to support v2 storage --- src/components/index/CreateModal.vue | 6 ++++-- src/components/index/RestoreModal.vue | 6 ++++-- src/components/index/UnlockModal.vue | 11 +++++++---- src/store.js | 22 ++++++++++------------ src/views/Index.vue | 2 +- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/components/index/CreateModal.vue b/src/components/index/CreateModal.vue index f6060a5..163d303 100644 --- a/src/components/index/CreateModal.vue +++ b/src/components/index/CreateModal.vue @@ -182,8 +182,10 @@ export default { if (!await this.v$.$validate()) return await storage.setWallet({ privateKey: this.privateKey, publicKey: this.publicKey }, this.password) - await storage.setWalletVersion(storage.getHighestWalletVersion()) - await this.$store.dispatch('reloadWallet') + + const address = xe.wallet.deriveAddress(this.publicKey) + this.$store.commit('setAddress', address) + this.$store.commit('setVersion', storage.getHighestWalletVersion()) this.$store.commit('unlock') this.$store.dispatch('refresh') diff --git a/src/components/index/RestoreModal.vue b/src/components/index/RestoreModal.vue index 64f27b9..fc62af8 100644 --- a/src/components/index/RestoreModal.vue +++ b/src/components/index/RestoreModal.vue @@ -134,8 +134,10 @@ export default { const publicKey = xe.wallet.publicKeyFromPrivateKey(this.privateKey) await storage.setWallet({ privateKey: this.privateKey, publicKey }, this.password) - await storage.setWalletVersion(storage.getHighestWalletVersion()) - await this.$store.dispatch('reloadWallet') + + const address = xe.wallet.deriveAddress(publicKey) + this.$store.commit('setAddress', address) + this.$store.commit('setVersion', storage.getHighestWalletVersion()) this.$store.commit('unlock') this.$store.dispatch('refresh') diff --git a/src/components/index/UnlockModal.vue b/src/components/index/UnlockModal.vue index ca24006..1bb5123 100644 --- a/src/components/index/UnlockModal.vue +++ b/src/components/index/UnlockModal.vue @@ -7,7 +7,7 @@ + + diff --git a/src/components/wallet/WalletIndicator.vue b/src/components/wallet/WalletIndicator.vue new file mode 100644 index 0000000..116ae50 --- /dev/null +++ b/src/components/wallet/WalletIndicator.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/components/wallet/WalletListItem.vue b/src/components/wallet/WalletListItem.vue new file mode 100644 index 0000000..986998a --- /dev/null +++ b/src/components/wallet/WalletListItem.vue @@ -0,0 +1,259 @@ + + + + + From 454b4ca25c9da6d4b9b4c7d6b5cf6d1d1759c637 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Fri, 6 Feb 2026 00:22:51 +0200 Subject: [PATCH 10/19] feat: update modals for multi-wallet --- src/components/index/CreateModal.vue | 72 ++++++++++++++++++----- src/components/index/ExportModal.vue | 30 ++++++++-- src/components/index/ForgetModal.vue | 44 +++++++++++--- src/components/index/RestoreModal.vue | 83 ++++++++++++++++++++------- src/components/index/UnlockModal.vue | 18 ++++-- 5 files changed, 194 insertions(+), 53 deletions(-) diff --git a/src/components/index/CreateModal.vue b/src/components/index/CreateModal.vue index 163d303..08528cf 100644 --- a/src/components/index/CreateModal.vue +++ b/src/components/index/CreateModal.vue @@ -1,7 +1,7 @@