Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ab2a471
feat: Store and check lightning invoices even if user closes app duri…
kwsantiago Aug 10, 2025
265b559
regtest mint
kwsantiago Aug 11, 2025
6f31c27
add regtest cashu tests
kwsantiago Aug 11, 2025
f84b4e9
update failing CI for LN regtest
kwsantiago Aug 11, 2025
07299ad
regtest mint ci fix
kwsantiago Aug 11, 2025
8b286c0
Allow users to configure relays
fanyiy Aug 12, 2025
0865fab
relay configuration is working and it's literally perfect.
sh1ftred Aug 9, 2025
471f9e3
Fixed all the bugs with sat vs msats
sh1ftred Aug 9, 2025
387c3e6
fixed lightning payments for msats.
sh1ftred Aug 11, 2025
051b731
fixed lightning and cashu paymetns on the floating wallet.
sh1ftred Aug 12, 2025
524a16b
removed Evan's relay implementeation as mine was more comprehensive.
sh1ftred Aug 12, 2025
8edee97
fixed formatBalance issues and others
sh1ftred Aug 13, 2025
522ae0a
fix & test for invoice status state transitions (Issue with ISSUED vs…
kwsantiago Aug 13, 2025
858a652
Merge pull request #63 from kwsantiago/kwsantiago/merging-invoice-his…
sh1ftred Aug 14, 2025
135efce
fix: recover invoices after app closure
kwsantiago Aug 14, 2025
90717b7
fix: only show success popup for actual token recovery
kwsantiago Aug 14, 2025
f581142
Update useInvoiceChecker.ts
kwsantiago Aug 14, 2025
80adb04
fix(ci): skip payment tests in CI
kwsantiago Aug 14, 2025
a0dcd8f
update log line
kwsantiago Aug 14, 2025
6004708
fix: floating wallet invoice persistence
kwsantiago Aug 14, 2025
e235c3a
fixed pending token clearing on the floating wallet. and multiple tok…
sh1ftred Aug 15, 2025
36933f5
fix: invoice persistence for BalanceDisplay
kwsantiago Aug 15, 2025
6073c52
feat: add exponential backoff and retry logic for invoice checking
kwsantiago Aug 15, 2025
24ad766
fix: remove duplicate invoice polling mechanism
kwsantiago Aug 15, 2025
d658684
feat: add success toast for recovered invoices
kwsantiago Aug 15, 2025
92c5c58
feat: add manual retry and delete controls for invoices
kwsantiago Aug 15, 2025
19578f9
fix: correct type error in invoice expiry field
kwsantiago Aug 15, 2025
2f55a01
test: add comprehensive invoice persistence tests
kwsantiago Aug 15, 2025
1373b09
chore: update test scripts in package.json
kwsantiago Aug 15, 2025
e92be7f
Lightning payments UI when fees are 0
sh1ftred Aug 15, 2025
4308a10
another lightning fix
sh1ftred Aug 15, 2025
a0180c8
removed exact token creation before trying wallet.send
sh1ftred Aug 15, 2025
0a20085
now wallet.send is primary for sending tokens and only if we fails we…
sh1ftred Aug 15, 2025
3ea8c8e
Add a feature tothe apikeys tab to add externally created apikeys tom…
sh1ftred Aug 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
"next/core-web-vitals",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier",
"plugin:tailwindcss/recommended"
"prettier"
],
"plugins": ["tailwindcss"],
"rules": {
"tailwindcss/no-custom-classname": "off",
"tailwindcss/classnames-order": "off"
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "off",
"import/no-duplicates": "off",
"import/no-unresolved": "off",
"react-hooks/exhaustive-deps": "off",
"import/no-named-as-default-member": "off"
},
"settings": {
"import/resolver": {
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/test-invoices.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Invoice Tests

on:
pull_request:
paths:
- 'lib/cashu*.ts'
- 'hooks/useInvoice*.ts'
- 'stores/*Store.ts'
- 'test/**'
- '.github/workflows/test-invoices.yml'

jobs:
test-invoice-persistence:
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Clone cashu-regtest
run: |
cd ~
git clone https://github.com/callebtc/cashu-regtest.git

- name: Start regtest environment
run: |
cd ~/cashu-regtest
./start.sh
sleep 30 # Wait for services to be ready

- name: Start Cashu mint
run: |
./test/setup-regtest-mint.sh
sleep 10 # Wait for mint to be ready

- name: Run invoice tests
run: node test/invoice-persistence.test.js

- name: Cleanup
if: always()
run: |
cd ~/cashu-regtest
./stop.sh || true
docker stop cashu-regtest-mint || true
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,10 @@ yarn-error.log*
/blob-report/
/playwright/*
.aider*

# Test environment files
/test/mint-data/
/test/*.log
/test/temp/
*.test.local
.aider*
5 changes: 4 additions & 1 deletion components/ClientProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ReactNode, useEffect } from 'react';
import NostrProvider from '@/components/NostrProvider'
import dynamic from 'next/dynamic';
import { migrateStorageItems } from '@/utils/storageUtils';
import { InvoiceRecoveryProvider } from '@/components/InvoiceRecoveryProvider';

const DynamicNostrLoginProvider = dynamic(
() => import('@nostrify/react/login').then((mod) => mod.NostrLoginProvider),
Expand Down Expand Up @@ -51,8 +52,10 @@ export default function ClientProviders({ children }: { children: ReactNode }) {
<DynamicNostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<QueryClientProvider client={queryClient}>
<InvoiceRecoveryProvider>
{children}
</QueryClientProvider>
</InvoiceRecoveryProvider>
</QueryClientProvider>
</NostrProvider>
</DynamicNostrLoginProvider>
</AppProvider>
Expand Down
27 changes: 23 additions & 4 deletions components/DepositModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import { cn } from "@/lib/utils";
import {
createLightningInvoice,
mintTokensFromPaidInvoice,
payMeltQuote,
parseInvoiceAmount,
createMeltQuote,
} from "@/lib/cashuLightning";
import {
useTransactionHistoryStore,
PendingTransaction,
} from "@/stores/transactionHistoryStore";
import { getBalanceFromStoredProofs } from "@/utils/cashuUtils";
import { useInvoiceSync } from "@/hooks/useInvoiceSync";
import { useInvoiceChecker } from "@/hooks/useInvoiceChecker";
import { MintQuoteState } from "@cashu/cashu-ts";

// Helper function to generate unique IDs
const generateId = () => crypto.randomUUID();
Expand Down Expand Up @@ -69,8 +69,10 @@ const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, mintUrl, b

const { wallet, isLoading, updateProofs } = useCashuWallet();
const cashuStore = useCashuStore();
const { sendToken, receiveToken, cleanSpentProofs, cleanupPendingProofs, isLoading: isTokenLoading, error: hookError } = useCashuToken();
const { receiveToken, isLoading: isTokenLoading, error: hookError } = useCashuToken();
const transactionHistoryStore = useTransactionHistoryStore();
const { addInvoice, updateInvoice } = useInvoiceSync();
const { triggerCheck } = useInvoiceChecker();

useEffect(() => {
if (hookError) {
Expand Down Expand Up @@ -125,6 +127,17 @@ const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, mintUrl, b
setcurrentMeltQuoteId(invoiceData.quoteId);
setPaymentRequest(invoiceData.paymentRequest);

// Store invoice persistently
await addInvoice({
type: 'mint',
mintUrl: cashuStore.activeMintUrl,
quoteId: invoiceData.quoteId,
paymentRequest: invoiceData.paymentRequest,
amount: amount,
state: MintQuoteState.UNPAID,
expiresAt: invoiceData.expiresAt
});

const pendingTxId = generateId();
const pendingTransaction: PendingTransaction = {
id: pendingTxId,
Expand Down Expand Up @@ -178,6 +191,12 @@ const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, mintUrl, b
proofsToRemove: [],
});

// Update stored invoice status
await updateInvoice(quoteId, {
state: MintQuoteState.PAID,
paidAt: Date.now()
});

transactionHistoryStore.removePendingTransaction(pendingTxId);
setPendingTransactionId(null);

Expand Down
116 changes: 116 additions & 0 deletions components/InvoiceRecoveryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use client';

import React, { useEffect, useRef, useState } from 'react';
import { useInvoiceChecker } from '@/hooks/useInvoiceChecker';
import { useInvoiceSync, StoredInvoice } from '@/hooks/useInvoiceSync';
import { toast } from 'sonner';
import { MintQuoteState, MeltQuoteState } from '@cashu/cashu-ts';
import { formatBalance } from '@/lib/cashu';

interface InvoiceRecoveryProviderProps {
children: React.ReactNode;
}

export const InvoiceRecoveryProvider: React.FC<InvoiceRecoveryProviderProps> = ({ children }) => {
const { invoices, getPendingInvoices } = useInvoiceSync();
const { triggerCheck } = useInvoiceChecker();
const hasCheckedOnMount = useRef(false);
const hasShownRecoveryToast = useRef(false);
const [trackingInvoices, setTrackingInvoices] = useState<Set<string>>(new Set());
const previousInvoiceStates = useRef<Map<string, string>>(new Map());

useEffect(() => {
// Only run once on mount
if (hasCheckedOnMount.current) return;
hasCheckedOnMount.current = true;

// Small delay to let the app initialize
const checkTimer = setTimeout(async () => {
const pending = getPendingInvoices();

if (pending.length > 0 && !hasShownRecoveryToast.current) {
hasShownRecoveryToast.current = true;

// Track which invoices we're recovering
const invoiceIds = new Set(pending.map(inv => inv.id));
setTrackingInvoices(invoiceIds);

// Store initial states
pending.forEach(inv => {
previousInvoiceStates.current.set(inv.id, inv.state as string);
});

// Show recovery toast
toast.info(
`Found ${pending.length} pending invoice${pending.length > 1 ? 's' : ''} from previous session. Checking status...`,
{ duration: 5000 }
);

// Trigger check
await triggerCheck();
}
}, 2000);

return () => clearTimeout(checkTimer);
}, [getPendingInvoices, triggerCheck]);

// Track recovered invoices
useEffect(() => {
if (trackingInvoices.size === 0) return;

const recoveredInvoices: StoredInvoice[] = [];

invoices.forEach(inv => {
if (trackingInvoices.has(inv.id)) {
const previousState = previousInvoiceStates.current.get(inv.id);
const currentState = inv.state as string;

if (previousState && previousState !== currentState) {
if (currentState === 'PAID' || currentState === 'ISSUED') {
recoveredInvoices.push(inv);
trackingInvoices.delete(inv.id);
previousInvoiceStates.current.delete(inv.id);
}
}
}
});

if (recoveredInvoices.length > 0) {
setTrackingInvoices(new Set(trackingInvoices));

recoveredInvoices.forEach(inv => {
const action = inv.type === 'mint' ? 'Received' : 'Sent';
toast.success(
`${action} ${formatBalance(inv.amount, 'sats')} - Invoice recovered from previous session`,
{ duration: 6000 }
);
});
}
}, [invoices, trackingInvoices]);

// Check for recently paid invoices on visibility change
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
// Check if any invoices were recently paid
const recentlyPaid = invoices.filter(inv => {
const isPaid = (inv.state as string) === 'PAID' || (inv.state as string) === 'ISSUED';
const wasRecentlyPaid = inv.paidAt && (Date.now() - inv.paidAt) < 60000; // Within last minute
return isPaid && wasRecentlyPaid && !trackingInvoices.has(inv.id);
});

if (recentlyPaid.length > 0) {
recentlyPaid.forEach(inv => {
const type = inv.type === 'mint' ? 'received' : 'sent';
toast.success(`Invoice ${type} successfully (${inv.amount} sats)`);
});
}
}
};

document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [invoices, trackingInvoices]);

return <>{children}</>;
};
Loading