diff --git a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts index c5d4ad00c..71f5baa67 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts @@ -14,6 +14,7 @@ import { } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useAccountRepository } from '~/features/accounts/account-repository-hooks'; +import { sdk } from '~/features/shared/sdk.client'; import { useGetCashuAccount, useSelectItemsWithOnlineAccount, @@ -97,8 +98,6 @@ export function usePendingCashuReceiveSwapsCache() { } export function useCreateCashuReceiveSwap() { - const userId = useUser((user) => user.id); - const receiveSwapService = useCashuReceiveSwapService(); const getCashuAccount = useGetCashuAccount(); return useMutation({ @@ -108,11 +107,7 @@ export function useCreateCashuReceiveSwap() { }, mutationFn: ({ token, accountId }: CreateProps) => { const account = getCashuAccount(accountId); - return receiveSwapService.create({ - userId, - token, - account, - }); + return sdk.receive.cashu.createSwap({ token, account }); }, }); } diff --git a/docs/superpowers/plans/2026-08-18-wallet-sdk-cashu-receive-swap-slice.md b/docs/superpowers/plans/2026-08-18-wallet-sdk-cashu-receive-swap-slice.md new file mode 100644 index 000000000..20241ecd8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-wallet-sdk-cashu-receive-swap-slice.md @@ -0,0 +1,142 @@ +# Wallet SDK Cashu Receive Swap Slice (Step 10) Implementation Plan + +**Goal:** Add `createSwap` to the `receive.cashu` sub-namespace of the SDK contract and flip the web same-mint token claim (`useCreateCashuReceiveSwap`) from `@agicash/wallet-sdk/temporary` to `sdk.receive.cashu.createSwap`. + +**Architecture:** Step 10 of the 19-step no-cache extraction (spec: `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md`). The already-moved `domain/receive` cashu-receive-swap code gets a host-facing contract method inside the existing session-fenced `createReceiveApi` factory (step 9). This is a **money-path slice**: the web background processor (`useProcessCashuReceiveSwapTasks`) keeps completing swaps through `/temporary` until step 18 — the same boundary step 9 kept for quotes. + +**Tech stack:** TypeScript, bun workspaces, bun:test, Supabase (postgrest-js), TanStack Query v5 (web side only). + +## Global constraints + +- The SDK stays React-agnostic: `packages/wallet-sdk` never imports `react` or `@tanstack/react-query`. +- Do not touch other domains' `/temporary` imports — only the files listed in this plan. +- The web money-path boundary stays intact: `useProcessCashuReceiveSwapTasks`, `useCashuReceiveSwapChangeHandlers`, `usePendingCashuReceiveSwaps`, `usePendingCashuReceiveSwapsCache`, and the `useCashuReceiveSwapRepository`/`useCashuReceiveSwapService` hook exports keep building `/temporary` classes until step 18. +- No event emission from the receive API. `cashu-receive-swap.created`/`.updated` stay type-only until the step-18 realtime feed. `events.ts` is untouched. +- Contract methods take the caller-supplied full `account: CashuAccount` (contract proposal, "Conventions across all namespaces"; set by the step-9 review #1176). Fetch-by-id stays reserved for background/orchestrator paths. A slice never re-decides this. +- Do not split the receive/send repositories or services into host/processing halves — that is the step-18 bullet in the production design, decided there and not earlier. +- Package manager: `bun` / `bunx` only. +- Base branch: `master` (at 056cbfc5, step 9 merged). +- No DB schema changes, no dependency changes. The `create_cashu_receive_swap` RPC is used unchanged. +- `packages/wallet-sdk/index.ts`, `domain/sdk/index.ts`, and `domain/sdk/sdk.ts` are untouched: `createReceiveApi` is already wired (step 9) and `export * from './domain/sdk'` already carries the new param types; `CashuReceiveSwap` is already a root type export. + +## Resolved design decisions + +1. **Contract home is `receive.cashu.createSwap`.** The entity is `CashuReceiveSwap`, a cashu-rail receive; rails appear as sub-namespaces where flows diverge, and the send-side sketch already places `createSwap` under `send.cashu`. The `spark`/`cashuToken` placeholders (steps 11/12) stay untouched. The contract-proposal `ReceiveApi` sketch gains the matching one-line entry so spec and code do not drift. +2. **Params: `CreateCashuReceiveSwapParams = { account: CashuAccount; token: Token }`.** The host passes the account it already holds (convention above). The web resolves it from its accounts cache — a cache read, not a network request, so the flipped flow issues no additional requests versus master. +3. **Result: `{ swap: CashuReceiveSwap; account: CashuAccount }`** — the service's own return shape. The `create_cashu_receive_swap` RPC advances the account keyset counter, so the updated account rides back on the same call (no extra read); a host without realtime needs it to stay coherent. The web component keeps using only `swap.transactionId` — parity, not new behavior. +4. **`reversedTransactionId` stays in-package.** Only `CashuSendSwapService` (reversal, step 14) and `ClaimCashuTokenService` (claim orchestration, step 12) pass it; the host never does. Same move as step 9 pinning `receiveType: 'LIGHTNING'`. +5. **No read surface this slice.** The only foreground swap read is the debug details page (`transaction-additional-details.tsx` → `getByTransactionId`), which step 9 deliberately left repo-level for the quote domain too (its decision 9). `getPending` is processor-only (step 18). +6. **`CashuReceiveSwapService.create` gains an optional second param `options?: { abortSignal?: AbortSignal }`**, threaded to `receiveSwapRepository.create(params, options)` (the repository already accepts `Options`). In-package callers (`claim-cashu-token-service.ts:125`, `cashu-send-swap-service.ts:280`) are unchanged — the param is optional. Mirrors step 9's `createReceiveQuote` change. +7. **Session fences follow the step-9 template** (accounts pattern): `requireUserId()` → capture `sessionSignal()` → await the service builder → re-check → `service.create(params, { abortSignal: signal })` → re-check. New test seams `createSwapRepository`/`createSwapService` mirror the quote seams. +8. **Web flip is confined to `useCreateCashuReceiveSwap`** in `cashu-receive-swap-hooks.ts`: the mutation body resolves the account from the accounts cache (`useGetCashuAccount`, unchanged) and calls `sdk.receive.cashu.createSwap({ token, account })`. The hook's external `{ token, accountId }` interface is unchanged, so `receive-cashu-token.tsx` is untouched. The `useUser` local leaves the hook (the SDK closes over the session); the import stays for `usePendingCashuReceiveSwaps`. +9. **Untouched laggard consumers** (each has its own slice): `_protected.receive.cashu_.token.tsx` + `ClaimCashuTokenService` graph (step 12), `cashu-send-swap-hooks.ts`'s `useCashuReceiveSwapService` ctor dep (step 14), `transaction-additional-details.tsx` (see decision 5), processor + change handlers + pending reads (step 18). +10. **Prune the dead swap-domain re-exports in `temporary.ts`**: `CashuSwapReceiveDbData` (type), `CashuSwapReceiveDbDataSchema`, and `CashuReceiveSwapSchema` — zero repo-wide importers (verified with grep over `apps/`). `AgicashDbCashuReceiveSwap`, `CashuReceiveSwapRepository`, and `CashuReceiveSwapService` stay (live consumers in decisions 8/9). + +## Pinned seams + +Contract (`packages/wallet-sdk/domain/sdk/receive.ts`) — `cashu` gains one method; two new types; one new type-only import (`Token` from `@cashu/cashu-ts`): + +```ts +cashu: { + // ...existing three quote methods... + createSwap( + params: CreateCashuReceiveSwapParams, + ): Promise; +}; + +export type CreateCashuReceiveSwapParams = { + /** The cashu account to receive the token into. Must match the token's mint and currency. */ + account: CashuAccount; + /** The token to receive. */ + token: Token; +}; + +export type CreateCashuReceiveSwapResult = { + /** The created receive swap. Completion is background-driven; observe `cashu-receive-swap.updated`. */ + swap: CashuReceiveSwap; + /** The receiving account with the keyset counter advanced for the swap's reserved outputs. */ + account: CashuAccount; +}; +``` + +API factory (`packages/wallet-sdk/domain/receive/receive-api.ts`) — two new deps seams and builders mirroring the quote ones, plus the method: + +```ts +const getSwapRepository = + deps.createSwapRepository ?? + (async (): Promise => { + const encryption = await deps.keys.getEncryption(); + const accountRepository = await deps.getAccountRepository(); + return new CashuReceiveSwapRepository(deps.db, encryption, accountRepository); + }); + +const getSwapService = + deps.createSwapService ?? + (async (): Promise => + new CashuReceiveSwapService(await getSwapRepository())); + +createSwap: async (params) => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const service = await getSwapService(); + if (signal.aborted) throw new SessionEndedError(); + const result = await service.create( + { userId, token: params.token, account: params.account }, + { abortSignal: signal }, + ); + if (signal.aborted) throw new SessionEndedError(); + return result; +}, +``` + +Service signature change (`packages/wallet-sdk/domain/receive/cashu-receive-swap-service.ts`): + +```ts +async create( + { userId, token, account, reversedTransactionId }: { /* unchanged */ }, + options?: { abortSignal?: AbortSignal }, +): Promise<{ swap: CashuReceiveSwap; account: CashuAccount }> +``` + +with the `receiveSwapRepository.create(...)` call gaining `options` as the second argument. Nothing else in the service changes. + +Web flip (`apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts`): add `import { sdk } from '~/features/shared/sdk.client';`; in `useCreateCashuReceiveSwap` delete the `useUser`/`useCashuReceiveSwapService` locals and replace the mutation body: + +```ts +mutationFn: ({ token, accountId }: CreateProps) => { + const account = getCashuAccount(accountId); + return sdk.receive.cashu.createSwap({ token, account }); +}, +``` + +No other hook, cache, type, or import in the file changes. + +## File map + +- Modify: `packages/wallet-sdk/domain/sdk/receive.ts` (contract method + types) +- Modify: `packages/wallet-sdk/domain/receive/receive-api.ts` (swap builders + `createSwap`) +- Modify: `packages/wallet-sdk/domain/receive/cashu-receive-swap-service.ts` (options param) +- Modify: `packages/wallet-sdk/domain/receive/receive-api.test.ts` (createSwap suite + seams) +- Modify: `apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts` (web flip) +- Modify: `packages/wallet-sdk/temporary.ts` (prune 3 dead re-exports) +- Modify: `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md` (ReceiveApi sketch gains `createSwap`) +- Untouched on purpose: `packages/wallet-sdk/index.ts`, `domain/sdk/index.ts`, `domain/sdk/sdk.ts`, `domain/sdk/events.ts`, `sdk.test.ts`, `cashu-receive-swap-repository.ts`, `cashu-receive-swap.ts`, `claim-cashu-token-service.ts`, `cashu-send-swap-service.ts`, all web `.tsx` components, `receive-cashu-token-hooks.ts`, `task-processing.ts`, `use-track-wallet-changes.ts`, `sdk.client.ts`, `transaction-additional-details.tsx`, `send/cashu-send-swap-hooks.ts`, `_protected.receive.cashu_.token.tsx`, all RPCs and migrations. + +## Test coverage (extend `receive-api.test.ts`) + +The `makeApi` harness gains `swapRepository?`/`swapService?` seam params. New `cashu.createSwap` block: + +- (a) throws `NoSessionError` without a session, before any repository work (builder call counters stay 0); +- (b) happy path passes `{ userId, token, account }` and `{ abortSignal }` to the service and returns the `{ swap, account }` result verbatim; +- (c) rejects with `SessionEndedError` when the session ends during service construction (mid-write fence) and the service is never called; +- (d) rejects with `SessionEndedError` when the session ends during the write; +- (e) threads the session signal into the service write (`options.abortSignal === keys.sessionSignal()`). + +## Verification summary + +| Gate | Command | Expectation | +|---|---|---| +| Lint/format | `bun run fix:all` | exit 0 | +| Types (all pkgs) | `bun run typecheck` | exit 0 | +| Unit tests | `bun run test` | green (existing suites + new createSwap tests) | +| Smoke | manual, browser, local stack | app boots; a live testnut token claim to a same-mint account creates the swap via `sdk.receive.cashu.createSwap` (web processor completes it via `/temporary`); transaction page shows the completed receive; no console errors | diff --git a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md index 349c2d6a5..218204364 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -143,6 +143,7 @@ type ReceiveApi = { getLightningQuote(params): Promise; createQuote(params): Promise; getQuote(id: string): Promise; + createSwap(params): Promise<{ swap: CashuReceiveSwap; account: CashuAccount }>; // same-mint token claim (step 10) }; spark: { getLightningQuote(params): Promise; diff --git a/packages/wallet-sdk/domain/receive/cashu-receive-swap-service.ts b/packages/wallet-sdk/domain/receive/cashu-receive-swap-service.ts index 472a56322..e32231794 100644 --- a/packages/wallet-sdk/domain/receive/cashu-receive-swap-service.ts +++ b/packages/wallet-sdk/domain/receive/cashu-receive-swap-service.ts @@ -13,6 +13,7 @@ import { splitAmount, } from '@cashu/cashu-ts'; import { tokenToMoney } from '../../lib/cashu'; +import { DomainError } from '../../lib/error'; import type { CashuAccount } from '../accounts/account'; import type { CashuReceiveSwap } from './cashu-receive-swap'; import type { CashuReceiveSwapRepository } from './cashu-receive-swap-repository'; @@ -27,41 +28,46 @@ export class CashuReceiveSwapService { * @returns The created receive swap and updated account. * @throws An error if creating the swap fails. */ - async create({ - userId, - token, - account, - reversedTransactionId, - }: { - /** - * The id of the user that is creating the swap. - */ - userId: string; - /** - * The token to receive. - */ - token: Token; - /** - * The account to receive the proofs into. - */ - account: CashuAccount; - /** - * The id of the transaction that this swap is reversing. - */ - reversedTransactionId?: string; - }): Promise<{ + async create( + { + userId, + token, + account, + reversedTransactionId, + }: { + /** + * The id of the user that is creating the swap. + */ + userId: string; + /** + * The token to receive. + */ + token: Token; + /** + * The account to receive the proofs into. + */ + account: CashuAccount; + /** + * The id of the transaction that this swap is reversing. + */ + reversedTransactionId?: string; + }, + options?: { + abortSignal?: AbortSignal; + }, + ): Promise<{ swap: CashuReceiveSwap; account: CashuAccount; }> { if (!areMintUrlsEqual(account.mintUrl, token.mint)) { - throw new Error('Cannot swap a token to a different mint'); + throw new DomainError('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.'); + throw new DomainError('Cannot swap a token to a different currency.'); } const wallet = account.wallet; @@ -71,7 +77,7 @@ export class CashuReceiveSwapService { const amountToReceive = sumProofs(token.proofs) - fee; if (amountToReceive <= 0) { - throw new Error('Token is too small to claim.'); + throw new DomainError('Token is too small to claim.'); } const cashuUnit = getCashuUnit(currency); @@ -88,17 +94,20 @@ export class CashuReceiveSwapService { const outputAmounts = splitAmount(amountToReceive, keyset.keys); - return await this.receiveSwapRepository.create({ - token, - userId, - accountId: account.id, - keysetId: wallet.keysetId, - inputAmount, - cashuReceiveFee: feeAmount, - receiveAmount, - outputAmounts, - reversedTransactionId, - }); + return await this.receiveSwapRepository.create( + { + token, + userId, + accountId: account.id, + keysetId: wallet.keysetId, + inputAmount, + cashuReceiveFee: feeAmount, + receiveAmount, + outputAmounts, + reversedTransactionId, + }, + options, + ); } /** diff --git a/packages/wallet-sdk/domain/receive/receive-api.test.ts b/packages/wallet-sdk/domain/receive/receive-api.test.ts index 69388f546..58c701e21 100644 --- a/packages/wallet-sdk/domain/receive/receive-api.test.ts +++ b/packages/wallet-sdk/domain/receive/receive-api.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { type Currency, Money } from '@agicash/money'; +import type { Token } from '@cashu/cashu-ts'; import type { AgicashDb } from '../../db/database'; import { BASE_CASHU_LOCKING_DERIVATION_PATH } from '../../lib/cashu'; import { derivePublicKey } from '../../lib/cryptography'; @@ -16,6 +17,9 @@ import type { CashuReceiveQuote } from './cashu-receive-quote'; import type { CashuReceiveLightningQuote } from './cashu-receive-quote-core'; import type { CashuReceiveQuoteRepository } from './cashu-receive-quote-repository'; import type { CashuReceiveQuoteService } from './cashu-receive-quote-service'; +import type { CashuReceiveSwap } from './cashu-receive-swap'; +import type { CashuReceiveSwapRepository } from './cashu-receive-swap-repository'; +import type { CashuReceiveSwapService } from './cashu-receive-swap-service'; import { createReceiveApi } from './receive-api'; const authUser = (id: string): AuthUser => @@ -72,6 +76,29 @@ const makeLightningQuote = (): CashuReceiveLightningQuote => paymentHash: 'payment-hash-1', }) as unknown as CashuReceiveLightningQuote; +const makeToken = (): Token => + ({ + mint: 'https://testnut.cashu.space', + unit: 'sat', + proofs: [ + { id: '009a1f293253e41e', amount: 64, secret: 'secret-1', C: '02def' }, + ], + memo: 'test token', + }) as unknown as Token; + +const makeSwap = (): CashuReceiveSwap => + ({ + tokenHash: 'token-hash-1', + userId: 'user-x', + accountId: 'acct-cashu', + keysetId: '009a1f293253e41e', + keysetCounter: 0, + outputAmounts: [32, 16, 8], + transactionId: 'tx-swap-1', + version: 1, + state: 'PENDING', + }) as unknown as CashuReceiveSwap; + const makeQuote = ( overrides: Partial> = {}, ): CashuReceiveQuote => @@ -98,16 +125,27 @@ const makeApi = (deps: { session: AuthSession; repository?: Partial; service?: Partial; + swapRepository?: Partial; + swapService?: Partial; }) => createReceiveApi({ db: {} as unknown as AgicashDb, keys: createSessionKeys(), getSession: () => deps.session, getAccountRepository: async () => ({}) as unknown as AccountRepository, - createRepository: async () => - (deps.repository ?? {}) as unknown as CashuReceiveQuoteRepository, - createService: async () => - (deps.service ?? {}) as unknown as CashuReceiveQuoteService, + createRepository: + deps.repository && + (async () => deps.repository as unknown as CashuReceiveQuoteRepository), + createService: + deps.service && + (async () => deps.service as unknown as CashuReceiveQuoteService), + createSwapRepository: + deps.swapRepository && + (async () => + deps.swapRepository as unknown as CashuReceiveSwapRepository), + createSwapService: + deps.swapService && + (async () => deps.swapService as unknown as CashuReceiveSwapService), }); describe('createReceiveApi', () => { @@ -333,6 +371,185 @@ describe('createReceiveApi', () => { }); }); + describe('cashu.createSwap', () => { + it('throws NoSessionError without a session, before any repository work', async () => { + // No seams injected: the default builders are live, so the counter below + // is on the path a broken fence would take. + let getAccountRepositoryCalls = 0; + const api = createReceiveApi({ + db: {} as unknown as AgicashDb, + keys: createSessionKeys(), + getSession: () => ({ isLoggedIn: false }), + getAccountRepository: async () => { + getAccountRepositoryCalls += 1; + return {} as unknown as AccountRepository; + }, + }); + + await expect( + api.cashu.createSwap({ + account: cashuDomain(), + token: makeToken(), + }), + ).rejects.toBeInstanceOf(NoSessionError); + expect(getAccountRepositoryCalls).toBe(0); + }); + + it('passes the session userId, the given token and account, and the abort signal to the service and returns the result verbatim', async () => { + let captured: Record | undefined; + let capturedOptions: { abortSignal?: AbortSignal } | undefined; + const account = cashuDomain(); + const token = makeToken(); + const swapResult = { swap: makeSwap(), account: cashuDomain() }; + const api = makeApi({ + session: loggedIn('user-x'), + swapService: { + create: (async ( + params: Record, + options?: { abortSignal?: AbortSignal }, + ) => { + captured = params; + capturedOptions = options; + return swapResult; + }) as unknown as CashuReceiveSwapService['create'], + }, + }); + + const result = await api.cashu.createSwap({ account, token }); + + expect(captured?.userId).toBe('user-x'); + expect(captured?.token).toBe(token); + expect(captured?.account).toBe(account); + expect(capturedOptions?.abortSignal).toBeDefined(); + expect(result).toBe(swapResult); + }); + + it('assembles the default swap service over the repository and computes the swap from the account wallet', async () => { + let captured: Record | undefined; + let capturedOptions: { abortSignal?: AbortSignal } | undefined; + const repoResult = { swap: makeSwap(), account: cashuDomain() }; + const keysetKeys = Object.fromEntries( + [1, 2, 4, 8, 16, 32, 64].map((amount) => [amount, '02aa']), + ); + const wallet = { + keysetId: 'keyset-1', + getKeyset: () => ({ keys: keysetKeys }), + getFeesForProofs: () => 1, + }; + const api = makeApi({ + session: loggedIn('user-x'), + swapRepository: { + create: (async ( + params: Record, + options?: { abortSignal?: AbortSignal }, + ) => { + captured = params; + capturedOptions = options; + return repoResult; + }) as unknown as CashuReceiveSwapRepository['create'], + }, + }); + + const result = await api.cashu.createSwap({ + account: cashuDomain({ wallet }), + token: makeToken(), + }); + + expect(captured?.userId).toBe('user-x'); + expect(captured?.accountId).toBe('acct-cashu'); + expect(captured?.keysetId).toBe('keyset-1'); + expect((captured?.inputAmount as Money).toNumber('sat')).toBe(64); + expect((captured?.cashuReceiveFee as Money).toNumber('sat')).toBe(1); + expect((captured?.receiveAmount as Money).toNumber('sat')).toBe(63); + expect( + [...(captured?.outputAmounts as number[])].sort((a, b) => a - b), + ).toEqual([1, 2, 4, 8, 16, 32]); + expect(capturedOptions?.abortSignal).toBeDefined(); + expect(result).toBe(repoResult); + }); + + it('rejects with SessionEndedError and never calls the service when the session ends during service construction', async () => { + const keys = createSessionKeys(); + let createCalls = 0; + const api = createReceiveApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + getAccountRepository: async () => ({}) as unknown as AccountRepository, + createSwapService: async () => { + // The session ends between the signal capture and the write. + keys.reset(); + return { + create: (async () => { + createCalls += 1; + return { swap: makeSwap(), account: cashuDomain() }; + }) as unknown as CashuReceiveSwapService['create'], + } as unknown as CashuReceiveSwapService; + }, + }); + + await expect( + api.cashu.createSwap({ + account: cashuDomain(), + token: makeToken(), + }), + ).rejects.toBeInstanceOf(SessionEndedError); + expect(createCalls).toBe(0); + }); + + it('rejects with SessionEndedError when the session ends during the write', async () => { + const keys = createSessionKeys(); + const api = createReceiveApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + getAccountRepository: async () => ({}) as unknown as AccountRepository, + createSwapService: async () => + ({ + create: (async () => { + keys.reset(); + return { swap: makeSwap(), account: cashuDomain() }; + }) as unknown as CashuReceiveSwapService['create'], + }) as unknown as CashuReceiveSwapService, + }); + + await expect( + api.cashu.createSwap({ + account: cashuDomain(), + token: makeToken(), + }), + ).rejects.toBeInstanceOf(SessionEndedError); + }); + + it('threads the session signal into the service write', async () => { + const keys = createSessionKeys(); + let writeSignal: AbortSignal | undefined; + const api = createReceiveApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + getAccountRepository: async () => ({}) as unknown as AccountRepository, + createSwapService: async () => + ({ + create: (async ( + _params: unknown, + options?: { abortSignal?: AbortSignal }, + ) => { + writeSignal = options?.abortSignal; + return { swap: makeSwap(), account: cashuDomain() }; + }) as unknown as CashuReceiveSwapService['create'], + }) as unknown as CashuReceiveSwapService, + }); + + await api.cashu.createSwap({ + account: cashuDomain(), + token: makeToken(), + }); + + expect(writeSignal).toBe(keys.sessionSignal()); + }); + }); + describe('session fences', () => { it('rejects getLightningQuote with SessionEndedError and never calls the mint when the session ends during service construction', async () => { const keys = createSessionKeys(); diff --git a/packages/wallet-sdk/domain/receive/receive-api.ts b/packages/wallet-sdk/domain/receive/receive-api.ts index ab02a37a4..e32a32e97 100644 --- a/packages/wallet-sdk/domain/receive/receive-api.ts +++ b/packages/wallet-sdk/domain/receive/receive-api.ts @@ -11,17 +11,23 @@ import type { AuthSession, ReceiveApi } from '../sdk'; import type { SessionKeys } from '../sdk/session-keys'; import { CashuReceiveQuoteRepository } from './cashu-receive-quote-repository'; import { CashuReceiveQuoteService } from './cashu-receive-quote-service'; +import { CashuReceiveSwapRepository } from './cashu-receive-swap-repository'; +import { CashuReceiveSwapService } from './cashu-receive-swap-service'; type Deps = { db: AgicashDb; getSession: () => AuthSession; keys: SessionKeys; - /** Accounts bridge; feeds the quote repository (sdk.ts wires accounts.getRepository). */ + /** Accounts bridge (sdk.ts wires accounts.getRepository). */ getAccountRepository: () => Promise; /** Test seam; defaults to building the repository from db + session keys + account repository. */ createRepository?: () => Promise; /** Test seam; defaults to building the service from session-keys cryptography + the repository. */ createService?: () => Promise; + /** Test seam; defaults to building the repository from db + session keys + account repository. */ + createSwapRepository?: () => Promise; + /** Test seam; defaults to building the service from the swap repository. */ + createSwapService?: () => Promise; }; /** Creates the `receive` SDK namespace. */ @@ -58,6 +64,23 @@ export function createReceiveApi(deps: Deps): ReceiveApi { (async (): Promise => new CashuReceiveQuoteService(cryptography, await getRepository())); + const getSwapRepository = + deps.createSwapRepository ?? + (async (): Promise => { + const encryption = await deps.keys.getEncryption(); + const accountRepository = await deps.getAccountRepository(); + return new CashuReceiveSwapRepository( + deps.db, + encryption, + accountRepository, + ); + }); + + const getSwapService = + deps.createSwapService ?? + (async (): Promise => + new CashuReceiveSwapService(await getSwapRepository())); + return { cashu: { getLightningQuote: async (params) => { @@ -99,6 +122,24 @@ export function createReceiveApi(deps: Deps): ReceiveApi { if (signal.aborted) throw new SessionEndedError(); return quote; }, + // reversedTransactionId is intentionally absent from the public params: + // reversal is internal orchestration, not host surface. + createSwap: async (params) => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const service = await getSwapService(); + if (signal.aborted) throw new SessionEndedError(); + const result = await service.create( + { + userId, + token: params.token, + account: params.account, + }, + { abortSignal: signal }, + ); + if (signal.aborted) throw new SessionEndedError(); + return result; + }, }, get spark(): ReceiveApi['spark'] { throw new NotImplementedError('receive.spark'); diff --git a/packages/wallet-sdk/domain/sdk/receive.ts b/packages/wallet-sdk/domain/sdk/receive.ts index 65438d3b6..2278ecfb3 100644 --- a/packages/wallet-sdk/domain/sdk/receive.ts +++ b/packages/wallet-sdk/domain/sdk/receive.ts @@ -1,7 +1,9 @@ import type { Money } from '@agicash/money'; +import type { Token } from '@cashu/cashu-ts'; import type { CashuAccount } from '../accounts/account'; import type { CashuReceiveQuote } from '../receive/cashu-receive-quote'; import type { CashuReceiveLightningQuote } from '../receive/cashu-receive-quote-core'; +import type { CashuReceiveSwap } from '../receive/cashu-receive-swap'; import type { SparkReceiveQuote } from '../receive/spark-receive-quote'; import type { SparkReceiveLightningQuote } from '../receive/spark-receive-quote-core'; import type { TransactionPurpose } from '../transactions/transaction-enums'; @@ -9,8 +11,7 @@ import type { TransactionPurpose } from '../transactions/transaction-enums'; // The public receive types are the domain entities for now: only the apps // consume the SDK and they just read these shapes, so the extra domain fields // (e.g. proofs) ride along until a later slice narrows the surface (#1164). -export type { CashuReceiveSwap } from '../receive/cashu-receive-swap'; -export type { CashuReceiveQuote, SparkReceiveQuote }; +export type { CashuReceiveSwap, CashuReceiveQuote, SparkReceiveQuote }; /** * `get*` methods are stateless previews; `create*` methods persist and enter @@ -26,6 +27,15 @@ export type ReceiveApi = { params: CreateCashuReceiveQuoteParams, ): Promise; getQuote(id: string): Promise; + /** + * Claims a token into a same-mint account by creating a receive swap. + * @throws {DomainError} When the token does not match the account's mint + * or currency, or is too small to cover the mint fee. + * @throws {UniqueConstraintError} When a swap for this token already exists. + */ + createSwap( + params: CreateCashuReceiveSwapParams, + ): Promise; }; spark: { getLightningQuote( @@ -63,6 +73,20 @@ export type CreateCashuReceiveQuoteParams = { /** UUID linking paired send/receive transactions in a transfer. */ transferId?: string; }; +export type CreateCashuReceiveSwapParams = { + /** The cashu account to receive the token into. Must match the token's mint and currency. */ + account: CashuAccount; + /** The token to receive. */ + token: Token; +}; + +export type CreatedCashuReceiveSwap = { + /** The created receive swap. Completion is background-driven, never called by the host. */ + swap: CashuReceiveSwap; + /** The receiving account with the keyset counter advanced for the swap's reserved outputs. */ + account: CashuAccount; +}; + export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index a5003fb29..3894235a1 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -21,7 +21,6 @@ export type { AccountDetailsDbData } from './db/json-models/account-details-db-d export type { CashuAccountDetailsDbData } from './db/json-models/cashu-account-details-db-data'; export type { CashuLightningReceiveDbData } from './db/json-models/cashu-lightning-receive-db-data'; export type { CashuLightningSendDbData } from './db/json-models/cashu-lightning-send-db-data'; -export type { CashuSwapReceiveDbData } from './db/json-models/cashu-swap-receive-db-data'; export type { CashuSwapSendDbData } from './db/json-models/cashu-swap-send-db-data'; export type { CashuTokenMeltDbData } from './db/json-models/cashu-token-melt-db-data'; export type { SparkAccountDetailsDbData } from './db/json-models/spark-account-details-db-data'; @@ -74,7 +73,6 @@ export { AccountDetailsDbDataSchema } from './db/json-models/account-details-db- export { CashuAccountDetailsDbDataSchema } from './db/json-models/cashu-account-details-db-data'; export { CashuLightningReceiveDbDataSchema } from './db/json-models/cashu-lightning-receive-db-data'; export { CashuLightningSendDbDataSchema } from './db/json-models/cashu-lightning-send-db-data'; -export { CashuSwapReceiveDbDataSchema } from './db/json-models/cashu-swap-receive-db-data'; export { CashuSwapSendDbDataSchema } from './db/json-models/cashu-swap-send-db-data'; export { CashuTokenMeltDbDataSchema } from './db/json-models/cashu-token-melt-db-data'; export { SparkAccountDetailsDbDataSchema } from './db/json-models/spark-account-details-db-data'; @@ -120,7 +118,6 @@ export { SparkReceiveQuoteRepository } from './domain/receive/spark-receive-quot export { SparkReceiveQuoteService } from './domain/receive/spark-receive-quote-service'; export { CashuReceiveQuoteRepository } from './domain/receive/cashu-receive-quote-repository'; export { CashuReceiveQuoteService } from './domain/receive/cashu-receive-quote-service'; -export { CashuReceiveSwapSchema } from './domain/receive/cashu-receive-swap'; export { CashuReceiveSwapRepository } from './domain/receive/cashu-receive-swap-repository'; export { CashuReceiveSwapService } from './domain/receive/cashu-receive-swap-service'; export { isClaimingToSameCashuAccount } from './domain/receive/receive-cashu-token-models';