@cashu/cashu-ts
Version:
cashu library for communicating with a cashu mint
136 lines (101 loc) • 6.07 kB
Markdown
# <a href="/">Documents</a> › **Deterministic Counters**
# Deterministic counters (persist, inspect, bump)
Deterministic outputs use per-keyset counters. The wallet reserves them atomically and emits a single event you can use to persist the "next" value in your storage.
API at a glance:
- `wallet.counters.peekNext(id)` – returns the current "next" for a keyset
- `wallet.counters.advanceToAtLeast(id, n)` – bump forward if behind
- `wallet.on.countersReserved(cb)` – subscribe to reservations (see [WalletEvents](./wallet_events/wallet_events.md) for subscription patterns)
** Optional:** - Depends on CounterSource:
These methods will throw if the CounterSource does not support them.
- `wallet.counters.snapshot()` – inspect current overall state
- `wallet.counters.setNext(id, n)` – hard-set for migrations/tests
```ts
// 1) Seed once at app start if you have previously saved "next" per keyset
const wallet = new Wallet(mintUrl, {
unit: 'sat',
bip39seed,
keysetId: preferredKeysetId, // e.g. '0111111'
counterInit: loadCountersFromDb(), // e.g. { '0111111': 128 }
});
await wallet.loadMint();
// Alternative to using counterInit for individual keyset allocation
await wallet.counters.advanceToAtLeast('0111111', 128);
// 2) Subscribe once, persist future reservations
wallet.on.countersReserved(({ keysetId, start, count, next }) => {
// next is start + count (i.e: next available)
saveNextToDb(keysetId, next); // do an atomic upsert per keysetId
});
// 3) Inspect current state, what will be reserved next
const nextCounter = await wallet.counters.peekNext('0111111'); // 128
// 4) After a restore or cross device sync, bump the cursor forward
const { lastCounterWithSignature } = await wallet.batchRestore();
if (lastCounterWithSignature != null) {
const next = lastCounterWithSignature + 1; // e.g. 137
await wallet.counters.advanceToAtLeast('0111111', next);
await saveNextToDb('0111111', next);
}
// 5) Parallel keysets without mutation
const wA = wallet; // bound to '0111111'
const wB = wallet.withKeyset('0122222'); // bound to '0122222', same CounterSource
await wB.counters.advanceToAtLeast('0122222', 10);
await wA.counters.snapshot(); // { '0111111': 137, '0122222': 10 }
await wB.counters.snapshot(); // { '0111111': 137, '0122222': 10 }
wA.keysetId; // '0111111'
wB.keysetId; // '0122222'
// 6) Switch wallet default keyset and bump counter
await wallet.counters.snapshot(); // { '0111111': 137, '0122222': 10 }
wallet.keysetId; // '0111111'
wallet.bindKeyset('0133333'); // bound to '0133333', same CounterSource
wallet.keysetId; // '0133333'
await wallet.counters.advanceToAtLeast('0133333', 456);
// Counters persist per keyset, so rebinding does not reset the old one
await wallet.counters.snapshot(); // { '0111111': 137, '0122222': 10, '0133333': 456 }
await wA.counters.snapshot(); // { '0111111': 137, '0122222': 10, '0133333': 456 }
await wB.counters.snapshot(); // { '0111111': 137, '0122222': 10, '0133333': 456 }
```
> **Note** The wallet does not await your callback.
> If saveNextToDb (or similar) is async, handle errors to avoid unhandled rejections
> For more on lifecycle management, see [WalletEvents](./wallet_events/wallet_events.md)
---
## Shared CounterSource across wallet instances
By default each `new Wallet(...)` creates its own internal counter source. If your app creates multiple wallet instances for the same seed (e.g. short-lived wallets per operation), each instance gets an independent copy seeded from `counterInit` — and concurrent operations can reserve **overlapping counter ranges**, causing "outputs have already been signed" errors.
Use `createEphemeralCounterSource()` to create a single shared source and pass it to every wallet via the `counterSource` option:
```ts
import { Wallet, createEphemeralCounterSource } from '@cashu/cashu-ts';
// Create once at app start, seeded from your persisted counters
const counters = createEphemeralCounterSource(loadCountersFromDb());
// Every wallet instance shares the same source — no overlapping reservations
const walletA = new Wallet(mintA, { unit: 'sat', bip39seed, counterSource: counters });
const walletB = new Wallet(mintB, { unit: 'sat', bip39seed, counterSource: counters });
```
### Persisting counter state
The ephemeral source is memory-only — counters do not survive page reloads. Use `wallet.on.countersReserved` to persist after every operation:
```ts
function wireCounterPersistence(wallet: Wallet) {
wallet.on.countersReserved(({ keysetId, next }) => {
saveNextToDb(keysetId, next);
});
}
wireCounterPersistence(walletA);
wireCounterPersistence(walletB);
```
Because the source is shared, the global event on any wallet instance reflects the true cursor — there is no need for per-operation `onCountersReserved` callbacks in your builder chains.
### counterSource vs counterInit
| Option | When to use |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `counterInit` | Single wallet instance, or you don't need cross-wallet coordination. Seeds a wallet-local ephemeral source. |
| `counterSource` | Multiple wallet instances for the same seed, or you need persistence/custom storage. Takes precedence over `counterInit`. |
### Custom CounterSource implementations
`createEphemeralCounterSource` returns the built-in in-memory implementation. For durable storage you can implement `CounterSource` directly:
```ts
import type { CounterSource, CounterRange } from '@cashu/cashu-ts';
class IndexedDbCounterSource implements CounterSource {
async reserve(keysetId: string, n: number): Promise<CounterRange> {
// atomic read-and-increment in your DB
}
async advanceToAtLeast(keysetId: string, minNext: number): Promise<void> {
// conditional update: SET next = max(next, minNext)
}
// Optional: snapshot(), setNext()
}
```