Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/*

# Test environment files
/test/mint-data/
/test/*.log
/test/temp/
*.test.local
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 @@ -39,7 +40,9 @@ export default function ClientProviders({ children }: { children: ReactNode }) {
<DynamicNostrLoginProvider storageKey='nostr:login'>
<NostrProvider relays={defaultRelays}>
<QueryClientProvider client={queryClient}>
{children}
<InvoiceRecoveryProvider>
{children}
</InvoiceRecoveryProvider>
</QueryClientProvider>
</NostrProvider>
</DynamicNostrLoginProvider>
Expand Down
22 changes: 22 additions & 0 deletions components/DepositModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
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 @@ -71,6 +74,8 @@ const DepositModal: React.FC<DepositModalProps> = ({ isOpen, onClose, mintUrl, b
const cashuStore = useCashuStore();
const { sendToken, receiveToken, cleanSpentProofs, cleanupPendingProofs, isLoading: isTokenLoading, error: hookError } = useCashuToken();
const transactionHistoryStore = useTransactionHistoryStore();
const { addInvoice, updateInvoice } = useInvoiceSync();
const { triggerCheck } = useInvoiceChecker();

useEffect(() => {
if (hookError) {
Expand Down Expand Up @@ -119,6 +124,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 @@ -172,6 +188,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
70 changes: 70 additions & 0 deletions components/InvoiceRecoveryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

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

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);

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

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

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

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

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

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

// 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';
const wasRecentlyPaid = inv.paidAt && (Date.now() - inv.paidAt) < 60000; // Within last minute
return isPaid && wasRecentlyPaid;
});

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]);

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