diff --git a/packages/wallet-sdk/src/domains/cashu.test.ts b/packages/wallet-sdk/src/domains/cashu.test.ts new file mode 100644 index 000000000..2202ef714 --- /dev/null +++ b/packages/wallet-sdk/src/domains/cashu.test.ts @@ -0,0 +1,165 @@ +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 { CashuSendOpsImpl, CashuReceiveOpsImpl } = await import('./cashu'); +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 cashuAccount = { + id: 'acc1', + type: 'cashu', + currency: 'BTC', + mintUrl: 'https://mint.example', +} as never; + +/** A send-quote service whose getLightningQuote echoes the payment request it was given. */ +function fakeSendQuoteService() { + return { + getLightningQuote: mock( + async ({ paymentRequest }: { paymentRequest: string }) => ({ + paymentRequest, + amountRequested: sats(100), + amountRequestedInBtc: sats(100), + meltQuote: { quote: 'melt1' }, + }), + ), + createSendQuote: mock( + async (args: { + sendQuote: { paymentRequest: string }; + destinationDetails?: unknown; + }) => ({ + id: 'q1', + state: 'UNPAID', + paymentRequest: args.sendQuote.paymentRequest, + destinationDetails: args.destinationDetails, + }), + ), + }; +} + +function makeSendOps(sendQuoteService = fakeSendQuoteService()) { + const ops = new CashuSendOpsImpl( + // biome-ignore lint/suspicious/noExplicitAny: minimal service/repo stubs for the fold + stub tests. + sendQuoteService as any, + {} as never, + {} as never, + {} as never, + {} as never, + session, + ); + return { ops, sendQuoteService }; +} + +afterEach(() => { + getInvoiceFromLud16.mockClear(); + lnurlResult = { pr: 'lnbc-resolved', verify: undefined, routes: [] }; +}); + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuSendOps.createLightningQuote — destination resolution', () => { + test('a bolt11 invoice is passed through (no LNURL call, no destinationDetails)', async () => { + const { ops, sendQuoteService } = makeSendOps(); + + const quote = await ops.createLightningQuote({ + account: cashuAccount, + destination: 'lnbc1pjinvoice', + }); + + expect(getInvoiceFromLud16).not.toHaveBeenCalled(); + expect( + sendQuoteService.getLightningQuote.mock.calls[0][0].paymentRequest, + ).toBe('lnbc1pjinvoice'); + expect( + (quote as { destinationDetails?: unknown }).destinationDetails, + ).toBeUndefined(); + }); + + test('an ln-address is resolved via LNURL-pay and tagged LN_ADDRESS', async () => { + const { ops, sendQuoteService } = makeSendOps(); + + const quote = await ops.createLightningQuote({ + account: cashuAccount, + destination: 'alice@example.com', + amount: sats(100), + }); + + expect(getInvoiceFromLud16).toHaveBeenCalledTimes(1); + // The resolved invoice flows into the quote service. + expect( + sendQuoteService.getLightningQuote.mock.calls[0][0].paymentRequest, + ).toBe('lnbc-resolved'); + expect( + (quote as { destinationDetails?: unknown }).destinationDetails, + ).toEqual({ + sendType: 'LN_ADDRESS', + lnAddress: 'alice@example.com', + }); + }); + + test('an ln-address without an amount is rejected (LNURL needs the amount)', async () => { + const { ops } = makeSendOps(); + await expect( + ops.createLightningQuote({ + account: cashuAccount, + 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: cashuAccount, + destination: 'alice@example.com', + amount: sats(100), + }), + ).rejects.toThrow(/amount out of range/); + }); +}); + +describe('CashuSendOps.executeQuote — deferred to PR5d', () => { + test('throws NotImplementedError (orchestrator state machine deferred)', () => { + const { ops } = makeSendOps(); + expect(() => ops.executeQuote({} as never)).toThrow(NotImplementedError); + }); +}); + +describe('CashuReceiveOps.receiveToken — deferred to PR5d', () => { + test('throws NotImplementedError with no side effects (the claim flow is deferred)', () => { + const ops = new CashuReceiveOpsImpl({} as never, {} as never, session); + expect(() => ops.receiveToken({ token: 'cashuAabc' })).toThrow( + NotImplementedError, + ); + }); +}); diff --git a/packages/wallet-sdk/src/domains/cashu.ts b/packages/wallet-sdk/src/domains/cashu.ts new file mode 100644 index 000000000..e9a7398df --- /dev/null +++ b/packages/wallet-sdk/src/domains/cashu.ts @@ -0,0 +1,311 @@ +/** + * `CashuDomain` implementation — §5 of the contract, Slice 3 / PR5b (cashu send + receive ops). + * + * Wires the framework-free cashu SERVICE primitives (`internal/cashu-*-service.ts`, re-housed + * from `apps/web-wallet/app/features/{send,receive}/*`) into the public `CashuSendOps` / + * `CashuReceiveOps` surface, replacing PR2's `createCashuStub`. The services use each account's + * LIVE `ExtendedCashuWallet` handle (PR5a) + the SDK-owned Supabase repos. + * + * TWO-MODE API rule: `createLightningQuote`/`createTokenQuote`/`receiveToken` are kickoffs + * (params + the FULL account); `failQuote`/`reverse` take FULL objects; `get` is a fetch. + * + * **`executeQuote` — DEFERRED to the orchestrator sub-slice (PR5d).** Per the build plan, the + * unified `executeQuote` orchestrator is the single biggest net-new construct — a framework-free + * state machine that absorbs master's six React-resident `useProcess*Tasks` hooks (each a + * TanStack mutation + retry/MintOperationError branch + a live mint-WS `*SubscriptionManager`) + * and drives UNPAID→PENDING→PAID off DB state. The plan (§1 + §4) keeps that state-machine core + * as its own reviewable unit and PR5b ships the IDEMPOTENT PRIMITIVES it will call + * (`initiateSend`/`markSendQuoteAsPending`/`completeSendQuote`/`failSendQuote` + the swap/claim + * services, with `wallet.restore` recovery + the DB reservation / CONCURRENCY_ERROR guards + * preserved). `executeQuote` is therefore a documented stub here — see {@link CashuSendOpsImpl.executeQuote}. + * + * @module + */ +import type { AccountRepository } from '../internal/account-repository'; +import type { CashuReceiveQuoteService } from '../internal/cashu-receive-quote-service'; +import type { CashuSendQuoteService } from '../internal/cashu-send-quote-service'; +import type { CashuSendSwapService } from '../internal/cashu-send-swap-service'; +import { getInvoiceFromLud16, isLNURLError } from '../internal/lib-lnurl'; +import type { CashuReceiveQuoteRepository } from '../internal/cashu-receive-quote-repository'; +import type { CashuSendQuoteRepository } from '../internal/cashu-send-quote-repository'; +import type { CashuSendSwapRepository } from '../internal/cashu-send-swap-repository'; +import type { SessionResolver } from '../internal/session'; +import type { CashuDomain, CashuReceiveOps, CashuSendOps } from '../domains'; +import { DomainError, NotImplementedError } from '../errors'; +import type { Account, CashuAccount } from '../types/account'; +import type { + CashuReceiveQuote, + CashuSendQuote, + CashuSendSwap, +} from '../types/cashu'; +import type { SparkReceiveQuote } from '../types/spark'; +import type { Money } from '../types/money'; + +/** + * Cashu SEND operations. Holds the lightning-send + token-send services + their repos (for + * `get`/`reverse`) + the account repo + session. + */ +export class CashuSendOpsImpl implements CashuSendOps { + constructor( + private readonly sendQuoteService: CashuSendQuoteService, + private readonly sendSwapService: CashuSendSwapService, + private readonly sendQuoteRepository: CashuSendQuoteRepository, + private readonly sendSwapRepository: CashuSendSwapRepository, + private readonly accounts: AccountRepository, + private readonly session: SessionResolver, + ) {} + + /** + * Create a cashu 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 lightning-send service + * (`getLightningQuote` + `createSendQuote`). + * + * @param params.account - the FULL cashu account to send from (its live 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. + * @returns the created {@link CashuSendQuote} (UNPAID). + */ + async createLightningQuote(params: { + account: CashuAccount; + destination: string; + amount?: Money; + }): Promise { + const user = await this.session.requireCurrentUser(); + const { paymentRequest, lnAddress } = await this.resolveDestination( + params.destination, + params.amount, + ); + + const lightningQuote = await this.sendQuoteService.getLightningQuote({ + account: params.account, + paymentRequest, + amount: params.amount, + }); + + return this.sendQuoteService.createSendQuote({ + userId: user.id, + account: params.account, + sendQuote: { + paymentRequest: lightningQuote.paymentRequest, + amountRequested: lightningQuote.amountRequested, + amountRequestedInBtc: lightningQuote.amountRequestedInBtc, + meltQuote: lightningQuote.meltQuote, + }, + destinationDetails: lnAddress + ? { sendType: 'LN_ADDRESS', lnAddress } + : undefined, + }); + } + + /** + * Create a cashu TOKEN send — returns a {@link CashuSendSwap} (not a lightning quote). The + * sender pays the fees (master's only supported mode). Delegates to the token-send service. + * + * @param params.account - the FULL cashu account to send from. + * @param params.amount - the amount to send (in the account's currency). + * @returns the created {@link CashuSendSwap} (DRAFT or PENDING). + */ + async createTokenQuote(params: { + account: CashuAccount; + amount: Money; + }): Promise { + const user = await this.session.requireCurrentUser(); + return this.sendSwapService.create({ + userId: user.id, + account: params.account, + amount: params.amount, + senderPaysFee: true, + }); + } + + /** + * DEFERRED (orchestrator sub-slice, PR5d). `executeQuote` IS the orchestrator — the + * framework-free state machine that drives UNPAID → PENDING → PAID off DB state + the mint + * melt-quote WS subscription (master has only the React `TaskProcessor`). PR5b ships the + * idempotent primitives it sequences (`initiateSend` / `markSendQuoteAsPending` / + * `completeSendQuote` / `failSendQuote`, with `wallet.restore` recovery + the DB + * CONCURRENCY_ERROR guard) but NOT the drive loop. Throws {@link NotImplementedError}. + * + * @param _quote - the quote to drive (full object on the kickoff path). + */ + executeQuote(_quote: CashuSendQuote): Promise { + throw new NotImplementedError( + 'cashu.send.executeQuote (the orchestrator state machine lands in the PR5d orchestrator sub-slice; PR5b ships the idempotent send primitives it drives)', + ); + } + + /** + * Mark a send quote FAILED (FULL object). Re-checks the mint melt quote is UNPAID first (so + * an in-flight/paid send is never failed). Delegates to the service. + * + * @param quote - the FULL quote to fail. + * @param reason - the failure reason. + */ + async failQuote(quote: CashuSendQuote, reason: string): Promise { + const account = await this.requireCashuAccount(quote.accountId); + await this.sendQuoteService.failSendQuote(account, quote, reason); + } + + /** + * Reverse a PENDING token send (decision 8): reclaim `proofsToSend` via a new cashu receive + * swap tagged `reversedTransactionId`. Refetches the account (for its live wallet) then + * delegates to the service. The swap lands REVERSED DB-side. + * + * @param swap - the FULL pending token-send swap. + * @returns the swap (the caller polls `get`/events for the REVERSED state). + */ + async reverse(swap: CashuSendSwap): Promise { + const account = await this.requireCashuAccount(swap.accountId); + await this.sendSwapService.reverse(swap, account); + return swap; + } + + /** + * Fetch a send quote OR token-send swap by id (fetch). Tries the lightning-send quote first, + * then the token-send swap. + * + * @param id - the quote/swap id. + * @returns the quote/swap, or null if neither exists. + */ + async get(id: string): Promise { + const quote = await this.sendQuoteRepository.get(id); + if (quote) { + return quote; + } + return this.sendSwapRepository.get(id); + } + + /** + * Resolve `destination` to a bolt11 invoice. A `user@domain` Lightning address is resolved + * via LNURL-pay using `amount` (required); a bolt11 invoice is returned as-is. + */ + private async resolveDestination( + destination: string, + amount?: Money, + ): Promise<{ paymentRequest: string; lnAddress?: 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') { + // LNURL-pay requests a msat amount; the SDK resolves ln-addresses in BTC. (A USD-account + // send to an ln-address would convert via exchange rate — that path rides with exchangeRate.) + 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, lnAddress: destination }; + } + + /** Fetch + assert the account is a live cashu account (for the wallet-using service calls). */ + private async requireCashuAccount(id: string): Promise { + const account = await this.accounts.get(id); + if (!account || account.type !== 'cashu') { + throw new DomainError('Cashu account not found'); + } + return account; + } +} + +/** + * Cashu RECEIVE operations. Holds the receive-quote + receive-swap services + the receive-quote + * repo (for `get`) + the account repo + session. + */ +export class CashuReceiveOpsImpl implements CashuReceiveOps { + constructor( + private readonly receiveQuoteService: CashuReceiveQuoteService, + private readonly receiveQuoteRepository: CashuReceiveQuoteRepository, + private readonly session: SessionResolver, + ) {} + + /** + * Claim a cashu token. DEFERRED to the orchestrator sub-slice (PR5d) at the PUBLIC surface. + * + * The IDEMPOTENT PRIMITIVE is shipped + tested in PR5b — `CashuReceiveSwapService.create` + * (same-mint claim: create a receive swap + reserve, with `wallet.restore` double-claim + * recovery in `completeSwap`). What this public method additionally needs is the full claim + * FLOW master implements in `ClaimCashuTokenService`: destination-account resolution (add the + * mint if unknown, set-default UX), same-mint-vs-cross-account ROUTING, the cross-account + * melt-then-mint quote path (token → a different mint, or token → spark), and the + * result-surface reconciliation (a same-mint claim produces a receive SWAP, but this method's + * declared result is a receive/spark QUOTE — only the cross-account path yields a quote). That + * flow is orchestrated over account/user services + exchange-rate fetches not yet in the SDK, + * and folds in with the orchestrator + transfers slices. Throws {@link NotImplementedError} + * (no side effects) rather than half-driving a claim. + * + * @param _params.token - the encoded cashu token string. + * @param _params.destinationAccount - the account to claim into. + */ + receiveToken(_params: { + token: string; + destinationAccount?: Account; + }): Promise { + throw new NotImplementedError( + 'cashu.receive.receiveToken (the claim FLOW — account resolution + same/cross routing + the cross-account melt-then-mint quote — lands with the PR5d orchestrator + transfers; PR5b ships the same-mint receive-swap primitive it builds on)', + ); + } + + /** + * Create a cashu 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-quote service (`getLightningQuote` + `createReceiveQuote`). + * + * @param params.account - the FULL cashu account to receive into. + * @param params.amount - the amount to receive. + * @param params.purpose - `'PAYMENT'` (default) or `'BUY_CASHAPP'`. + * @returns the created {@link CashuReceiveQuote} (UNPAID). + */ + async createLightningQuote(params: { + account: CashuAccount; + amount: Money; + purpose?: 'PAYMENT' | 'BUY_CASHAPP'; + }): Promise { + const user = await this.session.requireCurrentUser(); + const description = + params.purpose === 'BUY_CASHAPP' ? 'Pay to Agicash' : undefined; + + 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 receive quote by id (fetch). + * + * @param quoteId - the quote id. + * @returns the quote, or null. + */ + async get(quoteId: string): Promise { + return this.receiveQuoteRepository.get(quoteId); + } +} + +/** The cashu domain — `.send` + `.receive`. */ +export class CashuDomainImpl implements CashuDomain { + constructor( + readonly send: CashuSendOps, + readonly receive: CashuReceiveOps, + ) {} +} diff --git a/packages/wallet-sdk/src/errors.ts b/packages/wallet-sdk/src/errors.ts index 8df1c8165..cab1e4cbc 100644 --- a/packages/wallet-sdk/src/errors.ts +++ b/packages/wallet-sdk/src/errors.ts @@ -19,10 +19,18 @@ export class SdkError extends Error { /** Stable, machine-readable error code (e.g. a cashu/protocol code). */ readonly code: string; - constructor(message: string, code: string) { + /** + * @param message - the human-readable message. + * @param code - the machine-readable code; defaults to the concrete subclass name (e.g. + * `'DomainError'`) when a specific code is not supplied. The default lets the framework-free + * service primitives (re-housed verbatim from master, which throws `new DomainError(msg)`) + * construct an error without inventing a code, while callers that have a protocol/DB code + * (e.g. the repos' `ConcurrencyError`) still pass one. + */ + constructor(message: string, code?: string) { super(message); this.name = new.target.name; - this.code = code; + this.code = code ?? new.target.name; } } @@ -35,6 +43,14 @@ export class DomainError extends SdkError {} /** The requested entity does not exist. */ export class NotFoundError extends SdkError {} +/** + * A unique-constraint violation (a duplicate insert). Lifted from master + * `shared/error.ts#UniqueConstraintError`; the cashu receive-swap path raises it when a token + * has already been claimed (DB `23505`). A `ConcurrencyError` sub-type — it is transient from + * the caller's perspective (the conflicting row already exists, so the work is effectively done). + */ +export class UniqueConstraintError extends ConcurrencyError {} + /** * A method that exists on the contract but whose implementation has not landed yet * (a later build slice fills it in). Thrown by the domain stubs the `Sdk` shell wires diff --git a/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts b/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts index 3cbcf8c84..0cb2f038a 100644 --- a/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts +++ b/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts @@ -21,6 +21,10 @@ function fakeEncryption(plaintexts: unknown[]): Encryption { return { decryptBatch: async (data: readonly string[]) => plaintexts.slice(0, data.length) as never, + // The resolver only decrypts; the encrypt/decrypt-single halves are unused here. + decrypt: async () => undefined as never, + encrypt: async () => '', + encryptBatch: async (data: readonly unknown[]) => data.map(() => ''), }; } @@ -173,6 +177,9 @@ describe('LiveAccountHandleResolver.resolveCashu', () => { called = true; return [] as never; }, + decrypt: async () => undefined as never, + encrypt: async () => '', + encryptBatch: async () => [], }, mintCache: onlineMintCache(), sparkCache: new SparkWalletCache(), diff --git a/packages/wallet-sdk/src/internal/cashu-receive-quote-core.ts b/packages/wallet-sdk/src/internal/cashu-receive-quote-core.ts new file mode 100644 index 000000000..c6aa33106 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-quote-core.ts @@ -0,0 +1,199 @@ +/** + * Cashu receive-quote CORE — Slice 3 / PR5b (framework-free). + * + * EXTRACTED VERBATIM (logic) from + * `apps/web-wallet/app/features/receive/cashu-receive-quote-core.ts`. This module is already + * framework-free in master (pure `@cashu/cashu-ts` + `Money` + `@scure/bip32` + the + * `derivePublicKey` HDKey helper) — re-housed here with the SDK-internal imports. It owns: + * - {@link getLightningQuote} — creates a NUT-20-locked mint quote on the mint; + * - {@link deriveNut20LockingPublicKey} — derives the random locking key off the xPub; + * - {@link computeQuoteExpiry} / {@link computeTotalFee} — quote bookkeeping; + * - the {@link CreateQuoteBaseParams} / {@link RepositoryCreateQuoteParams} param shapes. + * + * @module + */ +import type { MintQuoteBolt11Response, Proof } from '@cashu/cashu-ts'; +import { HARDENED_OFFSET } from '@scure/bip32'; +import { + BASE_CASHU_LOCKING_DERIVATION_PATH, + derivePublicKey, +} from './lib-cashu-crypto'; +import { getCashuUnit } from './lib-cashu'; +import { decodeBolt11 } from './lib-scan'; +import type { CashuAccount } from '../types/account'; +import type { ExtendedCashuWallet } from '../types/dependencies'; +import { type Currency, Money } from '../types/money'; + +/** The locked mint quote + the data needed to create a receive quote (master verbatim). */ +export type CashuReceiveLightningQuote = { + mintQuote: MintQuoteBolt11Response; + lockingPublicKey: string; + fullLockingDerivationPath: string; + expiresAt: string; + amount: Money; + description?: string; + mintingFee?: Money; + paymentHash: string; +}; + +/** Params for {@link getLightningQuote}. */ +export type GetLightningQuoteParams = { + wallet: ExtendedCashuWallet; + amount: Money; + description?: string; + xPub: string; +}; + +/** Params for creating a receive quote in the service (master `CreateQuoteBaseParams`). */ +export type CreateQuoteBaseParams = { + userId: string; + /** The cashu account the money will be received into (only `id` is read here). */ + account: Pick; + lightningQuote: CashuReceiveLightningQuote; + receiveType: 'LIGHTNING' | 'CASHU_TOKEN'; + purpose?: string; + transferId?: string; +} & ( + | { receiveType: 'LIGHTNING' } + | { + receiveType: 'CASHU_TOKEN'; + tokenAmount: Money; + sourceMintUrl: string; + tokenProofs: Proof[]; + meltQuoteId: string; + meltQuoteExpiresAt: string; + cashuReceiveFee: Money; + lightningFeeReserve: Money; + } +); + +/** Params for creating a receive quote in the repository (master `RepositoryCreateQuoteParams`). */ +export type RepositoryCreateQuoteParams = { + userId: string; + accountId: string; + amount: Money; + quoteId: string; + paymentRequest: string; + paymentHash: string; + expiresAt: string; + description?: string; + lockingDerivationPath: string; + receiveType: 'LIGHTNING' | 'CASHU_TOKEN'; + mintingFee?: Money; + totalFee: Money; + purpose?: string; + transferId?: string; +} & ( + | { receiveType: 'LIGHTNING' } + | { + receiveType: 'CASHU_TOKEN'; + meltData: { + tokenMintUrl: string; + meltQuoteId: string; + tokenAmount: Money; + tokenProofs: Proof[]; + cashuReceiveFee: Money; + lightningFeeReserve: Money; + }; + } +); + +/** + * Derive a NUT-20 locking public key for a cashu mint quote (master verbatim): a random + * unhardened index off the locking xPub. + * + * @param xPub - the user's cashu locking xPub. + * @returns the locking public key + its full derivation path. + */ +export async function deriveNut20LockingPublicKey(xPub: string): Promise<{ + lockingPublicKey: string; + fullLockingDerivationPath: string; +}> { + const unhardenedIndex = Math.floor( + Math.random() * (HARDENED_OFFSET - 1), + ).toString(); + const lockingKey = derivePublicKey(xPub, `m/${unhardenedIndex}`); + return { + lockingPublicKey: lockingKey, + fullLockingDerivationPath: `${BASE_CASHU_LOCKING_DERIVATION_PATH}/${unhardenedIndex}`, + }; +} + +/** + * Get a NUT-20-locked mint quote for a lightning receive (master verbatim): derives the + * locking key, calls the mint's `createLockedMintQuote`, and returns the quote + invoice data. + * + * @param params - the wallet, amount, optional description, and locking xPub. + * @returns the locked mint quote + derived data. + */ +export async function getLightningQuote( + params: GetLightningQuoteParams, +): Promise { + const { wallet, amount, description, xPub } = params; + const cashuUnit = getCashuUnit(amount.currency); + + const { lockingPublicKey, fullLockingDerivationPath } = + await deriveNut20LockingPublicKey(xPub); + + const mintQuoteResponse = await wallet.createLockedMintQuote( + amount.toNumber(cashuUnit), + lockingPublicKey, + description, + ); + + const expiresAt = new Date(mintQuoteResponse.expiry * 1000).toISOString(); + + const mintingFee = mintQuoteResponse.fee + ? new Money({ + amount: mintQuoteResponse.fee, + currency: amount.currency, + unit: cashuUnit, + }) + : undefined; + + const { + decoded: { paymentHash }, + } = decodeBolt11(mintQuoteResponse.request); + + return { + mintQuote: mintQuoteResponse, + lockingPublicKey, + fullLockingDerivationPath, + expiresAt, + amount, + description, + mintingFee, + paymentHash, + }; +} + +/** + * Compute a receive quote's expiry (master verbatim). LIGHTNING = the mint quote expiry; + * CASHU_TOKEN = the earlier of the mint quote + melt quote expiry. + */ +export function computeQuoteExpiry(params: CreateQuoteBaseParams): string { + if (params.receiveType === 'LIGHTNING') { + return params.lightningQuote.expiresAt; + } + return new Date( + Math.min( + new Date(params.lightningQuote.expiresAt).getTime(), + new Date(params.meltQuoteExpiresAt).getTime(), + ), + ).toISOString(); +} + +/** + * Compute a receive quote's total fee (master verbatim). LIGHTNING = the minting fee; + * CASHU_TOKEN = minting fee + cashu receive fee + lightning fee reserve. + */ +export function computeTotalFee(params: CreateQuoteBaseParams): Money { + const mintingFee = + params.lightningQuote.mintingFee ?? + Money.zero(params.lightningQuote.amount.currency as Currency); + + if (params.receiveType === 'LIGHTNING') { + return mintingFee; + } + return mintingFee.add(params.cashuReceiveFee).add(params.lightningFeeReserve); +} diff --git a/packages/wallet-sdk/src/internal/cashu-receive-quote-repository.ts b/packages/wallet-sdk/src/internal/cashu-receive-quote-repository.ts new file mode 100644 index 000000000..fea85ee0a --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-quote-repository.ts @@ -0,0 +1,372 @@ +/** + * Internal `wallet.cashu_receive_quotes` repository — Slice 3 / PR5b (cashu lightning receive). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/receive/cashu-receive-quote-repository.ts`. Same re-housing as + * the send repos. Two RPCs (`process_cashu_receive_quote_payment` / `complete_cashu_receive_quote`) + * return an updated account row alongside the quote; master maps it via + * `accountRepository.toAccount`. Here that mapper is INJECTED ({@link AccountMapper}) — the + * resolver-backed `dbAccountToAccount` (Slice 2/3) — so the repo stays framework-free and does + * not depend on the account repository class. + * + * @module + */ +import type { Proof } from '@cashu/cashu-ts'; +import type { z } from 'zod/mini'; +import type { AgicashDbAccountWithProofs } from './db-account'; +import { + CashuLightningReceiveDbDataSchema, + CashuReceiveQuoteSchema, + proofToY, +} from './lib-cashu-quotes'; +import type { RepositoryCreateQuoteParams } from './cashu-receive-quote-core'; +import type { WalletSupabaseClient } from './supabase-client'; +import { computeSHA256 } from './crypto'; +import type { Encryption } from './encryption'; +import type { CashuAccount } from '../types/account'; +import type { CashuReceiveQuote } from '../types/cashu'; + +/** Per-call query options (an abort signal, matching master). */ +type Options = { abortSignal?: AbortSignal }; + +/** Maps a returned account row (joined with proofs) to the domain {@link CashuAccount}. */ +export type AccountMapper = ( + row: AgicashDbAccountWithProofs, +) => Promise; + +/** + * A row of the `wallet.cashu_receive_quotes` table (hand-written; master = generated types). + * Only the columns `toQuote` reads are typed. + */ +export type AgicashDbCashuReceiveQuote = { + id: string; + user_id: string; + account_id: string; + transaction_id: string; + encrypted_data: string; + payment_hash: string; + locking_derivation_path: string; + created_at: string; + expires_at: string; + version: number; + type: CashuReceiveQuote['type']; + state: CashuReceiveQuote['state']; + cashu_token_melt_initiated: boolean | null; + keyset_id: string | null; + keyset_counter: number | null; + failure_reason?: string | null; +}; + +/** Reads + writes for the `wallet.cashu_receive_quotes` table (RLS-scoped). */ +export class CashuReceiveQuoteRepository { + constructor( + private readonly db: WalletSupabaseClient, + private readonly encryption: Encryption, + private readonly mapAccount: AccountMapper, + ) {} + + /** Create a receive quote (RPC `create_cashu_receive_quote`). */ + async create( + params: RepositoryCreateQuoteParams, + options?: Options, + ): Promise { + const receiveData = CashuLightningReceiveDbDataSchema.parse({ + paymentRequest: params.paymentRequest, + mintQuoteId: params.quoteId, + amountReceived: params.amount, + description: params.description, + mintingFee: params.mintingFee, + cashuTokenMeltData: + params.receiveType === 'CASHU_TOKEN' ? params.meltData : undefined, + totalFee: params.totalFee, + } satisfies z.input); + + const [encryptedReceiveData, quoteIdHash] = await Promise.all([ + this.encryption.encrypt(receiveData), + computeSHA256(params.quoteId), + ]); + + const { data, error } = await this.rpc( + 'create_cashu_receive_quote', + { + p_user_id: params.userId, + p_account_id: params.accountId, + p_currency: params.amount.currency, + p_expires_at: params.expiresAt, + p_locking_derivation_path: params.lockingDerivationPath, + p_receive_type: params.receiveType, + p_encrypted_data: encryptedReceiveData, + p_quote_id_hash: quoteIdHash, + p_payment_hash: params.paymentHash, + p_purpose: params.purpose, + p_transfer_id: params.transferId, + }, + options, + ); + + if (error) { + throw new Error('Failed to create cashu receive quote', { cause: error }); + } + + return this.toQuote(data); + } + + /** Expire the receive quote (RPC `expire_cashu_receive_quote`). */ + async expire(id: string, options?: Options): Promise { + const { error } = await this.rpc( + 'expire_cashu_receive_quote', + { p_quote_id: id }, + options, + ); + if (error) { + throw new Error('Failed to expire cashu receive quote', { cause: error }); + } + } + + /** Fail the receive quote (RPC `fail_cashu_receive_quote`). */ + async fail( + { id, reason }: { id: string; reason: string }, + options?: Options, + ): Promise { + const { error } = await this.rpc( + 'fail_cashu_receive_quote', + { p_quote_id: id, p_failure_reason: reason }, + options, + ); + if (error) { + throw new Error('Failed to fail cashu receive quote', { cause: error }); + } + } + + /** Mark the melt initiated for a CASHU_TOKEN receive (RPC `…_cashu_token_melt_initiated`). */ + async markMeltInitiated( + quote: CashuReceiveQuote & { type: 'CASHU_TOKEN' }, + options?: Options, + ): Promise { + const { data, error } = await this.rpc( + 'mark_cashu_receive_quote_cashu_token_melt_initiated', + { p_quote_id: quote.id }, + options, + ); + if (error) { + throw new Error('Failed to mark melt initiated for cashu receive quote', { + cause: error, + }); + } + return (await this.toQuote(data)) as CashuReceiveQuote & { + type: 'CASHU_TOKEN'; + }; + } + + /** + * Process the payment of a receive quote (RPC `process_cashu_receive_quote_payment`): marks + * the quote PAID, persists the output amounts, and bumps the account's keyset counter. + * Returns the updated quote + account. + */ + async processPayment( + { + quote, + keysetId, + outputAmounts, + }: { + quote: CashuReceiveQuote; + keysetId: string; + outputAmounts: number[]; + }, + options?: Options, + ): Promise<{ quote: CashuReceiveQuote; account: CashuAccount }> { + const cashuTokenMeltData = + quote.type === 'CASHU_TOKEN' + ? { + tokenAmount: quote.tokenReceiveData.tokenAmount, + tokenProofs: quote.tokenReceiveData.tokenProofs, + tokenMintUrl: quote.tokenReceiveData.sourceMintUrl, + meltQuoteId: quote.tokenReceiveData.meltQuoteId, + cashuReceiveFee: quote.tokenReceiveData.cashuReceiveFee, + lightningFeeReserve: quote.tokenReceiveData.lightningFeeReserve, + } + : undefined; + + const receiveData = CashuLightningReceiveDbDataSchema.parse({ + paymentRequest: quote.paymentRequest, + mintQuoteId: quote.quoteId, + amountReceived: quote.amount, + description: quote.description, + mintingFee: quote.mintingFee, + cashuTokenMeltData, + totalFee: quote.totalFee, + outputAmounts, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(receiveData); + + const { data, error } = await this.rpc( + 'process_cashu_receive_quote_payment', + { + p_quote_id: quote.id, + p_keyset_id: keysetId, + p_number_of_outputs: outputAmounts.length, + p_encrypted_data: encryptedData, + }, + options, + ); + + if (error) { + throw new Error('Failed to mark cashu receive quote as paid', { + cause: error, + }); + } + + const [updatedQuote, account] = await Promise.all([ + this.toQuote(data.quote), + this.mapAccount(data.account), + ]); + + return { quote: updatedQuote, account }; + } + + /** + * Complete the receive quote (RPC `complete_cashu_receive_quote`): stores the minted proofs + * + marks the quote COMPLETED. Returns the updated quote + account + added proof ids. + */ + async completeReceive( + { quoteId, proofs }: { quoteId: string; proofs: Proof[] }, + options?: Options, + ): Promise<{ + quote: CashuReceiveQuote; + account: CashuAccount; + addedProofs: string[]; + }> { + const dataToEncrypt = proofs.flatMap((x) => [x.amount, x.secret]); + const encryptedData = await this.encryption.encryptBatch(dataToEncrypt); + const encryptedProofs = proofs.map((x, index) => { + const i = index * 2; + return { + keysetId: x.id, + amount: encryptedData[i], + secret: encryptedData[i + 1], + unblindedSignature: x.C, + publicKeyY: proofToY(x), + dleq: x.dleq ?? null, + witness: x.witness ?? null, + }; + }); + + const { data, error } = await this.rpc( + 'complete_cashu_receive_quote', + { p_quote_id: quoteId, p_proofs: encryptedProofs }, + options, + ); + + if (error) { + throw new Error('Failed to complete cashu receive quote', { + cause: error, + }); + } + + const [quote, account] = await Promise.all([ + this.toQuote(data.quote), + this.mapAccount(data.account), + ]); + + return { + quote, + account, + addedProofs: data.added_proofs.map((x: { id: string }) => x.id), + }; + } + + /** Get the receive quote with the given id, or null. */ + async get(id: string, options?: Options): Promise { + let query = this.db.from('cashu_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 cashu receive quote', { cause: error }); + } + return data ? this.toQuote(data) : null; + } + + /** + * Get all pending (UNPAID or PAID) receive quotes for the user. INTERNAL — feeds the future + * orchestrator's resume sweep (the public interface has no `listPending`). + */ + async getPending( + userId: string, + options?: Options, + ): Promise { + let query = this.db + .from('cashu_receive_quotes') + .select() + .eq('user_id', userId) + .in('state', ['UNPAID', 'PAID']); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = await query.returns(); + if (error) { + throw new Error('Failed to get cashu receive quotes', { cause: error }); + } + return Promise.all(data.map((d) => this.toQuote(d))); + } + + /** Map a DB row to the domain {@link CashuReceiveQuote}. */ + async toQuote(data: AgicashDbCashuReceiveQuote): Promise { + const decryptedData = await this.encryption.decrypt(data.encrypted_data); + const receiveData = CashuLightningReceiveDbDataSchema.parse(decryptedData); + + return CashuReceiveQuoteSchema.parse({ + id: data.id, + userId: data.user_id, + accountId: data.account_id, + quoteId: receiveData.mintQuoteId, + amount: receiveData.amountReceived, + description: receiveData.description, + createdAt: data.created_at, + expiresAt: data.expires_at, + paymentRequest: receiveData.paymentRequest, + paymentHash: data.payment_hash, + version: data.version, + lockingDerivationPath: data.locking_derivation_path, + transactionId: data.transaction_id, + mintingFee: receiveData.mintingFee, + totalFee: receiveData.totalFee, + type: data.type, + state: data.state, + tokenReceiveData: receiveData.cashuTokenMeltData + ? { + sourceMintUrl: receiveData.cashuTokenMeltData.tokenMintUrl, + tokenAmount: receiveData.cashuTokenMeltData.tokenAmount, + tokenProofs: receiveData.cashuTokenMeltData.tokenProofs, + meltQuoteId: receiveData.cashuTokenMeltData.meltQuoteId, + meltInitiated: data.cashu_token_melt_initiated as boolean, + cashuReceiveFee: receiveData.cashuTokenMeltData.cashuReceiveFee, + lightningFeeReserve: + receiveData.cashuTokenMeltData.lightningFeeReserve, + } + : undefined, + keysetId: data.keyset_id, + keysetCounter: data.keyset_counter, + outputAmounts: receiveData.outputAmounts, + failureReason: data.failure_reason ?? undefined, + }) as CashuReceiveQuote; + } + + /** Thin typed wrapper over the untyped Supabase `rpc` (see send-quote repo for the rationale). */ + 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. + 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/cashu-receive-quote-service.test.ts b/packages/wallet-sdk/src/internal/cashu-receive-quote-service.test.ts new file mode 100644 index 000000000..2a3b8a5b2 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-quote-service.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, mock, test } from 'bun:test'; +import type { CashuCryptography } from './cashu-receive-quote-service'; +import { CashuReceiveQuoteService } from './cashu-receive-quote-service'; +import type { CashuReceiveQuoteRepository } from './cashu-receive-quote-repository'; +import { type Currency, Money } from '../types/money'; +import type { CashuReceiveQuote } from '../types/cashu'; + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +function fakeCrypto(): CashuCryptography { + return { + getXpub: mock(async () => 'xpub'), + getPrivateKey: mock(async () => 'privkey'), + }; +} + +function fakeRepo(): CashuReceiveQuoteRepository { + return { + expire: mock(async () => undefined), + fail: mock(async () => undefined), + markMeltInitiated: mock(async (q: CashuReceiveQuote) => q), + create: mock(async () => quote({})), + } as unknown as CashuReceiveQuoteRepository; +} + +/** Build a LIGHTNING receive quote in the given state. */ +function quote( + overrides: Partial & { + state?: CashuReceiveQuote['state']; + }, +): CashuReceiveQuote { + return { + id: 'rq1', + userId: 'u1', + accountId: 'acc1', + transactionId: 'tx1', + quoteId: 'mint1', + amount: sats(100), + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-01T01:00:00.000Z', + paymentRequest: 'lnbc1...', + paymentHash: 'hash', + lockingDerivationPath: "m/129372'/0'/0'/1", + totalFee: sats(0), + version: 1, + type: 'LIGHTNING', + state: 'UNPAID', + ...overrides, + } as CashuReceiveQuote; +} + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuReceiveQuoteService idempotency guards', () => { + test('expire is a no-op when already EXPIRED', async () => { + const repo = fakeRepo(); + const service = new CashuReceiveQuoteService(fakeCrypto(), repo); + + await service.expire(quote({ state: 'EXPIRED' } as never)); + + expect(repo.expire).not.toHaveBeenCalled(); + }); + + test('expire rejects a not-yet-expired quote', async () => { + const service = new CashuReceiveQuoteService(fakeCrypto(), fakeRepo()); + const future = quote({ + 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 CashuReceiveQuoteService(fakeCrypto(), repo); + + await service.fail( + quote({ state: 'FAILED', failureReason: 'x' } as never), + 'r', + ); + + expect(repo.fail).not.toHaveBeenCalled(); + }); + + test('fail rejects a non-UNPAID quote', async () => { + const service = new CashuReceiveQuoteService(fakeCrypto(), fakeRepo()); + await expect( + service.fail(quote({ state: 'COMPLETED' } as never), 'r'), + ).rejects.toThrow(/not unpaid/); + }); + + test('markMeltInitiated rejects a LIGHTNING quote', async () => { + const service = new CashuReceiveQuoteService(fakeCrypto(), fakeRepo()); + await expect( + service.markMeltInitiated(quote({ type: 'LIGHTNING' }) as never), + ).rejects.toThrow(/must be of type CASHU_TOKEN/); + }); + + test('getLightningQuote derives the xPub at the base locking path', async () => { + const crypto = fakeCrypto(); + const service = new CashuReceiveQuoteService(crypto, fakeRepo()); + // A wallet whose createLockedMintQuote is stubbed; getLightningQuote only needs the xPub + // derivation + the mint call. + const wallet = { + createLockedMintQuote: mock(async () => ({ + quote: 'mint1', + request: 'lnbc1...', + state: 'UNPAID', + expiry: Math.floor(Date.now() / 1000) + 3600, + })), + }; + + // The decodeBolt11 of 'lnbc1...' would fail; stub the mint request to a decodable invoice is + // out of scope — assert the xPub derivation happened before the mint call. + await service + .getLightningQuote({ + // biome-ignore lint/suspicious/noExplicitAny: minimal wallet stub for the xPub-derivation assertion. + wallet: wallet as any, + amount: sats(100), + }) + .catch(() => { + /* decodeBolt11 on the stub request may throw; we only assert the xPub call below */ + }); + + expect(crypto.getXpub).toHaveBeenCalledWith("m/129372'/0'/0'"); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-receive-quote-service.ts b/packages/wallet-sdk/src/internal/cashu-receive-quote-service.ts new file mode 100644 index 000000000..d199cef1d --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-quote-service.ts @@ -0,0 +1,348 @@ +/** + * Cashu lightning-RECEIVE SERVICE — Slice 3 / PR5b. The idempotent primitives for a + * `CashuReceiveQuote`'s lifecycle. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/receive/cashu-receive-quote-service.ts`. Master's + * `CashuReceiveQuoteService` is a plain class (only the `useCashuReceiveQuoteService()` factory + * couples it to React); lifted near-verbatim, taking the SDK {@link CashuReceiveQuoteRepository} + * + a {@link CashuCryptography} (the xPub / private-key the NUT-20 quote-locking needs, backed + * by the OpenSecret client — re-housed off master's `useCashuCryptography` query options). + * + * `mintProofs`'s `wallet.restore` recovery (on OUTPUT_ALREADY_SIGNED / QUOTE_ALREADY_ISSUED) is + * the re-mint / idempotency protection — preserved verbatim. The completion path + * (`completeReceive` / `processPayment` → mint proofs) is what the (future) `executeQuote` + * orchestrator drives; `createLightningQuote` (`getLightningQuote` + `createReceiveQuote`) is the + * user-invoked kickoff. + * + * @module + */ +import { + MintOperationError, + MintQuoteState, + OutputData, + type Proof, + splitAmount, +} from '@cashu/cashu-ts'; +import { CashuErrorCodes } from './cashu-error-codes'; +import type { CashuReceiveQuoteRepository } from './cashu-receive-quote-repository'; +import { + BASE_CASHU_LOCKING_DERIVATION_PATH, + derivePublicKey, +} from './lib-cashu-crypto'; +import { + type CashuReceiveLightningQuote, + type CreateQuoteBaseParams, + type GetLightningQuoteParams, + computeQuoteExpiry, + computeTotalFee, + getLightningQuote, +} from './cashu-receive-quote-core'; +import { getCashuUnit } from './lib-cashu'; +import type { ExtendedCashuWallet } from './lib-cashu-wallet'; +import type { CashuAccount } from '../types/account'; +import type { CashuReceiveQuote } from '../types/cashu'; + +/** + * The cashu key operations the receive flow needs (xPub for NUT-20 quote-locking, private key + * for unlocking when minting). Re-housed off master's `shared/cashu.ts#CashuCryptography` + * (React-coupled query options) — the SDK backs it with the OpenSecret client. + */ +export type CashuCryptography = { + /** The cashu locking xPub at the given derivation path. */ + getXpub: (derivationPath?: string) => Promise; + /** The hex-encoded cashu locking private key at the given derivation path. */ + getPrivateKey: (derivationPath?: string) => Promise; +}; + +/** Idempotent service primitives for a cashu lightning-receive quote. */ +export class CashuReceiveQuoteService { + constructor( + private readonly cryptography: CashuCryptography, + private readonly cashuReceiveQuoteRepository: CashuReceiveQuoteRepository, + ) {} + + /** + * Get a NUT-20-locked mint quote (the locked invoice to receive over). Master verbatim. + * + * @returns the mint quote + the data needed to persist a receive quote. + */ + async getLightningQuote( + params: Omit, + ): Promise { + const xPub = await this.cryptography.getXpub( + BASE_CASHU_LOCKING_DERIVATION_PATH, + ); + return getLightningQuote({ ...params, xPub }); + } + + /** + * Create (persist) a receive quote from a lightning quote. Master verbatim. + * + * @throws Error if the mint quote is not UNPAID. + */ + async createReceiveQuote( + params: CreateQuoteBaseParams, + ): Promise { + const { + userId, + account, + lightningQuote, + receiveType, + purpose, + transferId, + } = params; + + if (lightningQuote.mintQuote.state !== MintQuoteState.UNPAID) { + throw new Error('Mint quote must be unpaid'); + } + + const expiresAt = computeQuoteExpiry(params); + const totalFee = computeTotalFee(params); + + const baseParams = { + accountId: account.id, + userId, + amount: lightningQuote.amount, + description: lightningQuote.description, + quoteId: lightningQuote.mintQuote.quote, + expiresAt, + paymentRequest: lightningQuote.mintQuote.request, + paymentHash: lightningQuote.paymentHash, + lockingDerivationPath: lightningQuote.fullLockingDerivationPath, + mintingFee: lightningQuote.mintingFee, + totalFee, + receiveType, + purpose, + transferId, + }; + + if (receiveType === 'CASHU_TOKEN') { + return this.cashuReceiveQuoteRepository.create({ + ...baseParams, + receiveType, + meltData: { + tokenMintUrl: params.sourceMintUrl, + tokenAmount: params.tokenAmount, + tokenProofs: params.tokenProofs, + meltQuoteId: params.meltQuoteId, + cashuReceiveFee: params.cashuReceiveFee, + lightningFeeReserve: params.lightningFeeReserve, + }, + }); + } + + return this.cashuReceiveQuoteRepository.create({ + ...baseParams, + receiveType, + }); + } + + /** + * Mark the melt initiated for a CASHU_TOKEN receive. No-op if already initiated. Master verbatim. + * + * @throws Error if the quote is not CASHU_TOKEN or is not UNPAID. + */ + async markMeltInitiated( + quote: CashuReceiveQuote & { 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.cashuReceiveQuoteRepository.markMeltInitiated(quote); + } + + /** + * Expire the receive quote. No-op if already EXPIRED. Master verbatim. + * + * @throws Error if the quote is not UNPAID or has not expired yet. + */ + async expire(quote: CashuReceiveQuote): Promise { + if (quote.state === 'EXPIRED') { + return; + } + if (quote.state !== 'UNPAID') { + throw new Error('Cannot expire quote that is not unpaid'); + } + if (new Date(quote.expiresAt) > new Date()) { + throw new Error('Cannot expire quote that has not expired yet'); + } + await this.cashuReceiveQuoteRepository.expire(quote.id); + } + + /** + * Fail the receive quote. No-op if already FAILED. Master verbatim. + * + * @throws Error if the quote is not UNPAID. + */ + async fail(quote: CashuReceiveQuote, reason: string): Promise { + if (quote.state === 'FAILED') { + return; + } + if (quote.state !== 'UNPAID') { + throw new Error('Cannot fail quote that is not unpaid'); + } + await this.cashuReceiveQuoteRepository.fail({ id: quote.id, reason }); + } + + /** + * Complete the receive: prepare output data, mint proofs, persist + credit the account. + * No-op if already COMPLETED. Master verbatim. + * + * @returns the updated quote, account, and added proof ids. + * @throws Error if the quote belongs to another account or is expired/failed. + */ + async completeReceive( + account: CashuAccount, + quote: CashuReceiveQuote, + ): Promise<{ + quote: CashuReceiveQuote; + account: CashuAccount; + addedProofs: string[]; + }> { + if (quote.accountId !== account.id) { + throw new Error('Quote does not belong to account'); + } + if (quote.state === 'EXPIRED' || quote.state === 'FAILED') { + throw new Error( + `Cannot complete quote that is expired or failed. State: ${quote.state}`, + ); + } + if (quote.state === 'COMPLETED') { + return { quote, account, addedProofs: [] }; + } + + const wallet = account.wallet; + + if (quote.state === 'UNPAID') { + return this.processUnpaidQuote(wallet, quote); + } + return this.processPaidQuote(wallet, quote); + } + + private async processUnpaidQuote( + wallet: ExtendedCashuWallet, + quote: CashuReceiveQuote, + ): Promise<{ + quote: CashuReceiveQuote; + account: CashuAccount; + addedProofs: string[]; + }> { + const keysetId = wallet.keysetId; + const keyset = wallet.getKeyset(keysetId); + const cashuUnit = getCashuUnit(quote.amount.currency); + const amountInCashuUnit = quote.amount.toNumber(cashuUnit); + const outputAmounts = splitAmount(amountInCashuUnit, keyset.keys); + + const result = await this.cashuReceiveQuoteRepository.processPayment({ + quote, + keysetId, + outputAmounts, + }); + + return this.processPaidQuote(wallet, result.quote); + } + + private async processPaidQuote( + wallet: ExtendedCashuWallet, + quote: CashuReceiveQuote, + ): Promise<{ + quote: CashuReceiveQuote; + account: CashuAccount; + addedProofs: string[]; + }> { + if (quote.state !== 'PAID') { + throw new Error('Quote must be in PAID state'); + } + + const cashuUnit = getCashuUnit(quote.amount.currency); + await wallet.keyChain.ensureKeysetKeys(quote.keysetId); + const keyset = wallet.getKeyset(quote.keysetId); + + const outputData = OutputData.createDeterministicData( + quote.amount.toNumber(cashuUnit), + wallet.seed, + quote.keysetCounter, + keyset, + quote.outputAmounts, + ); + + const mintedProofs = await this.mintProofs(wallet, quote, outputData); + + return this.cashuReceiveQuoteRepository.completeReceive({ + quoteId: quote.id, + proofs: mintedProofs, + }); + } + + private async mintProofs( + wallet: ExtendedCashuWallet, + quote: CashuReceiveQuote, + outputData: OutputData[], + ): Promise { + if (quote.state !== 'PAID') { + throw new Error( + 'Invalid quote state. Quote must be in PAID state to mint proofs.', + ); + } + + try { + const cashuUnit = getCashuUnit(quote.amount.currency); + const amount = quote.amount.toNumber(cashuUnit); + + const unlockingKey = await this.cryptography.getPrivateKey( + quote.lockingDerivationPath, + ); + + const xPub = await this.cryptography.getXpub( + BASE_CASHU_LOCKING_DERIVATION_PATH, + ); + const segments = quote.lockingDerivationPath.split('/'); + const unhardenedIndex = segments[segments.length - 1]; + const lockingPublicKey = derivePublicKey(xPub, `m/${unhardenedIndex}`); + + return await wallet.ops + .mintBolt11(amount, { + quote: quote.quoteId, + request: quote.paymentRequest, + state: MintQuoteState.PAID, + expiry: Math.floor(new Date(quote.expiresAt).getTime() / 1000), + pubkey: lockingPublicKey, + amount, + unit: wallet.unit, + }) + .keyset(quote.keysetId) + .privkey(unlockingKey) + .asCustom(outputData) + .run(); + } catch (error) { + if ( + error instanceof MintOperationError && + ([ + CashuErrorCodes.OUTPUT_ALREADY_SIGNED, + CashuErrorCodes.QUOTE_ALREADY_ISSUED, + ].includes(error.code) || + error.message + .toLowerCase() + .includes('outputs have already been signed before') || + error.message.toLowerCase().includes('mint quote already issued.')) + ) { + const { proofs } = await wallet.restore( + quote.keysetCounter, + quote.outputAmounts.length, + { keysetId: quote.keysetId }, + ); + return proofs; + } + throw error; + } + } +} diff --git a/packages/wallet-sdk/src/internal/cashu-receive-swap-repository.test.ts b/packages/wallet-sdk/src/internal/cashu-receive-swap-repository.test.ts new file mode 100644 index 000000000..8bb305ad9 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-swap-repository.test.ts @@ -0,0 +1,74 @@ +import type { Token } from '@cashu/cashu-ts'; +import { describe, expect, mock, test } from 'bun:test'; +import { CashuReceiveSwapRepository } from './cashu-receive-swap-repository'; +import type { Encryption } from './encryption'; +import type { WalletSupabaseClient } from './supabase-client'; +import { UniqueConstraintError } from '../errors'; +import { type Currency, Money } from '../types/money'; + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +const encryption: Encryption = { + encrypt: async () => 'enc', + encryptBatch: async (data: readonly unknown[]) => data.map(() => 'enc'), + decrypt: async () => undefined as never, + decryptBatch: async (data: readonly unknown[]) => data.map(() => '') as never, +}; + +function fakeDb(rpcResult: { + data?: unknown; + error?: unknown; +}): WalletSupabaseClient { + return { + rpc: mock(() => Promise.resolve(rpcResult)), + } as unknown as WalletSupabaseClient; +} + +const token: Token = { + mint: 'https://mint.example', + unit: 'sat', + proofs: [{ id: 'ks1', amount: 100, secret: 's', C: 'C' } as never], +}; + +const createParams = { + userId: 'u1', + accountId: 'acc1', + keysetId: 'ks1', + inputAmount: sats(100), + cashuReceiveFee: sats(0), + receiveAmount: sats(100), + outputAmounts: [100], + token, +}; + +const mapAccount = mock(async () => ({}) as never); + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuReceiveSwapRepository.create — double-claim guard', () => { + test('a 23505 unique-constraint violation becomes a UniqueConstraintError', async () => { + const repo = new CashuReceiveSwapRepository( + fakeDb({ error: { code: '23505', message: 'duplicate key' } }), + encryption, + mapAccount, + ); + + await expect(repo.create(createParams)).rejects.toBeInstanceOf( + UniqueConstraintError, + ); + }); + + test('other errors become a generic Error', async () => { + const repo = new CashuReceiveSwapRepository( + fakeDb({ error: { code: 'XXXXX', message: 'boom' } }), + encryption, + mapAccount, + ); + await expect(repo.create(createParams)).rejects.toThrow( + /Failed to create receive swap/, + ); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-receive-swap-repository.ts b/packages/wallet-sdk/src/internal/cashu-receive-swap-repository.ts new file mode 100644 index 000000000..e7139d98b --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-swap-repository.ts @@ -0,0 +1,294 @@ +/** + * Internal `wallet.cashu_receive_swaps` repository — Slice 3 / PR5b (same-mint token claim). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/receive/cashu-receive-swap-repository.ts`. Same re-housing as + * the other repos; the account-returning RPCs (`create_cashu_receive_swap` / + * `complete_cashu_receive_swap`) take an injected {@link AccountMapper}. The DB unique + * constraint on the token hash (a duplicate claim, `23505`) surfaces as + * {@link UniqueConstraintError}. + * + * The receive-SWAP domain type ({@link CashuReceiveSwap}) is INTERNAL — it is the same-mint + * token-claim working object the (future) orchestrator drives + the {@link reverse} target; + * the PUBLIC `receiveToken` result is a `CashuReceiveQuote` (§5). Typed here as the + * `z.infer` of the re-housed `CashuReceiveSwapSchema`. + * + * @module + */ +import type { Proof, Token } from '@cashu/cashu-ts'; +import type { z } from 'zod/mini'; +import type { AgicashDbAccountWithProofs } from './db-account'; +import { + CashuReceiveSwapSchema, + CashuSwapReceiveDbDataSchema, + getTokenHash, + proofToY, +} from './lib-cashu-quotes'; +import type { WalletSupabaseClient } from './supabase-client'; +import { UniqueConstraintError } from '../errors'; +import type { Encryption } from './encryption'; +import type { CashuAccount } from '../types/account'; +import type { Money } from '../types/money'; + +/** Per-call query options (an abort signal, matching master). */ +type Options = { abortSignal?: AbortSignal }; + +/** The INTERNAL cashu receive-swap domain type (`z.infer` of the re-housed schema). */ +export type CashuReceiveSwap = z.infer; + +/** Maps a returned account row (joined with proofs) to the domain {@link CashuAccount}. */ +export type AccountMapper = ( + row: AgicashDbAccountWithProofs, +) => Promise; + +/** + * A row of the `wallet.cashu_receive_swaps` table (hand-written; master = generated types). + * Only the columns `toReceiveSwap` reads are typed. + */ +export type AgicashDbCashuReceiveSwap = { + token_hash: string; + user_id: string; + account_id: string; + transaction_id: string; + encrypted_data: string; + keyset_id: string; + keyset_counter: number; + created_at: string; + version: number; + state: CashuReceiveSwap['state']; + failure_reason?: string | null; +}; + +/** Params for {@link CashuReceiveSwapRepository.create} (master `CreateReceiveSwap`). */ +export type CreateCashuReceiveSwap = { + userId: string; + accountId: string; + keysetId: string; + inputAmount: Money; + cashuReceiveFee: Money; + receiveAmount: Money; + outputAmounts: number[]; + token: Token; + reversedTransactionId?: string; +}; + +/** Reads + writes for the `wallet.cashu_receive_swaps` table (RLS-scoped). */ +export class CashuReceiveSwapRepository { + constructor( + private readonly db: WalletSupabaseClient, + private readonly encryption: Encryption, + private readonly mapAccount: AccountMapper, + ) {} + + /** + * Create a same-mint receive swap (RPC `create_cashu_receive_swap`) + bump the account keyset + * counter. Raises {@link UniqueConstraintError} when the token has already been claimed. + */ + async create( + { + token, + userId, + accountId, + keysetId, + inputAmount, + cashuReceiveFee, + receiveAmount, + outputAmounts, + reversedTransactionId, + }: CreateCashuReceiveSwap, + options?: Options, + ): Promise<{ swap: CashuReceiveSwap; account: CashuAccount }> { + const currency = inputAmount.currency; + const tokenHash = await getTokenHash(token); + + const receiveData = CashuSwapReceiveDbDataSchema.parse({ + tokenMintUrl: token.mint, + tokenAmount: inputAmount, + tokenProofs: token.proofs, + tokenDescription: token.memo, + amountReceived: receiveAmount, + outputAmounts, + cashuReceiveFee, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(receiveData); + + const { data, error } = await this.rpc( + 'create_cashu_receive_swap', + { + p_token_hash: tokenHash, + p_account_id: accountId, + p_user_id: userId, + p_currency: currency, + p_keyset_id: keysetId, + p_number_of_outputs: outputAmounts.length, + p_encrypted_data: encryptedData, + p_reversed_transaction_id: reversedTransactionId, + }, + options, + ); + + if (error) { + if (error.code === '23505') { + throw new UniqueConstraintError( + 'This token has already been claimed', + 'TOKEN_ALREADY_CLAIMED', + ); + } + throw new Error('Failed to create receive swap', { cause: error }); + } + + const [swap, account] = await Promise.all([ + this.toReceiveSwap(data.swap), + this.mapAccount(data.account), + ]); + return { swap, account }; + } + + /** + * Complete the token claim (RPC `complete_cashu_receive_swap`): stores the new proofs + marks + * the swap COMPLETED. Returns the updated swap + account + added proof ids. + */ + async completeReceiveSwap( + { + tokenHash, + userId, + proofs, + }: { + tokenHash: string; + userId: string; + proofs: Proof[]; + }, + options?: Options, + ): Promise<{ + swap: CashuReceiveSwap; + account: CashuAccount; + addedProofs: string[]; + }> { + const dataToEncrypt = proofs.flatMap((x) => [x.amount, x.secret]); + const encryptedData = await this.encryption.encryptBatch(dataToEncrypt); + const encryptedProofs = proofs.map((x, index) => { + const i = index * 2; + return { + keysetId: x.id, + amount: encryptedData[i], + secret: encryptedData[i + 1], + unblindedSignature: x.C, + publicKeyY: proofToY(x), + dleq: x.dleq ?? null, + witness: x.witness ?? null, + }; + }); + + const { data, error } = await this.rpc( + 'complete_cashu_receive_swap', + { p_token_hash: tokenHash, p_user_id: userId, p_proofs: encryptedProofs }, + options, + ); + + if (error) { + throw new Error('Failed to complete token claim', { cause: error }); + } + if (!data) { + throw new Error('No data returned from complete_cashu_receive_swap'); + } + + const [swap, account] = await Promise.all([ + this.toReceiveSwap(data.swap), + this.mapAccount(data.account), + ]); + return { + swap, + account, + addedProofs: data.added_proofs.map((x: { id: string }) => x.id), + }; + } + + /** Fail the receive swap (RPC `fail_cashu_receive_swap`). */ + async fail( + { + tokenHash, + userId, + reason, + }: { + tokenHash: string; + userId: string; + reason: string; + }, + options?: Options, + ): Promise { + const { data, error } = await this.rpc( + 'fail_cashu_receive_swap', + { p_token_hash: tokenHash, p_user_id: userId, p_failure_reason: reason }, + options, + ); + if (error) { + throw new Error('Failed to fail receive swap', { cause: error }); + } + return this.toReceiveSwap(data); + } + + /** + * Get all pending receive swaps for the user. INTERNAL — feeds the future orchestrator's + * resume sweep (the public interface has no `listPending`). + */ + async getPending( + userId: string, + options?: Options, + ): Promise { + let query = this.db + .from('cashu_receive_swaps') + .select() + .match({ user_id: userId, state: 'PENDING' }); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = await query.returns(); + if (error) { + throw new Error('Failed to get pending receive swaps', { cause: error }); + } + return Promise.all(data.map((item) => this.toReceiveSwap(item))); + } + + /** Map a DB row to the internal {@link CashuReceiveSwap}. */ + async toReceiveSwap( + data: AgicashDbCashuReceiveSwap, + ): Promise { + const decryptedData = await this.encryption.decrypt(data.encrypted_data); + const receiveData = CashuSwapReceiveDbDataSchema.parse(decryptedData); + + return CashuReceiveSwapSchema.parse({ + tokenHash: data.token_hash, + tokenProofs: receiveData.tokenProofs, + tokenDescription: receiveData.tokenDescription, + userId: data.user_id, + accountId: data.account_id, + inputAmount: receiveData.tokenAmount, + amountReceived: receiveData.amountReceived, + feeAmount: receiveData.cashuReceiveFee, + keysetId: data.keyset_id, + keysetCounter: data.keyset_counter, + outputAmounts: receiveData.outputAmounts, + transactionId: data.transaction_id, + version: data.version, + createdAt: data.created_at, + state: data.state, + failureReason: data.failure_reason ?? undefined, + }); + } + + /** Thin typed wrapper over the untyped Supabase `rpc` (see send-quote repo for the rationale). */ + 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. + 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/cashu-receive-swap-service.test.ts b/packages/wallet-sdk/src/internal/cashu-receive-swap-service.test.ts new file mode 100644 index 000000000..9091a9542 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-swap-service.test.ts @@ -0,0 +1,155 @@ +import type { Token } from '@cashu/cashu-ts'; +import { describe, expect, mock, test } from 'bun:test'; +import type { + CashuReceiveSwap, + CashuReceiveSwapRepository, +} from './cashu-receive-swap-repository'; +import { CashuReceiveSwapService } from './cashu-receive-swap-service'; +import type { CashuAccount } from '../types/account'; + +// -- Fakes ---------------------------------------------------------------------------------- + +/** A fake live cashu wallet exposing only the methods the create/fee path touches. */ +function fakeWallet(overrides: Record = {}) { + return { + keysetId: 'ks1', + getKeyset: () => ({ id: 'ks1', keys: { '1': '02aa', '2': '02bb' } }), + getFeesForProofs: () => 0, + ...overrides, + }; +} + +function fakeAccount(wallet: ReturnType): CashuAccount { + return { + id: 'acc1', + type: 'cashu', + currency: 'BTC', + mintUrl: 'https://mint.example', + wallet, + } as unknown as CashuAccount; +} + +const token: Token = { + mint: 'https://mint.example', + unit: 'sat', + proofs: [ + // amount summed by sumProofs; the value matters, the crypto fields do not for create(). + { id: 'ks1', amount: 100, secret: 's', C: 'C' } as never, + ], +}; + +function fakeRepo( + createImpl?: () => Promise<{ swap: CashuReceiveSwap; account: CashuAccount }>, +): CashuReceiveSwapRepository { + return { + create: mock( + createImpl ?? + (async () => ({ + swap: {} as CashuReceiveSwap, + account: {} as CashuAccount, + })), + ), + fail: mock(async () => ({ state: 'FAILED' }) as CashuReceiveSwap), + completeReceiveSwap: mock(async () => ({ + swap: {} as CashuReceiveSwap, + account: {} as CashuAccount, + addedProofs: [], + })), + } as unknown as CashuReceiveSwapRepository; +} + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuReceiveSwapService.create', () => { + test('creates a same-mint swap (passes the reversedTransactionId through)', async () => { + const repo = fakeRepo(); + const service = new CashuReceiveSwapService(repo); + const account = fakeAccount(fakeWallet()); + + await service.create({ + userId: 'u1', + token, + account, + reversedTransactionId: 'tx-orig', + }); + + expect(repo.create).toHaveBeenCalledTimes(1); + const createMock = repo.create as unknown as { + mock: { + calls: { + reversedTransactionId?: string; + accountId: string; + outputAmounts: number[]; + }[][]; + }; + }; + const arg = createMock.mock.calls[0][0]; + expect(arg.reversedTransactionId).toBe('tx-orig'); + expect(arg.accountId).toBe('acc1'); + // amountToReceive = 100 (proofs) - 0 (fee) → split into power-of-two output amounts. + expect(arg.outputAmounts.length).toBeGreaterThan(0); + }); + + test('rejects a token from a different mint', async () => { + const service = new CashuReceiveSwapService(fakeRepo()); + const account = fakeAccount(fakeWallet()); + + await expect( + service.create({ + userId: 'u1', + token: { ...token, mint: 'https://other.example' }, + account, + }), + ).rejects.toThrow(/different mint/); + }); + + test('rejects a token whose net amount (after fee) is not positive', async () => { + const service = new CashuReceiveSwapService(fakeRepo()); + // fee >= token amount → amountToReceive <= 0. + const account = fakeAccount(fakeWallet({ getFeesForProofs: () => 100 })); + + await expect( + service.create({ userId: 'u1', token, account }), + ).rejects.toThrow(/too small/); + }); +}); + +describe('CashuReceiveSwapService idempotency / double-claim guards', () => { + test('fail is a no-op when already FAILED', async () => { + const repo = fakeRepo(); + const service = new CashuReceiveSwapService(repo); + + const failed = { state: 'FAILED' } as CashuReceiveSwap; + const result = await service.fail(failed, 'reason'); + + expect(result).toBe(failed); + expect(repo.fail).not.toHaveBeenCalled(); + }); + + test('fail rejects a non-PENDING swap', async () => { + const service = new CashuReceiveSwapService(fakeRepo()); + await expect( + service.fail({ state: 'COMPLETED' } as CashuReceiveSwap, 'r'), + ).rejects.toThrow(/not pending/); + }); + + test('completeSwap is a no-op when already COMPLETED', async () => { + const repo = fakeRepo(); + const service = new CashuReceiveSwapService(repo); + const account = fakeAccount(fakeWallet()); + + const completed = { state: 'COMPLETED' } as CashuReceiveSwap; + const result = await service.completeSwap(account, completed); + + expect(result.swap).toBe(completed); + expect(repo.completeReceiveSwap).not.toHaveBeenCalled(); + }); + + test('completeSwap rejects a non-PENDING swap', async () => { + const service = new CashuReceiveSwapService(fakeRepo()); + const account = fakeAccount(fakeWallet()); + await expect( + service.completeSwap(account, { state: 'FAILED' } as CashuReceiveSwap), + ).rejects.toThrow(/not pending/); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-receive-swap-service.ts b/packages/wallet-sdk/src/internal/cashu-receive-swap-service.ts new file mode 100644 index 000000000..233df61d1 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-receive-swap-service.ts @@ -0,0 +1,237 @@ +/** + * Cashu same-mint token-claim SERVICE — Slice 3 / PR5b. The idempotent primitives for a + * same-mint `CashuReceiveSwap` (claim a token into the account it originated from). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/receive/cashu-receive-swap-service.ts`. Master's + * `CashuReceiveSwapService` is a plain class (only the `useCashuReceiveSwapService()` factory + * couples it to React); lifted near-verbatim, taking the SDK {@link CashuReceiveSwapRepository}. + * + * `completeSwap`'s `wallet.restore` recovery (on OUTPUT_ALREADY_SIGNED / TOKEN_ALREADY_SPENT) + * is the stale-proof / double-claim protection — preserved verbatim. The `CashuReceiveSwap` + * type is INTERNAL (see the repo). + * + * @module + */ +import { + MintOperationError, + OutputData, + type Token, + type Wallet, + splitAmount, +} from '@cashu/cashu-ts'; +import { CashuErrorCodes } from './cashu-error-codes'; +import type { + CashuReceiveSwap, + CashuReceiveSwapRepository, +} from './cashu-receive-swap-repository'; +import { + areMintUrlsEqual, + getCashuUnit, + sumProofs, + tokenToMoney, +} from './lib-cashu-quotes'; +import type { CashuAccount } from '../types/account'; +import { Money } from '../types/money'; + +/** Idempotent service primitives for a same-mint cashu token claim. */ +export class CashuReceiveSwapService { + constructor( + private readonly receiveSwapRepository: CashuReceiveSwapRepository, + ) {} + + /** + * Start a same-mint receive swap (create it + bump the account keyset counter). Master verbatim. + * + * @param params.reversedTransactionId - set when this swap is reversing a token send (decision 8). + * @returns the created swap + updated account. + * @throws Error on a cross-mint / cross-currency token, or a too-small token. + */ + async create({ + userId, + token, + account, + reversedTransactionId, + }: { + userId: string; + token: Token; + account: CashuAccount; + reversedTransactionId?: string; + }): Promise<{ swap: CashuReceiveSwap; account: CashuAccount }> { + if (!areMintUrlsEqual(account.mintUrl, token.mint)) { + throw new Error('Cannot swap a token to a different mint'); + } + + const inputAmount = tokenToMoney(token); + const currency = inputAmount.currency; + + if (currency !== account.currency) { + throw new Error('Cannot swap a token to a different currency.'); + } + + const wallet = account.wallet; + + const keyset = wallet.getKeyset(); + const fee = wallet.getFeesForProofs(token.proofs); + const amountToReceive = sumProofs(token.proofs) - fee; + + if (amountToReceive <= 0) { + throw new Error('Token is too small to claim.'); + } + + const cashuUnit = getCashuUnit(currency); + const feeAmount = new Money({ amount: fee, currency, unit: cashuUnit }); + const receiveAmount = new Money({ + amount: amountToReceive, + currency, + unit: cashuUnit, + }); + + const outputAmounts = splitAmount(amountToReceive, keyset.keys); + + return this.receiveSwapRepository.create({ + token, + userId, + accountId: account.id, + keysetId: wallet.keysetId, + inputAmount, + cashuReceiveFee: feeAmount, + receiveAmount, + outputAmounts, + reversedTransactionId, + }); + } + + /** + * Fail a PENDING receive swap. No-op if already FAILED. Master verbatim. + * + * @throws Error if the swap is not PENDING. + */ + async fail( + swap: CashuReceiveSwap, + reason: string, + ): Promise { + if (swap.state === 'FAILED') { + return swap; + } + if (swap.state !== 'PENDING') { + throw new Error( + `Cannot fail receive swap that is not pending. Current state: ${swap.state}`, + ); + } + return this.receiveSwapRepository.fail({ + tokenHash: swap.tokenHash, + userId: swap.userId, + reason, + }); + } + + /** + * Complete the receive swap by executing the mint swap + storing the output proofs. + * No-op if already COMPLETED. If the mint reports the token already claimed, the swap is + * failed instead of throwing. Master verbatim (`wallet.restore` recovery preserved). + * + * @returns the completed/failed swap, the updated account, and the added proof ids. + * @throws Error if the swap is not pending or the mint swap fails non-recoverably. + */ + async completeSwap( + account: CashuAccount, + receiveSwap: CashuReceiveSwap, + ): Promise<{ + swap: CashuReceiveSwap; + account: CashuAccount; + addedProofs: string[]; + }> { + if (receiveSwap.state === 'COMPLETED') { + return { swap: receiveSwap, account, addedProofs: [] }; + } + if (receiveSwap.state !== 'PENDING') { + throw new Error('Receive swap is not pending'); + } + + const wallet = account.wallet; + const { + keysetId, + keysetCounter, + amountReceived: receiveAmount, + outputAmounts, + } = receiveSwap; + + await wallet.keyChain.ensureKeysetKeys(keysetId); + const keyset = wallet.getKeyset(keysetId); + const outputData = OutputData.createDeterministicData( + receiveAmount.toNumber(getCashuUnit(receiveAmount.currency)), + wallet.seed, + keysetCounter, + keyset, + outputAmounts, + ); + + try { + const newProofs = await this.swapProofs(wallet, receiveSwap, outputData); + return this.receiveSwapRepository.completeReceiveSwap({ + tokenHash: receiveSwap.tokenHash, + userId: receiveSwap.userId, + proofs: newProofs, + }); + } catch (error) { + if (error instanceof Error && error.message === 'TOKEN_ALREADY_CLAIMED') { + const failedReceiveSwap = await this.fail( + receiveSwap, + 'Token already claimed', + ); + return { swap: failedReceiveSwap, account, addedProofs: [] }; + } + throw error; + } + } + + /** + * Perform the mint swap for the token proofs, recovering minted proofs via `wallet.restore` + * when the mint reports the outputs already signed/spent. Master verbatim. + */ + private async swapProofs( + wallet: Wallet, + receiveSwap: CashuReceiveSwap, + outputData: OutputData[], + ) { + try { + return await wallet.ops + .receive({ + mint: wallet.mint.mintUrl, + proofs: receiveSwap.tokenProofs, + unit: wallet.unit, + }) + .asCustom(outputData) + .run(); + } catch (error) { + if ( + error instanceof MintOperationError && + ([ + CashuErrorCodes.OUTPUT_ALREADY_SIGNED, + CashuErrorCodes.TOKEN_ALREADY_SPENT, + ].includes(error.code) || + error.message + .toLowerCase() + .includes('outputs have already been signed before')) + ) { + const { proofs } = await wallet.restore( + receiveSwap.keysetCounter, + receiveSwap.outputAmounts.length, + { keysetId: receiveSwap.keysetId }, + ); + + if ( + error.code === CashuErrorCodes.TOKEN_ALREADY_SPENT && + proofs.length === 0 + ) { + // Token spent + nothing to restore ⇒ someone else claimed it. + throw new Error('TOKEN_ALREADY_CLAIMED'); + } + + return proofs; + } + throw error; + } + } +} diff --git a/packages/wallet-sdk/src/internal/cashu-send-quote-repository.test.ts b/packages/wallet-sdk/src/internal/cashu-send-quote-repository.test.ts new file mode 100644 index 000000000..0ee5d9cae --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-quote-repository.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { CashuSendQuoteRepository } from './cashu-send-quote-repository'; +import type { Encryption } from './encryption'; +import type { WalletSupabaseClient } from './supabase-client'; +import { ConcurrencyError } from '../errors'; +import { type Currency, Money } from '../types/money'; + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +/** An Encryption stub: encrypt returns a marker, encryptBatch returns markers, decrypt unused. */ +const encryption: Encryption = { + encrypt: async () => 'enc', + encryptBatch: async (data: readonly unknown[]) => data.map(() => 'enc'), + decrypt: async () => undefined as never, + decryptBatch: async (data: readonly unknown[]) => data.map(() => '') as never, +}; + +/** A Supabase client whose `rpc` returns the queued `{ data, error }`. */ +function fakeDb(rpcResult: { + data?: unknown; + error?: unknown; +}): WalletSupabaseClient { + return { + rpc: mock(() => Promise.resolve(rpcResult)), + } as unknown as WalletSupabaseClient; +} + +const createParams = { + userId: 'u1', + accountId: 'acc1', + paymentRequest: 'lnbc1...', + paymentHash: 'hash', + expiresAt: '2026-01-01T01:00:00.000Z', + amountRequested: sats(100), + amountRequestedInMsat: 100_000, + amountToReceive: sats(100), + lightningFeeReserve: sats(1), + cashuFee: sats(0), + quoteId: 'melt1', + keysetId: 'ks1', + numberOfChangeOutputs: 0, + proofsToSend: [], + amountReserved: sats(101), +}; + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuSendQuoteRepository.create — stale-proof / concurrency guard', () => { + test('a CONCURRENCY_ERROR hint becomes a ConcurrencyError (preserving message + details)', async () => { + const repo = new CashuSendQuoteRepository( + fakeDb({ + error: { + hint: 'CONCURRENCY_ERROR', + message: 'proofs were reserved by another operation', + details: 'acc1', + }, + }), + encryption, + ); + + const promise = repo.create(createParams); + await expect(promise).rejects.toBeInstanceOf(ConcurrencyError); + await expect(promise).rejects.toThrow(/reserved by another operation/); + }); + + test('a non-concurrency error becomes a generic Error', async () => { + const repo = new CashuSendQuoteRepository( + fakeDb({ error: { message: 'boom' } }), + encryption, + ); + const promise = repo.create(createParams); + await expect(promise).rejects.toThrow(/Failed to create cashu send quote/); + await expect(promise).rejects.not.toBeInstanceOf(ConcurrencyError); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-send-quote-repository.ts b/packages/wallet-sdk/src/internal/cashu-send-quote-repository.ts new file mode 100644 index 000000000..a3597cc13 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-quote-repository.ts @@ -0,0 +1,375 @@ +/** + * Internal `wallet.cashu_send_quotes` repository — Slice 3 / PR5b (cashu lightning send). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/send/cashu-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_cashu_send_quote` / + * `complete_cashu_send_quote` / `mark_cashu_send_quote_as_pending` / `fail_…` / `expire_…`) + * are unchanged — the DB reservation + CONCURRENCY_ERROR guard (the stale-proof protection) + * lives in those stored procedures and is preserved verbatim. + * + * The only re-housing: the encrypted-jsonb schema (`CashuLightningSendDbDataSchema`) and the + * domain schema (`CashuSendQuoteSchema`) come from the SDK-internal single-source re-export + * (`./lib-cashu-quotes`); the DB-row types are the hand-written {@link AgicashDbCashuSendQuote} + * (matching `db-account.ts`'s approach, since the generated `database.types` are not yet + * lifted). `toQuote` maps a DB row → the public {@link CashuSendQuote}. + * + * @module + */ +import type { Proof } from '@cashu/cashu-ts'; +import type { z } from 'zod/mini'; +import type { AgicashDbCashuProof } from './db-account'; +import { + CashuLightningSendDbDataSchema, + CashuSendQuoteSchema, + proofToY, + toDecryptedCashuProofs, +} from './lib-cashu-quotes'; +import type { WalletSupabaseClient } from './supabase-client'; +import { computeSHA256 } from './crypto'; +import { ConcurrencyError } from '../errors'; +import type { Encryption } from './encryption'; +import type { CashuProof } from '../types/account'; +import type { CashuSendQuote, DestinationDetails } from '../types/cashu'; +import type { Money } from '../types/money'; + +/** Per-call query options (an abort signal, matching master). */ +type Options = { abortSignal?: AbortSignal }; + +/** + * A row of the `wallet.cashu_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 AgicashDbCashuSendQuote = { + id: string; + created_at: string; + expires_at: string; + user_id: string; + account_id: string; + transaction_id: string; + payment_hash: string; + encrypted_data: string; + keyset_id: string; + keyset_counter: number; + number_of_change_outputs: number; + version: number; + state: CashuSendQuote['state']; + failure_reason?: string | null; +}; + +/** Params for {@link CashuSendQuoteRepository.create} (master `CreateSendQuote`). */ +export type CreateCashuSendQuote = { + userId: string; + accountId: string; + paymentRequest: string; + paymentHash: string; + expiresAt: string; + amountRequested: Money; + amountRequestedInMsat: number; + amountToReceive: Money; + lightningFeeReserve: Money; + cashuFee: Money; + quoteId: string; + keysetId: string; + numberOfChangeOutputs: number; + proofsToSend: CashuProof[]; + amountReserved: Money; + destinationDetails?: DestinationDetails; + purpose?: string; + transferId?: string; +}; + +/** + * Reads + writes for the `wallet.cashu_send_quotes` table, scoped (via RLS) to the signed-in + * user. Holds the SDK-owned Supabase client + the SDK {@link Encryption}. + */ +export class CashuSendQuoteRepository { + constructor( + private readonly db: WalletSupabaseClient, + private readonly encryption: Encryption, + ) {} + + /** + * Create a cashu lightning send quote (RPC `create_cashu_send_quote`). The RPC reserves the + * input proofs atomically (UNSPENT → RESERVED) and raises `CONCURRENCY_ERROR` on a stale + * read — surfaced as {@link ConcurrencyError}. + */ + async create( + params: CreateCashuSendQuote, + options?: Options, + ): Promise { + const sendData = CashuLightningSendDbDataSchema.parse({ + paymentRequest: params.paymentRequest, + amountRequested: params.amountRequested, + amountRequestedInMsat: params.amountRequestedInMsat, + amountReceived: params.amountToReceive, + lightningFeeReserve: params.lightningFeeReserve, + cashuSendFee: params.cashuFee, + meltQuoteId: params.quoteId, + amountReserved: params.amountReserved, + destinationDetails: params.destinationDetails, + } satisfies z.input); + + const [encryptedData, quoteIdHash] = await Promise.all([ + this.encryption.encrypt(sendData), + computeSHA256(params.quoteId), + ]); + + const { data, error } = await this.rpc( + 'create_cashu_send_quote', + { + p_user_id: params.userId, + p_account_id: params.accountId, + p_currency: params.amountToReceive.currency, + p_currency_requested: params.amountRequested.currency, + p_expires_at: params.expiresAt, + p_keyset_id: params.keysetId, + p_number_of_change_outputs: params.numberOfChangeOutputs, + p_proofs_to_send: params.proofsToSend.map((p) => p.id), + p_encrypted_data: encryptedData, + p_quote_id_hash: quoteIdHash, + p_payment_hash: params.paymentHash, + p_purpose: params.purpose, + p_transfer_id: params.transferId, + }, + options, + ); + + if (error) { + if (error.hint === 'CONCURRENCY_ERROR') { + throw new ConcurrencyError(error.message, error.details); + } + throw new Error('Failed to create cashu send quote', { cause: error }); + } + + return this.toQuote({ ...data.quote, cashu_proofs: data.reserved_proofs }); + } + + /** + * Complete the send quote after a successful lightning payment (RPC + * `complete_cashu_send_quote`): stores the NUT-08 change proofs + the final fee data. + */ + async complete( + { + quote, + paymentPreimage, + amountSpent, + changeProofs, + }: { + quote: CashuSendQuote; + paymentPreimage: string; + amountSpent: Money; + changeProofs: Proof[]; + }, + options?: Options, + ): Promise { + const actualLightningFee = amountSpent + .subtract(quote.amountReceived) + .subtract(quote.cashuFee); + const totalFee = actualLightningFee.add(quote.cashuFee); + + const sendData = CashuLightningSendDbDataSchema.parse({ + paymentRequest: quote.paymentRequest, + amountRequested: quote.amountRequested, + amountRequestedInMsat: quote.amountRequestedInMsat, + amountReceived: quote.amountReceived, + lightningFeeReserve: quote.lightningFeeReserve, + cashuSendFee: quote.cashuFee, + meltQuoteId: quote.quoteId, + amountReserved: quote.amountReserved, + destinationDetails: quote.destinationDetails, + amountSpent, + paymentPreimage, + lightningFee: actualLightningFee, + totalFee, + } satisfies z.input); + + const proofDataToEncrypt = changeProofs.flatMap((x) => [ + x.amount, + x.secret, + ]); + const [encryptedData, ...encryptedProofData] = + await this.encryption.encryptBatch([sendData, ...proofDataToEncrypt]); + const encryptedProofs = changeProofs.map((x, index) => { + const i = index * 2; + return { + keysetId: x.id, + amount: encryptedProofData[i], + secret: encryptedProofData[i + 1], + unblindedSignature: x.C, + publicKeyY: proofToY(x), + dleq: x.dleq ?? null, + witness: x.witness ?? null, + }; + }); + + const { data, error } = await this.rpc( + 'complete_cashu_send_quote', + { + p_quote_id: quote.id, + p_change_proofs: encryptedProofs, + p_encrypted_data: encryptedData, + }, + options, + ); + + if (error) { + throw new Error('Failed to complete cashu send quote', { cause: error }); + } + + return this.toQuote({ ...data.quote, cashu_proofs: data.spent_proofs }); + } + + /** Mark the send quote PENDING (RPC `mark_cashu_send_quote_as_pending`). */ + async markAsPending(id: string, options?: Options): Promise { + const { data, error } = await this.rpc( + 'mark_cashu_send_quote_as_pending', + { p_quote_id: id }, + options, + ); + if (error) { + throw new Error('Failed to mark cashu send as pending', { cause: error }); + } + return this.toQuote({ ...data.quote, cashu_proofs: data.proofs }); + } + + /** Fail the send quote (RPC `fail_cashu_send_quote`), releasing the reserved proofs. */ + async fail( + { id, reason }: { id: string; reason: string }, + options?: Options, + ): Promise { + const { data, error } = await this.rpc( + 'fail_cashu_send_quote', + { p_quote_id: id, p_failure_reason: reason }, + options, + ); + if (error) { + throw new Error('Failed to fail cashu send quote', { cause: error }); + } + return this.toQuote({ ...data.quote, cashu_proofs: data.released_proofs }); + } + + /** Expire the send quote (RPC `expire_cashu_send_quote`), returning the reserved proofs. */ + async expire(id: string, options?: Options): Promise { + const { error } = await this.rpc( + 'expire_cashu_send_quote', + { p_quote_id: id }, + options, + ); + if (error) { + throw new Error('Failed to expire cashu send quote', { cause: error }); + } + } + + /** Get the send quote with the given id (joined with its reserved proofs), or null. */ + async get(id: string, options?: Options): Promise { + let query = this.db + .from('cashu_send_quotes') + .select('*, cashu_proofs!spending_cashu_send_quote_id(*)') + .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 cashu send', { cause: error }); + } + return data ? this.toQuote(data) : null; + } + + /** + * Get all unresolved (UNPAID or PENDING) send quotes for the user. INTERNAL — feeds the + * future orchestrator's resume sweep (the public interface has no `listPending`). + */ + async getUnresolved( + userId: string, + options?: Options, + ): Promise { + let query = this.db + .from('cashu_send_quotes') + .select('*, cashu_proofs!spending_cashu_send_quote_id(*)') + .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 pending cashu send quotes', { + cause: error, + }); + } + return Promise.all(data.map((x) => this.toQuote(x))); + } + + /** Map a DB row (joined with its proofs) to the domain {@link CashuSendQuote}. */ + async toQuote(data: CashuSendQuoteRow): Promise { + const proofsDataToDecrypt = data.cashu_proofs.flatMap((x) => [ + x.amount, + x.secret, + ]); + const [decryptedData, ...decryptedProofs] = + await this.encryption.decryptBatch([ + data.encrypted_data, + ...proofsDataToDecrypt, + ]); + const proofs = toDecryptedCashuProofs(data.cashu_proofs, decryptedProofs); + const sendData = CashuLightningSendDbDataSchema.parse(decryptedData); + + return CashuSendQuoteSchema.parse({ + id: data.id, + createdAt: data.created_at, + expiresAt: data.expires_at, + userId: data.user_id, + accountId: data.account_id, + paymentRequest: sendData.paymentRequest, + paymentHash: data.payment_hash, + amountRequested: sendData.amountRequested, + amountRequestedInMsat: sendData.amountRequestedInMsat, + amountReceived: sendData.amountReceived, + amountReserved: sendData.amountReserved, + lightningFeeReserve: sendData.lightningFeeReserve, + cashuFee: sendData.cashuSendFee, + proofs, + quoteId: sendData.meltQuoteId, + destinationDetails: sendData.destinationDetails, + keysetId: data.keyset_id, + keysetCounter: data.keyset_counter, + numberOfChangeOutputs: data.number_of_change_outputs, + version: data.version, + transactionId: data.transaction_id, + state: data.state, + failureReason: data.failure_reason ?? undefined, + paymentPreimage: sendData.paymentPreimage, + amountSpent: sendData.amountSpent, + lightningFee: sendData.lightningFee, + totalFee: sendData.totalFee, + }) as CashuSendQuote; + } + + /** + * 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; + } +} + +/** A `cashu_send_quotes` row joined with its reserved `cashu_proofs`. */ +type CashuSendQuoteRow = AgicashDbCashuSendQuote & { + cashu_proofs: AgicashDbCashuProof[]; +}; diff --git a/packages/wallet-sdk/src/internal/cashu-send-quote-service.test.ts b/packages/wallet-sdk/src/internal/cashu-send-quote-service.test.ts new file mode 100644 index 000000000..0e75a57df --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-quote-service.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { CashuSendQuoteService } from './cashu-send-quote-service'; +import type { CashuSendQuoteRepository } from './cashu-send-quote-repository'; +import { type Currency, Money } from '../types/money'; +import type { CashuAccount } from '../types/account'; +import type { CashuSendQuote } from '../types/cashu'; + +// -- Fakes ---------------------------------------------------------------------------------- + +/** A minimal repo whose methods are spies; each test stubs the ones it needs. */ +function fakeRepo(): CashuSendQuoteRepository { + return { + markAsPending: mock(async (id: string) => + baseQuote({ id, state: 'PENDING' }), + ), + fail: mock(async ({ id }: { id: string; reason: string }) => + baseQuote({ id, state: 'FAILED', failureReason: 'r' }), + ), + complete: mock(async () => baseQuote({ state: 'PAID' })), + expire: mock(async () => undefined), + create: mock(async () => baseQuote({})), + get: mock(async () => null), + getUnresolved: mock(async () => []), + toQuote: mock(async () => baseQuote({})), + } as unknown as CashuSendQuoteRepository; +} + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +/** Build a `CashuSendQuote` in the given state with sensible defaults. */ +function baseQuote( + overrides: Partial & { state?: CashuSendQuote['state'] }, +): CashuSendQuote { + 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', + paymentRequest: 'lnbc1...', + paymentHash: 'hash', + amountRequested: sats(100), + amountRequestedInMsat: 100_000, + amountReceived: sats(100), + lightningFeeReserve: sats(1), + cashuFee: sats(0), + quoteId: 'melt1', + proofs: [], + amountReserved: sats(101), + keysetId: 'ks1', + keysetCounter: 0, + numberOfChangeOutputs: 0, + version: 1, + ...overrides, + } as CashuSendQuote; +} + +const account = { id: 'acc1', type: 'cashu' } as CashuAccount; + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuSendQuoteService idempotency guards', () => { + test('markSendQuoteAsPending is a no-op when already PENDING (does not hit the repo)', async () => { + const repo = fakeRepo(); + const service = new CashuSendQuoteService(repo); + const pending = baseQuote({ state: 'PENDING' }); + + const result = await service.markSendQuoteAsPending(pending); + + expect(result).toBe(pending); + expect(repo.markAsPending).not.toHaveBeenCalled(); + }); + + test('markSendQuoteAsPending marks UNPAID via the repo', async () => { + const repo = fakeRepo(); + const service = new CashuSendQuoteService(repo); + + await service.markSendQuoteAsPending( + baseQuote({ id: 'q1', state: 'UNPAID' }), + ); + + expect(repo.markAsPending).toHaveBeenCalledWith('q1'); + }); + + test('markSendQuoteAsPending rejects a non-UNPAID state', async () => { + const service = new CashuSendQuoteService(fakeRepo()); + await expect( + service.markSendQuoteAsPending(baseQuote({ state: 'PAID' } as never)), + ).rejects.toThrow(/Only unpaid/); + }); + + test('completeSendQuote is a no-op when already PAID', async () => { + const repo = fakeRepo(); + const service = new CashuSendQuoteService(repo); + const paid = baseQuote({ state: 'PAID' } as never); + + const result = await service.completeSendQuote(account, paid, {} as never); + + expect(result).toBe(paid); + expect(repo.complete).not.toHaveBeenCalled(); + }); + + test('failSendQuote is a no-op when already FAILED', async () => { + const repo = fakeRepo(); + const service = new CashuSendQuoteService(repo); + const failed = baseQuote({ state: 'FAILED', failureReason: 'x' } as never); + + const result = await service.failSendQuote(account, failed, 'reason'); + + expect(result).toBe(failed); + expect(repo.fail).not.toHaveBeenCalled(); + }); + + test('failSendQuote rejects a non-pending/unpaid state', async () => { + const service = new CashuSendQuoteService(fakeRepo()); + await expect( + service.failSendQuote( + account, + baseQuote({ state: 'PAID' } as never), + 'r', + ), + ).rejects.toThrow(/not pending or unpaid/); + }); + + test('expireSendQuote rejects a not-yet-expired quote', async () => { + const service = new CashuSendQuoteService(fakeRepo()); + const future = baseQuote({ + state: 'UNPAID', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + await expect(service.expireSendQuote(future)).rejects.toThrow( + /has not expired/, + ); + }); + + test('initiateSend rejects when the account does not match', async () => { + const service = new CashuSendQuoteService(fakeRepo()); + await expect( + service.initiateSend( + { id: 'other', type: 'cashu' } as CashuAccount, + baseQuote({ state: 'UNPAID' }), + { quote: 'melt1', amount: 100 }, + ), + ).rejects.toThrow(/Account does not match/); + }); + + test('initiateSend rejects when the quote is not UNPAID', async () => { + const service = new CashuSendQuoteService(fakeRepo()); + await expect( + service.initiateSend(account, baseQuote({ state: 'PENDING' }), { + quote: 'melt1', + amount: 100, + }), + ).rejects.toThrow(/not unpaid/); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-send-quote-service.ts b/packages/wallet-sdk/src/internal/cashu-send-quote-service.ts new file mode 100644 index 000000000..ba85098e4 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-quote-service.ts @@ -0,0 +1,479 @@ +/** + * Cashu lightning-send SERVICE — Slice 3 / PR5b. The idempotent service primitives for a + * `CashuSendQuote`'s lifecycle. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/send/cashu-send-quote-service.ts`. Master's `CashuSendQuoteService` + * is ALREADY a plain class (only the `useCashuSendQuoteService()` factory couples it to React); + * here it is lifted near-verbatim, dropping the factory and taking the SDK + * {@link CashuSendQuoteRepository}. The mint operations (`createMeltQuoteBolt11` / + * `meltProofsIdempotent` / `checkMeltQuoteBolt11`) run against the account's LIVE + * `ExtendedCashuWallet` handle (PR5a). `initiateSend`'s `meltProofsIdempotent` is the + * idempotency keystone — a re-issued melt with the same deterministic counter does not + * double-spend. + * + * The state-machine that SEQUENCES these primitives (UNPAID → PENDING → PAID, driven off DB + * state + the mint melt-quote WS subscription) is the `executeQuote` ORCHESTRATOR — deferred to + * the orchestrator sub-slice (see `domains/cashu.ts`). These methods are the steps it calls. + * + * @module + */ +import { + type MeltQuoteBolt11Response, + MeltQuoteState, + OutputData, +} from '@cashu/cashu-ts'; +import type { Big } from 'big.js'; +import { matchBlindSignaturesToOutputData } from '../../../../apps/web-wallet/app/lib/cashu/blind-signature-matching'; +import { getDefaultUnit } from '../../../../apps/web-wallet/app/features/shared/currencies'; +import type { CashuSendQuoteRepository } from './cashu-send-quote-repository'; +import { getCashuUnit, sumProofs, toProof } from './lib-cashu-quotes'; +import { decodeBolt11, parseBolt11Invoice } from './lib-scan'; +import { DomainError } from '../errors'; +import type { CashuAccount, CashuProof } from '../types/account'; +import type { CashuSendQuote, DestinationDetails } from '../types/cashu'; +import { type Currency, Money } from '../types/money'; + +/** Options for {@link CashuSendQuoteService.getLightningQuote} (master verbatim). */ +export type GetCashuLightningQuoteOptions = { + account: CashuAccount; + paymentRequest: string; + amount?: Money; + exchangeRate?: Big; +}; + +/** The computed lightning quote returned before the send is persisted (master verbatim). */ +export type CashuLightningQuote = { + paymentRequest: string; + amountRequested: Money; + amountRequestedInBtc: Money<'BTC'>; + meltQuote: MeltQuoteBolt11Response; + amountToReceive: Money; + lightningFeeReserve: Money; + estimatedCashuFee: Money; + estimatedTotalFee: Money; + estimatedTotalAmount: Money; + expiresAt: Date | null; +}; + +/** The minimal quote data {@link CashuSendQuoteService.createSendQuote} persists from. */ +export type SendQuoteRequest = { + paymentRequest: string; + amountRequested: Money; + amountRequestedInBtc: Money<'BTC'>; + meltQuote: MeltQuoteBolt11Response; +}; + +/** Idempotent service primitives for a cashu lightning-send quote. */ +export class CashuSendQuoteService { + constructor(private readonly cashuSendRepository: CashuSendQuoteRepository) {} + + /** + * Get a lightning quote: validate the invoice, create the mint melt quote, select proofs, + * and compute the amounts/fees. Master verbatim. + */ + async getLightningQuote({ + account, + paymentRequest, + amount, + exchangeRate, + }: GetCashuLightningQuoteOptions): 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) { + if (amount.currency === 'BTC') { + amountRequestedInBtc = amount as Money<'BTC'>; + } else if (exchangeRate) { + amountRequestedInBtc = amount.convert('BTC', exchangeRate); + } else { + throw new Error('Exchange rate is required for non-BTC amounts'); + } + } else { + throw new Error('Unknown send amount'); + } + + // TODO: remove this once cashu-ts supports amountless lightning invoices + if (!invoice.amountMsat) { + throw new Error( + 'Cashu accounts do not support amountless lightning invoices', + ); + } + + const cashuUnit = getCashuUnit(account.currency); + const wallet = account.wallet; + + const meltQuote = await wallet.createMeltQuoteBolt11(paymentRequest); + const amountWithLightningFee = meltQuote.amount + meltQuote.fee_reserve; + + const { proofs, fee: proofsFee } = this.selectProofs( + account, + amountWithLightningFee, + ); + + const amountToReceive = new Money({ + amount: meltQuote.amount, + currency: account.currency, + unit: cashuUnit, + }); + const lightningFeeReserve = new Money({ + amount: meltQuote.fee_reserve, + currency: account.currency, + unit: cashuUnit, + }); + + const unit = getDefaultUnit(account.currency); + + const sumOfSendProofs = sumProofs(proofs); + if (sumOfSendProofs < amountWithLightningFee) { + throw new DomainError( + `Insufficient balance. Estimated total including fee is ${amountToReceive.add(lightningFeeReserve).toLocaleString({ unit })}.`, + ); + } + + const estimatedCashuFee = new Money({ + amount: proofsFee, + currency: account.currency, + unit: cashuUnit, + }); + const estimatedTotalFee = lightningFeeReserve.add(estimatedCashuFee); + const estimatedTotalAmount = amountToReceive.add(estimatedTotalFee); + + return { + paymentRequest, + amountRequested: amount ?? (amountRequestedInBtc as Money), + amountRequestedInBtc, + meltQuote, + amountToReceive, + lightningFeeReserve, + estimatedCashuFee, + estimatedTotalFee, + estimatedTotalAmount, + expiresAt, + }; + } + + /** Create (persist) the send quote without initiating the send. Master verbatim. */ + async createSendQuote({ + userId, + account, + sendQuote, + destinationDetails, + purpose, + transferId, + }: { + userId: string; + account: CashuAccount; + sendQuote: SendQuoteRequest; + destinationDetails?: DestinationDetails; + purpose?: string; + transferId?: string; + }): Promise { + const meltQuote = sendQuote.meltQuote; + const expiresAt = new Date(meltQuote.expiry * 1000); + const now = new Date(); + + if (now > expiresAt) { + throw new DomainError('Quote has expired'); + } + + const cashuUnit = getCashuUnit(account.currency); + const wallet = account.wallet; + const keyset = wallet.getKeyset(); + const keysetId = keyset.id; + + const amountWithLightningFee = meltQuote.amount + meltQuote.fee_reserve; + + const { proofs, fee: proofsFee } = this.selectProofs( + account, + amountWithLightningFee, + ); + const proofsToSendSum = sumProofs(proofs); + const totalAmountToSend = amountWithLightningFee + proofsFee; + + const amountToReceive = new Money({ + amount: meltQuote.amount, + currency: account.currency, + unit: cashuUnit, + }); + const lightningFeeReserve = new Money({ + amount: meltQuote.fee_reserve, + currency: account.currency, + unit: cashuUnit, + }); + const cashuFee = new Money({ + amount: proofsFee, + currency: account.currency, + unit: cashuUnit, + }); + const estimatedTotalFee = lightningFeeReserve.add(cashuFee); + const unit = getDefaultUnit(account.currency); + + if (proofsToSendSum < totalAmountToSend) { + throw new DomainError( + `Insufficient balance. Estimated total including fee is ${amountToReceive.add(estimatedTotalFee).toLocaleString({ unit })}.`, + ); + } + + const maxPotentialChangeAmount = + proofsToSendSum - meltQuote.amount - proofsFee; + const numberOfChangeOutputs = + maxPotentialChangeAmount === 0 + ? 0 + : Math.ceil(Math.log2(maxPotentialChangeAmount)) || 1; + + const amountReserved = new Money({ + amount: proofsToSendSum, + currency: account.currency, + unit: cashuUnit, + }); + + const { + decoded: { paymentHash }, + } = decodeBolt11(sendQuote.paymentRequest); + + return this.cashuSendRepository.create({ + userId, + accountId: account.id, + paymentRequest: sendQuote.paymentRequest, + paymentHash, + expiresAt: expiresAt.toISOString(), + amountRequested: sendQuote.amountRequested, + amountRequestedInMsat: sendQuote.amountRequestedInBtc.toNumber('msat'), + amountToReceive, + lightningFeeReserve, + cashuFee, + quoteId: meltQuote.quote, + keysetId, + numberOfChangeOutputs, + proofsToSend: proofs, + amountReserved, + destinationDetails, + purpose, + transferId, + }); + } + + /** + * Initiate the send by calling the mint's melt-proofs endpoint (idempotent — deterministic + * counter). Master verbatim. + * + * @throws Error if the account/quote don't match or the quote is not UNPAID. + */ + async initiateSend( + account: CashuAccount, + sendQuote: CashuSendQuote, + meltQuote: Pick, + ) { + if (account.id !== sendQuote.accountId) { + throw new Error('Account does not match'); + } + if (sendQuote.quoteId !== meltQuote.quote) { + throw new Error('Quote does not match'); + } + if (sendQuote.state !== 'UNPAID') { + throw new Error(`Send is not unpaid. Current state: ${sendQuote.state}`); + } + + const wallet = account.wallet; + + return wallet.meltProofsIdempotent( + meltQuote, + sendQuote.proofs.map((p) => toProof(p)), + { keysetId: sendQuote.keysetId }, + { type: 'deterministic', counter: sendQuote.keysetCounter }, + ); + } + + /** + * Mark the send quote PENDING. No-op if already pending. Master verbatim. + * + * @throws Error if the quote is not UNPAID. + */ + async markSendQuoteAsPending(quote: CashuSendQuote) { + if (quote.state === 'PENDING') { + return quote; + } + if (quote.state !== 'UNPAID') { + throw new Error( + `Only unpaid cashu send quote can be marked as pending. Current state: ${quote.state}`, + ); + } + return this.cashuSendRepository.markAsPending(quote.id); + } + + /** + * Complete the send quote after a successful payment, deriving + matching the NUT-08 change + * proofs. No-op if already PAID. Master verbatim. + * + * @throws Error if the account/quote don't match, the state is wrong, or the melt is not paid. + */ + async completeSendQuote( + account: CashuAccount, + sendQuote: CashuSendQuote, + meltQuote: MeltQuoteBolt11Response, + ) { + if (sendQuote.state === 'PAID') { + return sendQuote; + } + if (!['PENDING', 'UNPAID'].includes(sendQuote.state)) { + throw new Error( + `Cannot complete send quote that is not pending or unpaid. Current state: ${sendQuote.state}`, + ); + } + if (account.id !== sendQuote.accountId) { + throw new Error('Account does not match the quote account'); + } + if (meltQuote.quote !== sendQuote.quoteId) { + throw new Error('Quote does not match'); + } + if (meltQuote.state !== MeltQuoteState.PAID) { + throw new Error( + `Cannot complete send. Melt quote is not paid. Current state: ${meltQuote.state}`, + ); + } + if (!meltQuote.payment_preimage) { + console.warn('Payment preimage is missing on the melt quote'); + } + + const cashuUnit = getCashuUnit(account.currency); + const wallet = account.wallet; + + // Re-derive the deterministic NUT-08 change OutputData and match the mint's (possibly + // unordered) change BlindSignatures via DLEQ (see cashu-ts issue 287). Master verbatim. + await wallet.keyChain.ensureKeysetKeys(sendQuote.keysetId); + const keyset = wallet.getKeyset(sendQuote.keysetId); + const amounts = sendQuote.numberOfChangeOutputs + ? Array(sendQuote.numberOfChangeOutputs).fill(1) + : []; + const outputData = OutputData.createDeterministicData( + amounts.length, + wallet.seed, + sendQuote.keysetCounter, + keyset, + amounts, + ); + const changeProofs = meltQuote.change?.length + ? matchBlindSignaturesToOutputData(meltQuote.change, outputData, keyset) + : []; + + const amountSpent = new Money({ + amount: sumProofs(sendQuote.proofs) - sumProofs(changeProofs), + currency: account.currency, + unit: cashuUnit, + }); + + return this.cashuSendRepository.complete({ + quote: sendQuote, + paymentPreimage: meltQuote.payment_preimage ?? '', + amountSpent, + changeProofs, + }); + } + + /** + * Fail the send quote after a failed payment, re-checking the melt quote is UNPAID first + * (so a pending/paid send is never failed). No-op if already FAILED. Master verbatim. + * + * @throws Error if the account/quote don't match, the state is wrong, or the melt is not unpaid. + */ + async failSendQuote( + account: CashuAccount, + quote: CashuSendQuote, + reason: string, + ): Promise { + if (quote.state === 'FAILED') { + return quote; + } + if (!['PENDING', 'UNPAID'].includes(quote.state)) { + throw new Error( + `Cannot fail send quote that is not pending or unpaid. Current state: ${quote.state}`, + ); + } + if (account.id !== quote.accountId) { + throw new Error('Account does not match the quote account'); + } + + const latestMeltQuote = await account.wallet.checkMeltQuoteBolt11( + quote.quoteId, + ); + if (latestMeltQuote.state !== MeltQuoteState.UNPAID) { + // Pending/paid melt quotes must not be failed — the send is in progress or done. If the + // mint fails the melt, it resets the melt quote to UNPAID again. + throw new Error( + `Cannot fail melt quote that is not unpaid. Current state for send quote ${quote.id}: ${latestMeltQuote.state}`, + ); + } + + return this.cashuSendRepository.fail({ id: quote.id, reason }); + } + + /** + * Expire the send quote (return the reserved proofs). No-op if already EXPIRED. Master verbatim. + * + * @throws Error if the quote is not UNPAID or has not expired yet. + */ + async expireSendQuote(quote: CashuSendQuote): Promise { + if (quote.state === 'EXPIRED') { + return; + } + if (quote.state !== 'UNPAID') { + throw new Error('Cannot expire quote that is not unpaid'); + } + if (new Date(quote.expiresAt) > new Date()) { + throw new Error('Cannot expire quote that has not expired yet'); + } + await this.cashuSendRepository.expire(quote.id); + } + + /** + * Select spendable proofs covering `amount` (+ their fee) from the account. Master verbatim. + * + * @returns the selected proofs + their cashu fee. + */ + private selectProofs( + account: CashuAccount, + amount: number, + ): { proofs: CashuProof[]; fee: number } { + const accountProofsMap = new Map( + account.proofs.map((p) => [p.secret, p]), + ); + + const { send } = account.wallet.selectProofsToSend( + account.proofs.map((p) => toProof(p)), + amount, + true, + ); + + const selectedProofs = send.map((p) => { + const proof = accountProofsMap.get(p.secret); + if (!proof) { + throw new Error('Proof not found'); + } + return proof; + }); + + return { + proofs: selectedProofs, + fee: account.wallet.getFeesForProofs(send), + }; + } +} diff --git a/packages/wallet-sdk/src/internal/cashu-send-swap-repository.ts b/packages/wallet-sdk/src/internal/cashu-send-swap-repository.ts new file mode 100644 index 000000000..8261d50f2 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-swap-repository.ts @@ -0,0 +1,334 @@ +/** + * Internal `wallet.cashu_send_swaps` repository — Slice 3 / PR5b (cashu TOKEN send). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/send/cashu-send-swap-repository.ts`. Same re-housing as the + * send-quote repo: a plain class over the SDK-owned Supabase client + the SDK {@link Encryption}. + * The RPCs (`create_cashu_send_swap` / `commit_proofs_to_send` / `complete_cashu_send_swap` / + * `fail_cashu_send_swap`) carry the DB reservation + CONCURRENCY_ERROR guard verbatim. + * + * NOTE the `CashuSendSwap.createdAt: Date` quirk — `toSwap` passes `new Date(data.created_at)` + * (master verbatim), unlike the ISO-string `createdAt` everywhere else. + * + * @module + */ +import type { Proof } from '@cashu/cashu-ts'; +import type { z } from 'zod/mini'; +import type { AgicashDbCashuProof } from './db-account'; +import { + CashuSendSwapSchema, + CashuSwapSendDbDataSchema, + proofToY, + toDecryptedCashuProofs, +} from './lib-cashu-quotes'; +import type { WalletSupabaseClient } from './supabase-client'; +import { ConcurrencyError } from '../errors'; +import type { Encryption } from './encryption'; +import type { CashuProof } from '../types/account'; +import type { CashuSendSwap } from '../types/cashu'; +import type { Money } from '../types/money'; + +/** Per-call query options (an abort signal, matching master). */ +type Options = { abortSignal?: AbortSignal }; + +/** + * A row of the `wallet.cashu_send_swaps` table (hand-written; master = generated types). Only + * the columns `toSwap` reads are typed. + */ +export type AgicashDbCashuSendSwap = { + id: string; + account_id: string; + user_id: string; + transaction_id: string; + encrypted_data: string; + requires_input_proofs_swap: boolean; + keyset_id?: string | null; + keyset_counter?: number | null; + token_hash: string; + created_at: string; + version: number; + state: CashuSendSwap['state']; + failure_reason?: string | null; +}; + +/** Params for {@link CashuSendSwapRepository.create} (master `CreateSendSwap`). */ +export type CreateCashuSendSwap = { + accountId: string; + userId: string; + tokenMintUrl: string; + amountRequested: Money; + amountToSend: Money; + totalAmount: Money; + cashuSendFee: Money; + cashuReceiveFee: Money; + inputProofs: CashuProof[]; + inputAmount: Money; + tokenHash?: string; + keysetId?: string; + outputAmounts?: { send: number[]; change: number[] }; +}; + +/** Reads + writes for the `wallet.cashu_send_swaps` table (RLS-scoped). */ +export class CashuSendSwapRepository { + constructor( + private readonly db: WalletSupabaseClient, + private readonly encryption: Encryption, + ) {} + + /** + * Create a token-send swap (RPC `create_cashu_send_swap`). The RPC reserves the input proofs + * atomically and raises `CONCURRENCY_ERROR` on a stale read. + */ + async create( + params: CreateCashuSendSwap, + options?: Options, + ): Promise { + const requiresInputProofsSwap = !params.inputAmount.equals( + params.amountToSend, + ); + + const dataToEncrypt = CashuSwapSendDbDataSchema.parse({ + tokenMintUrl: params.tokenMintUrl, + amountReceived: params.amountRequested, + amountToSend: params.amountToSend, + cashuReceiveFee: params.cashuReceiveFee, + cashuSendFee: params.cashuSendFee, + amountSpent: params.totalAmount, + amountReserved: params.inputAmount, + totalFee: params.cashuSendFee.add(params.cashuReceiveFee), + outputAmounts: params.outputAmounts + ? { + send: params.outputAmounts.send, + change: params.outputAmounts.change, + } + : undefined, + } satisfies z.input); + + const encryptedData = await this.encryption.encrypt(dataToEncrypt); + + const numberOfOutputs = requiresInputProofsSwap + ? (params.outputAmounts?.send?.length ?? 0) + + (params.outputAmounts?.change?.length ?? 0) + : undefined; + + const { data, error } = await this.rpc( + 'create_cashu_send_swap', + { + p_user_id: params.userId, + p_account_id: params.accountId, + p_input_proofs: params.inputProofs.map((p) => p.id), + p_currency: params.amountToSend.currency, + p_encrypted_data: encryptedData, + p_requires_input_proofs_swap: requiresInputProofsSwap, + p_token_hash: params.tokenHash, + p_keyset_id: params.keysetId, + p_number_of_outputs: numberOfOutputs, + }, + options, + ); + + if (error) { + if (error.hint === 'CONCURRENCY_ERROR') { + throw new ConcurrencyError(error.message, error.details); + } + throw new Error('Failed to create cashu send swap', { cause: error }); + } + + return this.toSwap({ ...data.swap, cashu_proofs: data.reserved_proofs }); + } + + /** + * Commit the swapped proofs-to-send + change (RPC `commit_proofs_to_send`) — moves a DRAFT + * swap toward PENDING by persisting the encrypted proofsToSend / change proofs. + */ + async commitProofsToSend({ + swap, + tokenHash, + proofsToSend, + changeProofs, + }: { + swap: CashuSendSwap; + tokenHash: string; + proofsToSend: Proof[]; + changeProofs: Proof[]; + }): Promise { + const allProofs = proofsToSend.concat(changeProofs); + const proofDataToEncrypt = allProofs.flatMap((x) => [x.amount, x.secret]); + const encryptedProofData = + await this.encryption.encryptBatch(proofDataToEncrypt); + + const encryptedProofs = allProofs.map((x, index) => { + const i = index * 2; + return { + keysetId: x.id, + amount: encryptedProofData[i], + secret: encryptedProofData[i + 1], + unblindedSignature: x.C, + publicKeyY: proofToY(x), + dleq: x.dleq ?? null, + witness: x.witness ?? null, + }; + }); + const encryptedProofsToSend = encryptedProofs.slice(0, proofsToSend.length); + const encryptedChangeProofs = encryptedProofs.slice(proofsToSend.length); + + const { error } = await this.rpc('commit_proofs_to_send', { + p_swap_id: swap.id, + p_proofs_to_send: encryptedProofsToSend, + p_change_proofs: encryptedChangeProofs, + p_token_hash: tokenHash, + }); + + if (error) { + throw new Error('Failed to complete cashu send swap', { cause: error }); + } + } + + /** Mark the swap COMPLETED (RPC `complete_cashu_send_swap`). */ + async complete(swapId: string): Promise { + const { error } = await this.rpc('complete_cashu_send_swap', { + p_swap_id: swapId, + }); + if (error) { + throw new Error('Failed to complete cashu send swap', { cause: error }); + } + } + + /** Fail the swap (RPC `fail_cashu_send_swap`), releasing the reserved proofs. */ + async fail({ + swapId, + reason, + }: { + swapId: string; + reason: string; + }): Promise { + const { error } = await this.rpc('fail_cashu_send_swap', { + p_swap_id: swapId, + p_reason: reason, + }); + if (error) { + throw new Error('Failed to fail cashu send swap', { cause: error }); + } + } + + /** Get the swap with the given id (joined with its proofs), or null. */ + async get(id: string, options?: Options): Promise { + let query = this.db + .from('cashu_send_swaps') + .select('*, cashu_proofs!spending_cashu_send_swap_id(*)') + .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 cashu send swap', { cause: error }); + } + return data ? this.toSwap(data) : null; + } + + /** + * Get all unresolved (DRAFT or PENDING) token-send swaps for the user. INTERNAL — feeds the + * future orchestrator's resume sweep (the public interface has no `listPending`). + */ + async getUnresolved( + userId: string, + options?: Options, + ): Promise { + let query = this.db + .from('cashu_send_swaps') + .select('*, cashu_proofs!spending_cashu_send_swap_id(*)') + .eq('user_id', userId) + .in('state', ['DRAFT', 'PENDING']); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + const { data, error } = await query.returns(); + if (error) { + throw new Error('Failed to get unresolved cashu send swaps', { + cause: error, + }); + } + return Promise.all(data.map((d) => this.toSwap(d))); + } + + /** Map a DB row (joined with its proofs) to the domain {@link CashuSendSwap}. */ + async toSwap(data: CashuSendSwapRow): Promise { + const encryptedInputProofs = data.cashu_proofs.filter( + (p) => p.cashu_send_swap_id !== data.id, + ); + const encryptedProofsToSend = data.cashu_proofs.filter( + (p) => + p.cashu_send_swap_id === data.id || !data.requires_input_proofs_swap, + ); + const encryptedProofs = encryptedInputProofs.concat(encryptedProofsToSend); + const proofsDataToDecrypt = encryptedProofs.flatMap((x) => [ + x.amount, + x.secret, + ]); + + const [decryptedData, ...decryptedProofsData] = + await this.encryption.decryptBatch([ + data.encrypted_data, + ...proofsDataToDecrypt, + ]); + + const decryptedProofs = toDecryptedCashuProofs( + encryptedProofs, + decryptedProofsData, + ); + const inputProofs = decryptedProofs.slice(0, encryptedInputProofs.length); + const proofsToSend = decryptedProofs.slice(encryptedInputProofs.length); + + const sendData = CashuSwapSendDbDataSchema.parse(decryptedData); + + return CashuSendSwapSchema.parse({ + id: data.id, + accountId: data.account_id, + userId: data.user_id, + transactionId: data.transaction_id, + amountReceived: sendData.amountReceived, + amountToSend: sendData.amountToSend, + amountSpent: sendData.amountSpent, + cashuReceiveFee: sendData.cashuReceiveFee, + cashuSendFee: sendData.cashuSendFee, + inputProofs, + inputAmount: sendData.amountReserved, + totalFee: sendData.totalFee, + version: data.version, + state: data.state, + // NOTE: a Date, not an ISO string (master verbatim). + createdAt: new Date(data.created_at), + outputAmounts: sendData.outputAmounts + ? sendData.outputAmounts + : undefined, + keysetId: data.keyset_id ?? undefined, + keysetCounter: data.keyset_counter ?? undefined, + tokenHash: data.token_hash, + proofsToSend, + failureReason: data.failure_reason ?? undefined, + }) as CashuSendSwap; + } + + /** Thin typed wrapper over the untyped Supabase `rpc` (see send-quote repo for the rationale). */ + 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. + let query = (this.db.rpc as any)(fn, args); + if (options?.abortSignal) { + query = query.abortSignal(options.abortSignal); + } + return query; + } +} + +/** A `cashu_send_swaps` row joined with its `cashu_proofs` (input + send proofs). */ +type CashuSendSwapRow = AgicashDbCashuSendSwap & { + cashu_proofs: (AgicashDbCashuProof & { + cashu_send_swap_id?: string | null; + })[]; +}; diff --git a/packages/wallet-sdk/src/internal/cashu-send-swap-service.test.ts b/packages/wallet-sdk/src/internal/cashu-send-swap-service.test.ts new file mode 100644 index 000000000..0ba01df83 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-swap-service.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, mock, test } from 'bun:test'; +import type { CashuReceiveSwapService } from './cashu-receive-swap-service'; +import { CashuSendSwapService } from './cashu-send-swap-service'; +import type { CashuSendSwapRepository } from './cashu-send-swap-repository'; +import { type Currency, Money } from '../types/money'; +import type { CashuAccount, CashuProof } from '../types/account'; +import type { CashuSendSwap } from '../types/cashu'; + +// -- Fakes ---------------------------------------------------------------------------------- + +const sats = (n: number): Money => + new Money({ amount: n, currency: 'BTC' as Currency, unit: 'sat' }); + +function fakeProof(secret: string): CashuProof { + return { + id: 'p1', + accountId: 'acc1', + userId: 'u1', + keysetId: 'ks1', + amount: 50, + secret, + unblindedSignature: 'C', + publicKeyY: 'Y', + dleq: undefined, + witness: undefined, + state: 'RESERVED', + version: 1, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +/** A PENDING token-send swap (reclaimable via reverse). */ +function pendingSwap(overrides: Partial = {}): CashuSendSwap { + return { + id: 's1', + accountId: 'acc1', + userId: 'u1', + transactionId: 'tx-orig', + inputProofs: [fakeProof('in')], + inputAmount: sats(50), + amountReceived: sats(48), + amountToSend: sats(50), + amountSpent: sats(50), + cashuReceiveFee: sats(0), + cashuSendFee: sats(0), + totalFee: sats(0), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + version: 1, + state: 'PENDING', + tokenHash: 'th', + proofsToSend: [fakeProof('send')], + ...overrides, + } as CashuSendSwap; +} + +const account = { + id: 'acc1', + type: 'cashu', + mintUrl: 'https://mint.example', +} as CashuAccount; + +/** The shape of the arg `CashuReceiveSwapService.create` is called with (the assertion target). */ +type ReceiveCreateArg = { + reversedTransactionId?: string; + userId: string; + account: CashuAccount; + token: { mint: string; unit: string; proofs: unknown[] }; +}; + +function makeService( + receiveCreate = mock((_arg: ReceiveCreateArg) => + Promise.resolve({} as never), + ), +) { + const repo = { + complete: mock(async () => undefined), + fail: mock(async () => undefined), + } as unknown as CashuSendSwapRepository; + const receiveSwapService = { + create: receiveCreate, + } as unknown as CashuReceiveSwapService; + return { + service: new CashuSendSwapService(repo, receiveSwapService), + repo, + receiveCreate, + }; +} + +// -- Tests ---------------------------------------------------------------------------------- + +describe('CashuSendSwapService.reverse (decision 8)', () => { + test('creates a receive swap tagged with the original transactionId', async () => { + const { service, receiveCreate } = makeService(); + const swap = pendingSwap(); + + await service.reverse(swap, account); + + expect(receiveCreate).toHaveBeenCalledTimes(1); + const arg = receiveCreate.mock.calls[0][0]; + expect(arg.reversedTransactionId).toBe('tx-orig'); + expect(arg.userId).toBe('u1'); + expect(arg.account).toBe(account); + expect(arg.token.mint).toBe('https://mint.example'); + expect(arg.token.proofs).toHaveLength(1); + }); + + test('is a no-op when the swap is already REVERSED', async () => { + const { service, receiveCreate } = makeService(); + + await service.reverse(pendingSwap({ state: 'REVERSED' } as never), account); + + expect(receiveCreate).not.toHaveBeenCalled(); + }); + + test('rejects a swap that is not PENDING', async () => { + const { service } = makeService(); + await expect( + service.reverse(pendingSwap({ state: 'DRAFT' } as never), account), + ).rejects.toThrow(/not PENDING/); + }); + + test('rejects a swap that does not belong to the account', async () => { + const { service } = makeService(); + await expect( + service.reverse(pendingSwap(), { + id: 'other', + type: 'cashu', + mintUrl: 'https://mint.example', + } as CashuAccount), + ).rejects.toThrow(/does not belong/); + }); +}); + +describe('CashuSendSwapService idempotency guards', () => { + test('complete is a no-op when already COMPLETED', async () => { + const { service, repo } = makeService(); + await service.complete(pendingSwap({ state: 'COMPLETED' } as never)); + expect(repo.complete).not.toHaveBeenCalled(); + }); + + test('complete rejects a non-PENDING swap', async () => { + const { service } = makeService(); + await expect( + service.complete(pendingSwap({ state: 'DRAFT' } as never)), + ).rejects.toThrow(/not PENDING/); + }); + + test('fail is a no-op when already FAILED', async () => { + const { service, repo } = makeService(); + await service.fail( + pendingSwap({ state: 'FAILED', failureReason: 'x' } as never), + 'reason', + ); + expect(repo.fail).not.toHaveBeenCalled(); + }); + + test('fail rejects a non-DRAFT swap (only DRAFT swaps are failable)', async () => { + const { service } = makeService(); + await expect(service.fail(pendingSwap(), 'reason')).rejects.toThrow( + /not DRAFT/, + ); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-send-swap-service.ts b/packages/wallet-sdk/src/internal/cashu-send-swap-service.ts new file mode 100644 index 000000000..94bde10ff --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-send-swap-service.ts @@ -0,0 +1,471 @@ +/** + * Cashu TOKEN-send SERVICE — Slice 3 / PR5b. The idempotent service primitives for a + * `CashuSendSwap`'s lifecycle, including the user-initiated `reverse` (decision 8). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/send/cashu-send-swap-service.ts`. Master's `CashuSendSwapService` + * is a plain class (only the `useCashuSendSwapService()` factory couples it to React); lifted + * near-verbatim, dropping the factory and taking the SDK {@link CashuSendSwapRepository} + + * {@link CashuReceiveSwapService} (the latter only for {@link reverse}). + * + * `swapForProofsToSend`'s `wallet.restore` recovery (on OUTPUT_ALREADY_SIGNED / TOKEN_ALREADY_SPENT) + * is the stale-proof / re-issue protection — preserved verbatim. {@link reverse} = the public, + * user-initiated reclaim of a PENDING token send: it creates a cashu RECEIVE swap pulling + * `proofsToSend` back, tagged `reversedTransactionId` (lands REVERSED DB-side; no orchestrator + * auto-reverse). + * + * @module + */ +import { + MintOperationError, + OutputData, + type Proof, + type Wallet, +} from '@cashu/cashu-ts'; +import { CashuErrorCodes } from './cashu-error-codes'; +import type { CashuReceiveSwapService } from './cashu-receive-swap-service'; +import type { CashuSendSwapRepository } from './cashu-send-swap-repository'; +import { getDefaultUnit } from '../../../../apps/web-wallet/app/features/shared/currencies'; +import { + getCashuProtocolUnit, + getCashuUnit, + getTokenHash, + splitAmount, + sumProofs, + toProof, +} from './lib-cashu-quotes'; +import type { ExtendedCashuWallet } from './lib-cashu-wallet'; +import { DomainError } from '../errors'; +import type { CashuAccount, CashuProof } from '../types/account'; +import type { CashuSendSwap } from '../types/cashu'; +import { Money } from '../types/money'; + +/** The estimated token-swap quote returned before the swap is persisted (master verbatim). */ +export type CashuSwapQuote = { + amountRequested: Money; + senderPaysFee: boolean; + cashuReceiveFee: Money; + cashuSendFee: Money; + totalAmount: Money; + totalFee: Money; + amountToSend: Money; +}; + +/** Idempotent service primitives for a cashu token-send swap. */ +export class CashuSendSwapService { + constructor( + private readonly cashuSendSwapRepository: CashuSendSwapRepository, + private readonly cashuReceiveSwapService: CashuReceiveSwapService, + ) {} + + /** + * Estimate the swap fees for sending `amount` from the account. Master verbatim. + * + * @throws Error/DomainError on currency mismatch or insufficient balance. + */ + async getQuote({ + account, + amount, + senderPaysFee, + }: { + account: CashuAccount; + amount: Money; + senderPaysFee: boolean; + }): Promise { + if (account.currency !== amount.currency) { + throw new Error( + 'Currency mismatch. Account currency to send from must match the amount to send currency.', + ); + } + + const cashuUnit = getCashuUnit(account.currency); + const amountNumber = amount.toNumber(cashuUnit); + const wallet = account.wallet; + + const { cashuReceiveFee, cashuSendFee } = await this.prepareProofsAndFee( + wallet, + account.proofs, + amount, + senderPaysFee, + ); + + const toMoney = (num: number) => + new Money({ amount: num, currency: amount.currency, unit: cashuUnit }); + + return { + amountRequested: amount, + amountToSend: toMoney(amountNumber + cashuReceiveFee), + totalAmount: toMoney(amountNumber + cashuReceiveFee + cashuSendFee), + totalFee: toMoney(cashuReceiveFee + cashuSendFee), + senderPaysFee, + cashuReceiveFee: toMoney(cashuReceiveFee), + cashuSendFee: toMoney(cashuSendFee), + }; + } + + /** + * Create (persist) a token-send swap. If the account has exact proofs the swap is created + * PENDING (with a token hash); otherwise DRAFT (with output amounts for the swap). Master verbatim. + * + * @throws Error if the account does not have enough balance. + */ + async create({ + userId, + account, + amount, + senderPaysFee, + }: { + userId: string; + account: CashuAccount; + amount: Money; + senderPaysFee: boolean; + }): Promise { + if (account.currency !== amount.currency) { + throw new Error( + 'Currency mismatch. Account currency to send from must match the amount to send currency.', + ); + } + + const cashuUnit = getCashuUnit(account.currency); + const amountNumber = amount.toNumber(cashuUnit); + const wallet = account.wallet; + + const { + send: inputProofs, + cashuReceiveFee, + cashuSendFee, + } = await this.prepareProofsAndFee( + wallet, + account.proofs, + amount, + senderPaysFee, + ); + + const totalAmountToSend = amountNumber + cashuReceiveFee; + + let tokenHash: string | undefined; + let keysetId: string | undefined; + let outputAmounts: { send: number[]; change: number[] } | undefined; + + const haveExactProofs = sumProofs(inputProofs) === totalAmountToSend; + if (haveExactProofs) { + tokenHash = await getTokenHash({ + mint: account.mintUrl, + proofs: inputProofs.map((p) => toProof(p)), + unit: getCashuProtocolUnit(amount.currency), + }); + } else { + const keyset = wallet.getKeyset(); + keysetId = keyset.id; + const amountToKeep = + sumProofs(inputProofs) - totalAmountToSend - cashuSendFee; + outputAmounts = { + send: splitAmount(totalAmountToSend, keyset.keys), + change: splitAmount(amountToKeep, keyset.keys), + }; + } + + const toMoney = (num: number) => + new Money({ amount: num, currency: amount.currency, unit: cashuUnit }); + + return this.cashuSendSwapRepository.create({ + accountId: account.id, + userId, + tokenMintUrl: account.mintUrl, + inputProofs, + inputAmount: toMoney(sumProofs(inputProofs)), + amountRequested: amount, + amountToSend: toMoney(totalAmountToSend), + cashuSendFee: toMoney(cashuSendFee), + cashuReceiveFee: toMoney(cashuReceiveFee), + totalAmount: toMoney(totalAmountToSend + cashuSendFee), + tokenHash, + keysetId, + outputAmounts, + }); + } + + /** + * Swap the input proofs for the exact proofs-to-send (DRAFT → PENDING), recovering via + * `wallet.restore` if the mint reports the outputs already signed. Master verbatim. + */ + async swapForProofsToSend({ + account, + swap, + }: { + account: CashuAccount; + swap: CashuSendSwap; + }) { + if (swap.state !== 'DRAFT') { + throw new Error('Swap is not DRAFT'); + } + if (swap.accountId !== account.id) { + throw new Error('Swap does not belong to account'); + } + + const wallet = account.wallet; + + await wallet.keyChain.ensureKeysetKeys(swap.keysetId); + const keyset = wallet.getKeyset(swap.keysetId); + const currency = swap.amountToSend.currency; + const cashuUnit = getCashuUnit(currency); + const sendAmount = swap.amountToSend.toNumber(cashuUnit); + const sendOutputData = OutputData.createDeterministicData( + sendAmount, + wallet.seed, + swap.keysetCounter, + keyset, + swap.outputAmounts.send, + ); + + const amountToKeep = + sumProofs(swap.inputProofs) - + sendAmount - + swap.cashuSendFee.toNumber(cashuUnit); + const keepOutputData = OutputData.createDeterministicData( + amountToKeep, + wallet.seed, + swap.keysetCounter + sendOutputData.length, + keyset, + swap.outputAmounts.change, + ); + + const { send: proofsToSend, keep: changeProofs } = await this.swapProofs( + wallet, + swap, + { keep: keepOutputData, send: sendOutputData }, + ); + + const tokenHash = await getTokenHash({ + mint: account.mintUrl, + proofs: proofsToSend, + unit: getCashuProtocolUnit(currency), + }); + + await this.cashuSendSwapRepository.commitProofsToSend({ + swap, + proofsToSend, + changeProofs, + tokenHash, + }); + } + + /** Mark the swap COMPLETED. No-op if already COMPLETED. Master verbatim. */ + async complete(swap: CashuSendSwap) { + if (swap.state === 'COMPLETED') { + return; + } + if (swap.state !== 'PENDING') { + throw new Error(`Swap is not PENDING. Current state: ${swap.state}`); + } + return this.cashuSendSwapRepository.complete(swap.id); + } + + /** Fail a DRAFT swap. No-op if already FAILED. Master verbatim. */ + async fail(swap: CashuSendSwap, reason: string) { + if (swap.state === 'FAILED') { + return; + } + if (swap.state !== 'DRAFT') { + throw new Error(`Swap is not DRAFT. Current state: ${swap.state}`); + } + return this.cashuSendSwapRepository.fail({ swapId: swap.id, reason }); + } + + /** + * Reverse a PENDING token send (decision 8): create a cashu RECEIVE swap pulling + * `proofsToSend` back into the account, tagged `reversedTransactionId`. No-op if already + * REVERSED. Master verbatim. + * + * @throws Error if the swap is not PENDING or does not belong to the account. + */ + async reverse(swap: CashuSendSwap, account: CashuAccount): Promise { + if (swap.state === 'REVERSED') { + return; + } + if (swap.state !== 'PENDING') { + throw new Error('Swap is not PENDING'); + } + if (swap.accountId !== account.id) { + throw new Error('Swap does not belong to account'); + } + + await this.cashuReceiveSwapService.create({ + account, + userId: swap.userId, + token: { + mint: account.mintUrl, + proofs: swap.proofsToSend.map((p) => toProof(p)), + unit: getCashuProtocolUnit(swap.amountToSend.currency), + }, + reversedTransactionId: swap.transactionId, + }); + } + + /** + * Select + price the input proofs for a send (sender pays fees). Master verbatim. + * + * @throws Error if sender does not pay fees (unimplemented) or DomainError on insufficient balance. + */ + private async prepareProofsAndFee( + wallet: ExtendedCashuWallet, + accountProofs: CashuProof[], + requestedAmount: Money, + includeFeesInSendAmount: boolean, + ): Promise<{ + keep: CashuProof[]; + send: CashuProof[]; + cashuSendFee: number; + cashuReceiveFee: number; + }> { + if (!includeFeesInSendAmount) { + throw new Error( + 'Sender must pay fees - this feature is not yet implemented', + ); + } + + const accountProofsMap = new Map( + accountProofs.map((p) => [p.secret, p]), + ); + const toCashuProof = (p: Proof) => { + const proof = accountProofsMap.get(p.secret); + if (!proof) { + throw new Error('Proof not found'); + } + return proof; + }; + + const proofs = accountProofs.map((p) => toProof(p)); + const currency = requestedAmount.currency; + const cashuUnit = getCashuUnit(currency); + const requestedAmountNumber = requestedAmount.toNumber(cashuUnit); + + let { keep, send } = wallet.selectProofsToSend( + proofs, + requestedAmountNumber, + includeFeesInSendAmount, + ); + const feeToSwapSelectedProofs = wallet.getFeesForProofs(send); + + let proofAmountSelected = sumProofs(send); + const amountToSend = requestedAmountNumber + feeToSwapSelectedProofs; + + if (proofAmountSelected === amountToSend) { + return { + keep: keep.map(toCashuProof), + send: send.map(toCashuProof), + cashuSendFee: 0, + cashuReceiveFee: feeToSwapSelectedProofs, + }; + } + + const estimatedFeeToReceive = + wallet.getFeesEstimateToReceiveAtLeast(amountToSend); + + if (proofAmountSelected < amountToSend) { + const totalAmount = new Money({ + amount: requestedAmountNumber + estimatedFeeToReceive, + currency, + unit: cashuUnit, + }); + const unit = getDefaultUnit(currency); + throw new DomainError( + `Insufficient balance. Total amount including fees is ${totalAmount.toLocaleString({ unit })}.`, + ); + } + + ({ keep, send } = wallet.selectProofsToSend( + proofs, + requestedAmountNumber + estimatedFeeToReceive, + includeFeesInSendAmount, + )); + proofAmountSelected = sumProofs(send); + + const cashuSendFee = wallet.getFeesForProofs(send); + const cashuReceiveFee = estimatedFeeToReceive; + + if ( + proofAmountSelected < + requestedAmountNumber + cashuSendFee + cashuReceiveFee + ) { + const totalAmount = new Money({ + amount: requestedAmountNumber + cashuSendFee + cashuReceiveFee, + currency, + unit: cashuUnit, + }); + const unit = getDefaultUnit(currency); + throw new DomainError( + `Insufficient balance. Total amount including fees is ${totalAmount.toLocaleString({ unit })}.`, + ); + } + + return { + keep: keep.map(toCashuProof), + send: send.map(toCashuProof), + cashuSendFee, + cashuReceiveFee, + }; + } + + /** + * Perform the mint swap for the DRAFT proofs-to-send, recovering minted proofs via + * `wallet.restore` when the mint reports the outputs already signed/spent. Master verbatim. + */ + private async swapProofs( + wallet: Wallet, + swap: CashuSendSwap & { state: 'DRAFT' }, + outputData: { keep: OutputData[]; send: OutputData[] }, + ) { + const amountToSend = swap.amountToSend.toNumber( + getCashuUnit(swap.amountToSend.currency), + ); + + try { + return await wallet.ops + .send( + amountToSend, + swap.inputProofs.map((p) => toProof(p)), + ) + .keyset(swap.keysetId) + .asCustom(outputData.send) + .keepAsCustom(outputData.keep) + .run(); + } catch (error) { + if ( + error instanceof MintOperationError && + ([ + CashuErrorCodes.OUTPUT_ALREADY_SIGNED, + CashuErrorCodes.TOKEN_ALREADY_SPENT, + ].includes(error.code) || + // Nutshell < 0.16.5 did not conform to the spec (cashubtc/nutshell#693), so for + // earlier versions we also check the message. + error.message + .toLowerCase() + .includes('outputs have already been signed before')) + ) { + const totalOutputCount = + outputData.send.length + outputData.keep.length; + const { proofs } = await wallet.restore( + swap.keysetCounter, + totalOutputCount, + { keysetId: swap.keysetId }, + ); + + const textDecoder = new TextDecoder(); + return { + send: proofs.filter((o) => + outputData.send.some( + (s) => textDecoder.decode(s.secret) === o.secret, + ), + ), + keep: proofs.filter((o) => + outputData.keep.some( + (s) => textDecoder.decode(s.secret) === o.secret, + ), + ), + }; + } + throw error; + } + } +} diff --git a/packages/wallet-sdk/src/internal/encryption.test.ts b/packages/wallet-sdk/src/internal/encryption.test.ts index b6998a010..88060dbc9 100644 --- a/packages/wallet-sdk/src/internal/encryption.test.ts +++ b/packages/wallet-sdk/src/internal/encryption.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, mock, test } from 'bun:test'; -// Mock the ECIES primitive so these unit tests exercise the key-caching + (de)serialisation -// logic WITHOUT real crypto: `eciesDecryptBatch` echoes back a UTF-8 encoding of whatever the -// test queued (the base64-`decode` of the input is ignored). The queued plaintexts are the -// JSON strings master's `preprocessData` would have produced. +// Mock the ECIES primitives so these unit tests exercise the key-caching + (de)serialisation +// logic WITHOUT real crypto: +// - `eciesDecryptBatch` echoes back a UTF-8 encoding of whatever the test queued (the base64- +// `decode` of the input is ignored). The queued plaintexts are the JSON strings master's +// `preprocessData` would have produced. +// - `eciesDecrypt` (single) shifts one queued plaintext. +// - `eciesEncrypt` / `eciesEncryptBatch` echo the UTF-8 plaintext bytes straight back (so an +// encrypt → `decode(encode(x))` round-trip recovers the serialised JSON the encrypt path +// produced — letting the encrypt half be asserted against its own serialisation). const decryptedQueue: string[][] = []; mock.module('./lib-ecies', () => ({ eciesDecryptBatch: (data: Uint8Array[]) => { @@ -14,23 +19,36 @@ mock.module('./lib-ecies', () => ({ const enc = new TextEncoder(); return data.map((_, i) => enc.encode(next[i])); }, + eciesDecrypt: (_data: Uint8Array) => { + const next = decryptedQueue.shift(); + if (!next) { + throw new Error('no queued decrypt result'); + } + return new TextEncoder().encode(next[0]); + }, + eciesEncrypt: (data: Uint8Array) => data, + eciesEncryptBatch: (data: Uint8Array[]) => data, })); const { createEncryption, getEncryption } = await import('./encryption'); -/** A valid base64 ciphertext placeholder — its decoded bytes are ignored by the mock. */ +/** A valid base64 ciphertext placeholder — its decoded bytes are ignored by the decrypt mock. */ const CT = btoa('placeholder-ciphertext'); +/** Decode an encrypt-mock result (base64 of the serialised plaintext bytes) back to a string. */ +const decodeEncrypted = (b64: string) => new TextDecoder().decode(decode(b64)); +const { decode } = await import('@stablelib/base64'); afterEach(() => { decryptedQueue.length = 0; }); const key32 = new Uint8Array(32).fill(7); +const pubHex = '02'.repeat(33); describe('getEncryption.decryptBatch', () => { test('deserialises a number + a string proof field (master amount/secret)', async () => { decryptedQueue.push(['42', JSON.stringify('deadbeef')]); - const enc = getEncryption(key32); + const enc = getEncryption(key32, pubHex); const [amount, secret] = await enc.decryptBatch([CT, CT]); @@ -44,7 +62,7 @@ describe('getEncryption.decryptBatch', () => { JSON.stringify({ __type: 'undefined' }), JSON.stringify({ __type: 'number', value: 'Infinity' }), ]); - const enc = getEncryption(key32); + const enc = getEncryption(key32, pubHex); const [date, undef, inf] = await enc.decryptBatch([CT, CT, CT]); @@ -55,36 +73,85 @@ describe('getEncryption.decryptBatch', () => { }); }); -describe('createEncryption', () => { - test('fetches the key lazily + only once across calls', async () => { - let fetches = 0; - const enc = createEncryption(async () => { - fetches++; - return '07'.repeat(32); // hex → 32 bytes of 0x07 +describe('getEncryption.encrypt (serialisation)', () => { + test('encrypt serialises an object to type-preserving JSON', async () => { + const enc = getEncryption(key32, pubHex); + + const encrypted = await enc.encrypt({ a: 1, b: 'x' }); + + // The encrypt mock echoes the serialised bytes; recover them and assert master's shape. + expect(JSON.parse(decodeEncrypted(encrypted))).toEqual({ a: 1, b: 'x' }); + }); + + test('encryptBatch preserves order + tags non-finite numbers', async () => { + const enc = getEncryption(key32, pubHex); + + const [first, second] = await enc.encryptBatch([ + Number.POSITIVE_INFINITY, + 'second', + ]); + + expect(JSON.parse(decodeEncrypted(first))).toEqual({ + __type: 'number', + value: 'Infinity', }); + expect(JSON.parse(decodeEncrypted(second))).toBe('second'); + }); + + test('encrypt → decrypt round-trips a string field', async () => { + const enc = getEncryption(key32, pubHex); + + const encrypted = await enc.encrypt('roundtrip'); + // Feed the serialised plaintext (what the encrypt mock produced) into the decrypt queue. + decryptedQueue.push([decodeEncrypted(encrypted)]); + const decrypted = await enc.decrypt(CT); + + expect(decrypted).toBe('roundtrip'); + }); +}); + +describe('createEncryption', () => { + test('fetches the keys lazily + only once across calls', async () => { + let privFetches = 0; + let pubFetches = 0; + const enc = createEncryption( + async () => { + privFetches++; + return '07'.repeat(32); // hex → 32 bytes of 0x07 + }, + async () => { + pubFetches++; + return pubHex; + }, + ); // No fetch until the first decrypt. - expect(fetches).toBe(0); + expect(privFetches).toBe(0); + expect(pubFetches).toBe(0); decryptedQueue.push(['1']); await enc.decryptBatch([CT]); decryptedQueue.push(['2']); await enc.decryptBatch([CT]); - expect(fetches).toBe(1); + expect(privFetches).toBe(1); + expect(pubFetches).toBe(1); }); test('concurrent first-callers share a single key fetch', async () => { - let fetches = 0; - const enc = createEncryption(async () => { - fetches++; - return '07'.repeat(32); - }); + let privFetches = 0; + const enc = createEncryption( + async () => { + privFetches++; + return '07'.repeat(32); + }, + async () => pubHex, + ); decryptedQueue.push(['1']); decryptedQueue.push(['2']); await Promise.all([enc.decryptBatch([CT]), enc.decryptBatch([CT])]); - expect(fetches).toBe(1); + expect(privFetches).toBe(1); }); }); diff --git a/packages/wallet-sdk/src/internal/encryption.ts b/packages/wallet-sdk/src/internal/encryption.ts index d5030c85b..a14981282 100644 --- a/packages/wallet-sdk/src/internal/encryption.ts +++ b/packages/wallet-sdk/src/internal/encryption.ts @@ -5,24 +5,75 @@ * `apps/web-wallet/app/features/shared/encryption.ts`. A cashu account's stored proofs are * encrypted to the user's data-encryption key; reading an account therefore DECRYPTS the * `amount` + `secret` ciphertext (master `account-repository.decryptCashuProofs` → - * `encryption.decryptBatch`). Master expresses `Encryption` as a React hook - * (`useEncryption`) over two `@tanstack/react-query` suspense queries that fetch the - * derived key from the OpenSecret enclave; here the same key-derivation + ECIES is plain - * async code. + * `encryption.decryptBatch`), and the send/receive services that persist new quotes/swaps/ + * proofs (PR5b) ENCRYPT their `*-db-data` JSON + change proofs (`encrypt`/`encryptBatch`). + * Master expresses `Encryption` as a React hook (`useEncryption`) over two + * `@tanstack/react-query` suspense queries that fetch the derived private key + public key + * from the OpenSecret enclave; here the same key-derivation + ECIES is plain async code. * - * Only the DECRYPT path is built (Slice 3 reads encrypted proofs; the write/encrypt halves - * land with the send/receive services that persist new proofs). The - * `preprocessData`/`deserializeData` type-preservation (Date / undefined / non-finite - * number / `Money`) is ported VERBATIM from master so a decrypted value round-trips - * identically — for a proof, `amount` deserialises to a `number` and `secret` to a `string`. + * The full encrypt+decrypt surface is built (PR5a shipped the decrypt half for the account + * read path; PR5b adds the encrypt half the write path needs). The + * `preprocessData`/`serializeData`/`deserializeData` type-preservation (Date / undefined / + * non-finite number / `Money`) is ported VERBATIM from master so a value round-trips + * identically across encrypt → DB → decrypt — for a proof, `amount` deserialises to a + * `number` and `secret` to a `string`. + * + * KEYS: ECIES decrypt uses the user's 32-byte data-encryption PRIVATE key; encrypt uses the + * corresponding PUBLIC key (master `getPublicKey('schnorr', …)`). Both are derived from the + * OpenSecret enclave at `m/10111099'/0'` (`'enc'` in ASCII) and are stable for the session + * (master caches both with `staleTime: Infinity`). * * @module */ import { hexToBytes } from '@noble/hashes/utils'; -import { decode } from '@stablelib/base64'; -import { eciesDecryptBatch } from './lib-ecies'; +import { decode, encode } from '@stablelib/base64'; +import { + eciesDecrypt, + eciesDecryptBatch, + eciesEncrypt, + eciesEncryptBatch, +} from './lib-ecies'; import { Money } from '../types/money'; +/** + * Master's `preprocessData`: tag `Date` / `undefined` / non-finite `number` so JSON can + * round-trip them; `Money` is left as-is (re-tagged on serialize via its own JSON form). + * Ported verbatim from `shared/encryption.ts#preprocessData`. + */ +function preprocessData(obj: unknown): unknown { + if (obj === undefined) { + return { __type: 'undefined' }; + } + + if (typeof obj === 'number' && !Number.isFinite(obj)) { + return { __type: 'number', value: obj.toString() }; + } + + if (obj === null || typeof obj !== 'object' || obj instanceof Money) { + return obj; + } + + if (obj instanceof Date) { + return { __type: 'Date', value: obj.toISOString() }; + } + + if (Array.isArray(obj)) { + return obj.map(preprocessData); + } + + const result: Record = {}; + for (const key in obj) { + result[key] = preprocessData(obj[key as keyof typeof obj]); + } + return result; +} + +/** Master's `serializeData`: `preprocessData` + `JSON.stringify`. */ +function serializeData(data: unknown): string { + const preprocessedData = preprocessData(data); + return JSON.stringify(preprocessedData); +} + /** * Reverse of master's `preprocessData`: rehydrate the type-tagged JSON back into `Date` / * `undefined` / non-finite `number` / `Money`. A JSON `reviver`, ported verbatim from @@ -53,6 +104,49 @@ function deserializeData(serializedData: string): T { }) as T; } +/** + * Encrypt one value to the user's data-encryption public key with ECIES, base64-encoded. + * Ported from `shared/encryption.ts#encryptToPublicKey`. + */ +function encryptToPublicKey( + data: T, + publicKeyHex: string, +): string { + const serialized = serializeData(data); + const dataBytes = new TextEncoder().encode(serialized); + const publicKeyBytes = hexToBytes(publicKeyHex); + return encode(eciesEncrypt(dataBytes, publicKeyBytes)); +} + +/** + * Encrypt a batch of values to the user's data-encryption public key (one shared ephemeral + * key for the batch). Order preserved. Ported from `shared/encryption.ts#encryptBatchToPublicKey`. + */ +function encryptBatchToPublicKey( + data: T, + publicKeyHex: string, +): string[] { + const encoder = new TextEncoder(); + const dataBytes = data.map((x) => + encoder.encode(JSON.stringify(preprocessData(x))), + ); + const publicKeyBytes = hexToBytes(publicKeyHex); + return eciesEncryptBatch(dataBytes, publicKeyBytes).map((x) => encode(x)); +} + +/** + * Decrypt one base64-encoded ECIES ciphertext with the user's private key. Ported from + * `shared/encryption.ts#decryptWithPrivateKey`. + */ +function decryptWithPrivateKey( + encryptedData: string, + privateKeyBytes: Uint8Array, +): T { + const decryptedBytes = eciesDecrypt(decode(encryptedData), privateKeyBytes); + const decryptedString = new TextDecoder().decode(decryptedBytes); + return deserializeData(decryptedString); +} + /** * Decrypt a batch of base64-encoded ECIES ciphertexts with the user's private key, order * preserved. Ported from `shared/encryption.ts#decryptBatchWithPrivateKey`. @@ -75,14 +169,22 @@ function decryptBatchWithPrivateKey( } /** - * The decrypt surface the account read path needs — the framework-free half of master's - * `Encryption` type (the encrypt/encryptBatch halves land with the slices that write - * encrypted rows). + * The encrypt + decrypt surface the account read path + the send/receive write path need — + * the framework-free form of master's `Encryption` type. */ export type Encryption = { + /** Encrypt one value to the user's data-encryption public key (base64 ECIES). */ + encrypt: (data: T) => Promise; + /** Decrypt one value previously encrypted with `encrypt`. */ + decrypt: (data: string) => Promise; + /** + * Encrypt an array of values to the user's data-encryption public key (one shared ephemeral + * key for the batch). Order preserved. + */ + encryptBatch: (data: T) => Promise; /** - * Decrypt an array of values previously encrypted with the user's encryption key. Input - * may be in any order; output order matches input. + * Decrypt an array of values previously encrypted with `encryptBatch` (or any user-key + * ciphertexts). Input may be in any order; output order matches input. * * @param data - base64-encoded ECIES ciphertexts. * @returns a promise of the decrypted values, in input order. @@ -93,15 +195,25 @@ export type Encryption = { }; /** - * Build an {@link Encryption} bound to the user's data-encryption private key. Ported from - * `shared/encryption.ts#getEncryption` (decrypt half). The key is the 32-byte private key - * the SDK derives from the OpenSecret enclave (see {@link createEncryption}). + * Build an {@link Encryption} bound to the user's data-encryption private + public keys. + * Ported from `shared/encryption.ts#getEncryption`. The keys are the 32-byte private key + + * its hex public key the SDK derives from the OpenSecret enclave (see {@link createEncryption}). * - * @param privateKey - the 32-byte data-encryption private key. - * @returns the encryption (decrypt) surface. + * @param privateKey - the 32-byte data-encryption private key (decrypt). + * @param publicKeyHex - the hex-encoded data-encryption public key (encrypt). + * @returns the encryption surface. */ -export function getEncryption(privateKey: Uint8Array): Encryption { +export function getEncryption( + privateKey: Uint8Array, + publicKeyHex: string, +): Encryption { return { + encrypt: async (data: T) => + encryptToPublicKey(data, publicKeyHex), + decrypt: async (data: string) => + decryptWithPrivateKey(data, privateKey), + encryptBatch: async (data: T) => + encryptBatchToPublicKey(data, publicKeyHex), decryptBatch: async ( data: readonly [...{ [K in keyof T]: string }], ) => decryptBatchWithPrivateKey(data, privateKey), @@ -121,24 +233,53 @@ export function getEncryption(privateKey: Uint8Array): Encryption { export type FetchEncryptionPrivateKeyHex = () => Promise; /** - * Construct an {@link Encryption} from an enclave key fetcher, lazily fetching + caching the - * derived private key on first use (the key is stable for the session — master caches it - * with `staleTime: Infinity`). Concurrent first-callers share the single in-flight fetch. + * Fetch the user's hex-encoded data-encryption PUBLIC key from the enclave. Injected for the + * same reason as {@link FetchEncryptionPrivateKeyHex}. Master derives it at the same + * `m/10111099'/0'` path via `getPublicKey('schnorr', …)` + * (`shared/encryption.ts#encryptionPublicKeyQueryOptions`). + */ +export type FetchEncryptionPublicKeyHex = () => Promise; + +/** + * Construct an {@link Encryption} from the enclave key fetchers, lazily fetching + caching + * the derived private + public keys on first use (both are stable for the session — master + * caches them with `staleTime: Infinity`). Concurrent first-callers share the single + * in-flight fetch. * - * @param fetchPrivateKeyHex - obtains the hex-encoded data-encryption private key. - * @returns an {@link Encryption} whose `decryptBatch` resolves the key on first call. + * @param fetchPrivateKeyHex - obtains the hex-encoded data-encryption private key (decrypt). + * @param fetchPublicKeyHex - obtains the hex-encoded data-encryption public key (encrypt). + * @returns an {@link Encryption} whose methods resolve the keys on first call. */ export function createEncryption( fetchPrivateKeyHex: FetchEncryptionPrivateKeyHex, + fetchPublicKeyHex: FetchEncryptionPublicKeyHex, ): Encryption { - let keyPromise: Promise | null = null; - const getKey = (): Promise => { - keyPromise ??= fetchPrivateKeyHex().then(hexToBytes); - return keyPromise; + let keysPromise: Promise<{ + privateKey: Uint8Array; + publicKeyHex: string; + }> | null = null; + const getKeys = (): Promise<{ + privateKey: Uint8Array; + publicKeyHex: string; + }> => { + keysPromise ??= Promise.all([ + fetchPrivateKeyHex().then(hexToBytes), + fetchPublicKeyHex(), + ]).then(([privateKey, publicKeyHex]) => ({ privateKey, publicKeyHex })); + return keysPromise; + }; + const resolved = async (): Promise => { + const { privateKey, publicKeyHex } = await getKeys(); + return getEncryption(privateKey, publicKeyHex); }; return { + encrypt: async (data: T) => (await resolved()).encrypt(data), + decrypt: async (data: string) => + (await resolved()).decrypt(data), + encryptBatch: async (data: T) => + (await resolved()).encryptBatch(data), decryptBatch: async ( data: readonly [...{ [K in keyof T]: string }], - ) => getEncryption(await getKey()).decryptBatch(data), + ) => (await resolved()).decryptBatch(data), }; } diff --git a/packages/wallet-sdk/src/internal/lib-cashu-crypto.ts b/packages/wallet-sdk/src/internal/lib-cashu-crypto.ts new file mode 100644 index 000000000..d2aaf75cf --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-cashu-crypto.ts @@ -0,0 +1,42 @@ +/** + * SDK-internal cashu locking-key crypto — Slice 3 / PR5b. + * + * The cashu RECEIVE quote flow locks the mint quote with a NUT-20 public key derived from the + * user's cashu locking xPub (`shared/cashu.ts#BASE_CASHU_LOCKING_DERIVATION_PATH` + + * `shared/cryptography.ts#derivePublicKey`). Master's `shared/cryptography.ts` imports `react` + * at module level (`useMemo` for the `useCryptography` hook), so it CANNOT be re-exported + * single-source without pulling react. `derivePublicKey` itself is a tiny pure `@scure/bip32` + * HDKey derivation — re-housed VERBATIM here (the canonical relocation, when `shared/*` moves + * into the package, will collapse this back). + * + * @module + */ +import { HDKey } from '@scure/bip32'; + +/** + * The base cashu locking derivation path (`shared/cashu.ts`, verbatim). 129372 is UTF-8 for 🥜 + * (NUT-13); coin-type 0, account 0. DO NOT CHANGE without migrating users' stored xPub — it + * would derive the wrong keys when getting private keys. + */ +export const BASE_CASHU_LOCKING_DERIVATION_PATH = "m/129372'/0'/0'"; + +/** + * Derive a public key (hex) from an xPub + a derivation path. Re-housed VERBATIM from + * `shared/cryptography.ts#derivePublicKey`. + * + * @param xpub - the base58-check extended public key. + * @param derivationPath - the child path to derive. + * @returns the derived compressed public key as a hex string (empty if derivation fails). + */ +export const derivePublicKey = ( + xpub: string, + derivationPath: string, +): string => { + const hdKey = HDKey.fromExtendedKey(xpub); + const childKey = hdKey.derive(derivationPath); + return childKey.publicKey + ? Array.from(childKey.publicKey) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + : ''; +}; diff --git a/packages/wallet-sdk/src/internal/lib-cashu-quotes.ts b/packages/wallet-sdk/src/internal/lib-cashu-quotes.ts new file mode 100644 index 000000000..cc524bc53 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-cashu-quotes.ts @@ -0,0 +1,163 @@ +/** + * SDK-internal cashu quote/swap **runtime schemas + service primitives** — Slice 3 / PR5b. + * + * The cashu send/receive SERVICES (PR5b) need three groups of `app/lib/cashu` + `app/features` + * symbols beyond the Slice-2/PR5a balance + wallet-construction helpers (`./lib-cashu` / + * `./lib-cashu-wallet`): + * + * 1. the runtime zod SCHEMAS for the quote/swap domain types — the repos parse a decrypted + * DB row back to the domain type with `CashuSendQuoteSchema.parse` etc. (the package's + * `types/cashu.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); + * 2. the `agicash-db/json-models` `*DbDataSchema` validators for the encrypted `jsonb` blobs; + * 3. small framework-free cashu protocol helpers (`proofToY` / `sumProofs` / `splitAmount` / + * `getCashuProtocolUnit` / `areMintUrlsEqual`) + `toProof` / `CashuProofSchema`. + * + * Re-housing approach (matches `./lib-cashu` / `./lib-cashu-wallet` / `types/money.ts`): + * 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 modules (NOT the `lib/cashu` barrel) so we do NOT pull the heavier + * `melt-quote-subscription-manager` / `mint-quote-subscription-manager` surface the barrel + * re-exports — those mint-WS managers are the orchestrator sub-slice (5d), not PR5b. None of + * the symbols re-exported here transitively pulls react / @tanstack (verified): the master + * domain-schema + json-model files are pure `zod/mini` + `Money` + `@cashu/cashu-ts`. + * + * The `~/*` path alias (mapped in the package `tsconfig.json` to `apps/web-wallet/app/*`) lets + * the re-exported schema files resolve their own `~/lib/money` / `~/lib/cashu` imports — the + * same single-source seam, one level deeper than PR5a's leaf-only re-exports. `send/utils.ts`'s + * `toDecryptedCashuProofs` is the one helper NOT re-exported (it imports a `database.ts` DB-row + * type that pulls the generated `supabase/database.types`, not in the package's resolution); it + * is re-housed locally below (a tiny pure mapper) — matching `db-account.ts`'s hand-written DB + * rows. The canonical relocation of `app/lib/cashu/**` + these schema files INTO the package is + * a deferred follow-up (out of the build-plan's scope). + * + * @module + */ +import type { Token } from '@cashu/cashu-ts'; +import { z } from 'zod/mini'; +import type { AgicashDbCashuProof } from './db-account'; +import { ProofSchema } from './lib-cashu-wallet'; +import { computeSHA256 } from './crypto'; +import { encodeToken } from '../../../../apps/web-wallet/app/lib/cashu/token'; +import { sumProofs } from '../../../../apps/web-wallet/app/lib/cashu/proof'; +import type { CashuProof } from '../types/account'; +import { type Currency, type CurrencyUnit, Money } from '../types/money'; + +// --- cashu protocol helpers (specific modules; framework-free) --------------------------- +export { proofToY } from '../../../../apps/web-wallet/app/lib/cashu/proof'; +export { + areMintUrlsEqual, + getCashuProtocolUnit, + getCashuUnit, +} from '../../../../apps/web-wallet/app/lib/cashu/utils'; +export { encodeToken } from '../../../../apps/web-wallet/app/lib/cashu/token'; +export { splitAmount } from '@cashu/cashu-ts'; +export { sumProofs }; + +// --- domain CashuProof helpers (accounts/cashu-account.ts) -------------------------------- +export { + CashuProofSchema, + toProof, +} from '../../../../apps/web-wallet/app/features/accounts/cashu-account'; + +// --- runtime domain schemas (send/receive *-quote.ts + *-swap.ts) ------------------------ +export { + CashuSendQuoteSchema, + DestinationDetailsSchema, +} from '../../../../apps/web-wallet/app/features/send/cashu-send-quote'; +export { CashuSendSwapSchema } from '../../../../apps/web-wallet/app/features/send/cashu-send-swap'; +export { CashuReceiveQuoteSchema } from '../../../../apps/web-wallet/app/features/receive/cashu-receive-quote'; +export { CashuReceiveSwapSchema } from '../../../../apps/web-wallet/app/features/receive/cashu-receive-swap'; + +// --- encrypted-jsonb DB-data schemas (agicash-db/json-models) ---------------------------- +export { + CashuLightningSendDbDataSchema, + CashuSwapSendDbDataSchema, + CashuLightningReceiveDbDataSchema, + CashuSwapReceiveDbDataSchema, +} from '../../../../apps/web-wallet/app/features/agicash-db/json-models'; + +/** + * Map encrypted cashu-proof rows + their interleaved decrypted `(amount, secret)` plaintexts + * to domain {@link CashuProof}s. Re-housed verbatim from master `send/utils.ts#toDecryptedCashuProofs` + * (the SDK uses its own hand-written {@link AgicashDbCashuProof} row instead of master's + * `database.ts` generated one — same fields, so the logic is identical). + * + * @param proofs - the encrypted proof rows. + * @param decryptedProofsData - the interleaved decrypted `[amount, secret, amount, secret, …]`. + * @returns the domain proofs (amount re-parsed as a `number`, secret as a `string`). + */ +export function toDecryptedCashuProofs( + proofs: AgicashDbCashuProof[], + decryptedProofsData: unknown[], +): CashuProof[] { + return proofs.map((dbProof, index) => { + const decryptedDataIndex = index * 2; + const amount = z.number().parse(decryptedProofsData[decryptedDataIndex]); + const secret = z + .string() + .parse(decryptedProofsData[decryptedDataIndex + 1]); + + return { + id: dbProof.id, + accountId: dbProof.account_id, + userId: dbProof.user_id, + keysetId: dbProof.keyset_id, + amount, + secret, + unblindedSignature: dbProof.unblinded_signature, + publicKeyY: dbProof.public_key_y, + dleq: ProofSchema.shape.dleq.parse(dbProof.dleq), + witness: ProofSchema.shape.witness.parse(dbProof.witness), + state: dbProof.state, + version: dbProof.version, + createdAt: dbProof.created_at, + reservedAt: dbProof.reserved_at, + }; + }); +} + +/** + * The SHA-256 hash of a cashu token (its canonical encoded form). Re-housed VERBATIM from + * `shared/cashu.ts#getTokenHash` (master's lives next to react-coupled query options, so it is + * re-housed here rather than re-exported). Used to de-dupe receive swaps + tag send swaps. + * + * @param token - a decoded {@link Token} (encoded first) or an already-encoded token string. + * @returns the hex SHA-256 hash. + */ +export function getTokenHash(token: Token | string): Promise { + if (typeof token === 'string') { + return computeSHA256(token); + } + return computeSHA256(encodeToken(token)); +} + +/** Map a token's `unit` to the domain `Currency` + cashu `CurrencyUnit` (master verbatim). */ +function getCurrencyAndUnitFromToken(token: Token): { + currency: Currency; + unit: CurrencyUnit; +} { + if (token.unit === 'sat') { + return { currency: 'BTC', unit: 'sat' }; + } + if (token.unit === 'usd') { + return { currency: 'USD', unit: 'cent' }; + } + throw new Error(`Invalid token unit ${token.unit}`); +} + +/** + * The {@link Money} value of a cashu token (sum of its proofs in the token's unit). Re-housed + * VERBATIM from `shared/cashu.ts#tokenToMoney`. + * + * @param token - the decoded token. + * @returns the token amount as {@link Money}. + */ +export function tokenToMoney(token: Token): Money { + const { currency, unit } = getCurrencyAndUnitFromToken(token); + return new Money({ + amount: sumProofs(token.proofs), + currency, + unit, + }); +} diff --git a/packages/wallet-sdk/src/internal/lib-ecies.ts b/packages/wallet-sdk/src/internal/lib-ecies.ts index 7eab34374..b6cd2f61f 100644 --- a/packages/wallet-sdk/src/internal/lib-ecies.ts +++ b/packages/wallet-sdk/src/internal/lib-ecies.ts @@ -10,12 +10,16 @@ * there is ONE implementation and no web churn. The canonical relocation of `app/lib/ecies` * INTO the package is a deferred follow-up (out of the build-plan's scope). * - * Only the BATCH decrypt is re-exported: that is the single primitive `./encryption`'s - * `decryptBatch` (the proof-decrypt path) needs. The other ECIES functions - * (encrypt/decrypt single, encrypt batch) are not used by Slice 3 and are pulled in by - * later slices that write encrypted rows. + * The proof-DECRYPT path (PR5a) needs `eciesDecryptBatch`; the send/receive services this + * slice (PR5b) adds WRITE encrypted rows, so they also need the ENCRYPT primitives + * (`eciesEncrypt` single + `eciesEncryptBatch`). All three are re-exported single-source. * * @module */ -export { eciesDecryptBatch } from '../../../../apps/web-wallet/app/lib/ecies/ecies'; +export { + eciesDecrypt, + eciesDecryptBatch, + eciesEncrypt, + eciesEncryptBatch, +} from '../../../../apps/web-wallet/app/lib/ecies/ecies'; diff --git a/packages/wallet-sdk/src/internal/lib-lnurl.ts b/packages/wallet-sdk/src/internal/lib-lnurl.ts new file mode 100644 index 000000000..853ff0081 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-lnurl.ts @@ -0,0 +1,26 @@ +/** + * SDK-internal LNURL primitive — Slice 3 / PR5b. + * + * `createLightningQuote` resolves a Lightning address (LUD-16) to a bolt11 invoice INTERNALLY, + * using the known amount (the contract folds ln-address → invoice into the quote creation, NOT + * into `scan.parse`, §3). That resolution is `getInvoiceFromLud16` (LNURL-pay) from + * `app/lib/lnurl`, which is framework-free (`ky` HTTP only — no react / @tanstack; verified). + * + * Re-housing approach (matches `./lib-scan` / `./lib-cashu`): re-export the single live source + * via a relative path so there is exactly ONE implementation. The canonical relocation of + * `app/lib/lnurl` INTO the package is a deferred follow-up (out of the build-plan's scope). + * + * `getInvoiceFromLud16` returns `LNURLPayResult | LNURLError` — the caller checks `isLNURLError` + * and throws a {@link DomainError} on the error branch (master's `use-get-invoice-from-lud16` + * does the same). The `requestDomain` "bypassAmountValidation" optimisation master reads from + * `useLocationData().domain` is passed in from `SdkConfig` (or omitted) — re-housed off the hook. + * + * @module + */ + +export { + type LNURLError, + type LNURLPayResult, + getInvoiceFromLud16, + isLNURLError, +} from '../../../../apps/web-wallet/app/lib/lnurl'; diff --git a/packages/wallet-sdk/src/internal/lib-scan.ts b/packages/wallet-sdk/src/internal/lib-scan.ts index ad7392d27..3a4d94b28 100644 --- a/packages/wallet-sdk/src/internal/lib-scan.ts +++ b/packages/wallet-sdk/src/internal/lib-scan.ts @@ -24,6 +24,7 @@ export { type DecodedBolt11, + decodeBolt11, parseBolt11Invoice, } from '../../../../apps/web-wallet/app/lib/bolt11'; export { extractCashuToken } from '../../../../apps/web-wallet/app/lib/cashu/token'; diff --git a/packages/wallet-sdk/src/internal/open-secret.ts b/packages/wallet-sdk/src/internal/open-secret.ts index 4e34ab2d3..35f5634db 100644 --- a/packages/wallet-sdk/src/internal/open-secret.ts +++ b/packages/wallet-sdk/src/internal/open-secret.ts @@ -28,6 +28,7 @@ import { generateThirdPartyToken, getPrivateKey as osGetPrivateKey, getPrivateKeyBytes as osGetPrivateKeyBytes, + getPublicKey as osGetPublicKey, handleGoogleCallback as osHandleGoogleCallback, initiateGoogleAuth as osInitiateGoogleAuth, refreshAccessToken as osRefreshAccessToken, @@ -38,6 +39,7 @@ import { signUp as osSignUp, signUpGuest as osSignUpGuest, } from '@agicash/opensecret'; +import { HDKey } from '@scure/bip32'; import { mnemonicToSeedSync } from '@scure/bip39'; import { jwtDecode } from 'jwt-decode'; import { getSeedPhraseDerivationPath } from './lib-account-cryptography'; @@ -301,4 +303,53 @@ export class OpenSecretClient { }); return mnemonic; } + + /** + * The hex-encoded data-encryption PUBLIC key (`getPublicKey('schnorr', …)` at + * {@link ENCRYPTION_KEY_DERIVATION_PATH}). The `internal/encryption` layer's `encrypt`/ + * `encryptBatch` path needs it. Re-houses master `encryptionPublicKeyQueryOptions`. + * + * @returns the hex-encoded public key. + */ + async getEncryptionPublicKeyHex(): Promise { + const { public_key } = await osGetPublicKey('schnorr', { + private_key_derivation_path: ENCRYPTION_KEY_DERIVATION_PATH, + }); + return public_key; + } + + /** + * The cashu locking xPub at the given derivation path off the cashu wallet seed. Re-houses + * master `shared/cashu.ts#xpubQueryOptions`: derive the cashu child seed, build an + * {@link HDKey} from it, and return the (optionally child-derived) extended public key. + * Used to derive NUT-20 quote-locking public keys (receive quotes). + * + * @param derivationPath - optional child path off the cashu master seed. + * @returns the base58-check extended public key. + */ + async getCashuLockingXpub(derivationPath?: string): Promise { + const seed = await this.getCashuWalletSeed(); + const hdKey = HDKey.fromMasterSeed(seed); + if (derivationPath) { + return hdKey.derive(derivationPath).publicExtendedKey; + } + return hdKey.publicExtendedKey; + } + + /** + * The hex-encoded cashu locking PRIVATE key at the given derivation path (used to unlock a + * NUT-20-locked mint quote when minting). Re-houses master + * `shared/cashu.ts#privateKeyQueryOptions`: `getPrivateKeyBytes` off the cashu seed at the + * given `private_key_derivation_path`. + * + * @param derivationPath - the locking key's derivation path off the cashu seed. + * @returns the hex-encoded private key. + */ + async getCashuLockingPrivateKeyHex(derivationPath?: string): Promise { + const { private_key } = await osGetPrivateKeyBytes({ + seed_phrase_derivation_path: CASHU_SEED_DERIVATION_PATH, + private_key_derivation_path: derivationPath, + }); + return private_key; + } } diff --git a/packages/wallet-sdk/src/sdk.ts b/packages/wallet-sdk/src/sdk.ts index 06472dce1..30697c9fe 100644 --- a/packages/wallet-sdk/src/sdk.ts +++ b/packages/wallet-sdk/src/sdk.ts @@ -33,11 +33,25 @@ import type { import type { EventEmitter, SdkEventMap } from './events'; import { AccountsDomainImpl } from './domains/accounts'; import { AuthDomainImpl } from './domains/auth'; +import { + CashuDomainImpl, + CashuReceiveOpsImpl, + CashuSendOpsImpl, +} from './domains/cashu'; import { ScanDomainImpl } from './domains/scan'; import { UserDomainImpl } from './domains/user'; import { LiveAccountHandleResolver } from './internal/account-handle-resolver'; import { AccountRepository } from './internal/account-repository'; +import { CashuReceiveQuoteRepository } from './internal/cashu-receive-quote-repository'; +import { CashuReceiveQuoteService } from './internal/cashu-receive-quote-service'; +import { CashuReceiveSwapRepository } from './internal/cashu-receive-swap-repository'; +import { CashuReceiveSwapService } from './internal/cashu-receive-swap-service'; +import { CashuSendQuoteRepository } from './internal/cashu-send-quote-repository'; +import { CashuSendQuoteService } from './internal/cashu-send-quote-service'; +import { CashuSendSwapRepository } from './internal/cashu-send-swap-repository'; +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 { SparkWalletCache } from './internal/spark-wallet'; import { TypedEventEmitter } from './internal/event-emitter'; @@ -46,7 +60,6 @@ import { OpenSecretClient } from './internal/open-secret'; import { SessionResolver } from './internal/session'; import { createBackgroundStub, - createCashuStub, createContactsStub, createExchangeRateStub, createSparkStub, @@ -60,6 +73,8 @@ import { } from './internal/supabase-client'; import { SupabaseSessionTokenProvider } from './internal/supabase-session'; import { UserRepository } from './internal/user-repository'; +import type { AgicashDbAccountWithProofs } from './internal/db-account'; +import type { CashuAccount } from './types/account'; import type { StorageAdapter } from './types/dependencies'; /** @@ -168,6 +183,7 @@ export class Sdk { user: UserDomain; accounts: AccountsDomain; scan: ScanDomain; + cashu: CashuDomain; }, ) { this.connections = connections; @@ -180,9 +196,10 @@ export class Sdk { // Slice 2: accounts + scan. this.accounts = domains.accounts; this.scan = domains.scan; + // Slice 3 / PR5b: cashu send + receive ops (executeQuote orchestrator deferred to PR5d). + this.cashu = domains.cashu; // --- domain accessors still STUBBED (swap per slice) --------------------- - this.cashu = createCashuStub(); this.spark = createSparkStub(); this.transactions = createTransactionsStub(); this.contacts = createContactsStub(); @@ -273,8 +290,9 @@ export class Sdk { // proofs (OpenSecret-derived key + ECIES), and connects the spark Breez wallet (when a // `breezApiKey` is configured). The per-mint + per-spark memos (built above, on the // connection bundle) live as long as the SDK and are dropped in `destroy()`. - const encryption = createEncryption(() => - openSecret.getEncryptionPrivateKeyHex(), + const encryption = createEncryption( + () => openSecret.getEncryptionPrivateKeyHex(), + () => openSecret.getEncryptionPublicKeyHex(), ); const accountHandleResolver = new LiveAccountHandleResolver({ encryption, @@ -296,7 +314,75 @@ export class Sdk { // SDK does not read. const scan = new ScanDomainImpl(); - return new Sdk(connections, { auth, user, accounts, scan }); + // --- Slice 3 / PR5b: cashu send + receive ops ---------------------------- + // The cashu repos write/read the `wallet.cashu_{send,receive}_*` tables over the SDK-owned + // Supabase client + the SDK Encryption (encrypt-on-write / decrypt-on-read of the jsonb + + // proof ciphertext). The account-returning RPCs (receive payment/complete) map the returned + // row via the resolver-backed `dbAccountToAccount` (the same live-handle resolver Slice 2 + // wires). The services drive the mint over each account's live `ExtendedCashuWallet` (PR5a), + // keeping master's idempotency (`wallet.restore`) + DB reservation / CONCURRENCY_ERROR + // guards. `executeQuote` (the orchestrator state machine) is DEFERRED to PR5d — see + // `domains/cashu.ts`; PR5b ships the primitives it sequences. + const mapCashuAccount = (row: AgicashDbAccountWithProofs) => + dbAccountToAccount(row, accountHandleResolver); + + const cashuSendQuoteRepository = new CashuSendQuoteRepository( + supabase, + encryption, + ); + const cashuSendSwapRepository = new CashuSendSwapRepository( + supabase, + encryption, + ); + const cashuReceiveQuoteRepository = new CashuReceiveQuoteRepository( + supabase, + encryption, + mapCashuAccount, + ); + const cashuReceiveSwapRepository = new CashuReceiveSwapRepository( + supabase, + encryption, + mapCashuAccount, + ); + + const cashuReceiveSwapService = new CashuReceiveSwapService( + cashuReceiveSwapRepository, + ); + const cashuSendQuoteService = new CashuSendQuoteService( + cashuSendQuoteRepository, + ); + const cashuSendSwapService = new CashuSendSwapService( + cashuSendSwapRepository, + cashuReceiveSwapService, + ); + // The cashu crypto operations (NUT-20 quote-locking xPub + unlocking key) are backed by the + // OpenSecret client (re-housed off master's `useCashuCryptography` query options). + const cashuReceiveQuoteService = new CashuReceiveQuoteService( + { + getXpub: (path?: string) => openSecret.getCashuLockingXpub(path), + getPrivateKey: (path?: string) => + openSecret.getCashuLockingPrivateKeyHex(path), + }, + cashuReceiveQuoteRepository, + ); + + const cashu = new CashuDomainImpl( + new CashuSendOpsImpl( + cashuSendQuoteService, + cashuSendSwapService, + cashuSendQuoteRepository, + cashuSendSwapRepository, + accountRepository, + session, + ), + new CashuReceiveOpsImpl( + cashuReceiveQuoteService, + cashuReceiveQuoteRepository, + session, + ), + ); + + return new Sdk(connections, { auth, user, accounts, scan, cashu }); } /** diff --git a/packages/wallet-sdk/tsconfig.json b/packages/wallet-sdk/tsconfig.json index 56f294042..16f2f6c9d 100644 --- a/packages/wallet-sdk/tsconfig.json +++ b/packages/wallet-sdk/tsconfig.json @@ -4,6 +4,10 @@ "exclude": ["node_modules"], "compilerOptions": { "lib": ["ES2022", "DOM"], - "noEmit": true + "noEmit": true, + "baseUrl": ".", + "paths": { + "~/*": ["../../apps/web-wallet/app/*"] + } } }