From fc1776aedf0580da952ce667526c3e4b9b59d0f0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 20:54:59 +0000 Subject: [PATCH] feat(encryptid): sync wallet associations across devices via encrypted vault Wallets stored in local WalletStore are now bidirectionally synced with the encrypted AccountVault on the server. On login, vault wallets are restored to the local store; on wallet changes, local state is pushed back to the vault. The server user profile wallet_address is also set on login so mobile devices (without PRF) get the address via JWT. Co-Authored-By: Claude Opus 4.6 --- src/encryptid/index.ts | 9 ++ src/encryptid/ui/login-button.ts | 17 ++-- src/encryptid/wallet-sync.ts | 145 +++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 src/encryptid/wallet-sync.ts diff --git a/src/encryptid/index.ts b/src/encryptid/index.ts index 7992dee..923760c 100644 --- a/src/encryptid/index.ts +++ b/src/encryptid/index.ts @@ -78,6 +78,15 @@ export { type AccountVault, } from './vault'; +// ============================================================================ +// WALLET SYNC +// ============================================================================ + +export { + syncWalletsOnLogin, + syncWalletsToVault, +} from './wallet-sync'; + // ============================================================================ // UI COMPONENTS // ============================================================================ diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 36e7975..3dd3ce7 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -16,6 +16,7 @@ import { getKeyManager } from '../key-derivation'; import { getSessionManager, AuthLevel } from '../session'; import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; import { getVaultManager, resetVaultManager } from '../vault'; +import { syncWalletsOnLogin } from '../wallet-sync'; // ============================================================================ // STYLES @@ -415,9 +416,9 @@ export class EncryptIDLoginButton extends HTMLElement { // Load encrypted account vault in background const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { - getVaultManager(docCrypto).load().catch(err => - console.warn('Vault load failed:', err) - ); + getVaultManager(docCrypto).load() + .then(() => syncWalletsOnLogin(docCrypto)) + .catch(err => console.warn('Vault load failed:', err)); } } @@ -429,7 +430,7 @@ export class EncryptIDLoginButton extends HTMLElement { await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, - wallet: false, // Will be true after wallet setup + wallet: !!keys.eoaAddress, }); // Dispatch success event @@ -534,9 +535,9 @@ export class EncryptIDLoginButton extends HTMLElement { // Load encrypted account vault in background const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { - getVaultManager(docCrypto).load().catch(err => - console.warn('Vault load failed:', err) - ); + getVaultManager(docCrypto).load() + .then(() => syncWalletsOnLogin(docCrypto)) + .catch(err => console.warn('Vault load failed:', err)); } } @@ -546,7 +547,7 @@ export class EncryptIDLoginButton extends HTMLElement { await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, - wallet: false, + wallet: !!keys.eoaAddress, }); this.dispatchEvent(new CustomEvent('login-success', { diff --git a/src/encryptid/wallet-sync.ts b/src/encryptid/wallet-sync.ts new file mode 100644 index 0000000..d5a8fe4 --- /dev/null +++ b/src/encryptid/wallet-sync.ts @@ -0,0 +1,145 @@ +/** + * EncryptID Wallet Sync + * + * Bridges WalletStore (encrypted localStorage) ↔ VaultManager (encrypted server blob) + * so wallet associations survive across devices. + * + * On login: + * 1. Load vault from server + * 2. Merge vault wallets into local WalletStore (new entries only) + * 3. Merge local wallets into vault (in case this device has wallets the vault doesn't) + * 4. Save vault if anything changed + * + * On wallet add/remove: + * syncWalletsToVault() pushes current WalletStore state into the vault. + */ + +import { getWalletStore, type SafeWalletEntry } from './wallet-store'; +import { getVaultManager, type AccountVault } from './vault'; +import { getKeyManager } from './key-derivation'; +import type { DocCrypto } from '../../shared/local-first/crypto'; + +/** + * After login + vault load, merge wallets bidirectionally between + * the local WalletStore and the remote AccountVault. + * + * Requires PRF-derived encryption key (for WalletStore) and DocCrypto (for VaultManager). + */ +export async function syncWalletsOnLogin(docCrypto: DocCrypto): Promise { + try { + const km = getKeyManager(); + if (!km.isInitialized()) return; + + const keys = await km.getKeys(); + if (!keys.encryptionKey) return; + + const walletStore = getWalletStore(keys.encryptionKey); + const vaultManager = getVaultManager(docCrypto); + + const vault = await vaultManager.get(); + const localWallets = await walletStore.list(); + const vaultWallets = vault.wallets || []; + + let changed = false; + + // 1. Restore vault wallets → local WalletStore (if not already present) + for (const vw of vaultWallets) { + const exists = localWallets.find( + lw => lw.safeAddress.toLowerCase() === vw.safeAddress.toLowerCase() + && lw.chainId === vw.chainId + ); + if (!exists) { + await walletStore.add({ + safeAddress: vw.safeAddress, + chainId: vw.chainId, + eoaAddress: vw.eoaAddress, + label: vw.label, + }); + } + } + + // 2. Push local wallets → vault (if not already present) + const updatedLocal = await walletStore.list(); + const updatedVaultWallets = [...vaultWallets]; + + for (const lw of updatedLocal) { + const exists = updatedVaultWallets.find( + vw => vw.safeAddress.toLowerCase() === lw.safeAddress.toLowerCase() + && vw.chainId === lw.chainId + ); + if (!exists) { + updatedVaultWallets.push({ + id: lw.id, + safeAddress: lw.safeAddress, + chainId: lw.chainId, + eoaAddress: lw.eoaAddress, + label: lw.label, + addedAt: lw.addedAt, + }); + changed = true; + } + } + + // 3. Also ensure the server-side wallet_address profile field is set + // This is the fallback for mobile (no PRF) — the JWT carries it. + if (keys.eoaAddress) { + await setServerWalletAddress(keys.eoaAddress).catch(() => {}); + } + + // 4. Save vault if anything changed + if (changed) { + await vaultManager.update({ wallets: updatedVaultWallets }); + } + } catch (err) { + console.warn('Wallet sync failed:', err); + } +} + +/** + * Push current WalletStore state into the vault. + * Call this after any wallet add/update/remove operation. + */ +export async function syncWalletsToVault(docCrypto: DocCrypto): Promise { + try { + const km = getKeyManager(); + if (!km.isInitialized()) return; + + const keys = await km.getKeys(); + if (!keys.encryptionKey) return; + + const walletStore = getWalletStore(keys.encryptionKey); + const vaultManager = getVaultManager(docCrypto); + + const localWallets = await walletStore.list(); + const vaultWallets = localWallets.map(lw => ({ + id: lw.id, + safeAddress: lw.safeAddress, + chainId: lw.chainId, + eoaAddress: lw.eoaAddress, + label: lw.label, + addedAt: lw.addedAt, + })); + + await vaultManager.update({ wallets: vaultWallets }); + } catch (err) { + console.warn('Wallet vault sync failed:', err); + } +} + +/** + * Set the wallet address on the server user profile. + * This ensures the JWT carries the wallet address for non-PRF devices (mobile). + */ +async function setServerWalletAddress(walletAddress: string): Promise { + const token = localStorage.getItem('encryptid_token'); + if (!token) return; + + await fetch('/encryptid/api/wallet-capability', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ walletAddress }), + }); +}