From a6cb385102bbb6e4182b26823bb7eedab8fa9fca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 16:26:52 -0700 Subject: [PATCH] wallet-sdk PR5c: spark send + receive ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements §6 (Spark) of the no-cache contract — sub-slice 3c, stacked on PR5b. Replaces PR2's spark stub with real SparkSendOps + SparkReceiveOps, mirroring PR5b's cashu structure, driving each account's live Breez wallet (PR5a). Lifted framework-free from master (dropping the React factories + `measureOperation` telemetry; `~/lib/bolt11`→`./lib-scan`; `~/lib/spark`→the specific `lib/spark/errors` module so the native WASM barrel is never pulled): - SparkSendQuoteService + repository (send/spark-send-quote-{service,repository}.ts): getLightningSendQuote / createSendQuote / initiateSend / complete / fail. initiateSend drives Breez prepareSendPayment+sendPayment with `idempotencyKey: quote.id` (the idempotency keystone) and maps already-paid / insufficient-balance Breez errors to DomainError. - SparkReceiveQuoteService + repository + core (receive/spark-receive-quote-*.ts): getLightningQuote (Breez receivePayment) / createReceiveQuote / complete / expire / fail / markMeltInitiated. - SparkBalanceTracker (shared/spark.ts#useTrackAndUpdateSparkAccountBalances): registers Breez event listeners on online spark accounts and emits `account:updated` with compare-before-emit (the spark domain owns the balance source — Breez getInfo, not a DB trigger). Wired into Sdk.create + torn down in destroy(); started by S5. - Single-source runtime schemas re-exported (lib-spark-quotes.ts) so types/spark.ts can never drift from master's zod schemas. Public surface: spark.send.createLightningQuote / failQuote / get; spark.receive.createLightningQuote / get (ln-address resolved internally via LNURL-pay, matching cashu; listPending stays processor-internal). executeQuote DEFERRED to the PR5d orchestrator sub-slice (NotImplementedError stub), the SAME placement PR5b chose for cashu.send.executeQuote: the unified executeQuote orchestrator absorbs all six master useProcess*Tasks hooks (incl. useProcessSparkSendQuoteTasks) into one framework-free state machine kept as its own reviewable unit (build plan §1/§4). Spark's terminal UNPAID→PENDING→COMPLETED/FAILED transition is driven by the Breez paymentSucceeded/ paymentFailed callback — the same listener substrate the orchestrator owns — so building only spark's drive loop here would duplicate it and split the orchestrator across PRs. PR5c ships the idempotent primitives it sequences. Tests (Breez SDK mocked — no WASM/network): spark quote creation + ln-address fold + executeQuote stub (domains/spark.test.ts); the Breez-driven send (initiateSend idempotency-key + fee-change + error mapping) + state guards (spark-send-quote-service.test.ts); receive create + state guards (spark-receive-quote-service.test.ts); the balance source + account:updated compare-before-emit (spark-balance-tracker.test.ts). Full CI gate green locally: biome format+lint, root `bun --filter='*' run test` (wallet-sdk 237 pass / web-wallet 133 pass), root typecheck (all packages). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-sdk/src/domains/spark.test.ts | 248 ++++++++++++ packages/wallet-sdk/src/domains/spark.ts | 217 +++++++++++ .../src/internal/lib-spark-quotes.ts | 33 ++ packages/wallet-sdk/src/internal/lib-spark.ts | 27 ++ .../internal/spark-balance-tracker.test.ts | 181 +++++++++ .../src/internal/spark-balance-tracker.ts | 161 ++++++++ .../src/internal/spark-receive-quote-core.ts | 266 +++++++++++++ .../spark-receive-quote-repository.ts | 335 ++++++++++++++++ .../spark-receive-quote-service.test.ts | 175 +++++++++ .../internal/spark-receive-quote-service.ts | 200 ++++++++++ .../internal/spark-send-quote-repository.ts | 331 ++++++++++++++++ .../internal/spark-send-quote-service.test.ts | 233 +++++++++++ .../src/internal/spark-send-quote-service.ts | 366 ++++++++++++++++++ packages/wallet-sdk/src/sdk.ts | 56 ++- 14 files changed, 2826 insertions(+), 3 deletions(-) create mode 100644 packages/wallet-sdk/src/domains/spark.test.ts create mode 100644 packages/wallet-sdk/src/domains/spark.ts create mode 100644 packages/wallet-sdk/src/internal/lib-spark-quotes.ts create mode 100644 packages/wallet-sdk/src/internal/lib-spark.ts create mode 100644 packages/wallet-sdk/src/internal/spark-balance-tracker.test.ts create mode 100644 packages/wallet-sdk/src/internal/spark-balance-tracker.ts create mode 100644 packages/wallet-sdk/src/internal/spark-receive-quote-core.ts create mode 100644 packages/wallet-sdk/src/internal/spark-receive-quote-repository.ts create mode 100644 packages/wallet-sdk/src/internal/spark-receive-quote-service.test.ts create mode 100644 packages/wallet-sdk/src/internal/spark-receive-quote-service.ts create mode 100644 packages/wallet-sdk/src/internal/spark-send-quote-repository.ts create mode 100644 packages/wallet-sdk/src/internal/spark-send-quote-service.test.ts create mode 100644 packages/wallet-sdk/src/internal/spark-send-quote-service.ts diff --git a/packages/wallet-sdk/src/domains/spark.test.ts b/packages/wallet-sdk/src/domains/spark.test.ts new file mode 100644 index 000000000..51f7c4155 --- /dev/null +++ b/packages/wallet-sdk/src/domains/spark.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +// Mock the LNURL module so the ln-address fold is exercised without a real HTTP call. +// `getInvoiceFromLud16` returns whatever the test queues (an invoice result or an LNURL error). +let lnurlResult: unknown = { + pr: 'lnbc-resolved', + verify: undefined, + routes: [], +}; +const getInvoiceFromLud16 = mock(async () => lnurlResult); +mock.module('../internal/lib-lnurl', () => ({ + getInvoiceFromLud16, + isLNURLError: (o: unknown) => + typeof o === 'object' && + o !== null && + (o as { status?: string }).status === 'ERROR', +})); + +import type { Currency, Money as MoneyType } from '../types/money'; + +const { SparkSendOpsImpl, SparkReceiveOpsImpl } = await import('./spark'); +const { NotImplementedError, DomainError } = await import('../errors'); +const { Money } = await import('../types/money'); + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): MoneyType => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +const session = { + requireCurrentUser: async () => ({ id: 'u1' }), +} as never; + +const sparkAccount = { + id: 'acc1', + type: 'spark', + currency: 'BTC', + balance: sats(10_000), +} as never; + +/** A send-quote service whose getLightningSendQuote echoes the payment request it was given. */ +function fakeSendQuoteService() { + return { + getLightningSendQuote: mock( + async ({ paymentRequest }: { paymentRequest: string }) => ({ + paymentRequest, + paymentHash: 'ph1', + amountRequested: sats(100), + amountRequestedInBtc: sats(100), + amountToReceive: sats(100), + estimatedLightningFee: sats(1), + estimatedTotalFee: sats(1), + estimatedTotalAmount: sats(101), + paymentRequestIsAmountless: false, + expiresAt: null, + }), + ), + createSendQuote: mock( + async (args: { quote: { paymentRequest: string } }) => ({ + id: 'q1', + state: 'UNPAID', + paymentRequest: args.quote.paymentRequest, + }), + ), + fail: mock(async (_quote: unknown, _reason: string) => ({ + id: 'q1', + state: 'FAILED', + })), + get: mock(async (id: string) => ({ id, state: 'UNPAID' })), + }; +} + +function makeSendOps(sendQuoteService = fakeSendQuoteService()) { + // biome-ignore lint/suspicious/noExplicitAny: minimal service stub for the fold + stub tests. + const ops = new SparkSendOpsImpl(sendQuoteService as any, session); + return { ops, sendQuoteService }; +} + +/** A receive-quote service whose getLightningQuote echoes the description it was given. */ +function fakeReceiveQuoteService() { + return { + getLightningQuote: mock( + async ({ description }: { description?: string }) => ({ + id: 'spark-recv-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + invoice: { + paymentRequest: 'lnbc-invoice', + paymentHash: 'ph1', + amount: sats(100), + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + memo: description, + }, + status: 'PENDING', + }), + ), + createReceiveQuote: mock( + async (args: { + lightningQuote: { invoice: { memo?: string } }; + purpose?: string; + receiveType?: string; + }) => ({ + id: 'rq1', + state: 'UNPAID', + type: 'LIGHTNING', + description: args.lightningQuote.invoice.memo, + }), + ), + get: mock(async (id: string) => ({ id, state: 'UNPAID' })), + }; +} + +function makeReceiveOps(receiveQuoteService = fakeReceiveQuoteService()) { + // biome-ignore lint/suspicious/noExplicitAny: minimal service stub for the create tests. + const ops = new SparkReceiveOpsImpl(receiveQuoteService as any, session); + return { ops, receiveQuoteService }; +} + +afterEach(() => { + getInvoiceFromLud16.mockClear(); + lnurlResult = { pr: 'lnbc-resolved', verify: undefined, routes: [] }; +}); + +// -- Tests ---------------------------------------------------------------------------------- + +describe('SparkSendOps.createLightningQuote — destination resolution', () => { + test('a bolt11 invoice is passed through (no LNURL call)', async () => { + const { ops, sendQuoteService } = makeSendOps(); + + await ops.createLightningQuote({ + account: sparkAccount, + destination: 'lnbc1pjinvoice', + }); + + expect(getInvoiceFromLud16).not.toHaveBeenCalled(); + expect( + sendQuoteService.getLightningSendQuote.mock.calls[0][0].paymentRequest, + ).toBe('lnbc1pjinvoice'); + }); + + test('an ln-address is resolved via LNURL-pay using the amount', async () => { + const { ops, sendQuoteService } = makeSendOps(); + + await ops.createLightningQuote({ + account: sparkAccount, + destination: 'alice@example.com', + amount: sats(100), + }); + + expect(getInvoiceFromLud16).toHaveBeenCalledTimes(1); + expect( + sendQuoteService.getLightningSendQuote.mock.calls[0][0].paymentRequest, + ).toBe('lnbc-resolved'); + }); + + test('an ln-address without an amount is rejected (LNURL needs the amount)', async () => { + const { ops } = makeSendOps(); + await expect( + ops.createLightningQuote({ + account: sparkAccount, + destination: 'alice@example.com', + }), + ).rejects.toBeInstanceOf(DomainError); + expect(getInvoiceFromLud16).not.toHaveBeenCalled(); + }); + + test('an LNURL error surfaces as a DomainError', async () => { + lnurlResult = { status: 'ERROR', reason: 'amount out of range' }; + const { ops } = makeSendOps(); + + await expect( + ops.createLightningQuote({ + account: sparkAccount, + destination: 'alice@example.com', + amount: sats(100), + }), + ).rejects.toThrow(/amount out of range/); + }); +}); + +describe('SparkSendOps.executeQuote — deferred to PR5d', () => { + test('throws NotImplementedError (orchestrator state machine deferred, like cashu)', () => { + const { ops } = makeSendOps(); + expect(() => ops.executeQuote({} as never)).toThrow(NotImplementedError); + }); +}); + +describe('SparkSendOps.failQuote / get', () => { + test('failQuote delegates to the service', async () => { + const { ops, sendQuoteService } = makeSendOps(); + await ops.failQuote({ id: 'q1', state: 'UNPAID' } as never, 'because'); + expect(sendQuoteService.fail).toHaveBeenCalledTimes(1); + expect(sendQuoteService.fail.mock.calls[0][1]).toBe('because'); + }); + + test('get delegates to the service', async () => { + const { ops, sendQuoteService } = makeSendOps(); + const quote = await ops.get('q1'); + expect(sendQuoteService.get).toHaveBeenCalledWith('q1'); + expect((quote as { id: string }).id).toBe('q1'); + }); +}); + +describe('SparkReceiveOps.createLightningQuote', () => { + test('a PAYMENT receive passes the given description and tags LIGHTNING', async () => { + const { ops, receiveQuoteService } = makeReceiveOps(); + + await ops.createLightningQuote({ + account: sparkAccount, + amount: sats(100), + description: 'coffee', + }); + + expect( + receiveQuoteService.getLightningQuote.mock.calls[0][0].description, + ).toBe('coffee'); + expect( + receiveQuoteService.createReceiveQuote.mock.calls[0][0].purpose, + ).toBe('PAYMENT'); + expect( + receiveQuoteService.createReceiveQuote.mock.calls[0][0].receiveType, + ).toBe('LIGHTNING'); + }); + + test("a BUY_CASHAPP receive uses the 'Pay to Agicash' description + purpose", async () => { + const { ops, receiveQuoteService } = makeReceiveOps(); + + await ops.createLightningQuote({ + account: sparkAccount, + amount: sats(100), + purpose: 'BUY_CASHAPP', + }); + + expect( + receiveQuoteService.getLightningQuote.mock.calls[0][0].description, + ).toBe('Pay to Agicash'); + expect( + receiveQuoteService.createReceiveQuote.mock.calls[0][0].purpose, + ).toBe('BUY_CASHAPP'); + }); + + test('get delegates to the service', async () => { + const { ops, receiveQuoteService } = makeReceiveOps(); + await ops.get('rq1'); + expect(receiveQuoteService.get).toHaveBeenCalledWith('rq1'); + }); +}); diff --git a/packages/wallet-sdk/src/domains/spark.ts b/packages/wallet-sdk/src/domains/spark.ts new file mode 100644 index 000000000..5199cd4cb --- /dev/null +++ b/packages/wallet-sdk/src/domains/spark.ts @@ -0,0 +1,217 @@ +/** + * `SparkDomain` implementation — §6 of the contract, Slice 3 / PR5c (spark send + receive ops). + * + * Wires the framework-free spark SERVICE primitives (`internal/spark-*-service.ts`, re-housed + * from `apps/web-wallet/app/features/{send,receive}/*`) into the public `SparkSendOps` / + * `SparkReceiveOps` surface, replacing PR2's `createSparkStub`. The services drive the account's + * LIVE `BreezSdk` handle (PR5a) + the SDK-owned Supabase repos. Mirrors PR5b's `domains/cashu.ts`. + * + * TWO-MODE API rule: `createLightningQuote` is a kickoff (params + the FULL account); + * `failQuote` takes the FULL quote; `get` is a fetch. + * + * **`executeQuote` — DEFERRED to the orchestrator sub-slice (PR5d).** This is the SAME decision + * PR5b made for cashu's `executeQuote`, and for the same reason: the unified `executeQuote` + * orchestrator (the build plan's single biggest net-new construct) absorbs master's six + * React-resident `useProcess*Tasks` hooks — INCLUDING `spark-send-quote-hooks.ts#useProcessSparkSendQuoteTasks` + * — into one framework-free state machine, kept as its own reviewable unit (build plan §1 + §4 + + * risk callout). For spark specifically: `initiateSend` (Breez `sendPayment`) moves a quote + * UNPAID → PENDING, but the TERMINAL transition (→ COMPLETED / FAILED) is driven by the Breez + * `paymentSucceeded` / `paymentFailed` event callback — the same event-listener substrate + * `SparkBalanceTracker` uses and that the orchestrator owns (it also emits `send:completed` / + * `send:failed`). Building only spark's drive loop here would duplicate that substrate and split + * the orchestrator across two PRs. PR5c therefore ships the IDEMPOTENT PRIMITIVES the orchestrator + * sequences (`initiateSend` with `idempotencyKey: quote.id` / `complete` / `fail`) + the balance + * source, and leaves `executeQuote` a documented {@link NotImplementedError} stub — see + * {@link SparkSendOpsImpl.executeQuote}. + * + * @module + */ +import { getInvoiceFromLud16, isLNURLError } from '../internal/lib-lnurl'; +import type { SparkReceiveQuoteService } from '../internal/spark-receive-quote-service'; +import type { SparkSendQuoteService } from '../internal/spark-send-quote-service'; +import type { SparkDomain, SparkReceiveOps, SparkSendOps } from '../domains'; +import { DomainError, NotImplementedError } from '../errors'; +import type { SparkAccount } from '../types/account'; +import type { SessionResolver } from '../internal/session'; +import type { Money } from '../types/money'; +import type { SparkReceiveQuote, SparkSendQuote } from '../types/spark'; + +/** + * Spark SEND operations. Holds the lightning-send service + the session (for the user id on the + * create path). `get` / `failQuote` go through the service (which reads/writes the repo). + */ +export class SparkSendOpsImpl implements SparkSendOps { + constructor( + private readonly sendQuoteService: SparkSendQuoteService, + private readonly session: SessionResolver, + ) {} + + /** + * Create a spark lightning send quote. `destination` is a bolt11 invoice OR a Lightning + * address; an ln-address is resolved internally via LNURL-pay using `amount` (the contract's + * fold, §3 — NOT a scan step). Then delegates to the send service (`getLightningSendQuote` + + * `createSendQuote`). + * + * @param params.account - the FULL spark account to send from (its live Breez wallet is used). + * @param params.destination - a bolt11 invoice or a `user@domain` Lightning address. + * @param params.amount - required for an ln-address / amountless invoice (BTC). + * @returns the created {@link SparkSendQuote} (UNPAID). + */ + async createLightningQuote(params: { + account: SparkAccount; + destination: string; + amount?: Money; + }): Promise { + const user = await this.session.requireCurrentUser(); + const { paymentRequest } = await this.resolveDestination( + params.destination, + params.amount, + ); + + const lightningQuote = await this.sendQuoteService.getLightningSendQuote({ + account: params.account, + paymentRequest, + amount: params.amount as Money<'BTC'> | undefined, + }); + + return this.sendQuoteService.createSendQuote({ + userId: user.id, + account: params.account, + quote: lightningQuote, + }); + } + + /** + * DEFERRED (orchestrator sub-slice, PR5d). `executeQuote` IS the orchestrator — the + * framework-free state machine that drives UNPAID → PENDING → COMPLETED/FAILED off Breez + * `sendPayment` + its `paymentSucceeded` / `paymentFailed` event callback (master has only the + * React `TaskProcessor` + `useProcessSparkSendQuoteTasks`). PR5c ships the idempotent primitives + * it sequences (`initiateSend` with `idempotencyKey: quote.id`, `complete`, `fail`) but NOT the + * drive loop. Throws {@link NotImplementedError}. (Same placement as cashu's `executeQuote`.) + * + * @param _quote - the quote to drive (full object on the kickoff path). + */ + executeQuote(_quote: SparkSendQuote): Promise { + throw new NotImplementedError( + 'spark.send.executeQuote (the orchestrator state machine lands in the PR5d orchestrator sub-slice, with cashu.send.executeQuote; PR5c ships the idempotent spark send primitives it drives)', + ); + } + + /** + * Mark a send quote FAILED (FULL object). Delegates to the service (which guards the state). + * + * @param quote - the FULL quote to fail. + * @param reason - the failure reason. + */ + async failQuote(quote: SparkSendQuote, reason: string): Promise { + await this.sendQuoteService.fail(quote, reason); + } + + /** + * Fetch a spark send quote by id (fetch). + * + * @param quoteId - the quote id. + * @returns the quote, or null. + */ + async get(quoteId: string): Promise { + return this.sendQuoteService.get(quoteId); + } + + /** + * Resolve `destination` to a bolt11 invoice. A `user@domain` Lightning address is resolved via + * LNURL-pay using `amount` (required, BTC); a bolt11 invoice is returned as-is. Mirrors the + * cashu send domain's fold. + */ + private async resolveDestination( + destination: string, + amount?: Money, + ): Promise<{ paymentRequest: string }> { + if (!destination.includes('@')) { + return { paymentRequest: destination }; + } + + if (!amount) { + throw new DomainError('An amount is required to pay a Lightning address'); + } + if (amount.currency !== 'BTC') { + throw new DomainError( + 'A BTC amount is required to pay a Lightning address', + ); + } + + const invoiceResult = await getInvoiceFromLud16( + destination, + amount as Money<'BTC'>, + ); + if (isLNURLError(invoiceResult)) { + throw new DomainError(invoiceResult.reason); + } + return { paymentRequest: invoiceResult.pr }; + } +} + +/** + * Spark RECEIVE operations. Holds the receive-quote service + the session (for the user id on the + * create path). + */ +export class SparkReceiveOpsImpl implements SparkReceiveOps { + constructor( + private readonly receiveQuoteService: SparkReceiveQuoteService, + private readonly session: SessionResolver, + ) {} + + /** + * Create a spark lightning receive quote (an invoice to be paid). `purpose` defaults to + * `'PAYMENT'`; `'BUY_CASHAPP'` is the buy-bitcoin / Cash App flow. Delegates to the receive + * service (`getLightningQuote` via the account's live Breez wallet + `createReceiveQuote`). + * + * @param params.account - the FULL spark account to receive into. + * @param params.amount - the amount to receive. + * @param params.description - an optional invoice description (overridden by the BUY_CASHAPP + * description when `purpose === 'BUY_CASHAPP'`). + * @param params.purpose - `'PAYMENT'` (default) or `'BUY_CASHAPP'`. + * @returns the created {@link SparkReceiveQuote} (UNPAID). + */ + async createLightningQuote(params: { + account: SparkAccount; + amount: Money; + description?: string; + purpose?: 'PAYMENT' | 'BUY_CASHAPP'; + }): Promise { + const user = await this.session.requireCurrentUser(); + const description = + params.purpose === 'BUY_CASHAPP' ? 'Pay to Agicash' : params.description; + + const lightningQuote = await this.receiveQuoteService.getLightningQuote({ + wallet: params.account.wallet, + amount: params.amount, + description, + }); + + return this.receiveQuoteService.createReceiveQuote({ + userId: user.id, + account: params.account, + lightningQuote, + receiveType: 'LIGHTNING', + purpose: params.purpose ?? 'PAYMENT', + }); + } + + /** + * Fetch a spark receive quote by id (fetch). + * + * @param quoteId - the quote id. + * @returns the quote, or null. + */ + async get(quoteId: string): Promise { + return this.receiveQuoteService.get(quoteId); + } +} + +/** The spark domain — `.send` + `.receive`. */ +export class SparkDomainImpl implements SparkDomain { + constructor( + readonly send: SparkSendOps, + readonly receive: SparkReceiveOps, + ) {} +} diff --git a/packages/wallet-sdk/src/internal/lib-spark-quotes.ts b/packages/wallet-sdk/src/internal/lib-spark-quotes.ts new file mode 100644 index 000000000..ee8360946 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-spark-quotes.ts @@ -0,0 +1,33 @@ +/** + * SDK-internal spark quote **runtime schemas** — Slice 3 / PR5c (spark send + receive). + * + * The spark send/receive repositories (PR5c) parse a decrypted DB row back to the domain type + * with `SparkSendQuoteSchema.parse` / `SparkReceiveQuoteSchema.parse`, and parse the encrypted + * `jsonb` blob with the `agicash-db/json-models` `*DbDataSchema` validators. The package's + * `types/spark.ts` ships the matching `z.infer` TS types as the public surface; these are the + * runtime validators behind them, lifted SINGLE-SOURCE so the shapes can never drift. + * + * Re-housing approach (matches `./lib-cashu-quotes`): re-export the single live source from the + * SPECIFIC `apps/web-wallet/app/...` modules via a relative path so there is exactly ONE + * implementation (no duplication, no web churn). We import from the specific quote-schema + + * json-model files (NOT a barrel) so nothing heavier is pulled. None of these symbols + * transitively pulls react / @tanstack / the native Breez WASM (verified): the master + * spark-quote-schema + spark-lightning json-model files are pure `zod/mini` + `Money` + * (`SparkReceiveQuoteSchema` additionally pulls the cashu `CashuTokenMeltDataSchema`, also + * pure zod/mini). The `~/*` path alias (mapped in the package `tsconfig.json`) lets the + * re-exported schema files resolve their own `~/lib/money` imports — the same single-source + * seam PR5b's `lib-cashu-quotes` uses. The canonical relocation of these schema files INTO the + * package is a deferred follow-up (out of the build-plan's scope). + * + * @module + */ + +// --- runtime domain schemas (send/receive spark-*-quote.ts) ------------------------------ +export { SparkSendQuoteSchema } from '../../../../apps/web-wallet/app/features/send/spark-send-quote'; +export { SparkReceiveQuoteSchema } from '../../../../apps/web-wallet/app/features/receive/spark-receive-quote'; + +// --- encrypted-jsonb DB-data schemas (agicash-db/json-models) ---------------------------- +export { + SparkLightningSendDbDataSchema, + SparkLightningReceiveDbDataSchema, +} from '../../../../apps/web-wallet/app/features/agicash-db/json-models'; diff --git a/packages/wallet-sdk/src/internal/lib-spark.ts b/packages/wallet-sdk/src/internal/lib-spark.ts new file mode 100644 index 000000000..2585f4538 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-spark.ts @@ -0,0 +1,27 @@ +/** + * SDK-internal spark error-classification primitives — Slice 3 / PR5c (spark send + receive). + * + * The spark send service distinguishes two Breez `sendPayment` failure modes — an + * already-paid invoice (`isInvoiceAlreadyPaidError`) and an insufficient-balance error + * (`isInsufficentBalanceError`) — to surface a precise {@link DomainError} instead of the + * raw Breez message. Master imports these from the `~/lib/spark` barrel. + * + * Re-housing approach (matches `./lib-scan` / `./lib-cashu` / `./lib-lnurl`): re-export the + * single live source via a relative path so there is exactly ONE implementation (no + * duplication, no web churn). + * + * **IMPORTANT — import from the SPECIFIC `lib/spark/errors` module, NOT the `lib/spark` + * barrel.** The barrel (`app/lib/spark/index.ts`) re-exports `./wasm`, whose top-level + * `import initBreezWasm from '@agicash/breez-sdk-spark'` pulls the native/WASM package at + * module-eval — exactly what the framework-free + no-WASM-in-tests constraint forbids. These + * two error helpers live in `app/lib/spark/errors.ts`, which is pure (`instanceof Error` + a + * lower-cased message substring check) and pulls nothing. (Same discipline `lib-cashu-quotes` + * uses to avoid the mint-WS subscription managers in the `lib/cashu` barrel.) + * + * @module + */ + +export { + isInsufficentBalanceError, + isInvoiceAlreadyPaidError, +} from '../../../../apps/web-wallet/app/lib/spark/errors'; diff --git a/packages/wallet-sdk/src/internal/spark-balance-tracker.test.ts b/packages/wallet-sdk/src/internal/spark-balance-tracker.test.ts new file mode 100644 index 000000000..e83ba1f34 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-balance-tracker.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { SparkBalanceTracker } from './spark-balance-tracker'; +import { TypedEventEmitter } from './event-emitter'; +import type { SparkAccount } from '../types/account'; +import { type Currency, Money } from '../types/money'; +import type { SdkEventMap } from '../events'; + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +/** + * A mock spark account whose live Breez `wallet` records the registered `onEvent` callback (so a + * test can fire Breez events at it), serves a queued `getInfo` balance, and tracks listener + * add/remove. `fire(type)` invokes the registered callback synchronously. + */ +function fakeSparkAccount( + id: string, + initialBalance: Money | null, +): { + account: SparkAccount; + fire: (type: string) => void; + setBalanceSats: (n: number) => void; + removed: () => string[]; +} { + let onEvent: ((event: { type: string }) => void) | undefined; + let balanceSats = initialBalance ? initialBalance.toNumber('sat') : 0; + const removedIds: string[] = []; + + const account = { + id, + type: 'spark', + currency: 'BTC', + balance: initialBalance, + wallet: { + addEventListener: mock( + async (listener: { onEvent: (event: { type: string }) => void }) => { + onEvent = listener.onEvent; + return `listener-${id}`; + }, + ), + removeEventListener: mock(async (listenerId: string) => { + removedIds.push(listenerId); + }), + getInfo: mock(async (_args: unknown) => ({ balanceSats })), + }, + } as unknown as SparkAccount; + + return { + account, + fire: (type: string) => onEvent?.({ type }), + setBalanceSats: (n: number) => { + balanceSats = n; + }, + removed: () => removedIds, + }; +} + +/** Wait for the tracker's `getInfo().then(...)` microtask chain to settle. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// -- Tests ---------------------------------------------------------------------------------- + +describe('SparkBalanceTracker', () => { + test('emits account:updated on a balance-relevant Breez event with the fresh balance', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const updates: SdkEventMap['account:updated'][] = []; + events.on('account:updated', (e) => updates.push(e)); + + const { account, fire, setBalanceSats } = fakeSparkAccount('acc1', sats(0)); + tracker.track([account]); + + setBalanceSats(5000); + fire('paymentSucceeded'); + await flush(); + + expect(updates).toHaveLength(1); + expect(updates[0].op).toBe('updated'); + expect(updates[0].account.id).toBe('acc1'); + expect((updates[0].account as SparkAccount).balance?.toNumber('sat')).toBe( + 5000, + ); + }); + + test('ignores Breez events that cannot move the balance (no getInfo, no emit)', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const updates: unknown[] = []; + events.on('account:updated', (e) => updates.push(e)); + + const { account, fire } = fakeSparkAccount('acc1', sats(0)); + tracker.track([account]); + + // not in the balance-relevant set + fire('logEntry'); + fire('someUnrelatedEvent'); + await flush(); + + expect(updates).toHaveLength(0); + expect( + (account.wallet.getInfo as ReturnType).mock.calls, + ).toHaveLength(0); + }); + + test('compare-before-emit: an event that does not change the balance is not forwarded', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const updates: unknown[] = []; + events.on('account:updated', (e) => updates.push(e)); + + // baseline balance is 5000; the wallet still reports 5000 after the event. + const { account, fire } = fakeSparkAccount('acc1', sats(5000)); + tracker.track([account]); + + fire('synced'); + await flush(); + + expect(updates).toHaveLength(0); + }); + + test('emits once, then suppresses a repeat at the same (new) balance', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const updates: unknown[] = []; + events.on('account:updated', (e) => updates.push(e)); + + const { account, fire, setBalanceSats } = fakeSparkAccount('acc1', sats(0)); + tracker.track([account]); + + setBalanceSats(7000); + fire('claimedDeposits'); + await flush(); + // second event, same 7000 balance -> no second emit + fire('synced'); + await flush(); + + expect(updates).toHaveLength(1); + }); + + test('registers exactly one Breez listener per account even if track is called again', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const { account } = fakeSparkAccount('acc1', sats(0)); + + tracker.track([account]); + tracker.track([account]); + + expect( + (account.wallet.addEventListener as ReturnType).mock.calls, + ).toHaveLength(1); + }); + + test('stop() removes the Breez listeners', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const { account, removed } = fakeSparkAccount('acc1', sats(0)); + + tracker.track([account]); + await flush(); // let addEventListener resolve + tracker.stop(); + await flush(); // let removeEventListener resolve + + expect(removed()).toEqual(['listener-acc1']); + }); + + test('untracks an account no longer in the tracked set', async () => { + const events = new TypedEventEmitter(); + const tracker = new SparkBalanceTracker(events); + const a = fakeSparkAccount('acc1', sats(0)); + const b = fakeSparkAccount('acc2', sats(0)); + + tracker.track([a.account, b.account]); + await flush(); + // acc1 drops out + tracker.track([b.account]); + await flush(); + + expect(a.removed()).toEqual(['listener-acc1']); + expect(b.removed()).toEqual([]); + }); +}); diff --git a/packages/wallet-sdk/src/internal/spark-balance-tracker.ts b/packages/wallet-sdk/src/internal/spark-balance-tracker.ts new file mode 100644 index 000000000..ffa7f6eef --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-balance-tracker.ts @@ -0,0 +1,161 @@ +/** + * Spark account balance source — Slice 3 / PR5c (spark send + receive). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/shared/spark.ts#useTrackAndUpdateSparkAccountBalances`. + * + * Per the contract (§6) a spark account's balance is NOT sourced from a Supabase DB trigger + * (the way cashu balances are): it comes from the Breez SDK's OWN event listener + * (`paymentSucceeded` / `paymentPending` / `paymentFailed` / `claimedDeposits` / `synced`) + + * `getInfo().balanceSats`. The spark domain OWNS that source. Master's hook updates the TanStack + * accounts cache on each relevant event; the framework-free, no-cache SDK instead EMITS an + * `account:updated` event — with **compare-before-emit** so an event that does not actually move + * the balance is not forwarded (master writes the cache unconditionally; the SDK's event surface + * is noisier without the guard, so it dedupes). + * + * Re-housing vs master: + * - the React `useEffect` listener-registration/cleanup → explicit {@link SparkBalanceTracker.track} + * (register) + {@link SparkBalanceTracker.stop} (remove all) lifecycle, driven by the SDK (the + * background/realtime slice, S5, calls `track` with the online spark accounts; PR5c ships the + * mechanism + the `account:updated` emission); + * - `accountCache.updateSparkAccountBalance(...)` → `events.emit('account:updated', …)`; + * - the `SdkEvent` type is imported TYPE-ONLY from `@agicash/breez-sdk-spark` (erased, NO WASM); + * - `sparkDebugLog` (the feature-flag debug gate) is dropped (no feature-flag subsystem, §3). + * + * The set of balance-relevant Breez event types is master-verbatim. + * + * @module + */ +import type { SdkEvent } from '@agicash/breez-sdk-spark'; +import type { TypedEventEmitter } from './event-emitter'; +import { Money } from '../types/money'; +import type { SparkAccount } from '../types/account'; +import type { SdkEventMap } from '../events'; + +/** + * Breez event types that can move a spark account's balance (master verbatim). On any of these + * the tracker re-reads `getInfo()` and (if the balance changed) emits `account:updated`. + */ +const BALANCE_RELEVANT_EVENTS = new Set([ + 'paymentSucceeded', + 'paymentPending', + 'paymentFailed', + 'claimedDeposits', + 'synced', +]); + +/** A registered listener: the wallet it is on + the (resolved) Breez listener id, for cleanup. */ +type Registration = { + wallet: SparkAccount['wallet']; + listenerId: Promise; +}; + +/** + * Tracks live spark account balances off the Breez SDK's event stream and forwards balance + * changes to the SDK event emitter as `account:updated` (compare-before-emit). One instance per + * SDK; held so `Sdk.destroy()` / `background.stop()` can remove the listeners. + */ +export class SparkBalanceTracker { + /** accountId -> its Breez listener registration. */ + private readonly registrations = new Map(); + /** accountId -> the last balance we emitted for (the compare-before-emit baseline). */ + private readonly lastBalances = new Map(); + + constructor(private readonly events: TypedEventEmitter) {} + + /** + * Register Breez balance listeners for the given online spark accounts. Idempotent per account + * (an account already tracked is skipped); accounts no longer in `accounts` are untracked. The + * passed accounts are the compare-before-emit baseline (their current `balance`). + * + * @param accounts - the online spark accounts to track (their live `wallet` is used). + */ + track(accounts: SparkAccount[]): void { + const incomingIds = new Set(accounts.map((a) => a.id)); + + // Drop listeners for accounts that are no longer tracked. + for (const id of [...this.registrations.keys()]) { + if (!incomingIds.has(id)) { + this.untrack(id); + } + } + + for (const account of accounts) { + if (this.registrations.has(account.id)) { + // Refresh the baseline (the account object may carry a newer balance) but keep the + // single existing listener. + this.lastBalances.set(account.id, account.balance); + continue; + } + this.register(account); + } + } + + /** Register one account's Breez listener (and seed its compare baseline). */ + private register(account: SparkAccount): void { + this.lastBalances.set(account.id, account.balance); + + const listenerId = account.wallet.addEventListener({ + onEvent: (event: SdkEvent) => { + if (!BALANCE_RELEVANT_EVENTS.has(event.type)) { + return; + } + // Re-read the authoritative balance from the wallet; emit only if it changed. + account.wallet + .getInfo({}) + .then((info) => { + const balance = new Money({ + amount: info.balanceSats, + currency: 'BTC', + unit: 'sat', + }) as Money; + this.emitIfChanged(account, balance); + }) + .catch((error) => { + console.warn('Failed to read spark balance after Breez event', { + accountId: account.id, + cause: error, + }); + }); + }, + }); + + this.registrations.set(account.id, { wallet: account.wallet, listenerId }); + } + + /** + * Emit `account:updated` iff `balance` differs from the last emitted balance for this account + * (compare-before-emit). The emitted account carries the fresh balance. + */ + private emitIfChanged(account: SparkAccount, balance: Money): void { + const previous = this.lastBalances.get(account.id); + if (previous?.equals(balance)) { + return; + } + this.lastBalances.set(account.id, balance); + const updated: SparkAccount = { ...account, balance }; + this.events.emit('account:updated', { account: updated, op: 'updated' }); + } + + /** Remove one account's Breez listener. */ + private untrack(accountId: string): void { + const registration = this.registrations.get(accountId); + if (!registration) { + return; + } + this.registrations.delete(accountId); + this.lastBalances.delete(accountId); + registration.listenerId + .then((id) => registration.wallet.removeEventListener(id)) + .catch(() => { + console.warn('Failed to remove Spark event listener', { accountId }); + }); + } + + /** Remove ALL Breez listeners (called on `background.stop()` / `Sdk.destroy()`). */ + stop(): void { + for (const id of [...this.registrations.keys()]) { + this.untrack(id); + } + } +} diff --git a/packages/wallet-sdk/src/internal/spark-receive-quote-core.ts b/packages/wallet-sdk/src/internal/spark-receive-quote-core.ts new file mode 100644 index 000000000..b42435bf1 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-receive-quote-core.ts @@ -0,0 +1,266 @@ +/** + * Spark receive-quote CORE — Slice 3 / PR5c (framework-free). + * + * EXTRACTED VERBATIM (logic) from + * `apps/web-wallet/app/features/receive/spark-receive-quote-core.ts`. This module is already + * framework-free in master (pure `@agicash/breez-sdk-spark` (TYPES only) + `@cashu/cashu-ts` + * (`Proof` type) + `Money` + `parseBolt11Invoice`) — re-housed here with the SDK-internal + * imports. It owns: + * - {@link getLightningQuote} — creates a Spark Lightning invoice via Breez `receivePayment`; + * - {@link computeQuoteExpiry} / {@link getAmountAndFee} — quote bookkeeping; + * - the {@link CreateQuoteBaseParams} / {@link RepositoryCreateQuoteParams} param shapes. + * + * Re-housing vs master: + * - `parseBolt11Invoice` comes from `./lib-scan` (the SDK-internal bolt11 seam), not `~/lib/bolt11`; + * - `measureOperation` telemetry around the Breez call is dropped (§3 — same as `spark-wallet.ts`); + * - the `BreezSdk` / `LightningReceiveStatus` types are imported TYPE-ONLY from + * `@agicash/breez-sdk-spark` (erased, NO WASM load) — the live wallet is passed in by the + * caller (PR5a's resolved Breez handle), so this module never imports the native runtime. + * + * @module + */ +import type { + BreezSdk, + LightningReceiveStatus, +} from '@agicash/breez-sdk-spark'; +import type { Proof } from '@cashu/cashu-ts'; +import { parseBolt11Invoice } from './lib-scan'; +import type { SparkAccount } from '../types/account'; +import { Money } from '../types/money'; + +/** The Spark lightning receive quote returned before the quote is persisted (master verbatim). */ +export type SparkReceiveLightningQuote = { + /** + * The unique identifier of this entity across all Lightspark systems. Should be treated as an + * opaque string. + */ + id: string; + /** The date and time when the entity was first created. */ + createdAt: string; + /** The date and time when the entity was last updated. */ + updatedAt: string; + /** The lightning invoice generated to receive lightning payment. */ + invoice: { + paymentRequest: string; + paymentHash: string; + amount: Money<'BTC'>; + createdAt: string; + expiresAt: string; + memo?: string; + }; + /** The status of the request. */ + status: LightningReceiveStatus; + /** The receiver's identity public key if different from owner of the request. */ + receiverIdentityPublicKey?: string; +}; + +/** Params for {@link getLightningQuote} (master verbatim). */ +export type GetLightningQuoteParams = { + /** The Spark wallet (live Breez handle) to create the invoice with. */ + wallet: BreezSdk; + /** The amount to receive. */ + amount: Money; + /** + * The Spark public key of the receiver, used to create invoices on behalf of another user. + * If provided, the incoming payment can only be claimed by the Spark wallet that controls the + * specified public key. If not provided, the invoice is created for the wallet's own owner. + */ + receiverIdentityPubkey?: string; + /** The description of the receive request (BOLT11 `d` tag). */ + description?: string; + /** + * Hex-encoded SHA-256 commitment to a description (BOLT11 `h` tag). When set, Breez SDK emits + * `h` only and drops `description`. + */ + descriptionHash?: string; +}; + +/** Params for creating a spark receive quote in the service (master `CreateQuoteBaseParams`). */ +export type CreateQuoteBaseParams = { + /** The user ID. */ + userId: string; + /** The Spark account to create the receive request for. */ + account: SparkAccount; + /** The lightning quote to create the Spark receive quote from. */ + lightningQuote: SparkReceiveLightningQuote; + /** + * The purpose of this transaction (e.g. a Cash App buy or an internal transfer). When not + * provided, the transaction is created with PAYMENT purpose. + */ + purpose?: string; + /** UUID linking paired send/receive transactions in a transfer. */ + transferId?: string; +} & ( + | { + /** Standard lightning receive. */ + receiveType: 'LIGHTNING'; + } + | { + /** Receive cashu tokens to a Spark account (melt-then-pay). */ + receiveType: 'CASHU_TOKEN'; + /** The amount of the token to receive. */ + tokenAmount: Money; + /** URL of the source mint where the token proofs originate from. */ + sourceMintUrl: string; + /** The proofs from the source cashu token that will be melted. */ + tokenProofs: Proof[]; + /** ID of the melt quote on the source mint. */ + meltQuoteId: string; + /** The expiry of the melt quote in ISO 8601 format. */ + meltQuoteExpiresAt: string; + /** The fee (in the token's unit) incurred for spending the proofs as melt inputs. */ + cashuReceiveFee: Money; + /** The fee reserved for the lightning payment to melt the token proofs to this account. */ + lightningFeeReserve: Money; + } +); + +/** Params for creating a spark receive quote in the repository (master `RepositoryCreateQuoteParams`). */ +export type RepositoryCreateQuoteParams = { + /** ID of the receiving user. */ + userId: string; + /** ID of the receiving account. */ + accountId: string; + /** Amount of the quote. */ + amount: Money; + /** Lightning payment request. */ + paymentRequest: string; + /** Payment hash of the lightning invoice. */ + paymentHash: string; + /** Expiry of the quote in ISO 8601 format. */ + expiresAt: string; + /** Description of the quote. */ + description?: string; + /** ID of the receive request in the Spark system. */ + sparkId: string; + /** Optional public key of the wallet receiving the lightning invoice. */ + receiverIdentityPubkey?: string; + /** Total fee for the receive. */ + totalFee: Money; + /** + * The purpose of this transaction (e.g. a Cash App buy or an internal transfer). When not + * provided, the transaction is created with PAYMENT purpose. + */ + purpose?: string; + /** UUID linking paired send/receive transactions in a transfer. */ + transferId?: string; +} & ( + | { + /** Standard lightning receive. */ + receiveType: 'LIGHTNING'; + } + | { + /** Receive cashu tokens to a Spark account. */ + receiveType: 'CASHU_TOKEN'; + /** The data for the melt operation. */ + meltData: { + /** URL of the source mint where the token proofs originate from. */ + tokenMintUrl: string; + /** ID of the melt quote on the source mint. */ + meltQuoteId: string; + /** The amount of the token to receive. */ + tokenAmount: Money; + /** The proofs from the source cashu token that will be melted. */ + tokenProofs: Proof[]; + /** The fee paid for spending the token proofs as melt inputs. */ + cashuReceiveFee: Money; + /** The fee reserved for the lightning payment to melt the token proofs to this account. */ + lightningFeeReserve: Money; + }; + } +); + +/** + * Get a Breez SDK lightning receive quote for the given amount (master verbatim). Calls Breez + * `receivePayment` to mint a bolt11 invoice, parses it, and returns the quote + invoice data. + * + * @param params - the live wallet, amount, optional receiver pubkey / description / description hash. + * @returns the Spark lightning receive quote. + */ +export async function getLightningQuote({ + wallet, + amount, + receiverIdentityPubkey, + description, + descriptionHash, +}: GetLightningQuoteParams): Promise { + const response = await wallet.receivePayment({ + paymentMethod: { + type: 'bolt11Invoice', + description: description ?? '', + amountSats: amount.toNumber('sat'), + receiverIdentityPubkey, + descriptionHash, + }, + }); + + const bolt11 = parseBolt11Invoice(response.paymentRequest); + if (!bolt11.valid) { + throw new Error('Breez SDK returned an invalid bolt11 invoice'); + } + if (!response.lightningReceiveDetails) { + throw new Error( + 'Breez SDK did not return lightningReceiveDetails for a lightning receive', + ); + } + + const invoice = bolt11.decoded; + const invoiceAmount = invoice.amountMsat + ? new Money({ amount: invoice.amountMsat, currency: 'BTC', unit: 'msat' }) + : (amount as Money<'BTC'>); + const { receiveRequestId, status, createdAt, updatedAt } = + response.lightningReceiveDetails; + + return { + id: receiveRequestId, + createdAt: new Date(createdAt * 1000).toISOString(), + updatedAt: new Date(updatedAt * 1000).toISOString(), + invoice: { + paymentRequest: response.paymentRequest, + paymentHash: invoice.paymentHash, + amount: invoiceAmount, + createdAt: new Date(invoice.createdAtUnixMs).toISOString(), + expiresAt: new Date(invoice.expiryUnixMs).toISOString(), + memo: description, + }, + status, + receiverIdentityPublicKey: receiverIdentityPubkey, + }; +} + +/** + * Compute a receive quote's expiry (master verbatim). LIGHTNING = the lightning invoice expiry; + * CASHU_TOKEN = the earlier of the lightning + melt quote expiry. + */ +export function computeQuoteExpiry(params: CreateQuoteBaseParams): string { + if (params.receiveType === 'LIGHTNING') { + return params.lightningQuote.invoice.expiresAt; + } + + return new Date( + Math.min( + new Date(params.lightningQuote.invoice.expiresAt).getTime(), + new Date(params.meltQuoteExpiresAt).getTime(), + ), + ).toISOString(); +} + +/** + * Get the amount and total fee for a receive quote (master verbatim). LIGHTNING = zero fee; + * CASHU_TOKEN = cashu receive fee + lightning fee reserve. + */ +export function getAmountAndFee(params: CreateQuoteBaseParams): { + amount: Money; + totalFee: Money; +} { + const amount = params.lightningQuote.invoice.amount as Money; + + if (params.receiveType === 'LIGHTNING') { + return { amount, totalFee: Money.zero(amount.currency) }; + } + + return { + amount, + totalFee: params.cashuReceiveFee.add(params.lightningFeeReserve), + }; +} diff --git a/packages/wallet-sdk/src/internal/spark-receive-quote-repository.ts b/packages/wallet-sdk/src/internal/spark-receive-quote-repository.ts new file mode 100644 index 000000000..29ff6ec36 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-receive-quote-repository.ts @@ -0,0 +1,335 @@ +/** + * Internal `wallet.spark_receive_quotes` repository — Slice 3 / PR5c (spark lightning receive). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/receive/spark-receive-quote-repository.ts`. Master expresses + * this over a React-hook-constructed repo wired to the module-global `agicashDbClient` + + * `useEncryption`; here it is a plain class over the SDK-owned Supabase client + the SDK's + * {@link Encryption} (both injected). The RPCs (`create_spark_receive_quote` / + * `complete_spark_receive_quote` / `expire_…` / `fail_…` / + * `mark_spark_receive_quote_cashu_token_melt_initiated`) are unchanged. + * + * Re-housing details match `spark-send-quote-repository.ts`: single-source schemas from + * `./lib-spark-quotes`; hand-written {@link AgicashDbSparkReceiveQuote} row; the untyped `rpc` + * wrapper; master's `satisfies AllUnionFieldsRequired<…>` dropped (the runtime `.parse()` keeps + * the invariant check — e.g. `tokenReceiveData` present when type is CASHU_TOKEN), result cast + * `as SparkReceiveQuote`. NOTE the spark receive repos do NOT return an `Account` (unlike the + * cashu receive repos): a spark receive credits the Breez-held balance, not DB-stored proofs, so + * there is no account row to re-map here. + * + * @module + */ +import type { z } from 'zod/mini'; +import { + SparkLightningReceiveDbDataSchema, + SparkReceiveQuoteSchema, +} from './lib-spark-quotes'; +import type { RepositoryCreateQuoteParams } from './spark-receive-quote-core'; +import type { WalletSupabaseClient } from './supabase-client'; +import type { Encryption } from './encryption'; +import type { SparkReceiveQuote } from '../types/spark'; + +/** Per-call query options (an abort signal, matching master). */ +type Options = { abortSignal?: AbortSignal }; + +/** + * A row of the `wallet.spark_receive_quotes` table (hand-written; master = generated + * `database.types`). Only the columns `toQuote` reads are typed; `encrypted_data` is the + * ciphertext jsonb the repo decrypts. + */ +export type AgicashDbSparkReceiveQuote = { + id: string; + created_at: string; + expires_at: string; + user_id: string; + account_id: string; + transaction_id: string; + payment_hash: string; + encrypted_data: string; + spark_id: string; + spark_transfer_id: string | null; + receiver_identity_pubkey: string | null; + cashu_token_melt_initiated: boolean | null; + version: number; + type: SparkReceiveQuote['type']; + state: SparkReceiveQuote['state']; + failure_reason?: string | null; +}; + +/** Params for {@link SparkReceiveQuoteRepository.create} (master `RepositoryCreateQuoteParams`). */ +type CreateQuoteParams = RepositoryCreateQuoteParams; + +/** + * Reads + writes for the `wallet.spark_receive_quotes` table, scoped (via RLS) to the signed-in + * user. Holds the SDK-owned Supabase client + the SDK {@link Encryption}. + */ +export class SparkReceiveQuoteRepository { + constructor( + private readonly db: WalletSupabaseClient, + private readonly encryption: Encryption, + ) {} + + /** Create a spark receive quote (RPC `create_spark_receive_quote`). Master verbatim. */ + async create( + params: CreateQuoteParams, + options?: Options, + ): Promise { + const { + userId, + accountId, + amount, + paymentRequest, + paymentHash, + expiresAt, + sparkId, + receiverIdentityPubkey, + receiveType, + description, + totalFee, + } = params; + + const receiveData = SparkLightningReceiveDbDataSchema.parse({ + paymentRequest, + amountReceived: amount, + description, + cashuTokenMeltData: + receiveType === 'CASHU_TOKEN' ? params.meltData : undefined, + totalFee, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(receiveData); + + const { data, error } = await this.rpc( + 'create_spark_receive_quote', + { + p_user_id: userId, + p_account_id: accountId, + p_currency: amount.currency, + p_payment_hash: paymentHash, + p_expires_at: expiresAt, + p_spark_id: sparkId, + p_receiver_identity_pubkey: receiverIdentityPubkey ?? null, + p_receive_type: receiveType, + p_encrypted_data: encryptedData, + p_purpose: params.purpose, + p_transfer_id: params.transferId, + }, + options, + ); + + if (error) { + throw new Error('Failed to create spark receive quote', { cause: error }); + } + + return this.toQuote(data); + } + + /** Complete a spark receive quote (RPC `complete_spark_receive_quote`). Master verbatim. */ + async complete( + { + quote, + paymentPreimage, + sparkTransferId, + }: { + quote: SparkReceiveQuote; + paymentPreimage: string; + sparkTransferId: string; + }, + options?: Options, + ): Promise { + const cashuTokenMeltData = + quote.type === 'CASHU_TOKEN' + ? { + tokenMintUrl: quote.tokenReceiveData.sourceMintUrl, + meltQuoteId: quote.tokenReceiveData.meltQuoteId, + tokenAmount: quote.tokenReceiveData.tokenAmount, + tokenProofs: quote.tokenReceiveData.tokenProofs, + cashuReceiveFee: quote.tokenReceiveData.cashuReceiveFee, + lightningFeeReserve: quote.tokenReceiveData.lightningFeeReserve, + } + : undefined; + + const receiveData = SparkLightningReceiveDbDataSchema.parse({ + paymentRequest: quote.paymentRequest, + amountReceived: quote.amount, + description: quote.description, + cashuTokenMeltData, + totalFee: quote.totalFee, + paymentPreimage, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(receiveData); + + const { data, error } = await this.rpc( + 'complete_spark_receive_quote', + { + p_quote_id: quote.id, + p_spark_transfer_id: sparkTransferId, + p_encrypted_data: encryptedData, + }, + options, + ); + + if (error) { + throw new Error('Failed to complete spark receive quote', { + cause: error, + }); + } + + return this.toQuote(data); + } + + /** Expire a spark receive quote (RPC `expire_spark_receive_quote`). Master verbatim. */ + async expire(quoteId: string, options?: Options): Promise { + const { data, error } = await this.rpc( + 'expire_spark_receive_quote', + { p_quote_id: quoteId }, + options, + ); + + if (error) { + throw new Error('Failed to expire spark receive quote', { cause: error }); + } + + return this.toQuote(data); + } + + /** Fail a spark receive quote (RPC `fail_spark_receive_quote`). Master verbatim. */ + async fail( + { id, reason }: { id: string; reason: string }, + options?: Options, + ): Promise { + const { error } = await this.rpc( + 'fail_spark_receive_quote', + { p_quote_id: id, p_failure_reason: reason }, + options, + ); + + if (error) { + throw new Error('Failed to fail spark receive quote', { cause: error }); + } + } + + /** + * Mark the melt initiated for a CASHU_TOKEN spark receive quote (RPC + * `mark_spark_receive_quote_cashu_token_melt_initiated`). Master verbatim. + */ + async markMeltInitiated( + quote: SparkReceiveQuote & { type: 'CASHU_TOKEN' }, + options?: Options, + ): Promise { + const { data, error } = await this.rpc( + 'mark_spark_receive_quote_cashu_token_melt_initiated', + { p_quote_id: quote.id }, + options, + ); + + if (error) { + throw new Error('Failed to mark melt initiated for spark receive quote', { + cause: error, + }); + } + + const updatedQuote = await this.toQuote(data); + return updatedQuote as SparkReceiveQuote & { type: 'CASHU_TOKEN' }; + } + + /** Get the spark receive quote with the given id, or null. Master verbatim. */ + async get(id: string, options?: Options): Promise { + let query = this.db.from('spark_receive_quotes').select().eq('id', id); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = + await query.maybeSingle(); + if (error) { + throw new Error('Failed to get spark receive quote', { cause: error }); + } + return data ? this.toQuote(data) : null; + } + + /** + * Get all pending (UNPAID) spark receive quotes for the user. INTERNAL — feeds the future + * orchestrator's resume sweep (the public interface has no `listPending`). Master verbatim. + */ + async getPending( + userId: string, + options?: Options, + ): Promise { + let query = this.db + .from('spark_receive_quotes') + .select() + .eq('user_id', userId) + .eq('state', 'UNPAID'); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = await query.returns(); + if (error) { + throw new Error('Failed to get spark receive quotes', { cause: error }); + } + return Promise.all(data.map((row) => this.toQuote(row))); + } + + /** Map a DB row to the domain {@link SparkReceiveQuote}. Master verbatim. */ + async toQuote(data: AgicashDbSparkReceiveQuote): Promise { + const decryptedData = await this.encryption.decrypt(data.encrypted_data); + const receiveData = SparkLightningReceiveDbDataSchema.parse(decryptedData); + + // The runtime `.parse()` does the spark-receive-quote invariant check (e.g. it makes sure + // tokenReceiveData is present when type is CASHU_TOKEN, etc.). Cast to the domain type + // matches the cashu repos' `toQuote` approach. + return SparkReceiveQuoteSchema.parse({ + id: data.id, + sparkId: data.spark_id, + createdAt: data.created_at, + expiresAt: data.expires_at, + amount: receiveData.amountReceived, + paymentRequest: receiveData.paymentRequest, + paymentHash: data.payment_hash, + receiverIdentityPubkey: data.receiver_identity_pubkey ?? undefined, + transactionId: data.transaction_id, + userId: data.user_id, + accountId: data.account_id, + version: data.version, + description: receiveData.description, + totalFee: receiveData.totalFee, + type: data.type, + state: data.state, + paymentPreimage: receiveData.paymentPreimage, + sparkTransferId: data.spark_transfer_id, + failureReason: data.failure_reason ?? undefined, + tokenReceiveData: receiveData.cashuTokenMeltData + ? { + sourceMintUrl: receiveData.cashuTokenMeltData.tokenMintUrl, + tokenAmount: receiveData.cashuTokenMeltData.tokenAmount, + tokenProofs: receiveData.cashuTokenMeltData.tokenProofs, + meltQuoteId: receiveData.cashuTokenMeltData.meltQuoteId, + // the runtime parse checks cashu_token_melt_initiated is not null when CASHU_TOKEN + meltInitiated: data.cashu_token_melt_initiated as boolean, + cashuReceiveFee: receiveData.cashuTokenMeltData.cashuReceiveFee, + lightningFeeReserve: + receiveData.cashuTokenMeltData.lightningFeeReserve, + } + : undefined, + }) as SparkReceiveQuote; + } + + /** + * Thin typed wrapper over the untyped Supabase `rpc` (the DB generic is `any` until the + * generated types are lifted). Centralises the one cast so the call sites stay clean. + */ + private async rpc( + fn: string, + args: Record, + options?: Options, + // biome-ignore lint/suspicious/noExplicitAny: the Supabase client is untyped until the generated Database types are lifted (a later slice); the RPC arg shape is enforced by the stored procedure. + ): Promise<{ data: any; error: any }> { + // biome-ignore lint/suspicious/noExplicitAny: see above — the rpc name/args are not in the untyped client's type space. + let query = (this.db.rpc as any)(fn, args); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + return query; + } +} diff --git a/packages/wallet-sdk/src/internal/spark-receive-quote-service.test.ts b/packages/wallet-sdk/src/internal/spark-receive-quote-service.test.ts new file mode 100644 index 000000000..54df15d04 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-receive-quote-service.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { SparkReceiveQuoteService } from './spark-receive-quote-service'; +import type { SparkReceiveLightningQuote } from './spark-receive-quote-core'; +import type { SparkReceiveQuoteRepository } from './spark-receive-quote-repository'; +import type { SparkAccount } from '../types/account'; +import { type Currency, Money } from '../types/money'; +import type { SparkReceiveQuote } from '../types/spark'; + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +/** Build a `SparkReceiveQuote` in the given state with sensible defaults. */ +function baseQuote( + overrides: Partial & { + state?: SparkReceiveQuote['state']; + }, +): SparkReceiveQuote { + return { + id: overrides.id ?? 'rq1', + sparkId: 's1', + userId: 'u1', + accountId: 'acc1', + transactionId: 'tx1', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + amount: sats(100), + paymentRequest: 'lnbc1...', + paymentHash: 'hash', + totalFee: sats(0), + version: 1, + type: 'LIGHTNING', + ...overrides, + } as SparkReceiveQuote; +} + +/** A minimal repo whose methods are spies. */ +function fakeRepo(): SparkReceiveQuoteRepository { + return { + create: mock(async () => baseQuote({ state: 'UNPAID' })), + complete: mock(async ({ quote }: { quote: SparkReceiveQuote }) => + baseQuote({ id: quote.id, state: 'PAID' } as never), + ), + expire: mock(async () => baseQuote({ state: 'EXPIRED' })), + fail: mock(async () => undefined), + markMeltInitiated: mock(async (q: SparkReceiveQuote) => q), + get: mock(async () => null), + getPending: mock(async () => []), + toQuote: mock(async () => baseQuote({})), + } as unknown as SparkReceiveQuoteRepository; +} + +const account = { id: 'acc1', type: 'spark' } as SparkAccount; + +/** A pre-built lightning quote (as the core's getLightningQuote would return). */ +const lightningQuote: SparkReceiveLightningQuote = { + id: 'spark-recv-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + invoice: { + paymentRequest: 'lnbc-invoice', + paymentHash: 'ph1', + amount: sats(100) as Money<'BTC'>, + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + memo: 'coffee', + }, + status: 'PENDING' as never, +}; + +// -- Tests ---------------------------------------------------------------------------------- + +describe('SparkReceiveQuoteService.createReceiveQuote', () => { + test('a LIGHTNING receive persists with a zero total fee + invoice data', async () => { + const repo = fakeRepo(); + const service = new SparkReceiveQuoteService(repo); + + await service.createReceiveQuote({ + userId: 'u1', + account, + lightningQuote, + receiveType: 'LIGHTNING', + }); + + expect(repo.create).toHaveBeenCalledTimes(1); + const arg = (repo.create as ReturnType).mock.calls[0][0]; + expect(arg.receiveType).toBe('LIGHTNING'); + expect(arg.paymentRequest).toBe('lnbc-invoice'); + expect(arg.sparkId).toBe('spark-recv-1'); + expect((arg.totalFee as Money).toNumber('sat')).toBe(0); + }); + + test('a CASHU_TOKEN receive persists the melt data + sums the fee', async () => { + const repo = fakeRepo(); + const service = new SparkReceiveQuoteService(repo); + + await service.createReceiveQuote({ + userId: 'u1', + account, + lightningQuote, + receiveType: 'CASHU_TOKEN', + tokenAmount: sats(100), + sourceMintUrl: 'https://mint.example', + tokenProofs: [], + meltQuoteId: 'melt-1', + meltQuoteExpiresAt: '2026-01-01T00:30:00.000Z', + cashuReceiveFee: sats(1), + lightningFeeReserve: sats(2), + }); + + const arg = (repo.create as ReturnType).mock.calls[0][0]; + expect(arg.receiveType).toBe('CASHU_TOKEN'); + expect(arg.meltData.tokenMintUrl).toBe('https://mint.example'); + expect(arg.meltData.meltQuoteId).toBe('melt-1'); + // totalFee = cashuReceiveFee + lightningFeeReserve = 3 + expect((arg.totalFee as Money).toNumber('sat')).toBe(3); + // expiry is the earlier of the invoice + melt-quote expiry (the melt quote, here) + expect(arg.expiresAt).toBe('2026-01-01T00:30:00.000Z'); + }); +}); + +describe('SparkReceiveQuoteService state guards', () => { + test('complete is a no-op when already PAID', async () => { + const repo = fakeRepo(); + const service = new SparkReceiveQuoteService(repo); + const paid = baseQuote({ state: 'PAID' } as never); + + const result = await service.complete(paid, 'preimage', 'transfer-1'); + + expect(result).toBe(paid); + expect(repo.complete).not.toHaveBeenCalled(); + }); + + test('complete rejects a non-UNPAID state', async () => { + const service = new SparkReceiveQuoteService(fakeRepo()); + await expect( + service.complete( + baseQuote({ state: 'FAILED', failureReason: 'x' } as never), + 'preimage', + 'transfer-1', + ), + ).rejects.toThrow(/not unpaid/); + }); + + test('expire rejects a not-yet-expired quote', async () => { + const service = new SparkReceiveQuoteService(fakeRepo()); + const future = baseQuote({ + state: 'UNPAID', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + await expect(service.expire(future)).rejects.toThrow(/has not expired/); + }); + + test('fail is a no-op when already FAILED', async () => { + const repo = fakeRepo(); + const service = new SparkReceiveQuoteService(repo); + + await service.fail( + baseQuote({ state: 'FAILED', failureReason: 'x' } as never), + 'reason', + ); + + expect(repo.fail).not.toHaveBeenCalled(); + }); + + test('markMeltInitiated rejects a LIGHTNING (non-CASHU_TOKEN) quote', async () => { + const service = new SparkReceiveQuoteService(fakeRepo()); + await expect( + service.markMeltInitiated( + baseQuote({ type: 'LIGHTNING', state: 'UNPAID' }) as never, + ), + ).rejects.toThrow(/must be of type CASHU_TOKEN/); + }); +}); diff --git a/packages/wallet-sdk/src/internal/spark-receive-quote-service.ts b/packages/wallet-sdk/src/internal/spark-receive-quote-service.ts new file mode 100644 index 000000000..9df507885 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-receive-quote-service.ts @@ -0,0 +1,200 @@ +/** + * Spark lightning-RECEIVE SERVICE — Slice 3 / PR5c. The idempotent primitives for a + * `SparkReceiveQuote`'s lifecycle. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/receive/spark-receive-quote-service.ts`. Master's + * `SparkReceiveQuoteService` is a plain class (only the `useSparkReceiveQuoteService()` factory + * couples it to React); lifted near-verbatim, taking the SDK {@link SparkReceiveQuoteRepository}. + * The invoice-creation primitive ({@link getLightningQuote}, Breez `receivePayment`) lives in + * `spark-receive-quote-core.ts` and is exposed here as a thin passthrough so the public + * `createLightningQuote` kickoff can do `getLightningQuote` + `createReceiveQuote` in one place + * (mirroring the cashu receive service). Master calls the core `getLightningQuote` directly from + * its receive hook; the SDK folds it onto the service so the domain method stays declarative. + * + * The completion path (`complete`, marking PAID off the Breez transfer) is what the (future) + * `executeQuote` orchestrator drives; `createLightningQuote` is the user-invoked kickoff. + * + * @module + */ +import { + type CreateQuoteBaseParams, + type GetLightningQuoteParams, + type SparkReceiveLightningQuote, + computeQuoteExpiry, + getAmountAndFee, + getLightningQuote, +} from './spark-receive-quote-core'; +import type { SparkReceiveQuoteRepository } from './spark-receive-quote-repository'; +import type { SparkReceiveQuote } from '../types/spark'; + +/** Idempotent service primitives for a spark lightning-receive quote. */ +export class SparkReceiveQuoteService { + constructor(private readonly repository: SparkReceiveQuoteRepository) {} + + /** + * Create a Breez SDK lightning receive quote (the invoice to receive over) via the account's + * live Breez wallet. Thin passthrough to the core {@link getLightningQuote}. + * + * @param params - the live wallet, amount, and optional receiver pubkey / description. + * @returns the Spark lightning receive quote. + */ + async getLightningQuote( + params: GetLightningQuoteParams, + ): Promise { + return getLightningQuote(params); + } + + /** + * Create (persist) a new Spark Lightning receive quote for the given amount from a lightning + * quote. Master verbatim. + * + * @returns the created {@link SparkReceiveQuote} (UNPAID). + */ + async createReceiveQuote( + params: CreateQuoteBaseParams, + ): Promise { + const { userId, account, lightningQuote, purpose, transferId } = params; + const expiresAt = computeQuoteExpiry(params); + const { amount, totalFee } = getAmountAndFee(params); + + const baseParams = { + userId, + accountId: account.id, + amount, + paymentRequest: lightningQuote.invoice.paymentRequest, + paymentHash: lightningQuote.invoice.paymentHash, + description: lightningQuote.invoice.memo, + expiresAt, + sparkId: lightningQuote.id, + receiverIdentityPubkey: lightningQuote.receiverIdentityPublicKey, + totalFee, + purpose, + transferId, + }; + + if (params.receiveType === 'CASHU_TOKEN') { + return this.repository.create({ + ...baseParams, + receiveType: 'CASHU_TOKEN', + meltData: { + tokenMintUrl: params.sourceMintUrl, + tokenAmount: params.tokenAmount, + tokenProofs: params.tokenProofs, + meltQuoteId: params.meltQuoteId, + cashuReceiveFee: params.cashuReceiveFee, + lightningFeeReserve: params.lightningFeeReserve, + }, + }); + } + + return this.repository.create({ + ...baseParams, + receiveType: 'LIGHTNING', + }); + } + + /** + * Get a spark receive quote by id, or null. Master verbatim. + * + * @param quoteId - the quote id. + */ + async get(quoteId: string): Promise { + return this.repository.get(quoteId); + } + + /** + * Complete the spark receive quote (mark PAID). No-op if already PAID. Master verbatim. + * + * @throws Error if the quote is not UNPAID. + */ + async complete( + quote: SparkReceiveQuote, + paymentPreimage: string, + sparkTransferId: string, + ): Promise { + if (quote.state === 'PAID') { + return quote; + } + + if (quote.state !== 'UNPAID') { + throw new Error( + `Cannot complete quote that is not unpaid. State: ${quote.state}`, + ); + } + + return this.repository.complete({ + quote, + paymentPreimage, + sparkTransferId, + }); + } + + /** + * Expire the spark receive quote (mark EXPIRED). No-op if already EXPIRED. Master verbatim. + * + * @throws Error if the quote is not UNPAID or has not expired yet. + */ + async expire(quote: SparkReceiveQuote): Promise { + if (quote.state === 'EXPIRED') { + return; + } + + if (quote.state !== 'UNPAID') { + throw new Error( + `Cannot expire quote that is not unpaid. State: ${quote.state}`, + ); + } + + if (new Date(quote.expiresAt) > new Date()) { + throw new Error('Cannot expire quote that has not expired yet'); + } + + await this.repository.expire(quote.id); + } + + /** + * Fail the spark receive quote (mark FAILED). No-op if already FAILED. Master verbatim. + * + * @throws Error if the quote is not UNPAID. + */ + async fail(quote: SparkReceiveQuote, reason: string): Promise { + if (quote.state === 'FAILED') { + return; + } + + if (quote.state !== 'UNPAID') { + throw new Error( + `Cannot fail quote that is not unpaid. State: ${quote.state}`, + ); + } + + await this.repository.fail({ id: quote.id, reason }); + } + + /** + * Mark the melt initiated for a CASHU_TOKEN spark receive quote. No-op if already initiated. + * Master verbatim. + * + * @throws Error if the quote is not CASHU_TOKEN or is not UNPAID. + */ + async markMeltInitiated( + quote: SparkReceiveQuote & { type: 'CASHU_TOKEN' }, + ): Promise { + if (quote.type !== 'CASHU_TOKEN') { + throw new Error('Invalid quote type. Quote must be of type CASHU_TOKEN.'); + } + + if (quote.tokenReceiveData.meltInitiated) { + return quote; + } + + if (quote.state !== 'UNPAID') { + throw new Error( + `Invalid quote state. Quote must be in UNPAID state. State: ${quote.state}`, + ); + } + + return this.repository.markMeltInitiated(quote); + } +} diff --git a/packages/wallet-sdk/src/internal/spark-send-quote-repository.ts b/packages/wallet-sdk/src/internal/spark-send-quote-repository.ts new file mode 100644 index 000000000..ca1bb3ad1 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-send-quote-repository.ts @@ -0,0 +1,331 @@ +/** + * Internal `wallet.spark_send_quotes` repository — Slice 3 / PR5c (spark lightning send). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/send/spark-send-quote-repository.ts`. Master expresses this + * over a React-hook-constructed repo wired to the module-global `agicashDbClient` + + * `useEncryption`; here it is a plain class over the SDK-owned Supabase client + the SDK's + * {@link Encryption} (both injected). The RPCs (`create_spark_send_quote` / + * `mark_spark_send_quote_as_pending` / `complete_spark_send_quote` / `fail_spark_send_quote`) + * are unchanged — the `payment_hash` active-uniqueness guard (the duplicate-send protection) + * lives in the stored procedure / partial index and is preserved verbatim (surfaced as a + * {@link DomainError} on the `23505` unique violation, exactly as master). + * + * Re-housing details (matching `cashu-send-quote-repository.ts`): + * - the encrypted-jsonb schema (`SparkLightningSendDbDataSchema`) + the domain schema + * (`SparkSendQuoteSchema`) come from the SDK-internal single-source re-export + * (`./lib-spark-quotes`), so the runtime shapes can never drift from `types/spark.ts`; + * - the DB-row type is the hand-written {@link AgicashDbSparkSendQuote} (matching + * `db-account.ts` / the cashu repos, since the generated `database.types` are not lifted); + * - the untyped `rpc` wrapper centralises the one Supabase-client cast (the client's generic + * is `any` until the generated types land); + * - master's `satisfies AllUnionFieldsRequired<…>` compile-time assertion on the `toQuote` + * object is dropped (matching the cashu repos) — the runtime `.parse()` still enforces the + * per-state invariants (e.g. `sparkId`/`sparkTransferId`/`fee`/`paymentPreimage` present + * when COMPLETED); the result is cast `as SparkSendQuote`. + * - `measureOperation` telemetry is not in this layer (it wraps Breez calls in the service). + * + * @module + */ +import type { z } from 'zod/mini'; +import { + SparkLightningSendDbDataSchema, + SparkSendQuoteSchema, +} from './lib-spark-quotes'; +import type { WalletSupabaseClient } from './supabase-client'; +import { DomainError } from '../errors'; +import type { Encryption } from './encryption'; +import type { Money } from '../types/money'; +import type { SparkSendQuote } from '../types/spark'; + +/** Per-call query options (an abort signal, matching master). */ +type Options = { abortSignal?: AbortSignal }; + +/** + * A row of the `wallet.spark_send_quotes` table (hand-written; master = generated + * `database.types`). Only the columns `toQuote` reads are typed; `encrypted_data` is the + * ciphertext jsonb the repo decrypts. + */ +export type AgicashDbSparkSendQuote = { + id: string; + created_at: string; + expires_at: string | null; + user_id: string; + account_id: string; + transaction_id: string; + payment_hash: string; + payment_request_is_amountless: boolean; + encrypted_data: string; + spark_id: string | null; + spark_transfer_id: string | null; + version: number; + state: SparkSendQuote['state']; + failure_reason?: string | null; +}; + +/** Params for {@link SparkSendQuoteRepository.create} (master `CreateQuoteParams`). */ +export type CreateSparkSendQuote = { + userId: string; + accountId: string; + amount: Money; + estimatedFee: Money; + paymentRequest: string; + paymentHash: string; + paymentRequestIsAmountless: boolean; + expiresAt?: Date | null; + purpose?: string; + transferId?: string; +}; + +/** + * Reads + writes for the `wallet.spark_send_quotes` table, scoped (via RLS) to the signed-in + * user. Holds the SDK-owned Supabase client + the SDK {@link Encryption}. + */ +export class SparkSendQuoteRepository { + constructor( + private readonly db: WalletSupabaseClient, + private readonly encryption: Encryption, + ) {} + + /** + * Create a spark send quote (RPC `create_spark_send_quote`). The `payment_hash` active-unique + * partial index (covering UNPAID/PENDING/COMPLETED) raises `23505` if a send for the same + * invoice is already in flight or done — surfaced as a {@link DomainError}. Master verbatim. + */ + async create( + params: CreateSparkSendQuote, + options?: Options, + ): Promise { + const sendData = SparkLightningSendDbDataSchema.parse({ + paymentRequest: params.paymentRequest, + amountReceived: params.amount, + estimatedLightningFee: params.estimatedFee, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(sendData); + + const { data, error } = await this.rpc( + 'create_spark_send_quote', + { + p_user_id: params.userId, + p_account_id: params.accountId, + p_currency: params.amount.currency, + p_payment_hash: params.paymentHash, + p_payment_request_is_amountless: params.paymentRequestIsAmountless, + p_encrypted_data: encryptedData, + p_expires_at: params.expiresAt?.toISOString(), + p_purpose: params.purpose, + p_transfer_id: params.transferId, + }, + options, + ); + + if (error) { + if (error.code === '23505') { + // Hits the spark_send_quotes_payment_hash_active_unique partial index, which covers + // UNPAID, PENDING, and COMPLETED — so the existing quote could be in any of those states. + throw new DomainError( + 'A payment for this invoice is already being processed or was completed', + ); + } + throw new Error('Failed to create spark send quote', { cause: error }); + } + + return this.toQuote(data); + } + + /** + * Mark a spark send quote PENDING, recording the spark send-request id, transfer id, and the + * actual fee (RPC `mark_spark_send_quote_as_pending`). Master verbatim. + */ + async markAsPending( + { + quote, + sparkSendRequestId, + sparkTransferId, + fee, + }: { + quote: SparkSendQuote; + sparkSendRequestId: string; + sparkTransferId: string; + fee: Money; + }, + options?: Options, + ): Promise { + const sendData = SparkLightningSendDbDataSchema.parse({ + paymentRequest: quote.paymentRequest, + amountReceived: quote.amount, + estimatedLightningFee: quote.estimatedFee, + amountSpent: quote.amount.add(fee), + lightningFee: fee, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(sendData); + + const { data, error } = await this.rpc( + 'mark_spark_send_quote_as_pending', + { + p_quote_id: quote.id, + p_spark_id: sparkSendRequestId, + p_spark_transfer_id: sparkTransferId, + p_encrypted_data: encryptedData, + }, + options, + ); + + if (error) { + throw new Error('Failed to mark spark send quote as pending', { + cause: error, + }); + } + + return this.toQuote(data); + } + + /** + * Complete a spark send quote after a successful payment (RPC `complete_spark_send_quote`), + * storing the preimage + final fee data. Master verbatim. + */ + async complete( + { + quote, + paymentPreimage, + }: { + quote: SparkSendQuote & { state: 'PENDING' }; + paymentPreimage: string; + }, + options?: Options, + ): Promise { + const sendData = SparkLightningSendDbDataSchema.parse({ + paymentRequest: quote.paymentRequest, + amountReceived: quote.amount, + estimatedLightningFee: quote.estimatedFee, + amountSpent: quote.amount.add(quote.fee), + lightningFee: quote.fee, + paymentPreimage, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(sendData); + + const { data, error } = await this.rpc( + 'complete_spark_send_quote', + { + p_quote_id: quote.id, + p_encrypted_data: encryptedData, + }, + options, + ); + + if (error) { + throw new Error('Failed to complete spark send quote', { cause: error }); + } + + return this.toQuote(data); + } + + /** Fail a spark send quote (RPC `fail_spark_send_quote`). Master verbatim. */ + async fail( + quoteId: string, + failureReason: string, + options?: Options, + ): Promise { + const { data, error } = await this.rpc( + 'fail_spark_send_quote', + { + p_quote_id: quoteId, + p_failure_reason: failureReason, + }, + options, + ); + + if (error) { + throw new Error('Failed to fail spark send quote', { cause: error }); + } + + return this.toQuote(data); + } + + /** Get the spark send quote with the given id, or null. Master verbatim. */ + async get(id: string, options?: Options): Promise { + let query = this.db.from('spark_send_quotes').select().eq('id', id); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = await query.maybeSingle(); + if (error) { + throw new Error('Failed to get spark send quote', { cause: error }); + } + return data ? this.toQuote(data) : null; + } + + /** + * Get all unresolved (UNPAID or PENDING) spark send quotes for the user. INTERNAL — feeds the + * future orchestrator's resume sweep (the public interface has no `listPending`). Master verbatim. + */ + async getUnresolved( + userId: string, + options?: Options, + ): Promise { + let query = this.db + .from('spark_send_quotes') + .select() + .eq('user_id', userId) + .in('state', ['UNPAID', 'PENDING']); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = await query.returns(); + if (error) { + throw new Error('Failed to get spark send quotes', { cause: error }); + } + return Promise.all(data.map((row) => this.toQuote(row))); + } + + /** Map a DB row to the domain {@link SparkSendQuote}. Master verbatim. */ + async toQuote(data: AgicashDbSparkSendQuote): Promise { + const decryptedData = await this.encryption.decrypt(data.encrypted_data); + const sendData = SparkLightningSendDbDataSchema.parse(decryptedData); + + // The runtime `.parse()` does the spark-send-quote invariant check (e.g. it makes sure + // sparkId, sparkTransferId, fee and paymentPreimage are not undefined when state is + // COMPLETED). Cast to the domain type matches the cashu repos' `toQuote` approach. + return SparkSendQuoteSchema.parse({ + id: data.id, + sparkId: data.spark_id ?? undefined, + createdAt: data.created_at, + expiresAt: data.expires_at, + amount: sendData.amountReceived, + estimatedFee: sendData.estimatedLightningFee, + paymentRequest: sendData.paymentRequest, + paymentHash: data.payment_hash, + transactionId: data.transaction_id, + userId: data.user_id, + accountId: data.account_id, + version: data.version, + paymentRequestIsAmountless: data.payment_request_is_amountless, + state: data.state, + sparkTransferId: data.spark_transfer_id ?? undefined, + fee: sendData.lightningFee, + paymentPreimage: sendData.paymentPreimage, + failureReason: data.failure_reason ?? undefined, + }) as SparkSendQuote; + } + + /** + * Thin typed wrapper over the untyped Supabase `rpc` (the DB generic is `any` until the + * generated types are lifted). Centralises the one cast so the call sites stay clean. + */ + private async rpc( + fn: string, + args: Record, + options?: Options, + // biome-ignore lint/suspicious/noExplicitAny: the Supabase client is untyped until the generated Database types are lifted (a later slice); the RPC arg shape is enforced by the stored procedure. + ): Promise<{ data: any; error: any }> { + // biome-ignore lint/suspicious/noExplicitAny: see above — the rpc name/args are not in the untyped client's type space. + let query = (this.db.rpc as any)(fn, args); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + return query; + } +} diff --git a/packages/wallet-sdk/src/internal/spark-send-quote-service.test.ts b/packages/wallet-sdk/src/internal/spark-send-quote-service.test.ts new file mode 100644 index 000000000..1dfe290a7 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-send-quote-service.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { SparkSendQuoteService } from './spark-send-quote-service'; +import type { SparkSendQuoteRepository } from './spark-send-quote-repository'; +import { DomainError } from '../errors'; +import type { SparkAccount } from '../types/account'; +import { type Currency, Money } from '../types/money'; +import type { SparkSendQuote } from '../types/spark'; + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +/** Build a `SparkSendQuote` in the given state with sensible defaults. */ +function baseQuote( + overrides: Partial & { state?: SparkSendQuote['state'] }, +): SparkSendQuote { + return { + id: overrides.id ?? 'q1', + userId: 'u1', + accountId: 'acc1', + transactionId: 'tx1', + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + amount: sats(100), + estimatedFee: sats(2), + paymentRequest: 'lnbc1...', + paymentHash: 'hash', + paymentRequestIsAmountless: false, + version: 1, + ...overrides, + } as SparkSendQuote; +} + +/** A minimal repo whose methods are spies; each test stubs the ones it needs. */ +function fakeRepo(): SparkSendQuoteRepository { + return { + create: mock(async () => baseQuote({})), + markAsPending: mock(async ({ quote }: { quote: SparkSendQuote }) => + baseQuote({ + id: quote.id, + state: 'PENDING', + sparkId: 's1', + sparkTransferId: 'st1', + fee: sats(1), + } as never), + ), + complete: mock(async ({ quote }: { quote: SparkSendQuote }) => + baseQuote({ id: quote.id, state: 'COMPLETED' } as never), + ), + fail: mock(async (id: string) => + baseQuote({ id, state: 'FAILED', failureReason: 'r' } as never), + ), + get: mock(async () => null), + getUnresolved: mock(async () => []), + toQuote: mock(async () => baseQuote({})), + } as unknown as SparkSendQuoteRepository; +} + +/** + * A mock spark account whose live Breez `wallet` exposes the two send primitives the service + * calls. `prepareSendPayment` returns a bolt11Invoice payment method with a fixed fee; + * `sendPayment` returns a successful lightning send (or throws what the test queues). + */ +function fakeSparkAccount( + opts: { + lightningFeeSats?: number; + balance?: Money; + sendPaymentThrows?: unknown; + } = {}, +): { account: SparkAccount; sendPayment: ReturnType } { + const sendPayment = mock(async (_args: unknown) => { + if (opts.sendPaymentThrows) { + throw opts.sendPaymentThrows; + } + return { + payment: { id: 'transfer-1', fees: 1n }, + lightningSendDetails: { sendRequestId: 'send-req-1' }, + }; + }); + const account = { + id: 'acc1', + type: 'spark', + currency: 'BTC', + balance: opts.balance ?? sats(10_000), + wallet: { + prepareSendPayment: mock(async (_args: unknown) => ({ + paymentMethod: { + type: 'bolt11Invoice', + lightningFeeSats: opts.lightningFeeSats ?? 1, + }, + })), + sendPayment, + }, + } as unknown as SparkAccount; + return { account, sendPayment }; +} + +// -- Tests ---------------------------------------------------------------------------------- + +describe('SparkSendQuoteService state guards', () => { + test('complete is a no-op when already COMPLETED (does not hit the repo)', async () => { + const repo = fakeRepo(); + const service = new SparkSendQuoteService(repo); + const completed = baseQuote({ state: 'COMPLETED' } as never); + + const result = await service.complete(completed, 'preimage'); + + expect(result).toBe(completed); + expect(repo.complete).not.toHaveBeenCalled(); + }); + + test('complete rejects a non-PENDING state', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + await expect( + service.complete(baseQuote({ state: 'UNPAID' }), 'preimage'), + ).rejects.toThrow(/not pending/); + }); + + test('fail is a no-op when already FAILED', async () => { + const repo = fakeRepo(); + const service = new SparkSendQuoteService(repo); + const failed = baseQuote({ state: 'FAILED', failureReason: 'x' } as never); + + const result = await service.fail(failed, 'reason'); + + expect(result).toBe(failed); + expect(repo.fail).not.toHaveBeenCalled(); + }); + + test('fail rejects a COMPLETED state', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + await expect( + service.fail(baseQuote({ state: 'COMPLETED' } as never), 'r'), + ).rejects.toThrow(/not unpaid or pending/); + }); +}); + +describe('SparkSendQuoteService.initiateSend (Breez-driven send)', () => { + test('is a no-op when already PENDING (does not call the wallet)', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + const { account, sendPayment } = fakeSparkAccount(); + const pending = baseQuote({ + state: 'PENDING', + sparkId: 's1', + sparkTransferId: 'st1', + fee: sats(1), + } as never); + + const result = await service.initiateSend({ account, sendQuote: pending }); + + expect(result).toBe(pending); + expect(sendPayment).not.toHaveBeenCalled(); + }); + + test('rejects a non-UNPAID, non-PENDING state', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + const { account } = fakeSparkAccount(); + await expect( + service.initiateSend({ + account, + sendQuote: baseQuote({ state: 'FAILED', failureReason: 'x' } as never), + }), + ).rejects.toThrow(/not UNPAID/); + }); + + test('drives Breez sendPayment with the quote id as the idempotency key, then marks PENDING', async () => { + const repo = fakeRepo(); + const service = new SparkSendQuoteService(repo); + const { account, sendPayment } = fakeSparkAccount(); + const quote = baseQuote({ id: 'q-idem', state: 'UNPAID' }); + + const result = await service.initiateSend({ account, sendQuote: quote }); + + // The send is keyed by the quote id — the idempotency keystone (a re-issued send is safe). + expect(sendPayment).toHaveBeenCalledTimes(1); + expect(sendPayment.mock.calls[0][0].idempotencyKey).toBe('q-idem'); + // Routed over Lightning, not Spark. + expect(sendPayment.mock.calls[0][0].options).toEqual({ + type: 'bolt11Invoice', + preferSpark: false, + }); + // The repo is asked to mark it PENDING with the spark ids from the send result. + expect(repo.markAsPending).toHaveBeenCalledTimes(1); + expect( + (repo.markAsPending as ReturnType).mock.calls[0][0], + ).toMatchObject({ + sparkSendRequestId: 'send-req-1', + sparkTransferId: 'transfer-1', + }); + expect(result.state).toBe('PENDING'); + }); + + test('rejects (DomainError) when the live fee exceeds the confirmed estimate', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + // Quote confirmed at a 2-sat estimate; the wallet now quotes 5 sats. + const { account } = fakeSparkAccount({ lightningFeeSats: 5 }); + await expect( + service.initiateSend({ + account, + sendQuote: baseQuote({ state: 'UNPAID', estimatedFee: sats(2) }), + }), + ).rejects.toThrow(/fee has changed/); + }); + + test('maps an already-paid Breez error to a DomainError', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + const { account } = fakeSparkAccount({ + sendPaymentThrows: new Error('preimage request already exists'), + }); + await expect( + service.initiateSend({ + account, + sendQuote: baseQuote({ state: 'UNPAID' }), + }), + ).rejects.toThrow(/already been paid/); + }); + + test('maps an insufficient-balance Breez error to a DomainError', async () => { + const service = new SparkSendQuoteService(fakeRepo()); + const { account } = fakeSparkAccount({ + sendPaymentThrows: new Error('insufficient funds for the transaction'), + }); + const error = await service + .initiateSend({ + account, + sendQuote: baseQuote({ state: 'UNPAID' }), + }) + .catch((e) => e); + expect(error).toBeInstanceOf(DomainError); + expect(error.message).toMatch(/Insufficient balance/); + }); +}); diff --git a/packages/wallet-sdk/src/internal/spark-send-quote-service.ts b/packages/wallet-sdk/src/internal/spark-send-quote-service.ts new file mode 100644 index 000000000..7a67acf8e --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-send-quote-service.ts @@ -0,0 +1,366 @@ +/** + * Spark lightning-send SERVICE — Slice 3 / PR5c. The idempotent service primitives for a + * `SparkSendQuote`'s lifecycle. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/send/spark-send-quote-service.ts`. Master's `SparkSendQuoteService` + * is ALREADY a plain class (only the `useSparkSendQuoteService()` factory couples it to React); + * here it is lifted near-verbatim, dropping the factory and taking the SDK + * {@link SparkSendQuoteRepository}. The Breez operations (`prepareSendPayment` / `sendPayment`) + * run against the account's LIVE `BreezSdk` handle (PR5a). `initiateSend`'s `sendPayment` is the + * idempotency keystone — it passes `idempotencyKey: sendQuote.id`, so a re-issued send for the + * same quote does not double-pay (Breez surfaces the already-paid invoice, which is caught and + * surfaced as a {@link DomainError}). + * + * Re-housing vs master: + * - `parseBolt11Invoice` comes from `./lib-scan`; the Breez error classifiers + * (`isInsufficentBalanceError` / `isInvoiceAlreadyPaidError`) from `./lib-spark` (the specific + * `lib/spark/errors` module — NOT the barrel, which would pull the native WASM, see lib-spark); + * - `measureOperation` telemetry around the Breez calls is dropped (§3 — same as `spark-wallet.ts`). + * + * The state machine that SEQUENCES these primitives (UNPAID → PENDING → COMPLETED/FAILED, where + * the terminal transition is driven by the Breez `paymentSucceeded` / `paymentFailed` event + * callback) is the `executeQuote` ORCHESTRATOR — DEFERRED to the orchestrator sub-slice (PR5d), + * the same place cashu's `executeQuote` lands (see `domains/spark.ts`). These methods are the + * steps it calls. + * + * @module + */ +import { parseBolt11Invoice } from './lib-scan'; +import { + isInsufficentBalanceError, + isInvoiceAlreadyPaidError, +} from './lib-spark'; +import type { SparkSendQuoteRepository } from './spark-send-quote-repository'; +import { DomainError } from '../errors'; +import type { SparkAccount } from '../types/account'; +import { Money } from '../types/money'; +import type { SparkSendQuote } from '../types/spark'; + +/** The computed spark lightning quote returned before the send is persisted (master verbatim). */ +export type SparkLightningQuote = { + /** The payment request to pay. */ + paymentRequest: string; + /** Payment hash of the lightning invoice. */ + paymentHash: string; + /** The amount requested. */ + amountRequested: Money; + /** The amount requested in BTC. */ + amountRequestedInBtc: Money<'BTC'>; + /** The amount that the receiver will receive. */ + amountToReceive: Money; + /** The estimated lightning fee. */ + estimatedLightningFee: Money<'BTC'>; + /** The estimated total fee (lightning fee). */ + estimatedTotalFee: Money; + /** Estimated total amount of the send (amount to receive + estimated lightning fee). */ + estimatedTotalAmount: Money; + /** Whether the payment request has an amount encoded in the invoice. */ + paymentRequestIsAmountless: boolean; + /** The expiry date of the lightning invoice. */ + expiresAt: Date | null; +}; + +/** Options for {@link SparkSendQuoteService.getLightningSendQuote} (master verbatim). */ +export type GetSparkSendQuoteOptions = { + /** The Spark account to get a quote for (its live wallet is used). */ + account: SparkAccount; + /** The payment request to get a quote for. */ + paymentRequest: string; + /** Amount to send. Required for zero-amount invoices; ignored if the invoice has an amount. */ + amount?: Money<'BTC'>; +}; + +/** Params for {@link SparkSendQuoteService.createSendQuote} (master verbatim). */ +export type CreateSparkSendQuoteParams = { + /** The user ID. */ + userId: string; + /** The Spark account to send from. */ + account: SparkAccount; + /** The fee estimate returned by {@link SparkSendQuoteService.getLightningSendQuote}. */ + quote: SparkLightningQuote; + /** The purpose of this transaction (e.g. a Cash App buy or an internal transfer). */ + purpose?: string; + /** UUID linking paired send/receive transactions in a transfer. */ + transferId?: string; +}; + +/** Params for {@link SparkSendQuoteService.initiateSend} (master verbatim). */ +export type InitiateSparkSendParams = { + /** The Spark account to send from. */ + account: SparkAccount; + /** The send quote to initiate. */ + sendQuote: SparkSendQuote; +}; + +/** Idempotent service primitives for a spark lightning-send quote. */ +export class SparkSendQuoteService { + constructor(private readonly repository: SparkSendQuoteRepository) {} + + /** + * Estimate the fee for paying a Lightning invoice and return a quote for the send. Calls the + * account's live Breez wallet `prepareSendPayment` (routed over Lightning, `preferSpark: + * false`, so the Spark transfer fee does not apply). Master verbatim. + * + * @throws DomainError if the invoice is invalid / expired or the balance is insufficient. + */ + async getLightningSendQuote({ + account, + amount, + paymentRequest, + }: GetSparkSendQuoteOptions): Promise { + const bolt11ValidationResult = parseBolt11Invoice(paymentRequest); + if (!bolt11ValidationResult.valid) { + throw new DomainError('Invalid lightning invoice'); + } + const invoice = bolt11ValidationResult.decoded; + const expiresAt = invoice.expiryUnixMs + ? new Date(invoice.expiryUnixMs) + : null; + + if (expiresAt && expiresAt < new Date()) { + throw new DomainError('Lightning invoice has expired'); + } + + let amountRequestedInBtc = new Money({ amount: 0, currency: 'BTC' }); + + if (invoice.amountMsat) { + amountRequestedInBtc = new Money({ + amount: invoice.amountMsat, + currency: 'BTC', + unit: 'msat', + }); + } else if (amount) { + amountRequestedInBtc = amount; + } else { + throw new Error('Unknown send amount'); + } + + const prepareResponse = await account.wallet.prepareSendPayment({ + paymentRequest, + amount: BigInt(amountRequestedInBtc.toNumber('sat')), + }); + + const paymentMethod = prepareResponse.paymentMethod; + if (paymentMethod.type !== 'bolt11Invoice') { + throw new Error( + `Expected bolt11Invoice payment method, got: ${paymentMethod.type}`, + ); + } + + // We send with preferSpark: false so the payment is always routed over Lightning. + // Spark transfer fee does not apply in that case. + const estimatedLightningFee = new Money({ + amount: paymentMethod.lightningFeeSats, + currency: 'BTC', + unit: 'sat', + }); + + const estimatedTotalAmount = amountRequestedInBtc.add( + estimatedLightningFee, + ) as Money; + + const balance = account.balance ?? Money.zero(account.currency); + + if (balance.lessThan(estimatedTotalAmount)) { + const estimatedTotalFormatted = estimatedTotalAmount.toLocaleString({ + unit: 'sat', + }); + throw new DomainError( + `Insufficient balance. Estimated total including fee is ${estimatedTotalFormatted}.`, + ); + } + + return { + paymentRequest, + paymentHash: invoice.paymentHash, + amountRequested: amountRequestedInBtc as Money, + amountRequestedInBtc, + amountToReceive: amountRequestedInBtc as Money, + estimatedLightningFee, + estimatedTotalFee: estimatedLightningFee as Money, + estimatedTotalAmount, + paymentRequestIsAmountless: invoice.amountMsat === undefined, + expiresAt, + }; + } + + /** + * Create a send quote in UNPAID state. The quote must be initiated with {@link initiateSend} + * to start the lightning payment. Master verbatim. + * + * @throws DomainError if the invoice has expired or the balance is insufficient. + */ + async createSendQuote({ + userId, + account, + quote, + purpose, + transferId, + }: CreateSparkSendQuoteParams): Promise { + if (quote.expiresAt && quote.expiresAt < new Date()) { + throw new DomainError('Lightning invoice has expired'); + } + + const balance = account.balance ?? Money.zero(account.currency); + + if (balance.lessThan(quote.estimatedTotalAmount)) { + const estimatedTotalFormatted = quote.estimatedTotalAmount.toLocaleString( + { unit: 'sat' }, + ); + throw new DomainError( + `Insufficient balance. Estimated total including fee is ${estimatedTotalFormatted}.`, + ); + } + + return this.repository.create({ + userId, + accountId: account.id, + amount: quote.amountRequestedInBtc as Money, + estimatedFee: quote.estimatedLightningFee as Money, + paymentRequest: quote.paymentRequest, + paymentHash: quote.paymentHash, + paymentRequestIsAmountless: quote.paymentRequestIsAmountless, + expiresAt: quote.expiresAt, + purpose, + transferId, + }); + } + + /** + * Initiate the lightning payment for an UNPAID quote via the account's live Breez wallet. + * No-op if already PENDING. The `idempotencyKey: sendQuote.id` makes a re-issued send safe. + * Master verbatim. + * + * @throws DomainError if the invoice was already paid, the balance is insufficient, or the fee + * changed since confirmation; Error if the quote is not UNPAID/PENDING. + */ + async initiateSend({ + account, + sendQuote, + }: InitiateSparkSendParams): Promise { + if (sendQuote.state === 'PENDING') { + return sendQuote; + } + + if (sendQuote.state !== 'UNPAID') { + throw new Error( + `Cannot initiate send for quote that is not UNPAID. Current state: ${sendQuote.state}`, + ); + } + + const prepareResponse = await account.wallet.prepareSendPayment({ + paymentRequest: sendQuote.paymentRequest, + amount: BigInt(sendQuote.amount.toNumber('sat')), + }); + + const paymentMethod = prepareResponse.paymentMethod; + if (paymentMethod.type !== 'bolt11Invoice') { + throw new Error( + `Expected bolt11Invoice payment method, got: ${paymentMethod.type}`, + ); + } + + const estimatedFeeSats = sendQuote.estimatedFee.toNumber('sat'); + if (paymentMethod.lightningFeeSats > estimatedFeeSats) { + throw new DomainError( + 'Lightning network fee has changed since you confirmed. Please create a new send.', + ); + } + + try { + const { payment, lightningSendDetails } = + await account.wallet.sendPayment({ + prepareResponse, + idempotencyKey: sendQuote.id, + options: { type: 'bolt11Invoice', preferSpark: false }, + }); + + if (!lightningSendDetails) { + throw new Error( + 'Breez SDK did not return lightningSendDetails for a lightning send', + ); + } + + return this.repository.markAsPending({ + quote: sendQuote, + sparkSendRequestId: lightningSendDetails.sendRequestId, + sparkTransferId: payment.id, + fee: new Money({ + amount: Number(payment.fees), + currency: 'BTC', + unit: 'sat', + }) as Money, + }); + } catch (error) { + if (isInvoiceAlreadyPaidError(error)) { + throw new DomainError('Lightning invoice has already been paid.'); + } + + if (isInsufficentBalanceError(error)) { + const totalSats = sendQuote.amount + .add(sendQuote.estimatedFee) + .toNumber('sat'); + const availableSats = ( + account.balance ?? Money.zero(account.currency) + ).toNumber('sat'); + + throw new DomainError( + `Insufficient balance. Total cost of send is ${totalSats} sats but the available balance is ${availableSats} sats.`, + ); + } + + throw error; + } + } + + /** + * Get a spark send quote by id, or null. Master verbatim. + * + * @param quoteId - the quote id. + */ + async get(quoteId: string): Promise { + return this.repository.get(quoteId); + } + + /** + * Complete the spark send quote (mark COMPLETED). No-op if already COMPLETED. Master verbatim. + * + * @throws Error if the quote is not PENDING. + */ + async complete( + quote: SparkSendQuote, + paymentPreimage: string, + ): Promise { + if (quote.state === 'COMPLETED') { + return quote; + } + + if (quote.state !== 'PENDING') { + throw new Error( + `Cannot complete quote that is not pending. State: ${quote.state}`, + ); + } + + return this.repository.complete({ quote, paymentPreimage }); + } + + /** + * Fail the spark send quote (mark FAILED). No-op if already FAILED. Master verbatim. + * + * @throws Error if the quote is not UNPAID or PENDING. + */ + async fail(quote: SparkSendQuote, reason: string): Promise { + if (quote.state === 'FAILED') { + return quote; + } + + if (quote.state !== 'PENDING' && quote.state !== 'UNPAID') { + throw new Error( + `Cannot fail quote that is not unpaid or pending. State: ${quote.state}`, + ); + } + + return this.repository.fail(quote.id, reason); + } +} diff --git a/packages/wallet-sdk/src/sdk.ts b/packages/wallet-sdk/src/sdk.ts index 30697c9fe..34de1f6af 100644 --- a/packages/wallet-sdk/src/sdk.ts +++ b/packages/wallet-sdk/src/sdk.ts @@ -53,7 +53,17 @@ import { CashuSendSwapService } from './internal/cashu-send-swap-service'; import { MintMetadataCache } from './internal/cashu-wallet'; import { dbAccountToAccount } from './internal/db-account'; import { createEncryption } from './internal/encryption'; +import { SparkBalanceTracker } from './internal/spark-balance-tracker'; +import { SparkReceiveQuoteRepository } from './internal/spark-receive-quote-repository'; +import { SparkReceiveQuoteService } from './internal/spark-receive-quote-service'; +import { SparkSendQuoteRepository } from './internal/spark-send-quote-repository'; +import { SparkSendQuoteService } from './internal/spark-send-quote-service'; import { SparkWalletCache } from './internal/spark-wallet'; +import { + SparkDomainImpl, + SparkReceiveOpsImpl, + SparkSendOpsImpl, +} from './domains/spark'; import { TypedEventEmitter } from './internal/event-emitter'; import { GuestAccountStorage } from './internal/guest-account-storage'; import { OpenSecretClient } from './internal/open-secret'; @@ -62,7 +72,6 @@ import { createBackgroundStub, createContactsStub, createExchangeRateStub, - createSparkStub, createTransactionsStub, createTransfersStub, } from './internal/stub-domains'; @@ -96,6 +105,12 @@ export type SdkConnections = { readonly mintCache: MintMetadataCache; /** Per-(mnemonic,network) connected-spark-wallet memo (held so `destroy()` can drop it). */ readonly sparkCache: SparkWalletCache; + /** + * Spark balance source: Breez event listeners → `account:updated` (compare-before-emit). Held + * so `destroy()` removes its listeners. PR5c builds it; the S5 background/realtime slice calls + * `track(onlineSparkAccounts)` to start it. + */ + readonly sparkBalanceTracker: SparkBalanceTracker; }; /** Validate `config` (shape the rest of `create` relies on). Throws on a missing field. */ @@ -184,6 +199,7 @@ export class Sdk { accounts: AccountsDomain; scan: ScanDomain; cashu: CashuDomain; + spark: SparkDomain; }, ) { this.connections = connections; @@ -198,9 +214,10 @@ export class Sdk { this.scan = domains.scan; // Slice 3 / PR5b: cashu send + receive ops (executeQuote orchestrator deferred to PR5d). this.cashu = domains.cashu; + // Slice 3 / PR5c: spark send + receive ops (executeQuote orchestrator deferred to PR5d). + this.spark = domains.spark; // --- domain accessors still STUBBED (swap per slice) --------------------- - this.spark = createSparkStub(); this.transactions = createTransactionsStub(); this.contacts = createContactsStub(); this.transfers = createTransfersStub(); @@ -255,6 +272,8 @@ export class Sdk { // connection). Held on the connection bundle so `destroy()` can drop them. const mintCache = new MintMetadataCache(); const sparkCache = new SparkWalletCache(); + // Spark balance source (Breez listeners → `account:updated`). Built here, started by S5. + const sparkBalanceTracker = new SparkBalanceTracker(events); const connections: SdkConnections = { supabase, @@ -265,6 +284,7 @@ export class Sdk { clientId: config.clientId ?? generateClientId(), mintCache, sparkCache, + sparkBalanceTracker, }; // --- Slice 1: auth + user domains ---------------------------------------- @@ -382,7 +402,35 @@ export class Sdk { ), ); - return new Sdk(connections, { auth, user, accounts, scan, cashu }); + // --- Slice 3 / PR5c: spark send + receive ops ---------------------------- + // The spark repos write/read the `wallet.spark_{send,receive}_quotes` tables over the + // SDK-owned Supabase client + the SDK Encryption (encrypt-on-write / decrypt-on-read of the + // jsonb). The services drive each account's live `BreezSdk` handle (PR5a) for the protocol + // work: `prepareSendPayment`/`sendPayment` (send — idempotent via `idempotencyKey: quote.id`) + // and `receivePayment` (the receive invoice). Spark balance is sourced from Breez's OWN event + // listener (`sparkBalanceTracker`, started by S5), NOT a DB trigger. `executeQuote` (the + // orchestrator state machine) is DEFERRED to PR5d — see `domains/spark.ts`; PR5c ships the + // primitives it sequences. + const sparkSendQuoteRepository = new SparkSendQuoteRepository( + supabase, + encryption, + ); + const sparkReceiveQuoteRepository = new SparkReceiveQuoteRepository( + supabase, + encryption, + ); + const sparkSendQuoteService = new SparkSendQuoteService( + sparkSendQuoteRepository, + ); + const sparkReceiveQuoteService = new SparkReceiveQuoteService( + sparkReceiveQuoteRepository, + ); + const spark = new SparkDomainImpl( + new SparkSendOpsImpl(sparkSendQuoteService, session), + new SparkReceiveOpsImpl(sparkReceiveQuoteService, session), + ); + + return new Sdk(connections, { auth, user, accounts, scan, cashu, spark }); } /** @@ -403,6 +451,8 @@ export class Sdk { // Drop the live-handle memos (cashu mint metadata + connected spark wallets). this.connections.mintCache.clear(); this.connections.sparkCache.clear(); + // Remove the spark Breez balance listeners (the `account:updated` source). + this.connections.sparkBalanceTracker.stop(); // Remove every event subscriber. this.connections.events.removeAllListeners(); // TODO(Slice 3/5): close mint melt/mint-quote WS subs + Breez SDK instances (call