diff --git a/packages/core/README.md b/packages/core/README.md index 860143da..e86254fc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -190,6 +190,9 @@ In-memory reference implementations are provided under `repositories/memory/` fo - `receive(token: Token | string): Promise` - `getBalances(): Promise<{ [mintUrl: string]: number }>` +- `getBalanceBreakdown(mintUrl: string): Promise` +- `getBalancesBreakdown(): Promise` +- `getTrustedBalancesBreakdown(): Promise` - `restore(mintUrl: string): Promise` - `sweep(mintUrl: string, bip39seed: Uint8Array): Promise` - `processPaymentRequest(paymentRequest: string): Promise` @@ -335,7 +338,7 @@ From the package root: - `Manager`, `initializeCoco`, `CocoConfig` - Repository interfaces and memory implementations under `repositories/memory` - Models under `models` -- Types: `CoreProof`, `ProofState` +- Types: `CoreProof`, `ProofState`, `BalanceBreakdown`, `BalancesBreakdownByMint` - Logging: `ConsoleLogger`, `Logger` - Helpers: `getEncodedToken`, `getDecodedToken`, `normalizeMintUrl` - Subscription infra: `SubscriptionManager`, `WsConnectionManager`, `WebSocketLike`, `WebSocketFactory`, `SubscriptionCallback`, `SubscriptionKind` diff --git a/packages/core/api/WalletApi.ts b/packages/core/api/WalletApi.ts index 61c14d41..5e2a487a 100644 --- a/packages/core/api/WalletApi.ts +++ b/packages/core/api/WalletApi.ts @@ -14,6 +14,7 @@ import type { ParsedPaymentRequest, PaymentRequestTransaction, } from '@core/services'; +import type { BalanceBreakdown, BalancesBreakdownByMint } from '../types'; import type { SendOperationService } from '../operations/send/SendOperationService'; import type { Logger } from '../logging/Logger.ts'; @@ -68,6 +69,32 @@ export class WalletApi { return this.proofService.getBalances(); } + /** + * Gets detailed balance breakdown for a single mint. + * @param mintUrl - The URL of the mint + * @returns Balance breakdown with ready, reserved, and total amounts + */ + async getBalanceBreakdown(mintUrl: string): Promise { + return this.proofService.getBalanceBreakdown(mintUrl); + } + + /** + * Gets detailed balance breakdown for all mints. + * Shows ready (available), reserved (locked by operations), and total for each mint. + * @returns An object mapping mint URLs to their balance breakdowns + */ + async getBalancesBreakdown(): Promise { + return this.proofService.getBalancesBreakdown(); + } + + /** + * Gets detailed balance breakdown for trusted mints only. + * @returns An object mapping trusted mint URLs to their balance breakdowns + */ + async getTrustedBalancesBreakdown(): Promise { + return this.proofService.getTrustedBalancesBreakdown(); + } + // Payment Request methods /** diff --git a/packages/core/index.ts b/packages/core/index.ts index ffa0d390..90239c4d 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,7 +4,7 @@ export * from './models/index.ts'; export * from './api/index.ts'; export * from './services/index.ts'; export * from './operations/index.ts'; -export type { CoreProof, ProofState } from './types.ts'; +export type { CoreProof, ProofState, BalanceBreakdown, BalancesBreakdownByMint } from './types.ts'; export { type Logger, ConsoleLogger } from './logging/index.ts'; export { getEncodedToken, getDecodedToken } from '@cashu/cashu-ts'; export { SubscriptionManager } from './infra/SubscriptionManager.ts'; diff --git a/packages/core/services/PaymentRequestService.ts b/packages/core/services/PaymentRequestService.ts index 3bdd334d..5513f62f 100644 --- a/packages/core/services/PaymentRequestService.ts +++ b/packages/core/services/PaymentRequestService.ts @@ -173,12 +173,12 @@ export class PaymentRequestService { } private async findMatchingMints(paymentRequest: PaymentRequest): Promise { - const balances = await this.proofService.getTrustedBalances(); + const balances = await this.proofService.getTrustedBalancesBreakdown(); const amount = paymentRequest.amount ?? 0; const mintRequirement = paymentRequest.mints; const matchingMints: string[] = []; for (const [mintUrl, balance] of Object.entries(balances)) { - if (balance >= amount && (!mintRequirement || mintRequirement.includes(mintUrl))) { + if (balance.ready >= amount && (!mintRequirement || mintRequirement.includes(mintUrl))) { matchingMints.push(mintUrl); } } diff --git a/packages/core/services/ProofService.ts b/packages/core/services/ProofService.ts index 0d2b05e0..0f460d47 100644 --- a/packages/core/services/ProofService.ts +++ b/packages/core/services/ProofService.ts @@ -5,7 +5,7 @@ import { type Proof, type SerializedBlindedSignature, } from '@cashu/cashu-ts'; -import type { CoreProof } from '../types'; +import type { CoreProof, BalanceBreakdown, BalancesBreakdownByMint } from '../types'; import type { CounterService } from './CounterService'; import type { ProofRepository } from '../repositories'; import { EventBus } from '../events/EventBus'; @@ -268,7 +268,7 @@ export class ProofService { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); } - const proofs = await this.getReadyProofs(mintUrl); + const proofs = await this.proofRepository.getAvailableProofs(mintUrl); return proofs.reduce((acc, proof) => acc + proof.amount, 0); } @@ -280,6 +280,7 @@ export class ProofService { const proofs = await this.getAllReadyProofs(); const balances: { [mintUrl: string]: number } = {}; for (const proof of proofs) { + if (proof.usedByOperationId) continue; const mintUrl = proof.mintUrl; const balance = balances[mintUrl] || 0; balances[mintUrl] = balance + proof.amount; @@ -305,6 +306,67 @@ export class ProofService { return trustedBalances; } + /** + * Gets detailed balance breakdown for a single mint. + * @param mintUrl - The URL of the mint + * @returns Balance breakdown with ready, reserved, and total amounts + */ + async getBalanceBreakdown(mintUrl: string): Promise { + if (!mintUrl || mintUrl.trim().length === 0) { + throw new ProofValidationError('mintUrl is required'); + } + const proofs = await this.getReadyProofs(mintUrl); + let ready = 0; + let reserved = 0; + for (const proof of proofs) { + if (proof.usedByOperationId) { + reserved += proof.amount; + } else { + ready += proof.amount; + } + } + return { ready, reserved, total: ready + reserved }; + } + + /** + * Gets detailed balance breakdown for all mints. + * @returns An object mapping mint URLs to their balance breakdowns + */ + async getBalancesBreakdown(): Promise { + const proofs = await this.getAllReadyProofs(); + const balances: BalancesBreakdownByMint = {}; + for (const proof of proofs) { + const mintUrl = proof.mintUrl; + const balance = balances[mintUrl] || { ready: 0, reserved: 0, total: 0 }; + if (proof.usedByOperationId) { + balance.reserved += proof.amount; + } else { + balance.ready += proof.amount; + } + balance.total = balance.ready + balance.reserved; + balances[mintUrl] = balance; + } + return balances; + } + + /** + * Gets detailed balance breakdown for trusted mints only. + * @returns An object mapping trusted mint URLs to their balance breakdowns + */ + async getTrustedBalancesBreakdown(): Promise { + const balances = await this.getBalancesBreakdown(); + const trustedMints = await this.mintService.getAllTrustedMints(); + const trustedUrls = new Set(trustedMints.map((m) => m.mintUrl)); + + const trustedBalances: BalancesBreakdownByMint = {}; + for (const [mintUrl, balance] of Object.entries(balances)) { + if (trustedUrls.has(mintUrl)) { + trustedBalances[mintUrl] = balance; + } + } + return trustedBalances; + } + async setProofState( mintUrl: string, secrets: string[], diff --git a/packages/core/test/unit/ProofService.test.ts b/packages/core/test/unit/ProofService.test.ts index 150fad4e..a1860cac 100644 --- a/packages/core/test/unit/ProofService.test.ts +++ b/packages/core/test/unit/ProofService.test.ts @@ -555,4 +555,220 @@ describe('ProofService', () => { expect(selected.map((p) => p.secret)).toEqual(['b1', 'b2']); }); }); + + describe('balance methods', () => { + const otherMintUrl = 'https://mint.other'; + const operationId = 'op-123'; + + it('getBalance returns ready-only (excludes reserved proofs)', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + // Create some ready proofs + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'r1', amount: 10 }), + makeProof({ secret: 'r2', amount: 20 }), + makeProof({ secret: 'r3', amount: 30 }), + ]); + + // Reserve one proof + await proofRepo.reserveProofs(mintUrl, ['r2'], operationId); + + const balance = await service.getBalance(mintUrl); + // Should only include r1 (10) + r3 (30) = 40, excluding reserved r2 (20) + expect(balance).toBe(40); + }); + + it('getBalances returns ready-only for all mints', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'a1', amount: 100 }), + makeProof({ secret: 'a2', amount: 50 }), + ]); + await proofRepo.saveProofs(otherMintUrl, [ + makeProof({ secret: 'b1', amount: 200, mintUrl: otherMintUrl }), + ]); + + // Reserve one proof from first mint + await proofRepo.reserveProofs(mintUrl, ['a1'], operationId); + + const balances = await service.getBalances(); + // mintUrl: only a2 (50), not a1 (100) which is reserved + expect(balances[mintUrl]).toBe(50); + expect(balances[otherMintUrl]).toBe(200); + }); + + it('getTrustedBalances returns ready-only for trusted mints', async () => { + // Only the default mintUrl is trusted in our stub + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'c1', amount: 100 }), + makeProof({ secret: 'c2', amount: 25 }), + ]); + await proofRepo.saveProofs(otherMintUrl, [ + makeProof({ secret: 'd1', amount: 500, mintUrl: otherMintUrl }), + ]); + + await proofRepo.reserveProofs(mintUrl, ['c1'], operationId); + + const balances = await service.getTrustedBalances(); + // Only trusted mint (mintUrl) with c2 (25), not c1 (100) which is reserved + expect(balances[mintUrl]).toBe(25); + // otherMintUrl is not trusted, so should not appear + expect(balances[otherMintUrl]).toBeUndefined(); + }); + + it('getBalanceBreakdown returns ready, reserved, and total for a mint', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'e1', amount: 100 }), + makeProof({ secret: 'e2', amount: 50 }), + makeProof({ secret: 'e3', amount: 25 }), + ]); + + // Reserve e1 and e2 + await proofRepo.reserveProofs(mintUrl, ['e1', 'e2'], operationId); + + const breakdown = await service.getBalanceBreakdown(mintUrl); + expect(breakdown.ready).toBe(25); // only e3 + expect(breakdown.reserved).toBe(150); // e1 (100) + e2 (50) + expect(breakdown.total).toBe(175); // all proofs + }); + + it('getBalanceBreakdown throws for empty mintUrl', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await expect(service.getBalanceBreakdown('')).rejects.toThrow(ProofValidationError); + }); + + it('getBalancesBreakdown returns breakdown for all mints', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'f1', amount: 100 }), + makeProof({ secret: 'f2', amount: 50 }), + ]); + await proofRepo.saveProofs(otherMintUrl, [ + makeProof({ secret: 'g1', amount: 200, mintUrl: otherMintUrl }), + makeProof({ secret: 'g2', amount: 75, mintUrl: otherMintUrl }), + ]); + + // Reserve f1 and g1 + await proofRepo.reserveProofs(mintUrl, ['f1'], operationId); + await proofRepo.reserveProofs(otherMintUrl, ['g1'], 'op-456'); + + const breakdowns = await service.getBalancesBreakdown(); + + expect(breakdowns[mintUrl]).toEqual({ ready: 50, reserved: 100, total: 150 }); + expect(breakdowns[otherMintUrl]).toEqual({ ready: 75, reserved: 200, total: 275 }); + }); + + it('getTrustedBalancesBreakdown returns breakdown for trusted mints only', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'h1', amount: 100 }), + makeProof({ secret: 'h2', amount: 50 }), + ]); + await proofRepo.saveProofs(otherMintUrl, [ + makeProof({ secret: 'i1', amount: 500, mintUrl: otherMintUrl }), + ]); + + await proofRepo.reserveProofs(mintUrl, ['h1'], operationId); + + const breakdowns = await service.getTrustedBalancesBreakdown(); + + // Only trusted mint (mintUrl) + expect(breakdowns[mintUrl]).toEqual({ ready: 50, reserved: 100, total: 150 }); + // otherMintUrl is not trusted + expect(breakdowns[otherMintUrl]).toBeUndefined(); + }); + + it('breakdown methods return empty/zero values when no proofs exist', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + const breakdown = await service.getBalanceBreakdown(mintUrl); + expect(breakdown).toEqual({ ready: 0, reserved: 0, total: 0 }); + + const allBreakdowns = await service.getBalancesBreakdown(); + expect(Object.keys(allBreakdowns).length).toBe(0); + + const trustedBreakdowns = await service.getTrustedBalancesBreakdown(); + expect(Object.keys(trustedBreakdowns).length).toBe(0); + }); + }); }); diff --git a/packages/core/types.ts b/packages/core/types.ts index a379a5f4..86708382 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -4,6 +4,14 @@ export type MintInfo = Awaited>; export type ProofState = 'inflight' | 'ready' | 'spent'; +export interface BalanceBreakdown { + ready: number; + reserved: number; + total: number; +} + +export type BalancesBreakdownByMint = { [mintUrl: string]: BalanceBreakdown }; + export interface CoreProof extends Proof { mintUrl: string; state: ProofState; diff --git a/packages/react/src/lib/hooks/useTrustedBalance.ts b/packages/react/src/lib/hooks/useTrustedBalance.ts index 717c5e5a..216279b5 100644 --- a/packages/react/src/lib/hooks/useTrustedBalance.ts +++ b/packages/react/src/lib/hooks/useTrustedBalance.ts @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useManager } from '../contexts/ManagerContext'; import { useMints } from '../contexts/MintContext'; +import type { BalancesBreakdownByMint } from 'coco-cashu-core'; export type TrustedBalanceValue = { [mintUrl: string]: number; @@ -18,15 +19,15 @@ const useTrustedBalance = () => { const refreshBalance = useCallback(async () => { try { - const allBalances = await manager.wallet.getBalances(); + const allBalances: BalancesBreakdownByMint = await manager.wallet.getBalancesBreakdown(); const trustedMintUrls = new Set(trustedMints.map((m) => m.mintUrl)); const trustedBalances: TrustedBalanceValue = { total: 0 }; - for (const [mintUrl, amount] of Object.entries(allBalances || {})) { + for (const [mintUrl, breakdown] of Object.entries(allBalances || {})) { if (trustedMintUrls.has(mintUrl)) { - trustedBalances[mintUrl] = amount; - trustedBalances.total += amount; + trustedBalances[mintUrl] = breakdown.ready; + trustedBalances.total += breakdown.ready; } } @@ -41,10 +42,14 @@ const useTrustedBalance = () => { manager.on('proofs:saved', refreshBalance); manager.on('proofs:state-changed', refreshBalance); manager.on('mint:updated', refreshBalance); + manager.on('proofs:reserved', refreshBalance); + manager.on('proofs:released', refreshBalance); return () => { manager.off('proofs:saved', refreshBalance); manager.off('proofs:state-changed', refreshBalance); manager.off('mint:updated', refreshBalance); + manager.off('proofs:reserved', refreshBalance); + manager.off('proofs:released', refreshBalance); }; }, [manager, refreshBalance]); @@ -52,4 +57,3 @@ const useTrustedBalance = () => { }; export default useTrustedBalance; - diff --git a/packages/react/src/lib/providers/Balance.tsx b/packages/react/src/lib/providers/Balance.tsx index 286a038e..9bfd5e8a 100644 --- a/packages/react/src/lib/providers/Balance.tsx +++ b/packages/react/src/lib/providers/Balance.tsx @@ -19,9 +19,13 @@ const useBalance = (): BalanceContextValue => { getBalance(); manager.on('proofs:saved', getBalance); manager.on('proofs:state-changed', getBalance); + manager.on('proofs:reserved', getBalance); + manager.on('proofs:released', getBalance); return () => { manager.off('proofs:saved', getBalance); manager.off('proofs:state-changed', getBalance); + manager.off('proofs:reserved', getBalance); + manager.off('proofs:released', getBalance); }; }, [manager]);