Release v1.0.9 - Add wallet tab with Cashu and Lightning support

- Add wallet tab with NWC (Nostr Wallet Connect) Lightning support
- Add Cashu ecash wallet with mint management, send/receive tokens
- Add Cashu deposit feature (mint via Lightning invoice)
- Add token viewer showing proof amounts and timestamps
- Add refresh button with auto-refresh for spent proof detection
- Add browser sync warning for Cashu users on welcome screen
- Add Cashu onboarding info panel with storage considerations
- Add settings page sync info note explaining how to change sync
- Add backups page for vault snapshot management
- Add About section to identity (You) page
- Fix lint accessibility issues in wallet component

Files modified:
- projects/common/src/lib/services/nwc/* (new)
- projects/common/src/lib/services/cashu/* (new)
- projects/common/src/lib/services/storage/* (extended)
- projects/chrome/src/app/components/home/wallet/*
- projects/firefox/src/app/components/home/wallet/*
- projects/chrome/src/app/components/welcome/*
- projects/firefox/src/app/components/welcome/*
- projects/chrome/src/app/components/home/settings/*
- projects/firefox/src/app/components/home/settings/*
- projects/chrome/src/app/components/home/identity/*
- projects/firefox/src/app/components/home/identity/*
- projects/chrome/src/app/components/home/backups/* (new)
- projects/firefox/src/app/components/home/backups/* (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
woikos
2025-12-21 15:40:25 +01:00
parent 1f8d478cd7
commit ebc96e7201
54 changed files with 9542 additions and 50 deletions

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from './types';
@@ -104,6 +106,38 @@ export abstract class BrowserSyncHandler {
this.#browserSyncData.relays = Array.from(data.relays);
}
/**
* Persist the NWC connections to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void>;
setPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
}
/**
* Persist the Cashu mints to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void>;
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
}
/**
* Clear all data from the sync data storage.
*/

View File

@@ -0,0 +1,361 @@
import {
CryptoHelper,
CashuMint_DECRYPTED,
CashuMint_ENCRYPTED,
CashuProof,
StorageService,
} from '@common';
import { LockedVaultContext } from './identity';
/**
* Validate a Cashu mint URL
*/
export function isValidMintUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
export const addCashuMint = async function (
this: StorageService,
data: {
name: string;
mintUrl: string;
unit?: string;
}
): Promise<CashuMint_DECRYPTED> {
this.assureIsInitialized();
// Validate the mint URL
if (!isValidMintUrl(data.mintUrl)) {
throw new Error('Invalid mint URL format');
}
// Normalize URL (remove trailing slash)
const normalizedUrl = data.mintUrl.replace(/\/$/, '');
// Check if a mint with the same URL already exists
const existingMint = (
this.getBrowserSessionHandler().browserSessionData?.cashuMints ?? []
).find((x) => x.mintUrl === normalizedUrl);
if (existingMint) {
throw new Error(
`A connection to this mint already exists: ${existingMint.name}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedMint: CashuMint_DECRYPTED = {
id: CryptoHelper.v4(),
name: data.name,
mintUrl: normalizedUrl,
unit: data.unit ?? 'sat',
createdAt: new Date().toISOString(),
proofs: [], // Start with no proofs
cachedBalance: 0,
cachedBalanceAt: new Date().toISOString(),
};
// Initialize array if needed
if (!browserSessionData.cashuMints) {
browserSessionData.cashuMints = [];
}
// Add the new mint to the session data
browserSessionData.cashuMints.push(decryptedMint);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new mint and add it to the sync data
const encryptedMint = await encryptCashuMint.call(this, decryptedMint);
const encryptedMints = [
...(this.getBrowserSyncHandler().browserSyncData?.cashuMints ?? []),
encryptedMint,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: encryptedMints,
});
return decryptedMint;
};
export const deleteCashuMint = async function (
this: StorageService,
mintId: string
): Promise<void> {
this.assureIsInitialized();
if (!mintId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
// Remove from session data
browserSessionData.cashuMints = (browserSessionData.cashuMints ?? []).filter(
(x) => x.id !== mintId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data
const encryptedMintId = await this.encrypt(mintId);
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: (browserSyncData.cashuMints ?? []).filter(
(x) => x.id !== encryptedMintId
),
});
};
/**
* Update the proofs for a Cashu mint
* This is called after send/receive operations
*/
export const updateCashuMintProofs = async function (
this: StorageService,
mintId: string,
proofs: CashuProof[]
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionMint = (browserSessionData.cashuMints ?? []).find(
(x) => x.id === mintId
);
const encryptedMintId = await this.encrypt(mintId);
const syncMint = (browserSyncData.cashuMints ?? []).find(
(x) => x.id === encryptedMintId
);
if (!sessionMint || !syncMint) {
throw new Error('Cashu mint not found for proofs update.');
}
const now = new Date().toISOString();
// Calculate balance from proofs (sum of all proof amounts in satoshis)
const balance = proofs.reduce((sum, p) => sum + p.amount, 0);
// Update session data
sessionMint.proofs = proofs;
sessionMint.cachedBalance = balance;
sessionMint.cachedBalanceAt = now;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Update sync data
syncMint.proofs = await this.encrypt(JSON.stringify(proofs));
syncMint.cachedBalance = await this.encrypt(balance.toString());
syncMint.cachedBalanceAt = await this.encrypt(now);
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
cashuMints: browserSyncData.cashuMints ?? [],
});
};
export const encryptCashuMint = async function (
this: StorageService,
mint: CashuMint_DECRYPTED
): Promise<CashuMint_ENCRYPTED> {
const encrypted: CashuMint_ENCRYPTED = {
id: await this.encrypt(mint.id),
name: await this.encrypt(mint.name),
mintUrl: await this.encrypt(mint.mintUrl),
unit: await this.encrypt(mint.unit),
createdAt: await this.encrypt(mint.createdAt),
proofs: await this.encrypt(JSON.stringify(mint.proofs)),
};
if (mint.cachedBalance !== undefined) {
encrypted.cachedBalance = await this.encrypt(mint.cachedBalance.toString());
}
if (mint.cachedBalanceAt) {
encrypted.cachedBalanceAt = await this.encrypt(mint.cachedBalanceAt);
}
return encrypted;
};
export const decryptCashuMint = async function (
this: StorageService,
mint: CashuMint_ENCRYPTED,
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<CashuMint_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
// Normal decryption with unlocked vault
const proofsJson = await this.decrypt(mint.proofs, 'string');
const decrypted: CashuMint_DECRYPTED = {
id: await this.decrypt(mint.id, 'string'),
name: await this.decrypt(mint.name, 'string'),
mintUrl: await this.decrypt(mint.mintUrl, 'string'),
unit: await this.decrypt(mint.unit, 'string'),
createdAt: await this.decrypt(mint.createdAt, 'string'),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decrypt(mint.cachedBalance, 'number');
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decrypt(
mint.cachedBalanceAt,
'string'
);
}
return decrypted;
}
// v2: Use pre-derived key
if (withLockedVault.keyBase64) {
const proofsJson = await this.decryptWithLockedVaultV2(
mint.proofs,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
const decrypted: CashuMint_DECRYPTED = {
id: await this.decryptWithLockedVaultV2(
mint.id,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
name: await this.decryptWithLockedVaultV2(
mint.name,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
mintUrl: await this.decryptWithLockedVaultV2(
mint.mintUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
unit: await this.decryptWithLockedVaultV2(
mint.unit,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
createdAt: await this.decryptWithLockedVaultV2(
mint.createdAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
mint.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
mint.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
return decrypted;
}
// v1: Use password (PBKDF2)
const proofsJson = await this.decryptWithLockedVault(
mint.proofs,
'string',
withLockedVault.iv,
withLockedVault.password!
);
const decrypted: CashuMint_DECRYPTED = {
id: await this.decryptWithLockedVault(
mint.id,
'string',
withLockedVault.iv,
withLockedVault.password!
),
name: await this.decryptWithLockedVault(
mint.name,
'string',
withLockedVault.iv,
withLockedVault.password!
),
mintUrl: await this.decryptWithLockedVault(
mint.mintUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
unit: await this.decryptWithLockedVault(
mint.unit,
'string',
withLockedVault.iv,
withLockedVault.password!
),
createdAt: await this.decryptWithLockedVault(
mint.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password!
),
proofs: JSON.parse(proofsJson) as CashuProof[],
};
if (mint.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVault(
mint.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.password!
);
}
if (mint.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
mint.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
return decrypted;
};
export const decryptCashuMints = async function (
this: StorageService,
mints: CashuMint_ENCRYPTED[],
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<CashuMint_DECRYPTED[]> {
const decryptedMints: CashuMint_DECRYPTED[] = [];
for (const mint of mints) {
const decryptedMint = await decryptCashuMint.call(
this,
mint,
withLockedVault
);
decryptedMints.push(decryptedMint);
}
return decryptedMints;
};

View File

@@ -0,0 +1,419 @@
import {
CryptoHelper,
NwcConnection_DECRYPTED,
NwcConnection_ENCRYPTED,
StorageService,
} from '@common';
import { LockedVaultContext } from './identity';
/**
* Parse a nostr+walletconnect:// URL into its components
*/
export function parseNwcUrl(url: string): {
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
} | null {
try {
// Format: nostr+walletconnect://<pubkey>?relay=<url>&secret=<hex>&lud16=<optional>
const match = url.match(/^nostr\+walletconnect:\/\/([a-f0-9]{64})\?(.+)$/i);
if (!match) {
return null;
}
const walletPubkey = match[1].toLowerCase();
const params = new URLSearchParams(match[2]);
const relayUrl = params.get('relay');
const secret = params.get('secret');
const lud16 = params.get('lud16') || undefined;
if (!relayUrl || !secret) {
return null;
}
// Validate secret is 64-char hex
if (!/^[a-f0-9]{64}$/i.test(secret)) {
return null;
}
return {
walletPubkey,
relayUrl: decodeURIComponent(relayUrl),
secret: secret.toLowerCase(),
lud16,
};
} catch {
return null;
}
}
export const addNwcConnection = async function (
this: StorageService,
data: {
name: string;
connectionUrl: string;
}
): Promise<void> {
this.assureIsInitialized();
// Parse the NWC URL
const parsed = parseNwcUrl(data.connectionUrl);
if (!parsed) {
throw new Error('Invalid NWC URL format');
}
// Check if a connection with the same wallet pubkey already exists
const existingConnection = (
this.getBrowserSessionHandler().browserSessionData?.nwcConnections ?? []
).find((x) => x.walletPubkey === parsed.walletPubkey);
if (existingConnection) {
throw new Error(
`A connection to this wallet already exists: ${existingConnection.name}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedConnection: NwcConnection_DECRYPTED = {
id: CryptoHelper.v4(),
name: data.name,
connectionUrl: data.connectionUrl,
walletPubkey: parsed.walletPubkey,
relayUrl: parsed.relayUrl,
secret: parsed.secret,
lud16: parsed.lud16,
createdAt: new Date().toISOString(),
};
// Initialize array if needed
if (!browserSessionData.nwcConnections) {
browserSessionData.nwcConnections = [];
}
// Add the new connection to the session data
browserSessionData.nwcConnections.push(decryptedConnection);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new connection and add it to the sync data
const encryptedConnection = await encryptNwcConnection.call(
this,
decryptedConnection
);
const encryptedConnections = [
...(this.getBrowserSyncHandler().browserSyncData?.nwcConnections ?? []),
encryptedConnection,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: encryptedConnections,
});
};
export const deleteNwcConnection = async function (
this: StorageService,
connectionId: string
): Promise<void> {
this.assureIsInitialized();
if (!connectionId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
// Remove from session data
browserSessionData.nwcConnections = (
browserSessionData.nwcConnections ?? []
).filter((x) => x.id !== connectionId);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data
const encryptedConnectionId = await this.encrypt(connectionId);
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: (browserSyncData.nwcConnections ?? []).filter(
(x) => x.id !== encryptedConnectionId
),
});
};
export const updateNwcConnectionBalance = async function (
this: StorageService,
connectionId: string,
balanceMillisats: number
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionConnection = (browserSessionData.nwcConnections ?? []).find(
(x) => x.id === connectionId
);
const encryptedConnectionId = await this.encrypt(connectionId);
const syncConnection = (browserSyncData.nwcConnections ?? []).find(
(x) => x.id === encryptedConnectionId
);
if (!sessionConnection || !syncConnection) {
throw new Error('NWC connection not found for balance update.');
}
const now = new Date().toISOString();
// Update session data
sessionConnection.cachedBalance = balanceMillisats;
sessionConnection.cachedBalanceAt = now;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Update sync data
syncConnection.cachedBalance = await this.encrypt(balanceMillisats.toString());
syncConnection.cachedBalanceAt = await this.encrypt(now);
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
nwcConnections: browserSyncData.nwcConnections ?? [],
});
};
export const encryptNwcConnection = async function (
this: StorageService,
connection: NwcConnection_DECRYPTED
): Promise<NwcConnection_ENCRYPTED> {
const encrypted: NwcConnection_ENCRYPTED = {
id: await this.encrypt(connection.id),
name: await this.encrypt(connection.name),
connectionUrl: await this.encrypt(connection.connectionUrl),
walletPubkey: await this.encrypt(connection.walletPubkey),
relayUrl: await this.encrypt(connection.relayUrl),
secret: await this.encrypt(connection.secret),
createdAt: await this.encrypt(connection.createdAt),
};
if (connection.lud16) {
encrypted.lud16 = await this.encrypt(connection.lud16);
}
if (connection.cachedBalance !== undefined) {
encrypted.cachedBalance = await this.encrypt(
connection.cachedBalance.toString()
);
}
if (connection.cachedBalanceAt) {
encrypted.cachedBalanceAt = await this.encrypt(connection.cachedBalanceAt);
}
return encrypted;
};
export const decryptNwcConnection = async function (
this: StorageService,
connection: NwcConnection_ENCRYPTED,
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<NwcConnection_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
// Normal decryption with unlocked vault
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decrypt(connection.id, 'string'),
name: await this.decrypt(connection.name, 'string'),
connectionUrl: await this.decrypt(connection.connectionUrl, 'string'),
walletPubkey: await this.decrypt(connection.walletPubkey, 'string'),
relayUrl: await this.decrypt(connection.relayUrl, 'string'),
secret: await this.decrypt(connection.secret, 'string'),
createdAt: await this.decrypt(connection.createdAt, 'string'),
};
if (connection.lud16) {
decrypted.lud16 = await this.decrypt(connection.lud16, 'string');
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decrypt(
connection.cachedBalance,
'number'
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decrypt(
connection.cachedBalanceAt,
'string'
);
}
return decrypted;
}
// v2: Use pre-derived key
if (withLockedVault.keyBase64) {
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decryptWithLockedVaultV2(
connection.id,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
name: await this.decryptWithLockedVaultV2(
connection.name,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
connectionUrl: await this.decryptWithLockedVaultV2(
connection.connectionUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
walletPubkey: await this.decryptWithLockedVaultV2(
connection.walletPubkey,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
relayUrl: await this.decryptWithLockedVaultV2(
connection.relayUrl,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
secret: await this.decryptWithLockedVaultV2(
connection.secret,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
createdAt: await this.decryptWithLockedVaultV2(
connection.createdAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
),
};
if (connection.lud16) {
decrypted.lud16 = await this.decryptWithLockedVaultV2(
connection.lud16,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
connection.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
connection.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.keyBase64
);
}
return decrypted;
}
// v1: Use password (PBKDF2)
const decrypted: NwcConnection_DECRYPTED = {
id: await this.decryptWithLockedVault(
connection.id,
'string',
withLockedVault.iv,
withLockedVault.password!
),
name: await this.decryptWithLockedVault(
connection.name,
'string',
withLockedVault.iv,
withLockedVault.password!
),
connectionUrl: await this.decryptWithLockedVault(
connection.connectionUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
walletPubkey: await this.decryptWithLockedVault(
connection.walletPubkey,
'string',
withLockedVault.iv,
withLockedVault.password!
),
relayUrl: await this.decryptWithLockedVault(
connection.relayUrl,
'string',
withLockedVault.iv,
withLockedVault.password!
),
secret: await this.decryptWithLockedVault(
connection.secret,
'string',
withLockedVault.iv,
withLockedVault.password!
),
createdAt: await this.decryptWithLockedVault(
connection.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password!
),
};
if (connection.lud16) {
decrypted.lud16 = await this.decryptWithLockedVault(
connection.lud16,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
if (connection.cachedBalance) {
decrypted.cachedBalance = await this.decryptWithLockedVault(
connection.cachedBalance,
'number',
withLockedVault.iv,
withLockedVault.password!
);
}
if (connection.cachedBalanceAt) {
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
connection.cachedBalanceAt,
'string',
withLockedVault.iv,
withLockedVault.password!
);
}
return decrypted;
};
export const decryptNwcConnections = async function (
this: StorageService,
connections: NwcConnection_ENCRYPTED[],
withLockedVault: LockedVaultContext | undefined = undefined
): Promise<NwcConnection_DECRYPTED[]> {
const decryptedConnections: NwcConnection_DECRYPTED[] = [];
for (const connection of connections) {
const decryptedConnection = await decryptNwcConnection.call(
this,
connection,
withLockedVault
);
decryptedConnections.push(decryptedConnection);
}
return decryptedConnections;
};

View File

@@ -8,7 +8,9 @@ import {
deriveKeyArgon2,
} from '@common';
import { Buffer } from 'buffer';
import { decryptCashuMints, encryptCashuMint } from './cashu';
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
import { decryptNwcConnections, encryptNwcConnection } from './nwc';
import { decryptPermissions } from './permission';
import { decryptRelays, encryptRelay } from './relay';
@@ -34,6 +36,8 @@ export const createNewVault = async function (
identities: [],
permissions: [],
relays: [],
nwcConnections: [],
cashuMints: [],
selectedIdentityId: null,
};
await this.getBrowserSessionHandler().saveFullData(sessionData);
@@ -47,6 +51,8 @@ export const createNewVault = async function (
identities: [],
permissions: [],
relays: [],
nwcConnections: [],
cashuMints: [],
selectedIdentityId: null,
};
await this.getBrowserSyncHandler().saveAndSetFullData(syncData);
@@ -133,6 +139,22 @@ export const unlockVault = async function (
);
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
console.log('[vault] Decrypting NWC connections...');
const decryptedNwcConnections = await decryptNwcConnections.call(
this,
browserSyncData.nwcConnections ?? [],
withLockedVault
);
console.log('[vault] Decrypted', decryptedNwcConnections.length, 'NWC connections');
console.log('[vault] Decrypting Cashu mints...');
const decryptedCashuMints = await decryptCashuMints.call(
this,
browserSyncData.cashuMints ?? [],
withLockedVault
);
console.log('[vault] Decrypted', decryptedCashuMints.length, 'Cashu mints');
console.log('[vault] Decrypting selectedIdentityId...');
let decryptedSelectedIdentityId: string | null = null;
if (browserSyncData.selectedIdentityId !== null) {
@@ -163,6 +185,8 @@ export const unlockVault = async function (
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
nwcConnections: decryptedNwcConnections,
cashuMints: decryptedCashuMints,
};
console.log('[vault] Saving session data...');
@@ -234,6 +258,20 @@ async function migrateVaultV1ToV2(
encryptedPermissions.push(encryptedPermission);
}
// Re-encrypt NWC connections
const encryptedNwcConnections = [];
for (const nwcConnection of browserSessionData.nwcConnections ?? []) {
const encrypted = await encryptNwcConnection.call(this, nwcConnection);
encryptedNwcConnections.push(encrypted);
}
// Re-encrypt Cashu mints
const encryptedCashuMints = [];
for (const cashuMint of browserSessionData.cashuMints ?? []) {
const encrypted = await encryptCashuMint.call(this, cashuMint);
encryptedCashuMints.push(encrypted);
}
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
? await this.encrypt(browserSessionData.selectedIdentityId)
: null;
@@ -247,6 +285,8 @@ async function migrateVaultV1ToV2(
identities: encryptedIdentities,
permissions: encryptedPermissions,
relays: encryptedRelays,
nwcConnections: encryptedNwcConnections,
cashuMints: encryptedCashuMints,
selectedIdentityId: encryptedSelectedIdentityId,
};

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Bookmark, BrowserSyncFlow, SignerMetaData } from './types';
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
import { v4 as uuidv4 } from 'uuid';
export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined {
@@ -8,7 +9,8 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
readonly DEFAULT_MAX_BACKUPS = 5;
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
@@ -111,4 +113,120 @@ export abstract class SignerMetaHandler {
getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? [];
}
/**
* Gets the maximum number of backups to keep.
*/
getMaxBackups(): number {
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
}
/**
* Sets the maximum number of backups to keep and immediately saves it.
*/
async setMaxBackups(count: number): Promise<void> {
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
if (!this.#signerMetaData) {
this.#signerMetaData = {
maxBackups: clampedCount,
};
} else {
this.#signerMetaData.maxBackups = clampedCount;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Gets all vault backups, sorted newest first.
*/
getBackups(): SignerMetaData_VaultSnapshot[] {
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
return [...backups].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
/**
* Gets a specific backup by ID.
*/
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
}
/**
* Creates a new backup of the vault data.
* Automatically removes old backups if exceeding maxBackups.
*/
async createBackup(
browserSyncData: BrowserSyncData,
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
): Promise<SignerMetaData_VaultSnapshot> {
const now = new Date();
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const identityCount = browserSyncData.identities?.length ?? 0;
const snapshot: SignerMetaData_VaultSnapshot = {
id: uuidv4(),
fileName: `Vault Backup - ${dateTimeString}`,
createdAt: now.toISOString(),
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
identityCount,
reason,
};
if (!this.#signerMetaData) {
this.#signerMetaData = {
vaultSnapshots: [snapshot],
};
} else {
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
existingBackups.push(snapshot);
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
const maxBackups = this.getMaxBackups();
const autoBackups = existingBackups.filter(b => b.reason === 'auto');
const otherBackups = existingBackups.filter(b => b.reason !== 'auto');
// Sort auto backups by date (newest first) and keep only maxBackups
autoBackups.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
}
await this.saveFullData(this.#signerMetaData);
return snapshot;
}
/**
* Deletes a backup by ID.
*/
async deleteBackup(backupId: string): Promise<boolean> {
if (!this.#signerMetaData?.vaultSnapshots) {
return false;
}
const initialLength = this.#signerMetaData.vaultSnapshots.length;
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
b => b.id !== backupId
);
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
await this.saveFullData(this.#signerMetaData);
return true;
}
return false;
}
/**
* Gets the data from a backup for restoration.
* Note: The caller should create a pre-restore backup before calling this.
*/
getBackupData(backupId: string): BrowserSyncData | undefined {
const backup = this.getBackupById(backupId);
return backup?.data;
}
}

View File

@@ -20,6 +20,17 @@ import {
import { deletePermission } from './related/permission';
import { createNewVault, deleteVault, unlockVault } from './related/vault';
import { addRelay, deleteRelay, updateRelay } from './related/relay';
import {
addNwcConnection,
deleteNwcConnection,
updateNwcConnectionBalance,
} from './related/nwc';
import {
addCashuMint,
deleteCashuMint,
updateCashuMintProofs,
} from './related/cashu';
import { CashuMint_DECRYPTED, CashuProof } from './types';
export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler;
@@ -176,6 +187,43 @@ export class StorageService {
await updateRelay.call(this, relayClone);
}
async addNwcConnection(data: {
name: string;
connectionUrl: string;
}): Promise<void> {
await addNwcConnection.call(this, data);
}
async deleteNwcConnection(connectionId: string): Promise<void> {
await deleteNwcConnection.call(this, connectionId);
}
async updateNwcConnectionBalance(
connectionId: string,
balanceMillisats: number
): Promise<void> {
await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats);
}
async addCashuMint(data: {
name: string;
mintUrl: string;
unit?: string;
}): Promise<CashuMint_DECRYPTED> {
return await addCashuMint.call(this, data);
}
async deleteCashuMint(mintId: string): Promise<void> {
await deleteCashuMint.call(this, mintId);
}
async updateCashuMintProofs(
mintId: string,
proofs: CashuProof[]
): Promise<void> {
await updateCashuMintProofs.call(this, mintId, proofs);
}
exportVault(): string {
this.assureIsInitialized();
const vaultJson = JSON.stringify(
@@ -226,6 +274,17 @@ export class StorageService {
return this.#signerMetaHandler;
}
/**
* Get the current browser sync flow setting.
* Returns NO_SYNC if not initialized or no setting found.
*/
getSyncFlow(): BrowserSyncFlow {
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
return BrowserSyncFlow.NO_SYNC;
}
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
}
/**
* Throws an exception if the service is not initialized.
*/

View File

@@ -43,6 +43,80 @@ export interface Relay_ENCRYPTED {
write: string;
}
/**
* NWC (Nostr Wallet Connect) connection - Decrypted
* Stores NIP-47 wallet connection data
*/
export interface NwcConnection_DECRYPTED {
id: string;
name: string; // User-defined wallet name
connectionUrl: string; // Full nostr+walletconnect:// URL
walletPubkey: string; // Wallet service pubkey
relayUrl: string; // Relay URL for NWC communication
secret: string; // Client secret key (32-byte hex)
lud16?: string; // Optional lightning address
createdAt: string; // ISO timestamp
cachedBalance?: number; // Balance in millisatoshis
cachedBalanceAt?: string; // ISO timestamp when balance was fetched
}
/**
* NWC connection - Encrypted for storage
*/
export interface NwcConnection_ENCRYPTED {
id: string;
name: string;
connectionUrl: string;
walletPubkey: string;
relayUrl: string;
secret: string;
lud16?: string;
createdAt: string;
cachedBalance?: string; // Encrypted as string
cachedBalanceAt?: string;
}
/**
* Cashu Proof - represents a single ecash token
* This is the actual money stored locally
*/
export interface CashuProof {
id: string; // Keyset ID from mint
amount: number; // Satoshi amount
secret: string; // Blinded secret
C: string; // Unblinded signature (commitment)
receivedAt?: string; // ISO timestamp when token was received
}
/**
* Cashu Mint Connection - Decrypted
* Stores NIP-60 Cashu mint connection data with local proofs
*/
export interface CashuMint_DECRYPTED {
id: string;
name: string; // User-defined mint name
mintUrl: string; // Mint API URL
unit: string; // Unit (default: 'sat')
createdAt: string; // ISO timestamp
proofs: CashuProof[]; // Unspent proofs for this mint
cachedBalance?: number; // Sum of proof amounts (sats)
cachedBalanceAt?: string; // When balance was calculated
}
/**
* Cashu Mint Connection - Encrypted for storage
*/
export interface CashuMint_ENCRYPTED {
id: string;
name: string;
mintUrl: string;
unit: string;
createdAt: string;
proofs: string; // JSON stringified and encrypted
cachedBalance?: string;
cachedBalanceAt?: string;
}
export interface BrowserSyncData_PART_Unencrypted {
version: number;
iv: string;
@@ -57,6 +131,8 @@ export interface BrowserSyncData_PART_Encrypted {
permissions: Permission_ENCRYPTED[];
identities: Identity_ENCRYPTED[];
relays: Relay_ENCRYPTED[];
nwcConnections?: NwcConnection_ENCRYPTED[];
cashuMints?: CashuMint_ENCRYPTED[];
}
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
@@ -83,11 +159,17 @@ export interface BrowserSessionData {
identities: Identity_DECRYPTED[];
selectedIdentityId: string | null;
relays: Relay_DECRYPTED[];
nwcConnections?: NwcConnection_DECRYPTED[];
cashuMints?: CashuMint_DECRYPTED[];
}
export interface SignerMetaData_VaultSnapshot {
id: string;
fileName: string;
createdAt: string; // ISO timestamp
data: BrowserSyncData;
identityCount: number;
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
}
export const SIGNER_META_DATA_KEY = {
@@ -109,6 +191,9 @@ export interface SignerMetaData {
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
// Maximum number of automatic backups to keep (default: 5)
maxBackups?: number;
// Reckless mode: auto-approve all actions without prompting
recklessMode?: boolean;