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

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
"version": "1.0.8",
"version": "1.0.9",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -12,6 +12,7 @@ import { SettingsComponent } from './components/home/settings/settings.component
import { LogsComponent } from './components/home/logs/logs.component';
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
import { WalletComponent } from './components/home/wallet/wallet.component';
import { BackupsComponent } from './components/home/backups/backups.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
@@ -81,6 +82,10 @@ export const routes: Routes = [
path: 'wallet',
component: WalletComponent,
},
{
path: 'backups',
component: BackupsComponent,
},
],
},
{

View File

@@ -2,7 +2,9 @@
import {
BrowserSyncData,
BrowserSyncHandler,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from '@common';
@@ -57,6 +59,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await chrome.storage.local.remove(props);

View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
CashuMint_ENCRYPTED,
Identity_ENCRYPTED,
NwcConnection_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
@@ -49,6 +51,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
this.setPartialData_Relays(data);
}
async saveAndSetPartialData_NwcConnections(data: {
nwcConnections: NwcConnection_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_NwcConnections(data);
}
async saveAndSetPartialData_CashuMints(data: {
cashuMints: CashuMint_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_CashuMints(data);
}
async clearData(): Promise<void> {
await chrome.storage.sync.clear();
}

View File

@@ -0,0 +1,79 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
<span>Backups</span>
</div>
<div class="backup-settings">
<div class="setting-row">
<label for="maxBackups">Max Auto Backups:</label>
<input
id="maxBackups"
type="number"
[value]="maxBackups"
min="1"
max="20"
(change)="onMaxBackupsChange($event)"
/>
</div>
<p class="setting-note">
Automatic backups are created when significant changes are made.
Manual and pre-restore backups are not counted toward this limit.
</p>
</div>
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
Create Backup Now
</button>
<div class="backups-list">
@if (backups.length === 0) {
<div class="empty-state">
<span>No backups yet</span>
</div>
}
@for (backup of backups; track backup.id) {
<div class="backup-item">
<div class="backup-info">
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
<div class="backup-meta">
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
{{ getReasonLabel(backup.reason) }}
</span>
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
</div>
</div>
<div class="backup-actions">
<button
class="btn btn-sm btn-secondary"
(click)="
confirm.show(
'Restore this backup? A backup of your current state will be created first.',
restoreBackup.bind(this, backup.id)
)
"
[disabled]="restoringBackupId !== null"
>
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
</button>
<button
class="btn btn-sm btn-danger"
(click)="
confirm.show(
'Delete this backup? This cannot be undone.',
deleteBackup.bind(this, backup.id)
)
"
>
Delete
</button>
</div>
</div>
}
</div>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,192 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 12px;
}
.sam-text-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: bold;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.lock-btn,
.back-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--muted);
}
.emoji {
font-size: 16px;
}
}
.backup-settings {
background: var(--muted);
padding: 12px;
border-radius: 8px;
}
.setting-row {
display: flex;
align-items: center;
gap: 12px;
label {
font-weight: 500;
}
input[type="number"] {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
}
}
.setting-note {
margin-top: 8px;
font-size: 12px;
color: var(--muted-foreground);
}
.create-btn {
align-self: flex-start;
}
.backups-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 100px;
color: var(--muted-foreground);
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
gap: 12px;
}
.backup-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.backup-date {
font-weight: 500;
font-size: 13px;
}
.backup-meta {
display: flex;
gap: 8px;
font-size: 11px;
}
.backup-reason {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.reason-auto {
background: var(--muted);
color: var(--muted-foreground);
}
&.reason-manual {
background: rgba(34, 197, 94, 0.2);
color: rgb(34, 197, 94);
}
&.reason-prerestore {
background: rgba(234, 179, 8, 0.2);
color: rgb(234, 179, 8);
}
}
.backup-identities {
color: var(--muted-foreground);
}
.backup-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
.btn-secondary {
background: var(--secondary);
color: var(--secondary-foreground);
&:hover:not(:disabled) {
background: var(--muted);
}
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: rgb(239, 68, 68);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.3);
}
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}

View File

@@ -0,0 +1,126 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
LoggerService,
SignerMetaData_VaultSnapshot,
StartupService,
StorageService,
} from '@common';
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
@Component({
selector: 'app-backups',
templateUrl: './backups.component.html',
styleUrl: './backups.component.scss',
imports: [ConfirmComponent],
})
export class BackupsComponent implements OnInit {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
backups: SignerMetaData_VaultSnapshot[] = [];
maxBackups = 5;
restoringBackupId: string | null = null;
ngOnInit(): void {
this.loadBackups();
this.maxBackups = this.#storage.getSignerMetaHandler().getMaxBackups();
}
loadBackups(): void {
this.backups = this.#storage.getSignerMetaHandler().getBackups();
}
async onMaxBackupsChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const value = parseInt(input.value, 10);
if (!isNaN(value) && value >= 1 && value <= 20) {
this.maxBackups = value;
await this.#storage.getSignerMetaHandler().setMaxBackups(value);
}
}
async createManualBackup(): Promise<void> {
const browserSyncData = this.#storage.getBrowserSyncHandler().browserSyncData;
if (browserSyncData) {
await this.#storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
this.loadBackups();
}
}
async restoreBackup(backupId: string): Promise<void> {
this.restoringBackupId = backupId;
try {
// First, create a pre-restore backup of current state
const currentData = this.#storage.getBrowserSyncHandler().browserSyncData;
if (currentData) {
await this.#storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
}
// Get the backup data
const backupData = this.#storage.getSignerMetaHandler().getBackupData(backupId);
if (!backupData) {
throw new Error('Backup not found');
}
// Import the backup
await this.#storage.deleteVault(true);
await this.#storage.importVault(backupData);
this.#logger.logVaultImport('Backup Restore');
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
console.error('Failed to restore backup:', error);
this.restoringBackupId = null;
}
}
async deleteBackup(backupId: string): Promise<void> {
await this.#storage.getSignerMetaHandler().deleteBackup(backupId);
this.loadBackups();
}
formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
getReasonLabel(reason?: string): string {
switch (reason) {
case 'auto':
return 'Auto';
case 'manual':
return 'Manual';
case 'pre-restore':
return 'Pre-Restore';
default:
return 'Unknown';
}
}
getReasonClass(reason?: string): string {
switch (reason) {
case 'auto':
return 'reason-auto';
case 'manual':
return 'reason-manual';
case 'pre-restore':
return 'reason-prerestore';
default:
return '';
}
}
goBack(): void {
this.#router.navigateByUrl('/home/settings');
}
async onClickLock(): Promise<void> {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -73,4 +73,12 @@
</div>
</div>
<!-- About section -->
@if (aboutText) {
<div class="about-section">
<div class="about-header">About</div>
<div class="about-content">{{ aboutText }}</div>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -185,4 +185,33 @@
opacity: 1;
}
}
.about-section {
margin: var(--size);
margin-top: 0;
flex-shrink: 0;
max-height: 150px;
display: flex;
flex-direction: column;
.about-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--muted-foreground);
margin-bottom: var(--size-h);
}
.about-content {
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.5;
color: var(--foreground);
background: var(--background-light);
border-radius: var(--radius-sm);
padding: var(--size);
white-space: pre-wrap;
word-break: break-word;
}
}
}

View File

@@ -52,6 +52,10 @@ export class IdentityComponent implements OnInit {
return this.profile?.banner;
}
get aboutText(): string | undefined {
return this.profile?.about;
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;

View File

@@ -5,7 +5,13 @@
<span> Settings </span>
</div>
<span>SYNC: {{ syncFlow }}</span>
<div class="sync-info">
<span class="sync-label">SYNC: {{ syncFlow }}</span>
<p class="sync-note">
To change sync mode, export your vault, reset the extension,
and re-import with the desired sync setting.
</p>
</div>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
@@ -15,6 +21,7 @@
Import Vault
</button>
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>

View File

@@ -15,3 +15,17 @@
visibility: hidden;
}
}
.sync-info {
.sync-label {
display: block;
font-weight: 500;
}
.sync-note {
margin: var(--size-h) 0 0 0;
font-size: 0.85rem;
color: var(--muted-foreground);
line-height: 1.4;
}
}

View File

@@ -2,13 +2,652 @@
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span>Wallet</span>
@if (showBackButton) {
<button class="back-btn" title="Go Back" (click)="goBack()">
<span class="emoji"></span>
</button>
}
<span>{{ title }}</span>
<div class="section-btns">
<button
class="section-btn"
[class.active]="activeSection.startsWith('cashu')"
title="Cashu"
(click)="setSection('cashu')"
>
<span class="emoji">🥜</span>
</button>
<button
class="section-btn"
[class.active]="activeSection.startsWith('lightning')"
title="Lightning"
(click)="setSection('lightning')"
>
<span class="emoji"></span>
</button>
</div>
</div>
<div class="wallet-container">
<div class="empty-state">
<span class="sam-text-muted">
Wallet functionality coming soon.
</span>
</div>
<!-- Main wallet menu -->
@if (activeSection === 'main') {
<div class="wallet-menu">
<button class="wallet-menu-item" (click)="setSection('cashu')">
<span class="emoji">🥜</span>
<span class="label">Cashu</span>
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
</button>
<button class="wallet-menu-item" (click)="setSection('lightning')">
<span class="emoji"></span>
<span class="label">Lightning</span>
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
</button>
</div>
}
<!-- Cashu mint list -->
@else if (activeSection === 'cashu') {
<div class="lightning-section">
@if (mints.length === 0) {
<div class="cashu-onboarding">
@if (showCashuInfo) {
<div class="info-panel">
<h3>Welcome to Cashu Wallet</h3>
<div class="info-section">
<h4>Storage Considerations</h4>
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
<div class="warning-box">
<p><strong>Browser Sync is enabled</strong></p>
<p>
Sync storage is limited to ~100KB shared across all your vault data
(identities, permissions, relays, and Cashu tokens). This limits
your Cashu wallet to approximately 300-400 tokens.
</p>
<p>
For larger Cashu holdings, consider disabling browser sync which
provides ~5MB of local storage (~18,000+ tokens).
</p>
<button class="link-btn" (click)="navigateToSettings()">
Change Sync Settings
</button>
</div>
} @else {
<div class="success-box">
<p><strong>Local Storage Mode</strong></p>
<p>
You have ~5MB of local storage available, which can hold
thousands of Cashu tokens. Your data stays on this device only.
</p>
</div>
}
</div>
<div class="info-section">
<h4>Backup Your Wallet</h4>
<p>
<strong>Important:</strong> Cashu tokens are bearer assets.
If you lose your vault backup, you lose your tokens permanently.
</p>
<p>
Vault exports are saved to your browser's downloads folder.
Configure this to point to either:
</p>
<ul>
<li>Your backup storage device (external drive, NAS)</li>
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
</ul>
<p class="browser-url">
<code>{{ browserDownloadSettingsUrl }}</code>
</p>
<button class="link-btn" (click)="navigateToSettings()">
Go to Backup Settings
</button>
</div>
<button class="dismiss-btn" (click)="dismissCashuInfo()">
Got it, let me add a mint
</button>
</div>
} @else {
<div class="empty-state">
<span class="sam-text-muted">No mints connected yet.</span>
<button class="show-info-btn" (click)="showCashuInfo = true">
Show storage info
</button>
</div>
}
</div>
} @else {
<div class="wallet-list">
@for (mint of mints; track mint.id) {
<button class="wallet-list-item" (click)="selectMint(mint.id)">
<span class="wallet-name">{{ mint.name }}</span>
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddMint()">
<span class="emoji">+</span>
<span>Add Mint</span>
</button>
</div>
}
<!-- Cashu mint detail -->
@else if (activeSection === 'cashu-detail' && selectedMint) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button
class="refresh-icon-btn"
(click)="refreshMint()"
[disabled]="refreshingMint"
title="Refresh"
>
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
</button>
</div>
@if (refreshError) {
<div class="error-message small">{{ refreshError }}</div>
}
<div class="action-buttons">
<button class="action-btn deposit-btn" (click)="showDeposit()">
Deposit
</button>
<button class="action-btn receive-btn" (click)="showReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
Send
</button>
</div>
<!-- Token viewer section -->
<div class="token-section">
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
@if (selectedMintProofs.length === 0) {
<div class="empty-text">No tokens stored</div>
} @else {
<div class="token-list">
@for (proof of selectedMintProofs; track proof.secret) {
<div class="token-item">
<span class="token-amount">{{ proof.amount }}</span>
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
</div>
}
</div>
}
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Mint URL</span>
<span class="info-value">{{ selectedMint.mintUrl }}</span>
</div>
<div class="info-row">
<span class="info-label">Unit</span>
<span class="info-value">{{ selectedMint.unit }}</span>
</div>
</div>
<button class="delete-btn" (click)="deleteMint()">
Delete Mint
</button>
</div>
}
<!-- Cashu add mint form -->
@else if (activeSection === 'cashu-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="mintName">Mint Name</label>
<input
id="mintName"
type="text"
[(ngModel)]="newMintName"
placeholder="My Mint"
[disabled]="addingMint"
/>
</div>
<div class="form-group">
<label for="mintUrl">Mint URL</label>
<input
id="mintUrl"
type="text"
[(ngModel)]="newMintUrl"
placeholder="https://mint.example.com"
[disabled]="addingMint"
/>
</div>
@if (mintError) {
<div class="error-message">{{ mintError }}</div>
}
@if (mintTestResult) {
<div class="success-message">{{ mintTestResult }}</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testMint()"
[disabled]="testingMint || addingMint"
>
{{ testingMint ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addMint()"
[disabled]="addingMint"
>
{{ addingMint ? 'Adding...' : 'Add Mint' }}
</button>
</div>
</div>
}
<!-- Cashu receive token -->
@else if (activeSection === 'cashu-receive') {
<div class="add-wallet-form">
<div class="form-group">
<label for="receiveToken">Paste Cashu Token</label>
<textarea
id="receiveToken"
[(ngModel)]="receiveToken"
placeholder="cashuB..."
rows="5"
[disabled]="receivingToken"
></textarea>
</div>
@if (receiveError) {
<div class="error-message">{{ receiveError }}</div>
}
@if (receiveResult) {
<div class="success-message">{{ receiveResult }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="receiveTokens()"
[disabled]="receivingToken"
>
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
</button>
</div>
</div>
}
<!-- Cashu send token -->
@else if (activeSection === 'cashu-send') {
<div class="add-wallet-form">
<div class="balance-info">
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
</div>
<div class="form-group">
<label for="sendAmount">Amount (sats)</label>
<input
id="sendAmount"
type="number"
[(ngModel)]="sendAmount"
placeholder="0"
min="1"
[max]="selectedMintBalance"
[disabled]="sendingToken"
/>
</div>
@if (sendError) {
<div class="error-message">{{ sendError }}</div>
}
@if (sendResult) {
<div class="token-result">
<span class="token-label">Token to Share</span>
<textarea readonly rows="4">{{ sendResult }}</textarea>
<button class="copy-btn" (click)="copyToken()">
Copy Token
</button>
</div>
}
@if (!sendResult) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="sendTokens()"
[disabled]="sendingToken || sendAmount <= 0"
>
{{ sendingToken ? 'Creating...' : 'Create Token' }}
</button>
</div>
}
</div>
}
<!-- Cashu deposit (mint via Lightning) -->
@else if (activeSection === 'cashu-mint' && selectedMint) {
<div class="add-wallet-form">
@if (!depositInvoice) {
<div class="form-group">
<label for="depositAmount">Amount (sats)</label>
<input
id="depositAmount"
type="number"
[(ngModel)]="depositAmount"
placeholder="1000"
min="1"
[disabled]="creatingDepositQuote"
/>
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createDepositInvoice()"
[disabled]="creatingDepositQuote || depositAmount <= 0"
>
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
</button>
</div>
}
@if (depositInvoice) {
<div class="invoice-result">
@if (depositInvoiceQr) {
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="deposit-status">
@if (depositQuoteState === 'UNPAID') {
<span class="status-waiting">Waiting for payment...</span>
@if (checkingDepositPayment) {
<span class="status-checking">checking</span>
}
} @else if (depositQuoteState === 'PAID') {
<span class="status-paid">Payment received! Claiming tokens...</span>
} @else if (depositQuoteState === 'ISSUED') {
<span class="status-issued">✓ Tokens received!</span>
}
</div>
@if (depositError) {
<div class="error-message">{{ depositError }}</div>
}
@if (depositSuccess) {
<div class="success-message">{{ depositSuccess }}</div>
}
@if (depositQuoteState === 'UNPAID') {
<div class="invoice-text">{{ depositInvoice }}</div>
<button class="copy-btn" (click)="copyDepositInvoice()">
Copy Invoice
</button>
}
</div>
}
</div>
}
<!-- Lightning wallet list -->
@else if (activeSection === 'lightning') {
<div class="lightning-section">
@if (connections.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No wallets connected yet.
</span>
</div>
} @else {
<div class="wallet-list">
@for (conn of connections; track conn.id) {
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
<span class="wallet-name">{{ conn.name }}</span>
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
</button>
}
</div>
}
<button class="add-wallet-btn" (click)="showAddConnection()">
<span class="emoji">+</span>
<span>Add NWC Connection</span>
</button>
</div>
}
<!-- Lightning wallet detail -->
@else if (activeSection === 'lightning-detail' && selectedConnection) {
<div class="wallet-detail">
<div class="balance-row">
<div class="balance-display compact">
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
<span class="balance-unit">sats</span>
</div>
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
<span class="emoji">🔄</span>
</button>
</div>
<div class="action-buttons">
<button class="action-btn receive-btn" (click)="showLnReceive()">
Receive
</button>
<button class="action-btn send-btn" (click)="showLnPay()">
Pay
</button>
</div>
<div class="wallet-info">
<div class="info-row">
<span class="info-label">Relay</span>
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
</div>
@if (selectedConnection.lud16) {
<button class="info-row-btn" (click)="copyLightningAddress()">
<span class="info-label">Lightning Address</span>
<span class="info-value">
{{ selectedConnection.lud16 }}
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
</span>
</button>
}
</div>
<!-- Transaction History -->
<div class="transaction-section">
<div class="section-title">Transactions</div>
@if (loadingTransactions) {
<div class="loading-text">Loading...</div>
} @else if (transactionsNotSupported) {
<div class="not-supported-text">Transaction history not supported by this wallet</div>
} @else if (transactionsError) {
<div class="error-text">{{ transactionsError }}</div>
} @else if (transactions.length === 0) {
<div class="empty-text">No transactions yet</div>
} @else {
<div class="transaction-list">
@for (tx of transactions; track tx.payment_hash) {
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
</div>
}
</div>
}
</div>
<button class="delete-btn-small" (click)="deleteConnection()">
Delete Wallet
</button>
</div>
}
<!-- Lightning receive invoice -->
@else if (activeSection === 'lightning-receive' && selectedConnection) {
<div class="add-wallet-form">
<div class="form-group">
<label for="lnReceiveAmount">Amount (sats)</label>
<input
id="lnReceiveAmount"
type="number"
[(ngModel)]="lnReceiveAmount"
placeholder="1000"
min="1"
[disabled]="generatingInvoice"
/>
</div>
<div class="form-group">
<label for="lnReceiveDescription">Description (optional)</label>
<input
id="lnReceiveDescription"
type="text"
[(ngModel)]="lnReceiveDescription"
placeholder="Payment for..."
[disabled]="generatingInvoice"
/>
</div>
@if (lnReceiveError) {
<div class="error-message">{{ lnReceiveError }}</div>
}
@if (!generatedInvoice) {
<div class="form-actions">
<button
class="add-btn full-width"
(click)="createReceiveInvoice()"
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
>
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
</button>
</div>
}
@if (generatedInvoice) {
<div class="invoice-result">
@if (generatedInvoiceQr) {
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
}
<div class="invoice-text">{{ generatedInvoice }}</div>
<button class="copy-btn" (click)="copyInvoice()">
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
</button>
</div>
}
</div>
}
<!-- Pay Modal Overlay -->
@if (showPayModal && selectedConnection) {
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<div class="modal-header">
<span>Pay Invoice</span>
<button class="modal-close" (click)="closePayModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="payInput">Lightning Address or Invoice</label>
<textarea
id="payInput"
[(ngModel)]="payInput"
placeholder="user@domain.com or lnbc1..."
rows="3"
[disabled]="paying"
></textarea>
</div>
<div class="form-group">
<label for="payAmount">Amount (sats) - required for addresses</label>
<input
id="payAmount"
type="number"
[(ngModel)]="payAmount"
placeholder="Optional for invoices"
min="1"
[disabled]="paying"
/>
</div>
@if (paymentError) {
<div class="error-message">{{ paymentError }}</div>
}
@if (paymentSuccess) {
<div class="success-message payment-success">Payment Successful!</div>
}
@if (!paymentSuccess) {
<div class="form-actions">
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
Cancel
</button>
<button
class="add-btn"
(click)="payInvoiceOrAddress()"
[disabled]="paying || !payInput.trim()"
>
{{ paying ? 'Paying...' : 'Pay' }}
</button>
</div>
}
</div>
</div>
</div>
}
<!-- Add wallet form -->
@else if (activeSection === 'lightning-add') {
<div class="add-wallet-form">
<div class="form-group">
<label for="walletName">Wallet Name</label>
<input
id="walletName"
type="text"
[(ngModel)]="newWalletName"
placeholder="My Lightning Wallet"
[disabled]="addingConnection"
/>
</div>
<div class="form-group">
<label for="walletUrl">NWC Connection URL</label>
<textarea
id="walletUrl"
[(ngModel)]="newWalletUrl"
placeholder="nostr+walletconnect://..."
rows="3"
[disabled]="addingConnection"
></textarea>
</div>
@if (connectionError) {
<div class="error-message">{{ connectionError }}</div>
}
@if (connectionTestResult) {
<div class="success-message">{{ connectionTestResult }}</div>
}
@if (nwcService.logs.length > 0) {
<div class="nwc-log">
<div class="log-header">
<span>Connection Log</span>
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
</div>
<div class="log-entries">
@for (entry of nwcService.logs; track entry.timestamp) {
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-message">{{ entry.message }}</span>
</div>
}
</div>
</div>
}
<div class="form-actions">
<button
class="test-btn"
(click)="testConnection()"
[disabled]="testingConnection || addingConnection"
>
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
</button>
<button
class="add-btn"
(click)="addConnection()"
[disabled]="addingConnection"
>
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
</button>
</div>
</div>
}
</div>

View File

@@ -1,21 +1,952 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import {
LoggerService,
StorageService,
NwcService,
NwcConnection_DECRYPTED,
CashuService,
CashuMint_DECRYPTED,
CashuProof,
NwcLookupInvoiceResult,
BrowserSyncFlow,
} from '@common';
import * as QRCode from 'qrcode';
type WalletSection =
| 'main'
| 'cashu'
| 'cashu-detail'
| 'cashu-add'
| 'cashu-receive'
| 'cashu-send'
| 'cashu-mint'
| 'lightning'
| 'lightning-detail'
| 'lightning-add'
| 'lightning-receive'
| 'lightning-pay';
@Component({
selector: 'app-wallet',
templateUrl: './wallet.component.html',
styleUrl: './wallet.component.scss',
imports: [],
imports: [CommonModule, FormsModule],
})
export class WalletComponent {
export class WalletComponent implements OnInit, OnDestroy {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly nwcService = inject(NwcService);
readonly cashuService = inject(CashuService);
activeSection: WalletSection = 'main';
selectedConnectionId: string | null = null;
selectedMintId: string | null = null;
// Form fields for adding new NWC connection
newWalletName = '';
newWalletUrl = '';
addingConnection = false;
testingConnection = false;
connectionError = '';
connectionTestResult = '';
// Form fields for adding new Cashu mint
newMintName = '';
newMintUrl = '';
addingMint = false;
testingMint = false;
mintError = '';
mintTestResult = '';
// Cashu receive/send fields
receiveToken = '';
receivingToken = false;
receiveError = '';
receiveResult = '';
sendAmount = 0;
sendingToken = false;
sendError = '';
sendResult = '';
// Cashu mint (deposit) fields
depositAmount = 0;
creatingDepositQuote = false;
depositQuoteId = '';
depositInvoice = '';
depositInvoiceQr = '';
depositError = '';
depositSuccess = '';
checkingDepositPayment = false;
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
// Loading states
loadingBalances = false;
balanceError = '';
// Lightning transaction history
transactions: NwcLookupInvoiceResult[] = [];
loadingTransactions = false;
transactionsError = '';
transactionsNotSupported = false;
// Lightning receive
lnReceiveAmount = 0;
lnReceiveDescription = '';
generatingInvoice = false;
generatedInvoice = '';
generatedInvoiceQr = '';
lnReceiveError = '';
invoiceCopied = false;
// Lightning pay
showPayModal = false;
payInput = '';
payAmount = 0;
paying = false;
paymentSuccess = false;
paymentError = '';
// Clipboard feedback
addressCopied = false;
// Cashu onboarding info
showCashuInfo = true;
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
readonly browserDownloadSettingsUrl = 'chrome://settings/downloads';
// Cashu mint refresh
refreshingMint = false;
refreshError = '';
get title(): string {
switch (this.activeSection) {
case 'cashu':
return 'Cashu';
case 'cashu-detail':
return this.selectedMint?.name ?? 'Mint';
case 'cashu-add':
return 'Add Mint';
case 'cashu-receive':
return 'Receive';
case 'cashu-send':
return 'Send';
case 'cashu-mint':
return 'Deposit';
case 'lightning':
return 'Lightning';
case 'lightning-detail':
return this.selectedConnection?.name ?? 'Wallet';
case 'lightning-add':
return 'Add Wallet';
case 'lightning-receive':
return 'Receive';
case 'lightning-pay':
return 'Pay';
default:
return 'Wallet';
}
}
get showBackButton(): boolean {
return this.activeSection !== 'main';
}
get connections(): NwcConnection_DECRYPTED[] {
return this.nwcService.getConnections();
}
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
if (!this.selectedConnectionId) return undefined;
return this.nwcService.getConnection(this.selectedConnectionId);
}
get totalLightningBalance(): number {
return this.nwcService.getCachedTotalBalance();
}
get mints(): CashuMint_DECRYPTED[] {
return this.cashuService.getMints();
}
get selectedMint(): CashuMint_DECRYPTED | undefined {
if (!this.selectedMintId) return undefined;
return this.cashuService.getMint(this.selectedMintId);
}
get totalCashuBalance(): number {
return this.cashuService.getCachedTotalBalance();
}
get selectedMintBalance(): number {
if (!this.selectedMintId) return 0;
return this.cashuService.getBalance(this.selectedMintId);
}
get selectedMintProofs(): CashuProof[] {
if (!this.selectedMintId) return [];
return this.cashuService.getProofs(this.selectedMintId);
}
ngOnInit(): void {
// Load current sync flow setting
this.currentSyncFlow = this.#storage.getSyncFlow();
// Refresh balances on init if we have connections
if (this.connections.length > 0) {
this.refreshAllBalances();
}
}
ngOnDestroy(): void {
this.nwcService.disconnectAll();
this.stopDepositPolling();
}
setSection(section: WalletSection) {
this.activeSection = section;
this.connectionError = '';
this.connectionTestResult = '';
}
goBack() {
switch (this.activeSection) {
case 'lightning-detail':
case 'lightning-add':
this.activeSection = 'lightning';
this.selectedConnectionId = null;
this.resetAddForm();
this.resetLightningForms();
break;
case 'lightning-receive':
case 'lightning-pay':
this.activeSection = 'lightning-detail';
this.resetLightningForms();
break;
case 'cashu-detail':
case 'cashu-add':
this.activeSection = 'cashu';
this.selectedMintId = null;
this.resetAddMintForm();
break;
case 'cashu-receive':
case 'cashu-send':
case 'cashu-mint':
this.activeSection = 'cashu-detail';
this.resetReceiveSendForm();
this.resetDepositForm();
break;
case 'lightning':
case 'cashu':
this.activeSection = 'main';
break;
}
}
selectConnection(connectionId: string) {
this.selectedConnectionId = connectionId;
this.activeSection = 'lightning-detail';
this.loadTransactions(connectionId);
}
private resetLightningForms() {
this.lnReceiveAmount = 0;
this.lnReceiveDescription = '';
this.generatingInvoice = false;
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
this.lnReceiveError = '';
this.invoiceCopied = false;
this.payInput = '';
this.payAmount = 0;
this.paying = false;
this.paymentSuccess = false;
this.paymentError = '';
this.showPayModal = false;
}
showAddConnection() {
this.resetAddForm();
this.activeSection = 'lightning-add';
}
private resetAddForm() {
this.newWalletName = '';
this.newWalletUrl = '';
this.connectionError = '';
this.connectionTestResult = '';
this.addingConnection = false;
this.testingConnection = false;
}
async testConnection() {
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.testingConnection = true;
this.connectionError = '';
this.connectionTestResult = '';
this.nwcService.clearLogs();
try {
const info = await this.nwcService.testConnection(this.newWalletUrl);
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
// Hide logs on success
this.nwcService.clearLogs();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Connection test failed';
// Keep logs visible on failure for debugging
} finally {
this.testingConnection = false;
}
}
async addConnection() {
if (!this.newWalletName.trim()) {
this.connectionError = 'Please enter a wallet name';
return;
}
if (!this.newWalletUrl.trim()) {
this.connectionError = 'Please enter an NWC URL';
return;
}
this.addingConnection = true;
this.connectionError = '';
try {
await this.nwcService.addConnection(
this.newWalletName.trim(),
this.newWalletUrl.trim()
);
// Refresh the balance for the new connection
const connections = this.nwcService.getConnections();
const newConnection = connections[connections.length - 1];
if (newConnection) {
try {
await this.nwcService.getBalance(newConnection.id);
} catch {
// Ignore balance fetch error
}
}
this.goBack();
} catch (error) {
this.connectionError =
error instanceof Error ? error.message : 'Failed to add connection';
} finally {
this.addingConnection = false;
}
}
async deleteConnection() {
if (!this.selectedConnectionId) return;
const connection = this.selectedConnection;
if (
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
) {
return;
}
try {
await this.nwcService.deleteConnection(this.selectedConnectionId);
this.goBack();
} catch (error) {
console.error('Failed to delete connection:', error);
}
}
// Cashu methods
selectMint(mintId: string) {
this.selectedMintId = mintId;
this.activeSection = 'cashu-detail';
// Auto-refresh to check for spent proofs
this.refreshMint();
}
async refreshMint() {
if (!this.selectedMintId || this.refreshingMint) return;
this.refreshingMint = true;
this.refreshError = '';
try {
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
if (removedAmount > 0) {
// Balance was updated, proofs were spent
console.log(`Removed ${removedAmount} sats of spent proofs`);
}
} catch (error) {
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
console.error('Failed to refresh mint:', error);
} finally {
this.refreshingMint = false;
}
}
showAddMint() {
this.resetAddMintForm();
this.activeSection = 'cashu-add';
}
showReceive() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-receive';
}
showSend() {
this.resetReceiveSendForm();
this.activeSection = 'cashu-send';
}
private resetAddMintForm() {
this.newMintName = '';
this.newMintUrl = '';
this.mintError = '';
this.mintTestResult = '';
this.addingMint = false;
this.testingMint = false;
}
private resetReceiveSendForm() {
this.receiveToken = '';
this.receivingToken = false;
this.receiveError = '';
this.receiveResult = '';
this.sendAmount = 0;
this.sendingToken = false;
this.sendError = '';
this.sendResult = '';
}
private resetDepositForm() {
this.depositAmount = 0;
this.creatingDepositQuote = false;
this.depositQuoteId = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
this.depositError = '';
this.depositSuccess = '';
this.checkingDepositPayment = false;
this.depositQuoteState = 'UNPAID';
this.stopDepositPolling();
}
private stopDepositPolling() {
if (this.depositPollingInterval) {
clearInterval(this.depositPollingInterval);
this.depositPollingInterval = null;
}
}
async testMint() {
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.testingMint = true;
this.mintError = '';
this.mintTestResult = '';
try {
const info = await this.cashuService.testMintConnection(
this.newMintUrl.trim()
);
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Connection test failed';
} finally {
this.testingMint = false;
}
}
async addMint() {
if (!this.newMintName.trim()) {
this.mintError = 'Please enter a mint name';
return;
}
if (!this.newMintUrl.trim()) {
this.mintError = 'Please enter a mint URL';
return;
}
this.addingMint = true;
this.mintError = '';
try {
await this.cashuService.addMint(
this.newMintName.trim(),
this.newMintUrl.trim()
);
this.goBack();
} catch (error) {
this.mintError =
error instanceof Error ? error.message : 'Failed to add mint';
} finally {
this.addingMint = false;
}
}
async deleteMint() {
if (!this.selectedMintId) return;
const mint = this.selectedMint;
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
return;
}
try {
await this.cashuService.deleteMint(this.selectedMintId);
this.goBack();
} catch (error) {
console.error('Failed to delete mint:', error);
}
}
async receiveTokens() {
if (!this.receiveToken.trim()) {
this.receiveError = 'Please paste a Cashu token';
return;
}
this.receivingToken = true;
this.receiveError = '';
this.receiveResult = '';
try {
const result = await this.cashuService.receive(this.receiveToken.trim());
this.receiveResult = `Received ${result.amount} sats!`;
this.receiveToken = '';
} catch (error) {
this.receiveError =
error instanceof Error ? error.message : 'Failed to receive token';
} finally {
this.receivingToken = false;
}
}
async sendTokens() {
if (!this.selectedMintId) return;
if (this.sendAmount <= 0) {
this.sendError = 'Please enter a valid amount';
return;
}
const balance = this.selectedMintBalance;
if (this.sendAmount > balance) {
this.sendError = `Insufficient balance. You have ${balance} sats`;
return;
}
this.sendingToken = true;
this.sendError = '';
this.sendResult = '';
try {
const result = await this.cashuService.send(
this.selectedMintId,
this.sendAmount
);
this.sendResult = result.token;
this.sendAmount = 0;
} catch (error) {
this.sendError =
error instanceof Error ? error.message : 'Failed to create token';
} finally {
this.sendingToken = false;
}
}
copyToken() {
if (this.sendResult) {
navigator.clipboard.writeText(this.sendResult);
}
}
async checkProofs() {
if (!this.selectedMintId) return;
try {
const removedAmount = await this.cashuService.checkProofsSpent(
this.selectedMintId
);
if (removedAmount > 0) {
alert(`Removed ${removedAmount} sats of spent proofs.`);
} else {
alert('All proofs are valid.');
}
} catch (error) {
console.error('Failed to check proofs:', error);
}
}
// Cashu deposit (mint) methods
showDeposit() {
this.resetDepositForm();
this.activeSection = 'cashu-mint';
}
async createDepositInvoice() {
if (!this.selectedMintId) return;
if (this.depositAmount <= 0) {
this.depositError = 'Please enter an amount';
return;
}
this.creatingDepositQuote = true;
this.depositError = '';
this.depositInvoice = '';
this.depositInvoiceQr = '';
try {
const quote = await this.cashuService.createMintQuote(
this.selectedMintId,
this.depositAmount
);
this.depositQuoteId = quote.quoteId;
this.depositInvoice = quote.invoice;
this.depositQuoteState = quote.state;
// Generate QR code
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
// Start polling for payment
this.startDepositPolling();
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.creatingDepositQuote = false;
}
}
private startDepositPolling() {
// Poll every 3 seconds for payment confirmation
this.depositPollingInterval = setInterval(async () => {
await this.checkDepositPayment();
}, 3000);
}
async checkDepositPayment() {
if (!this.selectedMintId || !this.depositQuoteId) return;
this.checkingDepositPayment = true;
try {
const quote = await this.cashuService.checkMintQuote(
this.selectedMintId,
this.depositQuoteId
);
this.depositQuoteState = quote.state;
if (quote.state === 'PAID') {
// Invoice is paid, claim the tokens
this.stopDepositPolling();
await this.claimDepositTokens();
} else if (quote.state === 'ISSUED') {
// Already claimed
this.stopDepositPolling();
this.depositSuccess = 'Tokens already claimed!';
}
} catch (error) {
// Don't show error for polling failures, just log
console.error('Failed to check payment:', error);
} finally {
this.checkingDepositPayment = false;
}
}
async claimDepositTokens() {
if (!this.selectedMintId || !this.depositQuoteId) return;
try {
const result = await this.cashuService.mintTokens(
this.selectedMintId,
this.depositAmount,
this.depositQuoteId
);
this.depositSuccess = `Received ${result.amount} sats!`;
this.depositQuoteState = 'ISSUED';
} catch (error) {
this.depositError =
error instanceof Error ? error.message : 'Failed to claim tokens';
}
}
async copyDepositInvoice() {
if (this.depositInvoice) {
await navigator.clipboard.writeText(this.depositInvoice);
}
}
formatCashuBalance(sats: number | undefined): string {
return this.cashuService.formatBalance(sats);
}
async refreshBalance(connectionId: string) {
try {
await this.nwcService.getBalance(connectionId);
} catch (error) {
console.error('Failed to refresh balance:', error);
}
}
async refreshAllBalances() {
this.loadingBalances = true;
this.balanceError = '';
try {
await this.nwcService.getAllBalances();
} catch {
this.balanceError = 'Failed to refresh some balances';
} finally {
this.loadingBalances = false;
}
}
formatBalance(millisats: number | undefined): string {
if (millisats === undefined) return '—';
// Convert millisats to sats with 3 decimal places
const sats = millisats / 1000;
return sats.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
}
// Lightning transaction methods
async loadTransactions(connectionId: string) {
this.loadingTransactions = true;
this.transactionsError = '';
this.transactionsNotSupported = false;
try {
this.transactions = await this.nwcService.listTransactions(connectionId, {
limit: 20,
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
this.transactionsNotSupported = true;
} else {
this.transactionsError = errorMsg;
}
this.transactions = [];
} finally {
this.loadingTransactions = false;
}
}
async refreshWallet() {
if (!this.selectedConnectionId) return;
// Refresh balance and transactions in parallel
await Promise.all([
this.refreshBalance(this.selectedConnectionId),
this.loadTransactions(this.selectedConnectionId),
]);
}
showLnReceive() {
this.resetLightningForms();
this.activeSection = 'lightning-receive';
}
showLnPay() {
this.resetLightningForms();
this.showPayModal = true;
}
closePayModal() {
this.showPayModal = false;
this.resetLightningForms();
}
async createReceiveInvoice() {
if (!this.selectedConnectionId) return;
if (this.lnReceiveAmount <= 0) {
this.lnReceiveError = 'Please enter an amount';
return;
}
this.generatingInvoice = true;
this.lnReceiveError = '';
this.generatedInvoice = '';
this.generatedInvoiceQr = '';
try {
const result = await this.nwcService.makeInvoice(
this.selectedConnectionId,
this.lnReceiveAmount * 1000, // Convert sats to millisats
this.lnReceiveDescription || undefined
);
this.generatedInvoice = result.invoice;
// Generate QR code
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
this.lnReceiveError =
error instanceof Error ? error.message : 'Failed to create invoice';
} finally {
this.generatingInvoice = false;
}
}
async copyInvoice() {
if (this.generatedInvoice) {
await navigator.clipboard.writeText(this.generatedInvoice);
this.invoiceCopied = true;
setTimeout(() => (this.invoiceCopied = false), 2000);
}
}
async copyLightningAddress() {
const lud16 = this.selectedConnection?.lud16;
if (lud16) {
await navigator.clipboard.writeText(lud16);
this.addressCopied = true;
setTimeout(() => (this.addressCopied = false), 2000);
}
}
async payInvoiceOrAddress() {
if (!this.selectedConnectionId || !this.payInput.trim()) {
this.paymentError = 'Please enter a lightning address or invoice';
return;
}
this.paying = true;
this.paymentError = '';
this.paymentSuccess = false;
try {
let invoice = this.payInput.trim();
// Check if it's a lightning address
if (this.nwcService.isLightningAddress(invoice)) {
if (this.payAmount <= 0) {
this.paymentError = 'Please enter an amount for lightning address payments';
this.paying = false;
return;
}
// Resolve lightning address to invoice
invoice = await this.nwcService.resolveLightningAddress(
invoice,
this.payAmount * 1000 // Convert sats to millisats
);
}
// Pay the invoice
await this.nwcService.payInvoice(
this.selectedConnectionId,
invoice,
this.payAmount > 0 ? this.payAmount * 1000 : undefined
);
this.paymentSuccess = true;
// Refresh balance and transactions after payment
await this.refreshWallet();
// Close modal after a delay
setTimeout(() => {
this.closePayModal();
}, 2000);
} catch (error) {
this.paymentError =
error instanceof Error ? error.message : 'Payment failed';
} finally {
this.paying = false;
}
}
formatTransactionTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
formatProofTime(isoTimestamp: string | undefined): string {
if (!isoTimestamp) return '—';
const date = new Date(isoTimestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
// Cashu onboarding methods
dismissCashuInfo() {
this.showCashuInfo = false;
}
navigateToSettings() {
this.#router.navigateByUrl('/home/settings');
}
}

View File

@@ -37,6 +37,26 @@
<span> Sync OFF</span>
</button>
<div class="storage-info">
<details>
<summary>Important for Cashu wallet users</summary>
<p>
Browser sync storage is limited to ~100KB shared across all data
(identities, permissions, relays, and Cashu tokens).
</p>
<p>
If you plan to use the Cashu ecash wallet with significant balances,
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
(enough for ~18,000+ tokens vs ~300-400 with sync).
</p>
<p>
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
vault backup, you lose your tokens permanently. Make sure to configure
regular backups.
</p>
</details>
</div>
<div class="sam-flex-grow"></div>
<span class="sam-text-muted sam-text-md sam-mb">

View File

@@ -6,3 +6,41 @@
padding-left: var(--size);
padding-right: var(--size);
}
.storage-info {
margin-top: 1rem;
width: 100%;
details {
background: rgba(255, 193, 7, 0.1);
border: 1px solid var(--warning, #ffc107);
border-radius: 6px;
padding: 0.5rem;
summary {
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--warning, #ffc107);
&:hover {
text-decoration: underline;
}
}
p {
margin: 0.75rem 0 0 0;
font-size: 0.85rem;
line-height: 1.4;
color: var(--text-muted, #6c757d);
&:last-child {
margin-bottom: 0.5rem;
}
strong {
color: var(--text, #212529);
}
}
}
}

View File

@@ -5,6 +5,7 @@ import {
} from '@common';
import './app/common/extensions/array';
import browser from 'webextension-polyfill';
import { v4 as uuidv4 } from 'uuid';
//
// Functions
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
}
newSnapshots.push({
id: uuidv4(),
fileName: file.name,
createdAt: new Date().toISOString(),
data: vault,
identityCount: vault.identities?.length ?? 0,
reason: 'manual',
});
}