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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 20:54:59 +00:00
parent 564c16e431
commit fc1776aedf
3 changed files with 163 additions and 8 deletions

View File

@ -78,6 +78,15 @@ export {
type AccountVault,
} from './vault';
// ============================================================================
// WALLET SYNC
// ============================================================================
export {
syncWalletsOnLogin,
syncWalletsToVault,
} from './wallet-sync';
// ============================================================================
// UI COMPONENTS
// ============================================================================

View File

@ -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', {

View File

@ -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<void> {
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<void> {
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<void> {
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 }),
});
}