- 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>
362 lines
9.9 KiB
TypeScript
362 lines
9.9 KiB
TypeScript
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;
|
|
};
|