diff --git a/.changeset/yummy-queens-guess.md b/.changeset/yummy-queens-guess.md new file mode 100644 index 00000000..a9b5cac8 --- /dev/null +++ b/.changeset/yummy-queens-guess.md @@ -0,0 +1,21 @@ +--- +'coco-cashu-expo-sqlite': patch +'coco-cashu-sqlite-bun': patch +'coco-cashu-indexeddb': patch +'coco-cashu-sqlite3': patch +'coco-cashu-core': patch +'coco-cashu-adapter-tests': patch +--- + +Finish the mint quote removal migration and make mint operations the runtime source of truth. + +- Replace legacy `mint-quote:*` runtime events with operation-based mint events. +- Rename watcher and processor config and manager methods to the operation-based surface: + `mintOperationWatcher`, `mintOperationProcessor`, `enableMintOperationWatcher()`, + `enableMintOperationProcessor()`, and related disable/wait helpers. +- Remove the legacy `MintQuoteService` runtime path and keep `MintQuoteRepository` only for + cold-start reconciliation of old persisted quote rows. +- Move mint watcher, processor, history, and recovery flows onto `manager.ops.mint`. + +This is a breaking change for consumers using the old mint watcher/processor config keys, +manager methods, or `mint-quote:*` events. diff --git a/FEATURE_TODO.md b/FEATURE_TODO.md new file mode 100644 index 00000000..f293ef3d --- /dev/null +++ b/FEATURE_TODO.md @@ -0,0 +1,333 @@ +# Mint Quote Removal Plan + +## Goals + +- Make mint operations the single durable source of truth for quote-backed mint flows. +- Remove the legacy mint-quote service, watcher, processor, and API surface. +- Keep the legacy mint quote repository temporarily only as a compatibility / reconciliation source during migration. +- Move all mint quote metadata needed for execution, recovery, history, and subscriptions into mint operations. +- Preserve crash-safe recovery, background processing, and adapter portability across sqlite3, sqlite-bun, expo-sqlite, indexeddb, and memory repositories. + +## Constraints + +- Existing persisted `MintQuote` rows may exist without matching mint operations. +- Creating a missing prepared mint operation from a legacy quote row requires runtime services such as wallet access, counters, seed-derived outputs, and the normal operation lifecycle; this cannot be handled by schema migration alone. +- Existing public APIs and events are still used throughout tests, docs, and likely downstream integrations. +- History currently depends on quote payload data such as `request`, `amount`, `unit`, and `state`. +- Watcher and processor startup currently depend on quote repository scans and quote events. +- Mint-operation persistence is still unreleased, so mint-operation schema changes should update the existing adapter schema definitions in place rather than adding new mint-operation migrations/version bumps. + +## Target End State + +- `MintOperation` persists both: + - operation lifecycle state: `init`, `pending`, `executing`, `finalized`, `failed` + - init-time local intent for new-quote and import flows, even before a remote quote exists; this init payload includes at least `method`, `unit`, and `amount` + - quote snapshot / quote tracking data on prepared and later quote-backed states: `amount`, `request`, `unit`, `expiry`, optional `pubkey`, `lastObservedRemoteState`, optional `lastObservedRemoteStateAt`, and any terminal failure metadata needed for recovery and processors + - method-owned remote observation metadata: + - each mint method defines its own remote-state union + - handlers translate method-specific remote states into normalized service categories such as waiting / ready / completed / terminal +- `manager.ops.mint` owns the full mint quote lifecycle: + - expose a one-call API for preparing a new mint quote-backed operation, while internally keeping `MintOperationService.init()` -> `MintOperationService.prepare()` + - expose a one-call API for importing an existing mint quote as a prepared pending operation, while internally keeping `init` as the first durable state + - redeem / finalize / recover + - list / inspect tracked mint quote operations +- Watching and processing PAID / ISSUED quote transitions operate on mint operations, not on `MintQuoteRepository`. +- History is created and updated from mint operations plus observed quote-state updates emitted from the operation-based watcher path. +- Legacy `mint-quote:*` events are fully replaced by operation-based mint events rather than kept as a long-term compatibility layer. +- During the transition, a legacy `MintQuoteRepository` may still exist as a startup reconciliation source for old persisted quote rows that do not yet have operations. +- Legacy quote APIs are removed after migration and compatibility coverage are in place. + +## Proposed Phases + +### Phase 1: Expand the mint operation model + +- Scope note: this phase is limited to mint-operation types, persisted schema/repository shape, and adapter coverage. Service/API orchestration changes for quote-less `init`, remote quote creation during `prepare`, and watcher/processor-driven state refresh land in later phases. +- Extend `MintOperation` types to include quote snapshot data needed for execution, recovery, history, and watching. +- Persist the latest observed remote quote state as diagnostic / history metadata on the operation, without making it the authoritative lifecycle state. +- Add structured terminal failure metadata so quote-oriented flows do not rely on parsing error strings. +- Update all mint operation repositories and schemas to persist the expanded shape, altering the existing unreleased mint-operation schema definitions in place rather than adding new adapter migrations for these fields. +- Keep the old quote repository temporarily while operation persistence and startup reconciliation are being introduced. + +### Phase 2: Move creation/import into `ops.mint` + +- Add operation-oriented APIs for: + - preparing a new mint quote-backed operation in one API call + - importing an existing mint quote into a prepared pending operation in one API call + - listing tracked mint quote operations +- Make `MintOperationService` own quote creation/import bookkeeping. +- Keep the internal lifecycle explicit: + - `init` remains the first durable local operation state + - for brand-new quotes, `init` may exist before any `quoteId` or remote quote snapshot has been created + - `init` persists local mint intent such as `method`, `unit`, and `amount`, while `quoteId` is added during `prepare` + - API-level prepare/import calls may compose multiple service steps, but they should still persist an `init` operation before transitioning to `pending` + - for brand-new quotes, follow the melt-style sequencing: + - persist an `init` operation first + - create the remote mint quote during `prepare` + - generate deterministic output data during the same `prepare` step + - persist the fully prepared `pending` operation with the quote snapshot only after the quote and local prepared data are both available + - for imported existing quotes, `init` should already contain enough local intent to transition into `pending` using the imported quote snapshot + - `prepare` remains the transition that materializes deterministic output data and persists the fully prepared `pending` operation +- Ensure operation creation / import emits the events needed by watchers and history. + +### Phase 2.5: Add legacy quote reconciliation at runtime + +- Keep `MintQuoteRepository` as a temporary legacy persistence source after `MintQuoteService` is no longer the primary orchestration layer. +- Add a startup reconciliation step that scans legacy stored mint quotes and ensures each relevant quote has a corresponding mint operation. +- If a legacy stored quote has no matching mint operation: + - create the operation through the normal operation lifecycle + - preserve the internal durable `init -> prepare -> pending` progression + - materialize deterministic outputs at runtime using the normal services rather than via schema migration code +- Make reconciliation idempotent by checking for existing operations by `(mintUrl, quoteId)` before creating anything. +- Treat stale quote-backed `init` operations as incomplete reconciliation work, not as a completed backfill result: + - if a quote has a matching `pending`, `executing`, `finalized`, or `failed` operation, do not create another one + - if a quote only has a matching `init` operation, startup reconciliation must resume that operation through `prepare` instead of skipping it + - do not rely on generic init-cleanup recovery to delete backfilled quote-backed `init` operations before reconciliation has a chance to resume them +- Run reconciliation before watcher startup, processor startup, and mint-operation recovery so the rest of the system sees a consistent operation-first view. + +### Phase 3: Rewire watcher and processor around operations + +Implementation order for this phase: +1. Rewire watcher startup and live subscription registration around pending mint operations while keeping legacy quote-state compatibility events flowing from the operation path. +2. Rewire processor queueing and execution around mint operations / operation lookup instead of quote-repository-driven orchestration. +3. Rewrite history creation and state updates to consume operation-owned quote snapshots and operation-based mint events. +4. Remove the remaining quote-repository-driven watcher / processor assumptions once the operation path fully owns background mint flow progression. + +- Replace quote-repository scans with mint-operation scans. +- Replace the legacy quote-repository-driven watcher with an operation-based watcher. +- Replace quote events as the primary trigger path with mint-operation events plus remote quote-state updates from the operation-based watcher. +- Treat imported and newly created unpaid mint quotes as fully prepared `pending` mint operations with all deterministic local data already materialized. +- Split responsibilities explicitly: + - watcher observes remote quote changes, updates observational metadata, and emits quote-state change events for history / processor triggers + - processor is the only background component that advances local operations from `pending` into `executing` + - recovery is responsible for reconciling `executing` operations back to `pending`, `finalized`, or `failed` +- Ensure startup behavior still covers: + - pending unpaid quotes that need watching + - PAID quotes that need processing + - executing operations that need recovery + - finalized / failed operations that should not be requeued +- Ensure subscription resume behavior also covers the same operation-based work classes as startup bootstrap: + - pending unpaid operations that need watcher coverage again + - PAID operations that need processor queue coverage again + - executing operations that need recovery/reconciliation before normal background processing resumes +- Ensure runtime behavior also covers imported quotes without requiring a restart: + - importing an already-PAID external quote should create a normal `pending` mint operation and enqueue processor work immediately + - importing an unpaid external quote should create a normal watched `pending` mint operation that will be queued later when the watcher observes `PAID` + +### Phase 3.5: Replace `mint-quote:*` events with `mint-op:*` + +- Introduce an operation-based mint event model that fully replaces the old quote events. +- Keep `mint-op:pending`, `mint-op:executing`, and terminal lifecycle events as the primary public mint event surface. +- Add a dedicated quote-observation event for watcher output, for example `mint-op:quote-state-changed`, carrying: + - `mintUrl` + - `operationId` + - `quoteId` + - `previousState` + - `state` + - the latest operation snapshot +- Rewire internal consumers in this order: + - watcher emits operation-based quote observation events + - processor queues from operation-based events and operation scans + - history creates and updates entries from operation-based events +- Remove `mint-quote:requeue` entirely once bootstrap logic scans pending mint operations directly. +- Remove `mint-quote:created`, `mint-quote:added`, `mint-quote:state-changed`, and `mint-quote:redeemed` once all internal listeners have been migrated. + +### Phase 4: Migrate persisted data + +- Do not add new adapter schema migrations solely for unreleased mint-operation field changes; change the existing mint-operation schema definitions in place. +- Handle persisted legacy quote data by: + - copying quote metadata into existing mint operations when needed + - preserving enough legacy quote data for runtime reconciliation to create missing mint operations after restart +- Validate restart / reconciliation behavior across all adapters. + +### Phase 5: Move history and compatibility layers + +- Update history creation and state updates to use mint operations instead of `mint-quote:*` payloads. +- Migrate all internal listeners to `mint-op:*` and remove legacy `mint-quote:*` events rather than keeping a long-term compatibility event layer. +- Update tests, docs, and examples to use `manager.ops.mint`. + +### Phase 6: Remove legacy stack + +- Remove: + - `MintQuoteService` + - `MintQuoteWatcherService` + - `MintQuoteProcessor` + - `MintQuoteRepository` + - legacy mint quote API methods from `QuotesApi` / `Manager` + - related manager config for mint-quote watcher / processor +- Remove adapter implementations and exports for mint quote repositories. +- Remove quote-specific tests once operation-based replacements exist. + +## TODOs + +### Model and repository TODOs + +- [x] Define the operation-owned quote snapshot shape for mint operations. +- [x] Define the init-time local intent shape for mint operations separately from the pending quote snapshot shape, with `method`, `unit`, and `amount` persisted before any quote exists. +- [x] Allow the persisted mint-operation model to represent `init` operations before a remote quote exists, so later service work is not forced to persist `quoteId`, `request`, or a quote row up front. +- [x] Make `quoteId` absent from persisted `init` mint operations and introduce it during `prepare`, either from an imported quote snapshot or from the newly created remote mint quote. +- [ ] Decide whether `quoteId` becomes optional on one unified persisted mint-operation shape or whether `init` and pending+ states should use separate persisted field requirements. +- [x] Add `lastObservedRemoteState` and `lastObservedRemoteStateAt` to mint operations as observational metadata. +- [x] Make remote observation state method-owned rather than globally quote-owned, so future mint methods can define different remote-state unions. +- [x] Wire `lastObservedRemoteState` / `lastObservedRemoteStateAt` through the watcher / processor / finalize paths so operations persist the latest observed remote state, not just the prepare-time snapshot. +- [x] Define structured terminal failure fields for mint operations. +- [ ] Define the prepared-data invariant for `pending` mint operations so create/import always persist all local execution data up front. +- [ ] Ensure the new prepare flow persists quote snapshot data and deterministic outputs atomically. +- [ ] For brand-new quotes, mirror the melt flow: persist `init`, create the remote quote during `prepare`, then persist the prepared `pending` operation with quote snapshot data. +- [ ] Define the import-time init payload so `prepare` can transition an imported quote-backed `init` operation into a normal quote-backed `pending` operation without consulting `MintQuoteRepository`. +- [x] Update core mint operation types. +- [x] Update memory mint operation repository. +- [x] Update sqlite3 mint operation repository and schema. +- [x] Update sqlite-bun mint operation repository and schema. +- [x] Update expo-sqlite mint operation repository and schema. +- [x] Update indexeddb mint operation repository and schema. +- [x] Add repository round-trip / persisted-shape coverage for the new fields in every adapter, plus migration coverage where applicable. +- [ ] Keep the legacy mint quote repository shape stable enough to support temporary startup reconciliation. + +### Service and API TODOs + +- [x] Redefine `ops.mint.prepare(...)` as a one-call API that internally performs `MintOperationService.init()` followed by `MintOperationService.prepare()`. +- [x] Add `ops.mint` API support for importing an existing mint quote into a prepared pending operation. +- [x] Keep `MintOperationService.init()` as the first durable local state transition; do not collapse the internal lifecycle into a single persisted step. +- [x] Split the API and service contracts between: + - new-quote prepare, which should not require a pre-existing `quoteId` and should derive `quoteId` during `prepare` + - import-existing-quote prepare, which should accept a pre-existing quote snapshot / `quoteId` +- [x] Redefine `MintOperationService.init()` so new-quote init persists only local creation intent, mirroring `MeltOperationService.init()`, instead of validating a pre-existing quote row. +- [x] Redefine `MintOperationService.prepare()` so it performs the melt-style orchestration step: + - for new quotes, call the handler to create the remote quote during `prepare` + - for imported quotes, normalize and validate the imported quote snapshot during `prepare` + - only then derive deterministic outputs and persist the fully prepared `pending` operation with `quoteId` and the full quote snapshot attached +- [x] Refactor `MintBolt11Handler.prepare()` to mirror `MeltBolt11Handler.prepare()` by creating or ingesting the quote snapshot first and returning a complete pending-operation payload. +- [x] Move quote validation that currently happens in mint `init()` into the appropriate create/import `prepare()` path so quote-less init rows remain valid. +- [x] Ensure the prepare path uses the same mint-scoped locking guarantees as melt while creating the quote and materializing deterministic outputs. +- [x] Add any missing operation query APIs needed by watcher / processor / history flows. +- [x] Refactor `MintOperationService` to create and manage quote-backed operations without `MintQuoteRepository`. +- [x] Remove `MintQuoteService` as the primary orchestration path while keeping `MintQuoteRepository` available temporarily for startup reconciliation. +- [x] Refactor `MintBolt11Handler` and mint method deps to consume operation-owned quote data. +- [x] Remove mint quote methods from `QuotesApi` while keeping melt quote methods intact for now. +- [x] Decide whether `manager.quotes` remains as a melt-only API or keeps temporary deprecated mint shims during migration. + +### Watcher and processor TODOs + +- [x] Define the replacement event model for mint flows: + - `mint-op:pending` for tracked/prepared quote-backed operations + - `mint-op:quote-state-changed` for observed remote quote state changes + - `mint-op:executing` + - terminal lifecycle events for success and failure +- [x] Add any missing mint-operation events for creation / import if needed. +- [x] Replace the legacy mint quote watcher with an operation-based watcher that subscribes from mint operations. +- [x] Rebuild the mint quote processor to queue from mint operations. +- [x] Remove `MintOperationService.redeem(mintUrl, quoteId)` once the processor no longer delegates quote-id work through the legacy mint-quote service path. +- [x] Rework startup bootstrap to scan mint operations instead of mint quote rows. +- [x] Add a startup reconciliation pass that scans legacy mint quote rows and backfills missing mint operations before watcher / processor / recovery startup. +- [x] Rework `resumeSubscriptions()` to re-establish operation-based watcher / processor / recovery coverage for mint operations, not just restart transports. +- [x] Ensure startup reconciliation resumes quote-backed `init` operations before generic init cleanup can delete them. +- [x] Make the watcher emit operation-based quote observation events instead of `mint-quote:state-changed`. +- [x] Make the processor queue from live operation events: + - `mint-op:pending` when a newly prepared/imported operation is already observed as `PAID` + - `mint-op:quote-state-changed` when a watched operation transitions to `PAID` + - do not rely on pending-operation scans during startup bootstrap; startup backlog should be handled by recovery +- [x] Define whether resume uses the same pending-operation scan/requeue path as startup bootstrap or a smaller resume-specific reconciliation pass, and document the ordering. +- [x] Ensure runtime imports of external quotes are processed without restart: + - already-PAID imports should enqueue immediately from the operation path + - unpaid imports should rely on watcher coverage until they transition to `PAID` +- [x] Document and enforce the ownership boundary: watcher observes, processor advances live `pending`, recovery reconciles startup/backlog `pending` plus `executing`. +- [ ] Preserve untrusted-mint behavior for watched / queued work. +- [ ] Preserve expired-quote handling and other terminal processor outcomes. +- [x] Ensure unpaid watched quotes remain in local `pending` state until they eventually converge to `executing`, `finalized`, or `failed`. +- [x] Persist only the latest observed remote state as metadata; do not treat it as the authoritative operation state. +- [ ] Define when watcher callbacks update `lastObservedRemoteState` and when action paths must still re-check remote state before acting. + +### Migration TODOs + +- [ ] Identify all persisted states that can exist in `MintQuoteRepository` today. +- [ ] Define how orphaned stored `UNPAID` and `PAID` quotes map to new `pending` mint operations. +- [ ] Ensure legacy `ISSUED` quote rows are ignored during migration and never recreated as mint operations. +- [ ] Define the runtime reconciliation algorithm that turns orphan legacy quote rows into mint operations through the normal service lifecycle. +- [ ] Define how reconciliation handles pre-existing `init` operations for the same `(mintUrl, quoteId)` so crashes between `init` and `prepare` remain recoverable. +- [ ] Define how reconciliation distinguishes quote-less create-path `init` operations from imported/reconciled quote-backed `init` operations so recovery does not delete valid in-progress work. +- [ ] Write adapter restart/reconciliation tests for quote-to-operation migration. +- [ ] Verify reconciliation behavior on restart paths, not just fresh databases. +- [ ] Define the import path for external mint quotes so imported rows become normal tracked mint operations immediately. +- [ ] Define the new-quote prepare path so newly created quotes become normal tracked `pending` operations immediately via the melt-style sequence: persisted `init` first, remote quote creation during `prepare`, then persisted `pending`. + +### History and events TODOs + +- [x] Rewrite mint history creation to use operation-owned quote snapshot payloads. +- [x] Rewrite mint history state updates to use operation-owned quote metadata plus observed quote-state updates from the watcher path. +- [x] Make history creation come from operation events such as `mint-op:pending` rather than `mint-quote:created` / `mint-quote:added`. +- [ ] Make history state updates come from operation events such as `mint-op:quote-state-changed`, `mint-op:finalized`, and terminal failure events. +- [x] Audit all `mint-quote:*` listeners and replace or remove them. +- [x] Remove legacy `mint-quote:*` event types once all internal listeners have been migrated. +- [ ] Update README and API docs to document the new event / API model. + +### Removal TODOs + +- [ ] Remove `MintQuoteService` from manager wiring. +- [x] Remove the legacy `MintQuoteWatcherService` from manager wiring and config after the operation-based watcher replacement is in place. +- [x] Remove `MintQuoteProcessor` from manager wiring and config. +- [ ] Remove `MintQuoteRepository` from repository interfaces after startup reconciliation and compatibility imports are no longer needed. +- [ ] Remove memory mint quote repository after startup reconciliation and compatibility imports are no longer needed. +- [ ] Remove sqlite3 mint quote repository and schema after startup reconciliation and compatibility imports are no longer needed. +- [ ] Remove sqlite-bun mint quote repository and schema after startup reconciliation and compatibility imports are no longer needed. +- [ ] Remove expo-sqlite mint quote repository and schema after startup reconciliation and compatibility imports are no longer needed. +- [ ] Remove indexeddb mint quote repository and schema after startup reconciliation and compatibility imports are no longer needed. +- [ ] Remove quote API docs and old examples. + +### Test coverage TODOs + +- [ ] Update unit tests for manager wiring, ops API, history, watcher, and processor. +- [ ] Replace quote-oriented integration tests with operation-oriented equivalents. +- [ ] Keep migration tests for old persisted quote data. +- [ ] Verify pause / resume and startup recovery behavior. +- [ ] Verify `resumeSubscriptions()` re-establishes watcher, processor, and recovery coverage for operation-based mint flows. +- [ ] Verify background processing of PAID quotes still works after restart. + +## Decisions + +- Remove the mint-quote portion of `QuotesApi`; melt quote APIs remain for now. +- Mint history remains user-facing quote history with the same shape and semantics. +- Watching an unpaid mint quote continues to use local `pending` state, not `init`. +- Local operation state remains authoritative, but each mint operation should also persist the latest observed remote quote state as non-authoritative metadata. +- External mint quotes must be importable, and once imported they should be represented as normal `pending` mint operations. +- `manager.ops.mint` may expose one-call prepare/import APIs, but the internal service lifecycle should continue to use an explicit durable `init -> prepare -> pending` progression. +- Brand-new mint-operation `init` rows are quote-less and persist local creation intent such as `method`, `unit`, and `amount`; `quoteId` and the quote snapshot become mandatory once the operation reaches prepared `pending`. +- Mint-operation persistence is unreleased, so adapter schema changes for this work should update the existing mint-operation schema definitions in place rather than adding extra mint-operation migrations/version bumps. +- `MintQuoteService` should be removed before `MintQuoteRepository`; the repository remains temporarily as a legacy persistence source for startup reconciliation and compatibility imports. +- New quote creation should mirror the melt flow: persist `init` first, create the remote quote during `prepare`, then persist the prepared `pending` operation containing the quote snapshot and deterministic local execution data. +- Replace `mint-quote:*` events completely with operation-based mint events; do not keep a long-term compatibility event layer inside the repo. + +## Migration Matrix + +### Stored mint quote state -> migrated mint operation + +- `UNPAID` quote with no existing mint operation: + - At runtime startup reconciliation, create a fully prepared `pending` mint operation through the normal service lifecycle. + - Persist quote snapshot data plus all local deterministic execution data needed later. + - Start watcher coverage after reconciliation / startup. + +- `PAID` quote with no existing mint operation: + - At runtime startup reconciliation, create a fully prepared `pending` mint operation through the normal service lifecycle. + - Persist quote snapshot data plus all local deterministic execution data needed later. + - Processor / startup recovery should be able to advance it toward redemption. + +- `ISSUED` quote with no existing mint operation: + - Do not migrate it into any mint operation. + - Treat it as legacy data that can be dropped once migration completes. + - Rely on existing history entries for user-visible recordkeeping. + - Never allow restart logic to attempt fresh redemption for these rows. + +### Existing mint operation + stored mint quote + +- Existing `init` / `pending` / `executing` mint operation: + - Copy quote snapshot data onto the operation. + - Preserve the operation lifecycle state. + - Do not overwrite local execution data already stored on the operation. + - If the existing operation is a quote-backed `init` at startup reconciliation time, resume it through `prepare` rather than treating it as already reconciled. + +- Existing `finalized` mint operation: + - Copy any missing quote snapshot data needed for history / compatibility only. + - Preserve `finalized`. + +- Existing `failed` mint operation: + - Copy any missing quote snapshot data needed for history / compatibility only. + - Preserve `failed`. diff --git a/packages/adapter-tests/src/integration.ts b/packages/adapter-tests/src/integration.ts index 4f596f58..88c71394 100644 --- a/packages/adapter-tests/src/integration.ts +++ b/packages/adapter-tests/src/integration.ts @@ -65,6 +65,8 @@ type SendHistoryUpdatedPayload = { }; }; +type PreparedMintOperation = Awaited>; + const watcherTestSubscriptions = { slowPollingIntervalMs: 50, fastPollingIntervalMs: 50, @@ -106,6 +108,108 @@ function waitForSendHistoryState( }); } +async function prepareMintOperation( + manager: Manager, + mintUrl: string, + amount: number, + unit: 'sat' = 'sat', +) { + return manager.ops.mint.prepare({ + mintUrl, + amount, + unit, + method: 'bolt11', + methodData: {}, + }); +} + +async function executeMintOperation(manager: Manager, operationId: string) { + return manager.ops.mint.execute(operationId); +} + +function isMintQuoteReady( + state: PreparedMintOperation['lastObservedRemoteState'] | undefined, +): boolean { + return state === 'PAID' || state === 'ISSUED'; +} + +async function getLatestPendingMintOperation( + manager: Manager, + operationId: string, +): Promise { + const operation = await manager.ops.mint.get(operationId); + if (!operation || operation.state !== 'pending') { + return null; + } + + return operation; +} + +async function awaitMintQuotePaid( + manager: Manager, + pendingMint: PreparedMintOperation, +): Promise { + if (isMintQuoteReady(pendingMint.lastObservedRemoteState)) { + return pendingMint; + } + + let cancelWait: (() => void) | undefined; + const paidEventPromise = new Promise((resolve) => { + const off = manager.on('mint-op:quote-state-changed', (payload) => { + if (payload.operationId !== pendingMint.id || payload.state !== 'PAID') { + return; + } + + off(); + resolve(); + }); + + cancelWait = () => { + off(); + resolve(); + }; + }); + + const latestPendingMint = await getLatestPendingMintOperation(manager, pendingMint.id); + if (isMintQuoteReady(latestPendingMint?.lastObservedRemoteState)) { + cancelWait?.(); + return latestPendingMint; + } + + await paidEventPromise; + return getLatestPendingMintOperation(manager, pendingMint.id); +} + +async function awaitMintQuotePaidWithSubscription( + manager: Manager, + mintUrl: string, + pendingMint: PreparedMintOperation, +): Promise { + if (isMintQuoteReady(pendingMint.lastObservedRemoteState)) { + return pendingMint; + } + + const paidNotificationPromise = manager.subscription.awaitMintQuotePaid( + mintUrl, + pendingMint.quoteId, + ); + + const latestPendingMint = await getLatestPendingMintOperation(manager, pendingMint.id); + if (isMintQuoteReady(latestPendingMint?.lastObservedRemoteState)) { + return latestPendingMint; + } + + await paidNotificationPromise; + return (await getLatestPendingMintOperation(manager, pendingMint.id)) ?? pendingMint; +} + +async function mintAmount(manager: Manager, mintUrl: string, amount: number, unit: 'sat' = 'sat') { + const pendingMint = await prepareMintOperation(manager, mintUrl, amount, unit); + await awaitMintQuotePaidWithSubscription(manager, mintUrl, pendingMint); + await executeMintOperation(manager, pendingMint.id); + return pendingMint; +} + export async function runIntegrationTests( options: IntegrationTestOptions, runner: IntegrationTestRunner, @@ -217,8 +321,7 @@ export async function runIntegrationTests { - mgr!.once('mint-quote:created', (payload) => { - expect(payload.mintUrl).toBe(mintUrl); - expect(payload.quoteId).toBe(quote.quote); - resolve(payload); - }); - }); - - await new Promise((resolve) => { - mgr!.once('mint-quote:state-changed', (payload) => { - if (payload.state === 'PAID') { - expect(payload.mintUrl).toBe(mintUrl); - expect(payload.quoteId).toBe(quote.quote); - resolve(payload); - } - }); - }); + const pendingEventPromise = waitForEvent<{ + mintUrl: string; + operationId: string; + operation: { quoteId: string }; + }>(mgr!, 'mint-op:pending'); + const pendingMint = await prepareMintOperation(mgr!, mintUrl, 100); + expect(pendingMint.quoteId).toBeDefined(); + expect(pendingMint.request).toBeDefined(); + expect(pendingMint.amount).toBe(100); + + const pendingEvent = await pendingEventPromise; + expect(pendingEvent.mintUrl).toBe(mintUrl); + expect(pendingEvent.operation.quoteId).toBe(pendingMint.quoteId); + + const paidMint = await awaitMintQuotePaid(mgr!, pendingMint); + expect(paidMint?.quoteId).toBe(pendingMint.quoteId); + expect(paidMint?.lastObservedRemoteState).toBe('PAID'); + + const finalizedEventPromise = waitForEvent<{ + mintUrl: string; + operationId: string; + operation: { quoteId?: string }; + }>( + mgr!, + 'mint-op:finalized', + (payload) => payload.operationId === pendingMint.id, + ); - const redeemPromise = new Promise((res) => { - mgr!.quotes.redeemMintQuote(mintUrl, quote.quote).then(() => { - res(void 0); - }); - }); + await executeMintOperation(mgr!, pendingMint.id); - const redeemedEventPromise = new Promise((resolve) => { - mgr!.once('mint-quote:redeemed', (payload) => { - expect(payload.mintUrl).toBe(mintUrl); - expect(payload.quoteId).toBe(quote.quote); - resolve(payload); - }); - }); - - await Promise.all([redeemPromise, redeemedEventPromise]); + const finalizedEvent = await finalizedEventPromise; + expect(finalizedEvent.mintUrl).toBe(mintUrl); + expect(finalizedEvent.operation.quoteId).toBe(pendingMint.quoteId); const balance = await mgr.wallet.getBalances(); expect(balance[mintUrl] || 0).toBeGreaterThanOrEqual(100); @@ -310,7 +408,7 @@ export async function runIntegrationTests { @@ -572,8 +667,7 @@ export async function runIntegrationTests { @@ -627,8 +721,7 @@ export async function runIntegrationTests { @@ -828,8 +921,7 @@ export async function runIntegrationTests { @@ -916,7 +1008,7 @@ export async function runIntegrationTests { @@ -1120,7 +1211,7 @@ export async function runIntegrationTests { @@ -1302,8 +1392,7 @@ export async function runIntegrationTests { - mgr!.once('mint-quote:redeemed', (payload) => { - expect(payload.mintUrl).toBe(mintUrl); - expect(payload.quoteId).toBe(quote.quote); - resolve(payload); - }); - }); - - await mgr.quotes.redeemMintQuote(mintUrl, quote.quote); - await redeemedPromise; + const finalizedPromise = waitForEvent<{ + mintUrl: string; + operationId: string; + operation: { amount: number }; + }>(mgr!, 'mint-op:finalized'); + const pendingMint = await prepareMintOperation(mgr!, mintUrl, 150); + const finalized = await finalizedPromise; + expect(finalized.mintUrl).toBe(mintUrl); + expect(finalized.operationId).toBe(pendingMint.id); + expect(finalized.operation.amount).toBe(150); const balance = await mgr.wallet.getBalances(); expect(balance[mintUrl] || 0).toBeGreaterThanOrEqual(150); @@ -1464,8 +1549,7 @@ export async function runIntegrationTests { @@ -1522,7 +1605,7 @@ export async function runIntegrationTests { @@ -2201,7 +2281,7 @@ export async function runIntegrationTests { diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 1f0bec2d..0407c36c 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -1,6 +1,7 @@ import type { Repositories, MintQuoteRepository, + MintOperationRepository, SendOperationRepository, MeltOperationRepository, ReceiveOperationRepository, @@ -8,9 +9,8 @@ import type { import { CounterService, MintService, - MintQuoteService, - MintQuoteWatcherService, - MintQuoteProcessor, + MintOperationWatcherService, + MintOperationProcessor, ProofService, WalletService, SeedService, @@ -27,6 +27,7 @@ import { } from './services'; import { SendOperationService } from './operations/send/SendOperationService'; import { MeltOperationService } from './operations/melt/MeltOperationService'; +import { MintOperationService } from './operations/mint/MintOperationService'; import { ReceiveOperationService } from './operations/receive/ReceiveOperationService'; import { MintScopedLock } from './operations/MintScopedLock'; import { @@ -40,6 +41,8 @@ import { SendHandlerProvider, DefaultSendHandler, P2pkSendHandler, + MintBolt11Handler, + MintHandlerProvider, } from './infra'; import { EventBus, type CoreEvents } from './events'; import { type Logger, NullLogger } from './logging'; @@ -54,6 +57,7 @@ import { SendOpsApi, ReceiveOpsApi, MeltOpsApi, + MintOpsApi, } from './api'; import { SubscriptionApi } from './api/SubscriptionApi.ts'; import { PluginHost } from './plugins/PluginHost.ts'; @@ -80,8 +84,8 @@ export interface CocoConfig { * - Provide options to customize behavior */ watchers?: { - /** Mint quote watcher (enabled by default) */ - mintQuoteWatcher?: { + /** Mint operation watcher (enabled by default) */ + mintOperationWatcher?: { disabled?: boolean; watchExistingPendingOnStart?: boolean; }; @@ -99,8 +103,8 @@ export interface CocoConfig { * - Provide options to customize behavior */ processors?: { - /** Mint quote processor (enabled by default) */ - mintQuoteProcessor?: { + /** Mint operation processor (enabled by default) */ + mintOperationProcessor?: { disabled?: boolean; processIntervalMs?: number; maxRetries?: number; @@ -149,10 +153,14 @@ export async function initializeCoco(config: CocoConfig): Promise { // Initialize plugin system (must complete before watchers for extensions to be available) await coco.initPlugins(); + // Reconcile legacy mint quote rows into mint operations before any watcher, + // processor, or mint recovery path starts. + await coco.reconcileLegacyMintQuotes(); + // Enable watchers (default: all enabled unless explicitly disabled) - const mintQuoteWatcherConfig = config.watchers?.mintQuoteWatcher; - if (!mintQuoteWatcherConfig?.disabled) { - await coco.enableMintQuoteWatcher(mintQuoteWatcherConfig); + const mintOperationWatcherConfig = config.watchers?.mintOperationWatcher; + if (!mintOperationWatcherConfig?.disabled) { + await coco.enableMintOperationWatcher(mintOperationWatcherConfig); } const proofStateWatcherConfig = config.watchers?.proofStateWatcher; @@ -161,10 +169,9 @@ export async function initializeCoco(config: CocoConfig): Promise { } // Enable processors (default: all enabled unless explicitly disabled) - const mintQuoteProcessorConfig = config.processors?.mintQuoteProcessor; - if (!mintQuoteProcessorConfig?.disabled) { - await coco.enableMintQuoteProcessor(mintQuoteProcessorConfig); - await coco.quotes.requeuePaidMintQuotes(); + const mintOperationProcessorConfig = config.processors?.mintOperationProcessor; + if (!mintOperationProcessorConfig?.disabled) { + await coco.enableMintOperationProcessor(mintOperationProcessorConfig); } // Recover any pending send operations from previous session @@ -176,6 +183,9 @@ export async function initializeCoco(config: CocoConfig): Promise { // Recover any pending receive operations from previous session await coco.ops.receive.recovery.run(); + // Recover any pending mint operations from previous session + await coco.recoverPendingMintOperations(); + return coco; } @@ -207,9 +217,8 @@ export class Manager { private eventBus: EventBus; private logger: Logger; readonly subscriptions: SubscriptionManager; - private mintQuoteService: MintQuoteService; - private mintQuoteWatcher?: MintQuoteWatcherService; - private mintQuoteProcessor?: MintQuoteProcessor; + private mintOperationWatcher?: MintOperationWatcherService; + private mintOperationProcessor?: MintOperationProcessor; private mintQuoteRepository: MintQuoteRepository; private proofStateWatcher?: ProofStateWatcherService; private meltQuoteService: MeltQuoteService; @@ -225,6 +234,8 @@ export class Manager { private sendOperationRepository: SendOperationRepository; private meltOperationService: MeltOperationService; private meltOperationRepository: MeltOperationRepository; + private mintOperationService: MintOperationService; + private mintOperationRepository: MintOperationRepository; private receiveOperationService: ReceiveOperationService; private receiveOperationRepository: ReceiveOperationRepository; private proofRepository: Repositories['proofRepository']; @@ -270,7 +281,6 @@ export class Manager { this.keyRingService = core.keyRingService; this.seedService = core.seedService; this.counterService = core.counterService; - this.mintQuoteService = core.mintQuoteService; this.mintQuoteRepository = core.mintQuoteRepository; this.meltQuoteService = core.meltQuoteService; this.historyService = core.historyService; @@ -285,6 +295,8 @@ export class Manager { this.meltOperationRepository = core.meltOperationRepository; this.authSessionService = core.authSessionService; this.authService = core.authService; + this.mintOperationService = core.mintOperationService; + this.mintOperationRepository = core.mintOperationRepository; this.proofRepository = repositories.proofRepository; const apis = this.buildApis(); this.mint = apis.mint; @@ -323,7 +335,6 @@ export class Manager { seedService: this.seedService, walletRestoreService: this.walletRestoreService, counterService: this.counterService, - mintQuoteService: this.mintQuoteService, meltQuoteService: this.meltQuoteService, historyService: this.historyService, transactionService: this.transactionService, @@ -331,6 +342,7 @@ export class Manager { receiveOperationService: this.receiveOperationService, paymentRequestService: this.paymentRequestService, meltOperationService: this.meltOperationService, + mintOperationService: this.mintOperationService, tokenService: this.tokenService, subscriptions: this.subscriptions, eventBus: this.eventBus, @@ -377,9 +389,9 @@ export class Manager { walletRestoreService: this.walletRestoreService, paymentRequestService: this.paymentRequestService, counterService: this.counterService, - mintQuoteService: this.mintQuoteService, meltQuoteService: this.meltQuoteService, meltOperationService: this.meltOperationService, + mintOperationService: this.mintOperationService, historyService: this.historyService, transactionService: this.transactionService, sendOperationService: this.sendOperationService, @@ -404,58 +416,59 @@ export class Manager { return this.eventBus.off(event, handler); } - async enableMintQuoteWatcher(options?: { watchExistingPendingOnStart?: boolean }): Promise { - if (this.mintQuoteWatcher?.isRunning()) return; + async enableMintOperationWatcher(options?: { + watchExistingPendingOnStart?: boolean; + }): Promise { + if (this.mintOperationWatcher?.isRunning()) return; const watcherLogger = this.logger.child - ? this.logger.child({ module: 'MintQuoteWatcherService' }) + ? this.logger.child({ module: 'MintOperationWatcherService' }) : this.logger; - this.mintQuoteWatcher = new MintQuoteWatcherService( - this.mintQuoteRepository, + this.mintOperationWatcher = new MintOperationWatcherService( this.subscriptions, this.mintService, - this.mintQuoteService, + this.mintOperationService, this.eventBus, watcherLogger, { watchExistingPendingOnStart: options?.watchExistingPendingOnStart ?? true }, ); - await this.mintQuoteWatcher.start(); + await this.mintOperationWatcher.start(); } - async disableMintQuoteWatcher(): Promise { - if (!this.mintQuoteWatcher) return; - await this.mintQuoteWatcher.stop(); - this.mintQuoteWatcher = undefined; + async disableMintOperationWatcher(): Promise { + if (!this.mintOperationWatcher) return; + await this.mintOperationWatcher.stop(); + this.mintOperationWatcher = undefined; } - async enableMintQuoteProcessor(options?: { + async enableMintOperationProcessor(options?: { processIntervalMs?: number; maxRetries?: number; baseRetryDelayMs?: number; initialEnqueueDelayMs?: number; }): Promise { - if (this.mintQuoteProcessor?.isRunning()) return false; + if (this.mintOperationProcessor?.isRunning()) return false; const processorLogger = this.logger.child - ? this.logger.child({ module: 'MintQuoteProcessor' }) + ? this.logger.child({ module: 'MintOperationProcessor' }) : this.logger; - this.mintQuoteProcessor = new MintQuoteProcessor( - this.mintQuoteService, + this.mintOperationProcessor = new MintOperationProcessor( + this.mintOperationService, this.eventBus, processorLogger, options, ); - await this.mintQuoteProcessor.start(); + await this.mintOperationProcessor.start(); return true; } - async disableMintQuoteProcessor(): Promise { - if (!this.mintQuoteProcessor) return; - await this.mintQuoteProcessor.stop(); - this.mintQuoteProcessor = undefined; + async disableMintOperationProcessor(): Promise { + if (!this.mintOperationProcessor) return; + await this.mintOperationProcessor.stop(); + this.mintOperationProcessor = undefined; } - async waitForMintQuoteProcessor(): Promise { - if (!this.mintQuoteProcessor) return; - await this.mintQuoteProcessor.waitForCompletion(); + async waitForMintOperationProcessor(): Promise { + if (!this.mintOperationProcessor) return; + await this.mintOperationProcessor.waitForCompletion(); } async enableProofStateWatcher(options?: { @@ -508,6 +521,65 @@ export class Manager { await this.ops.receive.recovery.run(); } + async recoverPendingMintOperations(): Promise { + await this.mintOperationService.recoverPendingOperations(); + } + + async reconcileLegacyMintQuotes(mintUrl?: string): Promise<{ reconciled: string[]; skipped: string[] }> { + const reconciled: string[] = []; + const skipped: string[] = []; + const quotes = await this.mintQuoteRepository.getPendingMintQuotes(); + + for (const quote of quotes) { + if (mintUrl && quote.mintUrl !== mintUrl) continue; + if (quote.state === 'ISSUED') { + skipped.push(quote.quote); + continue; + } + + const trusted = await this.mintService.isTrustedMint(quote.mintUrl); + if (!trusted) { + this.logger.debug('Skipping legacy mint quote reconciliation for untrusted mint', { + mintUrl: quote.mintUrl, + quoteId: quote.quote, + }); + skipped.push(quote.quote); + continue; + } + + const existing = await this.mintOperationService.getOperationByQuote(quote.mintUrl, quote.quote); + if (existing && existing.state !== 'init') { + skipped.push(quote.quote); + continue; + } + + try { + const operation = await this.mintOperationService.importQuote( + quote.mintUrl, + quote, + 'bolt11', + {}, + ); + reconciled.push(operation.quoteId); + } catch (err) { + this.logger.warn('Failed to reconcile legacy mint quote', { + mintUrl: quote.mintUrl, + quoteId: quote.quote, + err, + }); + skipped.push(quote.quote); + } + } + + this.logger.info('Legacy mint quote reconciliation completed', { + mintUrl, + reconciled: reconciled.length, + skipped: skipped.length, + }); + + return { reconciled, skipped }; + } + async pauseSubscriptions(): Promise { if (this.subscriptionsPaused) { this.logger.debug('Subscriptions already paused'); @@ -520,11 +592,11 @@ export class Manager { this.subscriptions.pause(); // Disable watchers - await this.disableMintQuoteWatcher(); + await this.disableMintOperationWatcher(); await this.disableProofStateWatcher(); // Disable processor - await this.disableMintQuoteProcessor(); + await this.disableMintOperationProcessor(); this.logger.info('Subscriptions paused'); await this.eventBus.emit('subscriptions:paused', undefined); @@ -539,9 +611,9 @@ export class Manager { this.subscriptions.resume(); // Re-enable watchers based on original configuration (idempotent) - const mintQuoteWatcherConfig = this.originalWatcherConfig?.mintQuoteWatcher; - if (!mintQuoteWatcherConfig?.disabled) { - await this.enableMintQuoteWatcher(mintQuoteWatcherConfig); + const mintOperationWatcherConfig = this.originalWatcherConfig?.mintOperationWatcher; + if (!mintOperationWatcherConfig?.disabled) { + await this.enableMintOperationWatcher(mintOperationWatcherConfig); } const proofStateWatcherConfig = this.originalWatcherConfig?.proofStateWatcher; @@ -550,15 +622,13 @@ export class Manager { } // Re-enable processor based on original configuration (idempotent) - const mintQuoteProcessorConfig = this.originalProcessorConfig?.mintQuoteProcessor; - if (!mintQuoteProcessorConfig?.disabled) { - const wasEnabled = await this.enableMintQuoteProcessor(mintQuoteProcessorConfig); - // Only requeue if we actually re-enabled (not already running) - if (wasEnabled) { - await this.quotes.requeuePaidMintQuotes(); - } + const mintOperationProcessorConfig = this.originalProcessorConfig?.mintOperationProcessor; + if (!mintOperationProcessorConfig?.disabled) { + await this.enableMintOperationProcessor(mintOperationProcessorConfig); } + await this.recoverPendingMintOperations(); + this.logger.info('Subscriptions resumed'); } @@ -566,6 +636,30 @@ export class Manager { return this.logger.child ? this.logger.child({ module: moduleName }) : this.logger; } + async requeuePaidMintQuotes(mintUrl?: string): Promise<{ requeued: string[] }> { + const requeued: string[] = []; + const pendingOperations = await this.mintOperationService.getPendingOperations(); + + for (const operation of pendingOperations) { + if (mintUrl && operation.mintUrl !== mintUrl) continue; + if (operation.lastObservedRemoteState !== 'PAID') continue; + + const trusted = await this.mintService.isTrustedMint(operation.mintUrl); + if (!trusted) { + continue; + } + + await this.eventBus.emit('mint-op:requeue', { + mintUrl: operation.mintUrl, + operationId: operation.id, + operation, + }); + requeued.push(operation.quoteId); + } + + return { requeued }; + } + private createEventBus(): EventBus { const eventLogger = this.getChildLogger('EventBus'); return new EventBus({ @@ -616,7 +710,6 @@ export class Manager { tokenService: TokenService; walletRestoreService: WalletRestoreService; keyRingService: KeyRingService; - mintQuoteService: MintQuoteService; mintQuoteRepository: MintQuoteRepository; meltQuoteService: MeltQuoteService; historyService: HistoryService; @@ -630,12 +723,13 @@ export class Manager { meltOperationRepository: MeltOperationRepository; authSessionService: AuthSessionService; authService: AuthService; + mintOperationService: MintOperationService; + mintOperationRepository: MintOperationRepository; } { const mintLogger = this.getChildLogger('MintService'); const walletLogger = this.getChildLogger('WalletService'); const counterLogger = this.getChildLogger('CounterService'); const proofLogger = this.getChildLogger('ProofService'); - const mintQuoteLogger = this.getChildLogger('MintQuoteService'); const walletRestoreLogger = this.getChildLogger('WalletRestoreService'); const keyRingLogger = this.getChildLogger('KeyRingService'); const meltQuoteLogger = this.getChildLogger('MeltQuoteService'); @@ -684,17 +778,6 @@ export class Manager { walletRestoreLogger, ); - const quotesService = new MintQuoteService( - repositories.mintQuoteRepository, - mintService, - walletService, - proofService, - this.eventBus, - mintQuoteLogger, - ); - const mintQuoteService = quotesService; - const mintQuoteRepository = repositories.mintQuoteRepository; - const meltQuoteService = new MeltQuoteService( mintService, proofService, @@ -773,6 +856,26 @@ export class Manager { ); const meltOperationRepository = repositories.meltOperationRepository; + const mintOperationLogger = this.getChildLogger('MintOperationService'); + const mintHandlerProvider = new MintHandlerProvider({ + bolt11: new MintBolt11Handler(), + }); + const mintOperationService = new MintOperationService( + mintHandlerProvider, + repositories.mintOperationRepository, + repositories.proofRepository, + proofService, + mintService, + walletService, + this.mintAdapter, + this.eventBus, + mintOperationLogger, + mintScopedLock, + ); + const mintOperationRepository = repositories.mintOperationRepository; + + const mintQuoteRepository = repositories.mintQuoteRepository; + const paymentRequestLogger = this.getChildLogger('PaymentRequestService'); const paymentRequestService = new PaymentRequestService( sendOperationService, @@ -799,7 +902,6 @@ export class Manager { tokenService, walletRestoreService, keyRingService, - mintQuoteService, mintQuoteRepository, meltQuoteService, historyService, @@ -813,6 +915,8 @@ export class Manager { meltOperationRepository, authSessionService, authService, + mintOperationService, + mintOperationRepository, }; } @@ -844,7 +948,6 @@ export class Manager { walletApiLogger, ); const quotes = new QuotesApi( - this.mintQuoteService, this.meltQuoteService, this.meltOperationService, ); @@ -853,8 +956,9 @@ export class Manager { const history = new HistoryApi(this.historyService); const send = new SendOpsApi(this.sendOperationService); const receive = new ReceiveOpsApi(this.receiveOperationService); + const mintOps = new MintOpsApi(this.mintOperationService); const melt = new MeltOpsApi(this.meltOperationService); - const ops = new OpsApi(send, receive, melt); + const ops = new OpsApi(send, receive, mintOps, melt); const auth = new AuthApi(this.authService); return { mint, wallet, quotes, keyring, subscription, history, ops, auth, send, receive }; } diff --git a/packages/core/README.md b/packages/core/README.md index 0c0d128a..c2a94670 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -69,17 +69,22 @@ const unsubscribe = manager.on('counter:updated', (c) => { // Register a mint await manager.mint.addMint('https://nofees.testnut.cashu.space'); -// Create a mint quote, pay externally, then redeem -const mintQuote = await manager.quotes.createMintQuote('https://nofees.testnut.cashu.space', 100); +// Create a mint operation, pay externally, then redeem +const pendingMint = await manager.ops.mint.prepare({ + mintUrl: 'https://nofees.testnut.cashu.space', + amount: 100, + method: 'bolt11', + methodData: {}, +}); // Optionally, wait via subscription API instead of polling await manager.subscription.awaitMintQuotePaid( 'https://nofees.testnut.cashu.space', - mintQuote.quote, + pendingMint.quoteId, ); -// pay mintQuote.request externally, then: -await manager.quotes.redeemMintQuote('https://nofees.testnut.cashu.space', mintQuote.quote); +// pay pendingMint.request externally, then: +await manager.ops.mint.execute(pendingMint.id); // Check balances const balances = await manager.wallet.getBalances(); @@ -91,18 +96,18 @@ console.log('balances', balances); Start background watchers or processors to automatically react to changes: ```ts -// Watch mint quote updates and auto-redeem previously pending ones on start (default true) -await manager.enableMintQuoteWatcher({ watchExistingPendingOnStart: true }); +// Watch mint operation quote updates on startup and while running (default true) +await manager.enableMintOperationWatcher({ watchExistingPendingOnStart: true }); -// Process queued mint quotes (auto-enabled by initializeCoco) -await manager.enableMintQuoteProcessor({ processIntervalMs: 3000 }); +// Process queued mint operations from live events (auto-enabled by initializeCoco) +await manager.enableMintOperationProcessor({ processIntervalMs: 3000 }); // Watch proof state updates (e.g., to move inflight proofs to spent) await manager.enableProofStateWatcher(); // Later, you can stop them -await manager.disableMintQuoteWatcher(); -await manager.disableMintQuoteProcessor(); +await manager.disableMintOperationWatcher(); +await manager.disableMintOperationProcessor(); await manager.disableProofStateWatcher(); ``` @@ -115,8 +120,8 @@ await manager.disableProofStateWatcher(); - `logger`: optional logger (defaults to `NullLogger`) - `webSocketFactory`: optional WebSocket factory - `plugins`: optional plugin list -- `watchers`: enable/disable watcher services (`mintQuoteWatcher`, `proofStateWatcher`) -- `processors`: enable/disable processors (`mintQuoteProcessor`) and tune intervals +- `watchers`: enable/disable watcher services (`mintOperationWatcher`, `proofStateWatcher`) +- `processors`: enable/disable processors (`mintOperationProcessor`) and tune intervals - `subscriptions`: polling intervals for hybrid WebSocket + polling (`slowPollingIntervalMs`, `fastPollingIntervalMs`) If you prefer manual wiring, construct `Manager` directly and call `initPlugins()` before enabling watchers/processors. @@ -127,7 +132,7 @@ If you prefer manual wiring, construct `Manager` directly and call `initPlugins( - `MintService`: Fetches `mintInfo`, keysets and persists via repositories. - `WalletService`: Caches and constructs `Wallet` from stored keysets. - `ProofService`: Manages proofs, selection, states, and counters. -- `MintQuoteService`: Creates and redeems mint quotes. +- Legacy mint quote orchestration has been replaced by `MintOperationService` and `manager.ops.mint`. - `MeltQuoteService`: Creates and pays melt quotes (spend via Lightning). - `CounterService`: Simple per-(mint,keyset) numeric counter with events. - `EventBus`: Lightweight typed pub/sub used internally (includes `subscriptions:paused` and `subscriptions:resumed`). @@ -164,11 +169,11 @@ In-memory reference implementations are provided under `repositories/memory/` fo - `receive: ReceiveOpsApi` (deprecated alias of `manager.ops.receive`) - `ext: PluginExtensions` - `on/once/off` for `CoreEvents` -- `enableMintQuoteWatcher(options?: { watchExistingPendingOnStart?: boolean }): Promise` -- `disableMintQuoteWatcher(): Promise` -- `enableMintQuoteProcessor(options?: { processIntervalMs?: number; maxRetries?: number; baseRetryDelayMs?: number; initialEnqueueDelayMs?: number }): Promise` -- `disableMintQuoteProcessor(): Promise` -- `waitForMintQuoteProcessor(): Promise` +- `enableMintOperationWatcher(options?: { watchExistingPendingOnStart?: boolean }): Promise` +- `disableMintOperationWatcher(): Promise` +- `enableMintOperationProcessor(options?: { processIntervalMs?: number; maxRetries?: number; baseRetryDelayMs?: number; initialEnqueueDelayMs?: number }): Promise` +- `disableMintOperationProcessor(): Promise` +- `waitForMintOperationProcessor(): Promise` - `enableProofStateWatcher(): Promise` - `disableProofStateWatcher(): Promise` - `pauseSubscriptions(): Promise` @@ -218,6 +223,18 @@ In-memory reference implementations are provided under `repositories/memory/` fo - `melt.recovery.run(): Promise` - `melt.recovery.inProgress(): boolean` - `melt.diagnostics.isLocked(operationId): boolean` +- `mint.prepare({ mintUrl, quoteId, method: 'bolt11', methodData: {} }): Promise` +- `mint.execute(operationOrId): Promise` +- `mint.get(operationId): Promise` +- `mint.getByQuote(mintUrl, quoteId): Promise` +- `mint.listPending(): Promise` +- `mint.listInFlight(): Promise` +- `mint.checkPayment(operationId): Promise` +- `mint.refresh(operationId): Promise` +- `mint.finalize(operationId): Promise` +- `mint.recovery.run(): Promise` +- `mint.recovery.inProgress(): boolean` +- `mint.diagnostics.isLocked(operationId): boolean` ### MintApi @@ -242,15 +259,15 @@ In-memory reference implementations are provided under `repositories/memory/` fo ### QuotesApi -- `createMintQuote(mintUrl: string, amount: number): Promise` -- `redeemMintQuote(mintUrl: string, quoteId: string): Promise` - `prepareMeltBolt11(mintUrl: string, invoice: string): Promise` (deprecated) - `executeMelt(operationId: string): Promise` (deprecated) - `executeMeltByQuote(mintUrl: string, quoteId: string): Promise` (deprecated) - `checkPendingMelt(operationId: string): Promise` (deprecated) - `checkPendingMeltByQuote(mintUrl: string, quoteId: string): Promise` (deprecated) -- `addMintQuote(mintUrl: string, quotes: MintQuoteResponse[]): Promise<{ added: string[]; skipped: string[] }>` -- `requeuePaidMintQuotes(mintUrl?: string): Promise<{ requeued: string[] }>` +- `rollbackMelt(operationId: string, reason?: string): Promise` +- `getMeltOperation(operationId: string): Promise` +- `getPendingMeltOperations(): Promise` +- `getPreparedMeltOperations(): Promise` ### SubscriptionApi @@ -301,11 +318,11 @@ In-memory reference implementations are provided under `repositories/memory/` fo - `proofs:wiped` → `{ mintUrl, keysetId }` - `proofs:reserved` → `{ mintUrl, operationId, secrets, amount }` - `proofs:released` → `{ mintUrl, secrets }` -- `mint-quote:state-changed` → `{ mintUrl, quoteId, state }` -- `mint-quote:created` → `{ mintUrl, quoteId, quote }` -- `mint-quote:added` → `{ mintUrl, quoteId, quote }` -- `mint-quote:requeue` → `{ mintUrl, quoteId }` -- `mint-quote:redeemed` → `{ mintUrl, quoteId, quote }` +- `mint-op:pending` → `{ mintUrl, operationId, operation }` +- `mint-op:quote-state-changed` → `{ mintUrl, operationId, operation, quoteId, state }` +- `mint-op:requeue` → `{ mintUrl, operationId, operation }` +- `mint-op:executing` → `{ mintUrl, operationId, operation }` +- `mint-op:finalized` → `{ mintUrl, operationId, operation }` - `melt-quote:created` → `{ mintUrl, quoteId, quote }` - `melt-quote:state-changed` → `{ mintUrl, quoteId, state }` - `melt-quote:paid` → `{ mintUrl, quoteId, quote }` @@ -335,7 +352,7 @@ import type { Plugin, ServiceKey } from 'coco-cashu-core'; // Service keys you can request: // 'mintService' | 'walletService' | 'proofService' | 'seedService' | 'walletRestoreService' -// 'counterService' | 'mintQuoteService' | 'meltQuoteService' | 'historyService' +// 'counterService' | 'meltQuoteService' | 'historyService' // 'subscriptions' | 'eventBus' | 'logger' const myPlugin: Plugin<['eventBus', 'logger']> = { diff --git a/packages/core/api/MintOpsApi.ts b/packages/core/api/MintOpsApi.ts index 0aa96844..c320dd21 100644 --- a/packages/core/api/MintOpsApi.ts +++ b/packages/core/api/MintOpsApi.ts @@ -1,76 +1,44 @@ -import type { MintQuoteBolt11Response } from '@cashu/cashu-ts'; - -export interface PrepareMintInput { - /** Mint that will issue the quote-backed mint operation. */ - mintUrl: string; - /** Amount to mint in sats. */ - amount: number; -} - -export interface AwaitMintPaymentInput { - /** Managed operation ID to wait on. */ - operationId?: string; - /** Mint URL for quote lookup when no operation ID is available. */ - mintUrl?: string; - /** Quote ID for quote lookup when no operation ID is available. */ - quoteId?: string; -} - -export interface ImportMintQuotesInput { - /** Mint that created the external quotes. */ - mintUrl: string; - /** Quote payloads to import into managed operations. */ - quotes: MintQuoteBolt11Response[]; -} - -export interface RequeuePaidMintQuotesInput { - /** Optional mint filter for paid quotes to requeue. */ - mintUrl?: string; -} - -export type MintOperationState = 'prepared' | 'pending' | 'finalized' | 'failed' | 'rolled_back'; - -interface MintOperationBase { - /** Unique identifier for this operation. */ - id: string; - /** Mint URL associated with the quote-backed operation. */ - mintUrl: string; - /** Timestamp when the operation was created. */ - createdAt: number; - /** Timestamp when the operation was last updated. */ - updatedAt: number; - /** Error message if the operation failed. */ - error?: string; - /** Full mint quote payload backing the operation. */ - quote: MintQuoteBolt11Response; -} - -export interface PreparedMintOperation extends MintOperationBase { - state: 'prepared'; -} - -export interface PendingMintOperation extends MintOperationBase { - state: 'pending'; -} - -export interface FinalizedMintOperation extends MintOperationBase { - state: 'finalized'; -} - -export interface FailedMintOperation extends MintOperationBase { - state: 'failed'; -} - -export interface RolledBackMintOperation extends MintOperationBase { - state: 'rolled_back'; -} - -export type MintOperation = - | PreparedMintOperation - | PendingMintOperation - | FinalizedMintOperation - | FailedMintOperation - | RolledBackMintOperation; +import type { + MintMethod, + MintMethodData, + MintMethodQuoteSnapshot, + MintOperation, + MintOperationService, + PendingMintCheckResult, + PendingMintOperation, + TerminalMintOperation, +} from '@core/operations/mint'; + +/** Mint methods supported by the default `Manager` wiring. */ +export type DefaultSupportedMintMethod = 'bolt11'; + +export type PrepareMintInput = { + [M in TSupported]: { + /** Mint that will execute the quote-backed mint operation. */ + mintUrl: string; + /** Amount to request from the mint. */ + amount: number; + /** Unit to request from the mint. Only `sat` is currently supported. */ + unit?: 'sat'; + /** Mint method to prepare, for example `bolt11`. */ + method: M; + /** Method-specific payload required for the selected mint method. */ + methodData: MintMethodData; + }; +}[TSupported]; + +export type ImportMintQuoteInput = { + [M in TSupported]: { + /** Mint that issued the existing quote. */ + mintUrl: string; + /** Existing quote snapshot to track as an operation. */ + quote: MintMethodQuoteSnapshot; + /** Mint method to prepare, for example `bolt11`. */ + method: M; + /** Method-specific payload required for the selected mint method. */ + methodData: MintMethodData; + }; +}[TSupported]; export interface MintRecoveryApi { /** Runs the startup-style recovery sweep for mint operations. */ @@ -85,71 +53,150 @@ export interface MintDiagnosticsApi { } /** - * Operation-oriented API shell for quote-backed mint workflows. + * Operation-oriented API for quote-backed mint workflows. + * + * This API makes the mint lifecycle explicit so callers can prepare a quote, + * move it into a durable pending state, execute it, and inspect its progress. */ -export class MintOpsApi { +export class MintOpsApi { + /** Recovery helpers for mint operations. */ readonly recovery: MintRecoveryApi = { - run: async () => { - throw this.notImplemented(); - }, - inProgress: () => false, + run: async () => this.mintOperationService.recoverPendingOperations(), + inProgress: () => this.mintOperationService.isRecoveryInProgress(), }; + /** Lightweight diagnostics for mint operations. */ readonly diagnostics: MintDiagnosticsApi = { - isLocked: () => false, + isLocked: (operationId: string) => this.mintOperationService.isOperationLocked(operationId), }; - async prepare(_input: PrepareMintInput): Promise { - throw this.notImplemented(); + constructor(private readonly mintOperationService: MintOperationService) {} + + private assertSupportedUnit(unit: string): void { + if (unit !== 'sat') { + throw new Error(`Unsupported mint unit '${unit}'. Only 'sat' is currently supported.`); + } } - async execute( - _operationOrId: MintOperation | string, - ): Promise { - throw this.notImplemented(); + /** + * Creates a new remote quote, then persists a prepared mint operation without executing it. + */ + async prepare(input: PrepareMintInput): Promise { + const unit = input.unit ?? 'sat'; + this.assertSupportedUnit(unit); + + return this.mintOperationService.prepareNewQuote( + input.mintUrl, + input.amount, + unit, + input.method, + input.methodData, + ); } - async get(_operationId: string): Promise { - throw this.notImplemented(); + /** + * Imports an existing quote snapshot into a prepared mint operation without executing it. + */ + async importQuote(input: ImportMintQuoteInput): Promise { + this.assertSupportedUnit(input.quote.unit); + + return this.mintOperationService.importQuote( + input.mintUrl, + input.quote, + input.method, + input.methodData, + ); } - async getByQuote(_mintUrl: string, _quoteId: string): Promise { - throw this.notImplemented(); + /** + * Executes a pending mint operation and returns its terminal state. + */ + async execute(operationOrId: MintOperation | string): Promise { + const operation = await this.resolveOperation(operationOrId); + if (operation.state !== 'pending') { + throw new Error( + `Cannot execute operation in state '${operation.state}'. Expected 'pending'.`, + ); + } + + return this.mintOperationService.execute(operation.id); } - async listPrepared(): Promise { - throw this.notImplemented(); + /** Returns a mint operation by ID, or `null` when it does not exist. */ + async get(operationId: string): Promise { + return this.mintOperationService.getOperation(operationId); } - async listInFlight(): Promise { - throw this.notImplemented(); + /** Returns a mint operation by mint URL and quote ID, or `null` if not found. */ + async getByQuote(mintUrl: string, quoteId: string): Promise { + return this.mintOperationService.getOperationByQuote(mintUrl, quoteId); } - async refresh(_operationId: string): Promise { - throw this.notImplemented(); + /** Lists mint operations that are pending redemption or remote settlement. */ + async listPending(): Promise { + return this.mintOperationService.getPendingOperations(); } - async cancel(_operationId: string, _reason?: string): Promise { - throw this.notImplemented(); + /** Lists mint operations that are pending or currently executing. */ + async listInFlight(): Promise { + return this.mintOperationService.getInFlightOperations(); } - async awaitPayment(_input: AwaitMintPaymentInput): Promise { - throw this.notImplemented(); + /** + * Checks the remote quote state for a pending mint operation. + * Paid or issued quotes are reconciled immediately. + */ + async checkPayment(operationId: string): Promise { + const operation = await this.requireOperation(operationId); + if (operation.state !== 'pending') { + throw new Error(`Cannot check payment in state '${operation.state}'. Expected 'pending'.`); + } + + return this.mintOperationService.checkPendingOperation(operation.id); } - async importQuotes( - _input: ImportMintQuotesInput, - ): Promise<{ added: string[]; skipped: string[] }> { - throw this.notImplemented(); + /** + * Re-checks a mint operation and returns its latest persisted state. + */ + async refresh(operationId: string): Promise { + const operation = await this.requireOperation(operationId); + if (operation.state === 'pending') { + await this.mintOperationService.checkPendingOperation(operation.id); + return this.requireOperation(operationId); + } + + if (operation.state === 'executing') { + await this.mintOperationService.recoverExecutingOperation(operation); + return this.requireOperation(operationId); + } + + return operation; } - async requeuePaid( - _input?: RequeuePaidMintQuotesInput, - ): Promise<{ requeued: string[] }> { - throw this.notImplemented(); + /** + * Attempts to finalize a mint operation explicitly. + * + * Pending operations are executed, executing operations are recovered, + * and terminal operations are returned as-is. + */ + async finalize(operationId: string): Promise { + return this.mintOperationService.finalize(operationId); } - private notImplemented(): Error { - return new Error('Mint operation workflow is not available.'); + private async resolveOperation(operationOrId: MintOperation | string): Promise { + if (typeof operationOrId === 'string') { + return this.requireOperation(operationOrId); + } + + return this.requireOperation(operationOrId.id); + } + + private async requireOperation(operationId: string): Promise { + const operation = await this.mintOperationService.getOperation(operationId); + if (!operation) { + throw new Error(`Operation ${operationId} not found`); + } + + return operation; } } diff --git a/packages/core/api/OpsApi.ts b/packages/core/api/OpsApi.ts index bda77d52..b36fc815 100644 --- a/packages/core/api/OpsApi.ts +++ b/packages/core/api/OpsApi.ts @@ -1,4 +1,4 @@ -// import type { MintOpsApi } from './MintOpsApi'; +import type { MintOpsApi } from './MintOpsApi'; import type { MeltOpsApi } from './MeltOpsApi'; import type { ReceiveOpsApi } from './ReceiveOpsApi'; import type { SendOpsApi } from './SendOpsApi'; @@ -22,14 +22,15 @@ export class OpsApi { * recovering token receives. */ readonly receive: ReceiveOpsApi, + /** + * Mint operations for preparing, executing, inspecting, and recovering + * quote-backed mint flows. + */ + readonly mint: MintOpsApi, /** * Melt operations for preparing, executing, inspecting, refreshing, and * recovering outbound payment flows such as bolt11 melts. */ readonly melt: MeltOpsApi, - // /** - // * Mint operations for quote-backed minting workflows. - // */ - // readonly mint: MintOpsApi, ) {} } diff --git a/packages/core/api/QuotesApi.ts b/packages/core/api/QuotesApi.ts index a753d38d..d69d3bf3 100644 --- a/packages/core/api/QuotesApi.ts +++ b/packages/core/api/QuotesApi.ts @@ -1,4 +1,4 @@ -import type { MeltQuoteBolt11Response, MintQuoteBolt11Response } from '@cashu/cashu-ts'; +import type { MeltQuoteBolt11Response } from '@cashu/cashu-ts'; import type { FinalizedMeltOperation, MeltOperation, @@ -7,30 +7,19 @@ import type { PendingCheckResult, PreparedMeltOperation, } from '@core/operations/melt'; -import type { MintQuoteService, MeltQuoteService } from '@core/services'; +import type { MeltQuoteService } from '@core/services'; export class QuotesApi { - private mintQuoteService: MintQuoteService; private meltQuoteService: MeltQuoteService; private meltOperationService: MeltOperationService; constructor( - mintQuoteService: MintQuoteService, meltQuoteService: MeltQuoteService, meltOperationService: MeltOperationService, ) { - this.mintQuoteService = mintQuoteService; this.meltQuoteService = meltQuoteService; this.meltOperationService = meltOperationService; } - async createMintQuote(mintUrl: string, amount: number): Promise { - return this.mintQuoteService.createMintQuote(mintUrl, amount); - } - - async redeemMintQuote(mintUrl: string, quoteId: string): Promise { - return this.mintQuoteService.redeemMintQuote(mintUrl, quoteId); - } - /** * Create a bolt11 melt quote. * @deprecated Use `manager.ops.melt.prepare({ mintUrl, method: 'bolt11', methodData: { invoice } })` instead. @@ -150,15 +139,4 @@ export class QuotesApi { async getPreparedMeltOperations(): Promise { return this.meltOperationService.getPreparedOperations(); } - - async addMintQuote( - mintUrl: string, - quotes: MintQuoteBolt11Response[], - ): Promise<{ added: string[]; skipped: string[] }> { - return this.mintQuoteService.addExistingMintQuotes(mintUrl, quotes); - } - - async requeuePaidMintQuotes(mintUrl?: string): Promise<{ requeued: string[] }> { - return this.mintQuoteService.requeuePaidMintQuotes(mintUrl); - } } diff --git a/packages/core/api/index.ts b/packages/core/api/index.ts index ca239195..c7565552 100644 --- a/packages/core/api/index.ts +++ b/packages/core/api/index.ts @@ -10,5 +10,5 @@ export * from './ReceiveApi.ts'; export * from './SendOpsApi.ts'; export * from './ReceiveOpsApi.ts'; export * from './MeltOpsApi.ts'; -// export * from './MintOpsApi.ts'; +export * from './MintOpsApi.ts'; export * from './OpsApi.ts'; diff --git a/packages/core/events/types.ts b/packages/core/events/types.ts index 419ae6e0..6da0ff62 100644 --- a/packages/core/events/types.ts +++ b/packages/core/events/types.ts @@ -1,8 +1,6 @@ import type { MeltQuoteBolt11Response, MeltQuoteState, - MintQuoteBolt11Response, - MintQuoteState, Token, } from '@cashu/cashu-ts'; import type { MeltOperation } from '@core/operations/melt'; @@ -10,8 +8,10 @@ import type { Counter } from '../models/Counter'; import type { HistoryEntry } from '../models/History'; import type { Keyset } from '../models/Keyset'; import type { Mint } from '../models/Mint'; +import type { MintQuoteState } from '../models/MintQuoteState'; import type { SendOperation } from '../operations/send/SendOperation'; import type { CoreProof, ProofState } from '../types'; +import type { MintOperation } from '@core/operations/mint'; export interface CoreEvents { 'mint:added': { mint: Mint; keysets: Keyset[] }; @@ -29,15 +29,6 @@ export interface CoreEvents { 'proofs:wiped': { mintUrl: string; keysetId: string }; 'proofs:reserved': { mintUrl: string; operationId: string; secrets: string[]; amount: number }; 'proofs:released': { mintUrl: string; secrets: string[] }; - 'mint-quote:state-changed': { mintUrl: string; quoteId: string; state: MintQuoteState }; - 'mint-quote:created': { mintUrl: string; quoteId: string; quote: MintQuoteBolt11Response }; - 'mint-quote:added': { - mintUrl: string; - quoteId: string; - quote: MintQuoteBolt11Response; - }; - 'mint-quote:requeue': { mintUrl: string; quoteId: string }; - 'mint-quote:redeemed': { mintUrl: string; quoteId: string; quote: MintQuoteBolt11Response }; 'melt-quote:created': { mintUrl: string; quoteId: string; quote: MeltQuoteBolt11Response }; 'melt-quote:state-changed': { mintUrl: string; quoteId: string; state: MeltQuoteState }; 'melt-quote:paid': { mintUrl: string; quoteId: string; quote: MeltQuoteBolt11Response }; @@ -57,6 +48,17 @@ export interface CoreEvents { 'melt-op:pending': { mintUrl: string; operationId: string; operation: MeltOperation }; 'melt-op:finalized': { mintUrl: string; operationId: string; operation: MeltOperation }; 'melt-op:rolled-back': { mintUrl: string; operationId: string; operation: MeltOperation }; + 'mint-op:pending': { mintUrl: string; operationId: string; operation: MintOperation }; + 'mint-op:quote-state-changed': { + mintUrl: string; + operationId: string; + operation: MintOperation; + quoteId: string; + state: MintQuoteState; + }; + 'mint-op:requeue': { mintUrl: string; operationId: string; operation: MintOperation }; + 'mint-op:executing': { mintUrl: string; operationId: string; operation: MintOperation }; + 'mint-op:finalized': { mintUrl: string; operationId: string; operation: MintOperation }; 'subscriptions:paused': void; 'subscriptions:resumed': void; 'auth-session:updated': { mintUrl: string }; diff --git a/packages/core/infra/MintAdapter.ts b/packages/core/infra/MintAdapter.ts index 6198ed05..239c8046 100644 --- a/packages/core/infra/MintAdapter.ts +++ b/packages/core/infra/MintAdapter.ts @@ -8,6 +8,7 @@ import { type MeltQuoteBolt12Response, type GetKeysetsResponse, type AuthProvider, + type MintQuoteBolt11Response, } from '@cashu/cashu-ts'; import type { MintInfo } from '../types'; import type { MintRequestProvider } from './MintRequestProvider.ts'; @@ -74,7 +75,7 @@ export class MintAdapter { } // Check current state of a bolt11 mint quote - async checkMintQuoteState(mintUrl: string, quoteId: string): Promise { + async checkMintQuoteState(mintUrl: string, quoteId: string): Promise { const cashuMint = this.getCashuMint(mintUrl); return await cashuMint.checkMintQuoteBolt11(quoteId); } diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts new file mode 100644 index 00000000..7fe65e9a --- /dev/null +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -0,0 +1,217 @@ +import type { + ExecuteContext, + MintMethodMeta, + PrepareContext, + MintMethodHandler, + MintExecutionResult, + PendingMintOperation, + RecoverExecutingResult, + RecoverExecutingContext, + PendingContext, + PendingMintCheckResult, +} from '@core/operations/mint'; +import { MintOperationError } from '../../../models/Error'; +import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; +import type { MintQuoteBolt11Response } from '@cashu/cashu-ts'; + +export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { + async prepare( + ctx: PrepareContext<'bolt11'>, + ): Promise & MintMethodMeta<'bolt11'>> { + const quote = ctx.importedQuote ?? (await ctx.wallet.createMintQuoteBolt11(ctx.operation.amount)); + + if (!quote.amount || quote.amount <= 0) { + throw new Error(`Mint quote ${quote.quote} has invalid amount`); + } + + if (quote.amount !== ctx.operation.amount) { + throw new Error( + `Mint quote ${quote.quote} amount ${quote.amount} does not match requested amount ${ctx.operation.amount}`, + ); + } + + if (quote.unit !== ctx.operation.unit) { + throw new Error( + `Mint quote ${quote.quote} unit ${quote.unit} does not match requested unit ${ctx.operation.unit}`, + ); + } + + const outputData = await ctx.proofService.createOutputsAndIncrementCounters( + ctx.operation.mintUrl, + { + keep: quote.amount, + send: 0, + }, + ); + + if (outputData.keep.length === 0) { + throw new Error('Failed to create deterministic outputs for mint operation'); + } + + return { + ...ctx.operation, + quoteId: quote.quote, + amount: quote.amount, + unit: quote.unit, + request: quote.request, + expiry: quote.expiry, + pubkey: quote.pubkey, + lastObservedRemoteState: quote.state, + lastObservedRemoteStateAt: Date.now(), + outputData: serializeOutputData({ keep: outputData.keep, send: [] }), + state: 'pending', + }; + } + + async execute(ctx: ExecuteContext<'bolt11'>): Promise { + const outputData = deserializeOutputData(ctx.operation.outputData); + + try { + const proofs = await ctx.wallet.mintProofsBolt11( + ctx.operation.amount, + ctx.operation.quoteId, + undefined, + { + type: 'custom', + data: outputData.keep, + }, + ); + + return { status: 'ISSUED', proofs }; + } catch (err) { + if (err instanceof MintOperationError && err.code === 20002) { + return { status: 'ALREADY_ISSUED' }; + } + throw err; + } + } + + async recoverExecuting(ctx: RecoverExecutingContext<'bolt11'>): Promise { + const { mintUrl, quoteId } = ctx.operation; + let remoteQuote: MintQuoteBolt11Response; + try { + remoteQuote = await ctx.mintAdapter.checkMintQuoteState(mintUrl, quoteId); + } catch (error) { + ctx.logger?.warn('Failed to check mint quote state during recovery', { + mintUrl, + quoteId, + error: error instanceof Error ? error.message : String(error), + }); + return { + status: 'PENDING', + error: error instanceof Error ? error.message : String(error), + }; + } + + if (remoteQuote.state === 'PAID') { + const outputData = deserializeOutputData(ctx.operation.outputData); + try { + const proofs = await ctx.wallet.mintProofsBolt11( + ctx.operation.amount, + ctx.operation.quoteId, + undefined, + { + type: 'custom', + data: outputData.keep, + }, + ); + + await ctx.proofService.saveProofs( + ctx.operation.mintUrl, + mapProofToCoreProof(ctx.operation.mintUrl, 'ready', proofs, { + createdByOperationId: ctx.operation.id, + }), + ); + + return { status: 'FINALIZED' }; + } catch (err) { + if (err instanceof MintOperationError) { + if (err.code === 20002) { + // Quote already issued; fall through to proof recovery + } else if (err.code === 20007) { + return { + status: 'TERMINAL', + error: `Recovered: quote ${quoteId} expired while executing mint`, + }; + } else { + return { + status: 'PENDING', + error: err.message, + }; + } + } else { + return { + status: 'PENDING', + error: err instanceof Error ? err.message : String(err), + }; + } + } + } else if (remoteQuote.state === 'UNPAID') { + return { + status: 'PENDING', + error: `Recovered: quote ${quoteId} is still UNPAID`, + }; + } else if (remoteQuote.state !== 'ISSUED') { + return { + status: 'PENDING', + error: `Recovered: quote ${quoteId} remains in remote state ${remoteQuote.state}`, + }; + } + + try { + const recovered = await ctx.proofService.recoverProofsFromOutputData( + ctx.operation.mintUrl, + ctx.operation.outputData, + { + createdByOperationId: ctx.operation.id, + }, + ); + if (recovered.length === 0) { + return { + status: 'PENDING', + error: `Recovered: quote ${quoteId} issued remotely but proofs were not recoverable`, + }; + } + return { status: 'FINALIZED' }; + } catch (error) { + return { + status: 'PENDING', + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async checkPending(ctx: PendingContext<'bolt11'>): Promise { + const { mintUrl, quoteId } = ctx.operation; + ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteId }); + + const quote = await ctx.mintAdapter.checkMintQuoteState(mintUrl, quoteId); + ctx.logger?.info('Pending mint quote state', { mintUrl, quoteId, state: quote.state }); + const observedRemoteStateAt = Date.now(); + + switch (quote.state) { + case 'UNPAID': + return { + observedRemoteState: quote.state, + observedRemoteStateAt, + category: 'waiting', + }; + case 'PAID': + return { + observedRemoteState: quote.state, + observedRemoteStateAt, + category: 'ready', + }; + case 'ISSUED': + return { + observedRemoteState: quote.state, + observedRemoteStateAt, + category: 'completed', + }; + default: + throw new Error( + `Unexpected mint quote state: ${quote.state} for quote ${quoteId} at mint ${mintUrl}`, + ); + } + } +} diff --git a/packages/core/infra/handlers/mint/MintHandlerProvider.ts b/packages/core/infra/handlers/mint/MintHandlerProvider.ts new file mode 100644 index 00000000..8d3d6254 --- /dev/null +++ b/packages/core/infra/handlers/mint/MintHandlerProvider.ts @@ -0,0 +1,42 @@ +import type { + MintMethod, + MintMethodHandler, + MintMethodHandlerRegistry, +} from '../../../operations/mint/MintMethodHandler'; + +/** + * Runtime registry for mint method handlers. + */ +export class MintHandlerProvider { + private registry: Partial = {}; + + constructor(initialHandlers?: Partial) { + if (initialHandlers) { + this.registerMany(initialHandlers); + } + } + + register(method: M, handler: MintMethodHandler): void { + this.registry[method] = handler; + } + + registerMany(handlers: Partial): void { + for (const [method, handler] of Object.entries(handlers)) { + if (handler) { + this.registry[method as MintMethod] = handler; + } + } + } + + get(method: M): MintMethodHandler { + const handler = this.registry[method]; + if (!handler) { + throw new Error(`No mint handler registered for method ${method}`); + } + return handler as MintMethodHandler; + } + + getAll(): MintMethodHandlerRegistry { + return this.registry as MintMethodHandlerRegistry; + } +} diff --git a/packages/core/infra/handlers/mint/index.ts b/packages/core/infra/handlers/mint/index.ts new file mode 100644 index 00000000..4b2688f6 --- /dev/null +++ b/packages/core/infra/handlers/mint/index.ts @@ -0,0 +1,2 @@ +export * from './MintHandlerProvider'; +export * from './MintBolt11Handler'; diff --git a/packages/core/infra/index.ts b/packages/core/infra/index.ts index f7097539..822d6af2 100644 --- a/packages/core/infra/index.ts +++ b/packages/core/infra/index.ts @@ -10,3 +10,4 @@ export * from './HybridTransport'; export * from './SubscriptionProtocol'; export * from './handlers/melt'; export * from './handlers/send'; +export * from './handlers/mint'; diff --git a/packages/core/models/History.ts b/packages/core/models/History.ts index ca65eb84..7412d387 100644 --- a/packages/core/models/History.ts +++ b/packages/core/models/History.ts @@ -1,4 +1,5 @@ -import type { MeltQuoteState, MintQuoteState, Token } from '@cashu/cashu-ts'; +import type { MeltQuoteState, Token } from '@cashu/cashu-ts'; +import type { MintQuoteState } from './MintQuoteState'; type BaseHistoryEntry = { id: string; diff --git a/packages/core/models/MintQuoteState.ts b/packages/core/models/MintQuoteState.ts new file mode 100644 index 00000000..437d4b21 --- /dev/null +++ b/packages/core/models/MintQuoteState.ts @@ -0,0 +1 @@ +export type MintQuoteState = 'UNPAID' | 'PAID' | 'ISSUED'; diff --git a/packages/core/models/index.ts b/packages/core/models/index.ts index cb6066e4..f0d0d648 100644 --- a/packages/core/models/index.ts +++ b/packages/core/models/index.ts @@ -7,3 +7,4 @@ export * from './Keyset'; export * from './MeltQuote'; export * from './Mint'; export * from './MintQuote'; +export * from './MintQuoteState'; diff --git a/packages/core/operations/index.ts b/packages/core/operations/index.ts index d314090b..1c203d10 100644 --- a/packages/core/operations/index.ts +++ b/packages/core/operations/index.ts @@ -4,10 +4,21 @@ export type { } from './melt/MeltOperation.ts'; export type { MeltMethod, MeltMethodData } from './melt/MeltMethodHandler.ts'; export { MeltOperationService } from './melt/MeltOperationService.ts'; +export type { + MintOperation, + MintOperationState, +} from './mint/MintOperation.ts'; +export type { + MintMethod, + MintMethodData, + MintMethodRemoteState, + PendingMintCheckCategory, + PendingMintCheckResult, +} from './mint/MintMethodHandler.ts'; +export { MintOperationService } from './mint/MintOperationService.ts'; export * from './send'; export type { ReceiveOperation, ReceiveOperationState, } from './receive/ReceiveOperation.ts'; export { ReceiveOperationService } from './receive/ReceiveOperationService.ts'; - diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts new file mode 100644 index 00000000..3a9bcea7 --- /dev/null +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -0,0 +1,111 @@ +import type { MintQuoteBolt11Response, Proof, Wallet } from '@cashu/cashu-ts'; +import type { ProofRepository } from '../../repositories'; +import type { ProofService } from '../../services/ProofService'; +import type { WalletService } from '../../services/WalletService'; +import type { MintService } from '../../services/MintService'; +import type { EventBus } from '../../events/EventBus'; +import type { CoreEvents } from '../../events/types'; +import type { Logger } from '../../logging/Logger'; +import type { + ExecutingMintOperation, + InitMintOperation, + MintOperationFailure, + PendingMintOperation, +} from './MintOperation'; +import type { MintAdapter } from '../../infra/MintAdapter'; + +/** + * Registry of supported mint methods and payload shapes. + * Extend via declaration merging to support additional methods. + */ +export interface MintMethodDefinitions { + bolt11: { + methodData: Record; + remoteState: 'UNPAID' | 'PAID' | 'ISSUED'; + quote: MintQuoteBolt11Response; + }; +} + +export type MintMethod = keyof MintMethodDefinitions; +export type MintMethodData = + MintMethodDefinitions[M]['methodData']; +export type MintMethodRemoteState = + MintMethodDefinitions[M]['remoteState']; +export type MintMethodQuoteSnapshot = + MintMethodDefinitions[M]['quote']; + +export interface MintMethodMeta { + method: M; + methodData: MintMethodData; +} + +export interface BaseHandlerDeps { + proofRepository: ProofRepository; + proofService: ProofService; + walletService: WalletService; + mintService: MintService; + mintAdapter: MintAdapter; + eventBus: EventBus; + logger?: Logger; +} + +export interface PrepareContext extends BaseHandlerDeps { + operation: InitMintOperation; + wallet: Wallet; + importedQuote?: MintMethodQuoteSnapshot; +} + +export interface ExecuteContext extends BaseHandlerDeps { + operation: ExecutingMintOperation; + wallet: Wallet; +} + +export interface RecoverExecutingContext< + M extends MintMethod = MintMethod, +> extends BaseHandlerDeps { + operation: ExecutingMintOperation; + wallet: Wallet; +} + +export interface PendingContext extends BaseHandlerDeps { + operation: PendingMintOperation; + wallet: Wallet; +} + +export type MintExecutionResult = + | { + status: 'ISSUED'; + proofs: Proof[]; + } + | { + status: 'ALREADY_ISSUED'; + } + | { + status: 'FAILED'; + error?: string; + }; + +export type RecoverExecutingResult = + | { status: 'FINALIZED' } + | { status: 'TERMINAL'; error: string } + | { status: 'PENDING'; error?: string }; + +export type PendingMintCheckCategory = 'waiting' | 'ready' | 'completed' | 'terminal'; + +export interface PendingMintCheckResult { + observedRemoteState: MintMethodRemoteState; + observedRemoteStateAt: number; + category: PendingMintCheckCategory; + terminalFailure?: MintOperationFailure; +} + +export interface MintMethodHandler { + prepare(ctx: PrepareContext): Promise>; + execute(ctx: ExecuteContext): Promise; + recoverExecuting(ctx: RecoverExecutingContext): Promise; + checkPending(ctx: PendingContext): Promise>; +} + +export type MintMethodHandlerRegistry = { + [M in MintMethod]: MintMethodHandler; +}; diff --git a/packages/core/operations/mint/MintOperation.ts b/packages/core/operations/mint/MintOperation.ts new file mode 100644 index 00000000..dc64cd1b --- /dev/null +++ b/packages/core/operations/mint/MintOperation.ts @@ -0,0 +1,152 @@ +/** + * State machine for mint operations: + * + * init -> pending -> executing -> finalized + * ^ | + * +---------+-> failed + * + * - init: Local mint intent persisted before prepare has attached a quote snapshot + * - pending: Deterministic outputData persisted; quote may now settle remotely + * - executing: Mint or recovery call in progress + * - finalized: Quote reached terminal ISSUED state; proofs were saved when recoverable + * - failed: Operation reached a terminal non-issued state (for example, quote expiry) + */ +export type MintOperationState = 'init' | 'pending' | 'executing' | 'finalized' | 'failed'; + +import type { SerializedOutputData } from '../../utils'; +import { getSecretsFromSerializedOutputData } from '../../utils'; +import type { MintMethod, MintMethodMeta, MintMethodRemoteState } from './MintMethodHandler'; + +interface MintOperationBase extends MintMethodMeta { + id: string; + mintUrl: string; + createdAt: number; + updatedAt: number; + error?: string; + terminalFailure?: MintOperationFailure; +} + +export interface MintOperationFailure { + reason: string; + code?: string; + retryable?: boolean; + observedAt: number; +} + +interface MintIntentData { + amount: number; + unit: string; +} + +interface MintQuoteSnapshot { + quoteId: string; + request: string; + expiry: number; + pubkey?: string; +} + +interface MintRemoteObservation { + lastObservedRemoteState?: MintMethodRemoteState; + lastObservedRemoteStateAt?: number; +} + +interface PendingData { + outputData: SerializedOutputData; +} + +export interface InitMintOperation + extends MintOperationBase, + MintIntentData { + state: 'init'; + quoteId?: string; +} + +export interface PendingMintOperation + extends MintOperationBase, + MintIntentData, + MintQuoteSnapshot, + MintRemoteObservation, + PendingData { + state: 'pending'; +} + +export interface ExecutingMintOperation + extends MintOperationBase, + MintIntentData, + MintQuoteSnapshot, + MintRemoteObservation, + PendingData { + state: 'executing'; +} + +export interface FinalizedMintOperation + extends MintOperationBase, + MintIntentData, + MintQuoteSnapshot, + MintRemoteObservation, + PendingData { + state: 'finalized'; +} + +export interface FailedMintOperation + extends MintOperationBase, + MintIntentData, + MintQuoteSnapshot, + MintRemoteObservation, + PendingData { + state: 'failed'; +} + +export type MintOperation = + | InitMintOperation + | PendingMintOperation + | ExecutingMintOperation + | FinalizedMintOperation + | FailedMintOperation; + +export type PendingOrLaterOperation = + | PendingMintOperation + | ExecutingMintOperation + | FinalizedMintOperation + | FailedMintOperation; + +export type TerminalMintOperation = + | FinalizedMintOperation + | FailedMintOperation; + +export function hasPendingData( + op: MintOperation, +): op is PendingOrLaterOperation { + return op.state !== 'init'; +} + +export function isTerminalOperation( + op: MintOperation, +): op is TerminalMintOperation { + return op.state === 'finalized' || op.state === 'failed'; +} + +export function getOutputProofSecrets(op: PendingOrLaterOperation): string[] { + const { keepSecrets, sendSecrets } = getSecretsFromSerializedOutputData(op.outputData); + return [...keepSecrets, ...sendSecrets]; +} + +export function createMintOperation( + id: string, + mintUrl: string, + meta: MintMethodMeta, + intent: MintIntentData, + options?: { quoteId?: string }, +): InitMintOperation { + const now = Date.now(); + return { + ...meta, + ...intent, + ...(options?.quoteId ? { quoteId: options.quoteId } : {}), + id, + state: 'init', + mintUrl, + createdAt: now, + updatedAt: now, + }; +} diff --git a/packages/core/operations/mint/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts new file mode 100644 index 00000000..59c93cab --- /dev/null +++ b/packages/core/operations/mint/MintOperationService.ts @@ -0,0 +1,968 @@ +import type { Proof } from '@cashu/cashu-ts'; +import type { + MintOperationRepository, + ProofRepository, +} from '../../repositories'; +import type { + ExecutingMintOperation, + FailedMintOperation, + FinalizedMintOperation, + InitMintOperation, + MintOperation, + PendingMintOperation, + PendingOrLaterOperation, + TerminalMintOperation, +} from './MintOperation'; +import { + createMintOperation, + getOutputProofSecrets, + hasPendingData, + isTerminalOperation, +} from './MintOperation'; +import type { + MintMethod, + MintMethodData, + MintMethodMeta, + PendingMintCheckResult, + MintMethodQuoteSnapshot, + MintMethodRemoteState, +} from './MintMethodHandler'; +import type { MintService } from '../../services/MintService'; +import type { WalletService } from '../../services/WalletService'; +import type { ProofService } from '../../services/ProofService'; +import type { EventBus } from '../../events/EventBus'; +import type { CoreEvents } from '../../events/types'; +import type { Logger } from '../../logging/Logger'; +import { generateSubId, mapProofToCoreProof } from '../../utils'; +import { + OperationInProgressError, + NetworkError, + ProofValidationError, + UnknownMintError, +} from '../../models/Error'; +import type { MintAdapter } from '../../infra'; +import type { MintHandlerProvider } from '../../infra/handlers/mint'; +import { MintScopedLock } from '../MintScopedLock'; +import { OperationIdLock } from '../OperationIdLock'; + +/** + * MintOperationService orchestrates mint quote redemption as a crash-safe saga. + */ +export class MintOperationService { + private readonly handlerProvider: MintHandlerProvider; + private readonly mintOperationRepository: MintOperationRepository; + private readonly proofRepository: ProofRepository; + private readonly proofService: ProofService; + private readonly mintService: MintService; + private readonly walletService: WalletService; + private readonly mintAdapter: MintAdapter; + private readonly eventBus: EventBus; + private readonly logger?: Logger; + + private readonly operationIdLock = new OperationIdLock(); + private recoveryLock: Promise | null = null; + private readonly mintScopedLock: MintScopedLock; + + constructor( + handlerProvider: MintHandlerProvider, + mintOperationRepository: MintOperationRepository, + proofRepository: ProofRepository, + proofService: ProofService, + mintService: MintService, + walletService: WalletService, + mintAdapter: MintAdapter, + eventBus: EventBus, + logger?: Logger, + mintScopedLock?: MintScopedLock, + ) { + this.handlerProvider = handlerProvider; + this.mintOperationRepository = mintOperationRepository; + this.proofRepository = proofRepository; + this.proofService = proofService; + this.mintService = mintService; + this.walletService = walletService; + this.mintAdapter = mintAdapter; + this.eventBus = eventBus; + this.logger = logger; + this.mintScopedLock = mintScopedLock ?? new MintScopedLock(); + + this.eventBus.on('mint-op:quote-state-changed', async ({ operationId, operation, state }) => { + if (operation.state !== 'pending') { + return; + } + + await this.recordPendingObservation( + operationId, + state, + operation.lastObservedRemoteStateAt ?? Date.now(), + ); + }); + } + + private buildDeps() { + return { + proofRepository: this.proofRepository, + proofService: this.proofService, + walletService: this.walletService, + mintService: this.mintService, + mintAdapter: this.mintAdapter, + eventBus: this.eventBus, + logger: this.logger, + }; + } + + private async acquireOperationLock(operationId: string): Promise<() => void> { + return this.operationIdLock.acquire(operationId); + } + + isOperationLocked(operationId: string): boolean { + return this.operationIdLock.isLocked(operationId); + } + + isRecoveryInProgress(): boolean { + return this.recoveryLock !== null; + } + + private assertSupportedUnit(unit: string): void { + if (unit !== 'sat') { + throw new ProofValidationError( + `Unsupported mint unit '${unit}'. Only 'sat' is currently supported.`, + ); + } + } + + async init( + mintUrl: string, + intent: { amount: number; unit: string }, + method: MintMethod = 'bolt11', + methodData: MintMethodData = {}, + options?: { quoteId?: string }, + ): Promise { + const trusted = await this.mintService.isTrustedMint(mintUrl); + if (!trusted) { + throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); + } + + if (!Number.isFinite(intent.amount) || intent.amount <= 0) { + throw new ProofValidationError('Amount must be a positive number'); + } + + if (!intent.unit) { + throw new ProofValidationError('Unit is required'); + } + + this.assertSupportedUnit(intent.unit); + + const operationId = generateSubId(); + const operation = createMintOperation( + operationId, + mintUrl, + { + method, + methodData, + } as MintMethodMeta, + intent, + options, + ); + + await this.mintOperationRepository.create(operation); + this.logger?.debug('Mint operation created', { + operationId, + mintUrl, + quoteId: options?.quoteId, + method, + amount: intent.amount, + unit: intent.unit, + }); + + return operation; + } + + async prepareNewQuote( + mintUrl: string, + amount: number, + unit = 'sat', + method: MintMethod = 'bolt11', + methodData: MintMethodData = {}, + ): Promise { + const initOperation = await this.init(mintUrl, { amount, unit }, method, methodData); + return this.prepare(initOperation.id); + } + + async importQuote( + mintUrl: string, + quote: MintMethodQuoteSnapshot, + method: MintMethod = 'bolt11', + methodData: MintMethodData = {}, + options?: { skipMintLock?: boolean }, + ): Promise { + if (!quote.amount || quote.amount <= 0) { + throw new ProofValidationError(`Mint quote ${quote.quote} has invalid amount`); + } + + const existing = await this.getOperationByQuote(mintUrl, quote.quote); + if (existing?.state === 'pending') { + return existing; + } + if (existing?.state === 'init') { + return this.prepare(existing.id, { + importedQuote: quote, + skipMintLock: options?.skipMintLock, + }); + } + if (existing) { + throw new Error( + `Mint quote ${quote.quote} is already tracked by operation ${existing.id} in state ${existing.state}`, + ); + } + + const initOperation = await this.init( + mintUrl, + { amount: quote.amount, unit: quote.unit }, + method, + methodData, + { quoteId: quote.quote }, + ); + + return this.prepare(initOperation.id, { + importedQuote: quote, + skipMintLock: options?.skipMintLock, + }); + } + + async prepare( + operationId: string, + options?: { + skipMintLock?: boolean; + importedQuote?: MintMethodQuoteSnapshot; + }, + ): Promise { + const releaseLock = await this.acquireOperationLock(operationId); + let releaseMintLock: (() => void) | null = null; + let initOp: InitMintOperation | null = null; + let failure: unknown; + try { + const operation = await this.mintOperationRepository.getById(operationId); + if (!operation || operation.state !== 'init') { + throw new Error( + `Cannot prepare operation ${operationId}: expected state 'init' but found '${ + operation?.state ?? 'not found' + }'`, + ); + } + + initOp = operation as InitMintOperation; + if (!options?.skipMintLock) { + releaseMintLock = await this.mintScopedLock.acquire(initOp.mintUrl); + } + try { + const handler = this.handlerProvider.get(initOp.method); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(initOp.mintUrl); + const pending = await handler.prepare({ + ...this.buildDeps(), + operation: initOp as any, + wallet, + importedQuote: options?.importedQuote as any, + }); + + const pendingOp: PendingMintOperation = { + ...pending, + state: 'pending', + updatedAt: Date.now(), + }; + + await this.mintOperationRepository.update(pendingOp); + await this.eventBus.emit('mint-op:pending', { + mintUrl: pendingOp.mintUrl, + operationId: pendingOp.id, + operation: pendingOp, + }); + + this.logger?.info('Mint operation is pending', { + operationId: pendingOp.id, + mintUrl: pendingOp.mintUrl, + quoteId: pendingOp.quoteId, + method: pendingOp.method, + }); + + return pendingOp; + } catch (e) { + failure = e; + } finally { + releaseMintLock?.(); + } + } finally { + releaseLock(); + } + if (failure) { + if (initOp) { + await this.tryRecoverInitOperation(initOp); + } + throw failure; + } + throw new Error(`Failed to prepare operation ${operationId}`); + } + + async execute(operationId: string): Promise { + const releaseLock = await this.acquireOperationLock(operationId); + try { + const operation = await this.mintOperationRepository.getById(operationId); + if (!operation || operation.state !== 'pending') { + throw new Error( + `Cannot execute operation ${operationId}: expected state 'pending' but found '${ + operation?.state ?? 'not found' + }'`, + ); + } + + const pendingOp = operation as PendingMintOperation; + const executing: ExecutingMintOperation = { + ...pendingOp, + state: 'executing', + updatedAt: Date.now(), + error: undefined, + }; + await this.mintOperationRepository.update(executing); + + await this.eventBus.emit('mint-op:executing', { + mintUrl: executing.mintUrl, + operationId: executing.id, + operation: executing, + }); + + try { + const handler = this.handlerProvider.get(executing.method); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const result = await handler.execute({ + ...this.buildDeps(), + operation: executing as any, + wallet, + }); + + switch (result.status) { + case 'ISSUED': + if (!(await this.ensureOutputsSaved(executing, result.proofs))) { + throw new Error(`Failed to persist output proofs for operation ${executing.id}`); + } + return await this.finalizeIssuedOperation(executing); + case 'ALREADY_ISSUED': { + const proofsRecovered = await this.ensureOutputsSaved(executing); + const error = proofsRecovered + ? undefined + : `Recovered issued quote ${executing.quoteId} but no proofs could be restored`; + + if (error) { + this.logger?.warn('Mint quote was already issued but proofs could not be recovered', { + operationId: executing.id, + mintUrl: executing.mintUrl, + quoteId: executing.quoteId, + }); + } + + return await this.finalizeIssuedOperation(executing, error); + } + case 'FAILED': + throw new Error(result.error ?? 'Mint execution failed'); + } + } catch (e) { + await this.tryRecoverExecutingOperation(executing); + + const current = await this.mintOperationRepository.getById(operationId); + if (current && isTerminalOperation(current)) { + return current; + } + + throw e; + } + } finally { + releaseLock(); + } + } + + async finalize(operationId: string): Promise { + const operation = await this.mintOperationRepository.getById(operationId); + if (!operation) { + throw new Error(`Operation ${operationId} not found`); + } + + if (isTerminalOperation(operation)) { + this.logger?.debug('Operation already finalized', { operationId }); + return operation; + } + + if (operation.state === 'pending') { + return this.execute(operation.id); + } + + if (operation.state === 'executing') { + await this.recoverExecutingOperation(operation as ExecutingMintOperation); + const updated = await this.mintOperationRepository.getById(operationId); + if (updated && isTerminalOperation(updated)) { + return updated; + } + if (updated?.state === 'pending') { + throw new Error(`Operation ${operationId} remains pending after recovery`); + } + throw new Error( + `Unable to finalize operation ${operationId} in state '${updated?.state ?? 'missing'}'`, + ); + } + + throw new Error( + `Cannot finalize operation ${operationId} in state '${operation.state}'. Expected 'pending' or 'executing'.`, + ); + } + + async recoverPendingOperations(): Promise { + if (this.recoveryLock) { + throw new Error('Recovery is already in progress'); + } + + let releaseRecoveryLock: () => void; + this.recoveryLock = new Promise((resolve) => { + releaseRecoveryLock = resolve; + }); + + try { + let initCount = 0; + let pendingCount = 0; + let executingCount = 0; + + const initOps = await this.mintOperationRepository.getByState('init'); + for (const op of initOps) { + try { + await this.recoverInitOperation(op as InitMintOperation); + initCount++; + } catch (e) { + if (e instanceof OperationInProgressError) { + this.logger?.debug('Mint init operation in progress, skipping recovery', { + operationId: op.id, + }); + continue; + } + this.logger?.warn('Failed to recover mint init operation', { + operationId: op.id, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + const pendingOps = await this.mintOperationRepository.getByState('pending'); + for (const op of pendingOps) { + try { + if (await this.mintService.isTrustedMint(op.mintUrl)) { + await this.checkPendingOperation(op.id); + pendingCount++; + } else { + this.logger?.warn('Skipping recovery of pending operation for untrusted mint', { + operationId: op.id, + mintUrl: op.mintUrl, + }); + } + } catch (e) { + this.logger?.warn('Failed to reconcile stale pending mint operation', { + operationId: op.id, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + const executingOps = await this.mintOperationRepository.getByState('executing'); + for (const op of executingOps) { + try { + await this.recoverExecutingOperation(op as ExecutingMintOperation); + executingCount++; + } catch (e) { + if (e instanceof OperationInProgressError) { + this.logger?.debug('Mint executing operation in progress, skipping recovery', { + operationId: op.id, + }); + continue; + } + + this.logger?.error('Error recovering executing mint operation', { + operationId: op.id, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + this.logger?.info('Mint operation recovery completed', { + initOperations: initCount, + pendingOperations: pendingCount, + executingOperations: executingCount, + }); + } finally { + this.recoveryLock = null; + releaseRecoveryLock!(); + } + } + + async recoverExecutingOperation( + op: ExecutingMintOperation, + options?: { skipLock?: boolean }, + ): Promise { + const releaseLock = options?.skipLock ? undefined : await this.acquireOperationLock(op.id); + try { + const current = await this.mintOperationRepository.getById(op.id); + if (!current) { + this.logger?.warn('Mint operation missing during recovery', { operationId: op.id }); + return; + } + + if (isTerminalOperation(current)) { + return; + } + + if (current.state !== 'executing') { + this.logger?.debug('Mint operation not executing during recovery', { + operationId: current.id, + state: current.state, + }); + return; + } + + const executing = current as ExecutingMintOperation; + + if (await this.hasSavedOutputs(executing)) { + await this.finalizeIssuedOperation(executing); + return; + } + + if (!(await this.mintService.isTrustedMint(executing.mintUrl))) { + this.logger?.warn('Mint is not trusted, skipping recovery of executing mint operation', { + operationId: executing.id, + mintUrl: executing.mintUrl, + quoteId: executing.quoteId, + }); + return; + } + + const handler = this.handlerProvider.get(executing.method); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const result = await handler.recoverExecuting({ + ...this.buildDeps(), + operation: executing as any, + wallet, + }); + + switch (result.status) { + case 'FINALIZED': { + if (await this.ensureOutputsSaved(executing)) { + await this.finalizeIssuedOperation(executing); + } else { + await this.transitionToPending( + executing, + `Recovered issued quote ${executing.quoteId} but no proofs could be restored`, + ); + } + break; + } + case 'PENDING': { + await this.transitionToPending(executing, result.error); + this.logger?.warn('Mint operation returned to pending after recovery', { + operationId: executing.id, + mintUrl: executing.mintUrl, + quoteId: executing.quoteId, + error: result.error, + }); + break; + } + case 'TERMINAL': { + await this.failOperation(executing, result.error); + this.logger?.warn('Mint operation moved to failed during recovery', { + operationId: executing.id, + mintUrl: executing.mintUrl, + quoteId: executing.quoteId, + error: result.error, + }); + break; + } + } + } finally { + if (releaseLock) { + releaseLock(); + } + } + } + + async getOperation(operationId: string): Promise { + return this.mintOperationRepository.getById(operationId); + } + + async getOperationByQuote(mintUrl: string, quoteId: string): Promise { + const operations = await this.mintOperationRepository.getByQuoteId(mintUrl, quoteId); + if (operations.length === 0) { + return null; + } + + const sorted = operations.sort((a, b) => b.updatedAt - a.updatedAt); + + const finalized = sorted.find((op) => op.state === 'finalized'); + if (finalized) { + return finalized; + } + + const terminal = sorted.find((op) => isTerminalOperation(op)); + if (terminal) { + return terminal; + } + + return sorted[0] ?? null; + } + + async getInFlightOperations(): Promise { + return this.mintOperationRepository.getPending(); + } + + private async recoverInitOperation(op: InitMintOperation): Promise { + const releaseLock = await this.acquireOperationLock(op.id); + try { + const current = await this.mintOperationRepository.getById(op.id); + if (!current || current.state !== 'init') { + return; + } + + await this.mintOperationRepository.delete(op.id); + this.logger?.info('Cleaned up failed mint init operation', { operationId: op.id }); + } finally { + releaseLock(); + } + } + + async getPendingOperations(): Promise { + const ops = await this.mintOperationRepository.getByState('pending'); + return ops.filter((op): op is PendingMintOperation => op.state === 'pending'); + } + + private async tryRecoverInitOperation(op: InitMintOperation): Promise { + try { + await this.recoverInitOperation(op); + this.logger?.info('Recovered mint init operation after failure', { operationId: op.id }); + } catch (recoveryError) { + this.logger?.warn('Failed to recover mint init operation, will retry on startup', { + operationId: op.id, + error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), + }); + } + } + + private async tryRecoverExecutingOperation(op: ExecutingMintOperation): Promise { + try { + await this.recoverExecutingOperation(op, { skipLock: true }); + this.logger?.info('Recovered executing mint operation after failure', { + operationId: op.id, + }); + } catch (recoveryError) { + this.logger?.warn('Failed to recover executing mint operation, will retry on startup', { + operationId: op.id, + error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), + }); + } + } + + private async ensureOutputsSaved( + op: ExecutingMintOperation, + proofsFromExecute?: Proof[], + ): Promise { + if (await this.hasSavedOutputs(op)) { + return true; + } + + if (proofsFromExecute && proofsFromExecute.length > 0) { + await this.proofService.saveProofs( + op.mintUrl, + mapProofToCoreProof(op.mintUrl, 'ready', proofsFromExecute, { + createdByOperationId: op.id, + }), + ); + } + + if (await this.hasSavedOutputs(op)) { + return true; + } + + await this.proofService.recoverProofsFromOutputData(op.mintUrl, op.outputData, { + createdByOperationId: op.id, + }); + + return this.hasSavedOutputs(op); + } + + private async finalizeIssuedOperation( + op: ExecutingMintOperation, + error?: string, + ): Promise { + const current = await this.mintOperationRepository.getById(op.id); + if (!current) { + throw new Error(`Operation ${op.id} not found`); + } + + if (current.state === 'finalized') { + return current as FinalizedMintOperation; + } + + if (current.state !== 'executing') { + throw new Error(`Cannot finalize operation ${op.id} in state ${current.state}`); + } + + const observedRemoteStateAt = Date.now(); + + await this.eventBus.emit('mint-op:quote-state-changed', { + mintUrl: current.mintUrl, + operationId: current.id, + operation: current, + quoteId: current.quoteId, + state: 'ISSUED', + }); + + const finalized: FinalizedMintOperation = { + ...(current as PendingOrLaterOperation), + state: 'finalized', + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: observedRemoteStateAt, + updatedAt: Date.now(), + error, + }; + + await this.mintOperationRepository.update(finalized); + + await this.eventBus.emit('mint-op:finalized', { + mintUrl: finalized.mintUrl, + operationId: finalized.id, + operation: finalized, + }); + + this.logger?.info('Mint operation finalized', { + operationId: finalized.id, + mintUrl: finalized.mintUrl, + quoteId: finalized.quoteId, + }); + + return finalized; + } + + private async failOperation( + op: ExecutingMintOperation, + error: string, + ): Promise { + const current = await this.mintOperationRepository.getById(op.id); + if (!current) { + throw new Error(`Operation ${op.id} not found`); + } + + if (current.state === 'failed') { + return current as FailedMintOperation; + } + + if (current.state === 'finalized') { + throw new Error(`Cannot fail operation ${op.id} in state ${current.state}`); + } + + if (current.state !== 'executing') { + throw new Error(`Cannot fail operation ${op.id} in state ${current.state}`); + } + + const failed: FailedMintOperation = { + ...(current as PendingOrLaterOperation), + state: 'failed', + updatedAt: Date.now(), + error, + terminalFailure: { + reason: error, + observedAt: Date.now(), + }, + }; + + await this.mintOperationRepository.update(failed); + + await this.eventBus.emit('mint-op:finalized', { + mintUrl: failed.mintUrl, + operationId: failed.id, + operation: failed, + }); + + this.logger?.info('Mint operation failed during recovery', { + operationId: failed.id, + mintUrl: failed.mintUrl, + quoteId: failed.quoteId, + error, + }); + + return failed; + } + + private async transitionToPending( + op: ExecutingMintOperation, + error?: string, + ): Promise { + const pending: PendingMintOperation = { + ...op, + state: 'pending', + updatedAt: Date.now(), + error, + }; + + await this.mintOperationRepository.update(pending); + await this.eventBus.emit('mint-op:pending', { + mintUrl: op.mintUrl, + operationId: op.id, + operation: pending, + }); + + this.logger?.info('Mint operation moved to pending', { + operationId: op.id, + mintUrl: op.mintUrl, + quoteId: op.quoteId, + error, + }); + + return pending; + } + + async observePendingOperation(operationId: string): Promise { + const op = await this.getOperation(operationId); + if (!op || op.state !== 'pending') { + throw new Error( + `Cannot check operation ${operationId}: expected state 'pending' but found '${ + op?.state ?? 'not found' + }'`, + ); + } + const handler = this.handlerProvider.get(op.method); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl); + + const result = await handler.checkPending({ + ...this.buildDeps(), + operation: op as PendingMintOperation, + wallet, + }); + + const observedPending: PendingMintOperation = { + ...op, + lastObservedRemoteState: result.observedRemoteState, + lastObservedRemoteStateAt: result.observedRemoteStateAt, + updatedAt: Date.now(), + }; + + await this.eventBus.emit('mint-op:quote-state-changed', { + mintUrl: observedPending.mintUrl, + operationId: observedPending.id, + operation: observedPending, + quoteId: observedPending.quoteId, + state: result.observedRemoteState, + }); + + if (result.category === 'terminal' && result.terminalFailure) { + await this.failPendingOperation(op, result.terminalFailure); + } + + return result; + } + + async recordPendingObservation( + operationId: string, + observedRemoteState: MintMethodRemoteState, + observedRemoteStateAt = Date.now(), + ): Promise { + const op = await this.getOperation(operationId); + if (!op || op.state !== 'pending') { + throw new Error( + `Cannot record observation for operation ${operationId}: expected state 'pending' but found '${ + op?.state ?? 'not found' + }'`, + ); + } + + const observedPending: PendingMintOperation = { + ...op, + lastObservedRemoteState: observedRemoteState, + lastObservedRemoteStateAt: observedRemoteStateAt, + updatedAt: Date.now(), + }; + await this.mintOperationRepository.update(observedPending); + + return observedPending; + } + + async checkPendingOperation(operationId: string): Promise { + const result = await this.observePendingOperation(operationId); + + if (result.category === 'ready' || result.category === 'completed') { + await this.finalize(operationId); + } + + return result; + } + + private async failPendingOperation( + op: PendingMintOperation, + terminalFailure: FailedMintOperation['terminalFailure'], + ): Promise { + if (!terminalFailure) { + throw new Error(`Cannot fail pending operation ${op.id} without terminal failure details`); + } + + const current = await this.mintOperationRepository.getById(op.id); + if (!current) { + throw new Error(`Operation ${op.id} not found`); + } + + if (current.state === 'failed') { + return current as FailedMintOperation; + } + + if (current.state === 'finalized') { + throw new Error(`Cannot fail operation ${op.id} in state ${current.state}`); + } + + if (current.state !== 'pending') { + throw new Error(`Cannot fail operation ${op.id} in state ${current.state}`); + } + + const failed: FailedMintOperation = { + ...(current as PendingOrLaterOperation), + state: 'failed', + updatedAt: Date.now(), + error: terminalFailure.reason, + terminalFailure, + }; + + await this.mintOperationRepository.update(failed); + + await this.eventBus.emit('mint-op:finalized', { + mintUrl: failed.mintUrl, + operationId: failed.id, + operation: failed, + }); + + this.logger?.info('Mint operation failed while pending', { + operationId: failed.id, + mintUrl: failed.mintUrl, + quoteId: failed.quoteId, + error: terminalFailure.reason, + }); + + return failed; + } + + private async hasSavedOutputs(op: PendingOrLaterOperation): Promise { + if (!hasPendingData(op)) { + return false; + } + + const outputSecrets = getOutputProofSecrets(op); + if (outputSecrets.length === 0) { + return false; + } + + for (const secret of outputSecrets) { + const proof = await this.proofRepository.getProofBySecret(op.mintUrl, secret); + if (!proof) { + return false; + } + } + + return true; + } +} diff --git a/packages/core/operations/mint/index.ts b/packages/core/operations/mint/index.ts new file mode 100644 index 00000000..d4c112b5 --- /dev/null +++ b/packages/core/operations/mint/index.ts @@ -0,0 +1,3 @@ +export * from './MintOperation'; +export * from './MintMethodHandler'; +export * from './MintOperationService'; diff --git a/packages/core/plugins/types.ts b/packages/core/plugins/types.ts index 0ec442f9..7b8b9350 100644 --- a/packages/core/plugins/types.ts +++ b/packages/core/plugins/types.ts @@ -7,7 +7,6 @@ import type { HistoryService, KeyRingService, MeltQuoteService, - MintQuoteService, MintService, PaymentRequestService, ProofService, @@ -19,6 +18,7 @@ import type { } from '../services'; import type { SendOperationService } from '../operations/send/SendOperationService'; import type { MeltOperationService } from '../operations/melt/MeltOperationService'; +import type { MintOperationService } from '../operations/mint/MintOperationService'; import type { ReceiveOperationService } from '../operations/receive/ReceiveOperationService'; export type ServiceKey = keyof ServiceMap; @@ -32,13 +32,13 @@ export interface ServiceMap { walletRestoreService: WalletRestoreService; counterService: CounterService; tokenService: TokenService; - mintQuoteService: MintQuoteService; meltQuoteService: MeltQuoteService; historyService: HistoryService; transactionService: TransactionService; sendOperationService: SendOperationService; receiveOperationService: ReceiveOperationService; meltOperationService: MeltOperationService; + mintOperationService: MintOperationService; paymentRequestService: PaymentRequestService; subscriptions: SubscriptionManager; eventBus: EventBus; diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 3f3ff807..ee4ddd99 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -10,6 +10,7 @@ import type { Keypair } from '@core/models/Keypair'; import type { MeltQuote } from '@core/models/MeltQuote'; import type { MintQuote } from '@core/models/MintQuote'; import type { MeltOperation, MeltOperationState } from '@core/operations/melt/MeltOperation'; +import type { MintOperation, MintOperationState } from '@core/operations/mint/MintOperation'; import type { ReceiveOperation, ReceiveOperationState, @@ -194,6 +195,32 @@ export interface AuthSessionRepository { getAllSessions(): Promise; } +export interface MintOperationRepository { + /** Create a new mint operation */ + create(operation: MintOperation): Promise; + + /** Update an existing mint operation */ + update(operation: MintOperation): Promise; + + /** Get a mint operation by ID */ + getById(id: string): Promise; + + /** Get all mint operations in a specific state */ + getByState(state: MintOperationState): Promise; + + /** Get all in-flight operations (state in ['pending', 'executing']) */ + getPending(): Promise; + + /** Get all operations for a specific mint */ + getByMintUrl(mintUrl: string): Promise; + + /** Get all operations for a mint/quote pair */ + getByQuoteId(mintUrl: string, quoteId: string): Promise; + + /** Delete a mint operation */ + delete(id: string): Promise; +} + export interface ReceiveOperationRepository { /** Create a new receive operation */ create(operation: ReceiveOperation): Promise; @@ -229,6 +256,7 @@ interface RepositoriesBase { sendOperationRepository: SendOperationRepository; meltOperationRepository: MeltOperationRepository; authSessionRepository: AuthSessionRepository; + mintOperationRepository: MintOperationRepository; receiveOperationRepository: ReceiveOperationRepository; } diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts new file mode 100644 index 00000000..73173c9e --- /dev/null +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -0,0 +1,69 @@ +import type { MintOperationRepository } from '..'; +import type { MintOperation, MintOperationState } from '../../operations/mint/MintOperation'; + +export class MemoryMintOperationRepository implements MintOperationRepository { + private readonly operations = new Map(); + + async create(operation: MintOperation): Promise { + if (this.operations.has(operation.id)) { + throw new Error(`MintOperation with id ${operation.id} already exists`); + } + this.operations.set(operation.id, { ...operation }); + } + + async update(operation: MintOperation): Promise { + if (!this.operations.has(operation.id)) { + throw new Error(`MintOperation with id ${operation.id} not found`); + } + this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + } + + async getById(id: string): Promise { + const operation = this.operations.get(id); + return operation ? { ...operation } : null; + } + + async getByState(state: MintOperationState): Promise { + const results: MintOperation[] = []; + for (const operation of this.operations.values()) { + if (operation.state === state) { + results.push({ ...operation }); + } + } + return results; + } + + async getPending(): Promise { + const results: MintOperation[] = []; + for (const operation of this.operations.values()) { + if (operation.state === 'pending' || operation.state === 'executing') { + results.push({ ...operation }); + } + } + return results; + } + + async getByMintUrl(mintUrl: string): Promise { + const results: MintOperation[] = []; + for (const operation of this.operations.values()) { + if (operation.mintUrl === mintUrl) { + results.push({ ...operation }); + } + } + return results; + } + + async getByQuoteId(mintUrl: string, quoteId: string): Promise { + const results: MintOperation[] = []; + for (const operation of this.operations.values()) { + if (operation.mintUrl === mintUrl && 'quoteId' in operation && operation.quoteId === quoteId) { + results.push({ ...operation }); + } + } + return results; + } + + async delete(id: string): Promise { + this.operations.delete(id); + } +} diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index 9e32bdaf..63e98191 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -12,6 +12,7 @@ import type { Repositories, RepositoryTransactionScope, SendOperationRepository, + MintOperationRepository, ReceiveOperationRepository, } from '..'; import { MemoryAuthSessionRepository } from './MemoryAuthSessionRepository'; @@ -25,6 +26,7 @@ import { MemoryMintQuoteRepository } from './MemoryMintQuoteRepository'; import { MemoryMintRepository } from './MemoryMintRepository'; import { MemoryProofRepository } from './MemoryProofRepository'; import { MemorySendOperationRepository } from './MemorySendOperationRepository'; +import { MemoryMintOperationRepository } from './MemoryMintOperationRepository'; import { MemoryReceiveOperationRepository } from './MemoryReceiveOperationRepository'; export class MemoryRepositories implements Repositories { @@ -39,6 +41,7 @@ export class MemoryRepositories implements Repositories { sendOperationRepository: SendOperationRepository; meltOperationRepository: MeltOperationRepository; authSessionRepository: AuthSessionRepository; + mintOperationRepository: MintOperationRepository; receiveOperationRepository: ReceiveOperationRepository; constructor() { @@ -53,6 +56,7 @@ export class MemoryRepositories implements Repositories { this.sendOperationRepository = new MemorySendOperationRepository(); this.meltOperationRepository = new MemoryMeltOperationRepository(); this.authSessionRepository = new MemoryAuthSessionRepository(); + this.mintOperationRepository = new MemoryMintOperationRepository(); this.receiveOperationRepository = new MemoryReceiveOperationRepository(); } diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index d99ca78c..559c807c 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -11,5 +11,6 @@ export * from './MemoryProofRepository'; export * from './MemoryRepositories'; export * from './MemorySendOperationRepository'; export * from './MemoryMeltOperationRepository'; +export * from './MemoryMintOperationRepository'; export * from './MemoryReceiveOperationRepository'; diff --git a/packages/core/services/HistoryService.ts b/packages/core/services/HistoryService.ts index 6547476e..580d87ca 100644 --- a/packages/core/services/HistoryService.ts +++ b/packages/core/services/HistoryService.ts @@ -1,8 +1,6 @@ import type { MeltQuoteBolt11Response, MeltQuoteState, - MintQuoteBolt11Response, - MintQuoteState, Token, } from '@cashu/cashu-ts'; import type { HistoryRepository } from '../repositories'; @@ -16,6 +14,8 @@ import type { SendHistoryEntry, SendHistoryState, } from '@core/models/History'; +import type { PendingMintOperation } from '@core/operations/mint'; +import type { MintQuoteState } from '@core/models/MintQuoteState'; import type { Logger } from '@core/logging'; import type { SendOperation } from '@core/operations/send/SendOperation'; @@ -32,14 +32,12 @@ export class HistoryService { this.historyRepository = historyRepository; this.logger = logger; this.eventBus = eventBus; - this.eventBus.on('mint-quote:state-changed', ({ mintUrl, quoteId, state }) => { - this.handleMintQuoteStateChanged(mintUrl, quoteId, state); + this.eventBus.on('mint-op:pending', ({ mintUrl, operation }) => { + if (operation.state !== 'pending') return; + this.handleMintOperationPending(mintUrl, operation as PendingMintOperation); }); - this.eventBus.on('mint-quote:created', ({ mintUrl, quoteId, quote }) => { - this.handleMintQuoteCreated(mintUrl, quoteId, quote); - }); - this.eventBus.on('mint-quote:added', ({ mintUrl, quoteId, quote }) => { - this.handleMintQuoteAdded(mintUrl, quoteId, quote); + this.eventBus.on('mint-op:quote-state-changed', ({ mintUrl, operationId, quoteId, state }) => { + this.handleMintOperationQuoteStateChanged(mintUrl, operationId, quoteId, state); }); this.eventBus.on('melt-quote:created', ({ mintUrl, quoteId, quote }) => { this.handleMeltQuoteCreated(mintUrl, quoteId, quote); @@ -174,13 +172,19 @@ export class HistoryService { } } - async handleMintQuoteStateChanged(mintUrl: string, quoteId: string, state: MintQuoteState) { + async handleMintOperationQuoteStateChanged( + mintUrl: string, + operationId: string, + quoteId: string, + state: MintQuoteState, + ) { try { const entry = await this.historyRepository.getMintHistoryEntry(mintUrl, quoteId); if (!entry) { - this.logger?.error('Mint quote state changed history entry not found', { + this.logger?.error('Mint operation quote state changed history entry not found', { mintUrl, quoteId, + operationId, }); return; } @@ -188,9 +192,10 @@ export class HistoryService { await this.historyRepository.updateHistoryEntry(entry); await this.handleHistoryUpdated(mintUrl, { ...entry, state }); } catch (err) { - this.logger?.error('Failed to add mint quote state changed history entry', { + this.logger?.error('Failed to update mint operation history state', { mintUrl, quoteId, + operationId, err, }); } @@ -239,58 +244,43 @@ export class HistoryService { } } - async handleMintQuoteCreated(mintUrl: string, quoteId: string, quote: MintQuoteBolt11Response) { + async handleMintOperationPending(mintUrl: string, operation: PendingMintOperation) { const entry: Omit = { type: 'mint', mintUrl, - unit: quote.unit, - paymentRequest: quote.request, - quoteId, - state: quote.state, - createdAt: Date.now(), - amount: quote.amount, + unit: operation.unit, + paymentRequest: operation.request, + quoteId: operation.quoteId, + state: operation.lastObservedRemoteState ?? 'UNPAID', + createdAt: operation.createdAt, + amount: operation.amount, }; - try { - await this.historyRepository.addHistoryEntry(entry); - } catch (err) { - this.logger?.error('Failed to add mint quote created history entry', { - mintUrl, - quoteId, - err, - }); - } - } - - async handleMintQuoteAdded(mintUrl: string, quoteId: string, quote: MintQuoteBolt11Response) { - // Check if history entry already exists for this quote - const existing = await this.historyRepository.getMintHistoryEntry(mintUrl, quoteId); - if (existing) { - this.logger?.debug('History entry already exists for added mint quote', { mintUrl, quoteId }); - return; - } - const entry: Omit = { - type: 'mint', - mintUrl, - unit: quote.unit, - paymentRequest: quote.request, - quoteId, - state: quote.state, - createdAt: Date.now(), - amount: quote.amount, - }; try { + const existing = await this.historyRepository.getMintHistoryEntry(mintUrl, operation.quoteId); + if (existing) { + existing.unit = entry.unit; + existing.paymentRequest = entry.paymentRequest; + existing.state = entry.state; + existing.amount = entry.amount; + const updated = await this.historyRepository.updateHistoryEntry(existing); + await this.handleHistoryUpdated(mintUrl, updated); + return; + } + const created = await this.historyRepository.addHistoryEntry(entry); - await this.eventBus.emit('history:updated', { mintUrl, entry: created }); - this.logger?.debug('Added history entry for externally added mint quote', { + await this.handleHistoryUpdated(mintUrl, created); + this.logger?.debug('Added history entry for pending mint operation', { mintUrl, - quoteId, - state: quote.state, + quoteId: operation.quoteId, + operationId: operation.id, + state: entry.state, }); } catch (err) { - this.logger?.error('Failed to add mint quote added history entry', { + this.logger?.error('Failed to add pending mint operation history entry', { mintUrl, - quoteId, + quoteId: operation.quoteId, + operationId: operation.id, err, }); } diff --git a/packages/core/services/MintQuoteService.ts b/packages/core/services/MintQuoteService.ts deleted file mode 100644 index 2256f31b..00000000 --- a/packages/core/services/MintQuoteService.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { MintQuoteRepository } from '../repositories'; -import type { MintService } from './MintService'; -import type { WalletService } from './WalletService'; -import type { ProofService } from './ProofService'; -import type { MintQuoteBolt11Response, MintQuoteState } from '@cashu/cashu-ts'; -import type { CoreEvents, EventBus } from '@core/events'; -import type { Logger } from '../logging/Logger.ts'; -import { mapProofToCoreProof } from '@core/utils.ts'; -import { UnknownMintError } from '../models/Error'; - -export class MintQuoteService { - private readonly mintQuoteRepo: MintQuoteRepository; - private readonly mintService: MintService; - private readonly walletService: WalletService; - private readonly proofService: ProofService; - private readonly eventBus: EventBus; - private readonly logger?: Logger; - - constructor( - mintQuoteRepo: MintQuoteRepository, - mintService: MintService, - walletService: WalletService, - proofService: ProofService, - eventBus: EventBus, - logger?: Logger, - ) { - this.mintQuoteRepo = mintQuoteRepo; - this.mintService = mintService; - this.walletService = walletService; - this.proofService = proofService; - this.eventBus = eventBus; - this.logger = logger; - } - - async createMintQuote(mintUrl: string, amount: number): Promise { - this.logger?.info('Creating mint quote', { mintUrl, amount }); - - const trusted = await this.mintService.isTrustedMint(mintUrl); - if (!trusted) { - throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); - } - - try { - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); - const quote = await wallet.createMintQuoteBolt11(amount); - await this.mintQuoteRepo.addMintQuote({ ...quote, mintUrl }); - await this.eventBus.emit('mint-quote:created', { mintUrl, quoteId: quote.quote, quote }); - return quote; - } catch (err) { - this.logger?.error('Failed to create mint quote', { mintUrl, amount, err }); - throw err; - } - } - - async redeemMintQuote(mintUrl: string, quoteId: string): Promise { - this.logger?.info('Redeeming mint quote', { mintUrl, quoteId }); - - const trusted = await this.mintService.isTrustedMint(mintUrl); - if (!trusted) { - throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); - } - - try { - const quote = await this.mintQuoteRepo.getMintQuote(mintUrl, quoteId); - if (!quote) { - this.logger?.warn('Mint quote not found', { mintUrl, quoteId }); - throw new Error('Quote not found'); - } - if (!quote.amount) { - this.logger?.warn('Mint quote had undefined amount', { mintUrl, quoteId }); - throw new Error('Quote amount undefined'); - } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); - const { keep } = await this.proofService.createOutputsAndIncrementCounters(mintUrl, { - keep: quote.amount, - send: 0, - }); - const proofs = await wallet.mintProofsBolt11(quote.amount, quote.quote, undefined, { type: 'custom', data: keep }); - await this.eventBus.emit('mint-quote:redeemed', { mintUrl, quoteId, quote }); - this.logger?.info('Mint quote redeemed, proofs minted', { - mintUrl, - quoteId, - amount: quote.amount, - proofs: proofs.length, - }); - await this.setMintQuoteState(mintUrl, quoteId, 'ISSUED'); - await this.proofService.saveProofs(mintUrl, mapProofToCoreProof(mintUrl, 'ready', proofs)); - this.logger?.debug('Proofs saved to repository', { mintUrl, count: proofs.length }); - } catch (err) { - this.logger?.error('Failed to redeem mint quote', { mintUrl, quoteId, err }); - throw err; - } - } - - async addExistingMintQuotes( - mintUrl: string, - quotes: MintQuoteBolt11Response[], - ): Promise<{ added: string[]; skipped: string[] }> { - this.logger?.info('Adding existing mint quotes', { mintUrl, count: quotes.length }); - - const added: string[] = []; - const skipped: string[] = []; - - for (const quote of quotes) { - try { - // Check if quote already exists - const existing = await this.mintQuoteRepo.getMintQuote(mintUrl, quote.quote); - if (existing) { - this.logger?.debug('Quote already exists, skipping', { mintUrl, quoteId: quote.quote }); - skipped.push(quote.quote); - continue; - } - - // Add the quote to the repository - await this.mintQuoteRepo.addMintQuote({ ...quote, mintUrl }); - added.push(quote.quote); - - // Emit the added event - processor will handle PAID quotes - await this.eventBus.emit('mint-quote:added', { - mintUrl, - quoteId: quote.quote, - quote, - }); - - this.logger?.debug('Added existing mint quote', { - mintUrl, - quoteId: quote.quote, - state: quote.state, - }); - } catch (err) { - this.logger?.error('Failed to add existing mint quote', { - mintUrl, - quoteId: quote.quote, - err, - }); - skipped.push(quote.quote); - } - } - - this.logger?.info('Finished adding existing mint quotes', { - mintUrl, - added: added.length, - skipped: skipped.length, - }); - - return { added, skipped }; - } - - async updateStateFromRemote( - mintUrl: string, - quoteId: string, - state: MintQuoteState, - ): Promise { - this.logger?.info('Updating mint quote state from remote', { mintUrl, quoteId, state }); - await this.setMintQuoteState(mintUrl, quoteId, state); - } - - private async setMintQuoteState( - mintUrl: string, - quoteId: string, - state: MintQuoteState, - ): Promise { - this.logger?.debug('Setting mint quote state', { mintUrl, quoteId, state }); - await this.mintQuoteRepo.setMintQuoteState(mintUrl, quoteId, state); - await this.eventBus.emit('mint-quote:state-changed', { mintUrl, quoteId, state }); - this.logger?.debug('Mint quote state updated', { mintUrl, quoteId, state }); - } - - /** - * Requeue all PAID (but not yet ISSUED) quotes for processing. - * Only requeues quotes for trusted mints. - * Emits `mint-quote:requeue` for each PAID quote so the processor can enqueue them. - */ - async requeuePaidMintQuotes(mintUrl?: string): Promise<{ requeued: string[] }> { - const requeued: string[] = []; - try { - const pending = await this.mintQuoteRepo.getPendingMintQuotes(); - for (const q of pending) { - if (mintUrl && q.mintUrl !== mintUrl) continue; - if (q.state !== 'PAID') continue; - - // Only requeue for trusted mints - const trusted = await this.mintService.isTrustedMint(q.mintUrl); - if (!trusted) { - this.logger?.debug('Skipping requeue for untrusted mint', { - mintUrl: q.mintUrl, - quoteId: q.quote, - }); - continue; - } - - await this.eventBus.emit('mint-quote:requeue', { - mintUrl: q.mintUrl, - quoteId: q.quote, - }); - requeued.push(q.quote); - } - this.logger?.info('Requeued PAID mint quotes', { count: requeued.length, mintUrl }); - } catch (err) { - this.logger?.error('Failed to requeue PAID mint quotes', { mintUrl, err }); - } - return { requeued }; - } -} diff --git a/packages/core/services/index.ts b/packages/core/services/index.ts index 86334215..9e0ee6ed 100644 --- a/packages/core/services/index.ts +++ b/packages/core/services/index.ts @@ -4,7 +4,6 @@ export * from './CounterService'; export * from './HistoryService'; export * from './KeyRingService'; export * from './MeltQuoteService'; -export * from './MintQuoteService'; export * from './MintService'; export * from './PaymentRequestService'; export * from './ProofService'; diff --git a/packages/core/services/watchers/MintQuoteProcessor.ts b/packages/core/services/watchers/MintOperationProcessor.ts similarity index 61% rename from packages/core/services/watchers/MintQuoteProcessor.ts rename to packages/core/services/watchers/MintOperationProcessor.ts index 19f37da5..489da0f5 100644 --- a/packages/core/services/watchers/MintQuoteProcessor.ts +++ b/packages/core/services/watchers/MintOperationProcessor.ts @@ -1,43 +1,37 @@ import type { EventBus, CoreEvents } from '@core/events'; import type { Logger } from '../../logging/Logger.ts'; -import type { MintQuoteService } from '../MintQuoteService'; -import type { MintQuoteState } from '@cashu/cashu-ts'; +import type { MintOperationService } from '@core/operations/mint'; import { MintOperationError, NetworkError } from '../../models/Error'; interface QueueItem { mintUrl: string; - quoteId: string; - quoteType: string; + operationId: string; + method: string; retryCount: number; nextRetryAt: number; } -interface QuoteHandler { - canHandle(quoteType: string): boolean; - process(mintUrl: string, quoteId: string): Promise; +interface OperationHandler { + process(mintUrl: string, operationId: string): Promise; } -class Bolt11QuoteHandler implements QuoteHandler { - constructor(private quotes: MintQuoteService, private logger?: Logger) {} +class Bolt11MintOperationHandler implements OperationHandler { + constructor(private mintOperations: MintOperationService, private logger?: Logger) {} - canHandle(quoteType: string): boolean { - return quoteType === 'bolt11'; - } - - async process(mintUrl: string, quoteId: string): Promise { - await this.quotes.redeemMintQuote(mintUrl, quoteId); + async process(_mintUrl: string, operationId: string): Promise { + await this.mintOperations.finalize(operationId); } } -export interface MintQuoteProcessorOptions { +export interface MintOperationProcessorOptions { processIntervalMs?: number; maxRetries?: number; baseRetryDelayMs?: number; initialEnqueueDelayMs?: number; } -export class MintQuoteProcessor { - private readonly quotes: MintQuoteService; +export class MintOperationProcessor { + private readonly mintOperations: MintOperationService; private readonly bus: EventBus; private readonly logger?: Logger; @@ -46,23 +40,23 @@ export class MintQuoteProcessor { private processing = false; private processingTimer?: ReturnType; private offStateChanged?: () => void; - private offQuoteAdded?: () => void; + private offPending?: () => void; private offRequeue?: () => void; private offUntrusted?: () => void; - private handlers = new Map(); + private handlers = new Map(); private readonly processIntervalMs: number; private readonly maxRetries: number; private readonly baseRetryDelayMs: number; private readonly initialEnqueueDelayMs: number; constructor( - quotes: MintQuoteService, + mintOperations: MintOperationService, bus: EventBus, logger?: Logger, - options?: MintQuoteProcessorOptions, + options?: MintOperationProcessorOptions, ) { - this.quotes = quotes; + this.mintOperations = mintOperations; this.bus = bus; this.logger = logger; @@ -72,13 +66,13 @@ export class MintQuoteProcessor { this.baseRetryDelayMs = options?.baseRetryDelayMs ?? 5000; this.initialEnqueueDelayMs = options?.initialEnqueueDelayMs ?? 500; - // Register default handler for bolt11 quotes - this.registerHandler('bolt11', new Bolt11QuoteHandler(quotes, logger)); + // Register default handler for bolt11 mint operations + this.registerHandler('bolt11', new Bolt11MintOperationHandler(mintOperations, logger)); } - registerHandler(quoteType: string, handler: QuoteHandler): void { - this.handlers.set(quoteType, handler); - this.logger?.debug('Registered quote handler', { quoteType }); + registerHandler(method: string, handler: OperationHandler): void { + this.handlers.set(method, handler); + this.logger?.debug('Registered mint operation handler', { method }); } isRunning(): boolean { @@ -88,29 +82,28 @@ export class MintQuoteProcessor { async start(): Promise { if (this.running) return; this.running = true; - this.logger?.info('MintQuoteProcessor started'); + this.logger?.info('MintOperationProcessor started'); - // Subscribe to state changes + // Subscribe to operation-owned quote-state changes this.offStateChanged = this.bus.on( - 'mint-quote:state-changed', - async ({ mintUrl, quoteId, state }) => { + 'mint-op:quote-state-changed', + async ({ mintUrl, operationId, operation, state }) => { if (state === 'PAID') { - this.enqueue(mintUrl, quoteId, 'bolt11'); // Default to bolt11 for now + this.enqueue(mintUrl, operationId, operation.method); } }, ); - // Subscribe to manually added quotes - this.offQuoteAdded = this.bus.on('mint-quote:added', async ({ mintUrl, quoteId, quote }) => { - if (quote.state === 'PAID') { - // Use provided quoteType or default to bolt11 - this.enqueue(mintUrl, quoteId, 'bolt11'); + // Subscribe to pending operations so imported PAID operations enqueue immediately + this.offPending = this.bus.on('mint-op:pending', async ({ mintUrl, operation }) => { + if (operation.state === 'pending' && operation.lastObservedRemoteState === 'PAID') { + this.enqueue(mintUrl, operation.id, operation.method); } }); - // Subscribe to explicit requeue events (enqueue regardless of stored state) - this.offRequeue = this.bus.on('mint-quote:requeue', async ({ mintUrl, quoteId }) => { - this.enqueue(mintUrl, quoteId, 'bolt11'); + // Subscribe to explicit operation requeue events. + this.offRequeue = this.bus.on('mint-op:requeue', ({ mintUrl, operationId, operation }) => { + this.enqueue(mintUrl, operationId, operation.method); }); // Clear queue items when mint is untrusted @@ -137,13 +130,13 @@ export class MintQuoteProcessor { } } - if (this.offQuoteAdded) { + if (this.offPending) { try { - this.offQuoteAdded(); + this.offPending(); } catch { // ignore } finally { - this.offQuoteAdded = undefined; + this.offPending = undefined; } } @@ -178,12 +171,12 @@ export class MintQuoteProcessor { await new Promise((resolve) => setTimeout(resolve, 100)); } - this.logger?.info('MintQuoteProcessor stopped', { pendingItems: this.queue.length }); + this.logger?.info('MintOperationProcessor stopped', { pendingItems: this.queue.length }); } /** * Wait for the queue to be empty and all processing to complete. - * Useful for CLI applications that want to ensure all quotes are processed before exiting. + * Useful for CLI applications that want to ensure all queued operations are processed before exiting. */ async waitForCompletion(): Promise { while (this.queue.length > 0 || this.processing) { @@ -193,28 +186,28 @@ export class MintQuoteProcessor { /** * Remove all queued items for a specific mint. - * Called when a mint is untrusted to stop processing its quotes. + * Called when a mint is untrusted to stop processing its operations. */ clearMintFromQueue(mintUrl: string): void { const before = this.queue.length; this.queue = this.queue.filter((item) => item.mintUrl !== mintUrl); const removed = before - this.queue.length; if (removed > 0) { - this.logger?.info('Cleared mint quotes from processor queue', { mintUrl, removed }); + this.logger?.info('Cleared mint operations from processor queue', { mintUrl, removed }); } } - // TODO: Improve deduplication by tracking an "active" set keyed by `${mintUrl}::${quoteId}` + // TODO: Improve deduplication by tracking an "active" set keyed by `${mintUrl}::${operationId}` // to prevent re-enqueueing while an item is currently being processed. Today we only // deduplicate within the queue, so an item can be enqueued again if a new event arrives // during in-flight processing. - private enqueue(mintUrl: string, quoteId: string, quoteType: string): void { + private enqueue(mintUrl: string, operationId: string, method: string): void { // Check if already in queue const existing = this.queue.find( - (item) => item.mintUrl === mintUrl && item.quoteId === quoteId, + (item) => item.mintUrl === mintUrl && item.operationId === operationId, ); if (existing) { - this.logger?.debug('Quote already in queue', { mintUrl, quoteId }); + this.logger?.debug('Mint operation already in queue', { mintUrl, operationId }); return; } @@ -222,16 +215,16 @@ export class MintQuoteProcessor { this.queue.push({ mintUrl, - quoteId, - quoteType, + operationId, + method, retryCount: 0, nextRetryAt: 0, }); - this.logger?.debug('Quote enqueued for processing', { + this.logger?.debug('Mint operation enqueued for processing', { mintUrl, - quoteId, - quoteType, + operationId, + method, queueLength: this.queue.length, }); @@ -301,97 +294,78 @@ export class MintQuoteProcessor { } private async processItem(item: QueueItem): Promise { - const { mintUrl, quoteId, quoteType } = item; + const { mintUrl, operationId, method } = item; - const handler = this.handlers.get(quoteType); + const handler = this.handlers.get(method); if (!handler) { - this.logger?.warn('No handler registered for quote type', { quoteType, mintUrl, quoteId }); + this.logger?.warn('No handler registered for mint method', { + method, + mintUrl, + operationId, + }); return; } - this.logger?.info('Processing mint quote', { + this.logger?.info('Processing mint operation', { mintUrl, - quoteId, - quoteType, + operationId, + method, attempt: item.retryCount + 1, }); - try { - await handler.process(mintUrl, quoteId); - this.logger?.info('Successfully processed mint quote', { mintUrl, quoteId, quoteType }); - } catch (err) { - throw err; // Let the outer catch handle it - } + await handler.process(mintUrl, operationId); + this.logger?.info('Successfully processed mint operation', { mintUrl, operationId, method }); } private handleProcessingError(item: QueueItem, err: unknown): void { - const { mintUrl, quoteId } = item; + const { mintUrl, operationId } = item; - // Handle specific mint operation errors if (err instanceof MintOperationError) { if (err.code === 20007) { - // Quote expired - we can't set it to EXPIRED as that's not a valid state - // Just log and move on, the quote will remain in its current state - this.logger?.warn('Mint quote expired', { mintUrl, quoteId }); + this.logger?.warn('Mint operation quote expired', { mintUrl, operationId }); return; - } else if (err.code === 20002) { - // Quote already issued - this.logger?.info('Mint quote already issued, updating state', { mintUrl, quoteId }); - this.updateQuoteState(mintUrl, quoteId, 'ISSUED'); + } + + if (err.code === 20002) { + this.logger?.info('Mint operation quote already issued', { mintUrl, operationId }); return; } - // Other mint errors - don't retry + this.logger?.error('Mint operation error, not retrying', { mintUrl, - quoteId, + operationId, code: err.code, detail: err.message, }); return; } - // Handle network errors with retry if (err instanceof NetworkError || (err instanceof Error && err.message.includes('network'))) { item.retryCount++; if (item.retryCount <= this.maxRetries) { - // Calculate exponential backoff const delay = this.baseRetryDelayMs * Math.pow(2, item.retryCount - 1); item.nextRetryAt = Date.now() + delay; this.logger?.warn('Network error, will retry', { mintUrl, - quoteId, + operationId, attempt: item.retryCount, maxRetries: this.maxRetries, retryInMs: delay, }); - // Re-add to queue for retry this.queue.push(item); return; } this.logger?.error('Max retries exceeded for network error', { mintUrl, - quoteId, + operationId, maxRetries: this.maxRetries, }); return; } - // Unknown error - log and don't retry - this.logger?.error('Failed to process mint quote', { mintUrl, quoteId, err }); - } - - private async updateQuoteState( - mintUrl: string, - quoteId: string, - state: MintQuoteState, - ): Promise { - try { - await this.quotes.updateStateFromRemote(mintUrl, quoteId, state); - } catch (err) { - this.logger?.error('Failed to update quote state', { mintUrl, quoteId, state, err }); - } + this.logger?.error('Failed to process mint operation', { mintUrl, operationId, err }); } } diff --git a/packages/core/services/watchers/MintOperationWatcherService.ts b/packages/core/services/watchers/MintOperationWatcherService.ts new file mode 100644 index 00000000..32a591e5 --- /dev/null +++ b/packages/core/services/watchers/MintOperationWatcherService.ts @@ -0,0 +1,372 @@ +import type { EventBus, CoreEvents } from '@core/events'; +import type { Logger } from '../../logging/Logger.ts'; +import type { SubscriptionManager, UnsubscribeHandler } from '@core/infra/SubscriptionManager.ts'; +import type { MintQuoteResponse } from '@cashu/cashu-ts'; +import type { MintService } from '../MintService'; +import type { MintOperationService, PendingMintOperation } from '@core/operations/mint'; + +type QuoteKey = string; // `${mintUrl}::${quoteId}` + +function toKey(mintUrl: string, quoteId: string): QuoteKey { + return `${mintUrl}::${quoteId}`; +} + +export interface MintOperationWatcherOptions { + // If true, on start() the watcher will also load and watch all pending mint operations + watchExistingPendingOnStart?: boolean; +} + +export class MintOperationWatcherService { + private readonly subs: SubscriptionManager; + private readonly mintService: MintService; + private readonly mintOperations: MintOperationService; + private readonly bus: EventBus; + private readonly logger?: Logger; + private readonly options: MintOperationWatcherOptions; + + private running = false; + private unsubscribeByKey = new Map(); + private operationIdByKey = new Map(); + private keyByOperationId = new Map(); + private offPending?: () => void; + private offExecuting?: () => void; + private offFinalized?: () => void; + private offUntrusted?: () => void; + + constructor( + subs: SubscriptionManager, + mintService: MintService, + mintOperations: MintOperationService, + bus: EventBus, + logger?: Logger, + options: MintOperationWatcherOptions = { watchExistingPendingOnStart: true }, + ) { + this.subs = subs; + this.mintService = mintService; + this.mintOperations = mintOperations; + this.bus = bus; + this.logger = logger; + this.options = options; + } + + isRunning(): boolean { + return this.running; + } + + async start(): Promise { + if (this.running) return; + this.running = true; + this.logger?.info('MintOperationWatcherService started'); + + this.offPending = this.bus.on('mint-op:pending', async ({ operation }) => { + if (operation.state !== 'pending') return; + if (!operation.quoteId) return; + + try { + await this.watchOperations([operation as PendingMintOperation]); + } catch (err) { + this.logger?.error('Failed to start watching pending mint operation', { + operationId: operation.id, + mintUrl: operation.mintUrl, + quoteId: operation.quoteId, + err, + }); + } + }); + + this.offExecuting = this.bus.on('mint-op:executing', async ({ operationId }) => { + try { + await this.stopWatchingOperation(operationId); + } catch (err) { + this.logger?.error('Failed to stop watching executing mint operation', { + operationId, + err, + }); + } + }); + + this.offFinalized = this.bus.on('mint-op:finalized', async ({ operationId }) => { + try { + await this.stopWatchingOperation(operationId); + } catch (err) { + this.logger?.error('Failed to stop watching finalized mint operation', { + operationId, + err, + }); + } + }); + + // Stop watching operations when mint is untrusted + this.offUntrusted = this.bus.on('mint:untrusted', async ({ mintUrl }) => { + try { + await this.stopWatchingMint(mintUrl); + } catch (err) { + this.logger?.error('Failed to stop watching mint operations on untrust', { mintUrl, err }); + } + }); + + if (this.options.watchExistingPendingOnStart) { + // Also watch any pending mint operations on startup (only for trusted mints) + try { + const pending = await this.mintOperations.getPendingOperations(); + const byMint = new Map(); + for (const operation of pending) { + if (!operation.quoteId) continue; + let arr = byMint.get(operation.mintUrl); + if (!arr) { + arr = []; + byMint.set(operation.mintUrl, arr); + } + arr.push(operation); + } + for (const [mintUrl, operations] of byMint.entries()) { + const trusted = await this.mintService.isTrustedMint(mintUrl); + if (!trusted) { + this.logger?.debug('Skipping pending mint operations for untrusted mint', { + mintUrl, + count: operations.length, + }); + continue; + } + + try { + await this.watchOperations(operations); + } catch (err) { + this.logger?.warn('Failed to watch pending mint operation batch', { + mintUrl, + count: operations.length, + err, + }); + } + } + } catch (err) { + this.logger?.error('Failed to load pending mint operations to watch', { err }); + } + } + } + + async stop(): Promise { + if (!this.running) return; + this.running = false; + + if (this.offPending) { + try { + this.offPending(); + } catch { + // ignore + } finally { + this.offPending = undefined; + } + } + + if (this.offExecuting) { + try { + this.offExecuting(); + } catch { + // ignore + } finally { + this.offExecuting = undefined; + } + } + + if (this.offFinalized) { + try { + this.offFinalized(); + } catch { + // ignore + } finally { + this.offFinalized = undefined; + } + } + + if (this.offUntrusted) { + try { + this.offUntrusted(); + } catch { + // ignore + } finally { + this.offUntrusted = undefined; + } + } + + const keys = Array.from(this.unsubscribeByKey.keys()); + for (const key of keys) { + await this.stopWatching(key); + } + this.logger?.info('MintOperationWatcherService stopped'); + } + + private async watchOperations(operations: PendingMintOperation[]): Promise { + if (!this.running) return; + if (operations.length === 0) return; + + const byMint = new Map(); + for (const operation of operations) { + if (!operation.quoteId) continue; + let group = byMint.get(operation.mintUrl); + if (!group) { + group = []; + byMint.set(operation.mintUrl, group); + } + group.push(operation); + } + + for (const [mintUrl, mintOperations] of byMint.entries()) { + const trusted = await this.mintService.isTrustedMint(mintUrl); + if (!trusted) { + this.logger?.debug('Skipping watch for untrusted mint', { mintUrl }); + continue; + } + + const uniqueByQuote = new Map(); + for (const operation of mintOperations) { + uniqueByQuote.set(operation.quoteId, operation); + } + + const toWatch = Array.from(uniqueByQuote.values()).filter( + (operation) => !this.unsubscribeByKey.has(toKey(mintUrl, operation.quoteId)), + ); + if (toWatch.length === 0) continue; + + const chunks: PendingMintOperation[][] = []; + for (let i = 0; i < toWatch.length; i += 100) { + chunks.push(toWatch.slice(i, i + 100)); + } + + for (const batch of chunks) { + const quoteIds = batch.map((operation) => operation.quoteId); + const operationIdByQuote = new Map(batch.map((operation) => [operation.quoteId, operation.id])); + const { subId, unsubscribe } = await this.subs.subscribe( + mintUrl, + 'bolt11_mint_quote', + quoteIds, + async (payload) => { + // Only act on state changes we care about + if (payload.state !== 'PAID' && payload.state !== 'ISSUED') return; + + const quoteId = payload.quote; + if (!quoteId) return; + const key = toKey(mintUrl, quoteId); + const operationId = this.operationIdByKey.get(key) ?? operationIdByQuote.get(quoteId); + if (!operationId) return; + + try { + const current = await this.mintOperations.getOperation(operationId); + if (!current || current.state !== 'pending') { + await this.stopWatching(key); + return; + } + + const observedAt = Date.now(); + const observedOperation: PendingMintOperation = { + ...current, + lastObservedRemoteState: payload.state, + lastObservedRemoteStateAt: observedAt, + updatedAt: observedAt, + }; + + await this.bus.emit('mint-op:quote-state-changed', { + mintUrl: observedOperation.mintUrl, + operationId: observedOperation.id, + operation: observedOperation, + quoteId: observedOperation.quoteId, + state: payload.state, + }); + } catch (err) { + this.logger?.error('Failed to emit pending mint operation update from remote update', { + operationId, + mintUrl, + quoteId, + state: payload.state, + err, + }); + } + + if (payload.state === 'ISSUED') { + await this.stopWatching(key); + return; + } + + try { + const current = await this.mintOperations.getOperation(operationId); + if (!current || current.state !== 'pending') { + await this.stopWatching(key); + } + } catch (err) { + this.logger?.warn('Failed to inspect mint operation after remote update', { + operationId, + mintUrl, + quoteId, + err, + }); + } + }, + ); + + let didUnsubscribe = false; + const remaining = new Set(quoteIds); + const groupUnsubscribeOnce: UnsubscribeHandler = async () => { + if (didUnsubscribe) return; + didUnsubscribe = true; + await unsubscribe(); + }; + + for (const operation of batch) { + const key = toKey(mintUrl, operation.quoteId); + const perKeyStop: UnsubscribeHandler = async () => { + if (remaining.has(operation.quoteId)) remaining.delete(operation.quoteId); + if (remaining.size === 0) { + await groupUnsubscribeOnce(); + } + }; + this.unsubscribeByKey.set(key, perKeyStop); + this.operationIdByKey.set(key, operation.id); + this.keyByOperationId.set(operation.id, key); + } + + this.logger?.debug('Watching mint operation batch', { mintUrl, subId, count: batch.length }); + } + } + } + + private async stopWatching(key: QuoteKey): Promise { + const unsubscribe = this.unsubscribeByKey.get(key); + if (!unsubscribe) return; + const operationId = this.operationIdByKey.get(key); + try { + await unsubscribe(); + } catch (err) { + this.logger?.warn('Unsubscribe watcher failed', { key, err }); + } finally { + this.unsubscribeByKey.delete(key); + this.operationIdByKey.delete(key); + if (operationId) { + this.keyByOperationId.delete(operationId); + } + } + } + + private async stopWatchingOperation(operationId: string): Promise { + const key = this.keyByOperationId.get(operationId); + if (!key) return; + await this.stopWatching(key); + } + + async stopWatchingMint(mintUrl: string): Promise { + this.logger?.info('Stopping all quote watchers for mint', { mintUrl }); + const prefix = `${mintUrl}::`; + const keysToStop: QuoteKey[] = []; + + for (const key of this.unsubscribeByKey.keys()) { + if (key.startsWith(prefix)) { + keysToStop.push(key); + } + } + + for (const key of keysToStop) { + await this.stopWatching(key); + } + + this.logger?.info('Stopped quote watchers for mint', { mintUrl, count: keysToStop.length }); + } +} diff --git a/packages/core/services/watchers/MintQuoteWatcherService.ts b/packages/core/services/watchers/MintQuoteWatcherService.ts deleted file mode 100644 index 62fcd040..00000000 --- a/packages/core/services/watchers/MintQuoteWatcherService.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { EventBus, CoreEvents } from '@core/events'; -import type { Logger } from '../../logging/Logger.ts'; -import type { MintQuoteRepository } from '../../repositories'; -import type { SubscriptionManager, UnsubscribeHandler } from '@core/infra/SubscriptionManager.ts'; -import type { MintQuoteResponse } from '@cashu/cashu-ts'; -import type { MintQuoteService } from '../MintQuoteService'; -import type { MintService } from '../MintService'; - -type QuoteKey = string; // `${mintUrl}::${quoteId}` - -function toKey(mintUrl: string, quoteId: string): QuoteKey { - return `${mintUrl}::${quoteId}`; -} - -export interface MintQuoteWatcherOptions { - // If true, on start() the watcher will also load and watch all quotes that are not ISSUED yet - watchExistingPendingOnStart?: boolean; -} - -export class MintQuoteWatcherService { - private readonly repo: MintQuoteRepository; - private readonly subs: SubscriptionManager; - private readonly mintService: MintService; - private readonly quotes: MintQuoteService; - private readonly bus: EventBus; - private readonly logger?: Logger; - private readonly options: MintQuoteWatcherOptions; - - private running = false; - private unsubscribeByKey = new Map(); - private offCreated?: () => void; - private offAdded?: () => void; - private offUntrusted?: () => void; - - constructor( - repo: MintQuoteRepository, - subs: SubscriptionManager, - mintService: MintService, - quotes: MintQuoteService, - bus: EventBus, - logger?: Logger, - options: MintQuoteWatcherOptions = { watchExistingPendingOnStart: true }, - ) { - this.repo = repo; - this.subs = subs; - this.mintService = mintService; - this.quotes = quotes; - this.bus = bus; - this.logger = logger; - this.options = options; - } - - isRunning(): boolean { - return this.running; - } - - async start(): Promise { - if (this.running) return; - this.running = true; - this.logger?.info('MintQuoteWatcherService started'); - - // Subscribe to newly created quotes - this.offCreated = this.bus.on('mint-quote:created', async ({ mintUrl, quoteId }) => { - try { - await this.watchQuote(mintUrl, quoteId); - } catch (err) { - this.logger?.error('Failed to start watching quote from event', { mintUrl, quoteId, err }); - } - }); - - // Also watch added quotes that are not in terminal state - this.offAdded = this.bus.on('mint-quote:added', async ({ mintUrl, quoteId, quote }) => { - // Only watch if not already in terminal state - if (quote.state !== 'ISSUED' && quote.state !== 'PAID') { - try { - await this.watchQuote(mintUrl, quoteId); - } catch (err) { - this.logger?.error('Failed to start watching added quote', { - mintUrl, - quoteId, - state: quote.state, - err, - }); - } - } - }); - - // Stop watching quotes when mint is untrusted - this.offUntrusted = this.bus.on('mint:untrusted', async ({ mintUrl }) => { - try { - await this.stopWatchingMint(mintUrl); - } catch (err) { - this.logger?.error('Failed to stop watching mint quotes on untrust', { mintUrl, err }); - } - }); - - if (this.options.watchExistingPendingOnStart) { - // Also watch any quotes that are not ISSUED yet (only for trusted mints) - try { - const pending = await this.repo.getPendingMintQuotes(); - const byMint = new Map(); - for (const q of pending) { - let arr = byMint.get(q.mintUrl); - if (!arr) { - arr = []; - byMint.set(q.mintUrl, arr); - } - arr.push(q.quote); - } - for (const [mintUrl, quoteIds] of byMint.entries()) { - // Only watch quotes for trusted mints - const trusted = await this.mintService.isTrustedMint(mintUrl); - if (!trusted) { - this.logger?.debug('Skipping pending quotes for untrusted mint', { - mintUrl, - count: quoteIds.length, - }); - continue; - } - - try { - await this.watchQuote(mintUrl, quoteIds); - } catch (err) { - this.logger?.warn('Failed to watch pending quotes batch', { - mintUrl, - count: quoteIds.length, - err, - }); - } - } - } catch (err) { - this.logger?.error('Failed to load pending mint quotes to watch', { err }); - } - } - } - - async stop(): Promise { - if (!this.running) return; - this.running = false; - - if (this.offCreated) { - try { - this.offCreated(); - } catch { - // ignore - } finally { - this.offCreated = undefined; - } - } - - if (this.offAdded) { - try { - this.offAdded(); - } catch { - // ignore - } finally { - this.offAdded = undefined; - } - } - - if (this.offUntrusted) { - try { - this.offUntrusted(); - } catch { - // ignore - } finally { - this.offUntrusted = undefined; - } - } - - const entries = Array.from(this.unsubscribeByKey.entries()); - this.unsubscribeByKey.clear(); - for (const [key, unsub] of entries) { - try { - await unsub(); - this.logger?.debug('Stopped watching quote', { key }); - } catch (err) { - this.logger?.warn('Failed to unsubscribe watcher', { key, err }); - } - } - this.logger?.info('MintQuoteWatcherService stopped'); - } - - async watchQuote(mintUrl: string, quoteOrQuotes: string | string[]): Promise { - if (!this.running) return; - - // Only watch quotes for trusted mints - const trusted = await this.mintService.isTrustedMint(mintUrl); - if (!trusted) { - this.logger?.debug('Skipping watch for untrusted mint', { mintUrl }); - return; - } - - const input = Array.isArray(quoteOrQuotes) ? quoteOrQuotes : [quoteOrQuotes]; - const unique = Array.from(new Set(input)); - // Filter out already-watched - const toWatch = unique.filter((id) => !this.unsubscribeByKey.has(toKey(mintUrl, id))); - if (toWatch.length === 0) return; - - // Chunk into batches of 100 - const chunks: string[][] = []; - for (let i = 0; i < toWatch.length; i += 100) { - chunks.push(toWatch.slice(i, i + 100)); - } - - for (const batch of chunks) { - const { subId, unsubscribe } = await this.subs.subscribe( - mintUrl, - 'bolt11_mint_quote', - batch, - async (payload) => { - // Only act on state changes we care about - if (payload.state !== 'PAID' && payload.state !== 'ISSUED') return; - - const quoteId = payload.quote; - if (!quoteId) return; - const key = toKey(mintUrl, quoteId); - - // Update the local state from the remote state - try { - await this.quotes.updateStateFromRemote(mintUrl, quoteId, payload.state); - } catch (err) { - this.logger?.error('Failed to update quote state from remote', { - mintUrl, - quoteId, - state: payload.state, - err, - }); - } - - // Stop watching if the quote reached a terminal state - if (payload.state === 'ISSUED') { - await this.stopWatching(key); - } - }, - ); - - // Per-batch unsubscribe wrapper - let didUnsubscribe = false; - const remaining = new Set(batch); - const groupUnsubscribeOnce: UnsubscribeHandler = async () => { - if (didUnsubscribe) return; - didUnsubscribe = true; - await unsubscribe(); - }; - - // Register per-quote stoppers that shrink the remaining set and - // unsubscribe the entire batch when the last quote is removed - for (const quoteId of batch) { - const key = toKey(mintUrl, quoteId); - const perKeyStop: UnsubscribeHandler = async () => { - if (remaining.has(quoteId)) remaining.delete(quoteId); - if (remaining.size === 0) { - await groupUnsubscribeOnce(); - } - }; - this.unsubscribeByKey.set(key, perKeyStop); - } - - this.logger?.debug('Watching mint quote batch', { mintUrl, subId, count: batch.length }); - } - } - - private async stopWatching(key: QuoteKey): Promise { - const unsubscribe = this.unsubscribeByKey.get(key); - if (!unsubscribe) return; - try { - await unsubscribe(); - } catch (err) { - this.logger?.warn('Unsubscribe watcher failed', { key, err }); - } finally { - this.unsubscribeByKey.delete(key); - } - } - - async stopWatchingMint(mintUrl: string): Promise { - this.logger?.info('Stopping all quote watchers for mint', { mintUrl }); - const prefix = `${mintUrl}::`; - const keysToStop: QuoteKey[] = []; - - for (const key of this.unsubscribeByKey.keys()) { - if (key.startsWith(prefix)) { - keysToStop.push(key); - } - } - - for (const key of keysToStop) { - await this.stopWatching(key); - } - - this.logger?.info('Stopped quote watchers for mint', { mintUrl, count: keysToStop.length }); - } -} diff --git a/packages/core/services/watchers/index.ts b/packages/core/services/watchers/index.ts index 2c40b7b7..09687d29 100644 --- a/packages/core/services/watchers/index.ts +++ b/packages/core/services/watchers/index.ts @@ -1,3 +1,3 @@ -export * from './MintQuoteWatcherService'; -export * from './MintQuoteProcessor'; +export * from './MintOperationWatcherService'; +export * from './MintOperationProcessor'; export * from './ProofStateWatcherService'; diff --git a/packages/core/test/integration/PauseResumeIntegration.test.ts b/packages/core/test/integration/PauseResumeIntegration.test.ts index 3e0b7064..3d0d03e8 100644 --- a/packages/core/test/integration/PauseResumeIntegration.test.ts +++ b/packages/core/test/integration/PauseResumeIntegration.test.ts @@ -18,12 +18,12 @@ describe('Pause/Resume Integration Test', () => { logger: new NullLogger(), // Use faster intervals for testing watchers: { - mintQuoteWatcher: { + mintOperationWatcher: { watchExistingPendingOnStart: true, }, }, processors: { - mintQuoteProcessor: { + mintOperationProcessor: { processIntervalMs: 500, baseRetryDelayMs: 1000, maxRetries: 3, @@ -42,17 +42,22 @@ describe('Pause/Resume Integration Test', () => { it('should pause and resume subscriptions with real mint', async () => { // Verify initial state - watchers and processor should be running - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); // Add mint first (as trusted, since createMintQuote requires trust) await manager.mint.addMint(mintUrl, { trusted: true }); // Create a mint quote - const quote1 = await manager.quotes.createMintQuote(mintUrl, 1); - expect(quote1.quote).toBeDefined(); - console.log('Created quote 1:', quote1.quote); + const pendingMint1 = await manager.ops.mint.prepare({ + mintUrl, + amount: 1, + method: 'bolt11', + methodData: {}, + }); + expect(pendingMint1.quoteId).toBeDefined(); + console.log('Created quote 1:', pendingMint1.quoteId); // Wait a bit for watchers to start watching await sleep(200); @@ -62,23 +67,28 @@ describe('Pause/Resume Integration Test', () => { await manager.pauseSubscriptions(); // Verify watchers and processor are stopped - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); // Create another quote while paused (this should still work - just creating locally) - const quote2 = await manager.quotes.createMintQuote(mintUrl, 1); - expect(quote2.quote).toBeDefined(); - console.log('Created quote 2 while paused:', quote2.quote); + const pendingMint2 = await manager.ops.mint.prepare({ + mintUrl, + amount: 1, + method: 'bolt11', + methodData: {}, + }); + expect(pendingMint2.quoteId).toBeDefined(); + console.log('Created quote 2 while paused:', pendingMint2.quoteId); // Resume subscriptions console.log('Resuming subscriptions...'); await manager.resumeSubscriptions(); // Verify watchers and processor are running again - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); console.log('Pause/Resume cycle completed successfully'); }, 30000); // 30 second timeout for this integration test @@ -88,19 +98,24 @@ describe('Pause/Resume Integration Test', () => { // First pause/resume cycle await manager.pauseSubscriptions(); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); await manager.resumeSubscriptions(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); // Second pause/resume cycle await manager.pauseSubscriptions(); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); await manager.resumeSubscriptions(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); // Create a quote after multiple cycles - const quote = await manager.quotes.createMintQuote(mintUrl, 1); - expect(quote.quote).toBeDefined(); + const pendingMint = await manager.ops.mint.prepare({ + mintUrl, + amount: 1, + method: 'bolt11', + methodData: {}, + }); + expect(pendingMint.quoteId).toBeDefined(); // Wait for it to potentially be redeemed await sleep(3000); @@ -115,8 +130,13 @@ describe('Pause/Resume Integration Test', () => { await manager.mint.addMint(mintUrl, { trusted: true }); // Create a quote with subscriptions active - const quote = await manager.quotes.createMintQuote(mintUrl, 1); - expect(quote.quote).toBeDefined(); + const pendingMint = await manager.ops.mint.prepare({ + mintUrl, + amount: 1, + method: 'bolt11', + methodData: {}, + }); + expect(pendingMint.quoteId).toBeDefined(); // Simulate OS tearing down connections without explicit pause // Just call resume directly (as if recovering from background) @@ -124,15 +144,9 @@ describe('Pause/Resume Integration Test', () => { await manager.resumeSubscriptions(); // Everything should still be running - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); - - // Set up event listener - const redeemedQuotes: string[] = []; - manager.on('mint-quote:redeemed', ({ quoteId }) => { - redeemedQuotes.push(quoteId); - }); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); // Wait for processing await sleep(5000); @@ -156,30 +170,30 @@ describe('Pause/Resume Integration Test', () => { seedGetter, logger: new NullLogger(), watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: false }, }, processors: { - mintQuoteProcessor: { disabled: false }, + mintOperationProcessor: { disabled: false }, }, }); // Verify initial state - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); // Pause await manager.pauseSubscriptions(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); // Resume await manager.resumeSubscriptions(); - // Verify configuration is respected - mintQuoteWatcher should stay disabled - expect(manager['mintQuoteWatcher']).toBeUndefined(); + // Verify configuration is respected - mintOperationWatcher should stay disabled + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); }); }); diff --git a/packages/core/test/integration/ReceiveOperationIntegration.test.ts b/packages/core/test/integration/ReceiveOperationIntegration.test.ts index a3a333e3..2c7f26a3 100644 --- a/packages/core/test/integration/ReceiveOperationIntegration.test.ts +++ b/packages/core/test/integration/ReceiveOperationIntegration.test.ts @@ -27,11 +27,11 @@ describe('ReceiveOperationService integration', () => { const baseConfig = { logger: new NullLogger(), watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }; @@ -63,8 +63,13 @@ describe('ReceiveOperationService integration', () => { await sender.mint.addMint(mintUrl, { trusted: true }); await receiver.mint.addMint(mintUrl, { trusted: true }); - const quote = await sender.quotes.createMintQuote(mintUrl, 50); - await sender.quotes.redeemMintQuote(mintUrl, quote.quote); + const pendingMint = await sender.ops.mint.prepare({ + mintUrl, + amount: 50, + method: 'bolt11', + methodData: {}, + }); + await sender.ops.mint.execute(pendingMint.id); const preparedSend = await sender.ops.send.prepare({ mintUrl, amount: 30 }); const { token } = await sender.ops.send.execute(preparedSend.id); diff --git a/packages/core/test/integration/auth-bat.test.ts b/packages/core/test/integration/auth-bat.test.ts index e2d14ca5..bb3c8d80 100644 --- a/packages/core/test/integration/auth-bat.test.ts +++ b/packages/core/test/integration/auth-bat.test.ts @@ -82,11 +82,11 @@ describe('Auth BAT (automated — password grant)', () => { repo: repositories, seedGetter: async () => new Uint8Array(64), watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); @@ -99,9 +99,14 @@ describe('Auth BAT (automated — password grant)', () => { expect(provider).toBeDefined(); expect(mgr.auth.getPoolSize(mintUrl)).toBe(0); - const quote = await mgr.quotes.createMintQuote(mintUrl, 1); - expect(quote).toBeDefined(); - expect(quote.quote).toBeDefined(); + const pendingMint = await mgr.ops.mint.prepare({ + mintUrl, + amount: 1, + method: 'bolt11', + methodData: {}, + }); + expect(pendingMint).toBeDefined(); + expect(pendingMint.quoteId).toBeDefined(); expect(mgr.auth.getPoolSize(mintUrl)).toBe(0); }); @@ -120,11 +125,11 @@ describe('Auth BAT (automated — password grant)', () => { repo: repositories, seedGetter: async () => new Uint8Array(64), watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); @@ -134,9 +139,14 @@ describe('Auth BAT (automated — password grant)', () => { const provider2 = mgr2.auth.getAuthProvider(mintUrl) as AuthProvider; expect(provider2).toBeDefined(); - const quote = await mgr2.quotes.createMintQuote(mintUrl, 1); - expect(quote).toBeDefined(); - expect(quote.quote).toBeDefined(); + const pendingMint = await mgr2.ops.mint.prepare({ + mintUrl, + amount: 1, + method: 'bolt11', + methodData: {}, + }); + expect(pendingMint).toBeDefined(); + expect(pendingMint.quoteId).toBeDefined(); await provider2.ensure!(2); expect(mgr2.auth.getPoolSize(mintUrl)).toBeGreaterThanOrEqual(2); diff --git a/packages/core/test/integration/auth-login.test.ts b/packages/core/test/integration/auth-login.test.ts index 3483ff24..f3285b9c 100644 --- a/packages/core/test/integration/auth-login.test.ts +++ b/packages/core/test/integration/auth-login.test.ts @@ -74,11 +74,11 @@ describe('Auth Login (automated — password grant)', () => { repo: repositories, seedGetter: async () => new Uint8Array(32), watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); }); @@ -101,11 +101,11 @@ describe('Auth Login (automated — password grant)', () => { repo: repositories, seedGetter: async () => new Uint8Array(32), watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); diff --git a/packages/core/test/unit/HistoryService.test.ts b/packages/core/test/unit/HistoryService.test.ts index 9f9e7fcc..69849c87 100644 --- a/packages/core/test/unit/HistoryService.test.ts +++ b/packages/core/test/unit/HistoryService.test.ts @@ -4,20 +4,42 @@ import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { HistoryRepository } from '../../repositories'; import type { HistoryEntry, MintHistoryEntry } from '../../models/History'; -import type { MintQuoteResponse } from '@cashu/cashu-ts'; +import type { PendingMintOperation } from '../../operations/mint'; -describe('HistoryService - mint-quote:added', () => { +describe('HistoryService - mint operations', () => { let service: HistoryService; let mockRepo: HistoryRepository; let eventBus: EventBus; let historyEntries: Map; let historyUpdateEvents: Array<{ mintUrl: string; entry: HistoryEntry }>; + const makePendingOperation = ( + quoteId: string, + overrides: Partial = {}, + ): PendingMintOperation => + ({ + id: `mint-op-${quoteId}`, + state: 'pending', + mintUrl: 'https://mint.test', + method: 'bolt11', + methodData: {}, + amount: 1000, + unit: 'sat', + quoteId, + request: `request-${quoteId}`, + expiry: Math.floor(Date.now() / 1000) + 3600, + outputData: { keep: [], send: [] }, + createdAt: Date.now(), + updatedAt: Date.now(), + lastObservedRemoteState: 'UNPAID', + lastObservedRemoteStateAt: Date.now(), + ...overrides, + }) as PendingMintOperation; + beforeEach(() => { historyEntries = new Map(); historyUpdateEvents = []; - // Mock repository mockRepo = { async addHistoryEntry(entry: Omit): Promise { const id = Math.random().toString(36).substring(7); @@ -48,206 +70,118 @@ describe('HistoryService - mint-quote:added', () => { async getReceiveHistoryEntry(): Promise { return null; }, - async updateHistoryEntryState(): Promise { - // Not used in these tests - }, + async updateHistoryEntryState(): Promise {}, async getHistoryEntryById(): Promise { - // Not used in these tests - return null - }, - async updateHistoryEntry(): Promise { - // Not used in these tests - return Array.from(historyEntries.values())[0] as MintHistoryEntry; - }, - async updateSendHistoryState(): Promise { - // Not used in these tests + return null; }, - async deleteHistoryEntry(): Promise { - // Not used in these tests + async updateHistoryEntry(entry: HistoryEntry): Promise { + historyEntries.set(entry.id, entry); + return entry; }, - + async updateSendHistoryState(): Promise {}, + async deleteHistoryEntry(): Promise {}, } as HistoryRepository; - // Create event bus eventBus = new EventBus(); - - // Track history:updated events eventBus.on('history:updated', (payload) => { historyUpdateEvents.push(payload); }); - // Create service service = new HistoryService(mockRepo, eventBus); }); - it('creates history entry for added mint quote', async () => { - const quote: MintQuoteResponse = { - quote: 'added-quote-1', + it('creates history entry for mint-op:pending', async () => { + const operation = makePendingOperation('pending-quote', { amount: 1000, - state: 'UNPAID', request: 'lnbc1000...', - unit: 'sat', - } as MintQuoteResponse; + lastObservedRemoteState: 'UNPAID', + }); - await eventBus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'added-quote-1', - quote, + await eventBus.emit('mint-op:pending', { + mintUrl: operation.mintUrl, + operationId: operation.id, + operation, }); - // Give async handler time to complete await new Promise((resolve) => setTimeout(resolve, 10)); - // Check history entry was created expect(historyEntries.size).toBe(1); const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; expect(entry.type).toBe('mint'); - expect(entry.mintUrl).toBe('https://mint.test'); - expect(entry.quoteId).toBe('added-quote-1'); - expect(entry.amount).toBe(1000); + expect(entry.mintUrl).toBe(operation.mintUrl); + expect(entry.quoteId).toBe(operation.quoteId); + expect(entry.amount).toBe(operation.amount); expect(entry.state).toBe('UNPAID'); - expect(entry.unit).toBe('sat'); - expect(entry.paymentRequest).toBe('lnbc1000...'); - - // Check history:updated event was emitted + expect(entry.unit).toBe(operation.unit); + expect(entry.paymentRequest).toBe(operation.request); expect(historyUpdateEvents.length).toBe(1); - expect(historyUpdateEvents[0]?.mintUrl).toBe('https://mint.test'); - expect(historyUpdateEvents[0]?.entry.id).toBeDefined(); }); - it('does not create duplicate history entry if already exists', async () => { - // Pre-create a history entry - await mockRepo.addHistoryEntry({ - type: 'mint', - mintUrl: 'https://mint.test', - quoteId: 'existing-quote', - amount: 500, - state: 'UNPAID', - unit: 'sat', - paymentRequest: 'lnbc500...', - createdAt: Date.now(), - } as Omit); - - const quote: MintQuoteResponse = { - quote: 'existing-quote', + it('updates existing history entry on mint-op:quote-state-changed', async () => { + const operation = makePendingOperation('stateful-quote', { amount: 500, - state: 'PAID', // Different state request: 'lnbc500...', - unit: 'sat', - } as MintQuoteResponse; - - await eventBus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'existing-quote', - quote, + lastObservedRemoteState: 'UNPAID', }); - // Give async handler time to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Should still only have one entry - expect(historyEntries.size).toBe(1); - - // No new history:updated event should be emitted - expect(historyUpdateEvents.length).toBe(0); - }); + await mockRepo.addHistoryEntry({ + type: 'mint', + mintUrl: operation.mintUrl, + quoteId: operation.quoteId, + amount: operation.amount, + state: 'UNPAID', + unit: operation.unit, + paymentRequest: operation.request, + createdAt: operation.createdAt, + } as Omit); - it('creates history entries for PAID quotes', async () => { - const quote: MintQuoteResponse = { - quote: 'paid-quote', - amount: 2000, + await eventBus.emit('mint-op:quote-state-changed', { + mintUrl: operation.mintUrl, + operationId: operation.id, + operation, + quoteId: operation.quoteId, state: 'PAID', - request: 'lnbc2000...', - unit: 'sat', - } as MintQuoteResponse; - - await eventBus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'paid-quote', - quote, }); - // Give async handler time to complete await new Promise((resolve) => setTimeout(resolve, 10)); - // Check history entry was created with PAID state - expect(historyEntries.size).toBe(1); const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; expect(entry.state).toBe('PAID'); - expect(entry.amount).toBe(2000); + expect(historyUpdateEvents.length).toBe(1); + expect(historyUpdateEvents[0]?.entry.type).toBe('mint'); }); - it('creates history entries for ISSUED quotes', async () => { - const quote: MintQuoteResponse = { - quote: 'issued-quote', - amount: 3000, - state: 'ISSUED', - request: 'lnbc3000...', - unit: 'sat', - } as MintQuoteResponse; - - await eventBus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'issued-quote', - quote, + it('updates an existing history entry instead of creating a duplicate pending entry', async () => { + const operation = makePendingOperation('existing-quote', { + amount: 750, + request: 'lnbc750...', + lastObservedRemoteState: 'PAID', }); - // Give async handler time to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Check history entry was created with ISSUED state - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; - expect(entry.state).toBe('ISSUED'); - expect(entry.amount).toBe(3000); - }); - - it('handles multiple mints correctly', async () => { - const quote1: MintQuoteResponse = { - quote: 'mint1-quote', - amount: 100, - state: 'PAID', - request: 'lnbc100...', - unit: 'sat', - } as MintQuoteResponse; - - const quote2: MintQuoteResponse = { - quote: 'mint2-quote', - amount: 200, + await mockRepo.addHistoryEntry({ + type: 'mint', + mintUrl: operation.mintUrl, + quoteId: operation.quoteId, + amount: 10, state: 'UNPAID', - request: 'lnbc200...', - unit: 'sat', - } as MintQuoteResponse; - - await eventBus.emit('mint-quote:added', { - mintUrl: 'https://mint1.test', - quoteId: 'mint1-quote', - quote: quote1, - }); + unit: operation.unit, + paymentRequest: 'old-request', + createdAt: operation.createdAt, + } as Omit); - await eventBus.emit('mint-quote:added', { - mintUrl: 'https://mint2.test', - quoteId: 'mint2-quote', - quote: quote2, + await eventBus.emit('mint-op:pending', { + mintUrl: operation.mintUrl, + operationId: operation.id, + operation, }); - // Give async handlers time to complete await new Promise((resolve) => setTimeout(resolve, 10)); - // Should have two entries - expect(historyEntries.size).toBe(2); - - // Check both entries have correct mint URLs - const entries = Array.from(historyEntries.values()) as MintHistoryEntry[]; - const mint1Entry = entries.find((e) => e.quoteId === 'mint1-quote'); - const mint2Entry = entries.find((e) => e.quoteId === 'mint2-quote'); - - expect(mint1Entry?.mintUrl).toBe('https://mint1.test'); - expect(mint1Entry?.amount).toBe(100); - expect(mint2Entry?.mintUrl).toBe('https://mint2.test'); - expect(mint2Entry?.amount).toBe(200); - - // Should have emitted two history:updated events - expect(historyUpdateEvents.length).toBe(2); + expect(historyEntries.size).toBe(1); + const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; + expect(entry.amount).toBe(operation.amount); + expect(entry.paymentRequest).toBe(operation.request); + expect(entry.state).toBe('PAID'); + expect(historyUpdateEvents.length).toBe(1); }); }); diff --git a/packages/core/test/unit/Manager.test.ts b/packages/core/test/unit/Manager.test.ts index a3baf1c9..f157b7f4 100644 --- a/packages/core/test/unit/Manager.test.ts +++ b/packages/core/test/unit/Manager.test.ts @@ -25,15 +25,15 @@ describe('initializeCoco', () => { expect(manager).toBeInstanceOf(Manager); // Verify watchers are running (they have isRunning methods) - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); // Verify processor is running - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should initialize repositories', async () => { @@ -56,11 +56,12 @@ describe('initializeCoco', () => { expect(manager['logger']).toBeInstanceOf(NullLogger); expect(manager.ops.send).toBe(manager.send); expect(manager.ops.receive).toBe(manager.receive); + expect(manager.ops.mint).toBeDefined(); expect(manager.ops.melt).toBeDefined(); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should accept custom logger', async () => { @@ -72,27 +73,27 @@ describe('initializeCoco', () => { expect(manager['logger']).toBe(customLogger); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); }); describe('watchers configuration', () => { - it('should disable mintQuoteWatcher when explicitly disabled', async () => { + it('should disable mintOperationWatcher when explicitly disabled', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, }, }); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should disable proofStateWatcher when explicitly disabled', async () => { @@ -104,116 +105,116 @@ describe('initializeCoco', () => { }); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationWatcher(); + await manager.disableMintOperationProcessor(); }); it('should disable all watchers when all are explicitly disabled', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, }); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); - it('should pass options to mintQuoteWatcher when not disabled', async () => { + it('should pass options to mintOperationWatcher when not disabled', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { + mintOperationWatcher: { watchExistingPendingOnStart: false, }, }, }); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should enable with options even when disabled is explicitly false', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { + mintOperationWatcher: { disabled: false, watchExistingPendingOnStart: true, }, }, }); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); }); describe('processors configuration', () => { - it('should disable mintQuoteProcessor when explicitly disabled', async () => { + it('should disable mintOperationProcessor when explicitly disabled', async () => { const manager = await initializeCoco({ ...baseConfig, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); - expect(manager['mintQuoteProcessor']).toBeUndefined(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']).toBeUndefined(); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); }); - it('should pass options to mintQuoteProcessor when not disabled', async () => { + it('should pass options to mintOperationProcessor when not disabled', async () => { const manager = await initializeCoco({ ...baseConfig, processors: { - mintQuoteProcessor: { + mintOperationProcessor: { processIntervalMs: 5000, maxRetries: 3, }, }, }); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should enable with options even when disabled is explicitly false', async () => { const manager = await initializeCoco({ ...baseConfig, processors: { - mintQuoteProcessor: { + mintOperationProcessor: { disabled: false, processIntervalMs: 1000, }, }, }); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); }); @@ -222,45 +223,45 @@ describe('initializeCoco', () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: false }, }, processors: { - mintQuoteProcessor: { disabled: false }, + mintOperationProcessor: { disabled: false }, }, }); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should support options with mixed configuration', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { + mintOperationWatcher: { watchExistingPendingOnStart: false, }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { + mintOperationProcessor: { processIntervalMs: 10000, maxRetries: 5, }, }, }); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationWatcher(); + await manager.disableMintOperationProcessor(); }); }); @@ -283,9 +284,9 @@ describe('initializeCoco', () => { expect(pluginInitMock).toHaveBeenCalled(); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); }); @@ -296,12 +297,12 @@ describe('initializeCoco', () => { watchers: {}, }); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should handle empty processors config object', async () => { @@ -310,11 +311,11 @@ describe('initializeCoco', () => { processors: {}, }); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should handle empty config objects for both watchers and processors', async () => { @@ -324,30 +325,30 @@ describe('initializeCoco', () => { processors: {}, }); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should handle all features disabled', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); // Should still have API access expect(manager.mint).toBeDefined(); @@ -361,11 +362,11 @@ describe('initializeCoco', () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); @@ -382,16 +383,16 @@ describe('initializeCoco', () => { it('should pause and stop all watchers and processors', async () => { const manager = await initializeCoco(baseConfig); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); await manager.pauseSubscriptions(); // After pause, watchers and processor are disabled (set to undefined) - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); }); it('should resume and restart all watchers and processors', async () => { @@ -400,13 +401,13 @@ describe('initializeCoco', () => { await manager.pauseSubscriptions(); await manager.resumeSubscriptions(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should be idempotent - multiple pause calls should not error', async () => { @@ -417,9 +418,9 @@ describe('initializeCoco', () => { await manager.pauseSubscriptions(); // After pause, watchers and processor are disabled (set to undefined) - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); }); it('should be idempotent - multiple resume calls should not error', async () => { @@ -430,13 +431,13 @@ describe('initializeCoco', () => { await manager.resumeSubscriptions(); await manager.resumeSubscriptions(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should handle resume without prior pause (OS connection teardown scenario)', async () => { @@ -445,70 +446,70 @@ describe('initializeCoco', () => { // Simulate OS killing connections - just call resume without pause await manager.resumeSubscriptions(); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should respect original configuration - disabled watchers stay disabled', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: false }, }, processors: { - mintQuoteProcessor: { disabled: false }, + mintOperationProcessor: { disabled: false }, }, }); - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); await manager.pauseSubscriptions(); await manager.resumeSubscriptions(); - // mintQuoteWatcher should remain undefined (was disabled) - expect(manager['mintQuoteWatcher']).toBeUndefined(); + // mintOperationWatcher should remain undefined (was disabled) + expect(manager['mintOperationWatcher']).toBeUndefined(); // Others should be running again expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']?.isRunning()).toBe(true); + expect(manager['mintOperationProcessor']?.isRunning()).toBe(true); await manager.disableProofStateWatcher(); - await manager.disableMintQuoteProcessor(); + await manager.disableMintOperationProcessor(); }); it('should respect original configuration - disabled processor stays disabled', async () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: false }, + mintOperationWatcher: { disabled: false }, proofStateWatcher: { disabled: false }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); await manager.pauseSubscriptions(); await manager.resumeSubscriptions(); // Watchers should be running again - expect(manager['mintQuoteWatcher']?.isRunning()).toBe(true); + expect(manager['mintOperationWatcher']?.isRunning()).toBe(true); expect(manager['proofStateWatcher']?.isRunning()).toBe(true); // Processor should remain undefined (was disabled) - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); - await manager.disableMintQuoteWatcher(); + await manager.disableMintOperationWatcher(); await manager.disableProofStateWatcher(); }); @@ -516,11 +517,11 @@ describe('initializeCoco', () => { const manager = await initializeCoco({ ...baseConfig, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); @@ -528,9 +529,9 @@ describe('initializeCoco', () => { await manager.resumeSubscriptions(); // All should remain undefined - expect(manager['mintQuoteWatcher']).toBeUndefined(); + expect(manager['mintOperationWatcher']).toBeUndefined(); expect(manager['proofStateWatcher']).toBeUndefined(); - expect(manager['mintQuoteProcessor']).toBeUndefined(); + expect(manager['mintOperationProcessor']).toBeUndefined(); }); }); }); diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts new file mode 100644 index 00000000..bd97e451 --- /dev/null +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -0,0 +1,198 @@ +import { describe, it, beforeEach, expect, mock, type Mock } from 'bun:test'; +import { OutputData, type MintQuoteBolt11Response, type Wallet } from '@cashu/cashu-ts'; +import { MintBolt11Handler } from '../../infra/handlers/mint/MintBolt11Handler'; +import { MintOperationError } from '../../models/Error'; +import { EventBus } from '../../events/EventBus'; +import type { CoreEvents } from '../../events/types'; +import type { PendingContext, PrepareContext, RecoverExecutingContext } from '../../operations/mint'; +import { serializeOutputData } from '../../utils'; +import type { ProofService } from '../../services/ProofService'; +import type { WalletService } from '../../services/WalletService'; +import type { MintService } from '../../services/MintService'; +import type { MintAdapter } from '../../infra'; +import type { ProofRepository } from '../../repositories'; +import type { Logger } from '../../logging/Logger'; + +describe('MintBolt11Handler', () => { + const mintUrl = 'https://mint.test'; + const quoteId = 'quote-1'; + const keysetId = 'keyset-1'; + + let handler: MintBolt11Handler; + let wallet: Wallet; + let mintAdapter: MintAdapter; + let proofService: ProofService; + let proofRepository: ProofRepository; + let walletService: WalletService; + let mintService: MintService; + let eventBus: EventBus; + let logger: Logger; + + const outputData = serializeOutputData({ + keep: [ + new OutputData( + { + amount: 10, + id: keysetId, + B_: 'B_out_1', + }, + BigInt(1), + new TextEncoder().encode('out-1'), + ), + ], + send: [], + }); + + const operation = { + id: 'op-1', + state: 'init' as const, + mintUrl, + amount: 10, + unit: 'sat', + method: 'bolt11' as const, + methodData: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const quote: MintQuoteBolt11Response = { + quote: quoteId, + request: 'lnbc1test', + amount: 10, + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + }; + + const executingOperation = { + ...operation, + state: 'executing' as const, + quoteId, + request: quote.request, + expiry: quote.expiry, + lastObservedRemoteState: 'PAID' as const, + lastObservedRemoteStateAt: Date.now(), + outputData, + }; + + const buildPrepareContext = (): PrepareContext<'bolt11'> => ({ + operation, + wallet, + mintAdapter, + proofService, + proofRepository, + walletService, + mintService, + eventBus, + logger, + }); + + const buildRecoverContext = (): RecoverExecutingContext<'bolt11'> => ({ + operation: executingOperation, + wallet, + mintAdapter, + proofService, + proofRepository, + walletService, + mintService, + eventBus, + logger, + }); + + const buildPendingContext = (): PendingContext<'bolt11'> => ({ + operation: { + ...executingOperation, + state: 'pending', + }, + wallet, + mintAdapter, + proofService, + proofRepository, + walletService, + mintService, + eventBus, + logger, + }); + + beforeEach(() => { + handler = new MintBolt11Handler(); + + wallet = { + createMintQuoteBolt11: mock(async () => quote), + mintProofsBolt11: mock(async () => { + throw new MintOperationError(20007, 'Quote expired'); + }), + } as unknown as Wallet; + + mintAdapter = { + checkMintQuoteState: mock(async (): Promise => quote), + } as unknown as MintAdapter; + + proofService = { + createOutputsAndIncrementCounters: mock(async () => ({ keep: outputData.keep, send: [] })), + saveProofs: mock(async () => {}), + recoverProofsFromOutputData: mock(async () => []), + } as unknown as ProofService; + + proofRepository = {} as ProofRepository; + walletService = {} as WalletService; + mintService = {} as MintService; + eventBus = new EventBus(); + logger = { info: mock(() => {}) } as unknown as Logger; + }); + + describe('recoverExecuting', () => { + it('returns a terminal result when the mint quote expired during execution', async () => { + const result = await handler.recoverExecuting(buildRecoverContext()); + + expect(result).toEqual({ + status: 'TERMINAL', + error: `Recovered: quote ${quoteId} expired while executing mint`, + }); + expect((wallet.mintProofsBolt11 as Mock).mock.calls.length).toBe(1); + expect((proofService.saveProofs as Mock).mock.calls.length).toBe(0); + }); + }); + + describe('prepare', () => { + it('creates a remote quote when no imported quote is provided', async () => { + const result = await handler.prepare(buildPrepareContext()); + + expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(1); + expect(result.quoteId).toBe(quoteId); + expect(result.amount).toBe(quote.amount); + expect(result.request).toBe(quote.request); + expect(result.outputData.keep).toHaveLength(1); + expect(result.outputData.send).toEqual([]); + expect(result.outputData.keep[0]?.blindedMessage).toEqual(outputData.keep[0]?.blindedMessage); + expect(result.lastObservedRemoteState).toBe('PAID'); + }); + + it('uses the imported quote snapshot without creating a new remote quote', async () => { + const importedQuote = { + ...quote, + quote: 'quote-imported', + state: 'UNPAID' as const, + }; + + const result = await handler.prepare({ + ...buildPrepareContext(), + importedQuote, + }); + + expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); + expect(result.quoteId).toBe(importedQuote.quote); + expect(result.lastObservedRemoteState).toBe('UNPAID'); + }); + }); + + describe('checkPending', () => { + it('returns the observed remote state with a normalized ready category', async () => { + const result = await handler.checkPending(buildPendingContext()); + + expect(result.observedRemoteState).toBe('PAID'); + expect(result.category).toBe('ready'); + expect(result.observedRemoteStateAt).toEqual(expect.any(Number)); + }); + }); +}); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts new file mode 100644 index 00000000..2fc65b68 --- /dev/null +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -0,0 +1,503 @@ +import { describe, it, beforeEach, expect, mock, type Mock } from 'bun:test'; +import { OutputData, type MintQuoteBolt11Response, type Proof } from '@cashu/cashu-ts'; +import { EventBus } from '../../events/EventBus'; +import type { CoreEvents } from '../../events/types'; +import { MintOperationService } from '../../operations/mint/MintOperationService'; +import type { + ExecutingMintOperation, + InitMintOperation, + PendingMintOperation, +} from '../../operations/mint/MintOperation'; +import type { + MintExecutionResult, + MintMethodHandler, + PendingMintCheckResult, + RecoverExecutingResult, +} from '../../operations/mint/MintMethodHandler'; +import type { MintHandlerProvider } from '../../infra/handlers/mint'; +import { MemoryMintOperationRepository } from '../../repositories/memory/MemoryMintOperationRepository'; +import { MemoryProofRepository } from '../../repositories/memory/MemoryProofRepository'; +import type { MintService } from '../../services/MintService'; +import type { WalletService } from '../../services/WalletService'; +import type { ProofService } from '../../services/ProofService'; +import type { MintAdapter } from '../../infra/MintAdapter'; +import { serializeOutputData } from '../../utils'; +import type { CoreProof } from '../../types'; + +describe('MintOperationService', () => { + const mintUrl = 'https://mint.test'; + const quoteId = 'quote-1'; + const keysetId = 'keyset-1'; + + let operationRepo: MemoryMintOperationRepository; + let proofRepo: MemoryProofRepository; + let proofService: ProofService; + let mintService: MintService; + let walletService: WalletService; + let mintAdapter: MintAdapter; + let eventBus: EventBus; + let handler: MintMethodHandler<'bolt11'>; + let handlerProvider: MintHandlerProvider; + let service: MintOperationService; + + const makeProof = (secret: string): Proof => + ({ + id: keysetId, + amount: 10, + secret, + C: `C_${secret}`, + }) as Proof; + + const makeSerializedOutputData = (secret: string) => + serializeOutputData({ + keep: [ + new OutputData( + { + amount: 10, + id: keysetId, + B_: `B_${secret}`, + }, + BigInt(1), + new TextEncoder().encode(secret), + ), + ], + send: [], + }); + + const toCoreProof = (secret: string, operationId: string): CoreProof => ({ + id: keysetId, + amount: 10, + secret, + C: `C_${secret}`, + mintUrl, + state: 'ready', + createdByOperationId: operationId, + }); + + const makeInitOp = (id: string): InitMintOperation => ({ + id, + state: 'init', + mintUrl, + method: 'bolt11', + methodData: {}, + amount: 10, + unit: 'sat', + quoteId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const makePendingOp = (id: string, secret = 'out-1'): PendingMintOperation => ({ + ...makeInitOp(id), + state: 'pending', + quoteId, + amount: 10, + request: 'lnbc1test', + expiry: Math.floor(Date.now() / 1000) + 3600, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: Date.now(), + outputData: makeSerializedOutputData(secret), + }); + + const makeExecutingOp = (id: string, secret = 'out-1'): ExecutingMintOperation => ({ + ...makePendingOp(id, secret), + state: 'executing', + }); + + beforeEach(async () => { + operationRepo = new MemoryMintOperationRepository(); + proofRepo = new MemoryProofRepository(); + eventBus = new EventBus(); + + const mockPrepare = mock(async ({ operation }: { operation: InitMintOperation }) => { + return makePendingOp(operation.id); + }); + + const mockExecute = mock(async (): Promise => { + return { status: 'ISSUED', proofs: [makeProof('out-1')] }; + }); + + const mockRecoverExecuting = mock(async (): Promise => { + return { status: 'PENDING' }; + }); + + const mockCheckPending = mock(async (): Promise> => ({ + observedRemoteState: 'UNPAID', + observedRemoteStateAt: Date.now(), + category: 'waiting', + })); + + handler = { + prepare: mockPrepare, + execute: mockExecute, + recoverExecuting: mockRecoverExecuting, + checkPending: mockCheckPending, + }; + + handlerProvider = { + get: mock(() => handler), + } as unknown as MintHandlerProvider; + + proofService = { + saveProofs: mock(async (_mintUrl: string, proofs: CoreProof[]) => { + await proofRepo.saveProofs(mintUrl, proofs); + }), + recoverProofsFromOutputData: mock(async (_mintUrl: string, _outputData, options) => { + if (!options?.createdByOperationId) { + return []; + } + await proofRepo.saveProofs(mintUrl, [toCoreProof('out-1', options.createdByOperationId)]); + return [makeProof('out-1')]; + }), + } as unknown as ProofService; + + mintService = { + isTrustedMint: mock(async () => true), + } as unknown as MintService; + + walletService = { + getWalletWithActiveKeysetId: mock(async () => ({ wallet: {} })), + } as unknown as WalletService; + + mintAdapter = {} as MintAdapter; + + service = new MintOperationService( + handlerProvider, + operationRepo, + proofRepo, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + ); + }); + + it('prepareNewQuote persists a pending operation and emits mint-op:pending', async () => { + const pendingEvents: Array = []; + eventBus.on('mint-op:pending', (event) => { + pendingEvents.push(event); + }); + + (handler.prepare as Mock).mockImplementationOnce(async ({ operation }: { operation: InitMintOperation }) => ({ + ...makePendingOp(operation.id), + quoteId: 'quote-created', + request: 'lnbc1created', + lastObservedRemoteState: 'UNPAID', + })); + + const pending = await service.prepareNewQuote(mintUrl, 10, 'sat'); + + expect(pending.state).toBe('pending'); + expect(pending.quoteId).toBe('quote-created'); + expect(pendingEvents).toHaveLength(1); + expect(pendingEvents[0]?.operationId).toBe(pending.id); + const createdOperation = pendingEvents[0]?.operation as PendingMintOperation | undefined; + expect(createdOperation?.quoteId).toBe('quote-created'); + expect(createdOperation?.request).toBe('lnbc1created'); + expect(createdOperation?.lastObservedRemoteState).toBe('UNPAID'); + }); + + it('importQuote persists a pending operation and emits mint-op:pending', async () => { + const pendingEvents: Array = []; + eventBus.on('mint-op:pending', (event) => { + pendingEvents.push(event); + }); + + const importedQuote: MintQuoteBolt11Response = { + quote: 'quote-imported', + request: 'lnbc1imported', + amount: 12, + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + }; + + (handler.prepare as Mock).mockImplementationOnce(async ({ operation }: { operation: InitMintOperation }) => ({ + ...makePendingOp(operation.id), + quoteId: importedQuote.quote, + amount: importedQuote.amount, + request: importedQuote.request, + expiry: importedQuote.expiry, + lastObservedRemoteState: importedQuote.state, + })); + + const pending = await service.importQuote(mintUrl, importedQuote, 'bolt11', {}); + + expect(pending.state).toBe('pending'); + expect(pending.quoteId).toBe(importedQuote.quote); + expect(pendingEvents).toHaveLength(1); + expect(pendingEvents[0]?.operationId).toBe(pending.id); + const importedOperation = pendingEvents[0]?.operation as PendingMintOperation | undefined; + expect(importedOperation?.quoteId).toBe(importedQuote.quote); + expect(importedOperation?.request).toBe(importedQuote.request); + expect(importedOperation?.lastObservedRemoteState).toBe(importedQuote.state); + }); + + it('importQuote rejects unsupported quote units', async () => { + const importedQuote: MintQuoteBolt11Response = { + quote: 'quote-usd', + request: 'lnbc1imported', + amount: 12, + unit: 'usd', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + }; + + await expect(service.importQuote(mintUrl, importedQuote, 'bolt11', {})).rejects.toThrow( + "Unsupported mint unit 'usd'. Only 'sat' is currently supported.", + ); + + expect(handler.prepare).not.toHaveBeenCalled(); + }); + + it('prepare + finalize runs init -> pending -> execute for an existing tracked operation', async () => { + const quoteStateEvents: Array = []; + const finalizedEvents: Array = []; + eventBus.on('mint-op:quote-state-changed', (event) => { + quoteStateEvents.push(event); + }); + eventBus.on('mint-op:finalized', (event) => { + finalizedEvents.push(event); + }); + + const initOp = makeInitOp('mint-op-redeem'); + await operationRepo.create(initOp); + + const pending = await service.prepare(initOp.id); + const finalized = await service.finalize(pending.id); + + expect(finalized?.state).toBe('finalized'); + + const stored = await operationRepo.getByQuoteId(mintUrl, quoteId); + expect(stored.length).toBe(1); + expect(stored[0]?.state).toBe('finalized'); + + const saved = await proofRepo.getProofBySecret(mintUrl, 'out-1'); + expect(saved).not.toBeNull(); + expect(saved?.createdByOperationId).toBe(finalized?.id); + + expect(quoteStateEvents.length).toBe(1); + expect(quoteStateEvents[0]?.quoteId).toBe(quoteId); + expect(quoteStateEvents[0]?.state).toBe('ISSUED'); + expect(finalizedEvents.length).toBe(1); + expect(finalizedEvents[0]?.operationId).toBe(finalized?.id); + expect(finalizedEvents[0]?.operation.state).toBe('finalized'); + }); + + it('finalize is idempotent after finalize', async () => { + const initOp = makeInitOp('mint-op-idempotent'); + await operationRepo.create(initOp); + + const pending = await service.prepare(initOp.id); + const first = await service.finalize(pending.id); + const second = await service.finalize(first.id); + + expect(first?.state).toBe('finalized'); + expect(second?.id).toBe(first?.id); + + const ops = await operationRepo.getByQuoteId(mintUrl, quoteId); + expect(ops.length).toBe(1); + }); + + it('recoverExecutingOperation finalizes when handler marks FINALIZED', async () => { + const op = makeExecutingOp('exec-1'); + await operationRepo.create(op); + + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ status: 'FINALIZED' }); + + await service.recoverExecutingOperation(op); + + const stored = await operationRepo.getById(op.id); + expect(stored?.state).toBe('finalized'); + }); + + it('recoverExecutingOperation returns to pending when quote was not issued remotely', async () => { + const op = makeExecutingOp('exec-2'); + await operationRepo.create(op); + + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ + status: 'PENDING', + error: 'Recovered: quote not issued remotely', + }); + + await service.recoverExecutingOperation(op); + + const stored = await operationRepo.getById(op.id); + expect(stored?.state).toBe('pending'); + expect(stored?.error).toBe('Recovered: quote not issued remotely'); + }); + + it('recoverExecutingOperation returns to pending when proofs are not recoverable', async () => { + const op = makeExecutingOp('exec-3'); + await operationRepo.create(op); + + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ status: 'FINALIZED' }); + (proofService.recoverProofsFromOutputData as Mock).mockResolvedValueOnce([]); + + await service.recoverExecutingOperation(op); + + const stored = await operationRepo.getById(op.id); + expect(stored?.state).toBe('pending'); + }); + + it('recoverExecutingOperation finalizes expired quotes as terminal failures', async () => { + const op = makeExecutingOp('exec-expired'); + await operationRepo.create(op); + + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ + status: 'TERMINAL', + error: `Recovered: quote ${quoteId} expired while executing mint`, + }); + + await service.recoverExecutingOperation(op); + + const stored = await operationRepo.getById(op.id); + + expect(stored?.state).toBe('failed'); + expect(stored?.error).toBe(`Recovered: quote ${quoteId} expired while executing mint`); + }); + + it('finalize returns a failed operation when recovery finds an expired quote', async () => { + const op = makeExecutingOp('exec-expired-redeem'); + await operationRepo.create(op); + + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ + status: 'TERMINAL', + error: `Recovered: quote ${quoteId} expired while executing mint`, + }); + + const result = await service.finalize(op.id); + + expect(result?.state).toBe('failed'); + expect(result?.id).toBe(op.id); + }); + + it('finalize throws when executing operation is recovered back to pending', async () => { + const op = makeExecutingOp('exec-4'); + await operationRepo.create(op); + + (handler.recoverExecuting as Mock).mockResolvedValueOnce({ status: 'PENDING' }); + + await expect(service.finalize(op.id)).rejects.toThrow( + `Operation ${op.id} remains pending after recovery`, + ); + }); + + it('getOperationByQuote returns null when no tracked operation exists for the quote', async () => { + await expect(service.getOperationByQuote(mintUrl, quoteId)).resolves.toBeNull(); + }); + + it('execute finalizes when already issued proofs cannot be restored', async () => { + const pendingOp = makePendingOp('pending-2'); + await operationRepo.create(pendingOp); + + (handler.execute as Mock).mockResolvedValueOnce({ status: 'ALREADY_ISSUED' }); + (proofService.recoverProofsFromOutputData as Mock).mockResolvedValueOnce([]); + + const finalized = await service.execute(pendingOp.id); + + const stored = await operationRepo.getById(pendingOp.id); + + expect(finalized.state).toBe('finalized'); + expect(finalized.error).toBe( + `Recovered issued quote ${pendingOp.quoteId} but no proofs could be restored`, + ); + expect(stored?.state).toBe('finalized'); + expect(stored?.error).toBe( + `Recovered issued quote ${pendingOp.quoteId} but no proofs could be restored`, + ); + }); + + it('recoverPendingOperations cleans init operations and reconciles stale pending ones', async () => { + const initOp = makeInitOp('init-1'); + const pendingOp = makePendingOp('pending-1'); + + await operationRepo.create(initOp); + await operationRepo.create(pendingOp); + + (handler.checkPending as Mock).mockResolvedValueOnce({ + observedRemoteState: 'PAID', + observedRemoteStateAt: Date.now(), + category: 'ready', + }); + + await service.recoverPendingOperations(); + + const initStored = await operationRepo.getById(initOp.id); + const pendingStored = await operationRepo.getById(pendingOp.id); + + expect(initStored).toBeNull(); + expect(pendingStored?.state).toBe('finalized'); + }); + + it('checkPendingOperation leaves unpaid operations pending', async () => { + const pendingOp = makePendingOp('pending-3'); + await operationRepo.create(pendingOp); + + const result = await service.checkPendingOperation(pendingOp.id); + const stored = await operationRepo.getById(pendingOp.id); + + expect(result.category).toBe('waiting'); + expect(result.observedRemoteState).toBe('UNPAID'); + expect(stored?.state).toBe('pending'); + if (!stored || stored.state !== 'pending') { + throw new Error('Expected pending operation to remain pending after unpaid check'); + } + expect(stored.lastObservedRemoteState).toBe('UNPAID'); + expect(stored.lastObservedRemoteStateAt).toEqual(expect.any(Number)); + }); + + it('recordPendingObservation updates the stored remote state without emitting another event', async () => { + const pendingOp = makePendingOp('pending-4'); + const quoteStateEvents: Array = []; + eventBus.on('mint-op:quote-state-changed', (event) => { + quoteStateEvents.push(event); + }); + await operationRepo.create(pendingOp); + + const observedAt = Date.now(); + const result = await service.recordPendingObservation(pendingOp.id, 'PAID', observedAt); + const stored = await operationRepo.getById(pendingOp.id); + + expect(result.lastObservedRemoteState).toBe('PAID'); + expect(result.lastObservedRemoteStateAt).toBe(observedAt); + expect(stored?.state).toBe('pending'); + if (!stored || stored.state !== 'pending') { + throw new Error('Expected pending operation to remain pending after recording observation'); + } + expect(stored.lastObservedRemoteState).toBe('PAID'); + expect(stored.lastObservedRemoteStateAt).toBe(observedAt); + expect(handler.checkPending).not.toHaveBeenCalled(); + expect(quoteStateEvents).toHaveLength(0); + }); + + it('persists a pending quote-state-changed event emitted by another service', async () => { + const pendingOp = makePendingOp('pending-5'); + await operationRepo.create(pendingOp); + + const observedAt = Date.now(); + await eventBus.emit('mint-op:quote-state-changed', { + mintUrl, + operationId: pendingOp.id, + operation: { + ...pendingOp, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: observedAt, + updatedAt: observedAt, + }, + quoteId: pendingOp.quoteId, + state: 'PAID', + }); + + const stored = await operationRepo.getById(pendingOp.id); + + expect(stored?.state).toBe('pending'); + if (!stored || stored.state !== 'pending') { + throw new Error('Expected pending operation to remain pending after event persistence'); + } + expect(stored.lastObservedRemoteState).toBe('PAID'); + expect(stored.lastObservedRemoteStateAt).toBe(observedAt); + expect(handler.checkPending).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/unit/MintOperationWatcherService.test.ts b/packages/core/test/unit/MintOperationWatcherService.test.ts new file mode 100644 index 00000000..820422f7 --- /dev/null +++ b/packages/core/test/unit/MintOperationWatcherService.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, mock, type Mock } from 'bun:test'; +import { EventBus } from '../../events/EventBus.ts'; +import type { CoreEvents } from '../../events/types.ts'; +import { MintOperationWatcherService } from '../../services/watchers/MintOperationWatcherService.ts'; +import type { SubscriptionManager } from '../../infra/SubscriptionManager.ts'; +import type { MintService } from '../../services/MintService.ts'; +import type { MintOperationService } from '../../operations/mint/MintOperationService.ts'; +import type { PendingMintOperation } from '../../operations/mint/MintOperation.ts'; +import { NullLogger } from '../../logging/NullLogger.ts'; +import type { MintQuoteResponse } from '@cashu/cashu-ts'; + +describe('MintOperationWatcherService', () => { + const mintUrl = 'https://mint.test'; + const quoteId = 'quote-1'; + + let bus: EventBus; + let subscribe: Mock; + let unsubscribe: Mock; + let callback: ((payload: MintQuoteResponse) => Promise) | undefined; + + const makePendingOperation = (): PendingMintOperation => ({ + id: 'mint-op-1', + state: 'pending', + mintUrl, + method: 'bolt11', + methodData: {}, + amount: 10, + unit: 'sat', + quoteId, + request: 'lnbc1test', + expiry: Math.floor(Date.now() / 1000) + 3600, + outputData: '{"keep":[],"send":[]}' as unknown as PendingMintOperation['outputData'], + lastObservedRemoteState: 'UNPAID', + lastObservedRemoteStateAt: Date.now(), + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + beforeEach(() => { + bus = new EventBus(); + unsubscribe = mock(async () => {}); + callback = undefined; + subscribe = mock( + async ( + _mintUrl: string, + _kind: string, + _filters: string[], + next: (payload: MintQuoteResponse) => Promise, + ) => { + callback = next; + return { subId: 'sub-1', unsubscribe }; + }, + ); + }); + + it('records PAID subscription updates without re-checking the quote remotely', async () => { + const operation = makePendingOperation(); + const observePendingOperation = mock(async () => { + throw new Error('should not re-check'); + }); + const getOperation = mock(async () => operation); + const quoteStateEvents: Array = []; + bus.on('mint-op:quote-state-changed', (event) => { + quoteStateEvents.push(event); + }); + + const watcher = new MintOperationWatcherService( + { subscribe } as unknown as SubscriptionManager, + { isTrustedMint: mock(async () => true) } as unknown as MintService, + { + observePendingOperation, + getOperation, + } as unknown as MintOperationService, + bus, + new NullLogger(), + { watchExistingPendingOnStart: false }, + ); + + await watcher.start(); + await bus.emit('mint-op:pending', { + mintUrl, + operationId: operation.id, + operation, + }); + + expect(subscribe).toHaveBeenCalledTimes(1); + if (!callback) { + throw new Error('Expected watcher subscription callback'); + } + + await callback({ + quote: quoteId, + request: operation.request, + amount: operation.amount, + unit: operation.unit, + expiry: operation.expiry, + state: 'PAID', + }); + + expect(getOperation).toHaveBeenCalledWith(operation.id); + expect(observePendingOperation).not.toHaveBeenCalled(); + expect(quoteStateEvents).toHaveLength(1); + expect(quoteStateEvents[0]?.operationId).toBe(operation.id); + expect(quoteStateEvents[0]?.state).toBe('PAID'); + const paidOperation = quoteStateEvents[0]?.operation; + if (!paidOperation || paidOperation.state !== 'pending') { + throw new Error('Expected pending operation in PAID event'); + } + expect(paidOperation.lastObservedRemoteState).toBe('PAID'); + expect(unsubscribe).not.toHaveBeenCalled(); + + await watcher.stop(); + }); + + it('records ISSUED subscription updates and stops watching the operation', async () => { + const operation = makePendingOperation(); + const quoteStateEvents: Array = []; + bus.on('mint-op:quote-state-changed', (event) => { + quoteStateEvents.push(event); + }); + + const watcher = new MintOperationWatcherService( + { subscribe } as unknown as SubscriptionManager, + { isTrustedMint: mock(async () => true) } as unknown as MintService, + { + getOperation: mock(async () => operation), + } as unknown as MintOperationService, + bus, + new NullLogger(), + { watchExistingPendingOnStart: false }, + ); + + await watcher.start(); + await bus.emit('mint-op:pending', { + mintUrl, + operationId: operation.id, + operation, + }); + + if (!callback) { + throw new Error('Expected watcher subscription callback'); + } + + await callback({ + quote: quoteId, + request: operation.request, + amount: operation.amount, + unit: operation.unit, + expiry: operation.expiry, + state: 'ISSUED', + }); + + expect(quoteStateEvents).toHaveLength(1); + expect(quoteStateEvents[0]?.state).toBe('ISSUED'); + const issuedOperation = quoteStateEvents[0]?.operation; + if (!issuedOperation || issuedOperation.state !== 'pending') { + throw new Error('Expected pending operation in ISSUED event'); + } + expect(issuedOperation.lastObservedRemoteState).toBe('ISSUED'); + expect(unsubscribe).toHaveBeenCalledTimes(1); + + await watcher.stop(); + }); +}); diff --git a/packages/core/test/unit/MintOpsApi.test.ts b/packages/core/test/unit/MintOpsApi.test.ts new file mode 100644 index 00000000..a2314c30 --- /dev/null +++ b/packages/core/test/unit/MintOpsApi.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import { MintOpsApi } from '../../api/MintOpsApi.ts'; +import type { MintOperationService } from '../../operations/mint/MintOperationService.ts'; +import type { + ExecutingMintOperation, + FinalizedMintOperation, + MintOperation, + PendingMintOperation, + TerminalMintOperation, +} from '../../operations/mint/MintOperation.ts'; +import type { MintQuoteBolt11Response } from '@cashu/cashu-ts'; + +const mintUrl = 'https://mint.test'; +const quoteId = 'quote-1'; + +const makePendingOperation = (): PendingMintOperation => ({ + id: 'op-1', + state: 'pending', + mintUrl, + quoteId, + method: 'bolt11', + methodData: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + amount: 10, + unit: 'sat', + request: 'lnbc1test', + expiry: Math.floor(Date.now() / 1000) + 3600, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: Date.now(), + outputData: { keep: [], send: [] }, +}); + +describe('MintOpsApi', () => { + let api: MintOpsApi; + let mintOperationService: MintOperationService; + let pendingOperation: PendingMintOperation; + let quote: MintQuoteBolt11Response; + + beforeEach(() => { + pendingOperation = makePendingOperation(); + quote = { + quote: quoteId, + request: 'lnbc1test', + amount: 10, + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + }; + const executingOperation: ExecutingMintOperation = { + ...pendingOperation, + state: 'executing', + }; + const finalizedOperation: TerminalMintOperation = { + ...pendingOperation, + state: 'finalized', + }; + + mintOperationService = { + prepareNewQuote: mock(async () => pendingOperation), + importQuote: mock(async () => pendingOperation), + execute: mock(async () => finalizedOperation), + getOperation: mock(async () => pendingOperation), + getOperationByQuote: mock(async () => pendingOperation), + getPendingOperations: mock(async () => [pendingOperation]), + getInFlightOperations: mock(async () => [pendingOperation, executingOperation]), + checkPendingOperation: mock(async () => ({ + observedRemoteState: 'UNPAID', + observedRemoteStateAt: Date.now(), + category: 'waiting', + })), + recoverExecutingOperation: mock(async () => {}), + finalize: mock(async () => finalizedOperation), + recoverPendingOperations: mock(async () => {}), + isOperationLocked: mock(() => false), + isRecoveryInProgress: mock(() => false), + } as unknown as MintOperationService; + + api = new MintOpsApi(mintOperationService); + }); + + it('prepare creates a new quote-backed operation and returns a pending mint operation', async () => { + const result = await api.prepare({ + mintUrl, + amount: 10, + method: 'bolt11', + methodData: {}, + }); + + expect(mintOperationService.prepareNewQuote).toHaveBeenCalledWith( + mintUrl, + 10, + 'sat', + 'bolt11', + {}, + ); + expect(result).toBe(pendingOperation); + }); + + it('prepare rejects non-sat units before delegating to the service', async () => { + await expect( + api.prepare({ + mintUrl, + amount: 10, + unit: 'usd' as 'sat', + method: 'bolt11', + methodData: {}, + }), + ).rejects.toThrow("Unsupported mint unit 'usd'. Only 'sat' is currently supported."); + + expect(mintOperationService.prepareNewQuote).not.toHaveBeenCalled(); + }); + + it('importQuote delegates to the mint operation service', async () => { + const result = await api.importQuote({ + mintUrl, + quote, + method: 'bolt11', + methodData: {}, + }); + + expect(mintOperationService.importQuote).toHaveBeenCalledWith(mintUrl, quote, 'bolt11', {}); + expect(result).toBe(pendingOperation); + }); + + it('importQuote rejects non-sat quote units before delegating to the service', async () => { + await expect( + api.importQuote({ + mintUrl, + quote: { + ...quote, + unit: 'usd', + } as MintQuoteBolt11Response, + method: 'bolt11', + methodData: {}, + }), + ).rejects.toThrow("Unsupported mint unit 'usd'. Only 'sat' is currently supported."); + + expect(mintOperationService.importQuote).not.toHaveBeenCalled(); + }); + + it('execute only allows pending operations', async () => { + const result = await api.execute(pendingOperation.id); + + expect(mintOperationService.getOperation).toHaveBeenCalledWith(pendingOperation.id); + expect(mintOperationService.execute).toHaveBeenCalledWith(pendingOperation.id); + expect(result.state).toBe('finalized'); + + (mintOperationService.getOperation as unknown as ReturnType).mockResolvedValueOnce({ + ...pendingOperation, + state: 'executing', + } as MintOperation); + + await expect(api.execute(pendingOperation.id)).rejects.toThrow("Expected 'pending'"); + }); + + it('listPending and listInFlight delegate to separate service methods', async () => { + const pending = await api.listPending(); + const inFlight = await api.listInFlight(); + + expect(mintOperationService.getPendingOperations).toHaveBeenCalledWith(); + expect(mintOperationService.getInFlightOperations).toHaveBeenCalledWith(); + expect(pending).toEqual([pendingOperation]); + expect(inFlight).toHaveLength(2); + }); + + it('refresh reconciles pending and executing operations', async () => { + const finalizedOperation: TerminalMintOperation = { + ...pendingOperation, + state: 'finalized', + }; + + (mintOperationService.getOperation as unknown as ReturnType) + .mockResolvedValueOnce(pendingOperation as MintOperation) + .mockResolvedValueOnce(finalizedOperation as MintOperation); + + const refreshedPending = await api.refresh(pendingOperation.id); + + expect(mintOperationService.checkPendingOperation).toHaveBeenCalledWith(pendingOperation.id); + expect(refreshedPending).toBe(finalizedOperation); + + const executingOperation: ExecutingMintOperation = { + ...pendingOperation, + state: 'executing', + }; + + (mintOperationService.getOperation as unknown as ReturnType) + .mockResolvedValueOnce(executingOperation as MintOperation) + .mockResolvedValueOnce(finalizedOperation as MintOperation); + + const refreshedExecuting = await api.refresh(pendingOperation.id); + + expect(mintOperationService.recoverExecutingOperation).toHaveBeenCalledWith(executingOperation); + expect(refreshedExecuting).toBe(finalizedOperation); + }); +}); diff --git a/packages/core/test/unit/MintQuoteProcessor.test.ts b/packages/core/test/unit/MintQuoteProcessor.test.ts index b9c3c561..1e216b53 100644 --- a/packages/core/test/unit/MintQuoteProcessor.test.ts +++ b/packages/core/test/unit/MintQuoteProcessor.test.ts @@ -1,45 +1,37 @@ import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; -import { MintQuoteProcessor } from '../../services/watchers/MintQuoteProcessor'; +import { MintOperationProcessor } from '../../services/watchers/MintOperationProcessor'; import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; -import type { MintQuoteService } from '../../services/MintQuoteService'; -import type { MintQuoteState } from '@cashu/cashu-ts'; +import type { MintOperationService } from '../../operations/mint/MintOperationService'; import { MintOperationError, NetworkError } from '../../models/Error'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -describe('MintQuoteProcessor', () => { +describe('MintOperationProcessor', () => { let bus: EventBus; - let mockQuoteService: MintQuoteService; - let processor: MintQuoteProcessor; - let redeemCalls: Array<{ mintUrl: string; quoteId: string }>; - let updateStateCalls: Array<{ mintUrl: string; quoteId: string; state: MintQuoteState }>; + let processor: MintOperationProcessor; + let mockMintOperationService: MintOperationService; + let finalizeCalls: string[]; - // Use much shorter intervals for tests - const TEST_PROCESS_INTERVAL = 50; // 50ms instead of 3000ms - const TEST_RETRY_DELAY = 100; // 100ms instead of 5000ms - const TEST_INITIAL_DELAY = 10; // matches initialEnqueueDelayMs passed to processor + const TEST_PROCESS_INTERVAL = 50; + const TEST_RETRY_DELAY = 100; + const TEST_INITIAL_DELAY = 10; beforeEach(() => { bus = new EventBus(); - redeemCalls = []; - updateStateCalls = []; + finalizeCalls = []; - // Mock MintQuoteService - mockQuoteService = { - async redeemMintQuote(mintUrl: string, quoteId: string) { - redeemCalls.push({ mintUrl, quoteId }); + mockMintOperationService = { + async finalize(operationId: string) { + finalizeCalls.push(operationId); }, - async updateStateFromRemote(mintUrl: string, quoteId: string, state: MintQuoteState) { - updateStateCalls.push({ mintUrl, quoteId, state }); - }, - } as any; + } as unknown as MintOperationService; - processor = new MintQuoteProcessor(mockQuoteService, bus, undefined, { + processor = new MintOperationProcessor(mockMintOperationService, bus, undefined, { processIntervalMs: TEST_PROCESS_INTERVAL, baseRetryDelayMs: TEST_RETRY_DELAY, maxRetries: 3, - initialEnqueueDelayMs: 10, + initialEnqueueDelayMs: TEST_INITIAL_DELAY, }); }); @@ -49,623 +41,204 @@ describe('MintQuoteProcessor', () => { } }); - describe('lifecycle', () => { - it('starts and stops correctly', async () => { - expect(processor.isRunning()).toBe(false); - - await processor.start(); - expect(processor.isRunning()).toBe(true); - - await processor.stop(); - expect(processor.isRunning()).toBe(false); - }); - - it('ignores duplicate start calls', async () => { - await processor.start(); - await processor.start(); // Should not throw - expect(processor.isRunning()).toBe(true); + it('starts and stops correctly', async () => { + expect(processor.isRunning()).toBe(false); - await processor.stop(); - }); + await processor.start(); + expect(processor.isRunning()).toBe(true); - it('ignores duplicate stop calls', async () => { - await processor.start(); - await processor.stop(); - await processor.stop(); // Should not throw - expect(processor.isRunning()).toBe(false); - }); + await processor.stop(); + expect(processor.isRunning()).toBe(false); }); - describe('quote processing', () => { - it('processes PAID quotes from state-changed events', async () => { - await processor.start(); - - // Emit a PAID state change - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'quote1', - state: 'PAID', - }); - - // Wait for processing (3 second interval + some buffer) - await sleep(TEST_PROCESS_INTERVAL + 20); - - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]).toEqual({ - mintUrl: 'https://mint.test', - quoteId: 'quote1', - }); - }); - - it('processes PAID quotes from mint-quote:added events', async () => { - await processor.start(); - - // Emit an added quote with PAID state - await bus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'added-quote', - quote: { - quote: 'added-quote', - amount: 100, - state: 'PAID', - request: 'lnbc...', - } as any, - }); - - // Wait for processing - await sleep(TEST_PROCESS_INTERVAL + 20); + it('processes PAID operations from mint-op:quote-state-changed', async () => { + await processor.start(); - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]).toEqual({ + await bus.emit('mint-op:quote-state-changed', { + mintUrl: 'https://mint.test', + operationId: 'mint-op-1', + operation: { + id: 'mint-op-1', mintUrl: 'https://mint.test', - quoteId: 'added-quote', - }); + method: 'bolt11', + } as any, + quoteId: 'quote-1', + state: 'PAID', }); - it('processes quotes from mint-quote:requeue events', async () => { - await processor.start(); + await sleep(TEST_PROCESS_INTERVAL + 20); - // Emit a requeue event (no need for full quote payload) - await bus.emit('mint-quote:requeue', { - mintUrl: 'https://mint.test', - quoteId: 'requeued-quote', - }); + expect(finalizeCalls).toEqual(['mint-op-1']); + }); - // Wait for processing (test interval + buffer) - await sleep(TEST_PROCESS_INTERVAL + 20); + it('processes already-paid pending operations from mint-op:pending', async () => { + await processor.start(); - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]).toEqual({ + await bus.emit('mint-op:pending', { + mintUrl: 'https://mint.test', + operationId: 'mint-op-2', + operation: { + id: 'mint-op-2', + state: 'pending', mintUrl: 'https://mint.test', - quoteId: 'requeued-quote', - }); + method: 'bolt11', + lastObservedRemoteState: 'PAID', + } as any, }); - it('processes added quotes with bolt11 handler', async () => { - await processor.start(); + await sleep(TEST_PROCESS_INTERVAL + 20); - // Emit an added quote (always uses bolt11 for now) - await bus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'custom-quote', - quote: { - quote: 'custom-quote', - amount: 100, - state: 'PAID', - request: 'lnbc...', - } as any, - }); + expect(finalizeCalls).toEqual(['mint-op-2']); + }); - await sleep(TEST_PROCESS_INTERVAL + 20); + it('processes explicit mint-op:requeue events', async () => { + await processor.start(); - // Should use bolt11 handler - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]).toEqual({ + await bus.emit('mint-op:requeue', { + mintUrl: 'https://mint.test', + operationId: 'mint-op-3', + operation: { + id: 'mint-op-3', mintUrl: 'https://mint.test', - quoteId: 'custom-quote', - }); + method: 'bolt11', + } as any, }); - it('defaults to bolt11 when quoteType not specified in mint-quote:added', async () => { - await processor.start(); + await sleep(TEST_PROCESS_INTERVAL + 20); - // Emit an added quote - await bus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'default-type-quote', - quote: { - quote: 'default-type-quote', - amount: 100, - state: 'PAID', - request: 'lnbc...', - } as any, - }); + expect(finalizeCalls).toEqual(['mint-op-3']); + }); - await sleep(TEST_PROCESS_INTERVAL + 20); + it('ignores non-PAID quote-state changes', async () => { + await processor.start(); - // Should use default bolt11 handler - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]).toEqual({ + await bus.emit('mint-op:quote-state-changed', { + mintUrl: 'https://mint.test', + operationId: 'mint-op-4', + operation: { + id: 'mint-op-4', mintUrl: 'https://mint.test', - quoteId: 'default-type-quote', - }); + method: 'bolt11', + } as any, + quoteId: 'quote-4', + state: 'UNPAID', }); - it('ignores non-PAID quotes from mint-quote:added events', async () => { - await processor.start(); - - await bus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'unpaid-added', - quote: { - quote: 'unpaid-added', - amount: 100, - state: 'UNPAID', - request: 'lnbc...', - } as any, - }); - - await bus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'issued-added', - quote: { - quote: 'issued-added', - amount: 100, - state: 'ISSUED', - request: 'lnbc...', - } as any, - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - expect(redeemCalls.length).toBe(0); - }); + await sleep(TEST_PROCESS_INTERVAL + 20); - it('ignores non-PAID state changes', async () => { - await processor.start(); + expect(finalizeCalls).toEqual([]); + }); - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'quote1', - state: 'UNPAID', - }); + it('deduplicates repeated enqueue requests for the same operation', async () => { + await processor.start(); - await bus.emit('mint-quote:state-changed', { + for (let i = 0; i < 3; i++) { + await bus.emit('mint-op:quote-state-changed', { mintUrl: 'https://mint.test', - quoteId: 'quote2', - state: 'ISSUED', - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - expect(redeemCalls.length).toBe(0); - }); - - it('processes multiple quotes in FIFO order with throttling', async () => { - await processor.start(); - - // Emit multiple PAID quotes - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint1.test', - quoteId: 'quote1', - state: 'PAID', - }); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint2.test', - quoteId: 'quote2', - state: 'PAID', - }); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint3.test', - quoteId: 'quote3', - state: 'PAID', - }); - - // First should process after initial delay - await sleep(TEST_INITIAL_DELAY + 20); - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]?.quoteId).toBe('quote1'); - - // Second should process after another ~3s - await sleep(TEST_PROCESS_INTERVAL); - expect(redeemCalls.length).toBe(2); - expect(redeemCalls[1]?.quoteId).toBe('quote2'); - - // Third should process after another ~3s - await sleep(TEST_PROCESS_INTERVAL); - expect(redeemCalls.length).toBe(3); - expect(redeemCalls[2]?.quoteId).toBe('quote3'); - }); - - it('prevents duplicate quotes in queue', async () => { - await processor.start(); - - // Emit the same quote multiple times - for (let i = 0; i < 3; i++) { - await bus.emit('mint-quote:state-changed', { + operationId: 'mint-op-5', + operation: { + id: 'mint-op-5', mintUrl: 'https://mint.test', - quoteId: 'duplicate', - state: 'PAID', - }); - } - - await sleep(TEST_PROCESS_INTERVAL + 20); - - // Should only process once - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]).toEqual({ - mintUrl: 'https://mint.test', - quoteId: 'duplicate', - }); - }); - - it('handles both state-changed and added events together', async () => { - await processor.start(); - - // Mix of events - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'state-change-1', - state: 'PAID', - }); - - await bus.emit('mint-quote:added', { - mintUrl: 'https://mint.test', - quoteId: 'added-1', - quote: { - quote: 'added-1', - amount: 100, - state: 'PAID', - request: 'lnbc...', + method: 'bolt11', } as any, - }); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'state-change-2', + quoteId: 'quote-5', state: 'PAID', }); + } - // Process first quote - await sleep(TEST_INITIAL_DELAY + 20); - expect(redeemCalls.length).toBe(1); - expect(redeemCalls[0]?.quoteId).toBe('state-change-1'); - - // Process second quote - await sleep(TEST_PROCESS_INTERVAL); - expect(redeemCalls.length).toBe(2); - expect(redeemCalls[1]?.quoteId).toBe('added-1'); + await sleep(TEST_PROCESS_INTERVAL + 20); - // Process third quote - await sleep(TEST_PROCESS_INTERVAL); - expect(redeemCalls.length).toBe(3); - expect(redeemCalls[2]?.quoteId).toBe('state-change-2'); - }); + expect(finalizeCalls).toEqual(['mint-op-5']); }); - describe('error handling', () => { - it('updates state to ISSUED when quote already issued (20002)', async () => { - // Mock service to throw already issued error - mockQuoteService.redeemMintQuote = async () => { - throw new MintOperationError(20002, 'Quote already issued'); - }; - - await processor.start(); + it('retries network errors with exponential backoff', async () => { + let attemptCount = 0; + const attemptTimes: number[] = []; - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'already-issued', - state: 'PAID', - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - expect(updateStateCalls.length).toBe(1); - expect(updateStateCalls[0]).toEqual({ - mintUrl: 'https://mint.test', - quoteId: 'already-issued', - state: 'ISSUED', - }); - }); - - it('logs but does not update state when quote expired (20007)', async () => { - // Mock service to throw expired error - mockQuoteService.redeemMintQuote = async () => { - throw new MintOperationError(20007, 'Quote expired'); - }; - - await processor.start(); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'expired', - state: 'PAID', - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - // Should not update state (since EXPIRED is not a valid state) - expect(updateStateCalls.length).toBe(0); - }); - - it('does not retry other MintOperationErrors', async () => { - let attemptCount = 0; - mockQuoteService.redeemMintQuote = async () => { - attemptCount++; - throw new MintOperationError(10000, 'Some other error'); - }; - - await processor.start(); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'other-error', - state: 'PAID', - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - // Should only try once - expect(attemptCount).toBe(1); - expect(updateStateCalls.length).toBe(0); - }); - - it('retries network errors with exponential backoff', async () => { - let attemptCount = 0; - const attemptTimes: number[] = []; - - mockQuoteService.redeemMintQuote = async () => { + mockMintOperationService = { + async finalize(operationId: string) { attemptCount++; attemptTimes.push(Date.now()); if (attemptCount <= 2) { - throw new NetworkError('Connection failed'); + throw new NetworkError(`network failure for ${operationId}`); } - // Succeed on third attempt - }; - - await processor.start(); - - const startTime = Date.now(); - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'network-retry', - state: 'PAID', - }); - - // First attempt after interval - await sleep(TEST_PROCESS_INTERVAL + 20); - expect(attemptCount).toBe(1); - - // Second attempt after first retry delay - await sleep(TEST_RETRY_DELAY + 50); - expect(attemptCount).toBe(2); - - // Third attempt after second retry delay (TEST_RETRY_DELAY * 2) - await sleep(TEST_RETRY_DELAY * 2 + 50); - expect(attemptCount).toBe(3); - - // Verify exponential backoff timing (with some tolerance) - if (attemptTimes.length >= 2) { - const firstRetryDelay = attemptTimes[1]! - attemptTimes[0]!; - expect(firstRetryDelay).toBeGreaterThan(TEST_RETRY_DELAY - 20); - expect(firstRetryDelay).toBeLessThan(TEST_RETRY_DELAY + 100); - } - - if (attemptTimes.length >= 3) { - const secondRetryDelay = attemptTimes[2]! - attemptTimes[1]!; - expect(secondRetryDelay).toBeGreaterThan(TEST_RETRY_DELAY * 2 - 20); - expect(secondRetryDelay).toBeLessThan(TEST_RETRY_DELAY * 2 + 100); - } - }); - - it('gives up after max retries for network errors', async () => { - let attemptCount = 0; - - mockQuoteService.redeemMintQuote = async () => { - attemptCount++; - throw new NetworkError('Connection failed'); - }; - - await processor.start(); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'max-retries', - state: 'PAID', - }); - - // Wait for all retry attempts - // Initial: 50ms, Retry 1: +100ms, Retry 2: +200ms, Retry 3: +400ms - await sleep( - TEST_PROCESS_INTERVAL + - TEST_RETRY_DELAY + - TEST_RETRY_DELAY * 2 + - TEST_RETRY_DELAY * 4 + - 100, - ); - - // Should attempt exactly 4 times (initial + 3 retries) - expect(attemptCount).toBe(4); - }); - - it('handles unknown errors without retry', async () => { - let attemptCount = 0; - - mockQuoteService.redeemMintQuote = async () => { - attemptCount++; - throw new Error('Unknown error'); - }; - - await processor.start(); - - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: 'unknown-error', - state: 'PAID', - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - // Should only try once - expect(attemptCount).toBe(1); - expect(updateStateCalls.length).toBe(0); - }); - }); + finalizeCalls.push(operationId); + }, + } as unknown as MintOperationService; - describe('custom handlers', () => { - it('allows registering custom quote type handlers', async () => { - let customHandlerCalled = false; - const customHandler = { - canHandle(quoteType: string) { - return quoteType === 'custom'; - }, - async process(mintUrl: string, quoteId: string) { - customHandlerCalled = true; - }, - }; - - processor.registerHandler('custom', customHandler); - - // Manually enqueue a custom type quote (since we default to bolt11 in events) - // We'll need to access the private method, so let's test via the default handler - await processor.start(); - - // For this test, we'll verify the handler registration worked - // In real usage, the quote type would come from the quote data - expect(customHandlerCalled).toBe(false); + processor = new MintOperationProcessor(mockMintOperationService, bus, undefined, { + processIntervalMs: TEST_PROCESS_INTERVAL, + baseRetryDelayMs: TEST_RETRY_DELAY, + maxRetries: 3, + initialEnqueueDelayMs: TEST_INITIAL_DELAY, }); - it('warns when no handler registered for quote type', async () => { - // Create a processor without the default bolt11 handler - const emptyProcessor = new MintQuoteProcessor(mockQuoteService, bus); - - // Clear the default handler - (emptyProcessor as any).handlers.clear(); + await processor.start(); - await emptyProcessor.start(); - - await bus.emit('mint-quote:state-changed', { + await bus.emit('mint-op:requeue', { + mintUrl: 'https://mint.test', + operationId: 'mint-op-network', + operation: { + id: 'mint-op-network', mintUrl: 'https://mint.test', - quoteId: 'no-handler', - state: 'PAID', - }); - - await sleep(TEST_PROCESS_INTERVAL + 20); - - // Should not attempt to redeem - expect(redeemCalls.length).toBe(0); - - await emptyProcessor.stop(); + method: 'bolt11', + } as any, }); - }); - - describe('waitForCompletion', () => { - it('waits for empty queue', async () => { - await processor.start(); - - // Add multiple quotes - for (let i = 1; i <= 3; i++) { - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: `quote${i}`, - state: 'PAID', - }); - } - - // Start waiting for completion - const completionPromise = processor.waitForCompletion(); - - // Should not be complete immediately - let isComplete = false; - completionPromise.then(() => { - isComplete = true; - }); - await sleep(100); - expect(isComplete).toBe(false); + await sleep(TEST_PROCESS_INTERVAL + 20); + expect(attemptCount).toBe(1); - // Wait for all to process (3 quotes * interval each + buffer) - await sleep(3 * TEST_PROCESS_INTERVAL + 100); + await sleep(TEST_RETRY_DELAY + 50); + expect(attemptCount).toBe(2); - await completionPromise; - expect(isComplete).toBe(true); - expect(redeemCalls.length).toBe(3); - }); + await sleep(TEST_RETRY_DELAY * 2 + 50); + expect(attemptCount).toBe(3); + expect(finalizeCalls).toEqual(['mint-op-network']); - it('resolves immediately when queue is empty', async () => { - await processor.start(); - - const startTime = Date.now(); - await processor.waitForCompletion(); - const elapsed = Date.now() - startTime; + if (attemptTimes.length >= 2) { + const firstRetryDelay = attemptTimes[1]! - attemptTimes[0]!; + expect(firstRetryDelay).toBeGreaterThan(TEST_RETRY_DELAY - 20); + expect(firstRetryDelay).toBeLessThan(TEST_RETRY_DELAY + 100); + } - expect(elapsed).toBeLessThan(500); // Should be nearly instant - }); + if (attemptTimes.length >= 3) { + const secondRetryDelay = attemptTimes[2]! - attemptTimes[1]!; + expect(secondRetryDelay).toBeGreaterThan(TEST_RETRY_DELAY * 2 - 20); + expect(secondRetryDelay).toBeLessThan(TEST_RETRY_DELAY * 2 + 100); + } }); - describe('stop behavior', () => { - it('stops processing when stopped mid-queue', async () => { - await processor.start(); - - // Add multiple quotes - for (let i = 1; i <= 5; i++) { - await bus.emit('mint-quote:state-changed', { - mintUrl: 'https://mint.test', - quoteId: `quote${i}`, - state: 'PAID', - }); - } - - // Let first one process - await sleep(TEST_INITIAL_DELAY + 20); - expect(redeemCalls.length).toBe(1); - - // Stop the processor - await processor.stop(); + it('does not retry mint operation errors', async () => { + let attemptCount = 0; - // Wait what would be enough time for more processing - await sleep(3 * TEST_PROCESS_INTERVAL); + mockMintOperationService = { + async finalize() { + attemptCount++; + throw new MintOperationError(10000, 'operation failed'); + }, + } as unknown as MintOperationService; - // Should not have processed more - expect(redeemCalls.length).toBe(1); + processor = new MintOperationProcessor(mockMintOperationService, bus, undefined, { + processIntervalMs: TEST_PROCESS_INTERVAL, + baseRetryDelayMs: TEST_RETRY_DELAY, + maxRetries: 3, + initialEnqueueDelayMs: TEST_INITIAL_DELAY, }); - it('waits for current processing to complete before stopping', async () => { - let processingStarted = false; - let processingCompleted = false; - - mockQuoteService.redeemMintQuote = async () => { - processingStarted = true; - await sleep(200); // Simulate slow processing - processingCompleted = true; - }; + await processor.start(); - await processor.start(); - - await bus.emit('mint-quote:state-changed', { + await bus.emit('mint-op:requeue', { + mintUrl: 'https://mint.test', + operationId: 'mint-op-error', + operation: { + id: 'mint-op-error', mintUrl: 'https://mint.test', - quoteId: 'slow-quote', - state: 'PAID', - }); - - // Wait for processing to start - await sleep(TEST_PROCESS_INTERVAL + 20); - expect(processingStarted).toBe(true); - expect(processingCompleted).toBe(false); - - // Start stopping (should wait for processing to complete) - const stopPromise = processor.stop(); - - // Should still be processing - expect(processingCompleted).toBe(false); + method: 'bolt11', + } as any, + }); - // Wait for stop to complete - await stopPromise; + await sleep(TEST_PROCESS_INTERVAL + 20); - // Now processing should be complete - expect(processingCompleted).toBe(true); - }); + expect(attemptCount).toBe(1); }); }); diff --git a/packages/core/test/unit/MintQuoteService.test.ts b/packages/core/test/unit/MintQuoteService.test.ts deleted file mode 100644 index 98f86639..00000000 --- a/packages/core/test/unit/MintQuoteService.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, it, beforeEach, expect } from 'bun:test'; -import { MintQuoteService } from '../../services/MintQuoteService'; -import { EventBus } from '../../events/EventBus'; -import type { CoreEvents } from '../../events/types'; -import type { MintQuoteRepository } from '../../repositories'; -import type { MintQuote } from '../../models/MintQuote'; -import type { MintQuoteBolt11Response } from '@cashu/cashu-ts'; - -describe('MintQuoteService.addExistingMintQuotes', () => { - let service: MintQuoteService; - let mockRepo: MintQuoteRepository; - let eventBus: EventBus; - let emittedEvents: Array<{ event: string; payload: any }>; - let repoQuotes: Map; - - beforeEach(() => { - repoQuotes = new Map(); - emittedEvents = []; - - // Mock repository - mockRepo = { - async getMintQuote(mintUrl: string, quoteId: string): Promise { - const key = `${mintUrl}::${quoteId}`; - return repoQuotes.get(key) || null; - }, - async addMintQuote(quote: MintQuote): Promise { - const key = `${quote.mintUrl}::${quote.quote}`; - if (repoQuotes.has(key)) { - throw new Error('Quote already exists'); - } - repoQuotes.set(key, quote); - }, - async setMintQuoteState(): Promise { - // Not used in these tests - }, - async getPendingMintQuotes(): Promise { - // Not used in these tests - return []; - }, - }; - - // Create event bus with tracking - eventBus = new EventBus(); - eventBus.on('mint-quote:added', (payload) => { - emittedEvents.push({ event: 'mint-quote:added', payload }); - }); - - // Create service with mock mintService - const mockMintService = { - isTrustedMint: async () => true, - } as any; - - service = new MintQuoteService( - mockRepo, - mockMintService, - {} as any, // walletService not needed - {} as any, // proofService not needed - eventBus, - undefined, // logger - ); - }); - - it('adds new quotes and emits events', async () => { - const quotes: MintQuoteBolt11Response[] = [ - { - quote: 'quote1', - amount: 100, - state: 'PAID', - request: 'lnbc100...', - } as MintQuoteBolt11Response, - { - quote: 'quote2', - amount: 200, - state: 'ISSUED', - request: 'lnbc200...', - } as MintQuoteBolt11Response, - ]; - - const result = await service.addExistingMintQuotes('https://mint.test', quotes); - - // Both should be added - expect(result.added).toEqual(['quote1', 'quote2']); - expect(result.skipped).toEqual([]); - - // Check repository - expect(repoQuotes.size).toBe(2); - expect(repoQuotes.has('https://mint.test::quote1')).toBe(true); - expect(repoQuotes.has('https://mint.test::quote2')).toBe(true); - - // Check events were emitted - expect(emittedEvents.length).toBe(2); - expect(emittedEvents[0]?.payload.quoteId).toBe('quote1'); - expect(emittedEvents[0]?.payload.quote.state).toBe('PAID'); - expect(emittedEvents[1]?.payload.quoteId).toBe('quote2'); - expect(emittedEvents[1]?.payload.quote.state).toBe('ISSUED'); - }); - - it('skips quotes that already exist', async () => { - // Pre-add a quote - await mockRepo.addMintQuote({ - mintUrl: 'https://mint.test', - quote: 'existing', - amount: 50, - state: 'ISSUED', - request: 'lnbc50...', - expiry: Math.floor(Date.now() / 1000) + 1000, - unit: 'sat', - }); - - const quotes: MintQuoteBolt11Response[] = [ - { - quote: 'existing', - amount: 50, - state: 'PAID', // Different state, but still skipped - request: 'lnbc50...', - } as MintQuoteBolt11Response, - { - quote: 'new', - amount: 100, - state: 'PAID', - request: 'lnbc100...', - } as MintQuoteBolt11Response, - ]; - - const result = await service.addExistingMintQuotes('https://mint.test', quotes); - - // Only new quote should be added - expect(result.added).toEqual(['new']); - expect(result.skipped).toEqual(['existing']); - - // Check repository - expect(repoQuotes.size).toBe(2); - - // Only one event for the new quote - expect(emittedEvents.length).toBe(1); - expect(emittedEvents[0]?.payload.quoteId).toBe('new'); - }); - - it('handles empty quote array', async () => { - const result = await service.addExistingMintQuotes('https://mint.test', []); - - expect(result.added).toEqual([]); - expect(result.skipped).toEqual([]); - expect(emittedEvents.length).toBe(0); - }); - - it('handles repository errors gracefully', async () => { - // Mock repo to fail on second quote - let callCount = 0; - mockRepo.addMintQuote = async (quote: MintQuote) => { - callCount++; - if (callCount === 2) { - throw new Error('Database error'); - } - const key = `${quote.mintUrl}::${quote.quote}`; - repoQuotes.set(key, quote); - }; - - const quotes: MintQuoteBolt11Response[] = [ - { - quote: 'quote1', - amount: 100, - state: 'PAID', - request: 'lnbc100...', - } as MintQuoteBolt11Response, - { - quote: 'quote2', - amount: 200, - state: 'PAID', - request: 'lnbc200...', - } as MintQuoteBolt11Response, - { - quote: 'quote3', - amount: 300, - state: 'ISSUED', - request: 'lnbc300...', - } as MintQuoteBolt11Response, - ]; - - const result = await service.addExistingMintQuotes('https://mint.test', quotes); - - // First and third should succeed, second should be skipped - expect(result.added).toEqual(['quote1', 'quote3']); - expect(result.skipped).toEqual(['quote2']); - - // Check events - expect(emittedEvents.length).toBe(2); - expect(emittedEvents.map((e) => e.payload.quoteId)).toEqual(['quote1', 'quote3']); - }); - - it('correctly passes quote data in events', async () => { - const quote: MintQuoteBolt11Response = { - quote: 'detailed-quote', - amount: 1000, - state: 'PAID', - request: 'lnbc1000...', - expiry: 3600, - } as MintQuoteBolt11Response; - - await service.addExistingMintQuotes('https://mint.test', [quote]); - - // Check the event has all the quote data - expect(emittedEvents.length).toBe(1); - const event = emittedEvents[0]; - expect(event?.payload).toEqual({ - mintUrl: 'https://mint.test', - quoteId: 'detailed-quote', - quote: { - quote: 'detailed-quote', - amount: 1000, - state: 'PAID', - request: 'lnbc1000...', - expiry: 3600, - }, - }); - }); - - it('processes multiple mints correctly', async () => { - const mint1Quotes: MintQuoteBolt11Response[] = [ - { - quote: 'mint1-quote', - amount: 100, - state: 'PAID', - request: 'lnbc100...', - } as MintQuoteBolt11Response, - ]; - - const mint2Quotes: MintQuoteBolt11Response[] = [ - { - quote: 'mint2-quote', - amount: 200, - state: 'ISSUED', - request: 'lnbc200...', - } as MintQuoteBolt11Response, - ]; - - const result1 = await service.addExistingMintQuotes('https://mint1.test', mint1Quotes); - const result2 = await service.addExistingMintQuotes('https://mint2.test', mint2Quotes); - - expect(result1.added).toEqual(['mint1-quote']); - expect(result2.added).toEqual(['mint2-quote']); - - // Check both are in repo with correct mint URLs - expect(repoQuotes.has('https://mint1.test::mint1-quote')).toBe(true); - expect(repoQuotes.has('https://mint2.test::mint2-quote')).toBe(true); - - // Check events have correct mint URLs - expect(emittedEvents[0]?.payload.mintUrl).toBe('https://mint1.test'); - expect(emittedEvents[1]?.payload.mintUrl).toBe('https://mint2.test'); - }); -}); diff --git a/packages/core/test/unit/PluginHost.test.ts b/packages/core/test/unit/PluginHost.test.ts index 4f770ff1..b9dadff3 100644 --- a/packages/core/test/unit/PluginHost.test.ts +++ b/packages/core/test/unit/PluginHost.test.ts @@ -17,7 +17,6 @@ describe('PluginHost', () => { seedService: { s: 'seed' }, walletRestoreService: { s: 'walletRestore' }, counterService: { s: 'counter' }, - mintQuoteService: { s: 'mintQuote' }, meltQuoteService: { s: 'meltQuote' }, historyService: { s: 'history' }, subscriptions: { s: 'subs' }, diff --git a/packages/core/test/unit/QuotesApi.test.ts b/packages/core/test/unit/QuotesApi.test.ts index b8324ebb..5bf4fd35 100644 --- a/packages/core/test/unit/QuotesApi.test.ts +++ b/packages/core/test/unit/QuotesApi.test.ts @@ -3,7 +3,6 @@ import { QuotesApi } from '../../api/QuotesApi.ts'; import type { MeltOperationService } from '../../operations/melt/MeltOperationService.ts'; import type { PendingCheckResult } from '../../operations/melt/MeltMethodHandler.ts'; import type { PendingMeltOperation } from '../../operations/melt/MeltOperation.ts'; -import type { MintQuoteService } from '../../services/MintQuoteService.ts'; import type { MeltQuoteService } from '../../services/MeltQuoteService.ts'; const mintUrl = 'https://mint.test'; @@ -28,16 +27,14 @@ const makePendingOperation = (): PendingMeltOperation => ({ }); const makeMocks = (operation: PendingMeltOperation) => { - const mintQuoteService = {} as MintQuoteService; const meltQuoteService = {} as MeltQuoteService; - const meltOperationService = { execute: mock(async () => operation), checkPendingOperation: mock(async () => 'finalize' as PendingCheckResult), getOperationByQuote: mock(async () => operation), } as unknown as MeltOperationService; - return { mintQuoteService, meltQuoteService, meltOperationService }; + return { meltQuoteService, meltOperationService }; }; describe('QuotesApi', () => { @@ -49,11 +46,7 @@ describe('QuotesApi', () => { pendingOperation = makePendingOperation(); const mocks = makeMocks(pendingOperation); meltOperationService = mocks.meltOperationService; - api = new QuotesApi( - mocks.mintQuoteService, - mocks.meltQuoteService, - mocks.meltOperationService, - ); + api = new QuotesApi(mocks.meltQuoteService, mocks.meltOperationService); }); describe('executeMeltByQuote', () => { diff --git a/packages/docs/pages/coco-config.md b/packages/docs/pages/coco-config.md index 19ffa9f2..30254709 100644 --- a/packages/docs/pages/coco-config.md +++ b/packages/docs/pages/coco-config.md @@ -10,7 +10,7 @@ export interface CocoConfig { webSocketFactory?: WebSocketFactory; plugins?: Plugin[]; watchers?: { - mintQuoteWatcher?: { + mintOperationWatcher?: { disabled?: boolean; watchExistingPendingOnStart?: boolean; }; @@ -19,7 +19,7 @@ export interface CocoConfig { }; }; processors?: { - mintQuoteProcessor?: { + mintOperationProcessor?: { disabled?: boolean; processIntervalMs?: number; maxRetries?: number; diff --git a/packages/docs/pages/watchers-processors.md b/packages/docs/pages/watchers-processors.md index bc1d381f..f9a0a7e8 100644 --- a/packages/docs/pages/watchers-processors.md +++ b/packages/docs/pages/watchers-processors.md @@ -3,9 +3,9 @@ By default, when using `initializeCoco()`, all watchers and processors are automatically enabled. If you're instantiating the `Manager` class directly, you can manually enable them: ```ts -await coco.enableMintQuoteProcessor(); +await coco.enableMintOperationProcessor(); await coco.enableProofStateWatcher(); -await coco.enableMintQuoteWatcher(); +await coco.enableMintOperationWatcher(); ``` `initializeCoco()` also recovers pending `coco.ops.send`, `coco.ops.receive`, and `coco.ops.melt` @@ -18,22 +18,24 @@ const coco = await initializeCoco({ repo, seedGetter, watchers: { - mintQuoteWatcher: { disabled: true }, + mintOperationWatcher: { disabled: true }, proofStateWatcher: { disabled: true }, }, processors: { - mintQuoteProcessor: { disabled: true }, + mintOperationProcessor: { disabled: true }, }, }); ``` -## MintQuoteProcessor +## MintOperationProcessor -This module will periodically check the database for "PAID" mint quotes and redeem them. +This module processes live mint operation events. When a pending mint operation is observed as +`PAID`, the processor advances it by finalizing the operation. -## MintQuoteWatcher +## MintOperationWatcher -This module will check the state of mint quotes (via WebSockets and polling) and update their state automatically. +This module watches pending mint operations via WebSockets and polling, observes remote quote +state changes, and emits operation-based mint events. It does not finalize operations itself. ## ProofStateWatcher @@ -57,8 +59,8 @@ When `pauseSubscriptions()` is called: - All WebSocket connections are closed immediately - Reconnection attempts are disabled to save battery -- All watchers (`MintQuoteWatcher`, `ProofStateWatcher`) are stopped -- The `MintQuoteProcessor` is stopped +- All watchers (`MintOperationWatcher`, `ProofStateWatcher`) are stopped +- The `MintOperationProcessor` is stopped ### What happens during resume? @@ -66,7 +68,8 @@ When `resumeSubscriptions()` is called: - All subscriptions are re-established (WebSockets or polling) - Watchers are restarted based on their original configuration -- The `MintQuoteProcessor` is restarted and paid mint quotes are re-enqueued +- The `MintOperationProcessor` is restarted if enabled +- Startup and resume backlog reconciliation are handled by mint operation recovery - Everything returns to its previous state before pausing ### Use Cases diff --git a/packages/docs/starting/adding-mints.md b/packages/docs/starting/adding-mints.md index 11eaabeb..91f8ede2 100644 --- a/packages/docs/starting/adding-mints.md +++ b/packages/docs/starting/adding-mints.md @@ -27,7 +27,12 @@ console.log('Mint description:', mintInfo.description); await coco.mint.trustMint(mintUrl); // Now you can perform wallet operations -const quote = await coco.quotes.createMintQuote(mintUrl, 21); +const pendingMint = await coco.ops.mint.prepare({ + mintUrl, + amount: 21, + method: 'bolt11', + methodData: {}, +}); ``` ### Trust Immediately @@ -41,7 +46,12 @@ const mintUrl = 'https://trustworthy-mint.com'; await coco.mint.addMint(mintUrl, { trusted: true }); // Ready for wallet operations -const quote = await coco.quotes.createMintQuote(mintUrl, 21); +const pendingMint = await coco.ops.mint.prepare({ + mintUrl, + amount: 21, + method: 'bolt11', + methodData: {}, +}); ``` ## Trust Management diff --git a/packages/docs/starting/minting.md b/packages/docs/starting/minting.md index cbcf489f..d6cabbec 100644 --- a/packages/docs/starting/minting.md +++ b/packages/docs/starting/minting.md @@ -1,6 +1,6 @@ # Minting Cashu Token -The process of swapping sats for Cashu token is called "minting". To mint with Coco you need to create a mint quote, specifying a `mintUrl` and an `amount` in Sats. +The process of swapping sats for Cashu token is called "minting". To mint with Coco you prepare a mint operation, specifying a `mintUrl` and an `amount` in sats. Before minting, ensure the mint is added and trusted (see [Adding a Mint](./adding-mints.md)): @@ -8,21 +8,31 @@ Before minting, ensure the mint is added and trusted (see [Adding a Mint](./addi // Add and trust the mint first await coco.mint.addMint('https://minturl.com', { trusted: true }); -// Create a mint quote -const mintQuote = await coco.quotes.createMintQuote('https://minturl.com', 21); +// Create a mint operation (this also creates the remote quote) +const pendingMint = await coco.ops.mint.prepare({ + mintUrl: 'https://minturl.com', + amount: 21, + method: 'bolt11', + methodData: {}, +}); ``` -The returned `MintQuoteReponse` has a "request" field that contains a BOLT11 payment request that needs to be paid before minting can happen. When [Watchers and Processors](../pages/watchers-processors.md) are activated (they are by default) Coco will automatically check whether the quote has been paid and redeem it automatically. -You can use the event system to get notified once a quote was redeemed. +The returned pending mint operation has a `request` field containing the BOLT11 payment request that needs to be paid before minting can happen. When [Watchers and Processors](../pages/watchers-processors.md) are activated (they are by default) Coco will automatically check whether the quote has been paid and redeem it automatically. +You can also execute the pending operation yourself after payment. ```ts -const mintQuote = await coco.quotes.createMintQuote('https://minturl.com', 21); +const pendingMint = await coco.ops.mint.prepare({ + mintUrl: 'https://minturl.com', + amount: 21, + method: 'bolt11', + methodData: {}, +}); -console.log('pay this: ', mintQuote.request); -console.log('this is the quotes id: ', mintQuote.quote); +console.log('pay this: ', pendingMint.request); +console.log('this is the quote id: ', pendingMint.quoteId); -coco.on('mint-quote:redeemed', (payload) => { - if (payload.quoteId === mintQuote.quote) { +coco.on('mint-op:finalized', (payload) => { + if (payload.operationId === pendingMint.id) { console.log('This was paid!!'); } }); diff --git a/packages/expo-sqlite/src/index.ts b/packages/expo-sqlite/src/index.ts index 6c4eb0c0..abfd1f74 100644 --- a/packages/expo-sqlite/src/index.ts +++ b/packages/expo-sqlite/src/index.ts @@ -10,6 +10,7 @@ import type { SendOperationRepository, MeltOperationRepository, AuthSessionRepository, + MintOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from 'coco-cashu-core'; @@ -26,6 +27,7 @@ import { ExpoHistoryRepository } from './repositories/HistoryRepository.ts'; import { ExpoSendOperationRepository } from './repositories/SendOperationRepository.ts'; import { ExpoMeltOperationRepository } from './repositories/MeltOperationRepository.ts'; import { ExpoAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; +import { ExpoMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { ExpoReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; export interface ExpoSqliteRepositoriesOptions extends ExpoSqliteDbOptions {} @@ -42,6 +44,7 @@ export class ExpoSqliteRepositories implements Repositories { readonly sendOperationRepository: SendOperationRepository; readonly meltOperationRepository: MeltOperationRepository; readonly authSessionRepository: AuthSessionRepository; + readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; readonly db: ExpoSqliteDb; @@ -58,6 +61,7 @@ export class ExpoSqliteRepositories implements Repositories { this.sendOperationRepository = new ExpoSendOperationRepository(this.db); this.meltOperationRepository = new ExpoMeltOperationRepository(this.db); this.authSessionRepository = new ExpoAuthSessionRepository(this.db); + this.mintOperationRepository = new ExpoMintOperationRepository(this.db); this.receiveOperationRepository = new ExpoReceiveOperationRepository(this.db); } @@ -79,6 +83,7 @@ export class ExpoSqliteRepositories implements Repositories { sendOperationRepository: new ExpoSendOperationRepository(txDb), meltOperationRepository: new ExpoMeltOperationRepository(txDb), authSessionRepository: new ExpoAuthSessionRepository(txDb), + mintOperationRepository: new ExpoMintOperationRepository(txDb), receiveOperationRepository: new ExpoReceiveOperationRepository(txDb), }; @@ -103,6 +108,7 @@ export { ExpoSendOperationRepository, ExpoMeltOperationRepository, ExpoAuthSessionRepository, + ExpoMintOperationRepository, ExpoReceiveOperationRepository, }; diff --git a/packages/expo-sqlite/src/repositories/MintOperationRepository.ts b/packages/expo-sqlite/src/repositories/MintOperationRepository.ts new file mode 100644 index 00000000..8f7c7072 --- /dev/null +++ b/packages/expo-sqlite/src/repositories/MintOperationRepository.ts @@ -0,0 +1,264 @@ +import type { MintOperationRepository } from 'coco-cashu-core'; +import { ExpoSqliteDb, getUnixTimeSeconds } from '../db.ts'; + +type MintOperation = NonNullable>>; +type MintOperationState = Parameters[0]; +type MintMethod = MintOperation['method']; +type MintMethodData = MintOperation['methodData']; +type MintOperationFailure = NonNullable; + +interface MintOperationRow { + id: string; + mintUrl: string; + quoteId: string | null; + state: MintOperationState; + createdAt: number; + updatedAt: number; + error: string | null; + method: MintMethod; + methodDataJson: string; + amount: number | null; + unit: string | null; + request: string | null; + expiry: number | null; + pubkey: string | null; + lastObservedRemoteState: string | null; + lastObservedRemoteStateAt: number | null; + terminalFailureJson: string | null; + outputDataJson: string | null; +} + +const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; + +const isPersistedState = (state: string): state is (typeof persistedStates)[number] => + persistedStates.includes(state as (typeof persistedStates)[number]); + +const normalizeState = (state: string): MintOperationState => { + if ( + state === 'pending' || + state === 'executing' || + state === 'finalized' || + state === 'failed' + ) { + return state; + } + return 'init'; +}; + +const rowToOperation = (row: MintOperationRow): MintOperation => { + const base = { + id: row.id, + mintUrl: row.mintUrl, + method: row.method, + methodData: JSON.parse(row.methodDataJson) as MintMethodData, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + ...(row.terminalFailureJson + ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } + : {}), + }; + + const intent = { + amount: row.amount ?? 0, + unit: row.unit ?? '', + }; + + if (!isPersistedState(row.state)) { + return { + ...base, + ...intent, + state: 'init', + ...(row.quoteId ? { quoteId: row.quoteId } : {}), + }; + } + + return { + ...base, + ...intent, + state: normalizeState(row.state), + quoteId: row.quoteId ?? '', + request: row.request ?? '', + expiry: row.expiry ?? 0, + pubkey: row.pubkey ?? undefined, + lastObservedRemoteState: row.lastObservedRemoteState ?? undefined, + lastObservedRemoteStateAt: row.lastObservedRemoteStateAt ?? undefined, + outputData: row.outputDataJson ? JSON.parse(row.outputDataJson) : { keep: [], send: [] }, + } as MintOperation; +}; + +const operationToParams = (operation: MintOperation): unknown[] => { + const createdAtSeconds = Math.floor(operation.createdAt / 1000); + const updatedAtSeconds = Math.floor(operation.updatedAt / 1000); + const methodDataJson = JSON.stringify(operation.methodData); + + if (operation.state === 'init') { + return [ + operation.id, + operation.mintUrl, + operation.quoteId ?? null, + operation.state, + createdAtSeconds, + updatedAtSeconds, + operation.error ?? null, + operation.method, + methodDataJson, + operation.amount, + operation.unit, + null, + null, + null, + null, + null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + null, + ]; + } + + return [ + operation.id, + operation.mintUrl, + operation.quoteId, + operation.state, + createdAtSeconds, + updatedAtSeconds, + operation.error ?? null, + operation.method, + methodDataJson, + operation.amount, + operation.unit, + operation.request, + operation.expiry, + operation.pubkey ?? null, + operation.lastObservedRemoteState ?? null, + operation.lastObservedRemoteStateAt ?? null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + JSON.stringify(operation.outputData), + ]; +}; + +export class ExpoMintOperationRepository implements MintOperationRepository { + private readonly db: ExpoSqliteDb; + + constructor(db: ExpoSqliteDb) { + this.db = db; + } + + async create(operation: MintOperation): Promise { + const exists = await this.db.get<{ id: string }>( + 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + [operation.id], + ); + if (exists) { + throw new Error(`MintOperation with id ${operation.id} already exists`); + } + + const params = operationToParams(operation); + await this.db.run( + `INSERT INTO coco_cashu_mint_operations + (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + params, + ); + } + + async update(operation: MintOperation): Promise { + const exists = await this.db.get<{ id: string }>( + 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + [operation.id], + ); + if (!exists) { + throw new Error(`MintOperation with id ${operation.id} not found`); + } + + const updatedAtSeconds = getUnixTimeSeconds(); + + if (operation.state === 'init') { + await this.db.run( + `UPDATE coco_cashu_mint_operations + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ? + WHERE id = ?`, + [ + operation.quoteId ?? null, + operation.state, + updatedAtSeconds, + operation.error ?? null, + operation.method, + JSON.stringify(operation.methodData), + operation.amount, + operation.unit, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + operation.id, + ], + ); + return; + } + + await this.db.run( + `UPDATE coco_cashu_mint_operations + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ? + WHERE id = ?`, + [ + operation.quoteId, + operation.state, + updatedAtSeconds, + operation.error ?? null, + operation.method, + JSON.stringify(operation.methodData), + operation.amount, + operation.unit, + operation.request, + operation.expiry, + operation.pubkey ?? null, + operation.lastObservedRemoteState ?? null, + operation.lastObservedRemoteStateAt ?? null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + JSON.stringify(operation.outputData), + operation.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_mint_operations WHERE id = ?', + [id], + ); + return row ? rowToOperation(row) : null; + } + + async getByState(state: MintOperationState): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE state = ?', + [state], + ); + return rows.map(rowToOperation); + } + + async getPending(): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE state IN ("pending", "executing")', + ); + return rows.map(rowToOperation); + } + + async getByMintUrl(mintUrl: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE mintUrl = ?', + [mintUrl], + ); + return rows.map(rowToOperation); + } + + async getByQuoteId(mintUrl: string, quoteId: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE mintUrl = ? AND quoteId = ? ORDER BY updatedAt DESC', + [mintUrl, quoteId], + ); + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_mint_operations WHERE id = ?', [id]); + } +} diff --git a/packages/expo-sqlite/src/schema.ts b/packages/expo-sqlite/src/schema.ts index 6e5eb931..b04f1590 100644 --- a/packages/expo-sqlite/src/schema.ts +++ b/packages/expo-sqlite/src/schema.ts @@ -369,6 +369,165 @@ const MIGRATIONS: readonly Migration[] = [ ); `, }, + { + id: '018_mint_operations', + sql: ` + CREATE TABLE IF NOT EXISTS coco_cashu_mint_operations ( + id TEXT PRIMARY KEY NOT NULL, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, + { + id: '019_mint_operations_pending_lifecycle', + sql: ` + ALTER TABLE coco_cashu_mint_operations RENAME TO coco_cashu_mint_operations_legacy; + + CREATE TABLE coco_cashu_mint_operations ( + id TEXT PRIMARY KEY NOT NULL, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + INSERT INTO coco_cashu_mint_operations ( + id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson + ) + SELECT + id, + mintUrl, + quoteId, + CASE + WHEN state = 'prepared' THEN 'pending' + WHEN state = 'rolled_back' THEN 'finalized' + ELSE state + END, + createdAt, + updatedAt, + error, + method, + methodDataJson, + amount, + unit, + request, + expiry, + pubkey, + lastObservedRemoteState, + lastObservedRemoteStateAt, + terminalFailureJson, + outputDataJson + FROM coco_cashu_mint_operations_legacy; + + DROP TABLE coco_cashu_mint_operations_legacy; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, + { + id: '020_mint_operations_failed_state', + sql: ` + ALTER TABLE coco_cashu_mint_operations RENAME TO coco_cashu_mint_operations_legacy; + + CREATE TABLE coco_cashu_mint_operations ( + id TEXT PRIMARY KEY NOT NULL, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + INSERT INTO coco_cashu_mint_operations ( + id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson + ) + SELECT + id, + mintUrl, + quoteId, + state, + createdAt, + updatedAt, + error, + method, + methodDataJson, + amount, + unit, + request, + expiry, + pubkey, + lastObservedRemoteState, + lastObservedRemoteStateAt, + terminalFailureJson, + outputDataJson + FROM coco_cashu_mint_operations_legacy; + + DROP TABLE coco_cashu_mint_operations_legacy; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/expo-sqlite/src/test/MintOperationRepository.test.ts b/packages/expo-sqlite/src/test/MintOperationRepository.test.ts new file mode 100644 index 00000000..20d61277 --- /dev/null +++ b/packages/expo-sqlite/src/test/MintOperationRepository.test.ts @@ -0,0 +1,104 @@ +/// + +// @ts-ignore bun:test types are provided by the test runner in this workspace. +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +// @ts-ignore bun:sqlite types are provided by the runtime in this workspace. +import { Database } from 'bun:sqlite'; +import type { MintOperation } from 'coco-cashu-core'; +import { + ExpoSqliteRepositories, + type ExpoSqliteRepositoriesOptions, +} from '../index.ts'; + +type PendingMintOperation = Extract; + +type RunResult = { changes: number; lastInsertRowId: number; lastInsertRowid: number }; + +class BunExpoSqliteDatabaseShim { + private readonly db: Database; + + constructor(filename = ':memory:') { + this.db = new Database(filename); + } + + async execAsync(sql: string): Promise { + const statements = sql + .split(';') + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statementSql of statements) { + this.db.prepare(statementSql).run(); + } + } + + async runAsync(sql: string, ...params: any[]): Promise { + const result = this.db.prepare(sql).run(...params) as unknown as { + changes?: number; + lastInsertRowid?: number; + }; + + const changes = Number(result?.changes ?? 0); + const lastInsertRowId = Number(result?.lastInsertRowid ?? 0); + return { changes, lastInsertRowId, lastInsertRowid: lastInsertRowId }; + } + + async getFirstAsync(sql: string, ...params: any[]): Promise { + const row = this.db.prepare(sql).get(...params) as T | undefined; + return row ?? null; + } + + async getAllAsync(sql: string, ...params: any[]): Promise { + const rows = this.db.prepare(sql).all(...params) as T[] | undefined; + return rows ?? []; + } + + async closeAsync(): Promise { + this.db.close(); + } +} + +function makePendingMintOperation(): PendingMintOperation { + return { + id: 'mint-op-1', + mintUrl: 'https://mint.test', + quoteId: 'quote-1', + state: 'pending', + method: 'bolt11', + methodData: {}, + createdAt: 1_000, + updatedAt: 2_000, + amount: 100, + unit: 'sat', + request: 'lnbc1test', + expiry: 1_730_000_000, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 2_500, + outputData: { keep: [], send: [] }, + }; +} + +describe('ExpoMintOperationRepository', () => { + let database: BunExpoSqliteDatabaseShim; + let repositories: ExpoSqliteRepositories; + + beforeEach(async () => { + database = new BunExpoSqliteDatabaseShim(); + repositories = new ExpoSqliteRepositories({ + database: database as unknown as ExpoSqliteRepositoriesOptions['database'], + }); + await repositories.init(); + }); + + afterEach(async () => { + await repositories.db.raw.closeAsync?.(); + }); + + it('round-trips quote snapshot fields for pending operations', async () => { + const operation = makePendingMintOperation(); + + await repositories.mintOperationRepository.create(operation); + + expect(await repositories.mintOperationRepository.getById(operation.id)).toEqual(operation); + }); +}); diff --git a/packages/indexeddb/src/index.ts b/packages/indexeddb/src/index.ts index a2c4e7d3..fd496881 100644 --- a/packages/indexeddb/src/index.ts +++ b/packages/indexeddb/src/index.ts @@ -10,6 +10,7 @@ import type { SendOperationRepository, MeltOperationRepository, AuthSessionRepository, + MintOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from 'coco-cashu-core'; @@ -26,6 +27,7 @@ import { IdbHistoryRepository } from './repositories/HistoryRepository.ts'; import { IdbSendOperationRepository } from './repositories/SendOperationRepository.ts'; import { IdbMeltOperationRepository } from './repositories/MeltOperationRepository.ts'; import { IdbAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; +import { IdbMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { IdbReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; export interface IndexedDbRepositoriesOptions extends IdbDbOptions {} @@ -42,6 +44,7 @@ export class IndexedDbRepositories implements Repositories { readonly sendOperationRepository: SendOperationRepository; readonly meltOperationRepository: MeltOperationRepository; readonly authSessionRepository: AuthSessionRepository; + readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; readonly db: IdbDb; private initialized = false; @@ -59,6 +62,7 @@ export class IndexedDbRepositories implements Repositories { this.sendOperationRepository = new IdbSendOperationRepository(this.db); this.meltOperationRepository = new IdbMeltOperationRepository(this.db); this.authSessionRepository = new IdbAuthSessionRepository(this.db); + this.mintOperationRepository = new IdbMintOperationRepository(this.db); this.receiveOperationRepository = new IdbReceiveOperationRepository(this.db); } @@ -88,6 +92,7 @@ export class IndexedDbRepositories implements Repositories { sendOperationRepository: new IdbSendOperationRepository(scopedDb), meltOperationRepository: new IdbMeltOperationRepository(scopedDb), authSessionRepository: new IdbAuthSessionRepository(scopedDb), + mintOperationRepository: new IdbMintOperationRepository(scopedDb), receiveOperationRepository: new IdbReceiveOperationRepository(scopedDb), }; return fn(scopedRepositories); @@ -109,5 +114,6 @@ export { IdbSendOperationRepository, IdbMeltOperationRepository, IdbAuthSessionRepository, + IdbMintOperationRepository, IdbReceiveOperationRepository, }; diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index 49e76f66..7d54d7fe 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -229,3 +229,24 @@ export interface AuthSessionRow { scope: string | null; batPoolJson: string | null; } + +export interface MintOperationRow { + id: string; + mintUrl: string; + quoteId?: string | null; + state: 'init' | 'pending' | 'executing' | 'finalized' | 'failed'; + createdAt: number; + updatedAt: number; + error?: string | null; + method: string; + methodDataJson: string; + amount?: number | null; + unit?: string | null; + request?: string | null; + expiry?: number | null; + pubkey?: string | null; + lastObservedRemoteState?: string | null; + lastObservedRemoteStateAt?: number | null; + terminalFailureJson?: string | null; + outputDataJson?: string | null; +} diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index 7f0a50b0..f8f09700 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -404,4 +404,23 @@ export async function ensureSchema(db: IdbDb): Promise { coco_cashu_receive_operations: '&id, state, mintUrl', coco_cashu_auth_sessions: '&mintUrl', }); + + // Version 16: Add mint operations store with the current unreleased row shape + db.version(16).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+id+state], state, mintUrl, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl', + coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + }); } diff --git a/packages/indexeddb/src/repositories/MintOperationRepository.test.ts b/packages/indexeddb/src/repositories/MintOperationRepository.test.ts new file mode 100644 index 00000000..a5e439a8 --- /dev/null +++ b/packages/indexeddb/src/repositories/MintOperationRepository.test.ts @@ -0,0 +1,200 @@ +/// + +// @ts-ignore bun:test types are provided by the test runner in this workspace. +import { describe, expect, it } from 'bun:test'; +import { IdbMintOperationRepository } from './MintOperationRepository.ts'; +import type { MintOperationRow } from '../lib/db.ts'; + +describe('IdbMintOperationRepository', () => { + const quoteExpiry = 1_730_000_000; + it('loads supported persisted mint operation states', async () => { + const rows = new Map([ + [ + 'mint-op-init', + { + id: 'mint-op-init', + mintUrl: 'https://mint.test', + state: 'init', + createdAt: 1, + updatedAt: 2, + error: null, + method: 'bolt11', + methodDataJson: JSON.stringify({}), + amount: 100, + unit: 'sat', + outputDataJson: null, + }, + ], + [ + 'mint-op-pending', + { + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 3, + updatedAt: 4, + error: null, + method: 'bolt11', + methodDataJson: JSON.stringify({}), + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 5, + outputDataJson: JSON.stringify({ keep: [], send: [] }), + }, + ], + [ + 'mint-op-finalized', + { + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5, + updatedAt: 6, + error: 'already issued', + method: 'bolt11', + methodDataJson: JSON.stringify({}), + amount: 200, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 1, + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: 7, + outputDataJson: JSON.stringify({ keep: [], send: [] }), + }, + ], + [ + 'mint-op-failed', + { + id: 'mint-op-failed', + mintUrl: 'https://mint.test', + quoteId: 'quote-failed', + state: 'failed', + createdAt: 7, + updatedAt: 8, + error: 'quote expired', + method: 'bolt11', + methodDataJson: JSON.stringify({}), + amount: 300, + unit: 'sat', + request: 'lnbc1failed', + expiry: quoteExpiry + 2, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 9, + terminalFailureJson: JSON.stringify({ + reason: 'quote expired', + observedAt: 10, + }), + outputDataJson: JSON.stringify({ keep: [], send: [] }), + }, + ], + ]); + + const repository = new IdbMintOperationRepository({ + table: () => ({ + get: async (id: string) => rows.get(id), + }), + } as any); + + await expect(repository.getById('mint-op-init')).resolves.toEqual({ + id: 'mint-op-init', + mintUrl: 'https://mint.test', + state: 'init', + createdAt: 1000, + updatedAt: 2000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + }); + + await expect(repository.getById('mint-op-pending')).resolves.toEqual({ + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 3000, + updatedAt: 4000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 5, + outputData: { keep: [], send: [] }, + }); + + await expect(repository.getById('mint-op-finalized')).resolves.toEqual({ + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5000, + updatedAt: 6000, + error: 'already issued', + method: 'bolt11', + methodData: {}, + amount: 200, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 1, + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: 7, + outputData: { keep: [], send: [] }, + }); + + await expect(repository.getById('mint-op-failed')).resolves.toEqual({ + id: 'mint-op-failed', + mintUrl: 'https://mint.test', + quoteId: 'quote-failed', + state: 'failed', + createdAt: 7000, + updatedAt: 8000, + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 300, + unit: 'sat', + request: 'lnbc1failed', + expiry: quoteExpiry + 2, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 9, + terminalFailure: { + reason: 'quote expired', + observedAt: 10, + }, + outputData: { keep: [], send: [] }, + }); + }); + + it('queries only pending and executing states for active work', async () => { + const requestedStates: string[][] = []; + + const repository = new IdbMintOperationRepository({ + table: () => ({ + where: () => ({ + anyOf: (states: string[]) => { + requestedStates.push(states); + return { + toArray: async () => [] as MintOperationRow[], + }; + }, + }), + }), + } as any); + + await repository.getByState('pending'); + await repository.getPending(); + + expect(requestedStates[0]).toEqual(['pending']); + expect(requestedStates[1]).toEqual(['pending', 'executing']); + }); +}); diff --git a/packages/indexeddb/src/repositories/MintOperationRepository.ts b/packages/indexeddb/src/repositories/MintOperationRepository.ts new file mode 100644 index 00000000..0b6ab98b --- /dev/null +++ b/packages/indexeddb/src/repositories/MintOperationRepository.ts @@ -0,0 +1,199 @@ +import type { MintOperationRepository } from 'coco-cashu-core'; +import type { IdbDb, MintOperationRow } from '../lib/db.ts'; +import { getUnixTimeSeconds } from '../lib/db.ts'; + +type MintOperation = NonNullable>>; +type MintOperationState = Parameters[0]; +type MintMethodData = MintOperation['methodData']; +type MintOperationFailure = NonNullable; + +const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; + +const isPersistedState = (state: string): state is (typeof persistedStates)[number] => + persistedStates.includes(state as (typeof persistedStates)[number]); + +const normalizeState = (state: string): MintOperationState => { + if ( + state === 'pending' || + state === 'executing' || + state === 'finalized' || + state === 'failed' + ) { + return state; + } + return 'init'; +}; + +const rowToOperation = (row: MintOperationRow): MintOperation => { + const base = { + id: row.id, + mintUrl: row.mintUrl, + method: row.method as MintOperation['method'], + methodData: JSON.parse(row.methodDataJson) as MintMethodData, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + ...(row.terminalFailureJson + ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } + : {}), + }; + + const intent = { + amount: row.amount ?? 0, + unit: row.unit ?? '', + }; + + if (!isPersistedState(row.state)) { + return { + ...base, + ...intent, + state: 'init', + ...(row.quoteId ? { quoteId: row.quoteId } : {}), + }; + } + + return { + ...base, + ...intent, + state: normalizeState(row.state), + quoteId: row.quoteId ?? '', + request: row.request ?? '', + expiry: row.expiry ?? 0, + pubkey: row.pubkey ?? undefined, + lastObservedRemoteState: row.lastObservedRemoteState ?? undefined, + lastObservedRemoteStateAt: row.lastObservedRemoteStateAt ?? undefined, + outputData: row.outputDataJson ? JSON.parse(row.outputDataJson) : { keep: [], send: [] }, + } as MintOperation; +}; + +const operationToRow = (operation: MintOperation): MintOperationRow => { + const createdAtSeconds = Math.floor(operation.createdAt / 1000); + const updatedAtSeconds = Math.floor(operation.updatedAt / 1000); + const methodDataJson = JSON.stringify(operation.methodData); + + if (operation.state === 'init') { + return { + id: operation.id, + mintUrl: operation.mintUrl, + quoteId: operation.quoteId ?? null, + state: operation.state, + createdAt: createdAtSeconds, + updatedAt: updatedAtSeconds, + error: operation.error ?? null, + method: operation.method, + methodDataJson, + amount: operation.amount, + unit: operation.unit, + terminalFailureJson: operation.terminalFailure + ? JSON.stringify(operation.terminalFailure) + : null, + outputDataJson: null, + }; + } + + return { + id: operation.id, + mintUrl: operation.mintUrl, + quoteId: operation.quoteId, + state: operation.state, + createdAt: createdAtSeconds, + updatedAt: updatedAtSeconds, + error: operation.error ?? null, + method: operation.method, + methodDataJson, + amount: operation.amount, + unit: operation.unit, + request: operation.request, + expiry: operation.expiry, + pubkey: operation.pubkey ?? null, + lastObservedRemoteState: operation.lastObservedRemoteState ?? null, + lastObservedRemoteStateAt: operation.lastObservedRemoteStateAt ?? null, + terminalFailureJson: operation.terminalFailure + ? JSON.stringify(operation.terminalFailure) + : null, + outputDataJson: JSON.stringify(operation.outputData), + }; +}; + +export class IdbMintOperationRepository implements MintOperationRepository { + private readonly db: IdbDb; + + constructor(db: IdbDb) { + this.db = db; + } + + async create(operation: MintOperation): Promise { + await this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { + const table = tx.table('coco_cashu_mint_operations'); + const existing = await table.get(operation.id); + if (existing) { + throw new Error(`MintOperation with id ${operation.id} already exists`); + } + await table.add(operationToRow(operation)); + }); + } + + async update(operation: MintOperation): Promise { + await this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { + const table = tx.table('coco_cashu_mint_operations'); + const existing = await table.get(operation.id); + if (!existing) { + throw new Error(`MintOperation with id ${operation.id} not found`); + } + + const row = operationToRow(operation); + row.updatedAt = getUnixTimeSeconds(); + await table.put(row); + }); + } + + async getById(id: string): Promise { + const row = (await (this.db as any) + .table('coco_cashu_mint_operations') + .get(id)) as MintOperationRow | undefined; + return row ? rowToOperation(row) : null; + } + + async getByState(state: MintOperationState): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_mint_operations') + .where('state') + .anyOf([state]) + .toArray()) as MintOperationRow[]; + return rows.map(rowToOperation); + } + + async getPending(): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_mint_operations') + .where('state') + .anyOf(['pending', 'executing']) + .toArray()) as MintOperationRow[]; + return rows.map(rowToOperation); + } + + async getByMintUrl(mintUrl: string): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_mint_operations') + .where('mintUrl') + .equals(mintUrl) + .toArray()) as MintOperationRow[]; + return rows.map(rowToOperation); + } + + async getByQuoteId(mintUrl: string, quoteId: string): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_mint_operations') + .where('[mintUrl+quoteId]') + .equals([mintUrl, quoteId]) + .toArray()) as MintOperationRow[]; + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { + const table = tx.table('coco_cashu_mint_operations'); + await table.delete(id); + }); + } +} diff --git a/packages/sqlite-bun/src/env.d.ts b/packages/sqlite-bun/src/env.d.ts index c9a189df..3b4e40c5 100644 --- a/packages/sqlite-bun/src/env.d.ts +++ b/packages/sqlite-bun/src/env.d.ts @@ -1,3 +1,41 @@ +declare module 'bun:test' { + export function describe(name: string, fn: () => void): void; + export function it(name: string, fn: () => void | Promise, timeout?: number): void; + export function beforeEach(fn: () => void | Promise): void; + export function afterEach(fn: () => void | Promise): void; + export const expect: any; +} + +declare module 'bun:sqlite' { + export interface StatementRunResult { + lastInsertRowid: number | bigint; + changes: number; + } + + export class Statement { + run(...params: any[]): StatementRunResult; + get(...params: any[]): unknown; + all(...params: any[]): unknown[]; + } + + export class Database { + constructor(filename?: string); + prepare(sql: string): Statement; + exec(sql: string): void; + close(): void; + } +} + declare const process: { env: Record; }; + +declare function setTimeout( + handler: (...args: any[]) => void, + timeout?: number, + ...args: any[] +): number; + +declare const console: { + warn(...args: any[]): void; +}; diff --git a/packages/sqlite-bun/src/index.ts b/packages/sqlite-bun/src/index.ts index ed662054..98ba27a0 100644 --- a/packages/sqlite-bun/src/index.ts +++ b/packages/sqlite-bun/src/index.ts @@ -10,6 +10,7 @@ import type { SendOperationRepository, MeltOperationRepository, AuthSessionRepository, + MintOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from 'coco-cashu-core'; @@ -26,6 +27,7 @@ import { SqliteHistoryRepository } from './repositories/HistoryRepository.ts'; import { SqliteSendOperationRepository } from './repositories/SendOperationRepository.ts'; import { SqliteMeltOperationRepository } from './repositories/MeltOperationRepository.ts'; import { SqliteAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; +import { SqliteMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { SqliteReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; export interface SqliteRepositoriesOptions extends SqliteDbOptions {} @@ -42,6 +44,7 @@ export class SqliteRepositories implements Repositories { readonly sendOperationRepository: SendOperationRepository; readonly meltOperationRepository: MeltOperationRepository; readonly authSessionRepository: AuthSessionRepository; + readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; readonly db: SqliteDb; @@ -58,6 +61,7 @@ export class SqliteRepositories implements Repositories { this.sendOperationRepository = new SqliteSendOperationRepository(this.db); this.meltOperationRepository = new SqliteMeltOperationRepository(this.db); this.authSessionRepository = new SqliteAuthSessionRepository(this.db); + this.mintOperationRepository = new SqliteMintOperationRepository(this.db); this.receiveOperationRepository = new SqliteReceiveOperationRepository(this.db); } @@ -79,6 +83,7 @@ export class SqliteRepositories implements Repositories { sendOperationRepository: new SqliteSendOperationRepository(txDb), meltOperationRepository: new SqliteMeltOperationRepository(txDb), authSessionRepository: new SqliteAuthSessionRepository(txDb), + mintOperationRepository: new SqliteMintOperationRepository(txDb), receiveOperationRepository: new SqliteReceiveOperationRepository(txDb), }; @@ -103,6 +108,7 @@ export { SqliteSendOperationRepository, SqliteMeltOperationRepository, SqliteAuthSessionRepository, + SqliteMintOperationRepository, SqliteReceiveOperationRepository, }; diff --git a/packages/sqlite-bun/src/repositories/MintOperationRepository.ts b/packages/sqlite-bun/src/repositories/MintOperationRepository.ts new file mode 100644 index 00000000..08bf50e4 --- /dev/null +++ b/packages/sqlite-bun/src/repositories/MintOperationRepository.ts @@ -0,0 +1,264 @@ +import type { MintOperationRepository } from 'coco-cashu-core'; +import { SqliteDb, getUnixTimeSeconds } from '../db.ts'; + +type MintOperation = NonNullable>>; +type MintOperationState = Parameters[0]; +type MintMethod = MintOperation['method']; +type MintMethodData = MintOperation['methodData']; +type MintOperationFailure = NonNullable; + +interface MintOperationRow { + id: string; + mintUrl: string; + quoteId: string | null; + state: MintOperationState; + createdAt: number; + updatedAt: number; + error: string | null; + method: MintMethod; + methodDataJson: string; + amount: number | null; + unit: string | null; + request: string | null; + expiry: number | null; + pubkey: string | null; + lastObservedRemoteState: string | null; + lastObservedRemoteStateAt: number | null; + terminalFailureJson: string | null; + outputDataJson: string | null; +} + +const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; + +const isPersistedState = (state: string): state is (typeof persistedStates)[number] => + persistedStates.includes(state as (typeof persistedStates)[number]); + +const normalizeState = (state: string): MintOperationState => { + if ( + state === 'pending' || + state === 'executing' || + state === 'finalized' || + state === 'failed' + ) { + return state; + } + return 'init'; +}; + +const rowToOperation = (row: MintOperationRow): MintOperation => { + const base = { + id: row.id, + mintUrl: row.mintUrl, + method: row.method, + methodData: JSON.parse(row.methodDataJson) as MintMethodData, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + ...(row.terminalFailureJson + ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } + : {}), + }; + + const intent = { + amount: row.amount ?? 0, + unit: row.unit ?? '', + }; + + if (!isPersistedState(row.state)) { + return { + ...base, + ...intent, + state: 'init', + ...(row.quoteId ? { quoteId: row.quoteId } : {}), + }; + } + + return { + ...base, + ...intent, + state: normalizeState(row.state), + quoteId: row.quoteId ?? '', + request: row.request ?? '', + expiry: row.expiry ?? 0, + pubkey: row.pubkey ?? undefined, + lastObservedRemoteState: row.lastObservedRemoteState ?? undefined, + lastObservedRemoteStateAt: row.lastObservedRemoteStateAt ?? undefined, + outputData: row.outputDataJson ? JSON.parse(row.outputDataJson) : { keep: [], send: [] }, + } as MintOperation; +}; + +const operationToParams = (operation: MintOperation): unknown[] => { + const createdAtSeconds = Math.floor(operation.createdAt / 1000); + const updatedAtSeconds = Math.floor(operation.updatedAt / 1000); + const methodDataJson = JSON.stringify(operation.methodData); + + if (operation.state === 'init') { + return [ + operation.id, + operation.mintUrl, + operation.quoteId ?? null, + operation.state, + createdAtSeconds, + updatedAtSeconds, + operation.error ?? null, + operation.method, + methodDataJson, + operation.amount, + operation.unit, + null, + null, + null, + null, + null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + null, + ]; + } + + return [ + operation.id, + operation.mintUrl, + operation.quoteId, + operation.state, + createdAtSeconds, + updatedAtSeconds, + operation.error ?? null, + operation.method, + methodDataJson, + operation.amount, + operation.unit, + operation.request, + operation.expiry, + operation.pubkey ?? null, + operation.lastObservedRemoteState ?? null, + operation.lastObservedRemoteStateAt ?? null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + JSON.stringify(operation.outputData), + ]; +}; + +export class SqliteMintOperationRepository implements MintOperationRepository { + private readonly db: SqliteDb; + + constructor(db: SqliteDb) { + this.db = db; + } + + async create(operation: MintOperation): Promise { + const exists = await this.db.get<{ id: string }>( + 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + [operation.id], + ); + if (exists) { + throw new Error(`MintOperation with id ${operation.id} already exists`); + } + + const params = operationToParams(operation); + await this.db.run( + `INSERT INTO coco_cashu_mint_operations + (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + params, + ); + } + + async update(operation: MintOperation): Promise { + const exists = await this.db.get<{ id: string }>( + 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + [operation.id], + ); + if (!exists) { + throw new Error(`MintOperation with id ${operation.id} not found`); + } + + const updatedAtSeconds = getUnixTimeSeconds(); + + if (operation.state === 'init') { + await this.db.run( + `UPDATE coco_cashu_mint_operations + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ? + WHERE id = ?`, + [ + operation.quoteId ?? null, + operation.state, + updatedAtSeconds, + operation.error ?? null, + operation.method, + JSON.stringify(operation.methodData), + operation.amount, + operation.unit, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + operation.id, + ], + ); + return; + } + + await this.db.run( + `UPDATE coco_cashu_mint_operations + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ? + WHERE id = ?`, + [ + operation.quoteId, + operation.state, + updatedAtSeconds, + operation.error ?? null, + operation.method, + JSON.stringify(operation.methodData), + operation.amount, + operation.unit, + operation.request, + operation.expiry, + operation.pubkey ?? null, + operation.lastObservedRemoteState ?? null, + operation.lastObservedRemoteStateAt ?? null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + JSON.stringify(operation.outputData), + operation.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_mint_operations WHERE id = ?', + [id], + ); + return row ? rowToOperation(row) : null; + } + + async getByState(state: MintOperationState): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE state = ?', + [state], + ); + return rows.map(rowToOperation); + } + + async getPending(): Promise { + const rows = await this.db.all( + "SELECT * FROM coco_cashu_mint_operations WHERE state IN ('pending', 'executing')", + ); + return rows.map(rowToOperation); + } + + async getByMintUrl(mintUrl: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE mintUrl = ?', + [mintUrl], + ); + return rows.map(rowToOperation); + } + + async getByQuoteId(mintUrl: string, quoteId: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE mintUrl = ? AND quoteId = ? ORDER BY updatedAt DESC', + [mintUrl, quoteId], + ); + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_mint_operations WHERE id = ?', [id]); + } +} diff --git a/packages/sqlite-bun/src/schema.ts b/packages/sqlite-bun/src/schema.ts index 7adfd54b..6fcd70aa 100644 --- a/packages/sqlite-bun/src/schema.ts +++ b/packages/sqlite-bun/src/schema.ts @@ -369,6 +369,165 @@ const MIGRATIONS: readonly Migration[] = [ ); `, }, + { + id: '018_mint_operations', + sql: ` + CREATE TABLE IF NOT EXISTS coco_cashu_mint_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, + { + id: '019_mint_operations_pending_lifecycle', + sql: ` + ALTER TABLE coco_cashu_mint_operations RENAME TO coco_cashu_mint_operations_legacy; + + CREATE TABLE coco_cashu_mint_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + INSERT INTO coco_cashu_mint_operations ( + id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson + ) + SELECT + id, + mintUrl, + quoteId, + CASE + WHEN state = 'prepared' THEN 'pending' + WHEN state = 'rolled_back' THEN 'finalized' + ELSE state + END, + createdAt, + updatedAt, + error, + method, + methodDataJson, + amount, + unit, + request, + expiry, + pubkey, + lastObservedRemoteState, + lastObservedRemoteStateAt, + terminalFailureJson, + outputDataJson + FROM coco_cashu_mint_operations_legacy; + + DROP TABLE coco_cashu_mint_operations_legacy; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, + { + id: '020_mint_operations_failed_state', + sql: ` + ALTER TABLE coco_cashu_mint_operations RENAME TO coco_cashu_mint_operations_legacy; + + CREATE TABLE coco_cashu_mint_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + INSERT INTO coco_cashu_mint_operations ( + id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson + ) + SELECT + id, + mintUrl, + quoteId, + state, + createdAt, + updatedAt, + error, + method, + methodDataJson, + amount, + unit, + request, + expiry, + pubkey, + lastObservedRemoteState, + lastObservedRemoteStateAt, + terminalFailureJson, + outputDataJson + FROM coco_cashu_mint_operations_legacy; + + DROP TABLE coco_cashu_mint_operations_legacy; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/sqlite-bun/src/test/MintOperationRepository.test.ts b/packages/sqlite-bun/src/test/MintOperationRepository.test.ts new file mode 100644 index 00000000..196729bf --- /dev/null +++ b/packages/sqlite-bun/src/test/MintOperationRepository.test.ts @@ -0,0 +1,173 @@ +/// + +// @ts-ignore bun:test types are provided by the test runner in this workspace. +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +// @ts-ignore bun:sqlite types are provided by the runtime in this workspace. +import { Database } from 'bun:sqlite'; +import { SqliteRepositories } from '../index.ts'; + +describe('SqliteMintOperationRepository', () => { + const quoteExpiry = 1_730_000_000; + let database: Database; + let repositories: SqliteRepositories; + + beforeEach(async () => { + database = new Database(':memory:'); + repositories = new SqliteRepositories({ database }); + await repositories.init(); + }); + + afterEach(async () => { + await repositories.db.close(); + }); + + it('persists and loads supported mint operation states', async () => { + await repositories.mintOperationRepository.create({ + id: 'mint-op-init', + mintUrl: 'https://mint.test', + state: 'init', + createdAt: 1000, + updatedAt: 2000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + }); + + await repositories.mintOperationRepository.create({ + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 3000, + updatedAt: 4000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 4500, + outputData: { keep: [], send: [] }, + }); + + await repositories.mintOperationRepository.create({ + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5000, + updatedAt: 6000, + error: 'already issued', + method: 'bolt11', + methodData: {}, + amount: 200, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 1, + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: 6500, + outputData: { keep: [], send: [] }, + }); + + await repositories.mintOperationRepository.create({ + id: 'mint-op-failed', + mintUrl: 'https://mint.test', + quoteId: 'quote-failed', + state: 'failed', + createdAt: 7000, + updatedAt: 8000, + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 300, + unit: 'sat', + request: 'lnbc1failed', + expiry: quoteExpiry + 2, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 8500, + terminalFailure: { + reason: 'quote expired', + observedAt: 9000, + }, + outputData: { keep: [], send: [] }, + }); + + expect(await repositories.mintOperationRepository.getById('mint-op-init')).toEqual({ + id: 'mint-op-init', + mintUrl: 'https://mint.test', + state: 'init', + createdAt: 1000, + updatedAt: 2000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + }); + + expect(await repositories.mintOperationRepository.getById('mint-op-pending')).toEqual({ + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 3000, + updatedAt: 4000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 4500, + outputData: { keep: [], send: [] }, + }); + + expect(await repositories.mintOperationRepository.getById('mint-op-finalized')).toEqual({ + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5000, + updatedAt: 6000, + error: 'already issued', + method: 'bolt11', + methodData: {}, + amount: 200, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 1, + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: 6500, + outputData: { keep: [], send: [] }, + }); + + expect(await repositories.mintOperationRepository.getById('mint-op-failed')).toEqual({ + id: 'mint-op-failed', + mintUrl: 'https://mint.test', + quoteId: 'quote-failed', + state: 'failed', + createdAt: 7000, + updatedAt: 8000, + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 300, + unit: 'sat', + request: 'lnbc1failed', + expiry: quoteExpiry + 2, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 8500, + terminalFailure: { + reason: 'quote expired', + observedAt: 9000, + }, + outputData: { keep: [], send: [] }, + }); + }); +}); diff --git a/packages/sqlite-bun/src/test/schema.test.ts b/packages/sqlite-bun/src/test/schema.test.ts new file mode 100644 index 00000000..aa8047d8 --- /dev/null +++ b/packages/sqlite-bun/src/test/schema.test.ts @@ -0,0 +1,85 @@ +/// + +// @ts-ignore bun:test types are provided by the test runner in this workspace. +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +// @ts-ignore bun:sqlite types are provided by the runtime in this workspace. +import { Database } from 'bun:sqlite'; +import { SqliteDb, ensureSchemaUpTo } from '../index.ts'; +import { SqliteMintOperationRepository } from '../repositories/MintOperationRepository.ts'; + +describe('sqlite-bun schema migrations', () => { + let database: Database; + let db: SqliteDb; + + beforeEach(() => { + database = new Database(':memory:'); + db = new SqliteDb({ database }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('upgrades mint operations to allow failed state persistence', async () => { + await ensureSchemaUpTo(db, '020_mint_operations_failed_state'); + + await db.run( + `INSERT INTO coco_cashu_mint_operations + (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, outputDataJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'mint-op-1', + 'https://mint.test', + 'quote-1', + 'executing', + 1, + 2, + null, + 'bolt11', + '{}', + 100, + JSON.stringify({ keep: [], send: [] }), + ], + ); + + await ensureSchemaUpTo(db); + + const repository = new SqliteMintOperationRepository(db); + await repository.update({ + id: 'mint-op-1', + mintUrl: 'https://mint.test', + quoteId: 'quote-1', + state: 'failed', + createdAt: 1000, + updatedAt: 2000, + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1test', + expiry: 1_730_000_000, + outputData: { keep: [], send: [] }, + }); + + expect(await repository.getById('mint-op-1')).toEqual({ + id: 'mint-op-1', + mintUrl: 'https://mint.test', + quoteId: 'quote-1', + state: 'failed', + createdAt: 1000, + updatedAt: expect.any(Number), + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1test', + expiry: 1_730_000_000, + pubkey: undefined, + lastObservedRemoteState: undefined, + lastObservedRemoteStateAt: undefined, + outputData: { keep: [], send: [] }, + }); + }); +}); diff --git a/packages/sqlite-bun/tsconfig.json b/packages/sqlite-bun/tsconfig.json index 82fa2de3..8b0b6f76 100644 --- a/packages/sqlite-bun/tsconfig.json +++ b/packages/sqlite-bun/tsconfig.json @@ -13,6 +13,7 @@ "preserveSymlinks": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, + "types": ["bun"], "noEmit": true, // Best practices diff --git a/packages/sqlite3/src/env.d.ts b/packages/sqlite3/src/env.d.ts index c9a189df..6bed2773 100644 --- a/packages/sqlite3/src/env.d.ts +++ b/packages/sqlite3/src/env.d.ts @@ -1,3 +1,9 @@ +declare function setTimeout( + handler: (...args: any[]) => void, + timeout?: number, + ...args: any[] +): number; + declare const process: { env: Record; }; diff --git a/packages/sqlite3/src/index.ts b/packages/sqlite3/src/index.ts index ed662054..98ba27a0 100644 --- a/packages/sqlite3/src/index.ts +++ b/packages/sqlite3/src/index.ts @@ -10,6 +10,7 @@ import type { SendOperationRepository, MeltOperationRepository, AuthSessionRepository, + MintOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from 'coco-cashu-core'; @@ -26,6 +27,7 @@ import { SqliteHistoryRepository } from './repositories/HistoryRepository.ts'; import { SqliteSendOperationRepository } from './repositories/SendOperationRepository.ts'; import { SqliteMeltOperationRepository } from './repositories/MeltOperationRepository.ts'; import { SqliteAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; +import { SqliteMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { SqliteReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; export interface SqliteRepositoriesOptions extends SqliteDbOptions {} @@ -42,6 +44,7 @@ export class SqliteRepositories implements Repositories { readonly sendOperationRepository: SendOperationRepository; readonly meltOperationRepository: MeltOperationRepository; readonly authSessionRepository: AuthSessionRepository; + readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; readonly db: SqliteDb; @@ -58,6 +61,7 @@ export class SqliteRepositories implements Repositories { this.sendOperationRepository = new SqliteSendOperationRepository(this.db); this.meltOperationRepository = new SqliteMeltOperationRepository(this.db); this.authSessionRepository = new SqliteAuthSessionRepository(this.db); + this.mintOperationRepository = new SqliteMintOperationRepository(this.db); this.receiveOperationRepository = new SqliteReceiveOperationRepository(this.db); } @@ -79,6 +83,7 @@ export class SqliteRepositories implements Repositories { sendOperationRepository: new SqliteSendOperationRepository(txDb), meltOperationRepository: new SqliteMeltOperationRepository(txDb), authSessionRepository: new SqliteAuthSessionRepository(txDb), + mintOperationRepository: new SqliteMintOperationRepository(txDb), receiveOperationRepository: new SqliteReceiveOperationRepository(txDb), }; @@ -103,6 +108,7 @@ export { SqliteSendOperationRepository, SqliteMeltOperationRepository, SqliteAuthSessionRepository, + SqliteMintOperationRepository, SqliteReceiveOperationRepository, }; diff --git a/packages/sqlite3/src/repositories/MintOperationRepository.ts b/packages/sqlite3/src/repositories/MintOperationRepository.ts new file mode 100644 index 00000000..08bf50e4 --- /dev/null +++ b/packages/sqlite3/src/repositories/MintOperationRepository.ts @@ -0,0 +1,264 @@ +import type { MintOperationRepository } from 'coco-cashu-core'; +import { SqliteDb, getUnixTimeSeconds } from '../db.ts'; + +type MintOperation = NonNullable>>; +type MintOperationState = Parameters[0]; +type MintMethod = MintOperation['method']; +type MintMethodData = MintOperation['methodData']; +type MintOperationFailure = NonNullable; + +interface MintOperationRow { + id: string; + mintUrl: string; + quoteId: string | null; + state: MintOperationState; + createdAt: number; + updatedAt: number; + error: string | null; + method: MintMethod; + methodDataJson: string; + amount: number | null; + unit: string | null; + request: string | null; + expiry: number | null; + pubkey: string | null; + lastObservedRemoteState: string | null; + lastObservedRemoteStateAt: number | null; + terminalFailureJson: string | null; + outputDataJson: string | null; +} + +const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; + +const isPersistedState = (state: string): state is (typeof persistedStates)[number] => + persistedStates.includes(state as (typeof persistedStates)[number]); + +const normalizeState = (state: string): MintOperationState => { + if ( + state === 'pending' || + state === 'executing' || + state === 'finalized' || + state === 'failed' + ) { + return state; + } + return 'init'; +}; + +const rowToOperation = (row: MintOperationRow): MintOperation => { + const base = { + id: row.id, + mintUrl: row.mintUrl, + method: row.method, + methodData: JSON.parse(row.methodDataJson) as MintMethodData, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + ...(row.terminalFailureJson + ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } + : {}), + }; + + const intent = { + amount: row.amount ?? 0, + unit: row.unit ?? '', + }; + + if (!isPersistedState(row.state)) { + return { + ...base, + ...intent, + state: 'init', + ...(row.quoteId ? { quoteId: row.quoteId } : {}), + }; + } + + return { + ...base, + ...intent, + state: normalizeState(row.state), + quoteId: row.quoteId ?? '', + request: row.request ?? '', + expiry: row.expiry ?? 0, + pubkey: row.pubkey ?? undefined, + lastObservedRemoteState: row.lastObservedRemoteState ?? undefined, + lastObservedRemoteStateAt: row.lastObservedRemoteStateAt ?? undefined, + outputData: row.outputDataJson ? JSON.parse(row.outputDataJson) : { keep: [], send: [] }, + } as MintOperation; +}; + +const operationToParams = (operation: MintOperation): unknown[] => { + const createdAtSeconds = Math.floor(operation.createdAt / 1000); + const updatedAtSeconds = Math.floor(operation.updatedAt / 1000); + const methodDataJson = JSON.stringify(operation.methodData); + + if (operation.state === 'init') { + return [ + operation.id, + operation.mintUrl, + operation.quoteId ?? null, + operation.state, + createdAtSeconds, + updatedAtSeconds, + operation.error ?? null, + operation.method, + methodDataJson, + operation.amount, + operation.unit, + null, + null, + null, + null, + null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + null, + ]; + } + + return [ + operation.id, + operation.mintUrl, + operation.quoteId, + operation.state, + createdAtSeconds, + updatedAtSeconds, + operation.error ?? null, + operation.method, + methodDataJson, + operation.amount, + operation.unit, + operation.request, + operation.expiry, + operation.pubkey ?? null, + operation.lastObservedRemoteState ?? null, + operation.lastObservedRemoteStateAt ?? null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + JSON.stringify(operation.outputData), + ]; +}; + +export class SqliteMintOperationRepository implements MintOperationRepository { + private readonly db: SqliteDb; + + constructor(db: SqliteDb) { + this.db = db; + } + + async create(operation: MintOperation): Promise { + const exists = await this.db.get<{ id: string }>( + 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + [operation.id], + ); + if (exists) { + throw new Error(`MintOperation with id ${operation.id} already exists`); + } + + const params = operationToParams(operation); + await this.db.run( + `INSERT INTO coco_cashu_mint_operations + (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + params, + ); + } + + async update(operation: MintOperation): Promise { + const exists = await this.db.get<{ id: string }>( + 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + [operation.id], + ); + if (!exists) { + throw new Error(`MintOperation with id ${operation.id} not found`); + } + + const updatedAtSeconds = getUnixTimeSeconds(); + + if (operation.state === 'init') { + await this.db.run( + `UPDATE coco_cashu_mint_operations + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ? + WHERE id = ?`, + [ + operation.quoteId ?? null, + operation.state, + updatedAtSeconds, + operation.error ?? null, + operation.method, + JSON.stringify(operation.methodData), + operation.amount, + operation.unit, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + operation.id, + ], + ); + return; + } + + await this.db.run( + `UPDATE coco_cashu_mint_operations + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ? + WHERE id = ?`, + [ + operation.quoteId, + operation.state, + updatedAtSeconds, + operation.error ?? null, + operation.method, + JSON.stringify(operation.methodData), + operation.amount, + operation.unit, + operation.request, + operation.expiry, + operation.pubkey ?? null, + operation.lastObservedRemoteState ?? null, + operation.lastObservedRemoteStateAt ?? null, + operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + JSON.stringify(operation.outputData), + operation.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_mint_operations WHERE id = ?', + [id], + ); + return row ? rowToOperation(row) : null; + } + + async getByState(state: MintOperationState): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE state = ?', + [state], + ); + return rows.map(rowToOperation); + } + + async getPending(): Promise { + const rows = await this.db.all( + "SELECT * FROM coco_cashu_mint_operations WHERE state IN ('pending', 'executing')", + ); + return rows.map(rowToOperation); + } + + async getByMintUrl(mintUrl: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE mintUrl = ?', + [mintUrl], + ); + return rows.map(rowToOperation); + } + + async getByQuoteId(mintUrl: string, quoteId: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_mint_operations WHERE mintUrl = ? AND quoteId = ? ORDER BY updatedAt DESC', + [mintUrl, quoteId], + ); + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_mint_operations WHERE id = ?', [id]); + } +} diff --git a/packages/sqlite3/src/schema.ts b/packages/sqlite3/src/schema.ts index ab9b0eeb..edded86b 100644 --- a/packages/sqlite3/src/schema.ts +++ b/packages/sqlite3/src/schema.ts @@ -369,6 +369,165 @@ const MIGRATIONS: readonly Migration[] = [ ); `, }, + { + id: '018_mint_operations', + sql: ` + CREATE TABLE IF NOT EXISTS coco_cashu_mint_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, + { + id: '019_mint_operations_pending_lifecycle', + sql: ` + ALTER TABLE coco_cashu_mint_operations RENAME TO coco_cashu_mint_operations_legacy; + + CREATE TABLE coco_cashu_mint_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + INSERT INTO coco_cashu_mint_operations ( + id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson + ) + SELECT + id, + mintUrl, + quoteId, + CASE + WHEN state = 'prepared' THEN 'pending' + WHEN state = 'rolled_back' THEN 'finalized' + ELSE state + END, + createdAt, + updatedAt, + error, + method, + methodDataJson, + amount, + unit, + request, + expiry, + pubkey, + lastObservedRemoteState, + lastObservedRemoteStateAt, + terminalFailureJson, + outputDataJson + FROM coco_cashu_mint_operations_legacy; + + DROP TABLE coco_cashu_mint_operations_legacy; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, + { + id: '020_mint_operations_failed_state', + sql: ` + ALTER TABLE coco_cashu_mint_operations RENAME TO coco_cashu_mint_operations_legacy; + + CREATE TABLE coco_cashu_mint_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + quoteId TEXT, + state TEXT NOT NULL CHECK (state IN ('init', 'pending', 'executing', 'finalized', 'failed')), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + amount INTEGER, + unit TEXT, + request TEXT, + expiry INTEGER, + pubkey TEXT, + lastObservedRemoteState TEXT, + lastObservedRemoteStateAt INTEGER, + terminalFailureJson TEXT, + outputDataJson TEXT + ); + + INSERT INTO coco_cashu_mint_operations ( + id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson + ) + SELECT + id, + mintUrl, + quoteId, + state, + createdAt, + updatedAt, + error, + method, + methodDataJson, + amount, + unit, + request, + expiry, + pubkey, + lastObservedRemoteState, + lastObservedRemoteStateAt, + terminalFailureJson, + outputDataJson + FROM coco_cashu_mint_operations_legacy; + + DROP TABLE coco_cashu_mint_operations_legacy; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_state + ON coco_cashu_mint_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint + ON coco_cashu_mint_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_mint_quote + ON coco_cashu_mint_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/sqlite3/src/test/MintOperationRepository.test.ts b/packages/sqlite3/src/test/MintOperationRepository.test.ts new file mode 100644 index 00000000..7a892f63 --- /dev/null +++ b/packages/sqlite3/src/test/MintOperationRepository.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Database, { type Database as BetterSqlite3Database } from 'better-sqlite3'; +import { SqliteDb } from '../db.ts'; +import { ensureSchema } from '../schema.ts'; +import { SqliteMintOperationRepository } from '../repositories/MintOperationRepository.ts'; + +describe('SqliteMintOperationRepository', () => { + const quoteExpiry = 1_730_000_000; + let database: BetterSqlite3Database; + let db: SqliteDb; + let repository: SqliteMintOperationRepository; + + beforeEach(async () => { + database = new Database(':memory:'); + db = new SqliteDb({ database }); + await ensureSchema(db); + repository = new SqliteMintOperationRepository(db); + }); + + afterEach(async () => { + await db.close(); + }); + + it('persists and loads supported mint operation states', async () => { + await repository.create({ + id: 'mint-op-init', + mintUrl: 'https://mint.test', + state: 'init', + createdAt: 1000, + updatedAt: 2000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + }); + + await repository.create({ + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 3000, + updatedAt: 4000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 4500, + outputData: { keep: [], send: [] }, + }); + + await repository.create({ + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5000, + updatedAt: 6000, + error: 'already issued', + method: 'bolt11', + methodData: {}, + amount: 200, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 1, + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: 6500, + outputData: { keep: [], send: [] }, + }); + + await repository.create({ + id: 'mint-op-failed', + mintUrl: 'https://mint.test', + quoteId: 'quote-failed', + state: 'failed', + createdAt: 7000, + updatedAt: 8000, + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 300, + unit: 'sat', + request: 'lnbc1failed', + expiry: quoteExpiry + 2, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 8500, + terminalFailure: { + reason: 'quote expired', + observedAt: 9000, + }, + outputData: { keep: [], send: [] }, + }); + + await expect(repository.getById('mint-op-init')).resolves.toEqual({ + id: 'mint-op-init', + mintUrl: 'https://mint.test', + state: 'init', + createdAt: 1000, + updatedAt: 2000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + }); + + await expect(repository.getById('mint-op-pending')).resolves.toEqual({ + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 3000, + updatedAt: 4000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 4500, + outputData: { keep: [], send: [] }, + }); + + await expect(repository.getById('mint-op-finalized')).resolves.toEqual({ + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5000, + updatedAt: 6000, + error: 'already issued', + method: 'bolt11', + methodData: {}, + amount: 200, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 1, + lastObservedRemoteState: 'ISSUED', + lastObservedRemoteStateAt: 6500, + outputData: { keep: [], send: [] }, + }); + + await expect(repository.getById('mint-op-failed')).resolves.toEqual({ + id: 'mint-op-failed', + mintUrl: 'https://mint.test', + quoteId: 'quote-failed', + state: 'failed', + createdAt: 7000, + updatedAt: 8000, + error: 'quote expired', + method: 'bolt11', + methodData: {}, + amount: 300, + unit: 'sat', + request: 'lnbc1failed', + expiry: quoteExpiry + 2, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 8500, + terminalFailure: { + reason: 'quote expired', + observedAt: 9000, + }, + outputData: { keep: [], send: [] }, + }); + }); + + it('returns only pending and executing work from getPending', async () => { + await repository.create({ + id: 'mint-op-pending', + mintUrl: 'https://mint.test', + quoteId: 'quote-pending', + state: 'pending', + createdAt: 1000, + updatedAt: 2000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 100, + unit: 'sat', + request: 'lnbc1pending', + expiry: quoteExpiry, + outputData: { keep: [], send: [] }, + }); + + await repository.create({ + id: 'mint-op-executing', + mintUrl: 'https://mint.test', + quoteId: 'quote-executing', + state: 'executing', + createdAt: 3000, + updatedAt: 4000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 200, + unit: 'sat', + request: 'lnbc1executing', + expiry: quoteExpiry + 1, + outputData: { keep: [], send: [] }, + }); + + await repository.create({ + id: 'mint-op-finalized', + mintUrl: 'https://mint.test', + quoteId: 'quote-finalized', + state: 'finalized', + createdAt: 5000, + updatedAt: 6000, + error: undefined, + method: 'bolt11', + methodData: {}, + amount: 300, + unit: 'sat', + request: 'lnbc1finalized', + expiry: quoteExpiry + 2, + outputData: { keep: [], send: [] }, + }); + + const pending = await repository.getPending(); + + expect(pending).toHaveLength(2); + expect(pending.map((operation) => operation.state).sort()).toEqual(['executing', 'pending']); + expect(pending.map((operation) => operation.id).sort()).toEqual([ + 'mint-op-executing', + 'mint-op-pending', + ]); + }); +}); diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index af4a080e..228f8b24 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -400,18 +400,16 @@ bun run build # Check if any browser tests will be run and install Playwright if needed check_and_install_playwright() { - local needs_playwright=false - if [ "$COMMAND" = "all" ]; then # Check all packages for browser tests - discover_integration_tests | while IFS='|' read -r package test_file; do + while IFS='|' read -r package test_file; do [ -z "$package" ] && continue local pkg_dir=$(dirname "$test_file" | sed 's|/src/test||') if is_browser_test_package "$pkg_dir"; then echo "true" return fi - done + done < <(discover_integration_tests) else # Check specific package local test_file_path