From 4c1901b033b3d2eb455352f99c7ef92aac88dc1a Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 11:04:09 +0200 Subject: [PATCH 01/10] feat(core): add payment request receive operations --- packages/core/Manager.ts | 32 +- packages/core/api/PaymentRequestsApi.ts | 56 +- packages/core/operations/index.ts | 1 + .../PaymentRequestReceiveOperation.ts | 66 +++ .../operations/paymentRequestReceive/index.ts | 1 + .../operations/receive/ReceiveOperation.ts | 18 + .../receive/ReceiveOperationService.ts | 4 +- packages/core/plugins/types.ts | 2 + packages/core/repositories/index.ts | 33 ++ .../MemoryPaymentRequestReceiveRepository.ts | 156 +++++ .../repositories/memory/MemoryRepositories.ts | 11 + packages/core/repositories/memory/index.ts | 1 + packages/core/services/HistoryService.ts | 21 + .../services/PaymentRequestReceiveService.ts | 537 ++++++++++++++++++ packages/core/services/index.ts | 1 + .../unit/PaymentRequestReceiveService.test.ts | 191 +++++++ .../core/test/unit/PaymentRequestsApi.test.ts | 15 +- 17 files changed, 1142 insertions(+), 4 deletions(-) create mode 100644 packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts create mode 100644 packages/core/operations/paymentRequestReceive/index.ts create mode 100644 packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts create mode 100644 packages/core/services/PaymentRequestReceiveService.ts create mode 100644 packages/core/test/unit/PaymentRequestReceiveService.test.ts diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 3bace3f0..e395c48f 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -5,6 +5,8 @@ import type { SendOperationRepository, MeltOperationRepository, ReceiveOperationRepository, + PaymentRequestReceiveOperationRepository, + PaymentRequestReceiveAttemptRepository, } from './repositories'; import { CounterService, @@ -20,6 +22,7 @@ import { HistoryService, KeyRingService, PaymentRequestService, + PaymentRequestReceiveService, AuthSessionService, AuthService, TokenService, @@ -216,6 +219,7 @@ export class Manager { private counterService: CounterService; private tokenService: TokenService; private paymentRequestService: PaymentRequestService; + private paymentRequestReceiveService: PaymentRequestReceiveService; private authSessionService: AuthSessionService; private authService: AuthService; private sendOperationService: SendOperationService; @@ -226,6 +230,8 @@ export class Manager { private mintOperationRepository: MintOperationRepository; private receiveOperationService: ReceiveOperationService; private receiveOperationRepository: ReceiveOperationRepository; + private paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + private paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; private proofRepository: Repositories['proofRepository']; private readonly pluginHost: PluginHost = new PluginHost(); private subscriptionsPaused = false; @@ -278,6 +284,9 @@ export class Manager { this.sendOperationRepository = core.sendOperationRepository; this.receiveOperationService = core.receiveOperationService; this.receiveOperationRepository = core.receiveOperationRepository; + this.paymentRequestReceiveService = core.paymentRequestReceiveService; + this.paymentRequestReceiveOperationRepository = core.paymentRequestReceiveOperationRepository; + this.paymentRequestReceiveAttemptRepository = core.paymentRequestReceiveAttemptRepository; this.meltOperationService = core.meltOperationService; this.meltOperationRepository = core.meltOperationRepository; this.authSessionService = core.authSessionService; @@ -351,6 +360,7 @@ export class Manager { historyService: this.historyService, sendOperationService: this.sendOperationService, receiveOperationService: this.receiveOperationService, + paymentRequestReceiveService: this.paymentRequestReceiveService, tokenService: this.tokenService, subscriptions: this.subscriptions, eventBus: this.eventBus, @@ -654,6 +664,9 @@ export class Manager { sendOperationRepository: SendOperationRepository; receiveOperationService: ReceiveOperationService; receiveOperationRepository: ReceiveOperationRepository; + paymentRequestReceiveService: PaymentRequestReceiveService; + paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; meltOperationService: MeltOperationService; meltOperationRepository: MeltOperationRepository; authSessionService: AuthSessionService; @@ -763,6 +776,9 @@ export class Manager { receiveOperationLogger, ); const receiveOperationRepository = repositories.receiveOperationRepository; + const paymentRequestReceiveOperationRepository = + repositories.paymentRequestReceiveOperationRepository; + const paymentRequestReceiveAttemptRepository = repositories.paymentRequestReceiveAttemptRepository; const meltOperationLogger = this.getChildLogger('MeltOperationService'); const meltHandlerProvider = new MeltHandlerProvider({ @@ -808,6 +824,14 @@ export class Manager { proofService, paymentRequestLogger, ); + const paymentRequestReceiveLogger = this.getChildLogger('PaymentRequestReceiveService'); + const paymentRequestReceiveService = new PaymentRequestReceiveService( + paymentRequestReceiveOperationRepository, + paymentRequestReceiveAttemptRepository, + receiveOperationService, + mintService, + paymentRequestReceiveLogger, + ); const authSessionLogger = this.getChildLogger('AuthSessionService'); const authSessionService = new AuthSessionService( @@ -836,6 +860,9 @@ export class Manager { sendOperationRepository, receiveOperationService, receiveOperationRepository, + paymentRequestReceiveService, + paymentRequestReceiveOperationRepository, + paymentRequestReceiveAttemptRepository, meltOperationService, meltOperationRepository, authSessionService, @@ -876,7 +903,10 @@ export class Manager { const melt = new MeltOpsApi(this.meltOperationService); const ops = new OpsApi(send, receive, mintOps, melt); const auth = new AuthApi(this.authService); - const paymentRequests = new PaymentRequestsApi(this.paymentRequestService); + const paymentRequests = new PaymentRequestsApi( + this.paymentRequestService, + this.paymentRequestReceiveService, + ); return { mint, wallet, diff --git a/packages/core/api/PaymentRequestsApi.ts b/packages/core/api/PaymentRequestsApi.ts index 289c1174..cfc1cd10 100644 --- a/packages/core/api/PaymentRequestsApi.ts +++ b/packages/core/api/PaymentRequestsApi.ts @@ -1,19 +1,73 @@ import type { AmountLike } from '@cashu/cashu-ts'; import type { PaymentRequestExecutionResult, + PaymentRequestReceiveClaimResult, + PaymentRequestReceiveService, PaymentRequestService, PreparedPaymentRequest, ResolvedPaymentRequest, + CreatePaymentRequestReceiveInput, } from '@core/services'; +import type { + PaymentRequestReceiveOperation, + PaymentRequestReceiveSource, + PaymentRequestReceiveState, +} from '@core/operations/paymentRequestReceive'; +import type { PaymentRequestPayload } from '@cashu/cashu-ts'; + +export interface IncomingPaymentRequestsApi { + create(input: CreatePaymentRequestReceiveInput): Promise; + activate( + operationOrId: PaymentRequestReceiveOperation | string, + ): Promise; + cancel(operationId: string, reason?: string): Promise; + get(operationId: string): Promise; + list(filter?: { state?: PaymentRequestReceiveState }): Promise; + claimPayload( + operationOrId: PaymentRequestReceiveOperation | string, + payload: PaymentRequestPayload | string, + source?: PaymentRequestReceiveSource, + ): Promise; + ingestPayload( + payload: PaymentRequestPayload | string, + source?: PaymentRequestReceiveSource, + ): Promise; + readonly recovery: { + run(): Promise; + }; + readonly diagnostics: { + isLocked(operationId: string): boolean; + }; +} /** * API for parsing, preparing, and executing payment requests. */ export class PaymentRequestsApi { private readonly paymentRequestService: PaymentRequestService; + readonly incoming: IncomingPaymentRequestsApi; - constructor(paymentRequestService: PaymentRequestService) { + constructor( + paymentRequestService: PaymentRequestService, + paymentRequestReceiveService: PaymentRequestReceiveService, + ) { this.paymentRequestService = paymentRequestService; + this.incoming = { + create: (input) => paymentRequestReceiveService.create(input), + activate: (operationOrId) => paymentRequestReceiveService.activate(operationOrId), + cancel: (operationId, reason) => paymentRequestReceiveService.cancel(operationId, reason), + get: (operationId) => paymentRequestReceiveService.get(operationId), + list: (filter) => paymentRequestReceiveService.list(filter), + claimPayload: (operationOrId, payload, source) => + paymentRequestReceiveService.claimPayload(operationOrId, payload, source), + ingestPayload: (payload, source) => paymentRequestReceiveService.ingestPayload(payload, source), + recovery: { + run: () => paymentRequestReceiveService.recoverPendingAttempts(), + }, + diagnostics: { + isLocked: (operationId) => paymentRequestReceiveService.isOperationLocked(operationId), + }, + }; } /** diff --git a/packages/core/operations/index.ts b/packages/core/operations/index.ts index 82704d1e..7ffc9b8f 100644 --- a/packages/core/operations/index.ts +++ b/packages/core/operations/index.ts @@ -14,3 +14,4 @@ export { MintOperationService } from './mint/MintOperationService.ts'; export * from './send'; export type { ReceiveOperation, ReceiveOperationState } from './receive/ReceiveOperation.ts'; export { ReceiveOperationService } from './receive/ReceiveOperationService.ts'; +export * from './paymentRequestReceive'; diff --git a/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts b/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts new file mode 100644 index 00000000..36fbed49 --- /dev/null +++ b/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts @@ -0,0 +1,66 @@ +import type { Amount, PaymentRequestPayload, Proof } from '@cashu/cashu-ts'; + +export type PaymentRequestReceiveState = 'draft' | 'active' | 'completed' | 'cancelled' | 'expired'; + +export type PaymentRequestReceiveAttemptState = + | 'received' + | 'validating' + | 'receiving' + | 'finalized' + | 'rejected' + | 'duplicate'; + +export type PaymentRequestReceiveTransport = 'inband' | 'nostr' | 'post'; + +export type PaymentRequestReceiveSource = { + transport: PaymentRequestReceiveTransport; + transportMessageId?: string; + senderPubkey?: string; +}; + +export interface PaymentRequestReceiveOperation { + id: string; + requestId?: string; + encodedRequest: string; + state: PaymentRequestReceiveState; + transport: PaymentRequestReceiveTransport; + amount: Amount; + unit: string; + mints: string[]; + singleUse: boolean; + description?: string; + createdAt: number; + updatedAt: number; + error?: string; + completedAt?: number; +} + +export interface PaymentRequestReceiveAttempt { + id: string; + requestOperationId: string; + requestId?: string; + transport: PaymentRequestReceiveTransport; + transportMessageId?: string; + payloadHash: string; + senderPubkey?: string; + memo?: string; + mintUrl: string; + unit: string; + grossAmount: Amount; + fee?: Amount; + netAmount?: Amount; + receiveOperationId?: string; + state: PaymentRequestReceiveAttemptState; + error?: string; + payload?: PaymentRequestPayload; + createdAt: number; + updatedAt: number; +} + +export type ParsedPaymentRequestPayload = { + id?: string; + memo?: string; + mint: string; + unit: string; + proofs: Proof[]; +}; diff --git a/packages/core/operations/paymentRequestReceive/index.ts b/packages/core/operations/paymentRequestReceive/index.ts new file mode 100644 index 00000000..7fa43016 --- /dev/null +++ b/packages/core/operations/paymentRequestReceive/index.ts @@ -0,0 +1 @@ +export * from './PaymentRequestReceiveOperation.ts'; diff --git a/packages/core/operations/receive/ReceiveOperation.ts b/packages/core/operations/receive/ReceiveOperation.ts index b55575f3..5ac19883 100644 --- a/packages/core/operations/receive/ReceiveOperation.ts +++ b/packages/core/operations/receive/ReceiveOperation.ts @@ -20,6 +20,19 @@ import { type SerializedOutputData, } from '../../utils'; +export type ReceiveOperationSource = + | { type: 'manual-token' } + | { + type: 'payment-request'; + requestOperationId: string; + requestId?: string; + attemptId: string; + transport: 'inband' | 'nostr' | 'post'; + transportMessageId?: string; + senderPubkey?: string; + memo?: string; + }; + // ============================================================================ // Base and Data Interfaces // ============================================================================ @@ -51,6 +64,9 @@ interface ReceiveOperationBase { /** Error message if the operation failed */ error?: string; + + /** Optional origin metadata for receives created by higher-level sagas. */ + source?: ReceiveOperationSource; } /** @@ -189,6 +205,7 @@ export function createReceiveOperation( amount: AmountLike, inputProofs: Proof[], unit: string, + source?: ReceiveOperationSource, ): InitReceiveOperation { const now = Date.now(); return { @@ -198,6 +215,7 @@ export function createReceiveOperation( unit, amount: toAmount(amount), inputProofs, + source, createdAt: now, updatedAt: now, }; diff --git a/packages/core/operations/receive/ReceiveOperationService.ts b/packages/core/operations/receive/ReceiveOperationService.ts index 3db1d354..95f1303e 100644 --- a/packages/core/operations/receive/ReceiveOperationService.ts +++ b/packages/core/operations/receive/ReceiveOperationService.ts @@ -22,6 +22,7 @@ import { } from '../../models/Error'; import type { ReceiveOperation, + ReceiveOperationSource, InitReceiveOperation, PreparedReceiveOperation, PreparedOrLaterOperation, @@ -128,7 +129,7 @@ export class ReceiveOperationService { * Create a new receive operation by decoding and validating the token. * Persists the init state so recovery can reason about this operation. */ - async init(token: Token | string): Promise { + async init(token: Token | string, source?: ReceiveOperationSource): Promise { const mintUrl = this.extractMintUrl(token); const trusted = await this.mintService.isTrustedMint(mintUrl); if (!trusted) { @@ -158,6 +159,7 @@ export class ReceiveOperationService { amount, preparedProofs, decodedToken.unit || 'sat', + source, ); await this.receiveOperationRepository.create(operation); diff --git a/packages/core/plugins/types.ts b/packages/core/plugins/types.ts index ad2b5e0d..91807915 100644 --- a/packages/core/plugins/types.ts +++ b/packages/core/plugins/types.ts @@ -9,6 +9,7 @@ import type { MeltQuoteService, MintService, PaymentRequestService, + PaymentRequestReceiveService, ProofService, SeedService, TokenService, @@ -38,6 +39,7 @@ export interface ServiceMap { meltOperationService: MeltOperationService; mintOperationService: MintOperationService; paymentRequestService: PaymentRequestService; + paymentRequestReceiveService: PaymentRequestReceiveService; subscriptions: SubscriptionManager; eventBus: EventBus; logger: Logger; diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index b3927a16..278b53e4 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -17,6 +17,12 @@ import type { ReceiveOperation, ReceiveOperationState, } from '../operations/receive/ReceiveOperation'; +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveAttemptState, + PaymentRequestReceiveOperation, + PaymentRequestReceiveState, +} from '../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; import type { Counter } from '../models/Counter'; import type { Keyset } from '../models/Keyset'; import type { Mint } from '../models/Mint'; @@ -257,6 +263,31 @@ export interface ReceiveOperationRepository { delete(id: string): Promise; } +export interface PaymentRequestReceiveOperationRepository { + create(operation: PaymentRequestReceiveOperation): Promise; + update(operation: PaymentRequestReceiveOperation): Promise; + getById(id: string): Promise; + getByState(state: PaymentRequestReceiveState): Promise; + getActiveByRequestId(requestId: string): Promise; + list(filter?: { state?: PaymentRequestReceiveState }): Promise; + delete(id: string): Promise; +} + +export interface PaymentRequestReceiveAttemptRepository { + create(attempt: PaymentRequestReceiveAttempt): Promise; + update(attempt: PaymentRequestReceiveAttempt): Promise; + getById(id: string): Promise; + getByRequestOperationId(requestOperationId: string): Promise; + getByReceiveOperationId(receiveOperationId: string): Promise; + getByTransportMessageId(transportMessageId: string): Promise; + getByPayloadHash( + requestOperationId: string, + payloadHash: string, + ): Promise; + getByState(state: PaymentRequestReceiveAttemptState): Promise; + delete(id: string): Promise; +} + interface RepositoriesBase { mintRepository: MintRepository; keyRingRepository: KeyRingRepository; @@ -271,6 +302,8 @@ interface RepositoriesBase { authSessionRepository: AuthSessionRepository; mintOperationRepository: MintOperationRepository; receiveOperationRepository: ReceiveOperationRepository; + paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; } export interface Repositories extends RepositoriesBase { diff --git a/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts b/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts new file mode 100644 index 00000000..86fc9b8c --- /dev/null +++ b/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts @@ -0,0 +1,156 @@ +import type { + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, +} from '..'; +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveAttemptState, + PaymentRequestReceiveOperation, + PaymentRequestReceiveState, +} from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; + +function cloneOperation(operation: PaymentRequestReceiveOperation): PaymentRequestReceiveOperation { + return { ...operation, mints: [...operation.mints] }; +} + +function cloneAttempt(attempt: PaymentRequestReceiveAttempt): PaymentRequestReceiveAttempt { + return { + ...attempt, + payload: attempt.payload + ? { ...attempt.payload, proofs: attempt.payload.proofs.map((proof) => ({ ...proof })) } + : undefined, + }; +} + +export class MemoryPaymentRequestReceiveOperationRepository implements PaymentRequestReceiveOperationRepository { + private readonly operations = new Map(); + + async create(operation: PaymentRequestReceiveOperation): Promise { + if (this.operations.has(operation.id)) { + throw new Error(`PaymentRequestReceiveOperation with id ${operation.id} already exists`); + } + this.operations.set(operation.id, cloneOperation(operation)); + } + + async update(operation: PaymentRequestReceiveOperation): Promise { + if (!this.operations.has(operation.id)) { + throw new Error(`PaymentRequestReceiveOperation with id ${operation.id} not found`); + } + this.operations.set(operation.id, cloneOperation({ ...operation, updatedAt: Date.now() })); + } + + async getById(id: string): Promise { + const operation = this.operations.get(id); + return operation ? cloneOperation(operation) : null; + } + + async getByState(state: PaymentRequestReceiveState): Promise { + return Array.from(this.operations.values()) + .filter((operation) => operation.state === state) + .map(cloneOperation); + } + + async getActiveByRequestId(requestId: string): Promise { + return Array.from(this.operations.values()) + .filter((operation) => operation.state === 'active' && operation.requestId === requestId) + .map(cloneOperation); + } + + async list(filter?: { + state?: PaymentRequestReceiveState; + }): Promise { + return Array.from(this.operations.values()) + .filter((operation) => !filter?.state || operation.state === filter.state) + .map(cloneOperation); + } + + async delete(id: string): Promise { + this.operations.delete(id); + } +} + +export class MemoryPaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { + private readonly attempts = new Map(); + + async create(attempt: PaymentRequestReceiveAttempt): Promise { + if (this.attempts.has(attempt.id)) { + throw new Error(`PaymentRequestReceiveAttempt with id ${attempt.id} already exists`); + } + if ( + attempt.transportMessageId && + (await this.getByTransportMessageId(attempt.transportMessageId)) + ) { + throw new Error( + `PaymentRequestReceiveAttempt with transport message id ${attempt.transportMessageId} already exists`, + ); + } + if (await this.getByPayloadHash(attempt.requestOperationId, attempt.payloadHash)) { + throw new Error( + `PaymentRequestReceiveAttempt with payload hash ${attempt.payloadHash} already exists`, + ); + } + this.attempts.set(attempt.id, cloneAttempt(attempt)); + } + + async update(attempt: PaymentRequestReceiveAttempt): Promise { + if (!this.attempts.has(attempt.id)) { + throw new Error(`PaymentRequestReceiveAttempt with id ${attempt.id} not found`); + } + this.attempts.set(attempt.id, cloneAttempt({ ...attempt, updatedAt: Date.now() })); + } + + async getById(id: string): Promise { + const attempt = this.attempts.get(id); + return attempt ? cloneAttempt(attempt) : null; + } + + async getByRequestOperationId( + requestOperationId: string, + ): Promise { + return Array.from(this.attempts.values()) + .filter((attempt) => attempt.requestOperationId === requestOperationId) + .map(cloneAttempt); + } + + async getByReceiveOperationId( + receiveOperationId: string, + ): Promise { + const attempt = Array.from(this.attempts.values()).find( + (candidate) => candidate.receiveOperationId === receiveOperationId, + ); + return attempt ? cloneAttempt(attempt) : null; + } + + async getByTransportMessageId( + transportMessageId: string, + ): Promise { + const attempt = Array.from(this.attempts.values()).find( + (candidate) => candidate.transportMessageId === transportMessageId, + ); + return attempt ? cloneAttempt(attempt) : null; + } + + async getByPayloadHash( + requestOperationId: string, + payloadHash: string, + ): Promise { + const attempt = Array.from(this.attempts.values()).find( + (candidate) => + candidate.requestOperationId === requestOperationId && + candidate.payloadHash === payloadHash, + ); + return attempt ? cloneAttempt(attempt) : null; + } + + async getByState( + state: PaymentRequestReceiveAttemptState, + ): Promise { + return Array.from(this.attempts.values()) + .filter((attempt) => attempt.state === state) + .map(cloneAttempt); + } + + async delete(id: string): Promise { + this.attempts.delete(id); + } +} diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index 63e98191..3d9fff6c 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -13,6 +13,8 @@ import type { RepositoryTransactionScope, SendOperationRepository, MintOperationRepository, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, } from '..'; import { MemoryAuthSessionRepository } from './MemoryAuthSessionRepository'; @@ -28,6 +30,10 @@ import { MemoryProofRepository } from './MemoryProofRepository'; import { MemorySendOperationRepository } from './MemorySendOperationRepository'; import { MemoryMintOperationRepository } from './MemoryMintOperationRepository'; import { MemoryReceiveOperationRepository } from './MemoryReceiveOperationRepository'; +import { + MemoryPaymentRequestReceiveAttemptRepository, + MemoryPaymentRequestReceiveOperationRepository, +} from './MemoryPaymentRequestReceiveRepository'; export class MemoryRepositories implements Repositories { mintRepository: MintRepository; @@ -43,6 +49,8 @@ export class MemoryRepositories implements Repositories { authSessionRepository: AuthSessionRepository; mintOperationRepository: MintOperationRepository; receiveOperationRepository: ReceiveOperationRepository; + paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; constructor() { this.mintRepository = new MemoryMintRepository(); @@ -58,6 +66,9 @@ export class MemoryRepositories implements Repositories { this.authSessionRepository = new MemoryAuthSessionRepository(); this.mintOperationRepository = new MemoryMintOperationRepository(); this.receiveOperationRepository = new MemoryReceiveOperationRepository(); + this.paymentRequestReceiveOperationRepository = + new MemoryPaymentRequestReceiveOperationRepository(); + this.paymentRequestReceiveAttemptRepository = new MemoryPaymentRequestReceiveAttemptRepository(); } async init(): Promise { diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index f9b659c5..58273036 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -13,3 +13,4 @@ export * from './MemorySendOperationRepository'; export * from './MemoryMeltOperationRepository'; export * from './MemoryMintOperationRepository'; export * from './MemoryReceiveOperationRepository'; +export * from './MemoryPaymentRequestReceiveRepository'; diff --git a/packages/core/services/HistoryService.ts b/packages/core/services/HistoryService.ts index 6bfe720f..27970858 100644 --- a/packages/core/services/HistoryService.ts +++ b/packages/core/services/HistoryService.ts @@ -190,6 +190,7 @@ export class HistoryService { existing.amount = operation.amount; existing.unit = operation.unit || existing.unit || 'sat'; existing.state = state; + existing.metadata = this.getReceiveMetadata(operation) ?? existing.metadata; if (token) { existing.token = token; } @@ -207,6 +208,7 @@ export class HistoryService { operationId: operation.id, state, token, + metadata: this.getReceiveMetadata(operation), }; const entry = await this.historyRepository.addHistoryEntry(entryPayload); await this.handleHistoryUpdated(mintUrl, entry); @@ -220,6 +222,25 @@ export class HistoryService { } } + private getReceiveMetadata(operation: ReceiveOperation): Record | undefined { + if (operation.source?.type !== 'payment-request') { + return undefined; + } + + return { + source: 'payment-request', + requestOperationId: operation.source.requestOperationId, + attemptId: operation.source.attemptId, + ...(operation.source.requestId ? { requestId: operation.source.requestId } : {}), + transport: operation.source.transport, + ...(operation.source.transportMessageId + ? { transportMessageId: operation.source.transportMessageId } + : {}), + ...(operation.source.senderPubkey ? { senderPubkey: operation.source.senderPubkey } : {}), + ...(operation.source.memo ? { memo: operation.source.memo } : {}), + }; + } + async handleMintOperationQuoteStateChanged( mintUrl: string, operationId: string, diff --git a/packages/core/services/PaymentRequestReceiveService.ts b/packages/core/services/PaymentRequestReceiveService.ts new file mode 100644 index 00000000..1652cbbe --- /dev/null +++ b/packages/core/services/PaymentRequestReceiveService.ts @@ -0,0 +1,537 @@ +import { + Amount, + JSONInt, + PaymentRequest, + type AmountLike, + type NUT10Option, + type PaymentRequestPayload, + type Proof, + sumProofs, +} from '@cashu/cashu-ts'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; + +import type { Logger } from '@core/logging'; +import { PaymentRequestError, ProofValidationError } from '../models/Error'; +import type { MintService } from './MintService'; +import type { ReceiveOperationService } from '../operations/receive/ReceiveOperationService'; +import type { + FinalizedReceiveOperation, + ReceiveOperation, +} from '../operations/receive/ReceiveOperation'; +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveOperation, + PaymentRequestReceiveSource, + PaymentRequestReceiveState, + PaymentRequestReceiveTransport, + ParsedPaymentRequestPayload, +} from '../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; +import type { + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, +} from '../repositories'; +import { computeYHexForSecrets, generateSubId, normalizeMintUrl } from '../utils'; +import { OperationIdLock } from '../operations/OperationIdLock'; + +export interface CreatePaymentRequestReceiveInput { + amount: AmountLike; + unit?: string; + mints?: string[]; + requestId?: string; + description?: string; + singleUse?: boolean; + transport?: PaymentRequestReceiveTransport; + encoding?: 'creqA' | 'creqB'; + nut10?: NUT10Option; +} + +export interface PaymentRequestReceiveClaimResult { + operation: PaymentRequestReceiveOperation; + attempt: PaymentRequestReceiveAttempt; + receiveOperation?: ReceiveOperation; +} + +export class PaymentRequestReceiveService { + private readonly lock = new OperationIdLock(); + + constructor( + private readonly operationRepository: PaymentRequestReceiveOperationRepository, + private readonly attemptRepository: PaymentRequestReceiveAttemptRepository, + private readonly receiveOperationService: ReceiveOperationService, + private readonly mintService: MintService, + private readonly logger?: Logger, + ) {} + + isOperationLocked(operationId: string): boolean { + return this.lock.isLocked(operationId); + } + + async create(input: CreatePaymentRequestReceiveInput): Promise { + const unit = input.unit ?? 'sat'; + if (unit !== 'sat') { + throw new PaymentRequestError(`Unsupported payment request unit '${unit}'`); + } + if (input.nut10) { + throw new PaymentRequestError('NUT-10 receive requirements are not supported yet'); + } + + const transport = input.transport ?? 'inband'; + if (transport !== 'inband') { + throw new PaymentRequestError(`Transport '${transport}' is not supported yet`); + } + + const amount = Amount.from(input.amount); + if (amount.isZero()) { + throw new PaymentRequestError('Payment request amount must be positive'); + } + + const mints = input.mints?.map((mintUrl) => normalizeMintUrl(mintUrl)) ?? []; + for (const mintUrl of mints) { + const trusted = await this.mintService.isTrustedMint(mintUrl); + if (!trusted) { + throw new PaymentRequestError(`Mint ${mintUrl} is not trusted`); + } + } + + const requestId = input.requestId ?? generateSubId(); + const paymentRequest = new PaymentRequest( + [], + requestId, + amount, + unit, + mints.length > 0 ? mints : undefined, + input.description, + input.singleUse ?? true, + ); + const encodedRequest = + input.encoding === 'creqA' + ? paymentRequest.toEncodedCreqA() + : paymentRequest.toEncodedCreqB(); + const now = Date.now(); + const operation: PaymentRequestReceiveOperation = { + id: generateSubId(), + requestId, + encodedRequest, + state: 'draft', + transport, + amount, + unit, + mints, + singleUse: input.singleUse ?? true, + description: input.description, + createdAt: now, + updatedAt: now, + }; + + await this.operationRepository.create(operation); + return operation; + } + + async activate( + operationOrId: PaymentRequestReceiveOperation | string, + ): Promise { + const operation = await this.requireOperation(operationOrId); + if (operation.state === 'active') { + return operation; + } + if (operation.state !== 'draft') { + throw new PaymentRequestError( + `Cannot activate payment request receive operation in state '${operation.state}'`, + ); + } + + const active: PaymentRequestReceiveOperation = { + ...operation, + state: 'active', + updatedAt: Date.now(), + }; + await this.operationRepository.update(active); + return active; + } + + async cancel(operationId: string, reason?: string): Promise { + const operation = await this.requireOperation(operationId); + if (operation.state !== 'draft' && operation.state !== 'active') { + throw new PaymentRequestError( + `Cannot cancel payment request receive operation in state '${operation.state}'`, + ); + } + + const cancelled: PaymentRequestReceiveOperation = { + ...operation, + state: 'cancelled', + error: reason, + updatedAt: Date.now(), + }; + await this.operationRepository.update(cancelled); + return cancelled; + } + + async get(operationId: string): Promise { + return this.operationRepository.getById(operationId); + } + + async list(filter?: { + state?: PaymentRequestReceiveState; + }): Promise { + return this.operationRepository.list(filter); + } + + async claimPayload( + operationOrId: PaymentRequestReceiveOperation | string, + payloadInput: PaymentRequestPayload | string, + source?: PaymentRequestReceiveSource, + ): Promise { + const operation = await this.requireOperation(operationOrId); + const releaseLock = await this.lock.acquire(operation.id); + try { + return await this.claimPayloadLocked(operation.id, payloadInput, source); + } finally { + releaseLock(); + } + } + + async ingestPayload( + payloadInput: PaymentRequestPayload | string, + source?: PaymentRequestReceiveSource, + ): Promise { + const payload = this.parsePayload(payloadInput); + if (!payload.id) { + throw new PaymentRequestError('Payment request payload id is required for ingestion'); + } + + const candidates = await this.operationRepository.getActiveByRequestId(payload.id); + if (candidates.length === 0) { + throw new PaymentRequestError(`No active payment request found for id ${payload.id}`); + } + if (candidates.length > 1) { + throw new PaymentRequestError(`Multiple active payment requests found for id ${payload.id}`); + } + + return this.claimPayload(candidates[0]!, payload, source); + } + + async recoverPendingAttempts(): Promise { + await this.receiveOperationService.recoverPendingOperations(); + + const interruptedBeforeReceive = [ + ...(await this.attemptRepository.getByState('received')), + ...(await this.attemptRepository.getByState('validating')), + ]; + for (const attempt of interruptedBeforeReceive) { + await this.rejectAttempt(attempt, 'Interrupted before child receive operation was created'); + } + + const attempts = await this.attemptRepository.getByState('receiving'); + for (const attempt of attempts) { + if (!attempt.receiveOperationId) { + await this.rejectAttempt(attempt, 'Missing child receive operation id'); + continue; + } + + const receiveOperation = await this.receiveOperationService.getOperation( + attempt.receiveOperationId, + ); + if (!receiveOperation) { + await this.rejectAttempt(attempt, 'Child receive operation was not found'); + continue; + } + + if (receiveOperation.state === 'finalized') { + await this.finalizeAttemptFromReceive(attempt, receiveOperation); + } else if (receiveOperation.state === 'rolled_back') { + await this.rejectAttempt( + attempt, + receiveOperation.error ?? 'Child receive operation rolled back', + ); + } + } + } + + private async claimPayloadLocked( + operationId: string, + payloadInput: PaymentRequestPayload | string, + source?: PaymentRequestReceiveSource, + ): Promise { + const operation = await this.requireOperation(operationId); + const payload = this.parsePayload(payloadInput); + const payloadHash = this.hashPayload(payload); + if (source?.transportMessageId) { + const existingByMessage = await this.attemptRepository.getByTransportMessageId( + source.transportMessageId, + ); + if (existingByMessage) { + return this.resultForAttempt(operation, existingByMessage); + } + } + + const existingByPayload = await this.attemptRepository.getByPayloadHash( + operation.id, + payloadHash, + ); + if (existingByPayload) { + return this.resultForAttempt(operation, existingByPayload); + } + + if (operation.state !== 'active') { + throw new PaymentRequestError( + `Cannot claim payload for payment request receive operation in state '${operation.state}'`, + ); + } + + const grossAmount = sumProofs(payload.proofs); + const now = Date.now(); + let attempt: PaymentRequestReceiveAttempt = { + id: generateSubId(), + requestOperationId: operation.id, + requestId: payload.id, + transport: source?.transport ?? operation.transport, + transportMessageId: source?.transportMessageId, + payloadHash, + senderPubkey: source?.senderPubkey, + memo: payload.memo, + mintUrl: payload.mint, + unit: payload.unit, + grossAmount, + state: 'received', + payload, + createdAt: now, + updatedAt: now, + }; + await this.attemptRepository.create(attempt); + + try { + attempt = await this.updateAttempt({ ...attempt, state: 'validating' }); + await this.validatePayload(operation, payload, grossAmount); + await this.assertSingleUseAvailable(operation); + + const sourceMetadata = { + type: 'payment-request' as const, + requestOperationId: operation.id, + requestId: operation.requestId, + attemptId: attempt.id, + transport: attempt.transport, + transportMessageId: attempt.transportMessageId, + senderPubkey: attempt.senderPubkey, + memo: attempt.memo, + }; + const initReceive = await this.receiveOperationService.init( + { mint: payload.mint, unit: payload.unit, proofs: payload.proofs }, + sourceMetadata, + ); + attempt = await this.updateAttempt({ + ...attempt, + state: 'receiving', + receiveOperationId: initReceive.id, + }); + + const preparedReceive = await this.receiveOperationService.prepare(initReceive); + const netAmount = preparedReceive.amount.subtract(preparedReceive.fee); + attempt = await this.updateAttempt({ + ...attempt, + fee: preparedReceive.fee, + netAmount, + }); + + const finalizedReceive = await this.receiveOperationService.execute(preparedReceive); + attempt = await this.updateAttempt({ + ...attempt, + state: 'finalized', + fee: finalizedReceive.fee, + netAmount: finalizedReceive.amount.subtract(finalizedReceive.fee), + payload: undefined, + }); + const updatedOperation = await this.completeIfSingleUse(operation); + return { operation: updatedOperation, attempt, receiveOperation: finalizedReceive }; + } catch (error) { + const receiveOperation = attempt.receiveOperationId + ? await this.receiveOperationService.getOperation(attempt.receiveOperationId) + : undefined; + if (receiveOperation?.state === 'executing') { + this.logger?.warn('Payment request receive attempt left for recovery', { + attemptId: attempt.id, + receiveOperationId: receiveOperation.id, + }); + throw error; + } + + attempt = await this.rejectAttempt( + attempt, + error instanceof Error ? error.message : String(error), + ); + return { operation, attempt, receiveOperation: receiveOperation ?? undefined }; + } + } + + private parsePayload(payloadInput: PaymentRequestPayload | string): ParsedPaymentRequestPayload { + const raw = + typeof payloadInput === 'string' + ? (JSONInt.parse(payloadInput) as Partial) + : payloadInput; + if (!raw || typeof raw !== 'object') { + throw new PaymentRequestError('Payment request payload must be an object'); + } + if (!raw.mint || typeof raw.mint !== 'string') { + throw new PaymentRequestError('Payment request payload mint is required'); + } + if (!raw.unit || typeof raw.unit !== 'string') { + throw new PaymentRequestError('Payment request payload unit is required'); + } + if (!Array.isArray(raw.proofs) || raw.proofs.length === 0) { + throw new PaymentRequestError('Payment request payload proofs are required'); + } + + const proofs: Proof[] = raw.proofs.map((proof) => ({ + ...proof, + amount: Amount.from(proof.amount), + })); + return { + id: raw.id, + memo: raw.memo, + mint: normalizeMintUrl(raw.mint), + unit: raw.unit, + proofs, + }; + } + + private async validatePayload( + operation: PaymentRequestReceiveOperation, + payload: ParsedPaymentRequestPayload, + grossAmount: Amount, + ): Promise { + if (operation.requestId && payload.id !== operation.requestId) { + throw new PaymentRequestError('Payment request payload id does not match request id'); + } + if (!operation.requestId && !payload.id) { + this.logger?.debug('Claiming id-less payment request payload by explicit operation id', { + operationId: operation.id, + }); + } + + const trusted = await this.mintService.isTrustedMint(payload.mint); + if (!trusted) { + throw new PaymentRequestError(`Mint ${payload.mint} is not trusted`); + } + if (operation.mints.length > 0 && !operation.mints.includes(payload.mint)) { + throw new PaymentRequestError(`Mint ${payload.mint} is not allowed for this request`); + } + if (payload.unit !== operation.unit) { + throw new PaymentRequestError( + `Payment request payload unit '${payload.unit}' does not match request unit '${operation.unit}'`, + ); + } + if (payload.unit !== 'sat') { + throw new ProofValidationError( + `Unsupported mint unit '${payload.unit}'. Only 'sat' is currently supported.`, + ); + } + if (grossAmount.lessThan(operation.amount)) { + throw new PaymentRequestError('Payment request payload amount is below requested amount'); + } + } + + private async assertSingleUseAvailable(operation: PaymentRequestReceiveOperation): Promise { + if (!operation.singleUse) return; + const attempts = await this.attemptRepository.getByRequestOperationId(operation.id); + if (attempts.some((attempt) => attempt.state === 'finalized')) { + throw new PaymentRequestError('Single-use payment request has already been paid'); + } + } + + private hashPayload(payload: ParsedPaymentRequestPayload): string { + const proofYHexes = computeYHexForSecrets(payload.proofs.map((proof) => proof.secret)); + const proofSummaries = payload.proofs + .map((proof, index) => ({ + y: proofYHexes[index] ?? '', + id: proof.id, + amount: Amount.from(proof.amount).toString(), + C: proof.C, + })) + .sort((a, b) => a.y.localeCompare(b.y)); + const canonical = JSON.stringify({ + id: payload.id, + memo: payload.memo, + mint: payload.mint, + unit: payload.unit, + proofs: proofSummaries, + }); + return bytesToHex(sha256(new TextEncoder().encode(canonical))); + } + + private async updateAttempt( + attempt: PaymentRequestReceiveAttempt, + ): Promise { + const updated = { ...attempt, updatedAt: Date.now() }; + await this.attemptRepository.update(updated); + return updated; + } + + private async rejectAttempt( + attempt: PaymentRequestReceiveAttempt, + error: string, + ): Promise { + return this.updateAttempt({ ...attempt, state: 'rejected', error, payload: undefined }); + } + + private async finalizeAttemptFromReceive( + attempt: PaymentRequestReceiveAttempt, + receiveOperation: FinalizedReceiveOperation, + ): Promise { + const finalized = await this.updateAttempt({ + ...attempt, + state: 'finalized', + fee: receiveOperation.fee, + netAmount: receiveOperation.amount.subtract(receiveOperation.fee), + payload: undefined, + }); + const operation = await this.operationRepository.getById(finalized.requestOperationId); + if (operation) { + await this.completeIfSingleUse(operation); + } + } + + private async completeIfSingleUse( + operation: PaymentRequestReceiveOperation, + ): Promise { + if (!operation.singleUse) { + return operation; + } + const completed: PaymentRequestReceiveOperation = { + ...operation, + state: 'completed', + completedAt: Date.now(), + updatedAt: Date.now(), + }; + await this.operationRepository.update(completed); + return completed; + } + + private async resultForAttempt( + operation: PaymentRequestReceiveOperation, + attempt: PaymentRequestReceiveAttempt, + ): Promise { + const receiveOperation = attempt.receiveOperationId + ? await this.receiveOperationService.getOperation(attempt.receiveOperationId) + : undefined; + const latestOperation = await this.operationRepository.getById(operation.id); + return { + operation: latestOperation ?? operation, + attempt, + receiveOperation: receiveOperation ?? undefined, + }; + } + + private async requireOperation( + operationOrId: PaymentRequestReceiveOperation | string, + ): Promise { + if (typeof operationOrId !== 'string') { + return operationOrId; + } + const operation = await this.operationRepository.getById(operationOrId); + if (!operation) { + throw new PaymentRequestError(`Payment request receive operation ${operationOrId} not found`); + } + return operation; + } +} diff --git a/packages/core/services/index.ts b/packages/core/services/index.ts index 8f8cc2bf..532f7564 100644 --- a/packages/core/services/index.ts +++ b/packages/core/services/index.ts @@ -6,6 +6,7 @@ export * from './KeyRingService'; export * from './MeltQuoteService'; export * from './MintService'; export * from './PaymentRequestService'; +export * from './PaymentRequestReceiveService'; export * from './ProofService'; export * from './SeedService'; export * from './WalletRestoreService'; diff --git a/packages/core/test/unit/PaymentRequestReceiveService.test.ts b/packages/core/test/unit/PaymentRequestReceiveService.test.ts new file mode 100644 index 00000000..550dbbf3 --- /dev/null +++ b/packages/core/test/unit/PaymentRequestReceiveService.test.ts @@ -0,0 +1,191 @@ +import { Amount, type PaymentRequestPayload } from '@cashu/cashu-ts'; +import { beforeEach, describe, expect, it, mock } from 'bun:test'; + +import { PaymentRequestReceiveService } from '../../services/PaymentRequestReceiveService'; +import { PaymentRequestError } from '../../models/Error'; +import type { MintService } from '../../services/MintService'; +import type { ReceiveOperationService } from '../../operations/receive/ReceiveOperationService'; +import type { + FinalizedReceiveOperation, + InitReceiveOperation, + PreparedReceiveOperation, + ReceiveOperationSource, +} from '../../operations/receive/ReceiveOperation'; +import { + MemoryPaymentRequestReceiveAttemptRepository, + MemoryPaymentRequestReceiveOperationRepository, +} from '../../repositories/memory'; + +describe('PaymentRequestReceiveService', () => { + const mintUrl = 'https://mint.test'; + let operationRepository: MemoryPaymentRequestReceiveOperationRepository; + let attemptRepository: MemoryPaymentRequestReceiveAttemptRepository; + let mintService: MintService; + let receiveOperationService: ReceiveOperationService; + let service: PaymentRequestReceiveService; + + function createPayload(overrides: Partial = {}): PaymentRequestPayload { + return { + id: 'request-id', + mint: mintUrl, + unit: 'sat', + proofs: [{ id: 'keyset-id', amount: Amount.from(100), secret: 'secret-1', C: 'C-1' }], + ...overrides, + }; + } + + beforeEach(() => { + operationRepository = new MemoryPaymentRequestReceiveOperationRepository(); + attemptRepository = new MemoryPaymentRequestReceiveAttemptRepository(); + mintService = { + isTrustedMint: mock(async () => true), + } as unknown as MintService; + receiveOperationService = { + init: mock(async (_token, source?: ReceiveOperationSource): Promise => { + return { + id: 'receive-op-1', + state: 'init', + mintUrl, + unit: 'sat', + amount: Amount.from(100), + inputProofs: createPayload().proofs, + source, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + }), + prepare: mock(async (operation: InitReceiveOperation): Promise => { + return { + ...operation, + state: 'prepared', + fee: Amount.from(1), + outputData: { keep: [], send: [] }, + }; + }), + execute: mock( + async (operation: PreparedReceiveOperation): Promise => { + return { + ...operation, + state: 'finalized', + }; + }, + ), + getOperation: mock(async () => null), + recoverPendingOperations: mock(async () => undefined), + } as unknown as ReceiveOperationService; + + service = new PaymentRequestReceiveService( + operationRepository, + attemptRepository, + receiveOperationService, + mintService, + ); + }); + + it('creates CREQB payment requests and activates them', async () => { + const operation = await service.create({ + amount: Amount.from(100), + unit: 'sat', + mints: [mintUrl], + requestId: 'request-id', + description: 'test request', + }); + + expect(operation.state).toBe('draft'); + expect(operation.encodedRequest).toStartWith('CREQB'); + expect(operation.requestId).toBe('request-id'); + + const active = await service.activate(operation.id); + expect(active.state).toBe('active'); + }); + + it('claims a valid payload through a child receive operation', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + + const result = await service.claimPayload(operation.id, createPayload(), { + transport: 'inband', + transportMessageId: 'message-1', + }); + + expect(result.operation.state).toBe('completed'); + expect(result.attempt.state).toBe('finalized'); + expect(result.attempt.receiveOperationId).toBe('receive-op-1'); + expect(result.attempt.fee?.equals(Amount.from(1))).toBe(true); + expect(result.attempt.netAmount?.equals(Amount.from(99))).toBe(true); + expect(receiveOperationService.init).toHaveBeenCalledWith( + expect.objectContaining({ mint: mintUrl }), + expect.objectContaining({ + type: 'payment-request', + requestOperationId: operation.id, + attemptId: result.attempt.id, + }), + ); + }); + + it('returns the existing finalized attempt for duplicate payload delivery', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + + const first = await service.claimPayload(operation.id, payload); + const second = await service.claimPayload(operation.id, payload); + + expect(second.attempt.id).toBe(first.attempt.id); + expect(receiveOperationService.init).toHaveBeenCalledTimes(1); + }); + + it('records a rejected attempt for an underpaid payload', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + + const result = await service.claimPayload( + operation.id, + createPayload({ + proofs: [{ id: 'keyset-id', amount: Amount.from(50), secret: 'secret-2', C: 'C-2' }], + }), + ); + + expect(result.operation.state).toBe('active'); + expect(result.attempt.state).toBe('rejected'); + expect(result.attempt.error).toContain('below requested amount'); + expect(receiveOperationService.init).not.toHaveBeenCalled(); + }); + + it('rejects unsupported transports at create time', async () => { + await expect(service.create({ amount: Amount.from(100), transport: 'nostr' })).rejects.toThrow( + PaymentRequestError, + ); + }); + + it('rejects interrupted pre-child attempts during recovery', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-1', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash: 'payload-hash-1', + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + state: 'validating', + payload: createPayload(), + createdAt: now, + updatedAt: now, + }); + + await service.recoverPendingAttempts(); + + const attempt = await attemptRepository.getById('attempt-1'); + expect(attempt?.state).toBe('rejected'); + expect(attempt?.payload).toBeUndefined(); + expect(attempt?.error).toContain('Interrupted before child receive operation'); + }); +}); diff --git a/packages/core/test/unit/PaymentRequestsApi.test.ts b/packages/core/test/unit/PaymentRequestsApi.test.ts index 729945ec..d777252b 100644 --- a/packages/core/test/unit/PaymentRequestsApi.test.ts +++ b/packages/core/test/unit/PaymentRequestsApi.test.ts @@ -4,6 +4,7 @@ import { PaymentRequest } from '@cashu/cashu-ts'; import { PaymentRequestsApi } from '../../api/PaymentRequestsApi'; import type { PaymentRequestExecutionResult, + PaymentRequestReceiveService, PaymentRequestService, PreparedPaymentRequest, ResolvedPaymentRequest, @@ -12,6 +13,7 @@ import type { describe('PaymentRequestsApi', () => { let api: PaymentRequestsApi; let service: PaymentRequestService; + let incomingService: PaymentRequestReceiveService; const resolvedRequest: ResolvedPaymentRequest = { paymentRequest: new PaymentRequest([], 'request-id', 100, 'sat', ['https://mint.test']), @@ -55,8 +57,19 @@ describe('PaymentRequestsApi', () => { prepare: mock(async () => preparedRequest), execute: mock(async () => executionResult), } as unknown as PaymentRequestService; + incomingService = { + create: mock(), + activate: mock(), + cancel: mock(), + get: mock(), + list: mock(), + claimPayload: mock(), + ingestPayload: mock(), + recoverPendingAttempts: mock(), + isOperationLocked: mock(), + } as unknown as PaymentRequestReceiveService; - api = new PaymentRequestsApi(service); + api = new PaymentRequestsApi(service, incomingService); }); it('should parse a payment request', async () => { From 0e16ca90d931e5f9bd8a16e2eb5483afde92f256 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 11:04:28 +0200 Subject: [PATCH 02/10] feat(adapters): persist payment request receives --- packages/adapter-tests/src/index.ts | 156 +++++++++ packages/expo-sqlite/src/index.ts | 18 + .../PaymentRequestReceiveRepository.ts | 331 ++++++++++++++++++ .../ReceiveOperationRepository.ts | 14 +- packages/expo-sqlite/src/schema.ts | 63 ++++ .../expo-sqlite/src/test/contract.test.ts | 3 + packages/indexeddb/src/index.ts | 18 + packages/indexeddb/src/lib/db.ts | 40 +++ packages/indexeddb/src/lib/schema.ts | 22 ++ .../PaymentRequestReceiveRepository.ts | 284 +++++++++++++++ .../ReceiveOperationRepository.ts | 3 + packages/indexeddb/src/test/contract.test.ts | 3 + packages/sqlite-bun/src/index.ts | 18 + .../PaymentRequestReceiveRepository.ts | 331 ++++++++++++++++++ .../ReceiveOperationRepository.ts | 14 +- packages/sqlite-bun/src/schema.ts | 63 ++++ packages/sqlite-bun/src/test/contract.test.ts | 3 + packages/sqlite3/src/index.ts | 18 + .../PaymentRequestReceiveRepository.ts | 331 ++++++++++++++++++ .../ReceiveOperationRepository.ts | 14 +- packages/sqlite3/src/schema.ts | 63 ++++ packages/sqlite3/src/test/contract.test.ts | 3 + 22 files changed, 1801 insertions(+), 12 deletions(-) create mode 100644 packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts create mode 100644 packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts create mode 100644 packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts create mode 100644 packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 009f51d6..0bb28c65 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -6,6 +6,8 @@ import { type Repositories, type MeltOperation, type MintOperation, + type PaymentRequestReceiveAttempt, + type PaymentRequestReceiveOperation, type ReceiveOperation, type AuthSession, } from '@cashu/coco-core'; @@ -248,6 +250,45 @@ export function createDummyReceiveOperation(): ReceiveOperation { } satisfies ReceiveOperation; } +export function createDummyPaymentRequestReceiveOperation( + overrides?: Partial, +): PaymentRequestReceiveOperation { + return { + id: 'payment-request-receive-op', + requestId: 'request-id', + encodedRequest: 'CREQB1TEST', + state: 'active', + transport: 'inband', + amount: Amount.from(100), + unit: 'sat', + mints: ['https://mint.test'], + singleUse: true, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +export function createDummyPaymentRequestReceiveAttempt( + overrides?: Partial, +): PaymentRequestReceiveAttempt { + return { + id: 'payment-request-receive-attempt', + requestOperationId: 'payment-request-receive-op', + requestId: 'request-id', + transport: 'inband', + transportMessageId: 'message-id', + payloadHash: 'payload-hash', + mintUrl: 'https://mint.test', + unit: 'sat', + grossAmount: Amount.from(100), + state: 'received', + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + export function createDummyAuthSession(overrides?: Partial): AuthSession { return { mintUrl: 'https://mint.test', @@ -334,6 +375,121 @@ export async function runReceiveOperationRepositoryContract( await dispose(); } }); + + it('round-trips optional receive operation source metadata', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyReceiveOperation(); + operation.source = { + type: 'payment-request', + requestOperationId: 'request-op', + attemptId: 'attempt-id', + transport: 'inband', + }; + await repositories.receiveOperationRepository.create(operation); + + const stored = await repositories.receiveOperationRepository.getById(operation.id); + + expect(stored).toBeDefined(); + expect(stored!.source?.type).toBe('payment-request'); + if (stored!.source?.type === 'payment-request') { + expect(stored!.source.requestOperationId).toBe('request-op'); + expect(stored!.source.attemptId).toBe('attempt-id'); + } + } finally { + await dispose(); + } + }); + }); +} + +export async function runPaymentRequestReceiveRepositoryContract( + options: ContractOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('PaymentRequestReceiveRepository contract', () => { + it('round-trips active operations and attempts', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyPaymentRequestReceiveOperation(); + const attempt = createDummyPaymentRequestReceiveAttempt(); + + await repositories.paymentRequestReceiveOperationRepository.create(operation); + await repositories.paymentRequestReceiveAttemptRepository.create(attempt); + + const active = + await repositories.paymentRequestReceiveOperationRepository.getActiveByRequestId( + 'request-id', + ); + const attempts = + await repositories.paymentRequestReceiveAttemptRepository.getByRequestOperationId( + operation.id, + ); + const byPayload = + await repositories.paymentRequestReceiveAttemptRepository.getByPayloadHash( + operation.id, + attempt.payloadHash, + ); + + expect(active).toHaveLength(1); + expect(attempts).toHaveLength(1); + expect(byPayload).toBeDefined(); + expect(active[0]!.amount.equals(Amount.from(100))).toBe(true); + expect(attempts[0]!.grossAmount.equals(Amount.from(100))).toBe(true); + } finally { + await dispose(); + } + }); + + it('enforces idempotency by request operation and payload hash', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await repositories.paymentRequestReceiveAttemptRepository.create( + createDummyPaymentRequestReceiveAttempt(), + ); + + let duplicateRejected = false; + try { + await repositories.paymentRequestReceiveAttemptRepository.create( + createDummyPaymentRequestReceiveAttempt({ id: 'duplicate-attempt' }), + ); + } catch { + duplicateRejected = true; + } + + expect(duplicateRejected).toBe(true); + } finally { + await dispose(); + } + }); + + it('looks up attempts by transport message id and child receive id', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const attempt = createDummyPaymentRequestReceiveAttempt({ + receiveOperationId: 'receive-op-id', + }); + await repositories.paymentRequestReceiveAttemptRepository.create(attempt); + + const byMessage = + await repositories.paymentRequestReceiveAttemptRepository.getByTransportMessageId( + 'message-id', + ); + const byReceive = + await repositories.paymentRequestReceiveAttemptRepository.getByReceiveOperationId( + 'receive-op-id', + ); + + expect(byMessage).toBeDefined(); + expect(byReceive).toBeDefined(); + expect(byMessage!.id).toBe(attempt.id); + expect(byReceive!.id).toBe(attempt.id); + } finally { + await dispose(); + } + }); }); } diff --git a/packages/expo-sqlite/src/index.ts b/packages/expo-sqlite/src/index.ts index 319a7559..c16ab688 100644 --- a/packages/expo-sqlite/src/index.ts +++ b/packages/expo-sqlite/src/index.ts @@ -11,6 +11,8 @@ import type { MeltOperationRepository, AuthSessionRepository, MintOperationRepository, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from '@cashu/coco-core'; @@ -29,6 +31,10 @@ import { ExpoMeltOperationRepository } from './repositories/MeltOperationReposit import { ExpoAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; import { ExpoMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { ExpoReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; +import { + ExpoPaymentRequestReceiveAttemptRepository, + ExpoPaymentRequestReceiveOperationRepository, +} from './repositories/PaymentRequestReceiveRepository.ts'; export interface ExpoSqliteRepositoriesOptions extends ExpoSqliteDbOptions {} @@ -46,6 +52,8 @@ export class ExpoSqliteRepositories implements Repositories { readonly authSessionRepository: AuthSessionRepository; readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; + readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; readonly db: ExpoSqliteDb; constructor(options: ExpoSqliteRepositoriesOptions) { @@ -63,6 +71,10 @@ export class ExpoSqliteRepositories implements Repositories { this.authSessionRepository = new ExpoAuthSessionRepository(this.db); this.mintOperationRepository = new ExpoMintOperationRepository(this.db); this.receiveOperationRepository = new ExpoReceiveOperationRepository(this.db); + this.paymentRequestReceiveOperationRepository = + new ExpoPaymentRequestReceiveOperationRepository(this.db); + this.paymentRequestReceiveAttemptRepository = + new ExpoPaymentRequestReceiveAttemptRepository(this.db); } async init(): Promise { @@ -85,6 +97,10 @@ export class ExpoSqliteRepositories implements Repositories { authSessionRepository: new ExpoAuthSessionRepository(txDb), mintOperationRepository: new ExpoMintOperationRepository(txDb), receiveOperationRepository: new ExpoReceiveOperationRepository(txDb), + paymentRequestReceiveOperationRepository: + new ExpoPaymentRequestReceiveOperationRepository(txDb), + paymentRequestReceiveAttemptRepository: + new ExpoPaymentRequestReceiveAttemptRepository(txDb), }; return fn(scopedRepositories); @@ -110,6 +126,8 @@ export { ExpoAuthSessionRepository, ExpoMintOperationRepository, ExpoReceiveOperationRepository, + ExpoPaymentRequestReceiveOperationRepository, + ExpoPaymentRequestReceiveAttemptRepository, }; export type { Migration }; diff --git a/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts b/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts new file mode 100644 index 00000000..4c6df370 --- /dev/null +++ b/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts @@ -0,0 +1,331 @@ +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveAttemptState, + PaymentRequestReceiveOperation, + PaymentRequestReceiveOperationRepository, + PaymentRequestReceiveState, + PaymentRequestReceiveTransport, +} from '@cashu/coco-core'; +import { deserializeAmount, serializeAmount } from '@cashu/coco-core'; +import { ExpoSqliteDb, getUnixTimeSeconds } from '../db.ts'; + +interface OperationRow { + id: string; + requestId: string | null; + encodedRequest: string; + state: PaymentRequestReceiveState; + transport: PaymentRequestReceiveTransport; + amount: string | number; + unit: string; + mintsJson: string; + singleUse: number; + description: string | null; + createdAt: number; + updatedAt: number; + error: string | null; + completedAt: number | null; +} + +interface AttemptRow { + id: string; + requestOperationId: string; + requestId: string | null; + transport: PaymentRequestReceiveTransport; + transportMessageId: string | null; + payloadHash: string; + senderPubkey: string | null; + memo: string | null; + mintUrl: string; + unit: string; + grossAmount: string | number; + fee: string | number | null; + netAmount: string | number | null; + receiveOperationId: string | null; + state: PaymentRequestReceiveAttemptState; + error: string | null; + payloadJson: string | null; + createdAt: number; + updatedAt: number; +} + +function operationToRow(operation: PaymentRequestReceiveOperation): OperationRow { + return { + id: operation.id, + requestId: operation.requestId ?? null, + encodedRequest: operation.encodedRequest, + state: operation.state, + transport: operation.transport, + amount: serializeAmount(operation.amount), + unit: operation.unit, + mintsJson: JSON.stringify(operation.mints), + singleUse: operation.singleUse ? 1 : 0, + description: operation.description ?? null, + createdAt: Math.floor(operation.createdAt / 1000), + updatedAt: Math.floor(operation.updatedAt / 1000), + error: operation.error ?? null, + completedAt: operation.completedAt ? Math.floor(operation.completedAt / 1000) : null, + }; +} + +function rowToOperation(row: OperationRow): PaymentRequestReceiveOperation { + return { + id: row.id, + requestId: row.requestId ?? undefined, + encodedRequest: row.encodedRequest, + state: row.state, + transport: row.transport, + amount: deserializeAmount(row.amount), + unit: row.unit, + mints: JSON.parse(row.mintsJson) as string[], + singleUse: row.singleUse === 1, + description: row.description ?? undefined, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + completedAt: row.completedAt ? row.completedAt * 1000 : undefined, + }; +} + +function attemptToRow(attempt: PaymentRequestReceiveAttempt): AttemptRow { + return { + id: attempt.id, + requestOperationId: attempt.requestOperationId, + requestId: attempt.requestId ?? null, + transport: attempt.transport, + transportMessageId: attempt.transportMessageId ?? null, + payloadHash: attempt.payloadHash, + senderPubkey: attempt.senderPubkey ?? null, + memo: attempt.memo ?? null, + mintUrl: attempt.mintUrl, + unit: attempt.unit, + grossAmount: serializeAmount(attempt.grossAmount), + fee: attempt.fee ? serializeAmount(attempt.fee) : null, + netAmount: attempt.netAmount ? serializeAmount(attempt.netAmount) : null, + receiveOperationId: attempt.receiveOperationId ?? null, + state: attempt.state, + error: attempt.error ?? null, + payloadJson: attempt.payload ? JSON.stringify(attempt.payload) : null, + createdAt: Math.floor(attempt.createdAt / 1000), + updatedAt: Math.floor(attempt.updatedAt / 1000), + }; +} + +function rowToAttempt(row: AttemptRow): PaymentRequestReceiveAttempt { + const payload = row.payloadJson + ? (JSON.parse(row.payloadJson) as PaymentRequestReceiveAttempt['payload']) + : undefined; + return { + id: row.id, + requestOperationId: row.requestOperationId, + requestId: row.requestId ?? undefined, + transport: row.transport, + transportMessageId: row.transportMessageId ?? undefined, + payloadHash: row.payloadHash, + senderPubkey: row.senderPubkey ?? undefined, + memo: row.memo ?? undefined, + mintUrl: row.mintUrl, + unit: row.unit, + grossAmount: deserializeAmount(row.grossAmount), + fee: row.fee === null ? undefined : deserializeAmount(row.fee), + netAmount: row.netAmount === null ? undefined : deserializeAmount(row.netAmount), + receiveOperationId: row.receiveOperationId ?? undefined, + state: row.state, + error: row.error ?? undefined, + payload, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + }; +} + +export class ExpoPaymentRequestReceiveOperationRepository implements PaymentRequestReceiveOperationRepository { + constructor(private readonly db: ExpoSqliteDb) {} + + async create(operation: PaymentRequestReceiveOperation): Promise { + const row = operationToRow(operation); + await this.db.run( + `INSERT INTO coco_cashu_payment_request_receive_operations + (id, requestId, encodedRequest, state, transport, amount, unit, mintsJson, singleUse, description, createdAt, updatedAt, error, completedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + Object.values(row), + ); + } + + async update(operation: PaymentRequestReceiveOperation): Promise { + const row = operationToRow({ ...operation, updatedAt: Date.now() }); + await this.db.run( + `UPDATE coco_cashu_payment_request_receive_operations + SET requestId = ?, encodedRequest = ?, state = ?, transport = ?, amount = ?, unit = ?, + mintsJson = ?, singleUse = ?, description = ?, updatedAt = ?, error = ?, completedAt = ? + WHERE id = ?`, + [ + row.requestId, + row.encodedRequest, + row.state, + row.transport, + row.amount, + row.unit, + row.mintsJson, + row.singleUse, + row.description, + getUnixTimeSeconds(), + row.error, + row.completedAt, + row.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE id = ?', + [id], + ); + return row ? rowToOperation(row) : null; + } + + async getByState(state: PaymentRequestReceiveState): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = ?', + [state], + ); + return rows.map(rowToOperation); + } + + async getActiveByRequestId(requestId: string): Promise { + const rows = await this.db.all( + "SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = 'active' AND requestId = ?", + [requestId], + ); + return rows.map(rowToOperation); + } + + async list(filter?: { + state?: PaymentRequestReceiveState; + }): Promise { + const rows = filter?.state + ? await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = ?', + [filter.state], + ) + : await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations', + ); + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_payment_request_receive_operations WHERE id = ?', [ + id, + ]); + } +} + +export class ExpoPaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { + constructor(private readonly db: ExpoSqliteDb) {} + + async create(attempt: PaymentRequestReceiveAttempt): Promise { + const row = attemptToRow(attempt); + await this.db.run( + `INSERT INTO coco_cashu_payment_request_receive_attempts + (id, requestOperationId, requestId, transport, transportMessageId, payloadHash, senderPubkey, + memo, mintUrl, unit, grossAmount, fee, netAmount, receiveOperationId, state, error, + payloadJson, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + Object.values(row), + ); + } + + async update(attempt: PaymentRequestReceiveAttempt): Promise { + const row = attemptToRow({ ...attempt, updatedAt: Date.now() }); + await this.db.run( + `UPDATE coco_cashu_payment_request_receive_attempts + SET requestId = ?, transport = ?, transportMessageId = ?, payloadHash = ?, senderPubkey = ?, + memo = ?, mintUrl = ?, unit = ?, grossAmount = ?, fee = ?, netAmount = ?, + receiveOperationId = ?, state = ?, error = ?, payloadJson = ?, updatedAt = ? + WHERE id = ?`, + [ + row.requestId, + row.transport, + row.transportMessageId, + row.payloadHash, + row.senderPubkey, + row.memo, + row.mintUrl, + row.unit, + row.grossAmount, + row.fee, + row.netAmount, + row.receiveOperationId, + row.state, + row.error, + row.payloadJson, + getUnixTimeSeconds(), + row.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE id = ?', + [id], + ); + return row ? rowToAttempt(row) : null; + } + + async getByRequestOperationId( + requestOperationId: string, + ): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestOperationId = ?', + [requestOperationId], + ); + return rows.map(rowToAttempt); + } + + async getByReceiveOperationId( + receiveOperationId: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE receiveOperationId = ?', + [receiveOperationId], + ); + return row ? rowToAttempt(row) : null; + } + + async getByTransportMessageId( + transportMessageId: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE transportMessageId = ?', + [transportMessageId], + ); + return row ? rowToAttempt(row) : null; + } + + async getByPayloadHash( + requestOperationId: string, + payloadHash: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestOperationId = ? AND payloadHash = ?', + [requestOperationId, payloadHash], + ); + return row ? rowToAttempt(row) : null; + } + + async getByState( + state: PaymentRequestReceiveAttemptState, + ): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE state = ?', + [state], + ); + return rows.map(rowToAttempt); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_payment_request_receive_attempts WHERE id = ?', [id]); + } +} diff --git a/packages/expo-sqlite/src/repositories/ReceiveOperationRepository.ts b/packages/expo-sqlite/src/repositories/ReceiveOperationRepository.ts index c3379299..3b08de25 100644 --- a/packages/expo-sqlite/src/repositories/ReceiveOperationRepository.ts +++ b/packages/expo-sqlite/src/repositories/ReceiveOperationRepository.ts @@ -22,6 +22,7 @@ interface ReceiveOperationRow { fee: string | number | null; inputProofsJson: string | null; outputDataJson: string | null; + sourceJson: string | null; } function parseInputProofs(inputProofsJson: string | null): ReceiveOperation['inputProofs'] { @@ -44,6 +45,7 @@ function rowToOperation(row: ReceiveOperationRow): ReceiveOperation { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + source: row.sourceJson ? JSON.parse(row.sourceJson) : undefined, }; if (row.state === 'init') { @@ -86,6 +88,7 @@ function operationToParams(op: ReceiveOperation): unknown[] { null, //fee JSON.stringify(op.inputProofs), null, // outputDataJson + op.source ? JSON.stringify(op.source) : null, ]; } @@ -101,6 +104,7 @@ function operationToParams(op: ReceiveOperation): unknown[] { serializeAmount(op.fee), JSON.stringify(op.inputProofs), op.outputData ? JSON.stringify(op.outputData) : null, + op.source ? JSON.stringify(op.source) : null, ]; } @@ -123,8 +127,8 @@ export class ExpoReceiveOperationRepository implements ReceiveOperationRepositor const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_receive_operations - (id, mintUrl, unit, amount, state, createdAt, updatedAt, error, fee, inputProofsJson, outputDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, unit, amount, state, createdAt, updatedAt, error, fee, inputProofsJson, outputDataJson, sourceJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -143,7 +147,7 @@ export class ExpoReceiveOperationRepository implements ReceiveOperationRepositor if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_receive_operations - SET state = ?, updatedAt = ?, error = ?, unit = ?, inputProofsJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, inputProofsJson = ?, sourceJson = ? WHERE id = ?`, [ operation.state, @@ -151,13 +155,14 @@ export class ExpoReceiveOperationRepository implements ReceiveOperationRepositor operation.error ?? null, getOperationUnit(operation), JSON.stringify(operation.inputProofs), + operation.source ? JSON.stringify(operation.source) : null, operation.id, ], ); } else { await this.db.run( `UPDATE coco_cashu_receive_operations - SET state = ?, updatedAt = ?, error = ?, unit = ?, fee = ?, inputProofsJson = ?, outputDataJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, fee = ?, inputProofsJson = ?, outputDataJson = ?, sourceJson = ? WHERE id = ?`, [ operation.state, @@ -167,6 +172,7 @@ export class ExpoReceiveOperationRepository implements ReceiveOperationRepositor serializeAmount(operation.fee), JSON.stringify(operation.inputProofs), operation.outputData ? JSON.stringify(operation.outputData) : null, + operation.source ? JSON.stringify(operation.source) : null, operation.id, ], ); diff --git a/packages/expo-sqlite/src/schema.ts b/packages/expo-sqlite/src/schema.ts index 6828cbb2..438e7eb2 100644 --- a/packages/expo-sqlite/src/schema.ts +++ b/packages/expo-sqlite/src/schema.ts @@ -948,6 +948,69 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_payment_request_receive', + sql: ` + ALTER TABLE coco_cashu_receive_operations ADD COLUMN sourceJson TEXT; + + CREATE TABLE IF NOT EXISTS coco_cashu_payment_request_receive_operations ( + id TEXT PRIMARY KEY, + requestId TEXT, + encodedRequest TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('draft', 'active', 'completed', 'cancelled', 'expired')), + transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), + amount TEXT NOT NULL, + unit TEXT NOT NULL, + mintsJson TEXT NOT NULL, + singleUse INTEGER NOT NULL, + description TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + completedAt INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_operations_state + ON coco_cashu_payment_request_receive_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_operations_request_id + ON coco_cashu_payment_request_receive_operations(requestId); + + CREATE TABLE IF NOT EXISTS coco_cashu_payment_request_receive_attempts ( + id TEXT PRIMARY KEY, + requestOperationId TEXT NOT NULL, + requestId TEXT, + transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), + transportMessageId TEXT, + payloadHash TEXT NOT NULL, + senderPubkey TEXT, + memo TEXT, + mintUrl TEXT NOT NULL, + unit TEXT NOT NULL, + grossAmount TEXT NOT NULL, + fee TEXT, + netAmount TEXT, + receiveOperationId TEXT, + state TEXT NOT NULL CHECK (state IN ('received', 'validating', 'receiving', 'finalized', 'rejected', 'duplicate')), + error TEXT, + payloadJson TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_attempts_request_operation + ON coco_cashu_payment_request_receive_attempts(requestOperationId); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_attempts_state + ON coco_cashu_payment_request_receive_attempts(state); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_message + ON coco_cashu_payment_request_receive_attempts(transportMessageId) + WHERE transportMessageId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_payload + ON coco_cashu_payment_request_receive_attempts(requestOperationId, payloadHash); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_receive + ON coco_cashu_payment_request_receive_attempts(receiveOperationId) + WHERE receiveOperationId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/expo-sqlite/src/test/contract.test.ts b/packages/expo-sqlite/src/test/contract.test.ts index c651108e..967fa6f7 100644 --- a/packages/expo-sqlite/src/test/contract.test.ts +++ b/packages/expo-sqlite/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, createDummyMint, createDummyKeyset, @@ -124,6 +125,8 @@ runMintOperationRepositoryContract({ createRepositories }, { describe, it, expec runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('expo-sqlite adapter transactions', () => { it('commits across repositories', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/indexeddb/src/index.ts b/packages/indexeddb/src/index.ts index a792b64c..dff0acdc 100644 --- a/packages/indexeddb/src/index.ts +++ b/packages/indexeddb/src/index.ts @@ -11,6 +11,8 @@ import type { MeltOperationRepository, AuthSessionRepository, MintOperationRepository, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from '@cashu/coco-core'; @@ -29,6 +31,10 @@ import { IdbMeltOperationRepository } from './repositories/MeltOperationReposito import { IdbAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; import { IdbMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { IdbReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; +import { + IdbPaymentRequestReceiveAttemptRepository, + IdbPaymentRequestReceiveOperationRepository, +} from './repositories/PaymentRequestReceiveRepository.ts'; export interface IndexedDbRepositoriesOptions extends IdbDbOptions {} @@ -46,6 +52,8 @@ export class IndexedDbRepositories implements Repositories { readonly authSessionRepository: AuthSessionRepository; readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; + readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; readonly db: IdbDb; private initialized = false; @@ -64,6 +72,10 @@ export class IndexedDbRepositories implements Repositories { this.authSessionRepository = new IdbAuthSessionRepository(this.db); this.mintOperationRepository = new IdbMintOperationRepository(this.db); this.receiveOperationRepository = new IdbReceiveOperationRepository(this.db); + this.paymentRequestReceiveOperationRepository = + new IdbPaymentRequestReceiveOperationRepository(this.db); + this.paymentRequestReceiveAttemptRepository = + new IdbPaymentRequestReceiveAttemptRepository(this.db); } async init(): Promise { @@ -94,6 +106,10 @@ export class IndexedDbRepositories implements Repositories { authSessionRepository: new IdbAuthSessionRepository(scopedDb), mintOperationRepository: new IdbMintOperationRepository(scopedDb), receiveOperationRepository: new IdbReceiveOperationRepository(scopedDb), + paymentRequestReceiveOperationRepository: + new IdbPaymentRequestReceiveOperationRepository(scopedDb), + paymentRequestReceiveAttemptRepository: + new IdbPaymentRequestReceiveAttemptRepository(scopedDb), }; return fn(scopedRepositories); }); @@ -116,4 +132,6 @@ export { IdbAuthSessionRepository, IdbMintOperationRepository, IdbReceiveOperationRepository, + IdbPaymentRequestReceiveOperationRepository, + IdbPaymentRequestReceiveAttemptRepository, }; diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index b0046a7e..1534ccb1 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -205,6 +205,46 @@ export interface ReceiveOperationRow { fee?: string | number | null; inputProofsJson?: string | null; outputDataJson?: string | null; + sourceJson?: string | null; +} + +export interface PaymentRequestReceiveOperationRow { + id: string; + requestId?: string | null; + encodedRequest: string; + state: 'draft' | 'active' | 'completed' | 'cancelled' | 'expired'; + transport: 'inband' | 'nostr' | 'post'; + amount: string | number; + unit: string; + mintsJson: string; + singleUse: number; + description?: string | null; + createdAt: number; + updatedAt: number; + error?: string | null; + completedAt?: number | null; +} + +export interface PaymentRequestReceiveAttemptRow { + id: string; + requestOperationId: string; + requestId?: string | null; + transport: 'inband' | 'nostr' | 'post'; + transportMessageId?: string | null; + payloadHash: string; + senderPubkey?: string | null; + memo?: string | null; + mintUrl: string; + unit: string; + grossAmount: string | number; + fee?: string | number | null; + netAmount?: string | number | null; + receiveOperationId?: string | null; + state: 'received' | 'validating' | 'receiving' | 'finalized' | 'rejected' | 'duplicate'; + error?: string | null; + payloadJson?: string | null; + createdAt: number; + updatedAt: number; } export interface MeltOperationRow { diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index 0eca0edb..610b17eb 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -553,4 +553,26 @@ export async function ensureSchema(db: IdbDb): Promise { row.amount = normalizeStoredAmount(row.amount); }); }); + + // Version 19: Incoming payment-request receive saga tables. + db.version(19).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]', + coco_cashu_payment_request_receive_operations: '&id, state, requestId', + coco_cashu_payment_request_receive_attempts: + '&id, requestOperationId, state, payloadHash, transportMessageId, receiveOperationId', + }); } diff --git a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts new file mode 100644 index 00000000..78b96cd9 --- /dev/null +++ b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts @@ -0,0 +1,284 @@ +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveAttemptState, + PaymentRequestReceiveOperation, + PaymentRequestReceiveOperationRepository, + PaymentRequestReceiveState, +} from '@cashu/coco-core'; +import { deserializeAmount, serializeAmount } from '@cashu/coco-core'; +import type { + IdbDb, + PaymentRequestReceiveAttemptRow, + PaymentRequestReceiveOperationRow, +} from '../lib/db.ts'; +import { getUnixTimeSeconds } from '../lib/db.ts'; + +function operationToRow( + operation: PaymentRequestReceiveOperation, +): PaymentRequestReceiveOperationRow { + return { + id: operation.id, + requestId: operation.requestId ?? null, + encodedRequest: operation.encodedRequest, + state: operation.state, + transport: operation.transport, + amount: serializeAmount(operation.amount), + unit: operation.unit, + mintsJson: JSON.stringify(operation.mints), + singleUse: operation.singleUse ? 1 : 0, + description: operation.description ?? null, + createdAt: Math.floor(operation.createdAt / 1000), + updatedAt: Math.floor(operation.updatedAt / 1000), + error: operation.error ?? null, + completedAt: operation.completedAt ? Math.floor(operation.completedAt / 1000) : null, + }; +} + +function rowToOperation(row: PaymentRequestReceiveOperationRow): PaymentRequestReceiveOperation { + return { + id: row.id, + requestId: row.requestId ?? undefined, + encodedRequest: row.encodedRequest, + state: row.state, + transport: row.transport, + amount: deserializeAmount(row.amount), + unit: row.unit, + mints: JSON.parse(row.mintsJson) as string[], + singleUse: row.singleUse === 1, + description: row.description ?? undefined, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + completedAt: row.completedAt ? row.completedAt * 1000 : undefined, + }; +} + +function attemptToRow(attempt: PaymentRequestReceiveAttempt): PaymentRequestReceiveAttemptRow { + return { + id: attempt.id, + requestOperationId: attempt.requestOperationId, + requestId: attempt.requestId ?? null, + transport: attempt.transport, + transportMessageId: attempt.transportMessageId ?? null, + payloadHash: attempt.payloadHash, + senderPubkey: attempt.senderPubkey ?? null, + memo: attempt.memo ?? null, + mintUrl: attempt.mintUrl, + unit: attempt.unit, + grossAmount: serializeAmount(attempt.grossAmount), + fee: attempt.fee ? serializeAmount(attempt.fee) : null, + netAmount: attempt.netAmount ? serializeAmount(attempt.netAmount) : null, + receiveOperationId: attempt.receiveOperationId ?? null, + state: attempt.state, + error: attempt.error ?? null, + payloadJson: attempt.payload ? JSON.stringify(attempt.payload) : null, + createdAt: Math.floor(attempt.createdAt / 1000), + updatedAt: Math.floor(attempt.updatedAt / 1000), + }; +} + +function rowToAttempt(row: PaymentRequestReceiveAttemptRow): PaymentRequestReceiveAttempt { + const payload = row.payloadJson + ? (JSON.parse(row.payloadJson) as PaymentRequestReceiveAttempt['payload']) + : undefined; + return { + id: row.id, + requestOperationId: row.requestOperationId, + requestId: row.requestId ?? undefined, + transport: row.transport, + transportMessageId: row.transportMessageId ?? undefined, + payloadHash: row.payloadHash, + senderPubkey: row.senderPubkey ?? undefined, + memo: row.memo ?? undefined, + mintUrl: row.mintUrl, + unit: row.unit, + grossAmount: deserializeAmount(row.grossAmount), + fee: row.fee == null ? undefined : deserializeAmount(row.fee), + netAmount: row.netAmount == null ? undefined : deserializeAmount(row.netAmount), + receiveOperationId: row.receiveOperationId ?? undefined, + state: row.state, + error: row.error ?? undefined, + payload, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + }; +} + +export class IdbPaymentRequestReceiveOperationRepository implements PaymentRequestReceiveOperationRepository { + constructor(private readonly db: IdbDb) {} + + async create(operation: PaymentRequestReceiveOperation): Promise { + await this.db.runTransaction( + 'rw', + ['coco_cashu_payment_request_receive_operations'], + async (tx) => { + await tx + .table('coco_cashu_payment_request_receive_operations') + .add(operationToRow(operation)); + }, + ); + } + + async update(operation: PaymentRequestReceiveOperation): Promise { + await this.db.runTransaction( + 'rw', + ['coco_cashu_payment_request_receive_operations'], + async (tx) => { + const row = operationToRow(operation); + row.updatedAt = getUnixTimeSeconds(); + await tx.table('coco_cashu_payment_request_receive_operations').put(row); + }, + ); + } + + async getById(id: string): Promise { + const row = (await (this.db as any) + .table('coco_cashu_payment_request_receive_operations') + .get(id)) as PaymentRequestReceiveOperationRow | undefined; + return row ? rowToOperation(row) : null; + } + + async getByState(state: PaymentRequestReceiveState): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_operations') + .where('state') + .equals(state) + .toArray()) as PaymentRequestReceiveOperationRow[]; + return rows.map(rowToOperation); + } + + async getActiveByRequestId(requestId: string): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_operations') + .where('requestId') + .equals(requestId) + .filter((row: PaymentRequestReceiveOperationRow) => row.state === 'active') + .toArray()) as PaymentRequestReceiveOperationRow[]; + return rows.map(rowToOperation); + } + + async list(filter?: { + state?: PaymentRequestReceiveState; + }): Promise { + if (filter?.state) { + return this.getByState(filter.state); + } + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_operations') + .toArray()) as PaymentRequestReceiveOperationRow[]; + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.runTransaction( + 'rw', + ['coco_cashu_payment_request_receive_operations'], + async (tx) => { + await tx.table('coco_cashu_payment_request_receive_operations').delete(id); + }, + ); + } +} + +export class IdbPaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { + constructor(private readonly db: IdbDb) {} + + async create(attempt: PaymentRequestReceiveAttempt): Promise { + await this.db.runTransaction( + 'rw', + ['coco_cashu_payment_request_receive_attempts'], + async (tx) => { + await tx.table('coco_cashu_payment_request_receive_attempts').add(attemptToRow(attempt)); + }, + ); + } + + async update(attempt: PaymentRequestReceiveAttempt): Promise { + await this.db.runTransaction( + 'rw', + ['coco_cashu_payment_request_receive_attempts'], + async (tx) => { + const row = attemptToRow(attempt); + row.updatedAt = getUnixTimeSeconds(); + await tx.table('coco_cashu_payment_request_receive_attempts').put(row); + }, + ); + } + + async getById(id: string): Promise { + const row = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .get(id)) as PaymentRequestReceiveAttemptRow | undefined; + return row ? rowToAttempt(row) : null; + } + + async getByRequestOperationId( + requestOperationId: string, + ): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .where('requestOperationId') + .equals(requestOperationId) + .toArray()) as PaymentRequestReceiveAttemptRow[]; + return rows.map(rowToAttempt); + } + + async getByReceiveOperationId( + receiveOperationId: string, + ): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .where('receiveOperationId') + .equals(receiveOperationId) + .toArray()) as PaymentRequestReceiveAttemptRow[]; + return rows[0] ? rowToAttempt(rows[0]) : null; + } + + async getByTransportMessageId( + transportMessageId: string, + ): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .where('transportMessageId') + .equals(transportMessageId) + .toArray()) as PaymentRequestReceiveAttemptRow[]; + return rows[0] ? rowToAttempt(rows[0]) : null; + } + + async getByPayloadHash( + requestOperationId: string, + payloadHash: string, + ): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .where('payloadHash') + .equals(payloadHash) + .filter( + (row: PaymentRequestReceiveAttemptRow) => row.requestOperationId === requestOperationId, + ) + .toArray()) as PaymentRequestReceiveAttemptRow[]; + return rows[0] ? rowToAttempt(rows[0]) : null; + } + + async getByState( + state: PaymentRequestReceiveAttemptState, + ): Promise { + const rows = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .where('state') + .equals(state) + .toArray()) as PaymentRequestReceiveAttemptRow[]; + return rows.map(rowToAttempt); + } + + async delete(id: string): Promise { + await this.db.runTransaction( + 'rw', + ['coco_cashu_payment_request_receive_attempts'], + async (tx) => { + await tx.table('coco_cashu_payment_request_receive_attempts').delete(id); + }, + ); + } +} diff --git a/packages/indexeddb/src/repositories/ReceiveOperationRepository.ts b/packages/indexeddb/src/repositories/ReceiveOperationRepository.ts index 3b11ce4e..1c821ceb 100644 --- a/packages/indexeddb/src/repositories/ReceiveOperationRepository.ts +++ b/packages/indexeddb/src/repositories/ReceiveOperationRepository.ts @@ -33,6 +33,7 @@ function rowToOperation(row: ReceiveOperationRow): ReceiveOperation { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + source: row.sourceJson ? JSON.parse(row.sourceJson) : undefined, }; if (row.state === 'init') { @@ -75,6 +76,7 @@ function operationToRow(op: ReceiveOperation): ReceiveOperationRow { fee: null, inputProofsJson: JSON.stringify(op.inputProofs), outputDataJson: null, + sourceJson: op.source ? JSON.stringify(op.source) : null, }; } @@ -90,6 +92,7 @@ function operationToRow(op: ReceiveOperation): ReceiveOperationRow { fee: serializeAmount(op.fee), inputProofsJson: JSON.stringify(op.inputProofs), outputDataJson: op.outputData ? JSON.stringify(op.outputData) : null, + sourceJson: op.source ? JSON.stringify(op.source) : null, }; } diff --git a/packages/indexeddb/src/test/contract.test.ts b/packages/indexeddb/src/test/contract.test.ts index e0d0ab37..b64d68f0 100644 --- a/packages/indexeddb/src/test/contract.test.ts +++ b/packages/indexeddb/src/test/contract.test.ts @@ -4,6 +4,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, } from '@cashu/coco-adapter-tests'; import { IndexedDbRepositories } from '../index.ts'; @@ -34,3 +35,5 @@ runProofRepositoryContract({ createRepositories }, { describe, it, expect }); runMintOperationRepositoryContract({ createRepositories }, { describe, it, expect }); runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + +runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); diff --git a/packages/sqlite-bun/src/index.ts b/packages/sqlite-bun/src/index.ts index 3b5b654e..c199b3d0 100644 --- a/packages/sqlite-bun/src/index.ts +++ b/packages/sqlite-bun/src/index.ts @@ -11,6 +11,8 @@ import type { MeltOperationRepository, AuthSessionRepository, MintOperationRepository, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from '@cashu/coco-core'; @@ -29,6 +31,10 @@ import { SqliteMeltOperationRepository } from './repositories/MeltOperationRepos import { SqliteAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; import { SqliteMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { SqliteReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; +import { + SqlitePaymentRequestReceiveAttemptRepository, + SqlitePaymentRequestReceiveOperationRepository, +} from './repositories/PaymentRequestReceiveRepository.ts'; export interface SqliteRepositoriesOptions extends SqliteDbOptions {} @@ -46,6 +52,8 @@ export class SqliteRepositories implements Repositories { readonly authSessionRepository: AuthSessionRepository; readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; + readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; readonly db: SqliteDb; constructor(options: SqliteRepositoriesOptions) { @@ -63,6 +71,10 @@ export class SqliteRepositories implements Repositories { this.authSessionRepository = new SqliteAuthSessionRepository(this.db); this.mintOperationRepository = new SqliteMintOperationRepository(this.db); this.receiveOperationRepository = new SqliteReceiveOperationRepository(this.db); + this.paymentRequestReceiveOperationRepository = + new SqlitePaymentRequestReceiveOperationRepository(this.db); + this.paymentRequestReceiveAttemptRepository = + new SqlitePaymentRequestReceiveAttemptRepository(this.db); } async init(): Promise { @@ -85,6 +97,10 @@ export class SqliteRepositories implements Repositories { authSessionRepository: new SqliteAuthSessionRepository(txDb), mintOperationRepository: new SqliteMintOperationRepository(txDb), receiveOperationRepository: new SqliteReceiveOperationRepository(txDb), + paymentRequestReceiveOperationRepository: + new SqlitePaymentRequestReceiveOperationRepository(txDb), + paymentRequestReceiveAttemptRepository: + new SqlitePaymentRequestReceiveAttemptRepository(txDb), }; return fn(scopedRepositories); @@ -110,6 +126,8 @@ export { SqliteAuthSessionRepository, SqliteMintOperationRepository, SqliteReceiveOperationRepository, + SqlitePaymentRequestReceiveOperationRepository, + SqlitePaymentRequestReceiveAttemptRepository, }; export type { Migration }; diff --git a/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts b/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts new file mode 100644 index 00000000..2d232f8e --- /dev/null +++ b/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts @@ -0,0 +1,331 @@ +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveAttemptState, + PaymentRequestReceiveOperation, + PaymentRequestReceiveOperationRepository, + PaymentRequestReceiveState, + PaymentRequestReceiveTransport, +} from '@cashu/coco-core'; +import { deserializeAmount, serializeAmount } from '@cashu/coco-core'; +import { SqliteDb, getUnixTimeSeconds } from '../db.ts'; + +interface OperationRow { + id: string; + requestId: string | null; + encodedRequest: string; + state: PaymentRequestReceiveState; + transport: PaymentRequestReceiveTransport; + amount: string | number; + unit: string; + mintsJson: string; + singleUse: number; + description: string | null; + createdAt: number; + updatedAt: number; + error: string | null; + completedAt: number | null; +} + +interface AttemptRow { + id: string; + requestOperationId: string; + requestId: string | null; + transport: PaymentRequestReceiveTransport; + transportMessageId: string | null; + payloadHash: string; + senderPubkey: string | null; + memo: string | null; + mintUrl: string; + unit: string; + grossAmount: string | number; + fee: string | number | null; + netAmount: string | number | null; + receiveOperationId: string | null; + state: PaymentRequestReceiveAttemptState; + error: string | null; + payloadJson: string | null; + createdAt: number; + updatedAt: number; +} + +function operationToRow(operation: PaymentRequestReceiveOperation): OperationRow { + return { + id: operation.id, + requestId: operation.requestId ?? null, + encodedRequest: operation.encodedRequest, + state: operation.state, + transport: operation.transport, + amount: serializeAmount(operation.amount), + unit: operation.unit, + mintsJson: JSON.stringify(operation.mints), + singleUse: operation.singleUse ? 1 : 0, + description: operation.description ?? null, + createdAt: Math.floor(operation.createdAt / 1000), + updatedAt: Math.floor(operation.updatedAt / 1000), + error: operation.error ?? null, + completedAt: operation.completedAt ? Math.floor(operation.completedAt / 1000) : null, + }; +} + +function rowToOperation(row: OperationRow): PaymentRequestReceiveOperation { + return { + id: row.id, + requestId: row.requestId ?? undefined, + encodedRequest: row.encodedRequest, + state: row.state, + transport: row.transport, + amount: deserializeAmount(row.amount), + unit: row.unit, + mints: JSON.parse(row.mintsJson) as string[], + singleUse: row.singleUse === 1, + description: row.description ?? undefined, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + completedAt: row.completedAt ? row.completedAt * 1000 : undefined, + }; +} + +function attemptToRow(attempt: PaymentRequestReceiveAttempt): AttemptRow { + return { + id: attempt.id, + requestOperationId: attempt.requestOperationId, + requestId: attempt.requestId ?? null, + transport: attempt.transport, + transportMessageId: attempt.transportMessageId ?? null, + payloadHash: attempt.payloadHash, + senderPubkey: attempt.senderPubkey ?? null, + memo: attempt.memo ?? null, + mintUrl: attempt.mintUrl, + unit: attempt.unit, + grossAmount: serializeAmount(attempt.grossAmount), + fee: attempt.fee ? serializeAmount(attempt.fee) : null, + netAmount: attempt.netAmount ? serializeAmount(attempt.netAmount) : null, + receiveOperationId: attempt.receiveOperationId ?? null, + state: attempt.state, + error: attempt.error ?? null, + payloadJson: attempt.payload ? JSON.stringify(attempt.payload) : null, + createdAt: Math.floor(attempt.createdAt / 1000), + updatedAt: Math.floor(attempt.updatedAt / 1000), + }; +} + +function rowToAttempt(row: AttemptRow): PaymentRequestReceiveAttempt { + const payload = row.payloadJson + ? (JSON.parse(row.payloadJson) as PaymentRequestReceiveAttempt['payload']) + : undefined; + return { + id: row.id, + requestOperationId: row.requestOperationId, + requestId: row.requestId ?? undefined, + transport: row.transport, + transportMessageId: row.transportMessageId ?? undefined, + payloadHash: row.payloadHash, + senderPubkey: row.senderPubkey ?? undefined, + memo: row.memo ?? undefined, + mintUrl: row.mintUrl, + unit: row.unit, + grossAmount: deserializeAmount(row.grossAmount), + fee: row.fee === null ? undefined : deserializeAmount(row.fee), + netAmount: row.netAmount === null ? undefined : deserializeAmount(row.netAmount), + receiveOperationId: row.receiveOperationId ?? undefined, + state: row.state, + error: row.error ?? undefined, + payload, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + }; +} + +export class SqlitePaymentRequestReceiveOperationRepository implements PaymentRequestReceiveOperationRepository { + constructor(private readonly db: SqliteDb) {} + + async create(operation: PaymentRequestReceiveOperation): Promise { + const row = operationToRow(operation); + await this.db.run( + `INSERT INTO coco_cashu_payment_request_receive_operations + (id, requestId, encodedRequest, state, transport, amount, unit, mintsJson, singleUse, description, createdAt, updatedAt, error, completedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + Object.values(row), + ); + } + + async update(operation: PaymentRequestReceiveOperation): Promise { + const row = operationToRow({ ...operation, updatedAt: Date.now() }); + await this.db.run( + `UPDATE coco_cashu_payment_request_receive_operations + SET requestId = ?, encodedRequest = ?, state = ?, transport = ?, amount = ?, unit = ?, + mintsJson = ?, singleUse = ?, description = ?, updatedAt = ?, error = ?, completedAt = ? + WHERE id = ?`, + [ + row.requestId, + row.encodedRequest, + row.state, + row.transport, + row.amount, + row.unit, + row.mintsJson, + row.singleUse, + row.description, + getUnixTimeSeconds(), + row.error, + row.completedAt, + row.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE id = ?', + [id], + ); + return row ? rowToOperation(row) : null; + } + + async getByState(state: PaymentRequestReceiveState): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = ?', + [state], + ); + return rows.map(rowToOperation); + } + + async getActiveByRequestId(requestId: string): Promise { + const rows = await this.db.all( + "SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = 'active' AND requestId = ?", + [requestId], + ); + return rows.map(rowToOperation); + } + + async list(filter?: { + state?: PaymentRequestReceiveState; + }): Promise { + const rows = filter?.state + ? await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = ?', + [filter.state], + ) + : await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations', + ); + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_payment_request_receive_operations WHERE id = ?', [ + id, + ]); + } +} + +export class SqlitePaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { + constructor(private readonly db: SqliteDb) {} + + async create(attempt: PaymentRequestReceiveAttempt): Promise { + const row = attemptToRow(attempt); + await this.db.run( + `INSERT INTO coco_cashu_payment_request_receive_attempts + (id, requestOperationId, requestId, transport, transportMessageId, payloadHash, senderPubkey, + memo, mintUrl, unit, grossAmount, fee, netAmount, receiveOperationId, state, error, + payloadJson, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + Object.values(row), + ); + } + + async update(attempt: PaymentRequestReceiveAttempt): Promise { + const row = attemptToRow({ ...attempt, updatedAt: Date.now() }); + await this.db.run( + `UPDATE coco_cashu_payment_request_receive_attempts + SET requestId = ?, transport = ?, transportMessageId = ?, payloadHash = ?, senderPubkey = ?, + memo = ?, mintUrl = ?, unit = ?, grossAmount = ?, fee = ?, netAmount = ?, + receiveOperationId = ?, state = ?, error = ?, payloadJson = ?, updatedAt = ? + WHERE id = ?`, + [ + row.requestId, + row.transport, + row.transportMessageId, + row.payloadHash, + row.senderPubkey, + row.memo, + row.mintUrl, + row.unit, + row.grossAmount, + row.fee, + row.netAmount, + row.receiveOperationId, + row.state, + row.error, + row.payloadJson, + getUnixTimeSeconds(), + row.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE id = ?', + [id], + ); + return row ? rowToAttempt(row) : null; + } + + async getByRequestOperationId( + requestOperationId: string, + ): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestOperationId = ?', + [requestOperationId], + ); + return rows.map(rowToAttempt); + } + + async getByReceiveOperationId( + receiveOperationId: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE receiveOperationId = ?', + [receiveOperationId], + ); + return row ? rowToAttempt(row) : null; + } + + async getByTransportMessageId( + transportMessageId: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE transportMessageId = ?', + [transportMessageId], + ); + return row ? rowToAttempt(row) : null; + } + + async getByPayloadHash( + requestOperationId: string, + payloadHash: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestOperationId = ? AND payloadHash = ?', + [requestOperationId, payloadHash], + ); + return row ? rowToAttempt(row) : null; + } + + async getByState( + state: PaymentRequestReceiveAttemptState, + ): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE state = ?', + [state], + ); + return rows.map(rowToAttempt); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_payment_request_receive_attempts WHERE id = ?', [id]); + } +} diff --git a/packages/sqlite-bun/src/repositories/ReceiveOperationRepository.ts b/packages/sqlite-bun/src/repositories/ReceiveOperationRepository.ts index 53da433b..3bd9323f 100644 --- a/packages/sqlite-bun/src/repositories/ReceiveOperationRepository.ts +++ b/packages/sqlite-bun/src/repositories/ReceiveOperationRepository.ts @@ -22,6 +22,7 @@ interface ReceiveOperationRow { fee: string | number | null; inputProofsJson: string | null; outputDataJson: string | null; + sourceJson: string | null; } function parseInputProofs(inputProofsJson: string | null): ReceiveOperation['inputProofs'] { @@ -44,6 +45,7 @@ function rowToOperation(row: ReceiveOperationRow): ReceiveOperation { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + source: row.sourceJson ? JSON.parse(row.sourceJson) : undefined, }; if (row.state === 'init') { @@ -86,6 +88,7 @@ function operationToParams(op: ReceiveOperation): unknown[] { null, JSON.stringify(op.inputProofs), null, + op.source ? JSON.stringify(op.source) : null, ]; } @@ -101,6 +104,7 @@ function operationToParams(op: ReceiveOperation): unknown[] { serializeAmount(op.fee), JSON.stringify(op.inputProofs), op.outputData ? JSON.stringify(op.outputData) : null, + op.source ? JSON.stringify(op.source) : null, ]; } @@ -123,8 +127,8 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_receive_operations - (id, mintUrl, unit, amount, state, createdAt, updatedAt, error, fee, inputProofsJson, outputDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, unit, amount, state, createdAt, updatedAt, error, fee, inputProofsJson, outputDataJson, sourceJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -143,7 +147,7 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_receive_operations - SET state = ?, updatedAt = ?, error = ?, unit = ?, inputProofsJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, inputProofsJson = ?, sourceJson = ? WHERE id = ?`, [ operation.state, @@ -151,13 +155,14 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit operation.error ?? null, getOperationUnit(operation), JSON.stringify(operation.inputProofs), + operation.source ? JSON.stringify(operation.source) : null, operation.id, ], ); } else { await this.db.run( `UPDATE coco_cashu_receive_operations - SET state = ?, updatedAt = ?, error = ?, unit = ?, fee = ?, inputProofsJson = ?, outputDataJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, fee = ?, inputProofsJson = ?, outputDataJson = ?, sourceJson = ? WHERE id = ?`, [ operation.state, @@ -167,6 +172,7 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit serializeAmount(operation.fee), JSON.stringify(operation.inputProofs), operation.outputData ? JSON.stringify(operation.outputData) : null, + operation.source ? JSON.stringify(operation.source) : null, operation.id, ], ); diff --git a/packages/sqlite-bun/src/schema.ts b/packages/sqlite-bun/src/schema.ts index 1adf2477..065df54e 100644 --- a/packages/sqlite-bun/src/schema.ts +++ b/packages/sqlite-bun/src/schema.ts @@ -948,6 +948,69 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_payment_request_receive', + sql: ` + ALTER TABLE coco_cashu_receive_operations ADD COLUMN sourceJson TEXT; + + CREATE TABLE IF NOT EXISTS coco_cashu_payment_request_receive_operations ( + id TEXT PRIMARY KEY, + requestId TEXT, + encodedRequest TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('draft', 'active', 'completed', 'cancelled', 'expired')), + transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), + amount TEXT NOT NULL, + unit TEXT NOT NULL, + mintsJson TEXT NOT NULL, + singleUse INTEGER NOT NULL, + description TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + completedAt INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_operations_state + ON coco_cashu_payment_request_receive_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_operations_request_id + ON coco_cashu_payment_request_receive_operations(requestId); + + CREATE TABLE IF NOT EXISTS coco_cashu_payment_request_receive_attempts ( + id TEXT PRIMARY KEY, + requestOperationId TEXT NOT NULL, + requestId TEXT, + transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), + transportMessageId TEXT, + payloadHash TEXT NOT NULL, + senderPubkey TEXT, + memo TEXT, + mintUrl TEXT NOT NULL, + unit TEXT NOT NULL, + grossAmount TEXT NOT NULL, + fee TEXT, + netAmount TEXT, + receiveOperationId TEXT, + state TEXT NOT NULL CHECK (state IN ('received', 'validating', 'receiving', 'finalized', 'rejected', 'duplicate')), + error TEXT, + payloadJson TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_attempts_request_operation + ON coco_cashu_payment_request_receive_attempts(requestOperationId); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_attempts_state + ON coco_cashu_payment_request_receive_attempts(state); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_message + ON coco_cashu_payment_request_receive_attempts(transportMessageId) + WHERE transportMessageId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_payload + ON coco_cashu_payment_request_receive_attempts(requestOperationId, payloadHash); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_receive + ON coco_cashu_payment_request_receive_attempts(receiveOperationId) + WHERE receiveOperationId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/sqlite-bun/src/test/contract.test.ts b/packages/sqlite-bun/src/test/contract.test.ts index a4d1cc89..ea167178 100644 --- a/packages/sqlite-bun/src/test/contract.test.ts +++ b/packages/sqlite-bun/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, createDummyMint, createDummyKeyset, @@ -50,6 +51,8 @@ runMintOperationRepositoryContract({ createRepositories }, { describe, it, expec runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('sqlite-bun adapter transactions', () => { it('commits across repositories', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/sqlite3/src/index.ts b/packages/sqlite3/src/index.ts index 3b5b654e..c199b3d0 100644 --- a/packages/sqlite3/src/index.ts +++ b/packages/sqlite3/src/index.ts @@ -11,6 +11,8 @@ import type { MeltOperationRepository, AuthSessionRepository, MintOperationRepository, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, } from '@cashu/coco-core'; @@ -29,6 +31,10 @@ import { SqliteMeltOperationRepository } from './repositories/MeltOperationRepos import { SqliteAuthSessionRepository } from './repositories/AuthSessionRepository.ts'; import { SqliteMintOperationRepository } from './repositories/MintOperationRepository.ts'; import { SqliteReceiveOperationRepository } from './repositories/ReceiveOperationRepository.ts'; +import { + SqlitePaymentRequestReceiveAttemptRepository, + SqlitePaymentRequestReceiveOperationRepository, +} from './repositories/PaymentRequestReceiveRepository.ts'; export interface SqliteRepositoriesOptions extends SqliteDbOptions {} @@ -46,6 +52,8 @@ export class SqliteRepositories implements Repositories { readonly authSessionRepository: AuthSessionRepository; readonly mintOperationRepository: MintOperationRepository; readonly receiveOperationRepository: ReceiveOperationRepository; + readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; + readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; readonly db: SqliteDb; constructor(options: SqliteRepositoriesOptions) { @@ -63,6 +71,10 @@ export class SqliteRepositories implements Repositories { this.authSessionRepository = new SqliteAuthSessionRepository(this.db); this.mintOperationRepository = new SqliteMintOperationRepository(this.db); this.receiveOperationRepository = new SqliteReceiveOperationRepository(this.db); + this.paymentRequestReceiveOperationRepository = + new SqlitePaymentRequestReceiveOperationRepository(this.db); + this.paymentRequestReceiveAttemptRepository = + new SqlitePaymentRequestReceiveAttemptRepository(this.db); } async init(): Promise { @@ -85,6 +97,10 @@ export class SqliteRepositories implements Repositories { authSessionRepository: new SqliteAuthSessionRepository(txDb), mintOperationRepository: new SqliteMintOperationRepository(txDb), receiveOperationRepository: new SqliteReceiveOperationRepository(txDb), + paymentRequestReceiveOperationRepository: + new SqlitePaymentRequestReceiveOperationRepository(txDb), + paymentRequestReceiveAttemptRepository: + new SqlitePaymentRequestReceiveAttemptRepository(txDb), }; return fn(scopedRepositories); @@ -110,6 +126,8 @@ export { SqliteAuthSessionRepository, SqliteMintOperationRepository, SqliteReceiveOperationRepository, + SqlitePaymentRequestReceiveOperationRepository, + SqlitePaymentRequestReceiveAttemptRepository, }; export type { Migration }; diff --git a/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts b/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts new file mode 100644 index 00000000..2d232f8e --- /dev/null +++ b/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts @@ -0,0 +1,331 @@ +import type { + PaymentRequestReceiveAttempt, + PaymentRequestReceiveAttemptRepository, + PaymentRequestReceiveAttemptState, + PaymentRequestReceiveOperation, + PaymentRequestReceiveOperationRepository, + PaymentRequestReceiveState, + PaymentRequestReceiveTransport, +} from '@cashu/coco-core'; +import { deserializeAmount, serializeAmount } from '@cashu/coco-core'; +import { SqliteDb, getUnixTimeSeconds } from '../db.ts'; + +interface OperationRow { + id: string; + requestId: string | null; + encodedRequest: string; + state: PaymentRequestReceiveState; + transport: PaymentRequestReceiveTransport; + amount: string | number; + unit: string; + mintsJson: string; + singleUse: number; + description: string | null; + createdAt: number; + updatedAt: number; + error: string | null; + completedAt: number | null; +} + +interface AttemptRow { + id: string; + requestOperationId: string; + requestId: string | null; + transport: PaymentRequestReceiveTransport; + transportMessageId: string | null; + payloadHash: string; + senderPubkey: string | null; + memo: string | null; + mintUrl: string; + unit: string; + grossAmount: string | number; + fee: string | number | null; + netAmount: string | number | null; + receiveOperationId: string | null; + state: PaymentRequestReceiveAttemptState; + error: string | null; + payloadJson: string | null; + createdAt: number; + updatedAt: number; +} + +function operationToRow(operation: PaymentRequestReceiveOperation): OperationRow { + return { + id: operation.id, + requestId: operation.requestId ?? null, + encodedRequest: operation.encodedRequest, + state: operation.state, + transport: operation.transport, + amount: serializeAmount(operation.amount), + unit: operation.unit, + mintsJson: JSON.stringify(operation.mints), + singleUse: operation.singleUse ? 1 : 0, + description: operation.description ?? null, + createdAt: Math.floor(operation.createdAt / 1000), + updatedAt: Math.floor(operation.updatedAt / 1000), + error: operation.error ?? null, + completedAt: operation.completedAt ? Math.floor(operation.completedAt / 1000) : null, + }; +} + +function rowToOperation(row: OperationRow): PaymentRequestReceiveOperation { + return { + id: row.id, + requestId: row.requestId ?? undefined, + encodedRequest: row.encodedRequest, + state: row.state, + transport: row.transport, + amount: deserializeAmount(row.amount), + unit: row.unit, + mints: JSON.parse(row.mintsJson) as string[], + singleUse: row.singleUse === 1, + description: row.description ?? undefined, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + error: row.error ?? undefined, + completedAt: row.completedAt ? row.completedAt * 1000 : undefined, + }; +} + +function attemptToRow(attempt: PaymentRequestReceiveAttempt): AttemptRow { + return { + id: attempt.id, + requestOperationId: attempt.requestOperationId, + requestId: attempt.requestId ?? null, + transport: attempt.transport, + transportMessageId: attempt.transportMessageId ?? null, + payloadHash: attempt.payloadHash, + senderPubkey: attempt.senderPubkey ?? null, + memo: attempt.memo ?? null, + mintUrl: attempt.mintUrl, + unit: attempt.unit, + grossAmount: serializeAmount(attempt.grossAmount), + fee: attempt.fee ? serializeAmount(attempt.fee) : null, + netAmount: attempt.netAmount ? serializeAmount(attempt.netAmount) : null, + receiveOperationId: attempt.receiveOperationId ?? null, + state: attempt.state, + error: attempt.error ?? null, + payloadJson: attempt.payload ? JSON.stringify(attempt.payload) : null, + createdAt: Math.floor(attempt.createdAt / 1000), + updatedAt: Math.floor(attempt.updatedAt / 1000), + }; +} + +function rowToAttempt(row: AttemptRow): PaymentRequestReceiveAttempt { + const payload = row.payloadJson + ? (JSON.parse(row.payloadJson) as PaymentRequestReceiveAttempt['payload']) + : undefined; + return { + id: row.id, + requestOperationId: row.requestOperationId, + requestId: row.requestId ?? undefined, + transport: row.transport, + transportMessageId: row.transportMessageId ?? undefined, + payloadHash: row.payloadHash, + senderPubkey: row.senderPubkey ?? undefined, + memo: row.memo ?? undefined, + mintUrl: row.mintUrl, + unit: row.unit, + grossAmount: deserializeAmount(row.grossAmount), + fee: row.fee === null ? undefined : deserializeAmount(row.fee), + netAmount: row.netAmount === null ? undefined : deserializeAmount(row.netAmount), + receiveOperationId: row.receiveOperationId ?? undefined, + state: row.state, + error: row.error ?? undefined, + payload, + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + }; +} + +export class SqlitePaymentRequestReceiveOperationRepository implements PaymentRequestReceiveOperationRepository { + constructor(private readonly db: SqliteDb) {} + + async create(operation: PaymentRequestReceiveOperation): Promise { + const row = operationToRow(operation); + await this.db.run( + `INSERT INTO coco_cashu_payment_request_receive_operations + (id, requestId, encodedRequest, state, transport, amount, unit, mintsJson, singleUse, description, createdAt, updatedAt, error, completedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + Object.values(row), + ); + } + + async update(operation: PaymentRequestReceiveOperation): Promise { + const row = operationToRow({ ...operation, updatedAt: Date.now() }); + await this.db.run( + `UPDATE coco_cashu_payment_request_receive_operations + SET requestId = ?, encodedRequest = ?, state = ?, transport = ?, amount = ?, unit = ?, + mintsJson = ?, singleUse = ?, description = ?, updatedAt = ?, error = ?, completedAt = ? + WHERE id = ?`, + [ + row.requestId, + row.encodedRequest, + row.state, + row.transport, + row.amount, + row.unit, + row.mintsJson, + row.singleUse, + row.description, + getUnixTimeSeconds(), + row.error, + row.completedAt, + row.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE id = ?', + [id], + ); + return row ? rowToOperation(row) : null; + } + + async getByState(state: PaymentRequestReceiveState): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = ?', + [state], + ); + return rows.map(rowToOperation); + } + + async getActiveByRequestId(requestId: string): Promise { + const rows = await this.db.all( + "SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = 'active' AND requestId = ?", + [requestId], + ); + return rows.map(rowToOperation); + } + + async list(filter?: { + state?: PaymentRequestReceiveState; + }): Promise { + const rows = filter?.state + ? await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations WHERE state = ?', + [filter.state], + ) + : await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_operations', + ); + return rows.map(rowToOperation); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_payment_request_receive_operations WHERE id = ?', [ + id, + ]); + } +} + +export class SqlitePaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { + constructor(private readonly db: SqliteDb) {} + + async create(attempt: PaymentRequestReceiveAttempt): Promise { + const row = attemptToRow(attempt); + await this.db.run( + `INSERT INTO coco_cashu_payment_request_receive_attempts + (id, requestOperationId, requestId, transport, transportMessageId, payloadHash, senderPubkey, + memo, mintUrl, unit, grossAmount, fee, netAmount, receiveOperationId, state, error, + payloadJson, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + Object.values(row), + ); + } + + async update(attempt: PaymentRequestReceiveAttempt): Promise { + const row = attemptToRow({ ...attempt, updatedAt: Date.now() }); + await this.db.run( + `UPDATE coco_cashu_payment_request_receive_attempts + SET requestId = ?, transport = ?, transportMessageId = ?, payloadHash = ?, senderPubkey = ?, + memo = ?, mintUrl = ?, unit = ?, grossAmount = ?, fee = ?, netAmount = ?, + receiveOperationId = ?, state = ?, error = ?, payloadJson = ?, updatedAt = ? + WHERE id = ?`, + [ + row.requestId, + row.transport, + row.transportMessageId, + row.payloadHash, + row.senderPubkey, + row.memo, + row.mintUrl, + row.unit, + row.grossAmount, + row.fee, + row.netAmount, + row.receiveOperationId, + row.state, + row.error, + row.payloadJson, + getUnixTimeSeconds(), + row.id, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE id = ?', + [id], + ); + return row ? rowToAttempt(row) : null; + } + + async getByRequestOperationId( + requestOperationId: string, + ): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestOperationId = ?', + [requestOperationId], + ); + return rows.map(rowToAttempt); + } + + async getByReceiveOperationId( + receiveOperationId: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE receiveOperationId = ?', + [receiveOperationId], + ); + return row ? rowToAttempt(row) : null; + } + + async getByTransportMessageId( + transportMessageId: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE transportMessageId = ?', + [transportMessageId], + ); + return row ? rowToAttempt(row) : null; + } + + async getByPayloadHash( + requestOperationId: string, + payloadHash: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestOperationId = ? AND payloadHash = ?', + [requestOperationId, payloadHash], + ); + return row ? rowToAttempt(row) : null; + } + + async getByState( + state: PaymentRequestReceiveAttemptState, + ): Promise { + const rows = await this.db.all( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE state = ?', + [state], + ); + return rows.map(rowToAttempt); + } + + async delete(id: string): Promise { + await this.db.run('DELETE FROM coco_cashu_payment_request_receive_attempts WHERE id = ?', [id]); + } +} diff --git a/packages/sqlite3/src/repositories/ReceiveOperationRepository.ts b/packages/sqlite3/src/repositories/ReceiveOperationRepository.ts index 53da433b..3bd9323f 100644 --- a/packages/sqlite3/src/repositories/ReceiveOperationRepository.ts +++ b/packages/sqlite3/src/repositories/ReceiveOperationRepository.ts @@ -22,6 +22,7 @@ interface ReceiveOperationRow { fee: string | number | null; inputProofsJson: string | null; outputDataJson: string | null; + sourceJson: string | null; } function parseInputProofs(inputProofsJson: string | null): ReceiveOperation['inputProofs'] { @@ -44,6 +45,7 @@ function rowToOperation(row: ReceiveOperationRow): ReceiveOperation { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + source: row.sourceJson ? JSON.parse(row.sourceJson) : undefined, }; if (row.state === 'init') { @@ -86,6 +88,7 @@ function operationToParams(op: ReceiveOperation): unknown[] { null, JSON.stringify(op.inputProofs), null, + op.source ? JSON.stringify(op.source) : null, ]; } @@ -101,6 +104,7 @@ function operationToParams(op: ReceiveOperation): unknown[] { serializeAmount(op.fee), JSON.stringify(op.inputProofs), op.outputData ? JSON.stringify(op.outputData) : null, + op.source ? JSON.stringify(op.source) : null, ]; } @@ -123,8 +127,8 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_receive_operations - (id, mintUrl, unit, amount, state, createdAt, updatedAt, error, fee, inputProofsJson, outputDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, unit, amount, state, createdAt, updatedAt, error, fee, inputProofsJson, outputDataJson, sourceJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -143,7 +147,7 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_receive_operations - SET state = ?, updatedAt = ?, error = ?, unit = ?, inputProofsJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, inputProofsJson = ?, sourceJson = ? WHERE id = ?`, [ operation.state, @@ -151,13 +155,14 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit operation.error ?? null, getOperationUnit(operation), JSON.stringify(operation.inputProofs), + operation.source ? JSON.stringify(operation.source) : null, operation.id, ], ); } else { await this.db.run( `UPDATE coco_cashu_receive_operations - SET state = ?, updatedAt = ?, error = ?, unit = ?, fee = ?, inputProofsJson = ?, outputDataJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, fee = ?, inputProofsJson = ?, outputDataJson = ?, sourceJson = ? WHERE id = ?`, [ operation.state, @@ -167,6 +172,7 @@ export class SqliteReceiveOperationRepository implements ReceiveOperationReposit serializeAmount(operation.fee), JSON.stringify(operation.inputProofs), operation.outputData ? JSON.stringify(operation.outputData) : null, + operation.source ? JSON.stringify(operation.source) : null, operation.id, ], ); diff --git a/packages/sqlite3/src/schema.ts b/packages/sqlite3/src/schema.ts index 1adf2477..065df54e 100644 --- a/packages/sqlite3/src/schema.ts +++ b/packages/sqlite3/src/schema.ts @@ -948,6 +948,69 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_payment_request_receive', + sql: ` + ALTER TABLE coco_cashu_receive_operations ADD COLUMN sourceJson TEXT; + + CREATE TABLE IF NOT EXISTS coco_cashu_payment_request_receive_operations ( + id TEXT PRIMARY KEY, + requestId TEXT, + encodedRequest TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('draft', 'active', 'completed', 'cancelled', 'expired')), + transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), + amount TEXT NOT NULL, + unit TEXT NOT NULL, + mintsJson TEXT NOT NULL, + singleUse INTEGER NOT NULL, + description TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + completedAt INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_operations_state + ON coco_cashu_payment_request_receive_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_operations_request_id + ON coco_cashu_payment_request_receive_operations(requestId); + + CREATE TABLE IF NOT EXISTS coco_cashu_payment_request_receive_attempts ( + id TEXT PRIMARY KEY, + requestOperationId TEXT NOT NULL, + requestId TEXT, + transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), + transportMessageId TEXT, + payloadHash TEXT NOT NULL, + senderPubkey TEXT, + memo TEXT, + mintUrl TEXT NOT NULL, + unit TEXT NOT NULL, + grossAmount TEXT NOT NULL, + fee TEXT, + netAmount TEXT, + receiveOperationId TEXT, + state TEXT NOT NULL CHECK (state IN ('received', 'validating', 'receiving', 'finalized', 'rejected', 'duplicate')), + error TEXT, + payloadJson TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_attempts_request_operation + ON coco_cashu_payment_request_receive_attempts(requestOperationId); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_pr_receive_attempts_state + ON coco_cashu_payment_request_receive_attempts(state); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_message + ON coco_cashu_payment_request_receive_attempts(transportMessageId) + WHERE transportMessageId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_payload + ON coco_cashu_payment_request_receive_attempts(requestOperationId, payloadHash); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_pr_receive_attempts_receive + ON coco_cashu_payment_request_receive_attempts(receiveOperationId) + WHERE receiveOperationId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/sqlite3/src/test/contract.test.ts b/packages/sqlite3/src/test/contract.test.ts index 110b8a76..00d523d1 100644 --- a/packages/sqlite3/src/test/contract.test.ts +++ b/packages/sqlite3/src/test/contract.test.ts @@ -5,6 +5,7 @@ import { runAuthSessionRepositoryContract, runProofRepositoryContract, runMintOperationRepositoryContract, + runPaymentRequestReceiveRepositoryContract, runReceiveOperationRepositoryContract, createDummyMint, createDummyKeyset, @@ -50,6 +51,8 @@ runMintOperationRepositoryContract({ createRepositories }, { describe, it, expec runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('sqlite3 adapter transactions', () => { it('commits across repositories', async () => { const { repositories, dispose } = await createRepositories(); From e80b9155ab19026e0d7cb95160ff26928686a7f8 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 11:04:39 +0200 Subject: [PATCH 03/10] chore: add payment request receive changeset --- .changeset/payment-request-receive.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/payment-request-receive.md diff --git a/.changeset/payment-request-receive.md b/.changeset/payment-request-receive.md new file mode 100644 index 00000000..c70129b6 --- /dev/null +++ b/.changeset/payment-request-receive.md @@ -0,0 +1,18 @@ +--- +'@cashu/coco-core': minor +'@cashu/coco-indexeddb': minor +'@cashu/coco-expo-sqlite': minor +'@cashu/coco-sqlite': minor +'@cashu/coco-sqlite-bun': minor +'@cashu/coco-adapter-tests': minor +--- + +Add incoming payment-request receive operations. + +Core now exposes a payment-request receive saga that creates encoded requests, +claims incoming payloads into normal receive operations, deduplicates payloads, +records receive metadata for history, and reconciles pending child receive +operations during recovery. + +Adapters now persist payment-request receive operations and attempts, and receive +operations store optional source metadata for request-linked receives. From e8e3e49e91dfe4107aa5babe5127272068829bf2 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 11:59:28 +0200 Subject: [PATCH 04/10] fix(indexeddb): enforce payment request payload idempotency --- packages/indexeddb/src/lib/schema.ts | 24 ++++++++++++++++++- .../PaymentRequestReceiveRepository.ts | 13 ++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index 610b17eb..04fee275 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -573,6 +573,28 @@ export async function ensureSchema(db: IdbDb): Promise { coco_cashu_mint_operations: '&id, state, mintUrl, [mintUrl+quoteId]', coco_cashu_payment_request_receive_operations: '&id, state, requestId', coco_cashu_payment_request_receive_attempts: - '&id, requestOperationId, state, payloadHash, transportMessageId, receiveOperationId', + '&id, requestOperationId, state, &[requestOperationId+payloadHash], transportMessageId, receiveOperationId', + }); + + // Version 20: Enforce payment-request attempt idempotency at the IndexedDB schema layer. + db.version(20).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]', + coco_cashu_payment_request_receive_operations: '&id, state, requestId', + coco_cashu_payment_request_receive_attempts: + '&id, requestOperationId, state, &[requestOperationId+payloadHash], transportMessageId, receiveOperationId', }); } diff --git a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts index 78b96cd9..570e900d 100644 --- a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts @@ -250,15 +250,12 @@ export class IdbPaymentRequestReceiveAttemptRepository implements PaymentRequest requestOperationId: string, payloadHash: string, ): Promise { - const rows = (await (this.db as any) + const row = (await (this.db as any) .table('coco_cashu_payment_request_receive_attempts') - .where('payloadHash') - .equals(payloadHash) - .filter( - (row: PaymentRequestReceiveAttemptRow) => row.requestOperationId === requestOperationId, - ) - .toArray()) as PaymentRequestReceiveAttemptRow[]; - return rows[0] ? rowToAttempt(rows[0]) : null; + .where('[requestOperationId+payloadHash]') + .equals([requestOperationId, payloadHash]) + .first()) as PaymentRequestReceiveAttemptRow | undefined; + return row ? rowToAttempt(row) : null; } async getByState( From 4ce526f9bb8ec2dfaff957a51e00afdca0963734 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 15:14:32 +0200 Subject: [PATCH 05/10] fix(core): recover payment request receives on startup --- packages/core/Manager.ts | 8 ++- packages/core/test/unit/Manager.test.ts | 76 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index e395c48f..8f162af7 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -182,8 +182,8 @@ export async function initializeCoco(config: CocoConfig): Promise { // Recover any pending melt operations from previous session await coco.ops.melt.recovery.run(); - // Recover any pending receive operations from previous session - await coco.ops.receive.recovery.run(); + // Recover any pending receive operations and payment-request receive attempts from previous session + await coco.recoverPendingPaymentRequestReceiveAttempts(); // Recover any pending mint operations from previous session await coco.recoverPendingMintOperations(); @@ -466,6 +466,10 @@ export class Manager { await this.mintOperationService.recoverPendingOperations(); } + async recoverPendingPaymentRequestReceiveAttempts(): Promise { + await this.paymentRequestReceiveService.recoverPendingAttempts(); + } + async reconcileLegacyMintQuotes( mintUrl?: string, ): Promise<{ reconciled: string[]; skipped: string[] }> { diff --git a/packages/core/test/unit/Manager.test.ts b/packages/core/test/unit/Manager.test.ts index 1ce666d9..d3d5d9f2 100644 --- a/packages/core/test/unit/Manager.test.ts +++ b/packages/core/test/unit/Manager.test.ts @@ -1,8 +1,11 @@ +import { Amount } from '@cashu/cashu-ts'; import { describe, it, beforeEach, expect, mock } from 'bun:test'; + import { initializeCoco, type CocoConfig, Manager } from '../../Manager'; import { PaymentRequestsApi } from '../../api/PaymentRequestsApi'; import { MemoryRepositories } from '../../repositories/memory'; import { NullLogger } from '../../logging'; +import type { FinalizedReceiveOperation } from '../../operations/receive/ReceiveOperation'; describe('initializeCoco', () => { let repositories: MemoryRepositories; @@ -120,6 +123,79 @@ describe('initializeCoco', () => { expect(counters).toEqual({ init: 1, ready: 1 }); expect((manager.ext as Record).pluginInit).toBe(extension); }); + + it('should recover payment-request receive attempts during startup', async () => { + const now = Date.now(); + await repositories.paymentRequestReceiveOperationRepository.create({ + id: 'payment-request-receive-1', + requestId: 'request-id', + encodedRequest: 'CREQB-test', + state: 'active', + transport: 'inband', + amount: Amount.from(100), + unit: 'sat', + mints: ['https://mint.test'], + singleUse: true, + createdAt: now, + updatedAt: now, + }); + await repositories.paymentRequestReceiveAttemptRepository.create({ + id: 'attempt-1', + requestOperationId: 'payment-request-receive-1', + requestId: 'request-id', + transport: 'inband', + payloadHash: 'payload-hash-1', + mintUrl: 'https://mint.test', + unit: 'sat', + grossAmount: Amount.from(100), + receiveOperationId: 'receive-op-1', + state: 'receiving', + createdAt: now, + updatedAt: now, + }); + await repositories.receiveOperationRepository.create({ + id: 'receive-op-1', + state: 'finalized', + mintUrl: 'https://mint.test', + unit: 'sat', + amount: Amount.from(100), + fee: Amount.from(1), + inputProofs: [], + outputData: { keep: [], send: [] }, + source: { + type: 'payment-request', + requestOperationId: 'payment-request-receive-1', + requestId: 'request-id', + attemptId: 'attempt-1', + transport: 'inband', + }, + createdAt: now, + updatedAt: now, + } satisfies FinalizedReceiveOperation); + + await initializeCoco({ + ...baseConfig, + watchers: { + mintOperationWatcher: { disabled: true }, + proofStateWatcher: { disabled: true }, + }, + processors: { + mintOperationProcessor: { disabled: true }, + }, + }); + + const attempt = await repositories.paymentRequestReceiveAttemptRepository.getById( + 'attempt-1', + ); + const operation = await repositories.paymentRequestReceiveOperationRepository.getById( + 'payment-request-receive-1', + ); + + expect(attempt?.state).toBe('finalized'); + expect(attempt?.fee?.equals(Amount.from(1))).toBe(true); + expect(attempt?.netAmount?.equals(Amount.from(99))).toBe(true); + expect(operation?.state).toBe('completed'); + }); }); describe('watchers configuration', () => { From 1da2e13a008972bed92c59f45e052da23272e834 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 15:44:02 +0200 Subject: [PATCH 06/10] fix(core): allow retry after pre-child payment request crash --- .changeset/payment-request-receive.md | 2 ++ .../services/PaymentRequestReceiveService.ts | 2 +- .../unit/PaymentRequestReceiveService.test.ts | 24 +++++++++++++------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.changeset/payment-request-receive.md b/.changeset/payment-request-receive.md index c70129b6..1ca5201c 100644 --- a/.changeset/payment-request-receive.md +++ b/.changeset/payment-request-receive.md @@ -13,6 +13,8 @@ Core now exposes a payment-request receive saga that creates encoded requests, claims incoming payloads into normal receive operations, deduplicates payloads, records receive metadata for history, and reconciles pending child receive operations during recovery. +Pre-child crash attempts are discarded during recovery so redelivered payloads +can retry instead of being pinned to synthetic rejections. Adapters now persist payment-request receive operations and attempts, and receive operations store optional source metadata for request-linked receives. diff --git a/packages/core/services/PaymentRequestReceiveService.ts b/packages/core/services/PaymentRequestReceiveService.ts index 1652cbbe..4bcd5188 100644 --- a/packages/core/services/PaymentRequestReceiveService.ts +++ b/packages/core/services/PaymentRequestReceiveService.ts @@ -220,7 +220,7 @@ export class PaymentRequestReceiveService { ...(await this.attemptRepository.getByState('validating')), ]; for (const attempt of interruptedBeforeReceive) { - await this.rejectAttempt(attempt, 'Interrupted before child receive operation was created'); + await this.attemptRepository.delete(attempt.id); } const attempts = await this.attemptRepository.getByState('receiving'); diff --git a/packages/core/test/unit/PaymentRequestReceiveService.test.ts b/packages/core/test/unit/PaymentRequestReceiveService.test.ts index 550dbbf3..eeaba978 100644 --- a/packages/core/test/unit/PaymentRequestReceiveService.test.ts +++ b/packages/core/test/unit/PaymentRequestReceiveService.test.ts @@ -11,6 +11,7 @@ import type { PreparedReceiveOperation, ReceiveOperationSource, } from '../../operations/receive/ReceiveOperation'; +import type { ParsedPaymentRequestPayload } from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, @@ -161,31 +162,40 @@ describe('PaymentRequestReceiveService', () => { ); }); - it('rejects interrupted pre-child attempts during recovery', async () => { + it('removes interrupted pre-child attempts during recovery so payloads can retry', async () => { const operation = await service.activate( await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); const now = Date.now(); await attemptRepository.create({ id: 'attempt-1', requestOperationId: operation.id, requestId: operation.requestId, transport: 'inband', - payloadHash: 'payload-hash-1', + payloadHash, mintUrl, unit: 'sat', grossAmount: Amount.from(100), state: 'validating', - payload: createPayload(), + payload, createdAt: now, updatedAt: now, }); await service.recoverPendingAttempts(); - const attempt = await attemptRepository.getById('attempt-1'); - expect(attempt?.state).toBe('rejected'); - expect(attempt?.payload).toBeUndefined(); - expect(attempt?.error).toContain('Interrupted before child receive operation'); + expect(await attemptRepository.getById('attempt-1')).toBeNull(); + + const result = await service.claimPayload(operation.id, payload); + expect(result.attempt.id).not.toBe('attempt-1'); + expect(result.attempt.state).toBe('finalized'); + expect(result.attempt.receiveOperationId).toBe('receive-op-1'); + expect(receiveOperationService.init).toHaveBeenCalledTimes(1); }); }); From 4a9e549802014ff30a098aff2eecc2102504e766 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 16:41:22 +0200 Subject: [PATCH 07/10] fix(core): resume prepared payment request receives --- .changeset/prepared-request-receives.md | 5 + .../services/PaymentRequestReceiveService.ts | 39 ++++++ .../unit/PaymentRequestReceiveService.test.ts | 111 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 .changeset/prepared-request-receives.md diff --git a/.changeset/prepared-request-receives.md b/.changeset/prepared-request-receives.md new file mode 100644 index 00000000..0ee5cab9 --- /dev/null +++ b/.changeset/prepared-request-receives.md @@ -0,0 +1,5 @@ +--- +'@cashu/coco-core': patch +--- + +Resume prepared child receive operations during payment-request recovery. diff --git a/packages/core/services/PaymentRequestReceiveService.ts b/packages/core/services/PaymentRequestReceiveService.ts index 4bcd5188..448a677d 100644 --- a/packages/core/services/PaymentRequestReceiveService.ts +++ b/packages/core/services/PaymentRequestReceiveService.ts @@ -17,6 +17,7 @@ import type { MintService } from './MintService'; import type { ReceiveOperationService } from '../operations/receive/ReceiveOperationService'; import type { FinalizedReceiveOperation, + PreparedReceiveOperation, ReceiveOperation, } from '../operations/receive/ReceiveOperation'; import type { @@ -245,6 +246,8 @@ export class PaymentRequestReceiveService { attempt, receiveOperation.error ?? 'Child receive operation rolled back', ); + } else if (receiveOperation.state === 'prepared') { + await this.resumePreparedChildReceive(attempt, receiveOperation); } } } @@ -491,6 +494,42 @@ export class PaymentRequestReceiveService { } } + private async resumePreparedChildReceive( + attempt: PaymentRequestReceiveAttempt, + receiveOperation: PreparedReceiveOperation, + ): Promise { + try { + const finalizedReceive = await this.receiveOperationService.execute(receiveOperation); + await this.finalizeAttemptFromReceive(attempt, finalizedReceive); + } catch (error) { + const latestReceive = await this.receiveOperationService.getOperation(receiveOperation.id); + if (!latestReceive) { + await this.rejectAttempt(attempt, 'Child receive operation was not found after resume'); + return; + } + + if (latestReceive.state === 'finalized') { + await this.finalizeAttemptFromReceive(attempt, latestReceive); + return; + } + + if (latestReceive.state === 'rolled_back') { + await this.rejectAttempt( + attempt, + latestReceive.error ?? 'Child receive operation rolled back', + ); + return; + } + + this.logger?.warn('Payment request prepared child receive left for recovery retry', { + attemptId: attempt.id, + receiveOperationId: receiveOperation.id, + childState: latestReceive.state, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async completeIfSingleUse( operation: PaymentRequestReceiveOperation, ): Promise { diff --git a/packages/core/test/unit/PaymentRequestReceiveService.test.ts b/packages/core/test/unit/PaymentRequestReceiveService.test.ts index eeaba978..a7fce5b9 100644 --- a/packages/core/test/unit/PaymentRequestReceiveService.test.ts +++ b/packages/core/test/unit/PaymentRequestReceiveService.test.ts @@ -35,6 +35,25 @@ describe('PaymentRequestReceiveService', () => { }; } + function createPreparedReceiveOperation( + overrides: Partial = {}, + ): PreparedReceiveOperation { + const now = Date.now(); + return { + id: 'receive-op-prepared', + state: 'prepared', + mintUrl, + unit: 'sat', + amount: Amount.from(100), + inputProofs: createPayload().proofs, + fee: Amount.from(1), + outputData: { keep: [], send: [] }, + createdAt: now, + updatedAt: now, + ...overrides, + }; + } + beforeEach(() => { operationRepository = new MemoryPaymentRequestReceiveOperationRepository(); attemptRepository = new MemoryPaymentRequestReceiveAttemptRepository(); @@ -198,4 +217,96 @@ describe('PaymentRequestReceiveService', () => { expect(result.attempt.receiveOperationId).toBe('receive-op-1'); expect(receiveOperationService.init).toHaveBeenCalledTimes(1); }); + + it('resumes prepared child receive operations during recovery', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); + const preparedReceive = createPreparedReceiveOperation(); + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-1', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash, + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + state: 'receiving', + receiveOperationId: preparedReceive.id, + payload, + createdAt: now, + updatedAt: now, + }); + ( + receiveOperationService.getOperation as unknown as ReturnType + ).mockResolvedValueOnce(preparedReceive); + + await service.recoverPendingAttempts(); + + expect(receiveOperationService.execute).toHaveBeenCalledWith(preparedReceive); + const storedAttempt = await attemptRepository.getById('attempt-1'); + expect(storedAttempt?.state).toBe('finalized'); + expect(storedAttempt?.fee?.equals(Amount.from(1))).toBe(true); + expect(storedAttempt?.netAmount?.equals(Amount.from(99))).toBe(true); + expect(storedAttempt?.payload).toBeUndefined(); + const storedOperation = await operationRepository.getById(operation.id); + expect(storedOperation?.state).toBe('completed'); + }); + + it('rejects recovering attempts when prepared child receive execution rolls back', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); + const preparedReceive = createPreparedReceiveOperation(); + const rolledBackReceive = { + ...preparedReceive, + state: 'rolled_back' as const, + error: 'Child receive operation rolled back by mint', + }; + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-1', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash, + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + state: 'receiving', + receiveOperationId: preparedReceive.id, + payload, + createdAt: now, + updatedAt: now, + }); + (receiveOperationService.getOperation as unknown as ReturnType) + .mockResolvedValueOnce(preparedReceive) + .mockResolvedValueOnce(rolledBackReceive); + (receiveOperationService.execute as unknown as ReturnType).mockRejectedValueOnce( + new Error('receive failed'), + ); + + await service.recoverPendingAttempts(); + + const storedAttempt = await attemptRepository.getById('attempt-1'); + expect(storedAttempt?.state).toBe('rejected'); + expect(storedAttempt?.error).toBe('Child receive operation rolled back by mint'); + expect(storedAttempt?.payload).toBeUndefined(); + const storedOperation = await operationRepository.getById(operation.id); + expect(storedOperation?.state).toBe('active'); + }); }); From f39806fe296917f35e2e67faf1cb4e81bc39ce80 Mon Sep 17 00:00:00 2001 From: Egge Date: Mon, 11 May 2026 17:12:40 +0200 Subject: [PATCH 08/10] fix(core): recover init payment request receives --- .../services/PaymentRequestReceiveService.ts | 181 +++++++++++++++--- .../unit/PaymentRequestReceiveService.test.ts | 150 +++++++++++++++ 2 files changed, 307 insertions(+), 24 deletions(-) diff --git a/packages/core/services/PaymentRequestReceiveService.ts b/packages/core/services/PaymentRequestReceiveService.ts index 448a677d..c62e9f5f 100644 --- a/packages/core/services/PaymentRequestReceiveService.ts +++ b/packages/core/services/PaymentRequestReceiveService.ts @@ -12,11 +12,16 @@ import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; import type { Logger } from '@core/logging'; -import { PaymentRequestError, ProofValidationError } from '../models/Error'; +import { + OperationInProgressError, + PaymentRequestError, + ProofValidationError, +} from '../models/Error'; import type { MintService } from './MintService'; import type { ReceiveOperationService } from '../operations/receive/ReceiveOperationService'; import type { FinalizedReceiveOperation, + InitReceiveOperation, PreparedReceiveOperation, ReceiveOperation, } from '../operations/receive/ReceiveOperation'; @@ -214,8 +219,6 @@ export class PaymentRequestReceiveService { } async recoverPendingAttempts(): Promise { - await this.receiveOperationService.recoverPendingOperations(); - const interruptedBeforeReceive = [ ...(await this.attemptRepository.getByState('received')), ...(await this.attemptRepository.getByState('validating')), @@ -224,31 +227,70 @@ export class PaymentRequestReceiveService { await this.attemptRepository.delete(attempt.id); } + await this.recoverReceivingAttempts(); + await this.receiveOperationService.recoverPendingOperations(); + await this.recoverReceivingAttempts(); + } + + private async recoverReceivingAttempts(): Promise { const attempts = await this.attemptRepository.getByState('receiving'); for (const attempt of attempts) { - if (!attempt.receiveOperationId) { - await this.rejectAttempt(attempt, 'Missing child receive operation id'); - continue; + let releaseLock: (() => void) | undefined; + try { + releaseLock = await this.lock.acquire(attempt.requestOperationId); + } catch (error) { + if (error instanceof OperationInProgressError) { + this.logger?.debug( + 'Payment request receive operation is in progress, skipping recovery', + { + operationId: attempt.requestOperationId, + attemptId: attempt.id, + }, + ); + continue; + } + throw error; } - const receiveOperation = await this.receiveOperationService.getOperation( - attempt.receiveOperationId, - ); - if (!receiveOperation) { - await this.rejectAttempt(attempt, 'Child receive operation was not found'); - continue; + try { + const currentAttempt = await this.attemptRepository.getById(attempt.id); + if (!currentAttempt || currentAttempt.state !== 'receiving') { + continue; + } + await this.recoverReceivingAttemptLocked(currentAttempt); + } finally { + releaseLock(); } + } + } - if (receiveOperation.state === 'finalized') { - await this.finalizeAttemptFromReceive(attempt, receiveOperation); - } else if (receiveOperation.state === 'rolled_back') { - await this.rejectAttempt( - attempt, - receiveOperation.error ?? 'Child receive operation rolled back', - ); - } else if (receiveOperation.state === 'prepared') { - await this.resumePreparedChildReceive(attempt, receiveOperation); - } + private async recoverReceivingAttemptLocked( + attempt: PaymentRequestReceiveAttempt, + ): Promise { + if (!attempt.receiveOperationId) { + await this.dropAttemptForRetryOrReject(attempt, 'Missing child receive operation id'); + return; + } + + const receiveOperation = await this.receiveOperationService.getOperation( + attempt.receiveOperationId, + ); + if (!receiveOperation) { + await this.dropAttemptForRetryOrReject(attempt, 'Child receive operation was not found'); + return; + } + + if (receiveOperation.state === 'finalized') { + await this.finalizeAttemptFromReceive(attempt, receiveOperation); + } else if (receiveOperation.state === 'rolled_back') { + await this.rejectAttempt( + attempt, + receiveOperation.error ?? 'Child receive operation rolled back', + ); + } else if (receiveOperation.state === 'prepared') { + await this.resumePreparedChildReceive(attempt, receiveOperation); + } else if (receiveOperation.state === 'init') { + await this.resumeInitChildReceive(attempt, receiveOperation); } } @@ -304,10 +346,12 @@ export class PaymentRequestReceiveService { }; await this.attemptRepository.create(attempt); + let validationCompleted = false; try { attempt = await this.updateAttempt({ ...attempt, state: 'validating' }); await this.validatePayload(operation, payload, grossAmount); await this.assertSingleUseAvailable(operation); + validationCompleted = true; const sourceMetadata = { type: 'payment-request' as const, @@ -351,10 +395,31 @@ export class PaymentRequestReceiveService { const receiveOperation = attempt.receiveOperationId ? await this.receiveOperationService.getOperation(attempt.receiveOperationId) : undefined; - if (receiveOperation?.state === 'executing') { + if (receiveOperation?.state === 'finalized') { + attempt = await this.finalizeAttemptFromReceive(attempt, receiveOperation); + const updatedOperation = await this.operationRepository.getById(operation.id); + return { operation: updatedOperation ?? operation, attempt, receiveOperation }; + } + + if (receiveOperation?.state === 'prepared' || receiveOperation?.state === 'executing') { this.logger?.warn('Payment request receive attempt left for recovery', { attemptId: attempt.id, receiveOperationId: receiveOperation.id, + childState: receiveOperation.state, + }); + throw error; + } + + if ( + validationCompleted && + (!receiveOperation || receiveOperation.state === 'init') && + attempt.payload + ) { + await this.attemptRepository.delete(attempt.id); + this.logger?.warn('Payment request receive attempt removed for retry', { + attemptId: attempt.id, + receiveOperationId: attempt.receiveOperationId, + error: error instanceof Error ? error.message : String(error), }); throw error; } @@ -477,10 +542,27 @@ export class PaymentRequestReceiveService { return this.updateAttempt({ ...attempt, state: 'rejected', error, payload: undefined }); } + private async dropAttemptForRetryOrReject( + attempt: PaymentRequestReceiveAttempt, + error: string, + ): Promise { + if (attempt.payload) { + await this.attemptRepository.delete(attempt.id); + this.logger?.warn('Payment request receive attempt removed for redelivery retry', { + attemptId: attempt.id, + receiveOperationId: attempt.receiveOperationId, + error, + }); + return; + } + + await this.rejectAttempt(attempt, error); + } + private async finalizeAttemptFromReceive( attempt: PaymentRequestReceiveAttempt, receiveOperation: FinalizedReceiveOperation, - ): Promise { + ): Promise { const finalized = await this.updateAttempt({ ...attempt, state: 'finalized', @@ -492,6 +574,7 @@ export class PaymentRequestReceiveService { if (operation) { await this.completeIfSingleUse(operation); } + return finalized; } private async resumePreparedChildReceive( @@ -530,6 +613,56 @@ export class PaymentRequestReceiveService { } } + private async resumeInitChildReceive( + attempt: PaymentRequestReceiveAttempt, + receiveOperation: InitReceiveOperation, + ): Promise { + try { + const preparedReceive = await this.receiveOperationService.prepare(receiveOperation); + const netAmount = preparedReceive.amount.subtract(preparedReceive.fee); + const updatedAttempt = await this.updateAttempt({ + ...attempt, + fee: preparedReceive.fee, + netAmount, + }); + await this.resumePreparedChildReceive(updatedAttempt, preparedReceive); + } catch (error) { + const latestReceive = await this.receiveOperationService.getOperation(receiveOperation.id); + if (!latestReceive || latestReceive.state === 'init') { + await this.dropAttemptForRetryOrReject( + attempt, + error instanceof Error ? error.message : String(error), + ); + return; + } + + if (latestReceive.state === 'finalized') { + await this.finalizeAttemptFromReceive(attempt, latestReceive); + return; + } + + if (latestReceive.state === 'rolled_back') { + await this.rejectAttempt( + attempt, + latestReceive.error ?? 'Child receive operation rolled back', + ); + return; + } + + if (latestReceive.state === 'prepared') { + await this.resumePreparedChildReceive(attempt, latestReceive); + return; + } + + this.logger?.warn('Payment request init child receive left for recovery retry', { + attemptId: attempt.id, + receiveOperationId: receiveOperation.id, + childState: latestReceive.state, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async completeIfSingleUse( operation: PaymentRequestReceiveOperation, ): Promise { diff --git a/packages/core/test/unit/PaymentRequestReceiveService.test.ts b/packages/core/test/unit/PaymentRequestReceiveService.test.ts index a7fce5b9..4f75c62f 100644 --- a/packages/core/test/unit/PaymentRequestReceiveService.test.ts +++ b/packages/core/test/unit/PaymentRequestReceiveService.test.ts @@ -9,6 +9,7 @@ import type { FinalizedReceiveOperation, InitReceiveOperation, PreparedReceiveOperation, + ReceiveOperation, ReceiveOperationSource, } from '../../operations/receive/ReceiveOperation'; import type { ParsedPaymentRequestPayload } from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; @@ -54,6 +55,23 @@ describe('PaymentRequestReceiveService', () => { }; } + function createInitReceiveOperation( + overrides: Partial = {}, + ): InitReceiveOperation { + const now = Date.now(); + return { + id: 'receive-op-init', + state: 'init', + mintUrl, + unit: 'sat', + amount: Amount.from(100), + inputProofs: createPayload().proofs, + createdAt: now, + updatedAt: now, + ...overrides, + }; + } + beforeEach(() => { operationRepository = new MemoryPaymentRequestReceiveOperationRepository(); attemptRepository = new MemoryPaymentRequestReceiveAttemptRepository(); @@ -261,6 +279,138 @@ describe('PaymentRequestReceiveService', () => { expect(storedOperation?.state).toBe('completed'); }); + it('resumes init child receive operations before generic receive cleanup', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); + const initReceive = createInitReceiveOperation(); + let currentReceive: ReceiveOperation | null = initReceive; + const order: string[] = []; + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-1', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash, + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + state: 'receiving', + receiveOperationId: initReceive.id, + payload, + createdAt: now, + updatedAt: now, + }); + (receiveOperationService.getOperation as unknown as ReturnType).mockImplementation( + async () => currentReceive, + ); + (receiveOperationService.prepare as unknown as ReturnType).mockImplementation( + async (receiveOperation: InitReceiveOperation): Promise => { + order.push('prepare'); + const preparedReceive = { + ...receiveOperation, + state: 'prepared' as const, + fee: Amount.from(1), + outputData: { keep: [], send: [] }, + }; + currentReceive = preparedReceive; + return preparedReceive; + }, + ); + (receiveOperationService.execute as unknown as ReturnType).mockImplementation( + async (receiveOperation: PreparedReceiveOperation): Promise => { + order.push('execute'); + const finalizedReceive = { ...receiveOperation, state: 'finalized' as const }; + currentReceive = finalizedReceive; + return finalizedReceive; + }, + ); + ( + receiveOperationService.recoverPendingOperations as unknown as ReturnType + ).mockImplementation(async () => { + order.push('generic'); + if (currentReceive?.state === 'init') { + currentReceive = null; + } + }); + + await service.recoverPendingAttempts(); + + expect(order).toEqual(['prepare', 'execute', 'generic']); + const storedAttempt = await attemptRepository.getById('attempt-1'); + expect(storedAttempt?.state).toBe('finalized'); + expect(storedAttempt?.payload).toBeUndefined(); + const storedOperation = await operationRepository.getById(operation.id); + expect(storedOperation?.state).toBe('completed'); + }); + + it('drops receiving attempts with missing child operations so redelivery can retry', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-1', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash, + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + state: 'receiving', + receiveOperationId: 'missing-receive-op', + payload, + createdAt: now, + updatedAt: now, + }); + + await service.recoverPendingAttempts(); + + expect(await attemptRepository.getById('attempt-1')).toBeNull(); + + const result = await service.claimPayload(operation.id, payload); + expect(result.attempt.id).not.toBe('attempt-1'); + expect(result.attempt.state).toBe('finalized'); + }); + + it('does not pin validated payloads when child receive init fails', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); + (receiveOperationService.init as unknown as ReturnType).mockRejectedValueOnce( + new Error('temporary mint metadata failure'), + ); + + await expect(service.claimPayload(operation.id, payload)).rejects.toThrow( + 'temporary mint metadata failure', + ); + + expect(await attemptRepository.getByPayloadHash(operation.id, payloadHash)).toBeNull(); + + const result = await service.claimPayload(operation.id, payload); + expect(result.attempt.state).toBe('finalized'); + }); + it('rejects recovering attempts when prepared child receive execution rolls back', async () => { const operation = await service.activate( await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), From 61a656aaaec68c983259da1f55b958427e606f94 Mon Sep 17 00:00:00 2001 From: Egge Date: Tue, 12 May 2026 14:38:49 +0200 Subject: [PATCH 09/10] feat(core): support payment request receive transports --- .changeset/payment-request-receive.md | 5 + PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md | 370 ++++++++++++ PAYMENT_REQUEST_RECEIVE_PLAN.md | 564 ++++++++++++++++++ packages/adapter-tests/src/index.ts | 25 + packages/core/repositories/index.ts | 4 + .../MemoryPaymentRequestReceiveRepository.ts | 10 + .../services/PaymentRequestReceiveService.ts | 314 +++++++++- .../core/services/PaymentRequestService.ts | 28 +- .../unit/PaymentRequestReceiveService.test.ts | 203 ++++++- .../test/unit/PaymentRequestService.test.ts | 25 +- .../PaymentRequestReceiveRepository.ts | 11 + .../PaymentRequestReceiveRepository.ts | 38 +- .../PaymentRequestReceiveRepository.ts | 11 + .../PaymentRequestReceiveRepository.ts | 11 + 14 files changed, 1584 insertions(+), 35 deletions(-) create mode 100644 PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md create mode 100644 PAYMENT_REQUEST_RECEIVE_PLAN.md diff --git a/.changeset/payment-request-receive.md b/.changeset/payment-request-receive.md index 1ca5201c..0eedf25f 100644 --- a/.changeset/payment-request-receive.md +++ b/.changeset/payment-request-receive.md @@ -13,6 +13,11 @@ Core now exposes a payment-request receive saga that creates encoded requests, claims incoming payloads into normal receive operations, deduplicates payloads, records receive metadata for history, and reconciles pending child receive operations during recovery. +Transport plugins can now register receive handlers for external transports such +as Nostr, and outgoing payment-request parsing exposes Nostr transport +descriptors for plugin delivery. +Incoming request creation activates the request by default; callers that need a +draft can pass `activate: false`. Pre-child crash attempts are discarded during recovery so redelivered payloads can retry instead of being pinned to synthetic rejections. diff --git a/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md b/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md new file mode 100644 index 00000000..c86bc6a5 --- /dev/null +++ b/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md @@ -0,0 +1,370 @@ +# Payment Request Nostr Plugin Handoff + +## Purpose + +This document hands off the Nostr-specific payment-request work to an external +plugin agent. Core is now ready for a Nostr transport plugin: it owns the durable +incoming payment-request saga, idempotency, single-use enforcement, child receive +operations, recovery, and history linkage. The plugin should own Nostr signing, +relay/subscription lifecycle, NIP-17 wrapping/unwrapping, NUT-18 event delivery, +and app-facing Nostr ergonomics. + +The intended boundary is: + +- Core decides whether money was received or paid. +- Core persists request and attempt state. +- Core validates payload/request/mint/amount constraints. +- The plugin creates Nostr transport descriptors and moves encrypted messages + over relays. +- The plugin never treats relay delivery as settlement. + +## Core Baseline + +Incoming payment-request API: + +```ts +manager.paymentRequests.incoming.create(input); +manager.paymentRequests.incoming.activate(operationOrId); +manager.paymentRequests.incoming.cancel(operationId, reason?); +manager.paymentRequests.incoming.get(operationId); +manager.paymentRequests.incoming.list(filter?); +manager.paymentRequests.incoming.ingestPayload(payload, source?); +manager.paymentRequests.incoming.recovery.run(); +manager.paymentRequests.incoming.diagnostics.isLocked(operationId); +``` + +Services exposed through `ServiceMap` include: + +```ts +paymentRequestReceiveService; +paymentRequestService; +sendOperationService; +proofService; +eventBus; +logger; +``` + +Core receive service now also exposes: + +```ts +const unregister = paymentRequestReceiveService.registerTransportHandler(handler); +``` + +The handler is intentionally narrow: + +```ts +export interface PaymentRequestReceiveTransportHandler { + readonly type: 'nostr' | 'post'; + createRequestTransport?( + input: PaymentRequestReceiveTransportCreateInput, + ): PaymentRequestTransport; + activate?(operation: PaymentRequestReceiveOperation): Promise | void; + deactivate?(operation: PaymentRequestReceiveOperation): Promise | void; +} +``` + +Register one handler for `type: 'nostr'` during plugin startup and call the +returned `unregister` function during plugin teardown. + +## Incoming Request Flow + +The plugin should register a transport handler that converts a core request into +a Cashu NUT-18 Nostr transport descriptor: + +```ts +import { PaymentRequestTransportType } from '@cashu/cashu-ts'; + +const unregister = paymentRequestReceiveService.registerTransportHandler({ + type: 'nostr', + + async createRequestTransport(input) { + const pubkey = await signer.getPublicKey(); + const target = encodeNprofile({ + pubkey, + relays: resolveInboxRelays(input), + }); + + return { + type: PaymentRequestTransportType.NOSTR, + target, + tags: [['n', '17']], + }; + }, + + async activate(operation) { + await nostrSubscriptions.activatePaymentRequest(operation); + }, + + async deactivate(operation) { + await nostrSubscriptions.deactivatePaymentRequest(operation.id); + }, +}); +``` + +Then the app-facing plugin API can create an incoming Nostr payment request +through core directly. `create()` activates the operation by default, so the +plugin does not need a second activation call in the normal path: + +```ts +const operation = await paymentRequestReceiveService.create({ + amount, + unit: 'sat', + mints, + requestId, + description, + singleUse: true, + transport: 'nostr', + encoding: 'creqB', +}); + +return { + operation, + encodedRequest: operation.encodedRequest, +}; +``` + +Core will persist `operation.transport === 'nostr'` and encode the returned +Nostr descriptor into the Cashu payment request, then call the registered +transport handler's `activate(operation)` hook. The plugin no longer needs to +create an in-band request and a separate plugin-owned encoded request. + +If the plugin needs to prepare a request without subscribing yet, opt into draft +creation explicitly: + +```ts +const draft = await paymentRequestReceiveService.create({ + amount, + mints, + transport: 'nostr', + activate: false, +}); + +await paymentRequestReceiveService.activate(draft.id); +``` + +Core also accepts a direct descriptor for tests or advanced callers: + +```ts +await paymentRequestReceiveService.create({ + amount, + mints, + transport: { + type: PaymentRequestTransportType.NOSTR, + target: nprofile, + tags: [['n', '17']], + }, +}); +``` + +## Incoming Event Ingest + +For every decrypted NIP-17 event containing a `PaymentRequestPayload`, call core +with the raw JSON or parsed payload: + +```ts +await paymentRequestReceiveService.ingestPayload(payloadJson, { + transport: 'nostr', + transportMessageId: giftWrapEvent.id, + senderPubkey, +}); +``` + +Core will: + +- parse the payload with integer-safe handling, +- look up the request by payload `id`, +- dedupe by `transportMessageId`, +- dedupe redelivered payloads by request id and canonical payload hash, even + after a single-use request has completed, +- reject reused Nostr event ids that point at a different request operation, +- validate request id, mint, unit, gross amount, trusted mint, and unsupported + NUT-10 requirements, +- enforce single-use requests while a previous claim is in flight, +- create and execute a child receive operation, +- finalize or reject the attempt, +- complete the parent operation for single-use requests, +- persist receive source metadata for history. + +The plugin should not call `claimPayload()` directly unless it has already +resolved a specific operation. `ingestPayload()` is the payload-only ingress path +for relay delivery. + +## Recovery Contract + +Core calls registered transport handlers during +`paymentRequestReceiveService.recoverPendingAttempts()`: + +- active Nostr operations call `handler.activate(operation)`; +- interrupted receive attempts are reconciled; +- already-finalized attempts complete active single-use parents; +- child receive recovery remains owned by core. + +The plugin's `activate()` must be idempotent. Startup may call it for operations +that were already subscribed before the process crashed. + +If an active Nostr operation exists but no Nostr handler is registered, core +cannot safely recover the transport subscription and will throw. Apps that use +Nostr payment requests should install the plugin before running manager startup +recovery. + +## Outgoing Nostr Payment Flow + +Core can now parse Nostr payment-request transports: + +```ts +const resolved = await paymentRequestService.parse(encodedRequest); + +if (resolved.transport.type === 'nostr') { + // Plugin owns transport delivery. +} +``` + +Core intentionally does not execute Nostr delivery itself: + +```ts +await paymentRequestService.execute(prepared); +// Throws: Nostr payment request execution requires a transport plugin +``` + +The plugin should implement outgoing payment like this: + +1. Parse the encoded request with `paymentRequestService.parse(encodedRequest)`. +2. Require `resolved.transport.type === 'nostr'`. +3. Prepare the send through `paymentRequestService.prepare(resolved, options)`. +4. Execute the underlying send operation through `sendOperationService`. +5. Build a NUT-18 `PaymentRequestPayload` with the request id, token mint, unit, + proofs, and memo. +6. Deliver the payload to the receiver using NIP-17/Nostr. +7. Report send-operation finality from core separately from relay delivery. + +The exact send-operation call should follow the plugin's current service access +pattern. Do not duplicate proof selection, swap, fee, or proof persistence logic +in the plugin. + +## Suggested Plugin API + +Suggested package name: + +```text +@cashu/coco-plugin-nostr-payment-requests +``` + +Suggested public entrypoints: + +```ts +export function createNostrPaymentRequestsPlugin( + options: NostrPaymentRequestsPluginOptions, +): Plugin<[ + 'paymentRequestReceiveService', + 'paymentRequestService', + 'sendOperationService', + 'proofService', + 'logger', +]>; + +export interface NostrPaymentRequestsApi { + createRequest(input: CreateNostrPaymentRequestInput): Promise; + activateRequest(operationId: string): Promise; + deactivateRequest(operationId: string): Promise; + payRequest(input: PayNostrPaymentRequestInput): Promise; + start(): Promise; + stop(): Promise; +} +``` + +Register the extension as: + +```ts +ctx.registerExtension('nostrPaymentRequests', api); +``` + +Apps can then call: + +```ts +await manager.ext.nostrPaymentRequests.createRequest(...); +await manager.ext.nostrPaymentRequests.payRequest(...); +``` + +Use module augmentation in the plugin package so app code gets typed access to +`manager.ext.nostrPaymentRequests`. + +Recommended plugin options: + +```ts +export interface NostrPaymentRequestsPluginOptions { + signer: NostrSigner; + relays: { + inbox: string[]; + publish?: string[]; + discovery?: string[]; + }; + publishInboxRelayList?: boolean; + requestDefaults?: { + encoding?: 'creqA' | 'creqB'; + singleUse?: boolean; + }; + clock?: () => number; +} + +export interface NostrSigner { + getPublicKey(): Promise; // x-only hex pubkey + signEvent(event: UnsignedNostrEvent): Promise; + nip44Encrypt?(pubkey: string, plaintext: string): Promise; + nip44Decrypt?(pubkey: string, ciphertext: string): Promise; +} +``` + +Keep key custody app-owned. A local private-key signer is fine for tests, but the +public API should accept an injected signer. + +## Nostr Responsibilities + +The plugin should implement: + +- nprofile target creation from receiver pubkey and relay hints, +- relay connection lifecycle, +- subscription lifecycle for active receive operations, +- optional inbox relay list publication if the app opts in, +- NIP-17 wrapping and unwrapping, +- NIP-44 encryption/decryption through the injected signer where possible, +- event validation before forwarding payloads to core, +- replay handling at the relay layer without bypassing core idempotency, +- app-facing status/events for relay delivery and subscription health. + +Treat Nostr event metadata as transport metadata. It can be useful for audit and +UI, but it must not become the source of truth for payment state. + +## Current Constraints + +The plugin must respect current core constraints: + +- incoming request receives are `sat`-only, +- incoming NUT-10 receive requirements are rejected, +- DLEQ-required policy is not implemented in the incoming receive saga, +- core does not bundle Nostr dependencies, +- core does not publish to or subscribe from relays, +- core outgoing `execute()` does not deliver Nostr payloads. + +The plugin should not advertise unsupported receive policies in generated +requests. Keep the first plugin slice Nostr-transport-only. + +## Required Plugin Tests + +At minimum, cover: + +- registering and unregistering the Nostr transport handler, +- creating an incoming request with `transport: 'nostr'`, +- encoded request contains the expected Nostr transport descriptor, +- `activate()` subscribes idempotently, +- `deactivate()` unsubscribes idempotently, +- redelivered Nostr event id returns the existing core attempt, +- redelivered payload after single-use completion returns the finalized core + attempt, +- different payload using the same Nostr event id is rejected by core, +- startup recovery reactivates subscriptions for active Nostr operations, +- outgoing Nostr request parse/prepare/send/deliver flow keeps core send finality + separate from relay publication. + +Core has focused tests for the receive-service transport seam, idempotency, and +recovery behavior; plugin tests should prove the Nostr implementation exercises +that seam correctly. diff --git a/PAYMENT_REQUEST_RECEIVE_PLAN.md b/PAYMENT_REQUEST_RECEIVE_PLAN.md new file mode 100644 index 00000000..a7e3e3c7 --- /dev/null +++ b/PAYMENT_REQUEST_RECEIVE_PLAN.md @@ -0,0 +1,564 @@ +# Receiving Cashu Payment Requests + +## Purpose + +Coco already supports paying Cashu payment requests by parsing NUT-18/NUT-26 request +strings, preparing a send operation, and delivering the resulting token either in-band or +by HTTP POST. The missing half is receiver-initiated payment requests: Coco should be +able to create a payment request, wait for a `PaymentRequestPayload`, and claim the +payload through the same crash-safe receive saga used by ordinary token receives. + +This plan adds receiving support without weakening the current operation model: + +- Request creation is tracked as its own durable incoming-payment-request saga. +- Each incoming payload is claimed through a normal `ReceiveOperationService` child + operation. +- Nostr is the first transport, preferably using NUT-18's NIP-17 direct-message path. +- Core remains usable without a Nostr dependency; the Nostr relay/key work is an + adapter/plugin around a core transport interface. + +## Current Coco Receive Architecture + +Token receives are already operation-first: + +1. `ReceiveOpsApi.prepare({ token })` calls `ReceiveOperationService.init(token)`. +2. `init()` extracts and normalizes the mint URL, requires the mint to be trusted, + decodes the token, enforces the current `sat`-only receive guard, prepares incoming + proofs for receiving, sums proof amounts, creates a `ReceiveOperation` in `init`, and + persists it. +3. `prepare()` reloads the operation, computes receive fees with the active wallet, + creates deterministic output data for the net keep amount, persists `prepared`, and + emits `receive-op:prepared`. +4. `execute()` persists `executing` before mint interaction, calls `wallet.receive(...)` + with the stored deterministic output data, saves the new proofs with + `createdByOperationId`, persists `finalized`, and emits `receive-op:finalized`. +5. Terminal mint-originated failures are rolled back through `receive-op:rolled-back`. + Non-terminal or ambiguous failures remain `executing` so startup recovery can use + `outputData`. +6. Startup recovery cleans up stale `init`, leaves `prepared` for user action, and + reconciles `executing` by checking input proof states. If inputs are spent, it + recovers proofs from `outputData`; if inputs are unspent, it re-executes; otherwise it + retries later. +7. `HistoryService` intentionally ignores `receive-op:prepared`. User-facing receive + history is created or updated only from `receive-op:finalized` and + `receive-op:rolled-back`. + +The important invariant is that a receive only becomes user-facing when Coco has either +finalized it or intentionally rolled it back. Payment-request receiving should preserve +that boundary. + +## Spec Constraints + +Sources: + +- NUT-18 Payment Requests: https://cashubtc.github.io/nuts/18/ +- NUT-26 Payment Request Bech32m Encoding: https://cashubtc.github.io/nuts/26/ +- NIP-17 Private Direct Messages: https://nips.nostr.com/17 + +Relevant NUT-18 behavior: + +- A receiver creates a request, displays or shares it, the sender constructs a matching + token, delivers it through the request transport, and the receiver finalizes the + transaction. +- Request fields include payment id `i`, amount `a`, unit `u`, single-use flag `s`, + allowed mints `m`, description `d`, transports `t`, and optional `nut10` locking + requirements. +- Transport may be empty, which means delivery is in-band by the surrounding protocol. +- Nostr transport uses type `nostr`, target `nprofile`, and tags such as `[["n", "17"]]`. + For NIP-17, the sender sends the `PaymentRequestPayload` as the direct-message content. +- The payload is JSON with optional `id` and `memo`, plus required `mint`, `unit`, and + `proofs`. +- The payee is responsible for validating incoming proofs, including DLEQ and required + locking conditions. + +Relevant NUT-26 behavior: + +- `creqb1...` is the compact TLV plus Bech32m encoding of a NUT-18 payment request. +- Implementations should parse both `creqA...` and `creqb1...`/`CREQB1...`. +- Nostr TLV target encoding stores the raw 32-byte x-only pubkey and can round-trip + relay hints as tag tuples. This is compatible with exposing a NUT-18 `nprofile`. +- NUT-26 currently describes the Nostr transport kind as NIP-04 in one table, while + NUT-18 says the Nostr `n` tag declares supported NIPs and explicitly describes NIP-17. + Coco should follow NUT-18 for new receive support and encode `n=17` when advertising + Nostr receive capability. + +Relevant NIP-17 behavior: + +- The user-visible message is an unsigned kind 14 rumor. +- It is sealed with NIP-44, then gift-wrapped in kind 1059 events. +- Gift wraps are published to the receiver's kind 10050 inbox relay list when available. +- The receiver must unwrap, verify the seal sender matches the rumor sender, parse the + kind 14 content, and then hand the payload to Coco. + +## Design Overview + +Do not model the payment request itself as a plain receive operation. At request creation +time there are no proofs, no mint interaction, and no deterministic receive outputs yet. +Instead add an incoming payment-request saga that owns request lifecycle and delegates +payload claiming to `ReceiveOperationService`. + +Proposed relationship: + +```text +PaymentRequestReceiveOperation + id: local saga id + requestId: NUT-18 payment id (`i`) + encodedRequest: creqA or CREQB + state: draft | active | completed | cancelled | expired + transport: inband | nostr | post + amount/unit/mints/singleUse/description/nut10 + children: PaymentRequestReceiveAttempt[] + +PaymentRequestReceiveAttempt + id: local attempt id + requestOperationId + requestId + transportMessageId: nostr event id, HTTP delivery id, or caller-provided id + payloadHash + senderPubkey? + memo? + mint/unit/grossAmount/netAmount? + receiveOperationId? + state: received | validating | receiving | finalized | rejected | duplicate +``` + +The child `ReceiveOperation` remains the source of truth for mint interaction, output +recovery, and wallet proof persistence. The request saga tracks why that receive exists +and whether the request can accept more payloads. + +## Data Model Changes + +Add core models: + +- `PaymentRequestReceiveOperation` +- `PaymentRequestReceiveAttempt` +- `PaymentRequestReceiveState` +- `PaymentRequestReceiveAttemptState` +- `PaymentRequestReceiveSource` + +Add repositories: + +- `PaymentRequestReceiveOperationRepository` +- `PaymentRequestReceiveAttemptRepository` + +Repository requirements: + +- Create/update/get by local operation id. +- Get active requests by request id. +- Get attempts by request operation id. +- Get attempt by transport message id. +- Get attempt by payload hash. +- Transactionally create an attempt and link it to a child receive operation. +- Enforce uniqueness on transport message id when present. +- Enforce idempotency on `(requestOperationId, payloadHash)`. + +Adapter work: + +- Memory repository first. +- SQLite3, sqlite-bun, Expo SQLite, and IndexedDB schema migrations. +- Adapter contract tests for active lookup, idempotent attempts, single-use locking, and + recovery-state round trips. + +Extend `ReceiveOperation` with an optional source field: + +```ts +type ReceiveOperationSource = + | { type: 'manual-token' } + | { + type: 'payment-request'; + requestOperationId: string; + requestId?: string; + attemptId: string; + transport: 'inband' | 'nostr' | 'post'; + transportMessageId?: string; + senderPubkey?: string; + memo?: string; + }; +``` + +Persist this as `sourceJson` in receive-operation adapters. Existing rows should read as +`manual-token` or `undefined` without migration churn beyond adding the nullable column +or IndexedDB field. + +History: + +- Keep normal receive history creation on `receive-op:finalized` and + `receive-op:rolled-back`. +- Include source metadata in receive history `metadata` so UI can show that a receive + came from a payment request and can link back to the request saga. +- Do not create user-facing history at request creation or at payload arrival. + +## Coordination With Multi-Unit Work + +This feature should not block on the custom-unit branch, but the first implementation +should avoid hard conflicts with it: + +- Default incoming request `unit` to `sat` at the API boundary. +- Persist `unit` explicitly on the new payment-request receive operation and attempt + rows, even while only `sat` is accepted. +- Reuse the existing receive-side `sat` guard instead of changing it in this feature. +- Keep amount and unit coupled in new APIs, matching the intended multi-unit direction: + `{ amount, unit }`. +- Prefer linking attempts to child receives through `attempt.receiveOperationId` in the + new request-attempt repository for the first slice. +- Defer adding optional `ReceiveOperation.source` / `sourceJson` until after the + multi-unit branch lands if that branch is actively editing receive-operation models or + adapter schemas. +- When `sourceJson` is deferred, derive request context by querying the attempt table by + `receiveOperationId`; this keeps the core receive operation shape untouched. + +With that sequencing, the only unavoidable overlap is validation behavior around +non-`sat` payloads. That should be a small guard removal or replacement once custom-unit +support lands, not a structural rewrite. + +## API Shape + +Keep existing outgoing payment-request methods working: + +- `manager.paymentRequests.parse(...)` +- `manager.paymentRequests.prepare(...)` +- `manager.paymentRequests.execute(...)` + +Add an incoming namespace to avoid overloading the outgoing names: + +```ts +manager.paymentRequests.incoming.create(input) +manager.paymentRequests.incoming.activate(operationOrId) +manager.paymentRequests.incoming.cancel(operationId, reason?) +manager.paymentRequests.incoming.get(operationId) +manager.paymentRequests.incoming.list(filter?) +manager.paymentRequests.incoming.claimPayload(operationOrId, payload, source?) +manager.paymentRequests.incoming.ingestPayload(payload, source?) +manager.paymentRequests.incoming.recovery.run() +manager.paymentRequests.incoming.diagnostics.isLocked(operationId) +``` + +`create(input)`: + +- Validates amount/unit/mint constraints. +- Generates a unique request id unless the caller supplies one. +- Builds `PaymentRequest` with requested fields. +- Encodes to `CREQB` by default for QR efficiency, with `creqA` as an option. +- Persists the request operation. +- Activates the operation by default and starts registered transport listener state. +- Supports `activate: false` for callers that explicitly need a draft. +- Returns the operation and encoded request. + +`activate(operationOrId)`: + +- Moves `draft` to `active`, or re-runs transport activation for an already-active request. +- Starts or confirms any registered transport listener state. +- Does not perform mint interaction. + +`claimPayload(operationOrId, payload, source?)`: + +- Parses and normalizes a `PaymentRequestPayload` with the same integer-safe JSON handling + used by outgoing HTTP delivery. +- Creates a durable attempt. +- Validates request id, mint, unit, gross proof amount, single-use state, and locking + requirements before mint interaction. +- Creates a child receive operation with `source.type = 'payment-request'`. +- Prepares the child receive to compute fees and deterministic outputs. +- Executes the child receive. +- Marks the attempt finalized or rejected. +- Marks the request completed if it is single-use and the child receive finalized. + +`recovery.run()`: + +- Restarts active transport listeners. +- Reconciles attempts whose child receive operation exists. +- Replays attempts stuck before child receive creation if the full payload was persisted, + or marks them rejected if only incomplete metadata is available. +- Defers to `manager.ops.receive.recovery.run()` for child receive execution recovery. + +## Payload Validation Rules + +Validation should be stricter than outgoing parsing because this path credits wallet +balance: + +1. Request id: + - If the request has `i`, payload `id` must match. + - If the request omits `i`, only explicit local operation selection can claim it. +2. Mint: + - Payload `mint` must be normalized. + - Payload mint must be trusted. + - If request `m` is non-empty, payload mint must be in it. +3. Unit: + - Payload unit must match request unit when request unit is set. + - For the first implementation, reject non-`sat`, matching current + `ReceiveOperationService` behavior. +4. Payload parsing: + - Parse Nostr/HTTP JSON content with integer-safe handling, not plain `JSON.parse`, so + proof amounts round-trip like outgoing `JSONInt.stringify(token)`. + - Derive payload hashes from canonical payload content or proof Y values, not raw JSON + text, because Nostr content and HTTP bodies can vary in field order. +5. Amount: + - Compute gross payload amount from proofs. + - Treat the NUT-18 request amount as the gross ecash amount the sender must deliver: + `grossAmount >= request.amount`. + - After preparing the child receive, compute and persist receive fee and net credited + amount for display and reconciliation. + - Do not reject otherwise valid interoperable payments only because receive fees reduce + local net balance. If an app needs a strict net-credit guarantee, expose that as an + explicit Coco policy rather than the default NUT-18 behavior. + - If a stricter policy rejects after a child receive has already been prepared, roll the + child receive back with a clear reason. Do not leave a prepared child operation + dangling. +6. Single-use: + - A single-use request can have only one finalized attempt. + - Use a request-level lock so two simultaneous Nostr deliveries cannot both pass the + single-use check. +7. Duplicate delivery: + - Duplicate Nostr events or repeated payloads should return the existing attempt + result without running another receive. + - Payload hash should be based on canonical payload content or proof Y values, not + unstable JSON string order. +8. NUT-10: + - Do not advertise `nut10` receive requirements until validation is implemented. + - When implemented, validate the incoming proofs satisfy the requested secret kind, + data, tags, signature/witness requirements, and timelock policy before executing. + - Existing `ProofService.prepareProofsForReceiving()` can sign supported P2PK proofs, + but it is not enough by itself to prove the payload matches the request policy. +9. DLEQ: + - If the request policy requires DLEQ proofs, validate before execution. + - If DLEQ is absent and policy requires it, reject the attempt. + +## Nostr Transport Plan + +Core should define a small incoming transport contract: + +```ts +interface IncomingPaymentRequestTransport { + readonly type: 'nostr' | 'post' | 'inband'; + createRequestTransport(input: CreateTransportInput): Promise; + activate(operation: PaymentRequestReceiveOperation): Promise; + deactivate(operationId: string): Promise; +} +``` + +The first implementation should be an optional Nostr plugin/package, not a required core +dependency: + +- Depends on `nostr-tools` or a similarly maintained NIP-17 capable library. +- Owns receiver private key access, relay URLs, inbox relay discovery/publication, and + gift-wrap subscribe/unwrap logic. +- Registers an extension such as `manager.ext.nostrPaymentRequests`. +- Registers a transport handler with `PaymentRequestReceiveService`. +- Emits received payloads into + `paymentRequestReceiveService.ingestPayload(payload, source)`. + +Request creation for Nostr: + +- Build a NUT-18 transport with type `nostr`. +- Encode target as `nprofile` containing the receiver pubkey and relay hints. +- Include tags `[["n", "17"]]`. +- Publish or refresh the NIP-17 kind 10050 inbox relay list when the plugin owns the + receiver key. If the app owns relay publication, make activation fail loudly unless the + app confirms a usable inbox relay list exists. +- Default to `CREQB` output for QR codes while keeping `creqA` support. + +Receiving Nostr payloads: + +1. Subscribe to configured inbox relays for kind 1059 gift wraps addressed to the + receiver pubkey. +2. Unwrap NIP-17 events and verify the kind 13 seal sender matches the kind 14 rumor + sender. +3. Parse kind 14 content as `PaymentRequestPayload` JSON. +4. Use payload `id` to find active request operations. +5. Call core `claimPayload(...)` with source `{ transport: 'nostr', eventId, senderPubkey }`. +6. Persist relay/event metadata only as metadata. The proofs and mint interaction remain + in the child receive operation. + +Security stance: + +- Do not mark requests paid based on Nostr delivery alone. +- Do not trust sender pubkey for payment validity. +- Deduplicate Nostr event ids and payload hashes. +- Bound retained raw payload data. Prefer storing canonical payload hashes and child + receive operation ids after the receive operation is durably created. + +## Saga State Transitions + +Incoming request operation: + +```text +draft -> active -> completed + | | ^ + | | | + | +-> expired+ + | | + +------> cancelled +``` + +Attempt: + +```text +received -> validating -> receiving -> finalized + | | | + +------------+-----------+-> rejected + | + +-> duplicate +``` + +Child receive operation: + +```text +init -> prepared -> executing -> finalized + | | | + +--------+----------+-> rolled_back +``` + +Parent/child consistency: + +- Parent `completed` requires at least one finalized child attempt. +- Single-use parent completion is terminal. +- Multi-use parent remains `active` after each finalized attempt. +- Parent cancellation stops new attempts but must not mutate already finalized child + receives. +- Expiration stops new attempts but does not roll back an executing child receive. + +## Recovery Cases + +Crash after request create: + +- Default-created requests are already active and recovered as active requests. +- Explicit `activate: false` drafts remain visible and can be activated or cancelled. + +Crash after activation: + +- Recovery restarts the transport listener for `active` requests. + +Crash after Nostr event received but before child receive creation: + +- If full payload was stored, retry validation and child creation. +- If only event metadata was stored, mark attempt rejected as incomplete and wait for + relay redelivery. + +Crash after child receive `init` or `prepared`: + +- Existing receive recovery handles `init` cleanup and leaves `prepared` for action. +- Incoming-request recovery should either resume the child receive automatically for + attempts it owns or mark the attempt `rejected` if policy validation cannot be + reproduced. + +Crash after child receive `executing`: + +- Existing receive recovery reconciles proofs from mint state and `outputData`. +- Incoming-request recovery should poll the child operation and update attempt/parent + state when it reaches `finalized` or `rolled_back`. + +Crash after child finalized but before parent update: + +- Parent recovery scans attempts with finalized child receive operations and marks the + attempt finalized, then completes the single-use parent if applicable. + +Duplicate relay delivery after finalized: + +- Request/attempt lookup returns the existing finalized result and does not call the + mint again. + +## Implementation Phases + +### Phase 1: Core incoming request saga without live transport + +- Add models, repositories, adapters, and contracts. +- Add `PaymentRequestReceiveService`. +- Add `manager.paymentRequests.incoming`. +- Implement create, activate, cancel, list, get, and manual `claimPayload(...)`. +- Extend `ReceiveOperation` source metadata. +- Add unit tests for validation, single-use locking, duplicate payloads, and child + receive linkage. +- Add adapter contract tests and migration tests. + +This phase supports in-band receive of `PaymentRequestPayload` and proves the saga +boundary before adding Nostr complexity. + +### Phase 2: Nostr transport plugin + +- Add an optional Nostr payment-request plugin/package. +- Implement nprofile/npub parsing and NUT-18 transport creation. +- Implement NIP-17 unwrap and relay subscription. +- Register the transport with the incoming request service. +- Add deterministic tests around handler registration, payload ingestion, duplicate + event handling, and request lookup. +- Use mocked relay/NIP-17 primitives in core tests; keep live relay tests optional. + +### Phase 3: Outgoing Nostr delivery parity + +- Extend existing outgoing `PaymentRequestService` to support Nostr transport. +- Reuse the same Nostr plugin transport handler for sending payloads. +- Preserve HTTP and in-band behavior. +- Add outgoing tests for selecting Nostr when preferred and for falling back according + to transport order. + +### Phase 4: Policy hardening + +- Implement full NUT-10 validation for payment-request receives. +- Add DLEQ policy checks where requested. +- Revisit multi-unit support once receive is not `sat`-only. +- Add optional expiration and memo display policy. +- Add docs for app developers, including relay privacy and key-management warnings. + +## Testing Plan + +Core unit tests: + +- Creates `CREQB` by default and can create `creqA`. +- Request ids are unique and payload ids must match. +- Claims valid in-band payload through a child receive operation. +- Rejects untrusted mint, wrong mint, wrong unit, under-gross-amount payload, and + unsupported NUT-10 policy. +- Records receive fee and net credited amount without rejecting valid gross payments. +- Single-use request finalizes once under concurrent duplicate claims. +- Duplicate payload/event returns existing attempt. +- Parent recovery completes after child receive finalizes. + +Receive operation tests: + +- Source metadata survives memory and persistent repositories. +- History metadata links finalized/rolled-back receives back to request operation ids. +- Existing manual token receive behavior is unchanged. + +Adapter tests: + +- New repositories round-trip all states. +- Payload hash and transport message ids are unique. +- Schema migrations preserve old receive operations with no source metadata. +- SQLite3, sqlite-bun, Expo SQLite, and IndexedDB agree on state filtering. + +Nostr plugin tests: + +- Creates NUT-18 Nostr transport with `nprofile` and `n=17`. +- Decodes `CREQB` Nostr targets back to usable pubkey and relays. +- Unwraps a mocked NIP-17 event and calls `claimPayload`. +- Ignores malformed content. +- Deduplicates event ids. +- Stops subscriptions on pause/dispose. + +Docs: + +- Add a receive-payment-request guide beside `packages/docs/starting/payment-requests.md`. +- Document incoming vs outgoing payment-request APIs. +- Document that Nostr support requires the optional plugin and receiver key material. + +## Open Decisions + +- Should incoming requests live only under `manager.paymentRequests.incoming`, or should + there also be an `manager.ops.paymentRequestReceive` namespace for lifecycle parity + with mint/send/receive/melt? +- Should rejected payment-request attempts appear in public history, or only in the + incoming request detail view? +- Should the first Nostr plugin publish kind 10050 automatically, or should apps own + inbox relay publication? +- Should Coco expose an optional strict net-credit policy for apps that want invoice-like + guarantees, knowing that default NUT-18 interoperability should validate the gross + proof amount delivered by the sender? +- Should raw payloads be persisted until finalization for maximum recovery, or should + Coco store only hashes after child receive creation for lower at-rest sensitivity? + +## Recommended First Slice + +Start with Phase 1. It gives Coco a durable incoming payment-request model and proves +that payment-request payloads can safely flow through the existing receive saga. Once +that is solid, the Nostr plugin becomes transport plumbing rather than a new money-flow +implementation. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 0bb28c65..1b0f2101 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -465,6 +465,31 @@ export async function runPaymentRequestReceiveRepositoryContract( } }); + it('enforces idempotency by transport message id', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await repositories.paymentRequestReceiveAttemptRepository.create( + createDummyPaymentRequestReceiveAttempt(), + ); + + let duplicateRejected = false; + try { + await repositories.paymentRequestReceiveAttemptRepository.create( + createDummyPaymentRequestReceiveAttempt({ + id: 'duplicate-message-attempt', + payloadHash: 'different-payload-hash', + }), + ); + } catch { + duplicateRejected = true; + } + + expect(duplicateRejected).toBe(true); + } finally { + await dispose(); + } + }); + it('looks up attempts by transport message id and child receive id', async () => { const { repositories, dispose } = await options.createRepositories(); try { diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 278b53e4..77dcca1d 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -284,6 +284,10 @@ export interface PaymentRequestReceiveAttemptRepository { requestOperationId: string, payloadHash: string, ): Promise; + getByRequestIdAndPayloadHash( + requestId: string, + payloadHash: string, + ): Promise; getByState(state: PaymentRequestReceiveAttemptState): Promise; delete(id: string): Promise; } diff --git a/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts b/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts index 86fc9b8c..1d208ef9 100644 --- a/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts +++ b/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts @@ -142,6 +142,16 @@ export class MemoryPaymentRequestReceiveAttemptRepository implements PaymentRequ return attempt ? cloneAttempt(attempt) : null; } + async getByRequestIdAndPayloadHash( + requestId: string, + payloadHash: string, + ): Promise { + const attempt = Array.from(this.attempts.values()).find( + (candidate) => candidate.requestId === requestId && candidate.payloadHash === payloadHash, + ); + return attempt ? cloneAttempt(attempt) : null; + } + async getByState( state: PaymentRequestReceiveAttemptState, ): Promise { diff --git a/packages/core/services/PaymentRequestReceiveService.ts b/packages/core/services/PaymentRequestReceiveService.ts index c62e9f5f..a658e2a4 100644 --- a/packages/core/services/PaymentRequestReceiveService.ts +++ b/packages/core/services/PaymentRequestReceiveService.ts @@ -2,9 +2,11 @@ import { Amount, JSONInt, PaymentRequest, + PaymentRequestTransportType, type AmountLike, type NUT10Option, type PaymentRequestPayload, + type PaymentRequestTransport, type Proof, sumProofs, } from '@cashu/cashu-ts'; @@ -40,6 +42,37 @@ import type { import { computeYHexForSecrets, generateSubId, normalizeMintUrl } from '../utils'; import { OperationIdLock } from '../operations/OperationIdLock'; +type CashuPaymentRequestTransportInput = + | PaymentRequestTransport + | { + type: 'nostr' | 'post' | PaymentRequestTransportType; + target: string; + tags?: string[][]; + }; + +export type PaymentRequestReceiveTransportInput = + | PaymentRequestReceiveTransport + | { type: 'inband' } + | CashuPaymentRequestTransportInput; + +export interface PaymentRequestReceiveTransportCreateInput { + requestId: string; + amount: Amount; + unit: string; + mints: string[]; + description?: string; + singleUse: boolean; +} + +export interface PaymentRequestReceiveTransportHandler { + readonly type: Exclude; + createRequestTransport?( + input: PaymentRequestReceiveTransportCreateInput, + ): Promise | PaymentRequestTransport; + activate?(operation: PaymentRequestReceiveOperation): Promise | void; + deactivate?(operation: PaymentRequestReceiveOperation): Promise | void; +} + export interface CreatePaymentRequestReceiveInput { amount: AmountLike; unit?: string; @@ -47,9 +80,10 @@ export interface CreatePaymentRequestReceiveInput { requestId?: string; description?: string; singleUse?: boolean; - transport?: PaymentRequestReceiveTransport; + transport?: PaymentRequestReceiveTransportInput; encoding?: 'creqA' | 'creqB'; nut10?: NUT10Option; + activate?: boolean; } export interface PaymentRequestReceiveClaimResult { @@ -60,6 +94,10 @@ export interface PaymentRequestReceiveClaimResult { export class PaymentRequestReceiveService { private readonly lock = new OperationIdLock(); + private readonly transportHandlers = new Map< + Exclude, + PaymentRequestReceiveTransportHandler + >(); constructor( private readonly operationRepository: PaymentRequestReceiveOperationRepository, @@ -73,6 +111,20 @@ export class PaymentRequestReceiveService { return this.lock.isLocked(operationId); } + registerTransportHandler(handler: PaymentRequestReceiveTransportHandler): () => void { + if (this.transportHandlers.has(handler.type)) { + throw new PaymentRequestError( + `Payment request receive transport handler '${handler.type}' is already registered`, + ); + } + this.transportHandlers.set(handler.type, handler); + return () => { + if (this.transportHandlers.get(handler.type) === handler) { + this.transportHandlers.delete(handler.type); + } + }; + } + async create(input: CreatePaymentRequestReceiveInput): Promise { const unit = input.unit ?? 'sat'; if (unit !== 'sat') { @@ -82,11 +134,6 @@ export class PaymentRequestReceiveService { throw new PaymentRequestError('NUT-10 receive requirements are not supported yet'); } - const transport = input.transport ?? 'inband'; - if (transport !== 'inband') { - throw new PaymentRequestError(`Transport '${transport}' is not supported yet`); - } - const amount = Amount.from(input.amount); if (amount.isZero()) { throw new PaymentRequestError('Payment request amount must be positive'); @@ -101,14 +148,26 @@ export class PaymentRequestReceiveService { } const requestId = input.requestId ?? generateSubId(); + const singleUse = input.singleUse ?? true; + const { transport, paymentRequestTransports } = await this.resolveTransportInput( + input.transport, + { + requestId, + amount, + unit, + mints, + description: input.description, + singleUse, + }, + ); const paymentRequest = new PaymentRequest( - [], + paymentRequestTransports, requestId, amount, unit, mints.length > 0 ? mints : undefined, input.description, - input.singleUse ?? true, + singleUse, ); const encodedRequest = input.encoding === 'creqA' @@ -124,14 +183,17 @@ export class PaymentRequestReceiveService { amount, unit, mints, - singleUse: input.singleUse ?? true, + singleUse, description: input.description, createdAt: now, updatedAt: now, }; await this.operationRepository.create(operation); - return operation; + if (input.activate === false) { + return operation; + } + return this.activate(operation); } async activate( @@ -139,6 +201,7 @@ export class PaymentRequestReceiveService { ): Promise { const operation = await this.requireOperation(operationOrId); if (operation.state === 'active') { + await this.activateTransport(operation); return operation; } if (operation.state !== 'draft') { @@ -153,6 +216,7 @@ export class PaymentRequestReceiveService { updatedAt: Date.now(), }; await this.operationRepository.update(active); + await this.activateTransport(active); return active; } @@ -164,6 +228,9 @@ export class PaymentRequestReceiveService { ); } + if (operation.state === 'active') { + await this.deactivateTransport(operation); + } const cancelled: PaymentRequestReceiveOperation = { ...operation, state: 'cancelled', @@ -207,6 +274,24 @@ export class PaymentRequestReceiveService { throw new PaymentRequestError('Payment request payload id is required for ingestion'); } + const payloadHash = this.hashPayload(payload); + if (source?.transportMessageId) { + const existingByMessage = await this.attemptRepository.getByTransportMessageId( + source.transportMessageId, + ); + if (existingByMessage) { + return this.resultForStoredAttempt(existingByMessage); + } + } + + const existingByPayload = await this.attemptRepository.getByRequestIdAndPayloadHash( + payload.id, + payloadHash, + ); + if (existingByPayload) { + return this.resultForStoredAttempt(existingByPayload); + } + const candidates = await this.operationRepository.getActiveByRequestId(payload.id); if (candidates.length === 0) { throw new PaymentRequestError(`No active payment request found for id ${payload.id}`); @@ -219,6 +304,8 @@ export class PaymentRequestReceiveService { } async recoverPendingAttempts(): Promise { + await this.recoverActiveTransports(); + const interruptedBeforeReceive = [ ...(await this.attemptRepository.getByState('received')), ...(await this.attemptRepository.getByState('validating')), @@ -230,6 +317,72 @@ export class PaymentRequestReceiveService { await this.recoverReceivingAttempts(); await this.receiveOperationService.recoverPendingOperations(); await this.recoverReceivingAttempts(); + await this.recoverFinalizedAttempts(); + } + + private async recoverActiveTransports(): Promise { + const activeOperations = await this.operationRepository.getByState('active'); + for (const operation of activeOperations) { + await this.activateTransport(operation); + } + } + + private async activateTransport(operation: PaymentRequestReceiveOperation): Promise { + if (operation.transport === 'inband') return; + const handler = this.transportHandlers.get(operation.transport); + if (!handler?.activate) { + throw new PaymentRequestError( + `No payment request receive transport handler registered for '${operation.transport}'`, + ); + } + await handler.activate(operation); + } + + private async deactivateTransport(operation: PaymentRequestReceiveOperation): Promise { + if (operation.transport === 'inband') return; + const handler = this.transportHandlers.get(operation.transport); + if (!handler?.deactivate) { + throw new PaymentRequestError( + `No payment request receive transport handler registered for '${operation.transport}'`, + ); + } + await handler.deactivate(operation); + } + + private async recoverFinalizedAttempts(): Promise { + const attempts = await this.attemptRepository.getByState('finalized'); + for (const attempt of attempts) { + const operation = await this.operationRepository.getById(attempt.requestOperationId); + if (!operation || !operation.singleUse || operation.state !== 'active') { + continue; + } + + let releaseLock: (() => void) | undefined; + try { + releaseLock = await this.lock.acquire(operation.id); + } catch (error) { + if (error instanceof OperationInProgressError) { + this.logger?.debug( + 'Payment request receive operation is in progress, skipping finalized recovery', + { + operationId: operation.id, + attemptId: attempt.id, + }, + ); + continue; + } + throw error; + } + + try { + const currentOperation = await this.operationRepository.getById(operation.id); + if (currentOperation?.singleUse && currentOperation.state === 'active') { + await this.completeIfSingleUse(currentOperation); + } + } finally { + releaseLock(); + } + } } private async recoverReceivingAttempts(): Promise { @@ -307,6 +460,12 @@ export class PaymentRequestReceiveService { source.transportMessageId, ); if (existingByMessage) { + if (existingByMessage.requestOperationId !== operation.id) { + throw new PaymentRequestError( + `Transport message ${source.transportMessageId} belongs to another ` + + 'payment request receive operation', + ); + } return this.resultForAttempt(operation, existingByMessage); } } @@ -350,7 +509,7 @@ export class PaymentRequestReceiveService { try { attempt = await this.updateAttempt({ ...attempt, state: 'validating' }); await this.validatePayload(operation, payload, grossAmount); - await this.assertSingleUseAvailable(operation); + await this.assertSingleUseAvailable(operation, attempt.id); validationCompleted = true; const sourceMetadata = { @@ -415,13 +574,21 @@ export class PaymentRequestReceiveService { (!receiveOperation || receiveOperation.state === 'init') && attempt.payload ) { - await this.attemptRepository.delete(attempt.id); - this.logger?.warn('Payment request receive attempt removed for retry', { - attemptId: attempt.id, - receiveOperationId: attempt.receiveOperationId, - error: error instanceof Error ? error.message : String(error), - }); - throw error; + if (this.shouldDropAttemptForRetry(error)) { + await this.attemptRepository.delete(attempt.id); + this.logger?.warn('Payment request receive attempt removed for retry', { + attemptId: attempt.id, + receiveOperationId: attempt.receiveOperationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + + attempt = await this.rejectAttempt( + attempt, + error instanceof Error ? error.message : String(error), + ); + return { operation, attempt, receiveOperation: receiveOperation ?? undefined }; } attempt = await this.rejectAttempt( @@ -432,6 +599,74 @@ export class PaymentRequestReceiveService { } } + private async resolveTransportInput( + input: PaymentRequestReceiveTransportInput | undefined, + createInput: PaymentRequestReceiveTransportCreateInput, + ): Promise<{ + transport: PaymentRequestReceiveTransport; + paymentRequestTransports: PaymentRequestTransport[]; + }> { + if (!input || input === 'inband' || (typeof input === 'object' && input.type === 'inband')) { + return { transport: 'inband', paymentRequestTransports: [] }; + } + + if (typeof input === 'string') { + const handler = this.transportHandlers.get(input); + if (!handler?.createRequestTransport) { + throw new PaymentRequestError(`Transport '${input}' is not supported yet`); + } + const paymentRequestTransport = await handler.createRequestTransport(createInput); + return { + transport: input, + paymentRequestTransports: [this.normalizePaymentRequestTransport(paymentRequestTransport)], + }; + } + + const paymentRequestTransport = this.normalizePaymentRequestTransport(input); + return { + transport: this.toReceiveTransport(paymentRequestTransport.type), + paymentRequestTransports: [paymentRequestTransport], + }; + } + + private toReceiveTransport(type: PaymentRequestTransportType): PaymentRequestReceiveTransport { + switch (type) { + case PaymentRequestTransportType.NOSTR: + return 'nostr'; + case PaymentRequestTransportType.POST: + return 'post'; + default: + throw new PaymentRequestError(`Unsupported payment request transport '${type}'`); + } + } + + private normalizePaymentRequestTransport( + transport: CashuPaymentRequestTransportInput, + ): PaymentRequestTransport { + if (!transport.target || transport.target.trim().length === 0) { + throw new PaymentRequestError(`Transport '${transport.type}' target is required`); + } + + switch (transport.type) { + case 'nostr': + case PaymentRequestTransportType.NOSTR: + return { + type: PaymentRequestTransportType.NOSTR, + target: transport.target, + tags: transport.tags, + }; + case 'post': + case PaymentRequestTransportType.POST: + return { + type: PaymentRequestTransportType.POST, + target: transport.target, + tags: transport.tags, + }; + default: + throw new PaymentRequestError('Unsupported payment request transport'); + } + } + private parsePayload(payloadInput: PaymentRequestPayload | string): ParsedPaymentRequestPayload { const raw = typeof payloadInput === 'string' @@ -499,12 +734,25 @@ export class PaymentRequestReceiveService { } } - private async assertSingleUseAvailable(operation: PaymentRequestReceiveOperation): Promise { + private async assertSingleUseAvailable( + operation: PaymentRequestReceiveOperation, + currentAttemptId: string, + ): Promise { if (!operation.singleUse) return; const attempts = await this.attemptRepository.getByRequestOperationId(operation.id); - if (attempts.some((attempt) => attempt.state === 'finalized')) { + const blockingAttempt = attempts.find( + (attempt) => + attempt.id !== currentAttemptId && + (attempt.state === 'received' || + attempt.state === 'validating' || + attempt.state === 'receiving' || + attempt.state === 'finalized'), + ); + if (!blockingAttempt) return; + if (blockingAttempt.state === 'finalized') { throw new PaymentRequestError('Single-use payment request has already been paid'); } + throw new PaymentRequestError('Single-use payment request has an in-flight claim'); } private hashPayload(payload: ParsedPaymentRequestPayload): string { @@ -559,6 +807,10 @@ export class PaymentRequestReceiveService { await this.rejectAttempt(attempt, error); } + private shouldDropAttemptForRetry(error: unknown): boolean { + return !(error instanceof PaymentRequestError || error instanceof ProofValidationError); + } + private async finalizeAttemptFromReceive( attempt: PaymentRequestReceiveAttempt, receiveOperation: FinalizedReceiveOperation, @@ -629,10 +881,12 @@ export class PaymentRequestReceiveService { } catch (error) { const latestReceive = await this.receiveOperationService.getOperation(receiveOperation.id); if (!latestReceive || latestReceive.state === 'init') { - await this.dropAttemptForRetryOrReject( - attempt, - error instanceof Error ? error.message : String(error), - ); + const message = error instanceof Error ? error.message : String(error); + if (this.shouldDropAttemptForRetry(error)) { + await this.dropAttemptForRetryOrReject(attempt, message); + } else { + await this.rejectAttempt(attempt, message); + } return; } @@ -694,6 +948,18 @@ export class PaymentRequestReceiveService { }; } + private async resultForStoredAttempt( + attempt: PaymentRequestReceiveAttempt, + ): Promise { + const operation = await this.operationRepository.getById(attempt.requestOperationId); + if (!operation) { + throw new PaymentRequestError( + `Payment request receive operation ${attempt.requestOperationId} not found`, + ); + } + return this.resultForAttempt(operation, attempt); + } + private async requireOperation( operationOrId: PaymentRequestReceiveOperation | string, ): Promise { diff --git a/packages/core/services/PaymentRequestService.ts b/packages/core/services/PaymentRequestService.ts index 60b5a089..ea94ade4 100644 --- a/packages/core/services/PaymentRequestService.ts +++ b/packages/core/services/PaymentRequestService.ts @@ -17,7 +17,15 @@ import type { type InbandPaymentRequestTransport = { type: 'inband' }; type HttpPaymentRequestTransport = { type: 'http'; url: string }; -type PaymentRequestTransport = InbandPaymentRequestTransport | HttpPaymentRequestTransport; +type NostrPaymentRequestTransport = { + type: 'nostr'; + target: string; + tags?: string[][]; +}; +type PaymentRequestTransport = + | InbandPaymentRequestTransport + | HttpPaymentRequestTransport + | NostrPaymentRequestTransport; type ResolvedPaymentRequest = { paymentRequest: PaymentRequest; @@ -52,15 +60,18 @@ export type PaymentRequestExecutionResult = type InbandTransport = InbandPaymentRequestTransport; type HttpTransport = HttpPaymentRequestTransport; +type NostrTransport = NostrPaymentRequestTransport; type Transport = PaymentRequestTransport; export type { ResolvedPaymentRequest, InbandPaymentRequestTransport, HttpPaymentRequestTransport, + NostrPaymentRequestTransport, PaymentRequestTransport, InbandTransport, HttpTransport, + NostrTransport, Transport, }; @@ -163,6 +174,10 @@ export class PaymentRequestService { request: transaction.request, }; } + case 'nostr': + throw new PaymentRequestError( + 'Nostr payment request execution requires a transport plugin', + ); } } @@ -194,9 +209,18 @@ export class PaymentRequestService { if (httpTransport) { return { type: 'http', url: httpTransport.target }; } + const nostrTransport = pr.transport.find((t) => t.type === PaymentRequestTransportType.NOSTR); + if (nostrTransport) { + return { + type: 'nostr', + target: nostrTransport.target, + tags: nostrTransport.tags, + }; + } const supportedTypes = pr.transport.map((t) => t.type).join(', '); throw new PaymentRequestError( - `Unsupported transport type. Only HTTP POST is supported, found: ${supportedTypes}`, + 'Unsupported transport type. Only HTTP POST and Nostr are supported, found: ' + + supportedTypes, ); } diff --git a/packages/core/test/unit/PaymentRequestReceiveService.test.ts b/packages/core/test/unit/PaymentRequestReceiveService.test.ts index 4f75c62f..86b9198e 100644 --- a/packages/core/test/unit/PaymentRequestReceiveService.test.ts +++ b/packages/core/test/unit/PaymentRequestReceiveService.test.ts @@ -1,8 +1,13 @@ -import { Amount, type PaymentRequestPayload } from '@cashu/cashu-ts'; +import { + Amount, + PaymentRequest, + PaymentRequestTransportType, + type PaymentRequestPayload, +} from '@cashu/cashu-ts'; import { beforeEach, describe, expect, it, mock } from 'bun:test'; import { PaymentRequestReceiveService } from '../../services/PaymentRequestReceiveService'; -import { PaymentRequestError } from '../../models/Error'; +import { PaymentRequestError, ProofValidationError } from '../../models/Error'; import type { MintService } from '../../services/MintService'; import type { ReceiveOperationService } from '../../operations/receive/ReceiveOperationService'; import type { @@ -12,7 +17,9 @@ import type { ReceiveOperation, ReceiveOperationSource, } from '../../operations/receive/ReceiveOperation'; -import type { ParsedPaymentRequestPayload } from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; +import type { + ParsedPaymentRequestPayload, +} from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, @@ -20,6 +27,10 @@ import { describe('PaymentRequestReceiveService', () => { const mintUrl = 'https://mint.test'; + const nostrTarget = [ + 'nprofile1qqsqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp', + 'zpmhxue69uhhyetvv9ujuar9wd6qymamsk', + ].join(''); let operationRepository: MemoryPaymentRequestReceiveOperationRepository; let attemptRepository: MemoryPaymentRequestReceiveAttemptRepository; let mintService: MintService; @@ -120,7 +131,7 @@ describe('PaymentRequestReceiveService', () => { ); }); - it('creates CREQB payment requests and activates them', async () => { + it('creates active CREQB payment requests by default', async () => { const operation = await service.create({ amount: Amount.from(100), unit: 'sat', @@ -129,14 +140,62 @@ describe('PaymentRequestReceiveService', () => { description: 'test request', }); - expect(operation.state).toBe('draft'); + expect(operation.state).toBe('active'); expect(operation.encodedRequest).toStartWith('CREQB'); expect(operation.requestId).toBe('request-id'); + }); + it('can create draft payment requests when activation is explicitly disabled', async () => { + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + activate: false, + }); + + expect(operation.state).toBe('draft'); const active = await service.activate(operation.id); expect(active.state).toBe('active'); }); + it('creates and activates Nostr payment requests through a registered handler', async () => { + const createRequestTransport = mock(async () => ({ + type: PaymentRequestTransportType.NOSTR, + target: nostrTarget, + tags: [['n', '17']], + })); + const activate = mock(async () => undefined); + const unregister = service.registerTransportHandler({ + type: 'nostr', + createRequestTransport, + activate, + }); + + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + transport: 'nostr', + }); + + expect(operation.transport).toBe('nostr'); + expect(operation.state).toBe('active'); + expect(createRequestTransport).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-id', + unit: 'sat', + singleUse: true, + }), + ); + const decoded = PaymentRequest.fromEncodedRequest(operation.encodedRequest); + expect(decoded.getTransport(PaymentRequestTransportType.NOSTR)?.target).toBe(nostrTarget); + expect(decoded.getTransport(PaymentRequestTransportType.NOSTR)?.tags).toEqual([['n', '17']]); + expect(activate).toHaveBeenCalledWith(expect.objectContaining({ id: operation.id })); + expect(activate).toHaveBeenCalledTimes(1); + + unregister(); + }); + it('claims a valid payload through a child receive operation', async () => { const operation = await service.activate( await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), @@ -175,6 +234,23 @@ describe('PaymentRequestReceiveService', () => { expect(receiveOperationService.init).toHaveBeenCalledTimes(1); }); + it('returns the finalized attempt for duplicate payload ingestion after completion', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + + const first = await service.ingestPayload(payload); + expect(first.operation.state).toBe('completed'); + + const second = await service.ingestPayload(payload); + + expect(second.operation.id).toBe(operation.id); + expect(second.operation.state).toBe('completed'); + expect(second.attempt.id).toBe(first.attempt.id); + expect(receiveOperationService.init).toHaveBeenCalledTimes(1); + }); + it('records a rejected attempt for an underpaid payload', async () => { const operation = await service.activate( await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), @@ -193,6 +269,74 @@ describe('PaymentRequestReceiveService', () => { expect(receiveOperationService.init).not.toHaveBeenCalled(); }); + it('blocks new payloads while a single-use request has an in-flight attempt', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-in-flight', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash: 'in-flight-payload-hash', + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + state: 'receiving', + receiveOperationId: 'receive-op-in-flight', + createdAt: now, + updatedAt: now, + }); + + const result = await service.claimPayload( + operation.id, + createPayload({ + proofs: [{ id: 'keyset-id', amount: Amount.from(100), secret: 'secret-2', C: 'C-2' }], + }), + ); + + expect(result.attempt.state).toBe('rejected'); + expect(result.attempt.error).toContain('in-flight claim'); + expect(receiveOperationService.init).not.toHaveBeenCalled(); + }); + + it('rejects a reused transport message id from a different request', async () => { + const firstOperation = await service.activate( + await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }), + ); + const secondOperation = await service.activate( + await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id-2', + }), + ); + + await service.claimPayload(firstOperation.id, createPayload(), { + transport: 'inband', + transportMessageId: 'message-1', + }); + + await expect( + service.claimPayload( + secondOperation.id, + createPayload({ + id: 'request-id-2', + proofs: [{ id: 'keyset-id', amount: Amount.from(100), secret: 'secret-2', C: 'C-2' }], + }), + { + transport: 'inband', + transportMessageId: 'message-1', + }, + ), + ).rejects.toThrow('belongs to another payment request receive operation'); + }); + it('rejects unsupported transports at create time', async () => { await expect(service.create({ amount: Amount.from(100), transport: 'nostr' })).rejects.toThrow( PaymentRequestError, @@ -236,6 +380,34 @@ describe('PaymentRequestReceiveService', () => { expect(receiveOperationService.init).toHaveBeenCalledTimes(1); }); + it('completes single-use parents for already-finalized attempts during recovery', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const now = Date.now(); + await attemptRepository.create({ + id: 'attempt-finalized', + requestOperationId: operation.id, + requestId: operation.requestId, + transport: 'inband', + payloadHash: 'payload-hash', + mintUrl, + unit: 'sat', + grossAmount: Amount.from(100), + fee: Amount.from(1), + netAmount: Amount.from(99), + state: 'finalized', + receiveOperationId: 'receive-op-finalized', + createdAt: now, + updatedAt: now, + }); + + await service.recoverPendingAttempts(); + + const storedOperation = await operationRepository.getById(operation.id); + expect(storedOperation?.state).toBe('completed'); + }); + it('resumes prepared child receive operations during recovery', async () => { const operation = await service.activate( await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), @@ -411,6 +583,27 @@ describe('PaymentRequestReceiveService', () => { expect(result.attempt.state).toBe('finalized'); }); + it('rejects permanent child receive init validation failures', async () => { + const operation = await service.activate( + await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), + ); + const payload = createPayload(); + const payloadHash = ( + service as unknown as { + hashPayload(payload: ParsedPaymentRequestPayload): string; + } + ).hashPayload(payload); + (receiveOperationService.init as unknown as ReturnType).mockRejectedValueOnce( + new ProofValidationError('Only P2PK locking scripts are supported'), + ); + + const result = await service.claimPayload(operation.id, payload); + + expect(result.attempt.state).toBe('rejected'); + expect(result.attempt.error).toBe('Only P2PK locking scripts are supported'); + expect(await attemptRepository.getByPayloadHash(operation.id, payloadHash)).toBeDefined(); + }); + it('rejects recovering attempts when prepared child receive execution rolls back', async () => { const operation = await service.activate( await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), diff --git a/packages/core/test/unit/PaymentRequestService.test.ts b/packages/core/test/unit/PaymentRequestService.test.ts index 264291ed..9edcca38 100644 --- a/packages/core/test/unit/PaymentRequestService.test.ts +++ b/packages/core/test/unit/PaymentRequestService.test.ts @@ -188,7 +188,7 @@ describe('PaymentRequestService', () => { expect(result.amount).toBeUndefined(); }); - it('should throw for unsupported transport', async () => { + it('should decode a Nostr payment request for plugin delivery', async () => { const pr = new PaymentRequest( [{ type: PaymentRequestTransportType.NOSTR, target: 'npub123...' }], 'request-id-4', @@ -197,8 +197,27 @@ describe('PaymentRequestService', () => { ); const encoded = pr.toEncodedRequest(); - await expect(service.parse(encoded)).rejects.toThrow(PaymentRequestError); - await expect(service.parse(encoded)).rejects.toThrow('Unsupported transport type'); + const result = await service.parse(encoded); + + expect(result.transport.type).toBe('nostr'); + if (result.transport.type === 'nostr') { + expect(result.transport.target).toBe('npub123...'); + } + }); + + it('should require a plugin to execute Nostr payment requests', async () => { + const request = createResolvedRequest({ + transport: { type: 'nostr', target: 'npub123...' }, + }); + const prepared = await service.prepare(request, { + mintUrl: testMintUrl, + amount: Amount.from(100), + }); + + await expect(service.execute(prepared)).rejects.toThrow(PaymentRequestError); + await expect(service.execute(prepared)).rejects.toThrow( + 'Nostr payment request execution requires a transport plugin', + ); }); it('should return an empty payable mint list if no matching mints are found', async () => { diff --git a/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts b/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts index 4c6df370..c13cc0d7 100644 --- a/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts @@ -315,6 +315,17 @@ export class ExpoPaymentRequestReceiveAttemptRepository implements PaymentReques return row ? rowToAttempt(row) : null; } + async getByRequestIdAndPayloadHash( + requestId: string, + payloadHash: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestId = ? AND payloadHash = ?', + [requestId, payloadHash], + ); + return row ? rowToAttempt(row) : null; + } + async getByState( state: PaymentRequestReceiveAttemptState, ): Promise { diff --git a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts index 570e900d..47259d8a 100644 --- a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts @@ -189,7 +189,30 @@ export class IdbPaymentRequestReceiveAttemptRepository implements PaymentRequest 'rw', ['coco_cashu_payment_request_receive_attempts'], async (tx) => { - await tx.table('coco_cashu_payment_request_receive_attempts').add(attemptToRow(attempt)); + const table = tx.table('coco_cashu_payment_request_receive_attempts'); + if (attempt.transportMessageId) { + const existingByMessage = await table + .where('transportMessageId') + .equals(attempt.transportMessageId) + .first(); + if (existingByMessage) { + throw new Error( + `PaymentRequestReceiveAttempt with transport message id ${attempt.transportMessageId} already exists`, + ); + } + } + if (attempt.receiveOperationId) { + const existingByReceive = await table + .where('receiveOperationId') + .equals(attempt.receiveOperationId) + .first(); + if (existingByReceive) { + throw new Error( + `PaymentRequestReceiveAttempt with receive operation id ${attempt.receiveOperationId} already exists`, + ); + } + } + await table.add(attemptToRow(attempt)); }, ); } @@ -258,6 +281,19 @@ export class IdbPaymentRequestReceiveAttemptRepository implements PaymentRequest return row ? rowToAttempt(row) : null; } + async getByRequestIdAndPayloadHash( + requestId: string, + payloadHash: string, + ): Promise { + const row = (await (this.db as any) + .table('coco_cashu_payment_request_receive_attempts') + .where('requestId') + .equals(requestId) + .filter((candidate: PaymentRequestReceiveAttemptRow) => candidate.payloadHash === payloadHash) + .first()) as PaymentRequestReceiveAttemptRow | undefined; + return row ? rowToAttempt(row) : null; + } + async getByState( state: PaymentRequestReceiveAttemptState, ): Promise { diff --git a/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts b/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts index 2d232f8e..12e701a9 100644 --- a/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts @@ -315,6 +315,17 @@ export class SqlitePaymentRequestReceiveAttemptRepository implements PaymentRequ return row ? rowToAttempt(row) : null; } + async getByRequestIdAndPayloadHash( + requestId: string, + payloadHash: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestId = ? AND payloadHash = ?', + [requestId, payloadHash], + ); + return row ? rowToAttempt(row) : null; + } + async getByState( state: PaymentRequestReceiveAttemptState, ): Promise { diff --git a/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts b/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts index 2d232f8e..12e701a9 100644 --- a/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts @@ -315,6 +315,17 @@ export class SqlitePaymentRequestReceiveAttemptRepository implements PaymentRequ return row ? rowToAttempt(row) : null; } + async getByRequestIdAndPayloadHash( + requestId: string, + payloadHash: string, + ): Promise { + const row = await this.db.get( + 'SELECT * FROM coco_cashu_payment_request_receive_attempts WHERE requestId = ? AND payloadHash = ?', + [requestId, payloadHash], + ); + return row ? rowToAttempt(row) : null; + } + async getByState( state: PaymentRequestReceiveAttemptState, ): Promise { From 1813e34f6f07c480fc5362b5595b7171aca48ac4 Mon Sep 17 00:00:00 2001 From: Egge Date: Tue, 12 May 2026 17:05:40 +0200 Subject: [PATCH 10/10] feat(core): simplify payment request receive lifecycle --- .changeset/payment-request-receive.md | 4 +- PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md | 47 ++--- PAYMENT_REQUEST_RECEIVE_PLAN.md | 44 ++--- packages/core/api/PaymentRequestsApi.ts | 7 +- .../PaymentRequestReceiveOperation.ts | 2 +- packages/core/repositories/index.ts | 1 - .../MemoryPaymentRequestReceiveRepository.ts | 4 - .../services/PaymentRequestReceiveService.ts | 52 ++--- .../unit/PaymentRequestReceiveService.test.ts | 178 +++++++++++------- .../core/test/unit/PaymentRequestsApi.test.ts | 1 - packages/docs/starting/payment-requests.md | 62 +++++- .../PaymentRequestReceiveRepository.ts | 6 - packages/expo-sqlite/src/schema.ts | 2 +- packages/indexeddb/src/lib/db.ts | 2 +- .../PaymentRequestReceiveRepository.ts | 10 - .../PaymentRequestReceiveRepository.ts | 6 - packages/sqlite-bun/src/schema.ts | 2 +- .../PaymentRequestReceiveRepository.ts | 6 - packages/sqlite3/src/schema.ts | 2 +- 19 files changed, 230 insertions(+), 208 deletions(-) diff --git a/.changeset/payment-request-receive.md b/.changeset/payment-request-receive.md index 0eedf25f..6cf41743 100644 --- a/.changeset/payment-request-receive.md +++ b/.changeset/payment-request-receive.md @@ -16,8 +16,8 @@ operations during recovery. Transport plugins can now register receive handlers for external transports such as Nostr, and outgoing payment-request parsing exposes Nostr transport descriptors for plugin delivery. -Incoming request creation activates the request by default; callers that need a -draft can pass `activate: false`. +Incoming request creation stores active requests immediately; callers can +cancel requests to stop accepting future payloads while keeping request history. Pre-child crash attempts are discarded during recovery so redelivered payloads can retry instead of being pinned to synthetic rejections. diff --git a/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md b/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md index c86bc6a5..238dcccb 100644 --- a/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md +++ b/PAYMENT_REQUEST_NOSTR_PLUGIN_HANDOFF.md @@ -24,7 +24,6 @@ Incoming payment-request API: ```ts manager.paymentRequests.incoming.create(input); -manager.paymentRequests.incoming.activate(operationOrId); manager.paymentRequests.incoming.cancel(operationId, reason?); manager.paymentRequests.incoming.get(operationId); manager.paymentRequests.incoming.list(filter?); @@ -58,8 +57,8 @@ export interface PaymentRequestReceiveTransportHandler { createRequestTransport?( input: PaymentRequestReceiveTransportCreateInput, ): PaymentRequestTransport; - activate?(operation: PaymentRequestReceiveOperation): Promise | void; - deactivate?(operation: PaymentRequestReceiveOperation): Promise | void; + activate(operation: PaymentRequestReceiveOperation): Promise | void; + deactivate(operation: PaymentRequestReceiveOperation): Promise | void; } ``` @@ -102,8 +101,9 @@ const unregister = paymentRequestReceiveService.registerTransportHandler({ ``` Then the app-facing plugin API can create an incoming Nostr payment request -through core directly. `create()` activates the operation by default, so the -plugin does not need a second activation call in the normal path: +through core directly. `create()` stores an active operation and calls the +registered transport handler, so the plugin does not expose a separate +activation step: ```ts const operation = await paymentRequestReceiveService.create({ @@ -128,21 +128,9 @@ Nostr descriptor into the Cashu payment request, then call the registered transport handler's `activate(operation)` hook. The plugin no longer needs to create an in-band request and a separate plugin-owned encoded request. -If the plugin needs to prepare a request without subscribing yet, opt into draft -creation explicitly: - -```ts -const draft = await paymentRequestReceiveService.create({ - amount, - mints, - transport: 'nostr', - activate: false, -}); - -await paymentRequestReceiveService.activate(draft.id); -``` - -Core also accepts a direct descriptor for tests or advanced callers: +Core also accepts a direct descriptor for tests or advanced callers. Since all +created requests are active, a handler for the descriptor's transport type must +still be registered before creation: ```ts await paymentRequestReceiveService.create({ @@ -254,18 +242,19 @@ Suggested public entrypoints: ```ts export function createNostrPaymentRequestsPlugin( options: NostrPaymentRequestsPluginOptions, -): Plugin<[ - 'paymentRequestReceiveService', - 'paymentRequestService', - 'sendOperationService', - 'proofService', - 'logger', -]>; +): Plugin< + [ + 'paymentRequestReceiveService', + 'paymentRequestService', + 'sendOperationService', + 'proofService', + 'logger', + ] +>; export interface NostrPaymentRequestsApi { createRequest(input: CreateNostrPaymentRequestInput): Promise; - activateRequest(operationId: string): Promise; - deactivateRequest(operationId: string): Promise; + cancelRequest(operationId: string, reason?: string): Promise; payRequest(input: PayNostrPaymentRequestInput): Promise; start(): Promise; stop(): Promise; diff --git a/PAYMENT_REQUEST_RECEIVE_PLAN.md b/PAYMENT_REQUEST_RECEIVE_PLAN.md index a7e3e3c7..9cd051eb 100644 --- a/PAYMENT_REQUEST_RECEIVE_PLAN.md +++ b/PAYMENT_REQUEST_RECEIVE_PLAN.md @@ -104,7 +104,7 @@ PaymentRequestReceiveOperation id: local saga id requestId: NUT-18 payment id (`i`) encodedRequest: creqA or CREQB - state: draft | active | completed | cancelled | expired + state: active | completed | cancelled | expired transport: inband | nostr | post amount/unit/mints/singleUse/description/nut10 children: PaymentRequestReceiveAttempt[] @@ -223,7 +223,6 @@ Add an incoming namespace to avoid overloading the outgoing names: ```ts manager.paymentRequests.incoming.create(input) -manager.paymentRequests.incoming.activate(operationOrId) manager.paymentRequests.incoming.cancel(operationId, reason?) manager.paymentRequests.incoming.get(operationId) manager.paymentRequests.incoming.list(filter?) @@ -239,17 +238,10 @@ manager.paymentRequests.incoming.diagnostics.isLocked(operationId) - Generates a unique request id unless the caller supplies one. - Builds `PaymentRequest` with requested fields. - Encodes to `CREQB` by default for QR efficiency, with `creqA` as an option. -- Persists the request operation. -- Activates the operation by default and starts registered transport listener state. -- Supports `activate: false` for callers that explicitly need a draft. +- Persists the request operation as active. +- Starts registered transport listener state before returning. - Returns the operation and encoded request. -`activate(operationOrId)`: - -- Moves `draft` to `active`, or re-runs transport activation for an already-active request. -- Starts or confirms any registered transport listener state. -- Does not perform mint interaction. - `claimPayload(operationOrId, payload, source?)`: - Parses and normalizes a `PaymentRequestPayload` with the same integer-safe JSON handling @@ -331,8 +323,8 @@ Core should define a small incoming transport contract: interface IncomingPaymentRequestTransport { readonly type: 'nostr' | 'post' | 'inband'; createRequestTransport(input: CreateTransportInput): Promise; - activate(operation: PaymentRequestReceiveOperation): Promise; - deactivate(operationId: string): Promise; + activate(operation: PaymentRequestReceiveOperation): Promise; + deactivate(operation: PaymentRequestReceiveOperation): Promise; } ``` @@ -364,8 +356,8 @@ Receiving Nostr payloads: 2. Unwrap NIP-17 events and verify the kind 13 seal sender matches the kind 14 rumor sender. 3. Parse kind 14 content as `PaymentRequestPayload` JSON. -4. Use payload `id` to find active request operations. -5. Call core `claimPayload(...)` with source `{ transport: 'nostr', eventId, senderPubkey }`. +4. Use core ingestion to find the active request operation by payload `id`. +5. Call core `ingestPayload(...)` with source `{ transport: 'nostr', transportMessageId: eventId, senderPubkey }`. 6. Persist relay/event metadata only as metadata. The proofs and mint interaction remain in the child receive operation. @@ -382,12 +374,12 @@ Security stance: Incoming request operation: ```text -draft -> active -> completed - | | ^ - | | | - | +-> expired+ - | | - +------> cancelled +active -> completed + | ^ + | | + +-> expired+ + | + +-> cancelled ``` Attempt: @@ -421,11 +413,7 @@ Parent/child consistency: Crash after request create: -- Default-created requests are already active and recovered as active requests. -- Explicit `activate: false` drafts remain visible and can be activated or cancelled. - -Crash after activation: - +- Created requests are active and recovered as active requests. - Recovery restarts the transport listener for `active` requests. Crash after Nostr event received but before child receive creation: @@ -464,7 +452,7 @@ Duplicate relay delivery after finalized: - Add models, repositories, adapters, and contracts. - Add `PaymentRequestReceiveService`. - Add `manager.paymentRequests.incoming`. -- Implement create, activate, cancel, list, get, and manual `claimPayload(...)`. +- Implement create, cancel, list, get, and manual `claimPayload(...)`. - Extend `ReceiveOperation` source metadata. - Add unit tests for validation, single-use locking, duplicate payloads, and child receive linkage. @@ -530,7 +518,7 @@ Nostr plugin tests: - Creates NUT-18 Nostr transport with `nprofile` and `n=17`. - Decodes `CREQB` Nostr targets back to usable pubkey and relays. -- Unwraps a mocked NIP-17 event and calls `claimPayload`. +- Unwraps a mocked NIP-17 event and calls `ingestPayload`. - Ignores malformed content. - Deduplicates event ids. - Stops subscriptions on pause/dispose. diff --git a/packages/core/api/PaymentRequestsApi.ts b/packages/core/api/PaymentRequestsApi.ts index cfc1cd10..f2cf2a4d 100644 --- a/packages/core/api/PaymentRequestsApi.ts +++ b/packages/core/api/PaymentRequestsApi.ts @@ -17,9 +17,6 @@ import type { PaymentRequestPayload } from '@cashu/cashu-ts'; export interface IncomingPaymentRequestsApi { create(input: CreatePaymentRequestReceiveInput): Promise; - activate( - operationOrId: PaymentRequestReceiveOperation | string, - ): Promise; cancel(operationId: string, reason?: string): Promise; get(operationId: string): Promise; list(filter?: { state?: PaymentRequestReceiveState }): Promise; @@ -54,13 +51,13 @@ export class PaymentRequestsApi { this.paymentRequestService = paymentRequestService; this.incoming = { create: (input) => paymentRequestReceiveService.create(input), - activate: (operationOrId) => paymentRequestReceiveService.activate(operationOrId), cancel: (operationId, reason) => paymentRequestReceiveService.cancel(operationId, reason), get: (operationId) => paymentRequestReceiveService.get(operationId), list: (filter) => paymentRequestReceiveService.list(filter), claimPayload: (operationOrId, payload, source) => paymentRequestReceiveService.claimPayload(operationOrId, payload, source), - ingestPayload: (payload, source) => paymentRequestReceiveService.ingestPayload(payload, source), + ingestPayload: (payload, source) => + paymentRequestReceiveService.ingestPayload(payload, source), recovery: { run: () => paymentRequestReceiveService.recoverPendingAttempts(), }, diff --git a/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts b/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts index 36fbed49..bad4fb5a 100644 --- a/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts +++ b/packages/core/operations/paymentRequestReceive/PaymentRequestReceiveOperation.ts @@ -1,6 +1,6 @@ import type { Amount, PaymentRequestPayload, Proof } from '@cashu/cashu-ts'; -export type PaymentRequestReceiveState = 'draft' | 'active' | 'completed' | 'cancelled' | 'expired'; +export type PaymentRequestReceiveState = 'active' | 'completed' | 'cancelled' | 'expired'; export type PaymentRequestReceiveAttemptState = | 'received' diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 77dcca1d..91c6e719 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -270,7 +270,6 @@ export interface PaymentRequestReceiveOperationRepository { getByState(state: PaymentRequestReceiveState): Promise; getActiveByRequestId(requestId: string): Promise; list(filter?: { state?: PaymentRequestReceiveState }): Promise; - delete(id: string): Promise; } export interface PaymentRequestReceiveAttemptRepository { diff --git a/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts b/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts index 1d208ef9..ef79b4e7 100644 --- a/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts +++ b/packages/core/repositories/memory/MemoryPaymentRequestReceiveRepository.ts @@ -63,10 +63,6 @@ export class MemoryPaymentRequestReceiveOperationRepository implements PaymentRe .filter((operation) => !filter?.state || operation.state === filter.state) .map(cloneOperation); } - - async delete(id: string): Promise { - this.operations.delete(id); - } } export class MemoryPaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { diff --git a/packages/core/services/PaymentRequestReceiveService.ts b/packages/core/services/PaymentRequestReceiveService.ts index a658e2a4..8b3e6971 100644 --- a/packages/core/services/PaymentRequestReceiveService.ts +++ b/packages/core/services/PaymentRequestReceiveService.ts @@ -69,8 +69,8 @@ export interface PaymentRequestReceiveTransportHandler { createRequestTransport?( input: PaymentRequestReceiveTransportCreateInput, ): Promise | PaymentRequestTransport; - activate?(operation: PaymentRequestReceiveOperation): Promise | void; - deactivate?(operation: PaymentRequestReceiveOperation): Promise | void; + activate(operation: PaymentRequestReceiveOperation): Promise | void; + deactivate(operation: PaymentRequestReceiveOperation): Promise | void; } export interface CreatePaymentRequestReceiveInput { @@ -83,7 +83,6 @@ export interface CreatePaymentRequestReceiveInput { transport?: PaymentRequestReceiveTransportInput; encoding?: 'creqA' | 'creqB'; nut10?: NUT10Option; - activate?: boolean; } export interface PaymentRequestReceiveClaimResult { @@ -178,7 +177,7 @@ export class PaymentRequestReceiveService { id: generateSubId(), requestId, encodedRequest, - state: 'draft', + state: 'active', transport, amount, unit, @@ -190,47 +189,30 @@ export class PaymentRequestReceiveService { }; await this.operationRepository.create(operation); - if (input.activate === false) { - return operation; - } - return this.activate(operation); - } - - async activate( - operationOrId: PaymentRequestReceiveOperation | string, - ): Promise { - const operation = await this.requireOperation(operationOrId); - if (operation.state === 'active') { + try { await this.activateTransport(operation); return operation; + } catch (error) { + const cancelled: PaymentRequestReceiveOperation = { + ...operation, + state: 'cancelled', + error: error instanceof Error ? error.message : String(error), + updatedAt: Date.now(), + }; + await this.operationRepository.update(cancelled); + throw error; } - if (operation.state !== 'draft') { - throw new PaymentRequestError( - `Cannot activate payment request receive operation in state '${operation.state}'`, - ); - } - - const active: PaymentRequestReceiveOperation = { - ...operation, - state: 'active', - updatedAt: Date.now(), - }; - await this.operationRepository.update(active); - await this.activateTransport(active); - return active; } async cancel(operationId: string, reason?: string): Promise { const operation = await this.requireOperation(operationId); - if (operation.state !== 'draft' && operation.state !== 'active') { + if (operation.state !== 'active') { throw new PaymentRequestError( `Cannot cancel payment request receive operation in state '${operation.state}'`, ); } - if (operation.state === 'active') { - await this.deactivateTransport(operation); - } + await this.deactivateTransport(operation); const cancelled: PaymentRequestReceiveOperation = { ...operation, state: 'cancelled', @@ -330,7 +312,7 @@ export class PaymentRequestReceiveService { private async activateTransport(operation: PaymentRequestReceiveOperation): Promise { if (operation.transport === 'inband') return; const handler = this.transportHandlers.get(operation.transport); - if (!handler?.activate) { + if (!handler) { throw new PaymentRequestError( `No payment request receive transport handler registered for '${operation.transport}'`, ); @@ -341,7 +323,7 @@ export class PaymentRequestReceiveService { private async deactivateTransport(operation: PaymentRequestReceiveOperation): Promise { if (operation.transport === 'inband') return; const handler = this.transportHandlers.get(operation.transport); - if (!handler?.deactivate) { + if (!handler) { throw new PaymentRequestError( `No payment request receive transport handler registered for '${operation.transport}'`, ); diff --git a/packages/core/test/unit/PaymentRequestReceiveService.test.ts b/packages/core/test/unit/PaymentRequestReceiveService.test.ts index 86b9198e..76ac8801 100644 --- a/packages/core/test/unit/PaymentRequestReceiveService.test.ts +++ b/packages/core/test/unit/PaymentRequestReceiveService.test.ts @@ -17,9 +17,7 @@ import type { ReceiveOperation, ReceiveOperationSource, } from '../../operations/receive/ReceiveOperation'; -import type { - ParsedPaymentRequestPayload, -} from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; +import type { ParsedPaymentRequestPayload } from '../../operations/paymentRequestReceive/PaymentRequestReceiveOperation'; import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, @@ -145,19 +143,6 @@ describe('PaymentRequestReceiveService', () => { expect(operation.requestId).toBe('request-id'); }); - it('can create draft payment requests when activation is explicitly disabled', async () => { - const operation = await service.create({ - amount: Amount.from(100), - mints: [mintUrl], - requestId: 'request-id', - activate: false, - }); - - expect(operation.state).toBe('draft'); - const active = await service.activate(operation.id); - expect(active.state).toBe('active'); - }); - it('creates and activates Nostr payment requests through a registered handler', async () => { const createRequestTransport = mock(async () => ({ type: PaymentRequestTransportType.NOSTR, @@ -165,10 +150,12 @@ describe('PaymentRequestReceiveService', () => { tags: [['n', '17']], })); const activate = mock(async () => undefined); + const deactivate = mock(async () => undefined); const unregister = service.registerTransportHandler({ type: 'nostr', createRequestTransport, activate, + deactivate, }); const operation = await service.create({ @@ -196,10 +183,43 @@ describe('PaymentRequestReceiveService', () => { unregister(); }); + it('records a cancelled operation when transport activation fails', async () => { + const createRequestTransport = mock(async () => ({ + type: PaymentRequestTransportType.NOSTR, + target: nostrTarget, + })); + const unregister = service.registerTransportHandler({ + type: 'nostr', + createRequestTransport, + activate: mock(async () => { + throw new Error('subscription failed'); + }), + deactivate: mock(async () => undefined), + }); + + await expect( + service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + transport: 'nostr', + }), + ).rejects.toThrow('subscription failed'); + + const cancelled = await operationRepository.list({ state: 'cancelled' }); + expect(cancelled).toHaveLength(1); + expect(cancelled[0]!.requestId).toBe('request-id'); + expect(cancelled[0]!.error).toBe('subscription failed'); + + unregister(); + }); + it('claims a valid payload through a child receive operation', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const result = await service.claimPayload(operation.id, createPayload(), { transport: 'inband', @@ -222,9 +242,11 @@ describe('PaymentRequestReceiveService', () => { }); it('returns the existing finalized attempt for duplicate payload delivery', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const first = await service.claimPayload(operation.id, payload); @@ -235,9 +257,11 @@ describe('PaymentRequestReceiveService', () => { }); it('returns the finalized attempt for duplicate payload ingestion after completion', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const first = await service.ingestPayload(payload); @@ -252,9 +276,11 @@ describe('PaymentRequestReceiveService', () => { }); it('records a rejected attempt for an underpaid payload', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const result = await service.claimPayload( operation.id, @@ -270,9 +296,11 @@ describe('PaymentRequestReceiveService', () => { }); it('blocks new payloads while a single-use request has an in-flight attempt', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const now = Date.now(); await attemptRepository.create({ id: 'attempt-in-flight', @@ -302,20 +330,16 @@ describe('PaymentRequestReceiveService', () => { }); it('rejects a reused transport message id from a different request', async () => { - const firstOperation = await service.activate( - await service.create({ - amount: Amount.from(100), - mints: [mintUrl], - requestId: 'request-id', - }), - ); - const secondOperation = await service.activate( - await service.create({ - amount: Amount.from(100), - mints: [mintUrl], - requestId: 'request-id-2', - }), - ); + const firstOperation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); + const secondOperation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id-2', + }); await service.claimPayload(firstOperation.id, createPayload(), { transport: 'inband', @@ -344,9 +368,11 @@ describe('PaymentRequestReceiveService', () => { }); it('removes interrupted pre-child attempts during recovery so payloads can retry', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { @@ -381,9 +407,11 @@ describe('PaymentRequestReceiveService', () => { }); it('completes single-use parents for already-finalized attempts during recovery', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const now = Date.now(); await attemptRepository.create({ id: 'attempt-finalized', @@ -409,9 +437,11 @@ describe('PaymentRequestReceiveService', () => { }); it('resumes prepared child receive operations during recovery', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { @@ -452,9 +482,11 @@ describe('PaymentRequestReceiveService', () => { }); it('resumes init child receive operations before generic receive cleanup', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { @@ -524,9 +556,11 @@ describe('PaymentRequestReceiveService', () => { }); it('drops receiving attempts with missing child operations so redelivery can retry', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { @@ -560,9 +594,11 @@ describe('PaymentRequestReceiveService', () => { }); it('does not pin validated payloads when child receive init fails', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { @@ -584,9 +620,11 @@ describe('PaymentRequestReceiveService', () => { }); it('rejects permanent child receive init validation failures', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { @@ -605,9 +643,11 @@ describe('PaymentRequestReceiveService', () => { }); it('rejects recovering attempts when prepared child receive execution rolls back', async () => { - const operation = await service.activate( - await service.create({ amount: Amount.from(100), mints: [mintUrl], requestId: 'request-id' }), - ); + const operation = await service.create({ + amount: Amount.from(100), + mints: [mintUrl], + requestId: 'request-id', + }); const payload = createPayload(); const payloadHash = ( service as unknown as { diff --git a/packages/core/test/unit/PaymentRequestsApi.test.ts b/packages/core/test/unit/PaymentRequestsApi.test.ts index d777252b..dbfbb69d 100644 --- a/packages/core/test/unit/PaymentRequestsApi.test.ts +++ b/packages/core/test/unit/PaymentRequestsApi.test.ts @@ -59,7 +59,6 @@ describe('PaymentRequestsApi', () => { } as unknown as PaymentRequestService; incomingService = { create: mock(), - activate: mock(), cancel: mock(), get: mock(), list: mock(), diff --git a/packages/docs/starting/payment-requests.md b/packages/docs/starting/payment-requests.md index 12f4bd4d..56f027bd 100644 --- a/packages/docs/starting/payment-requests.md +++ b/packages/docs/starting/payment-requests.md @@ -19,7 +19,7 @@ console.log('Matching mints:', prepared.payableMints); The returned `ResolvedPaymentRequest` contains: -- **transport** - How to deliver the tokens (`inband` or `http`) +- **transport** - How to deliver the tokens (`inband`, `http`, or `nostr`) - **amount** - The requested amount (optional, but required for payment) - **allowedMints** - List of allowed mints from the request - **payableMints** - Trusted mints with sufficient balance @@ -70,6 +70,66 @@ if (prepared.transport.type === 'http') { } ``` +### Nostr Transport + +Core can parse Nostr payment-request transports, but relay delivery is owned by an +optional transport plugin. Calling `paymentRequests.execute()` for a Nostr request +throws unless the app routes the prepared send through a plugin. + +```ts +const prepared = await coco.paymentRequests.parse(paymentRequest); + +if (prepared.transport.type === 'nostr') { + // Hand this request to the Nostr payment-request plugin. + console.log(prepared.transport.target); +} +``` + +## Creating a Payment Request to Receive + +Incoming payment requests live under `paymentRequests.incoming`. Created requests are +active immediately. Use `cancel()` to stop accepting future payloads; completed and +cancelled requests remain queryable. + +```ts +const request = await coco.paymentRequests.incoming.create({ + amount: 100, + unit: 'sat', + mints: ['https://mint.url'], + description: 'Coffee', + singleUse: true, +}); + +console.log(request.encodedRequest); +``` + +For in-band delivery, receive a `PaymentRequestPayload` from your own transport and +claim it against the request: + +```ts +const result = await coco.paymentRequests.incoming.claimPayload(request.id, payload, { + transport: 'inband', + transportMessageId: messageId, +}); + +console.log(result.operation.state); +``` + +For Nostr delivery, install a Nostr payment-request plugin. The plugin registers the +transport handler that creates the Nostr descriptor, subscribes to relays, decrypts +incoming events, and calls `ingestPayload()`: + +```ts +await coco.paymentRequests.incoming.create({ + amount: 100, + mints: ['https://mint.url'], + transport: 'nostr', +}); +``` + +Core then validates the payload, deduplicates redeliveries, runs the normal receive +operation, and completes the request if it is single-use. + ## Specifying the Amount If the payment request doesn't include an amount, you must provide one: diff --git a/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts b/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts index c13cc0d7..9c799fec 100644 --- a/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/expo-sqlite/src/repositories/PaymentRequestReceiveRepository.ts @@ -213,12 +213,6 @@ export class ExpoPaymentRequestReceiveOperationRepository implements PaymentRequ ); return rows.map(rowToOperation); } - - async delete(id: string): Promise { - await this.db.run('DELETE FROM coco_cashu_payment_request_receive_operations WHERE id = ?', [ - id, - ]); - } } export class ExpoPaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { diff --git a/packages/expo-sqlite/src/schema.ts b/packages/expo-sqlite/src/schema.ts index 438e7eb2..86bdd396 100644 --- a/packages/expo-sqlite/src/schema.ts +++ b/packages/expo-sqlite/src/schema.ts @@ -957,7 +957,7 @@ const MIGRATIONS: readonly Migration[] = [ id TEXT PRIMARY KEY, requestId TEXT, encodedRequest TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('draft', 'active', 'completed', 'cancelled', 'expired')), + state TEXT NOT NULL CHECK (state IN ('active', 'completed', 'cancelled', 'expired')), transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), amount TEXT NOT NULL, unit TEXT NOT NULL, diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index 1534ccb1..497965e8 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -212,7 +212,7 @@ export interface PaymentRequestReceiveOperationRow { id: string; requestId?: string | null; encodedRequest: string; - state: 'draft' | 'active' | 'completed' | 'cancelled' | 'expired'; + state: 'active' | 'completed' | 'cancelled' | 'expired'; transport: 'inband' | 'nostr' | 'post'; amount: string | number; unit: string; diff --git a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts index 47259d8a..4faf5d82 100644 --- a/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/indexeddb/src/repositories/PaymentRequestReceiveRepository.ts @@ -169,16 +169,6 @@ export class IdbPaymentRequestReceiveOperationRepository implements PaymentReque .toArray()) as PaymentRequestReceiveOperationRow[]; return rows.map(rowToOperation); } - - async delete(id: string): Promise { - await this.db.runTransaction( - 'rw', - ['coco_cashu_payment_request_receive_operations'], - async (tx) => { - await tx.table('coco_cashu_payment_request_receive_operations').delete(id); - }, - ); - } } export class IdbPaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { diff --git a/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts b/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts index 12e701a9..a70d4653 100644 --- a/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/sqlite-bun/src/repositories/PaymentRequestReceiveRepository.ts @@ -213,12 +213,6 @@ export class SqlitePaymentRequestReceiveOperationRepository implements PaymentRe ); return rows.map(rowToOperation); } - - async delete(id: string): Promise { - await this.db.run('DELETE FROM coco_cashu_payment_request_receive_operations WHERE id = ?', [ - id, - ]); - } } export class SqlitePaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { diff --git a/packages/sqlite-bun/src/schema.ts b/packages/sqlite-bun/src/schema.ts index 065df54e..b2727e1d 100644 --- a/packages/sqlite-bun/src/schema.ts +++ b/packages/sqlite-bun/src/schema.ts @@ -957,7 +957,7 @@ const MIGRATIONS: readonly Migration[] = [ id TEXT PRIMARY KEY, requestId TEXT, encodedRequest TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('draft', 'active', 'completed', 'cancelled', 'expired')), + state TEXT NOT NULL CHECK (state IN ('active', 'completed', 'cancelled', 'expired')), transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), amount TEXT NOT NULL, unit TEXT NOT NULL, diff --git a/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts b/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts index 12e701a9..a70d4653 100644 --- a/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts +++ b/packages/sqlite3/src/repositories/PaymentRequestReceiveRepository.ts @@ -213,12 +213,6 @@ export class SqlitePaymentRequestReceiveOperationRepository implements PaymentRe ); return rows.map(rowToOperation); } - - async delete(id: string): Promise { - await this.db.run('DELETE FROM coco_cashu_payment_request_receive_operations WHERE id = ?', [ - id, - ]); - } } export class SqlitePaymentRequestReceiveAttemptRepository implements PaymentRequestReceiveAttemptRepository { diff --git a/packages/sqlite3/src/schema.ts b/packages/sqlite3/src/schema.ts index 065df54e..b2727e1d 100644 --- a/packages/sqlite3/src/schema.ts +++ b/packages/sqlite3/src/schema.ts @@ -957,7 +957,7 @@ const MIGRATIONS: readonly Migration[] = [ id TEXT PRIMARY KEY, requestId TEXT, encodedRequest TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('draft', 'active', 'completed', 'cancelled', 'expired')), + state TEXT NOT NULL CHECK (state IN ('active', 'completed', 'cancelled', 'expired')), transport TEXT NOT NULL CHECK (transport IN ('inband', 'nostr', 'post')), amount TEXT NOT NULL, unit TEXT NOT NULL,