Skip to content
Open
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
5 changes: 4 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ In-memory reference implementations are provided under `repositories/memory/` fo

- `receive(token: Token | string): Promise<void>`
- `getBalances(): Promise<{ [mintUrl: string]: number }>`
- `getBalanceBreakdown(mintUrl: string): Promise<BalanceBreakdown>`
- `getBalancesBreakdown(): Promise<BalancesBreakdownByMint>`
- `getTrustedBalancesBreakdown(): Promise<BalancesBreakdownByMint>`
- `restore(mintUrl: string): Promise<void>`
- `sweep(mintUrl: string, bip39seed: Uint8Array): Promise<void>`
- `processPaymentRequest(paymentRequest: string): Promise<ParsedPaymentRequest>`
Expand Down Expand Up @@ -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`
Expand Down
27 changes: 27 additions & 0 deletions packages/core/api/WalletApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<BalanceBreakdown> {
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<BalancesBreakdownByMint> {
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<BalancesBreakdownByMint> {
return this.proofService.getTrustedBalancesBreakdown();
}

// Payment Request methods

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/core/services/PaymentRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,12 @@ export class PaymentRequestService {
}

private async findMatchingMints(paymentRequest: PaymentRequest): Promise<string[]> {
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);
}
}
Expand Down
66 changes: 64 additions & 2 deletions packages/core/services/ProofService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}

Expand All @@ -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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate reservation changes to spendable balance consumers

By skipping proofs with usedByOperationId here, getBalances() now returns spendable-only amounts, but reservation/unreservation does not trigger the events that current React balance consumers subscribe to. I checked packages/react/src/lib/providers/Balance.tsx and packages/react/src/lib/hooks/useTrustedBalance.ts: both refresh on proofs:saved/proofs:state-changed (and mint updates), while reserveProofs/releaseProofs emit proofs:reserved/proofs:released. During send.prepare (which reserves proofs without changing proof state), UI balances can remain stale and overstate spendable funds until a later unrelated event fires.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trigger balance updates when proofs are reserved/released

By skipping usedByOperationId proofs here, getBalances() now changes when reservations are created or cleared, but the existing React balance consumers (useBalance in packages/react/src/lib/providers/Balance.tsx and useTrustedBalance in packages/react/src/lib/hooks/useTrustedBalance.ts) only refresh on proofs:saved/proofs:state-changed and do not listen to proofs:reserved/proofs:released; during any pending send/payment-request flow this leaves UI balances stale and can show spendable amounts that no longer match wallet.getBalances() until another unrelated event fires.

Useful? React with 👍 / 👎.

const mintUrl = proof.mintUrl;
const balance = balances[mintUrl] || 0;
balances[mintUrl] = balance + proof.amount;
Expand All @@ -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<BalanceBreakdown> {
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<BalancesBreakdownByMint> {
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<BalancesBreakdownByMint> {
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[],
Expand Down
216 changes: 216 additions & 0 deletions packages/core/test/unit/ProofService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading