diff --git a/.changeset/many-units-restore.md b/.changeset/many-units-restore.md new file mode 100644 index 00000000..a4697786 --- /dev/null +++ b/.changeset/many-units-restore.md @@ -0,0 +1,18 @@ +--- +'@cashu/coco-core': minor +'@cashu/coco-react': minor +'@cashu/coco-indexeddb': minor +'@cashu/coco-expo-sqlite': minor +'@cashu/coco-sqlite': minor +'@cashu/coco-sqlite-bun': minor +'@cashu/coco-adapter-tests': minor +--- + +Add first-class custom Cashu unit support across core APIs, React balance hooks, +operation recovery, and storage adapters. + +Bare amount inputs continue to default to sats, while object-form amount inputs +carry an explicit unit. Proofs, balances, quotes, operations, history, tokens, +restore/sweep flows, and adapter persistence now preserve normalized unit +metadata, with migrations and contract tests covering legacy sat fallback and +custom-unit rows. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 009f51d6..4e9d562c 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -7,6 +7,7 @@ import { type MeltOperation, type MintOperation, type ReceiveOperation, + type SendOperation, type AuthSession, } from '@cashu/coco-core'; @@ -192,23 +193,81 @@ export function createDummyProof(overrides?: Partial): CoreProof { secret: 'secret', C: 'C', mintUrl: 'https://mint.test', + unit: 'sat', state: 'ready', ...overrides, } satisfies CoreProof; } -export function createDummyMeltOperation(): MeltOperation { +type InitMeltOperation = Extract; + +export function createDummyMeltOperation( + overrides?: Partial, +): InitMeltOperation { return { id: 'melt-op', state: 'init', mintUrl: 'https://mint.test', + unit: 'sat', method: 'bolt11', methodData: { invoice: 'lnbc1test', amountSats: Amount.from(1) }, createdAt: 0, updatedAt: 0, + ...overrides, } satisfies MeltOperation; } +type PreparedSendOperation = Extract; + +function createDummyPreparedSendOperation( + overrides?: Partial, +): PreparedSendOperation { + return { + id: 'send-op', + state: 'prepared', + mintUrl: 'https://mint.test', + amount: Amount.from(3), + unit: 'sat', + method: 'default', + methodData: {}, + createdAt: 0, + updatedAt: 0, + needsSwap: false, + fee: Amount.zero(), + inputAmount: Amount.from(3), + inputProofSecrets: ['send-secret-1'], + ...overrides, + } satisfies PreparedSendOperation; +} + +function createDummySendOperationsByState(unit: string): SendOperation[] { + const prepared = createDummyPreparedSendOperation({ id: 'send-prepared', unit }); + const token = { + mint: prepared.mintUrl, + unit, + proofs: [{ id: 'keyset-id', amount: Amount.from(3), secret: 'token-secret', C: 'C-token' }], + }; + return [ + { + id: 'send-init', + state: 'init', + mintUrl: prepared.mintUrl, + amount: prepared.amount, + unit, + method: 'default', + methodData: {}, + createdAt: 0, + updatedAt: 0, + }, + prepared, + { ...prepared, id: 'send-executing', state: 'executing' }, + { ...prepared, id: 'send-pending', state: 'pending', token }, + { ...prepared, id: 'send-finalized', state: 'finalized', token }, + { ...prepared, id: 'send-rolling-back', state: 'rolling_back', token }, + { ...prepared, id: 'send-rolled-back', state: 'rolled_back', token }, + ] satisfies SendOperation[]; +} + type PendingMintOperation = Extract; export function createDummyMintOperation( @@ -337,6 +396,68 @@ export async function runReceiveOperationRepositoryContract( }); } +export async function runSendOperationRepositoryContract( + options: ContractOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('SendOperationRepository contract', () => { + it('round-trips custom-unit send operations in every state', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operations = createDummySendOperationsByState('usd'); + for (const operation of operations) { + await repositories.sendOperationRepository.create(operation); + } + + for (const operation of operations) { + const stored = await repositories.sendOperationRepository.getById(operation.id); + expect(stored).toBeDefined(); + expect(stored!.unit).toBe('usd'); + } + + const pending = await repositories.sendOperationRepository.getByState('pending'); + expect(pending).toHaveLength(1); + expect(pending[0]!.unit).toBe('usd'); + + const inFlight = await repositories.sendOperationRepository.getPending(); + expect(inFlight).toHaveLength(3); + for (const operation of inFlight) { + expect(operation.unit).toBe('usd'); + } + } finally { + await dispose(); + } + }); + }); +} + +export async function runMeltOperationRepositoryContract( + options: ContractOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('MeltOperationRepository contract', () => { + it('round-trips custom-unit init melt operations', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMeltOperation({ unit: 'usd' }); + await repositories.meltOperationRepository.create(operation); + + const stored = await repositories.meltOperationRepository.getById(operation.id); + + expect(stored).toBeDefined(); + expect(stored!.state).toBe('init'); + expect(stored!.unit).toBe('usd'); + } finally { + await dispose(); + } + }); + }); +} + export async function runAuthSessionRepositoryContract( options: ContractOptions, runner: ContractRunner, @@ -558,6 +679,50 @@ export async function runProofRepositoryContract( await dispose(); } }); + + it('round-trips proof units and filters ready proofs by unit', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await repositories.proofRepository.saveProofs('https://mint.test', [ + createDummyProof({ secret: 'sat-secret', C: 'C-sat', unit: 'sat' }), + createDummyProof({ secret: 'usd-secret', C: 'C-usd', unit: 'USD' }), + ]); + + const satProofs = await repositories.proofRepository.getReadyProofs('https://mint.test', { + unit: 'sat', + }); + const usdProofs = await repositories.proofRepository.getAvailableProofs( + 'https://mint.test', + { unit: 'usd' }, + ); + const allUsd = await repositories.proofRepository.getAllReadyProofs({ units: ['usd'] }); + + expect(satProofs).toHaveLength(1); + expect(usdProofs).toHaveLength(1); + expect(allUsd).toHaveLength(1); + expect(usdProofs[0]?.unit).toBe('usd'); + } finally { + await dispose(); + } + }); + + it('rejects proofs without a unit', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const proof = createDummyProof({ secret: 'missing-unit' }) as unknown as Omit< + CoreProof, + 'unit' + >; + delete (proof as { unit?: string }).unit; + + await expectThrows( + () => repositories.proofRepository.saveProofs('https://mint.test', [proof as CoreProof]), + expect, + ); + } finally { + await dispose(); + } + }); }); } diff --git a/packages/adapter-tests/src/integration.ts b/packages/adapter-tests/src/integration.ts index 1a492d5d..0f254d74 100644 --- a/packages/adapter-tests/src/integration.ts +++ b/packages/adapter-tests/src/integration.ts @@ -1,5 +1,5 @@ import type { Repositories, Manager, Logger } from '@cashu/coco-core'; -import { initializeCoco, getEncodedToken, ConsoleLogger } from '@cashu/coco-core'; +import { initializeCoco, getEncodedToken, ConsoleLogger, normalizeUnit } from '@cashu/coco-core'; import { HttpResponseError, JSONInt, @@ -57,6 +57,7 @@ export type IntegrationTestOptions; }>; mintUrl: string; + customUnit?: string; logger?: Logger; suiteName?: string; }; @@ -254,13 +255,21 @@ function waitForMeltHistoryState( }); } -async function getMintTotalBalance(manager: Manager, mintUrl: string): Promise { - const balances = await manager.wallet.balances.byMint({ mintUrls: [mintUrl] }); +async function getMintTotalBalance( + manager: Manager, + mintUrl: string, + unit = 'sat', +): Promise { + const balances = await manager.wallet.balances.byMint({ mintUrls: [mintUrl], units: [unit] }); return balances[mintUrl]?.total.toNumber() ?? 0; } -async function getMintSpendableBalance(manager: Manager, mintUrl: string): Promise { - const balances = await manager.wallet.balances.byMint({ mintUrls: [mintUrl] }); +async function getMintSpendableBalance( + manager: Manager, + mintUrl: string, + unit = 'sat', +): Promise { + const balances = await manager.wallet.balances.byMint({ mintUrls: [mintUrl], units: [unit] }); return balances[mintUrl]?.spendable.toNumber() ?? 0; } @@ -268,7 +277,7 @@ async function prepareMintOperation( manager: Manager, mintUrl: string, amount: number, - unit: 'sat' = 'sat', + unit = 'sat', ) { return manager.ops.mint.prepare({ mintUrl, @@ -359,7 +368,7 @@ async function awaitMintQuotePaidWithSubscription( return (await getLatestPendingMintOperation(manager, pendingMint.id)) ?? pendingMint; } -async function mintAmount(manager: Manager, mintUrl: string, amount: number, unit: 'sat' = 'sat') { +async function mintAmount(manager: Manager, mintUrl: string, amount: number, unit = 'sat') { const pendingMint = await prepareMintOperation(manager, mintUrl, amount, unit); await awaitMintQuotePaidWithSubscription(manager, mintUrl, pendingMint); await executeMintOperation(manager, pendingMint.id); @@ -372,6 +381,7 @@ export async function runIntegrationTests { const { describe, it, beforeEach, afterEach, expect } = runner; const { createRepositories, mintUrl, logger, suiteName = 'Integration Tests' } = options; + const customUnit = options.customUnit ? normalizeUnit(options.customUnit) : undefined; describe(suiteName, () => { let mgr: Manager | undefined; @@ -584,6 +594,119 @@ export async function runIntegrationTests { + let repositoriesDispose: (() => Promise) | undefined; + let repositories: Repositories | undefined; + + beforeEach(async () => { + const created = await createRepositories(); + repositories = created.repositories; + repositoriesDispose = created.dispose; + mgr = await initializeCoco({ + repo: created.repositories, + seedGetter, + logger, + }); + + await mgr.mint.addMint(mintUrl, { trusted: true }); + }); + + afterEach(async () => { + if (repositoriesDispose) { + await repositoriesDispose(); + repositoriesDispose = undefined; + } + repositories = undefined; + }); + + it('should mint, send, and receive custom-unit tokens without falling back to sat', async () => { + await mintAmount(mgr!, mintUrl, 200, customUnit); + + const customBalance = await getMintSpendableBalance(mgr!, mintUrl, customUnit); + expect(customBalance).toBeGreaterThanOrEqual(200); + + const satBalance = await getMintSpendableBalance(mgr!, mintUrl, 'sat'); + expect(satBalance).toBe(0); + + const preparedSend = await mgr!.ops.send.prepare({ + mintUrl, + amount: { amount: 50, unit: customUnit }, + }); + expect(preparedSend.unit).toBe(customUnit); + + const { operation, token } = await mgr!.ops.send.execute(preparedSend.id); + expect(operation.unit).toBe(customUnit); + expect(token.unit).toBe(customUnit); + + const spendableAfterSend = await getMintSpendableBalance(mgr!, mintUrl, customUnit); + expect(spendableAfterSend).toBeLessThan(customBalance); + + const preparedReceive = await mgr!.ops.receive.prepare({ token }); + expect(preparedReceive.unit).toBe(customUnit); + + const finalizedReceive = await mgr!.ops.receive.execute(preparedReceive.id); + expect(finalizedReceive.unit).toBe(customUnit); + + const spendableAfterReceive = await getMintSpendableBalance(mgr!, mintUrl, customUnit); + expect(spendableAfterReceive).toBeGreaterThan(spendableAfterSend); + }); + + it('should not cross-fund custom-unit sends with sat proofs', async () => { + await mintAmount(mgr!, mintUrl, 100, 'sat'); + await mintAmount(mgr!, mintUrl, 10, customUnit); + + await expect( + mgr!.ops.send.prepare({ + mintUrl, + amount: { amount: 50, unit: customUnit }, + }), + ).rejects.toThrow(); + }); + + it('should prepare custom-unit melt operations and persist the unit', async () => { + await mintAmount(mgr!, mintUrl, 200, customUnit); + + const invoice = createFakeInvoice(20); + const prepared = await mgr!.ops.melt.prepare({ + mintUrl, + method: 'bolt11', + methodData: { invoice }, + unit: customUnit, + }); + + expect(prepared.unit).toBe(customUnit); + expect(prepared.quoteId).toBeDefined(); + + const stored = await repositories!.meltOperationRepository.getById(prepared.id); + expect(stored?.unit).toBe(customUnit); + }); + + it('should preserve custom-unit proofs and operations across manager restart', async () => { + await mintAmount(mgr!, mintUrl, 200, customUnit); + + const preparedSend = await mgr!.ops.send.prepare({ + mintUrl, + amount: { amount: 25, unit: customUnit }, + }); + await mgr!.pauseSubscriptions(); + await mgr!.dispose(); + + mgr = await initializeCoco({ + repo: repositories!, + seedGetter, + logger, + }); + + const restoredSend = await mgr.ops.send.get(preparedSend.id); + expect(restoredSend?.unit).toBe(customUnit); + + const balanceAfterRestart = await getMintSpendableBalance(mgr, mintUrl, customUnit); + expect(balanceAfterRestart).toBeGreaterThan(0); + }); + }); + } + describe('Wallet Operations', () => { let repositoriesDispose: (() => Promise) | undefined; diff --git a/packages/adapter-tests/src/migrations.ts b/packages/adapter-tests/src/migrations.ts index 9216b4cb..e1ec5d85 100644 --- a/packages/adapter-tests/src/migrations.ts +++ b/packages/adapter-tests/src/migrations.ts @@ -189,6 +189,7 @@ export function runMigrationTests` -- `balances.byMint(scope?: { mintUrls?: string[]; trustedOnly?: boolean }): Promise` -- `balances.total(scope?: { mintUrls?: string[]; trustedOnly?: boolean }): Promise` -- `restore(mintUrl: string): Promise` -- `sweep(mintUrl: string, bip39seed: Uint8Array): Promise` +- `balances.byMint(scope?: { mintUrls?: string[]; units?: string[]; trustedOnly?: boolean }): Promise` +- `balances.byMintAndUnit(scope?: { mintUrls?: string[]; units?: string[]; trustedOnly?: boolean }): Promise` +- `balances.byUnit(scope?: { mintUrls?: string[]; units?: string[]; trustedOnly?: boolean }): Promise` +- `balances.total(scope?: { mintUrls?: string[]; units?: string[]; trustedOnly?: boolean }): Promise` +- `balances.totalByUnit(scope?: { mintUrls?: string[]; units?: string[]; trustedOnly?: boolean }): Promise` +- `restore(mintUrl: string, options?: { units?: string[] }): Promise` +- `sweep(mintUrl: string, bip39seed: Uint8Array, options?: { units?: string[] }): Promise` - `decodeToken(tokenString: string, mintUrl?: string): Promise` - `encodeToken(token: Token, opts?: { removeDleq?: boolean }): string` - `encodePaymentRequest(paymentRequest: PaymentRequest, version?: 'creqA' | 'creqB'): string` diff --git a/packages/core/amounts.ts b/packages/core/amounts.ts new file mode 100644 index 00000000..12970b75 --- /dev/null +++ b/packages/core/amounts.ts @@ -0,0 +1,77 @@ +import { Amount, type AmountLike } from '@cashu/cashu-ts'; + +import { UnitMismatchError, UnitValidationError } from './models/Error.ts'; + +export const DEFAULT_UNIT = 'sat'; + +export interface UnitAmount { + amount: Amount; + unit: string; +} + +export type UnitAmountLike = + | AmountLike + | { + amount: AmountLike; + unit: string; + }; + +export function isUnitAmountLikeObject( + input: UnitAmountLike, +): input is { amount: AmountLike; unit: string } { + return typeof input === 'object' && input !== null && 'amount' in input && 'unit' in input; +} + +export function normalizeUnit(unit?: string, options?: { defaultUnit?: string }): string { + const rawUnit = unit === undefined ? options?.defaultUnit : unit; + if (typeof rawUnit !== 'string') { + throw new UnitValidationError('Unit is required'); + } + + const normalized = rawUnit.trim().toLowerCase(); + if (!normalized) { + throw new UnitValidationError('Unit cannot be empty'); + } + + return normalized; +} + +export function normalizeUnitList(units?: readonly string[]): string[] | undefined { + if (units === undefined) return undefined; + return Array.from(new Set(units.map((unit) => normalizeUnit(unit)))); +} + +export function assertSameUnit(actual: string, expected: string, context?: string): void { + const normalizedActual = normalizeUnit(actual); + const normalizedExpected = normalizeUnit(expected); + if (normalizedActual !== normalizedExpected) { + const prefix = context ? `${context}: ` : ''; + throw new UnitMismatchError( + `${prefix}Unit mismatch: expected ${normalizedExpected}, received ${normalizedActual}`, + ); + } +} + +export function parseUnitAmount( + input: UnitAmountLike, + options?: { + defaultUnit?: string; + explicitUnit?: string; + }, +): UnitAmount { + const isObjectInput = isUnitAmountLikeObject(input); + const amountInput = isObjectInput ? input.amount : input; + const unitInput = isObjectInput + ? input.unit + : (options?.explicitUnit ?? options?.defaultUnit ?? DEFAULT_UNIT); + const unit = normalizeUnit(unitInput); + + if (options?.explicitUnit !== undefined) { + assertSameUnit(unit, options.explicitUnit, 'Amount input'); + } + + return { + amount: Amount.from(amountInput), + unit, + }; +} diff --git a/packages/core/api/MeltOpsApi.ts b/packages/core/api/MeltOpsApi.ts index bb41823a..387e1a15 100644 --- a/packages/core/api/MeltOpsApi.ts +++ b/packages/core/api/MeltOpsApi.ts @@ -17,6 +17,8 @@ export type PrepareMeltInput; + /** Unit to melt. Defaults to `sat`. */ + unit?: string; }; }[TSupported]; @@ -60,11 +62,15 @@ export class MeltOpsApi): Promise { - const initOperation = await this.meltOperationService.init( - input.mintUrl, - input.method, - input.methodData, - ); + const initOperation = + input.unit === undefined + ? await this.meltOperationService.init(input.mintUrl, input.method, input.methodData) + : await this.meltOperationService.init( + input.mintUrl, + input.method, + input.methodData, + input.unit, + ); return this.meltOperationService.prepare(initOperation.id); } diff --git a/packages/core/api/MintOpsApi.ts b/packages/core/api/MintOpsApi.ts index 619d4b4a..6ad123f0 100644 --- a/packages/core/api/MintOpsApi.ts +++ b/packages/core/api/MintOpsApi.ts @@ -1,4 +1,3 @@ -import type { AmountLike } from '@cashu/cashu-ts'; import type { MintMethod, MintMethodData, @@ -9,6 +8,7 @@ import type { PendingMintOperation, TerminalMintOperation, } from '@core/operations/mint'; +import { parseUnitAmount, type UnitAmountLike } from '../amounts.ts'; /** Mint methods supported by the default `Manager` wiring. */ export type DefaultSupportedMintMethod = 'bolt11'; @@ -16,10 +16,10 @@ export type DefaultSupportedMintMethod = 'bolt11'; type PrepareMintInputCommon = { /** Mint that will execute the quote-backed mint operation. */ mintUrl: string; - /** Amount to request from the mint. */ - amount: AmountLike; - /** Unit to request from the mint. Only `sat` is currently supported. */ - unit?: 'sat'; + /** Amount to request from the mint. Bare amounts use `sat` unless `unit` is set. */ + amount: UnitAmountLike; + /** Unit to request from the mint. */ + unit?: string; }; type ImportMintQuoteInputCommon = { @@ -86,24 +86,17 @@ export class MintOpsApi): Promise { - const unit = input.unit ?? 'sat'; - this.assertSupportedUnit(unit); + const parsed = parseUnitAmount(input.amount, { explicitUnit: input.unit }); const methodData = ('methodData' in input ? input.methodData : undefined) ?? {}; return this.mintOperationService.prepareNewQuote( input.mintUrl, - input.amount, - unit, + parsed, + parsed.unit, input.method, methodData, ); @@ -113,7 +106,6 @@ export class MintOpsApi): Promise { - this.assertSupportedUnit(input.quote.unit); const methodData = ('methodData' in input ? input.methodData : undefined) ?? {}; return this.mintOperationService.importQuote( diff --git a/packages/core/api/PaymentRequestsApi.ts b/packages/core/api/PaymentRequestsApi.ts index 289c1174..05e2f538 100644 --- a/packages/core/api/PaymentRequestsApi.ts +++ b/packages/core/api/PaymentRequestsApi.ts @@ -1,10 +1,10 @@ -import type { AmountLike } from '@cashu/cashu-ts'; import type { PaymentRequestExecutionResult, PaymentRequestService, PreparedPaymentRequest, ResolvedPaymentRequest, } from '@core/services'; +import type { UnitAmountLike } from '../amounts.ts'; /** * API for parsing, preparing, and executing payment requests. @@ -28,7 +28,7 @@ export class PaymentRequestsApi { */ async prepare( request: ResolvedPaymentRequest, - options: { mintUrl: string; amount?: AmountLike }, + options: { mintUrl: string; amount?: UnitAmountLike }, ): Promise { return this.paymentRequestService.prepare(request, options); } diff --git a/packages/core/api/SendOpsApi.ts b/packages/core/api/SendOpsApi.ts index 1dc19230..95b84a12 100644 --- a/packages/core/api/SendOpsApi.ts +++ b/packages/core/api/SendOpsApi.ts @@ -1,4 +1,4 @@ -import type { AmountLike, Token } from '@cashu/cashu-ts'; +import type { Token } from '@cashu/cashu-ts'; import type { CreateSendOperationOptions, PendingSendOperation, @@ -7,6 +7,7 @@ import type { } from '../operations/send/SendOperation'; import type { SendMethod, SendMethodData } from '../operations/send/SendMethodHandler'; import type { SendOperationService } from '../operations/send/SendOperationService'; +import { parseUnitAmount, type UnitAmountLike } from '../amounts.ts'; type NonDefaultSendMethod = Exclude; @@ -17,8 +18,10 @@ export type SendTarget = { export interface PrepareSendInput { /** Mint to send from. */ mintUrl: string; - /** Amount to send. */ - amount: AmountLike; + /** Amount to send. Bare amounts use `sat` unless `unit` is set. */ + amount: UnitAmountLike; + /** Unit to send. */ + unit?: string; /** Optional non-default send target, for example a P2PK recipient. */ target?: SendTarget; } @@ -65,9 +68,10 @@ export class SendOpsApi { * before producing the outgoing token. */ async prepare(input: PrepareSendInput): Promise { + const parsed = parseUnitAmount(input.amount, { explicitUnit: input.unit }); const initOp = await this.sendOperationService.init( input.mintUrl, - input.amount, + parsed, this.getCreateOptions(input.target), ); return this.sendOperationService.prepare(initOp); diff --git a/packages/core/api/WalletApi.ts b/packages/core/api/WalletApi.ts index 2a063b17..b7967969 100644 --- a/packages/core/api/WalletApi.ts +++ b/packages/core/api/WalletApi.ts @@ -14,6 +14,23 @@ import type { import type { ReceiveOperationService } from '../operations/receive/ReceiveOperationService'; import type { Logger } from '../logging/Logger.ts'; import { WalletBalancesApi } from './WalletBalancesApi.ts'; +import { DEFAULT_UNIT, normalizeUnit, normalizeUnitList } from '../amounts.ts'; + +export interface WalletRestoreOptions { + /** + * Optional unit filter. Units are normalized to lowercase. + * Omit this to restore every keyset unit known by the mint. + */ + units?: string[]; +} + +export interface WalletSweepOptions { + /** + * Optional unit filter. Units are normalized to lowercase. + * Omit this to sweep every keyset unit known by the mint. + */ + units?: string[]; +} export class WalletApi { private mintService: MintService; @@ -56,20 +73,26 @@ export class WalletApi { // Restoration logic is delegated to WalletRestoreService - async restore(mintUrl: string) { + async restore(mintUrl: string, options?: WalletRestoreOptions) { this.logger?.info('Starting restore', { mintUrl }); const mint = await this.mintService.addMintByUrl(mintUrl, { trusted: true }); this.logger?.debug('Mint fetched for restore', { mintUrl, keysetCount: mint.keysets.length, }); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const unitFilter = this.getUnitFilter(options?.units); const failedKeysetIds: { [keysetId: string]: Error } = {}; - for (const keyset of mint.keysets) { + for (const { keyset, unit } of this.getUnitScopedKeysets(mint.keysets, unitFilter)) { try { - await this.walletRestoreService.restoreKeyset(mintUrl, wallet, keyset.id); + const wallet = await this.walletService.getWallet(mintUrl, unit); + await this.walletRestoreService.restoreKeyset(mintUrl, wallet, keyset.id, unit); } catch (error) { - this.logger?.error('Keyset restore failed', { mintUrl, keysetId: keyset.id, error }); + this.logger?.error('Keyset restore failed', { + mintUrl, + keysetId: keyset.id, + unit, + error, + }); failedKeysetIds[keyset.id] = error as Error; } } @@ -88,19 +111,20 @@ export class WalletApi { * @param mintUrl - The URL of the mint to sweep * @param bip39seed - The BIP39 seed of the wallet to sweep */ - async sweep(mintUrl: string, bip39seed: Uint8Array) { + async sweep(mintUrl: string, bip39seed: Uint8Array, options?: WalletSweepOptions) { this.logger?.info('Starting sweep', { mintUrl }); const mint = await this.mintService.addMintByUrl(mintUrl, { trusted: true }); this.logger?.debug('Mint fetched for sweep', { mintUrl, keysetCount: mint.keysets.length, }); + const unitFilter = this.getUnitFilter(options?.units); const failedKeysetIds: { [keysetId: string]: Error } = {}; - for (const keyset of mint.keysets) { + for (const { keyset, unit } of this.getUnitScopedKeysets(mint.keysets, unitFilter)) { try { - await this.walletRestoreService.sweepKeyset(mintUrl, keyset.id, bip39seed); + await this.walletRestoreService.sweepKeyset(mintUrl, keyset.id, bip39seed, unit); } catch (error) { - this.logger?.error('Keyset restore failed', { mintUrl, keysetId: keyset.id, error }); + this.logger?.error('Keyset restore failed', { mintUrl, keysetId: keyset.id, unit, error }); failedKeysetIds[keyset.id] = error as Error; } } @@ -131,8 +155,7 @@ export class WalletApi { } const metadata = getTokenMetadata(tokenString); - const wallet = await this.walletService.getWallet(metadata.mint); - return wallet.decodeToken(tokenString); + return this.tokenService.decodeToken(tokenString, metadata.mint); } /** @@ -157,4 +180,21 @@ export class WalletApi { } return paymentRequest.toEncodedCreqA(); } + + private getUnitFilter(units?: string[]): Set | undefined { + const normalizedUnits = normalizeUnitList(units); + return normalizedUnits ? new Set(normalizedUnits) : undefined; + } + + private getUnitScopedKeysets( + keysets: T[], + unitFilter?: Set, + ): Array<{ keyset: T; unit: string }> { + return keysets + .map((keyset) => ({ + keyset, + unit: normalizeUnit(keyset.unit ?? DEFAULT_UNIT, { defaultUnit: DEFAULT_UNIT }), + })) + .filter(({ unit }) => !unitFilter || unitFilter.has(unit)); + } } diff --git a/packages/core/api/WalletBalancesApi.ts b/packages/core/api/WalletBalancesApi.ts index 674e08bc..ad6b4a3d 100644 --- a/packages/core/api/WalletBalancesApi.ts +++ b/packages/core/api/WalletBalancesApi.ts @@ -1,5 +1,11 @@ import type { ProofService } from '@core/services'; -import type { BalanceQuery, BalanceSnapshot, BalancesByMint } from '../types'; +import type { + BalanceQuery, + BalanceSnapshot, + BalancesByMint, + BalancesByMintAndUnit, + BalancesByUnit, +} from '../types'; export class WalletBalancesApi { private readonly proofService: ProofService; @@ -12,7 +18,19 @@ export class WalletBalancesApi { return this.proofService.getBalancesByMint(scope); } + async byMintAndUnit(scope?: BalanceQuery): Promise { + return this.proofService.getBalancesByMintAndUnit(scope); + } + + async byUnit(scope?: BalanceQuery): Promise { + return this.proofService.getBalancesByUnit(scope); + } + async total(scope?: BalanceQuery): Promise { return this.proofService.getBalanceTotal(scope); } + + async totalByUnit(scope?: BalanceQuery): Promise { + return this.proofService.getBalanceTotalByUnit(scope); + } } diff --git a/packages/core/events/types.ts b/packages/core/events/types.ts index 363357c0..1f2780d0 100644 --- a/packages/core/events/types.ts +++ b/packages/core/events/types.ts @@ -24,7 +24,13 @@ export interface CoreEvents { }; 'proofs:deleted': { mintUrl: string; secrets: string[] }; 'proofs:wiped': { mintUrl: string; keysetId: string }; - 'proofs:reserved': { mintUrl: string; operationId: string; secrets: string[]; amount: Amount }; + 'proofs:reserved': { + mintUrl: string; + unit: string; + operationId: string; + secrets: string[]; + amount: Amount; + }; 'proofs:released': { mintUrl: string; secrets: string[] }; 'melt-quote:created': { mintUrl: string; quoteId: string; quote: MeltQuoteBolt11Response }; 'melt-quote:state-changed': { mintUrl: string; quoteId: string; state: MeltQuoteState }; diff --git a/packages/core/index.ts b/packages/core/index.ts index a979535e..c42f8464 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -1,4 +1,5 @@ export * from './Manager.ts'; +export * from './amounts.ts'; export * from './repositories/index.ts'; export * from './models/index.ts'; export * from './api/index.ts'; @@ -10,6 +11,8 @@ export type { BalanceQuery, BalanceSnapshot, BalancesByMint, + BalancesByMintAndUnit, + BalancesByUnit, BalanceBreakdown, BalancesBreakdownByMint, } from './types.ts'; diff --git a/packages/core/infra/handlers/melt/MeltBolt11Handler.ts b/packages/core/infra/handlers/melt/MeltBolt11Handler.ts index 8c01ce6b..7a770fd1 100644 --- a/packages/core/infra/handlers/melt/MeltBolt11Handler.ts +++ b/packages/core/infra/handlers/melt/MeltBolt11Handler.ts @@ -37,6 +37,7 @@ import { getSwapSendSecrets, type MeltQuoteData, } from './MeltBolt11Handler.utils.ts'; +import { assertSameUnit } from '@core/amounts'; export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { // ============================================================================ @@ -117,6 +118,7 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ctx.operation.methodData.invoice, amountMsat, ); + assertSameUnit(quote.unit, ctx.operation.unit, `Melt quote ${quote.quote}`); const { amount, fee_reserve } = quote; const totalAmount = amount.add(fee_reserve); @@ -128,7 +130,10 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { totalAmount, }); - const selectedProofs = await ctx.proofService.selectProofsToSend(mintUrl, totalAmount, true); + const selectedProofs = await ctx.proofService.selectProofsToSend(mintUrl, totalAmount, { + unit: ctx.operation.unit, + includeFees: true, + }); const selectedAmount = sumProofs(selectedProofs); if (selectedAmount.lessThan(totalAmount)) { throw new ProofValidationError('Melt amount is not sufficient after fees'); @@ -169,7 +174,9 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ctx.logger?.debug('Preparing direct melt (no swap)', { operationId, selectedAmount }); - await ctx.proofService.reserveProofs(mintUrl, inputSecrets, operationId); + await ctx.proofService.reserveProofs(mintUrl, inputSecrets, operationId, { + unit: ctx.operation.unit, + }); const blankOutputs = await this.createChangeOutputs(amount, selectedAmount, ctx); @@ -185,7 +192,7 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ...ctx.operation, ...ctx.operation.methodData, quoteId: quote.quote, - unit: quote.unit, + unit: ctx.operation.unit, changeOutputData: serializeOutputData({ keep: blankOutputs, send: [] }), needsSwap: false, amount, @@ -212,7 +219,10 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ctx.logger?.debug('Preparing swap-then-melt', { operationId, totalAmount }); // Re-select proofs including the swap fee - const selectedProofs = await ctx.proofService.selectProofsToSend(mintUrl, totalAmount, true); + const selectedProofs = await ctx.proofService.selectProofsToSend(mintUrl, totalAmount, { + unit: ctx.operation.unit, + includeFees: true, + }); const selectedAmount = sumProofs(selectedProofs); const inputSecrets = selectedProofs.map((p) => p.secret); @@ -232,7 +242,9 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { swapFee, }); - await ctx.proofService.reserveProofs(mintUrl, inputSecrets, operationId); + await ctx.proofService.reserveProofs(mintUrl, inputSecrets, operationId, { + unit: ctx.operation.unit, + }); const blankOutputs = await this.createChangeOutputs(amount, sendAmount, ctx); @@ -245,7 +257,7 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { keep: keepAmount, send: sendAmount, }, - { includeFees: true }, + { includeFees: true, unit: ctx.operation.unit }, ); ctx.logger?.info('Swap-then-melt prepared', { @@ -261,7 +273,7 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ...ctx.operation, ...ctx.operation.methodData, quoteId: quote.quote, - unit: quote.unit, + unit: ctx.operation.unit, swapOutputData: serializeOutputData(swapOutputData), changeOutputData: serializeOutputData({ keep: blankOutputs, send: [] }), needsSwap: true, @@ -284,7 +296,9 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ctx: BasePrepareContext<'bolt11'>, ) { const changeDelta = sendAmount.subtract(quoteAmount); - return ctx.proofService.createBlankOutputs(changeDelta, ctx.operation.mintUrl); + return ctx.proofService.createBlankOutputs(changeDelta, ctx.operation.mintUrl, { + unit: ctx.operation.unit, + }); } // ============================================================================ @@ -420,7 +434,10 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { const swapData = deserializeOutputData(swapOutputData); const sendAmount = OutputData.sumOutputAmounts(swapData.send); - const { wallet } = await ctx.walletService.getWalletWithActiveKeysetId(mintUrl); + const { wallet } = await ctx.walletService.getWalletWithActiveKeysetId( + mintUrl, + ctx.operation.unit, + ); ctx.logger?.debug('Executing pre-melt swap', { operationId, @@ -437,8 +454,14 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { await ctx.proofService.setProofState(mintUrl, inputProofSecrets, 'spent'); const newProofs = [ - ...mapProofToCoreProof(mintUrl, 'ready', keep, { createdByOperationId: operationId }), - ...mapProofToCoreProof(mintUrl, 'inflight', send, { createdByOperationId: operationId }), + ...mapProofToCoreProof(mintUrl, 'ready', keep, { + unit: ctx.operation.unit, + createdByOperationId: operationId, + }), + ...mapProofToCoreProof(mintUrl, 'inflight', send, { + unit: ctx.operation.unit, + createdByOperationId: operationId, + }), ]; await ctx.proofService.saveProofs(mintUrl, newProofs); @@ -519,6 +542,7 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { if (change && change.length > 0) { const changeOutputData = deserializeOutputData(serializedChangeOutputData).keep; await ctx.proofService.unblindAndSaveChangeProofs(mintUrl, changeOutputData, change, { + unit: ctx.operation.unit, createdByOperationId: operationId, }); } @@ -774,7 +798,10 @@ export class MeltBolt11Handler implements MeltMethodHandler<'bolt11'> { ctx.logger?.debug('Swap proofs not found locally, recovering from mint', { operationId }); - await ctx.proofService.recoverProofsFromOutputData(mintUrl, swapOutputData); + await ctx.proofService.recoverProofsFromOutputData(mintUrl, swapOutputData, { + unit: operation.unit, + createdByOperationId: operationId, + }); try { await ctx.proofService.setProofState(mintUrl, operation.inputProofSecrets, 'spent'); diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index 48936717..0c36006c 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -11,6 +11,7 @@ import type { PendingMintCheckResult, } from '@core/operations/mint'; import { MintOperationError } from '../../../models/Error'; +import { assertSameUnit } from '@core/amounts'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; import type { MintQuoteBolt11Response } from '@cashu/cashu-ts'; @@ -31,11 +32,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ); } - if (quote.unit !== ctx.operation.unit) { - throw new Error( - `Mint quote ${quote.quote} unit ${quote.unit} does not match requested unit ${ctx.operation.unit}`, - ); - } + assertSameUnit(quote.unit, ctx.operation.unit, `Mint quote ${quote.quote}`); const outputData = await ctx.proofService.createOutputsAndIncrementCounters( ctx.operation.mintUrl, @@ -43,6 +40,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { keep: quote.amount, send: 0, }, + { unit: ctx.operation.unit }, ); if (outputData.keep.length === 0) { @@ -53,7 +51,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ...ctx.operation, quoteId: quote.quote, amount: quote.amount, - unit: quote.unit, + unit: ctx.operation.unit, request: quote.request, expiry: quote.expiry, pubkey: quote.pubkey, @@ -120,6 +118,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { await ctx.proofService.saveProofs( ctx.operation.mintUrl, mapProofToCoreProof(ctx.operation.mintUrl, 'ready', proofs, { + unit: ctx.operation.unit, createdByOperationId: ctx.operation.id, }), ); @@ -164,6 +163,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ctx.operation.mintUrl, ctx.operation.outputData, { + unit: ctx.operation.unit, createdByOperationId: ctx.operation.id, }, ); diff --git a/packages/core/infra/handlers/send/DefaultSendHandler.ts b/packages/core/infra/handlers/send/DefaultSendHandler.ts index f328c04f..1febae13 100644 --- a/packages/core/infra/handlers/send/DefaultSendHandler.ts +++ b/packages/core/infra/handlers/send/DefaultSendHandler.ts @@ -33,10 +33,13 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { */ async prepare(ctx: BasePrepareContext): Promise { const { operation, wallet, proofService, logger } = ctx; - const { mintUrl, amount } = operation; + const { mintUrl, amount, unit } = operation; // Try exact match first (no swap needed) - const exactProofs = await proofService.selectProofsToSend(mintUrl, amount, false); + const exactProofs = await proofService.selectProofsToSend(mintUrl, amount, { + unit, + includeFees: false, + }); const exactAmount = sumProofs(exactProofs); const needsSwap = !exactAmount.equals(amount); @@ -55,7 +58,10 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { } else { // Need to swap - select proofs including fees - const selected = await proofService.selectProofsToSend(mintUrl, amount, true); + const selected = await proofService.selectProofsToSend(mintUrl, amount, { + unit, + includeFees: true, + }); selectedProofs = selected; const selectedAmount = sumProofs(selectedProofs); fee = wallet.getFeesForProofs(selectedProofs); @@ -66,10 +72,14 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { const keepAmount = selectedAmount.subtract(requiredAmount); // Use ProofService to create outputs and increment counters - const outputResult = await proofService.createOutputsAndIncrementCounters(mintUrl, { - keep: keepAmount, - send: amount, - }); + const outputResult = await proofService.createOutputsAndIncrementCounters( + mintUrl, + { + keep: keepAmount, + send: amount, + }, + { unit }, + ); // Serialize for storage serializedOutputData = serializeOutputData({ @@ -91,7 +101,7 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { // Reserve the selected proofs const inputSecrets = selectedProofs.map((p: Proof) => p.secret); - await proofService.reserveProofs(mintUrl, inputSecrets, operation.id); + await proofService.reserveProofs(mintUrl, inputSecrets, operation.id, { unit }); // Build prepared operation const prepared: PreparedSendOperation = { @@ -99,6 +109,7 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { state: 'prepared', mintUrl: operation.mintUrl, amount: operation.amount, + unit: operation.unit, createdAt: operation.createdAt, updatedAt: Date.now(), error: operation.error, @@ -174,9 +185,11 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { // Save new proofs with correct states and operationId in a single call const keepCoreProofs = mapProofToCoreProof(mintUrl, 'ready', keepProofs, { + unit: operation.unit, createdByOperationId: operation.id, }); const sendCoreProofs = mapProofToCoreProof(mintUrl, 'inflight', sendProofs, { + unit: operation.unit, createdByOperationId: operation.id, }); await proofService.saveProofs(mintUrl, [...keepCoreProofs, ...sendCoreProofs]); @@ -188,7 +201,7 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { const token: Token = { mint: mintUrl, proofs: sendProofs, - unit: wallet.unit, + unit: operation.unit, }; // Build pending operation @@ -265,20 +278,27 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { if (!reclaimAmount.isZero()) { // Use ProofService to create outputs for reclaim - const outputResult = await proofService.createOutputsAndIncrementCounters(mintUrl, { - keep: reclaimAmount, - send: 0, - }); + const outputResult = await proofService.createOutputsAndIncrementCounters( + mintUrl, + { + keep: reclaimAmount, + send: 0, + }, + { unit: operation.unit }, + ); // Swap to reclaim const keep = await wallet.receive( - { mint: mintUrl, proofs: sendProofs, unit: wallet.unit }, + { mint: mintUrl, proofs: sendProofs, unit: operation.unit }, undefined, { type: 'custom', data: outputResult.keep }, ); // Save reclaimed proofs - await proofService.saveProofs(mintUrl, mapProofToCoreProof(mintUrl, 'ready', keep)); + await proofService.saveProofs( + mintUrl, + mapProofToCoreProof(mintUrl, 'ready', keep, { unit: operation.unit }), + ); // Mark send proofs as spent await proofService.setProofState( @@ -363,7 +383,10 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { ); if (!alreadySaved) { - await proofService.recoverProofsFromOutputData(operation.mintUrl, operation.outputData); + await proofService.recoverProofsFromOutputData(operation.mintUrl, operation.outputData, { + unit: operation.unit, + createdByOperationId: operation.id, + }); } } diff --git a/packages/core/infra/handlers/send/P2pkSendHandler.ts b/packages/core/infra/handlers/send/P2pkSendHandler.ts index cd03c67e..f534df41 100644 --- a/packages/core/infra/handlers/send/P2pkSendHandler.ts +++ b/packages/core/infra/handlers/send/P2pkSendHandler.ts @@ -34,7 +34,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { */ async prepare(ctx: BasePrepareContext): Promise { const { operation, wallet, proofService, logger } = ctx; - const { mintUrl, amount } = operation; + const { mintUrl, amount, unit } = operation; // Validate that we have a pubkey in methodData const pubkey = (operation.methodData as { pubkey: string })?.pubkey; @@ -44,7 +44,10 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { // P2PK always requires a swap to lock proofs to the pubkey // Select proofs including fees - const selected = await proofService.selectProofsToSend(mintUrl, amount, true); + const selected = await proofService.selectProofsToSend(mintUrl, amount, { + unit, + includeFees: true, + }); const selectedAmount = sumProofs(selected); const fee = wallet.getFeesForProofs(selected); const requiredAmount = amount.add(fee); @@ -54,10 +57,14 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { const keepAmount = selectedAmount.subtract(requiredAmount); // Use ProofService to create outputs and increment counters - const outputResult = await proofService.createOutputsAndIncrementCounters(mintUrl, { - keep: keepAmount, - send: 0, - }); + const outputResult = await proofService.createOutputsAndIncrementCounters( + mintUrl, + { + keep: keepAmount, + send: 0, + }, + { unit }, + ); const keyset = wallet.getKeyset(); @@ -83,7 +90,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { // Reserve the selected proofs const inputSecrets = selected.map((p: Proof) => p.secret); - await proofService.reserveProofs(mintUrl, inputSecrets, operation.id); + await proofService.reserveProofs(mintUrl, inputSecrets, operation.id, { unit }); // Build prepared operation const prepared: PreparedSendOperation = { @@ -91,6 +98,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { state: 'prepared', mintUrl: operation.mintUrl, amount: operation.amount, + unit: operation.unit, createdAt: operation.createdAt, updatedAt: Date.now(), error: operation.error, @@ -160,9 +168,11 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { // Persist keep proofs as ready and P2PK send proofs as inflight so the // existing proof watcher/finalization flow can track them uniformly. const keepCoreProofs = mapProofToCoreProof(mintUrl, 'ready', keepProofs, { + unit: operation.unit, createdByOperationId: operation.id, }); const sendCoreProofs = mapProofToCoreProof(mintUrl, 'inflight', sendProofs, { + unit: operation.unit, createdByOperationId: operation.id, }); if (keepCoreProofs.length > 0 || sendCoreProofs.length > 0) { @@ -175,7 +185,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { const token: Token = { mint: mintUrl, proofs: sendProofs, - unit: wallet.unit, + unit: operation.unit, }; // Build pending operation @@ -286,6 +296,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { ); if (existingKeepProofs.length === 0 && keepOutputData.keep.length > 0) { await proofService.recoverProofsFromOutputData(operation.mintUrl, keepOutputData, { + unit: operation.unit, createdByOperationId: operation.id, }); } @@ -301,6 +312,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { send: operation.outputData.send, }, { + unit: operation.unit, persistRecoveredProofs: false, }, ); @@ -309,6 +321,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { await proofService.saveProofs( operation.mintUrl, mapProofToCoreProof(operation.mintUrl, 'inflight', recoveredSendProofs, { + unit: operation.unit, createdByOperationId: operation.id, }), ); @@ -324,7 +337,7 @@ export class P2pkSendHandler implements SendMethodHandler<'p2pk'> { token = { mint: operation.mintUrl, proofs: sendProofs, - unit: wallet.unit, + unit: operation.unit, }; } else if (outputSecrets.sendSecrets.length > 0) { const sendStates = await wallet.checkProofsStates( diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts index 88063d71..374a4240 100644 --- a/packages/core/models/Error.ts +++ b/packages/core/models/Error.ts @@ -34,6 +34,21 @@ export class ProofValidationError extends Error { this.name = 'ProofValidationError'; } } + +export class UnitValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnitValidationError'; + } +} + +export class UnitMismatchError extends UnitValidationError { + constructor(message: string) { + super(message); + this.name = 'UnitMismatchError'; + } +} + export class TokenValidationError extends Error { constructor(message: string, cause?: unknown) { super(message); diff --git a/packages/core/operations/melt/MeltOperation.ts b/packages/core/operations/melt/MeltOperation.ts index bd975440..dcaa1f62 100644 --- a/packages/core/operations/melt/MeltOperation.ts +++ b/packages/core/operations/melt/MeltOperation.ts @@ -31,6 +31,7 @@ export type MeltOperationState = import type { Amount } from '@cashu/cashu-ts'; import { getSecretsFromSerializedOutputData, type SerializedOutputData } from '../../utils'; import type { MeltMethod, MeltMethodData, MeltMethodMeta } from './MeltMethodHandler'; +import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; // ============================================================================ // Base and Data Interfaces @@ -46,6 +47,9 @@ interface MeltOperationBase extends MeltMethodMeta { /** The mint URL for this operation */ mintUrl: string; + /** Unit for all amounts, proofs, quotes, outputs, and change in this operation. */ + unit: string; + /** Timestamp when the operation was created */ createdAt: number; @@ -60,9 +64,6 @@ interface MeltOperationBase extends MeltMethodMeta { * Data set during the prepare phase */ interface PreparedData { - /** The unit used by this melt operation */ - unit: string; - /** Whether the operation requires a swap (false = exact match melt) */ needsSwap: boolean; @@ -295,6 +296,7 @@ export function createMeltOperation( id: string, mintUrl: string, meta: MeltMethodMeta, + unit = DEFAULT_UNIT, ): InitMeltOperation { const now = Date.now(); return { @@ -302,6 +304,7 @@ export function createMeltOperation( id, state: 'init', mintUrl, + unit: normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }), createdAt: now, updatedAt: now, }; diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 9bf1b8cc..02c1c853 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -26,6 +26,7 @@ import type { MeltHandlerProvider } from '../../infra/handlers/melt'; import type { FinalizeResult } from './MeltMethodHandler'; import { MintScopedLock } from '../MintScopedLock'; import { OperationIdLock } from '../OperationIdLock'; +import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; /** * MeltOperationService orchestrates melt sagas while delegating @@ -98,7 +99,9 @@ export class MeltOperationService { mintUrl: string, method: MeltMethod, methodData: MeltMethodInputData, + unit = DEFAULT_UNIT, ): Promise { + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); const trusted = await this.mintService.isTrustedMint(mintUrl); if (!trusted) { throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); @@ -122,13 +125,23 @@ export class MeltOperationService { } const id = generateSubId(); - const operation = createMeltOperation(id, mintUrl, { - method, - methodData: normalizedMethodData, - }); + const operation = createMeltOperation( + id, + mintUrl, + { + method, + methodData: normalizedMethodData, + }, + normalizedUnit, + ); await this.meltOperationRepository.create(operation); - this.logger?.debug('Melt operation created', { operationId: id, mintUrl, method }); + this.logger?.debug('Melt operation created', { + operationId: id, + mintUrl, + method, + unit: normalizedUnit, + }); return operation; } @@ -157,7 +170,16 @@ export class MeltOperationService { try { const handler = this.handlerProvider.get(initOp.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(initOp.mintUrl); + await this.mintService.assertMintMethodUnitSupported( + initOp.mintUrl, + 5, + initOp.method, + initOp.unit, + ); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + initOp.mintUrl, + initOp.unit, + ); const prepared = await handler.prepare({ ...this.buildDeps(), operation: initOp, @@ -227,7 +249,10 @@ export class MeltOperationService { try { const handler = this.handlerProvider.get(executing.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + executing.mintUrl, + executing.unit, + ); const operationProofs = await this.proofRepository.getProofsByOperationId( executing.mintUrl, executing.id, @@ -390,7 +415,10 @@ export class MeltOperationService { } const handler = this.handlerProvider.get(operation.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(operation.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); // For pending operations, verify the quote is actually UNPAID before rolling back. // This prevents releasing proofs that are still inflight with the Lightning network. @@ -532,7 +560,7 @@ export class MeltOperationService { ); } const handler = this.handlerProvider.get(op.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); const decision: PendingCheckResult = (await handler.checkPending?.({ ...this.buildDeps(), @@ -649,7 +677,10 @@ export class MeltOperationService { const executing = current as ExecutingMeltOperation; const handler = this.handlerProvider.get(executing.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + executing.mintUrl, + executing.unit, + ); const result = await handler.recoverExecuting({ ...this.buildDeps(), diff --git a/packages/core/operations/mint/MintOperation.ts b/packages/core/operations/mint/MintOperation.ts index af60687f..4ab4b916 100644 --- a/packages/core/operations/mint/MintOperation.ts +++ b/packages/core/operations/mint/MintOperation.ts @@ -17,6 +17,7 @@ import type { Amount, AmountLike } from '@cashu/cashu-ts'; import type { SerializedOutputData } from '../../utils'; import { getSecretsFromSerializedOutputData, toAmount } from '../../utils'; import type { MintMethod, MintMethodMeta, MintMethodRemoteState } from './MintMethodHandler'; +import { normalizeUnit } from '../../amounts.ts'; interface MintOperationBase extends MintMethodMeta { id: string; @@ -149,6 +150,7 @@ export function createMintOperation( ...meta, ...intent, amount: toAmount(intent.amount), + unit: normalizeUnit(intent.unit), ...(options?.quoteId ? { quoteId: options.quoteId } : {}), id, state: 'init', diff --git a/packages/core/operations/mint/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts index 2bc53b1b..18a929b5 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -1,4 +1,4 @@ -import type { AmountLike, Proof } from '@cashu/cashu-ts'; +import type { Proof } from '@cashu/cashu-ts'; import type { MintOperationRepository, ProofRepository } from '../../repositories'; import type { ExecutingMintOperation, @@ -30,13 +30,14 @@ import type { ProofService } from '../../services/ProofService'; import type { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; -import { generateSubId, mapProofToCoreProof, toAmount } from '../../utils'; +import { generateSubId, mapProofToCoreProof } from '../../utils'; import { OperationInProgressError, NetworkError, ProofValidationError, UnknownMintError, } from '../../models/Error'; +import { DEFAULT_UNIT, parseUnitAmount, type UnitAmountLike } from '../../amounts.ts'; import type { MintAdapter } from '../../infra'; import type { MintHandlerProvider } from '../../infra/handlers/mint'; import { MintScopedLock } from '../MintScopedLock'; @@ -120,36 +121,23 @@ export class MintOperationService { return this.recoveryLock !== null; } - private assertSupportedUnit(unit: string): void { - if (unit !== 'sat') { - throw new ProofValidationError( - `Unsupported mint unit '${unit}'. Only 'sat' is currently supported.`, - ); - } - } - async init( mintUrl: string, - intent: { amount: AmountLike; unit: string }, + intent: UnitAmountLike, method: MintMethod = 'bolt11', methodData: MintMethodData = {}, options?: { quoteId?: string }, ): Promise { + const parsed = parseUnitAmount(intent); const trusted = await this.mintService.isTrustedMint(mintUrl); if (!trusted) { throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); } - if (toAmount(intent.amount).isZero()) { + if (parsed.amount.isZero()) { throw new ProofValidationError('Amount must be a positive number'); } - if (!intent.unit) { - throw new ProofValidationError('Unit is required'); - } - - this.assertSupportedUnit(intent.unit); - const operationId = generateSubId(); const operation = createMintOperation( operationId, @@ -158,7 +146,7 @@ export class MintOperationService { method, methodData, } as MintMethodMeta, - intent, + parsed, options, ); @@ -168,8 +156,8 @@ export class MintOperationService { mintUrl, quoteId: options?.quoteId, method, - amount: intent.amount, - unit: intent.unit, + amount: parsed.amount, + unit: parsed.unit, }); return operation; @@ -177,12 +165,13 @@ export class MintOperationService { async prepareNewQuote( mintUrl: string, - amount: AmountLike, - unit = 'sat', + amount: UnitAmountLike, + unit = DEFAULT_UNIT, method: MintMethod = 'bolt11', methodData: MintMethodData = {}, ): Promise { - const initOperation = await this.init(mintUrl, { amount, unit }, method, methodData); + const parsed = parseUnitAmount(amount, { explicitUnit: unit }); + const initOperation = await this.init(mintUrl, parsed, method, methodData); return this.prepare(initOperation.id); } @@ -254,7 +243,17 @@ export class MintOperationService { } try { const handler = this.handlerProvider.get(initOp.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(initOp.mintUrl); + await this.mintService.assertMintMethodUnitSupported( + initOp.mintUrl, + 4, + initOp.method, + initOp.unit, + initOp.amount, + ); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + initOp.mintUrl, + initOp.unit, + ); const pending = await handler.prepare({ ...this.buildDeps(), operation: initOp as any, @@ -329,7 +328,10 @@ export class MintOperationService { try { const handler = this.handlerProvider.get(executing.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + executing.mintUrl, + executing.unit, + ); const result = await handler.execute({ ...this.buildDeps(), operation: executing as any, @@ -536,7 +538,10 @@ export class MintOperationService { } const handler = this.handlerProvider.get(executing.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + executing.mintUrl, + executing.unit, + ); const result = await handler.recoverExecuting({ ...this.buildDeps(), operation: executing as any, @@ -670,6 +675,7 @@ export class MintOperationService { await this.proofService.saveProofs( op.mintUrl, mapProofToCoreProof(op.mintUrl, 'ready', proofsFromExecute, { + unit: op.unit, createdByOperationId: op.id, }), ); @@ -680,6 +686,7 @@ export class MintOperationService { } await this.proofService.recoverProofsFromOutputData(op.mintUrl, op.outputData, { + unit: op.unit, createdByOperationId: op.id, }); @@ -827,7 +834,7 @@ export class MintOperationService { ); } const handler = this.handlerProvider.get(op.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); const result = await handler.checkPending({ ...this.buildDeps(), diff --git a/packages/core/operations/receive/ReceiveOperation.ts b/packages/core/operations/receive/ReceiveOperation.ts index b55575f3..8d5e07a8 100644 --- a/packages/core/operations/receive/ReceiveOperation.ts +++ b/packages/core/operations/receive/ReceiveOperation.ts @@ -19,6 +19,7 @@ import { toAmount, type SerializedOutputData, } from '../../utils'; +import { normalizeUnit } from '../../amounts.ts'; // ============================================================================ // Base and Data Interfaces @@ -195,7 +196,7 @@ export function createReceiveOperation( id, state: 'init', mintUrl, - unit, + unit: normalizeUnit(unit), amount: toAmount(amount), inputProofs, createdAt: now, diff --git a/packages/core/operations/receive/ReceiveOperationService.ts b/packages/core/operations/receive/ReceiveOperationService.ts index 3db1d354..1095d3cd 100644 --- a/packages/core/operations/receive/ReceiveOperationService.ts +++ b/packages/core/operations/receive/ReceiveOperationService.ts @@ -40,6 +40,7 @@ import type { WalletService } from '../../services/WalletService'; import { createReceiveOperation, getOutputProofSecrets } from './ReceiveOperation'; import type { ReceiveOperationRepository, ProofRepository } from '../../repositories'; import { OperationIdLock } from '../OperationIdLock'; +import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; const NON_TERMINAL_RECEIVE_MINT_ERROR_CODES = new Set([ // 11003 is special for receive recovery: the mint may already have accepted and @@ -116,14 +117,6 @@ export class ReceiveOperationService { return this.recoveryLock !== null; } - private assertSupportedUnit(unit: string): void { - if (unit !== 'sat') { - throw new ProofValidationError( - `Unsupported mint unit '${unit}'. Only 'sat' is currently supported.`, - ); - } - } - /** * Create a new receive operation by decoding and validating the token. * Persists the init state so recovery can reason about this operation. @@ -136,7 +129,7 @@ export class ReceiveOperationService { } const decodedToken = await this.tokenService.decodeToken(token, mintUrl); - this.assertSupportedUnit(decodedToken.unit || 'sat'); + const unit = normalizeUnit(decodedToken.unit, { defaultUnit: DEFAULT_UNIT }); const proofs = decodedToken.proofs; const preparedProofs = await this.proofService.prepareProofsForReceiving(proofs); @@ -152,13 +145,7 @@ export class ReceiveOperationService { } const id = generateSubId(); - const operation = createReceiveOperation( - id, - mintUrl, - amount, - preparedProofs, - decodedToken.unit || 'sat', - ); + const operation = createReceiveOperation(id, mintUrl, amount, preparedProofs, unit); await this.receiveOperationRepository.create(operation); this.logger?.debug('Receive operation created', { @@ -208,7 +195,10 @@ export class ReceiveOperationService { } const { mintUrl } = operation; - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + mintUrl, + operation.unit, + ); const fee = wallet.getFeesForProofs(operation.inputProofs); if (operation.amount.lessThanOrEqual(fee)) { @@ -217,10 +207,14 @@ export class ReceiveOperationService { const keepAmount = operation.amount.subtract(fee); - const outputResult = await this.proofService.createOutputsAndIncrementCounters(mintUrl, { - keep: keepAmount, - send: 0, - }); + const outputResult = await this.proofService.createOutputsAndIncrementCounters( + mintUrl, + { + keep: keepAmount, + send: 0, + }, + { unit: operation.unit }, + ); if (!outputResult.keep || outputResult.keep.length === 0) { throw new Error('Failed to create deterministic outputs for receive'); @@ -303,7 +297,10 @@ export class ReceiveOperationService { throw new Error('Missing output data for receive operation'); } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(executing.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + executing.mintUrl, + executing.unit, + ); const outputData = deserializeOutputData(executing.outputData); this.logger?.info('Receiving token', { @@ -314,7 +311,7 @@ export class ReceiveOperationService { }); const newProofs = await wallet.receive( - { mint: executing.mintUrl, proofs: executing.inputProofs, unit: wallet.unit }, + { mint: executing.mintUrl, proofs: executing.inputProofs, unit: executing.unit }, undefined, { type: 'custom', data: outputData.keep }, ); @@ -322,6 +319,7 @@ export class ReceiveOperationService { await this.proofService.saveProofs( executing.mintUrl, mapProofToCoreProof(executing.mintUrl, 'ready', newProofs, { + unit: executing.unit, createdByOperationId: executing.id, }), ); @@ -585,6 +583,7 @@ export class ReceiveOperationService { executing.mintUrl, executing.outputData, { + unit: executing.unit, createdByOperationId: executing.id, }, ); diff --git a/packages/core/operations/send/SendOperation.ts b/packages/core/operations/send/SendOperation.ts index 95d52c84..08cff3cd 100644 --- a/packages/core/operations/send/SendOperation.ts +++ b/packages/core/operations/send/SendOperation.ts @@ -24,12 +24,9 @@ export type SendOperationState = | 'rolling_back' | 'rolled_back'; -import type { Amount, AmountLike, Token } from '@cashu/cashu-ts'; -import { - getSecretsFromSerializedOutputData, - toAmount, - type SerializedOutputData, -} from '../../utils'; +import type { Amount, Token } from '@cashu/cashu-ts'; +import { getSecretsFromSerializedOutputData, type SerializedOutputData } from '../../utils'; +import { parseUnitAmount, type UnitAmountLike } from '../../amounts.ts'; import type { SendMethod, SendMethodData } from './SendMethodHandler'; // ============================================================================ @@ -49,6 +46,9 @@ interface SendOperationBase { /** The amount requested to send (before fees) */ amount: Amount; + /** Unit for all amounts, proofs, outputs, and token data in this operation. */ + unit: string; + /** The send method (e.g., 'default', 'p2pk') */ method: M; @@ -292,15 +292,17 @@ export interface CreateSendOperationOptions { export function createSendOperation( id: string, mintUrl: string, - amount: AmountLike, + amount: UnitAmountLike, options: CreateSendOperationOptions, ): InitSendOperation { const now = Date.now(); + const parsed = parseUnitAmount(amount); return { id, state: 'init', mintUrl, - amount: toAmount(amount), + amount: parsed.amount, + unit: parsed.unit, method: options.method, methodData: options.methodData, createdAt: now, diff --git a/packages/core/operations/send/SendOperationService.ts b/packages/core/operations/send/SendOperationService.ts index 8fadf89d..cce9b506 100644 --- a/packages/core/operations/send/SendOperationService.ts +++ b/packages/core/operations/send/SendOperationService.ts @@ -1,4 +1,4 @@ -import type { AmountLike, Token, ProofState as CashuProofState } from '@cashu/cashu-ts'; +import type { Token, ProofState as CashuProofState } from '@cashu/cashu-ts'; import type { SendOperationRepository, ProofRepository } from '../../repositories'; import type { SendOperation, @@ -26,7 +26,7 @@ import type { ProofService } from '../../services/ProofService'; import type { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; -import { generateSubId, toAmount } from '../../utils'; +import { generateSubId } from '../../utils'; import { UnknownMintError, ProofValidationError, @@ -34,6 +34,7 @@ import { } from '../../models/Error'; import { MintScopedLock } from '../MintScopedLock'; import { OperationIdLock } from '../OperationIdLock'; +import { parseUnitAmount, type UnitAmountLike } from '../../amounts.ts'; /** * Service that manages send operations as sagas. @@ -120,29 +121,31 @@ export class SendOperationService { */ async init( mintUrl: string, - amount: AmountLike, + amount: UnitAmountLike, options: CreateSendOperationOptions = { method: 'default' as M, methodData: {} as SendMethodData, }, ): Promise { + const parsed = parseUnitAmount(amount); const trusted = await this.mintService.isTrustedMint(mintUrl); if (!trusted) { throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); } - if (toAmount(amount).isZero()) { + if (parsed.amount.isZero()) { throw new ProofValidationError('Amount must be a positive number'); } const id = generateSubId(); - const operation = createSendOperation(id, mintUrl, amount, options); + const operation = createSendOperation(id, mintUrl, parsed, options); await this.sendOperationRepository.create(operation); this.logger?.debug('Send operation created', { operationId: id, mintUrl, - amount, + amount: parsed.amount, + unit: parsed.unit, method: options.method, }); @@ -173,7 +176,10 @@ export class SendOperationService { throw new Error(`No handler registered for method: ${operation.method}`); } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(operation.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); const ctx = { operation, wallet, @@ -244,7 +250,10 @@ export class SendOperationService { throw new Error(`No handler registered for method: ${operation.method}`); } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(operation.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); const reservedProofs = await this.proofRepository.getProofsByOperationId( operation.mintUrl, operation.id, @@ -314,7 +323,7 @@ export class SendOperationService { * High-level send method that orchestrates init → prepare → execute. * This is the main entry point for consumers. */ - async send(mintUrl: string, amount: AmountLike): Promise { + async send(mintUrl: string, amount: UnitAmountLike): Promise { const initOp = await this.init(mintUrl, amount); const preparedOp = await this.prepare(initOp); const { token } = await this.execute(preparedOp); @@ -464,7 +473,10 @@ export class SendOperationService { throw new Error('Cannot rollback pending P2PK send operation'); } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(operation.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); let opForRollback: PreparedOrLaterOperation = operation; if (operation.state === 'pending') { @@ -632,7 +644,7 @@ export class SendOperationService { */ private async recoverExecutingOperation(op: ExecutingSendOperation): Promise { const handler = this.handlerProvider.get(op.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); const result = await handler.recoverExecuting({ ...this.buildDeps(), @@ -688,7 +700,7 @@ export class SendOperationService { */ async checkPendingOperation(op: PendingSendOperation): Promise { const handler = this.handlerProvider.get(op.method); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); const decision = (await handler.checkPending?.({ @@ -716,7 +728,7 @@ export class SendOperationService { let sendStates: CashuProofState[]; try { - sendStates = await this.checkProofStatesWithMint(op.mintUrl, sendSecrets); + sendStates = await this.checkProofStatesWithMint(op.mintUrl, sendSecrets, op.unit); } catch (_e) { this.logger?.warn('Could not reach mint for recovery, will retry later', { operationId: op.id, @@ -734,8 +746,9 @@ export class SendOperationService { private async checkProofStatesWithMint( mintUrl: string, secrets: string[], + unit: string, ): Promise { - const wallet = await this.walletService.getWallet(mintUrl); + const wallet = await this.walletService.getWallet(mintUrl, unit); const proofInputs = secrets.map((secret) => ({ secret })); return wallet.checkProofsStates(proofInputs); } diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index b3927a16..b2514537 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -23,6 +23,11 @@ import type { Mint } from '../models/Mint'; import type { SendOperation, SendOperationState } from '../operations/send/SendOperation'; import type { CoreProof, ProofState } from '../types'; +export interface ProofUnitFilter { + unit?: string; + units?: string[]; +} + export interface MintRepository { isTrustedMint(mintUrl: string): Promise; getMintByUrl(mintUrl: string): Promise; @@ -50,15 +55,19 @@ export interface CounterRepository { export interface ProofRepository { saveProofs(mintUrl: string, proofs: CoreProof[]): Promise; - getReadyProofs(mintUrl: string): Promise; + getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise; /** * Retrieves all proofs marked as inflight. Can be optionally filtered by a list of mint URLs. */ - getInflightProofs(mintUrls?: string[]): Promise; - getAllReadyProofs(): Promise; + getInflightProofs(mintUrls?: string[], filter?: ProofUnitFilter): Promise; + getAllReadyProofs(filter?: ProofUnitFilter): Promise; setProofState(mintUrl: string, secrets: string[], state: ProofState): Promise; deleteProofs(mintUrl: string, secrets: string[]): Promise; - getProofsByKeysetId(mintUrl: string, keysetId: string): Promise; + getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise; wipeProofsByKeysetId(mintUrl: string, keysetId: string): Promise; /** @@ -96,7 +105,7 @@ export interface ProofRepository { * Get available (ready and not reserved) proofs for a mint. * This filters out proofs that have usedByOperationId set. */ - getAvailableProofs(mintUrl: string): Promise; + getAvailableProofs(mintUrl: string, filter?: ProofUnitFilter): Promise; /** * Get all proofs that are reserved (have usedByOperationId set) and are still in ready state. diff --git a/packages/core/repositories/memory/MemoryProofRepository.ts b/packages/core/repositories/memory/MemoryProofRepository.ts index 0916f6a1..f3fbef9b 100644 --- a/packages/core/repositories/memory/MemoryProofRepository.ts +++ b/packages/core/repositories/memory/MemoryProofRepository.ts @@ -1,5 +1,20 @@ -import type { ProofRepository } from '..'; +import type { ProofRepository, ProofUnitFilter } from '..'; import type { CoreProof, ProofState } from '../../types'; +import { normalizeUnit } from '../../amounts.ts'; + +function normalizeProofUnit(proof: CoreProof): string { + return normalizeUnit((proof as { unit?: string }).unit); +} + +function getUnitFilter(filter?: ProofUnitFilter): Set | undefined { + const units = [...(filter?.units ?? []), ...(filter?.unit ? [filter.unit] : [])]; + if (units.length === 0) return undefined; + return new Set(units.map((unit) => normalizeUnit(unit))); +} + +function matchesUnit(proof: CoreProof, unitFilter?: Set): boolean { + return !unitFilter || unitFilter.has(normalizeProofUnit(proof)); +} export class MemoryProofRepository implements ProofRepository { private proofsByMint: Map> = new Map(); @@ -14,30 +29,36 @@ export class MemoryProofRepository implements ProofRepository { async saveProofs(mintUrl: string, proofs: CoreProof[]): Promise { if (!proofs || proofs.length === 0) return; const map = this.getMintMap(mintUrl); + const normalizedProofs = proofs.map((proof) => ({ + ...proof, + unit: normalizeProofUnit(proof), + })); // Pre-check for any collisions and fail atomically - for (const p of proofs) { + for (const p of normalizedProofs) { if (map.has(p.secret)) { throw new Error(`Proof with secret already exists: ${p.secret}`); } } - for (const p of proofs) { + for (const p of normalizedProofs) { map.set(p.secret, { ...p, mintUrl }); } } - async getReadyProofs(mintUrl: string): Promise { + async getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { const map = this.getMintMap(mintUrl); + const unitFilter = getUnitFilter(filter); return Array.from(map.values()) - .filter((p) => p.state === 'ready') + .filter((p) => p.state === 'ready' && matchesUnit(p, unitFilter)) .map((p) => ({ ...p })); } - async getInflightProofs(mintUrls?: string[]): Promise { + async getInflightProofs(mintUrls?: string[], filter?: ProofUnitFilter): Promise { + const unitFilter = getUnitFilter(filter); if (!mintUrls || mintUrls.length === 0) { const all: CoreProof[] = []; for (const map of this.proofsByMint.values()) { for (const p of map.values()) { - if (p.state === 'inflight') { + if (p.state === 'inflight' && matchesUnit(p, unitFilter)) { all.push({ ...p }); } } @@ -52,7 +73,7 @@ export class MemoryProofRepository implements ProofRepository { const map = this.proofsByMint.get(mintUrl); if (!map) continue; for (const p of map.values()) { - if (p.state === 'inflight') { + if (p.state === 'inflight' && matchesUnit(p, unitFilter)) { results.push({ ...p }); } } @@ -60,11 +81,12 @@ export class MemoryProofRepository implements ProofRepository { return results; } - async getAllReadyProofs(): Promise { + async getAllReadyProofs(filter?: ProofUnitFilter): Promise { + const unitFilter = getUnitFilter(filter); const all: CoreProof[] = []; for (const map of this.proofsByMint.values()) { for (const p of map.values()) { - if (p.state === 'ready') { + if (p.state === 'ready' && matchesUnit(p, unitFilter)) { all.push({ ...p }); } } @@ -72,11 +94,16 @@ export class MemoryProofRepository implements ProofRepository { return all; } - async getProofsByKeysetId(mintUrl: string, keysetId: string): Promise { + async getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise { const map = this.getMintMap(mintUrl); + const unitFilter = getUnitFilter(filter); const results: CoreProof[] = []; for (const p of map.values()) { - if (p.state === 'ready' && p.id === keysetId) { + if (p.state === 'ready' && p.id === keysetId && matchesUnit(p, unitFilter)) { results.push({ ...p }); } } @@ -188,10 +215,11 @@ export class MemoryProofRepository implements ProofRepository { return results; } - async getAvailableProofs(mintUrl: string): Promise { + async getAvailableProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { const map = this.getMintMap(mintUrl); + const unitFilter = getUnitFilter(filter); return Array.from(map.values()) - .filter((p) => p.state === 'ready' && !p.usedByOperationId) + .filter((p) => p.state === 'ready' && !p.usedByOperationId && matchesUnit(p, unitFilter)) .map((p) => ({ ...p })); } diff --git a/packages/core/services/HistoryService.ts b/packages/core/services/HistoryService.ts index 6bfe720f..fe226cc5 100644 --- a/packages/core/services/HistoryService.ts +++ b/packages/core/services/HistoryService.ts @@ -112,7 +112,7 @@ export class HistoryService { const entry: Omit = { type: 'send', createdAt: Date.now(), - unit: 'sat', // TODO: get unit from operation/mint + unit: operation.unit, amount: operation.amount, mintUrl, operationId, diff --git a/packages/core/services/MeltQuoteService.ts b/packages/core/services/MeltQuoteService.ts index 0d37755d..c0ab36bc 100644 --- a/packages/core/services/MeltQuoteService.ts +++ b/packages/core/services/MeltQuoteService.ts @@ -13,6 +13,11 @@ import type { CoreEvents } from '../events/types'; import type { MeltQuoteRepository } from '../repositories'; import { mapProofToCoreProof } from '@core/utils'; import { UnknownMintError } from '../models/Error'; +import { DEFAULT_UNIT, assertSameUnit, normalizeUnit } from '../amounts.ts'; + +export interface MeltQuoteOptions { + unit?: string; +} export class MeltQuoteService { private readonly mintService: MintService; @@ -38,7 +43,12 @@ export class MeltQuoteService { this.logger = logger; } - async createMeltQuote(mintUrl: string, invoice: string): Promise { + async createMeltQuote( + mintUrl: string, + invoice: string, + options: MeltQuoteOptions = {}, + ): Promise { + const unit = normalizeUnit(options.unit, { defaultUnit: DEFAULT_UNIT }); if (!mintUrl || !mintUrl.trim()) { this.logger?.warn('Invalid parameter: mintUrl is required for createMeltQuote'); throw new Error('mintUrl is required'); @@ -55,20 +65,32 @@ export class MeltQuoteService { throw new UnknownMintError(`Mint ${mintUrl} is not trusted`); } - this.logger?.info('Creating melt quote', { mintUrl }); + await this.mintService.assertMintMethodUnitSupported(mintUrl, 5, 'bolt11', unit); + + this.logger?.info('Creating melt quote', { mintUrl, unit }); try { - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl, unit); const quote = await wallet.createMeltQuoteBolt11(invoice); - await this.meltQuoteRepo.addMeltQuote({ ...quote, mintUrl }); - await this.eventBus.emit('melt-quote:created', { mintUrl, quoteId: quote.quote, quote }); - return quote; + assertSameUnit(quote.unit, unit, `Melt quote ${quote.quote}`); + const normalizedQuote = { ...quote, unit }; + await this.meltQuoteRepo.addMeltQuote({ ...normalizedQuote, mintUrl }); + await this.eventBus.emit('melt-quote:created', { + mintUrl, + quoteId: normalizedQuote.quote, + quote: normalizedQuote, + }); + return normalizedQuote; } catch (err) { - this.logger?.error('Failed to create melt quote', { mintUrl, err }); + this.logger?.error('Failed to create melt quote', { mintUrl, unit, err }); throw err; } } - async payMeltQuote(mintUrl: string, quoteId: string): Promise { + async payMeltQuote( + mintUrl: string, + quoteId: string, + options: MeltQuoteOptions = {}, + ): Promise { if (!mintUrl || !mintUrl.trim()) { this.logger?.warn('Invalid parameter: mintUrl is required for payMeltQuote'); throw new Error('mintUrl is required'); @@ -90,10 +112,18 @@ export class MeltQuoteService { this.logger?.warn('Melt quote not found', { mintUrl, quoteId }); throw new Error('Quote not found'); } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const unit = normalizeUnit(quote.unit, { defaultUnit: DEFAULT_UNIT }); + if (options.unit !== undefined) { + assertSameUnit(unit, options.unit, `Melt quote ${quoteId}`); + } + await this.mintService.assertMintMethodUnitSupported(mintUrl, 5, 'bolt11', unit); + const scopedQuote = { ...quote, unit }; + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl, unit); - let targetAmount = quote.amount.add(quote.fee_reserve); - const selectedProofs = await this.proofService.selectProofsToSend(mintUrl, targetAmount); + let targetAmount = scopedQuote.amount.add(scopedQuote.fee_reserve); + const selectedProofs = await this.proofService.selectProofsToSend(mintUrl, targetAmount, { + unit, + }); const selectedInputFee = wallet.getFeesForProofs(selectedProofs); targetAmount = targetAmount.add(selectedInputFee); const selectedAmount = sumProofs(selectedProofs); @@ -119,8 +149,11 @@ export class MeltQuoteService { selectedProofs.map((proof) => proof.secret), 'inflight', ); - const { change } = await wallet.meltProofsBolt11(quote, selectedProofs); - await this.proofService.saveProofs(mintUrl, mapProofToCoreProof(mintUrl, 'ready', change)); + const { change } = await wallet.meltProofsBolt11(scopedQuote, selectedProofs); + await this.proofService.saveProofs( + mintUrl, + mapProofToCoreProof(mintUrl, 'ready', change ?? [], { unit }), + ); await this.proofService.setProofState( mintUrl, selectedProofs.map((proof) => proof.secret), @@ -135,7 +168,7 @@ export class MeltQuoteService { selectedProofs, }); const swapFees = wallet.getFeesForProofs(selectedProofs); - const totalSendAmount = quote.amount.add(quote.fee_reserve).add(swapFees); + const totalSendAmount = scopedQuote.amount.add(scopedQuote.fee_reserve).add(swapFees); if (selectedAmount.lessThan(totalSendAmount)) { this.logger?.warn('Insufficient proofs after fee calculation', { mintUrl, @@ -146,12 +179,14 @@ export class MeltQuoteService { }); throw new Error('Insufficient proofs to pay melt quote after fees'); } - const sendAmount = quote.amount.add(quote.fee_reserve); + const sendAmount = scopedQuote.amount.add(scopedQuote.fee_reserve); const keepAmount = selectedAmount.subtract(sendAmount).subtract(swapFees); // Create deterministic blank outputs for receiving change and reserve counters - const changeDelta = sendAmount.subtract(quote.amount); - const blankOutputs = await this.proofService.createBlankOutputs(changeDelta, mintUrl); + const changeDelta = sendAmount.subtract(scopedQuote.amount); + const blankOutputs = await this.proofService.createBlankOutputs(changeDelta, mintUrl, { + unit, + }); const outputData = await this.proofService.createOutputsAndIncrementCounters( mintUrl, @@ -159,7 +194,7 @@ export class MeltQuoteService { keep: keepAmount, send: sendAmount, }, - { includeFees: true }, + { includeFees: true, unit }, ); const outputConfig: OutputConfig = { send: { type: 'custom', data: outputData.send }, @@ -181,7 +216,7 @@ export class MeltQuoteService { await this.proofService.saveProofs( mintUrl, - mapProofToCoreProof(mintUrl, 'ready', [...keep, ...send]), + mapProofToCoreProof(mintUrl, 'ready', [...keep, ...send], { unit }), ); await this.proofService.setProofState( mintUrl, @@ -194,11 +229,14 @@ export class MeltQuoteService { 'inflight', ); - const { change } = await wallet.meltProofsBolt11(quote, send, undefined, { + const { change } = await wallet.meltProofsBolt11(scopedQuote, send, undefined, { type: 'custom', data: blankOutputs, }); - await this.proofService.saveProofs(mintUrl, mapProofToCoreProof(mintUrl, 'ready', change)); + await this.proofService.saveProofs( + mintUrl, + mapProofToCoreProof(mintUrl, 'ready', change ?? [], { unit }), + ); await this.proofService.setProofState( mintUrl, send.map((proof) => proof.secret), @@ -206,7 +244,7 @@ export class MeltQuoteService { ); } await this.setMeltQuoteState(mintUrl, quoteId, 'PAID'); - await this.eventBus.emit('melt-quote:paid', { mintUrl, quoteId, quote }); + await this.eventBus.emit('melt-quote:paid', { mintUrl, quoteId, quote: scopedQuote }); } catch (err) { this.logger?.error('Failed to pay melt quote', { mintUrl, quoteId, err }); throw err; diff --git a/packages/core/services/MintService.ts b/packages/core/services/MintService.ts index e79ef4d5..3a30cdbe 100644 --- a/packages/core/services/MintService.ts +++ b/packages/core/services/MintService.ts @@ -1,4 +1,10 @@ -import { KeysetSyncError, MintFetchError, UnknownMintError } from '../models/Error'; +import { Amount, type AmountLike } from '@cashu/cashu-ts'; +import { + KeysetSyncError, + MintFetchError, + ProofValidationError, + UnknownMintError, +} from '../models/Error'; import type { Mint } from '../models/Mint'; import type { Keyset } from '../models/Keyset'; import type { MintAdapter } from '../infra/MintAdapter'; @@ -8,9 +14,36 @@ import type { CoreEvents } from '../events/types'; import type { MintInfo } from '../types'; import type { Logger } from '../logging/Logger.ts'; import { normalizeMintUrl } from '../utils'; +import { DEFAULT_UNIT, normalizeUnit } from '../amounts.ts'; const MINT_REFRESH_TTL_S = 60 * 5; +export interface MethodUnitCapability { + supported: boolean; + disabled: boolean; + nut: 4 | 5; + method: string; + unit: string; + minAmount?: Amount | null; + maxAmount?: Amount | null; + options?: unknown; + legacySatAllowed?: boolean; + reason?: string; +} + +type NutMethodSetting = { + method: string; + unit: string; + min_amount?: AmountLike | null; + max_amount?: AmountLike | null; + options?: unknown; +}; + +type NutMethodSettings = { + methods?: NutMethodSetting[]; + disabled?: boolean; +}; + export class MintService { private readonly mintRepo: MintRepository; private readonly keysetRepo: KeysetRepository; @@ -152,6 +185,111 @@ export class MintService { return mint.mintInfo; } + async getMintMethodUnitCapability( + mintUrl: string, + nut: 4 | 5, + method: string, + unit: string, + ): Promise { + const normalizedMintUrl = normalizeMintUrl(mintUrl); + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + const mintInfo = await this.getMintInfo(normalizedMintUrl); + const settings = this.getNutMethodSettings(mintInfo, nut); + + if (!settings || !Array.isArray(settings.methods)) { + if (normalizedUnit === DEFAULT_UNIT) { + return { + supported: true, + disabled: false, + nut, + method, + unit: normalizedUnit, + legacySatAllowed: true, + reason: `NUT-${nut} method-unit metadata is missing; allowing legacy sat flow`, + }; + } + + return { + supported: false, + disabled: false, + nut, + method, + unit: normalizedUnit, + reason: `NUT-${nut} method-unit metadata is missing for unit ${normalizedUnit}`, + }; + } + + if (settings.disabled === true) { + return { + supported: false, + disabled: true, + nut, + method, + unit: normalizedUnit, + reason: `NUT-${nut} is disabled`, + }; + } + + const matchingMethod = settings.methods.find((entry) => { + try { + return entry.method === method && normalizeUnit(entry.unit) === normalizedUnit; + } catch { + return false; + } + }); + + if (!matchingMethod) { + return { + supported: false, + disabled: false, + nut, + method, + unit: normalizedUnit, + reason: `NUT-${nut} method ${method} does not support unit ${normalizedUnit}`, + }; + } + + return { + supported: true, + disabled: false, + nut, + method, + unit: normalizedUnit, + minAmount: this.parseOptionalAmount(matchingMethod.min_amount), + maxAmount: this.parseOptionalAmount(matchingMethod.max_amount), + options: matchingMethod.options, + }; + } + + async assertMintMethodUnitSupported( + mintUrl: string, + nut: 4 | 5, + method: string, + unit: string, + amount?: AmountLike, + ): Promise { + const capability = await this.getMintMethodUnitCapability(mintUrl, nut, method, unit); + if (!capability.supported) { + throw new ProofValidationError( + capability.reason ?? `NUT-${nut} method ${method} does not support unit ${capability.unit}`, + ); + } + + if (amount === undefined) return; + + const requestedAmount = Amount.from(amount); + if (capability.minAmount && requestedAmount.lessThan(capability.minAmount)) { + throw new ProofValidationError( + `NUT-${nut} method ${method} unit ${capability.unit} requires amount >= ${capability.minAmount}`, + ); + } + if (capability.maxAmount && requestedAmount.greaterThan(capability.maxAmount)) { + throw new ProofValidationError( + `NUT-${nut} method ${method} unit ${capability.unit} requires amount <= ${capability.maxAmount}`, + ); + } + } + async getAllMints(): Promise { const mints = await this.mintRepo.getAllMints(); return mints; @@ -178,6 +316,15 @@ export class MintService { await this.eventBus?.emit('mint:updated', await this.ensureUpdatedMint(mintUrl)); } + private getNutMethodSettings(mintInfo: MintInfo, nut: 4 | 5): NutMethodSettings | undefined { + const nuts = mintInfo.nuts as Record | undefined; + return nuts?.[String(nut)] as NutMethodSettings | undefined; + } + + private parseOptionalAmount(amount: AmountLike | null | undefined): Amount | null { + return amount === undefined || amount === null ? null : Amount.from(amount); + } + private async updateMint(mint: Mint): Promise<{ mint: Mint; keysets: Keyset[] }> { let mintInfo; try { diff --git a/packages/core/services/PaymentRequestService.ts b/packages/core/services/PaymentRequestService.ts index 60b5a089..4eba8e21 100644 --- a/packages/core/services/PaymentRequestService.ts +++ b/packages/core/services/PaymentRequestService.ts @@ -4,7 +4,6 @@ import { JSONInt, PaymentRequest, PaymentRequestTransportType, - type AmountLike, type Token, } from '@cashu/cashu-ts'; import { PaymentRequestError } from '../models/Error'; @@ -14,6 +13,7 @@ import type { PreparedSendOperation, SendOperationService, } from '../operations/send'; +import { DEFAULT_UNIT, parseUnitAmount, normalizeUnit, type UnitAmountLike } from '../amounts.ts'; type InbandPaymentRequestTransport = { type: 'inband' }; type HttpPaymentRequestTransport = { type: 'http'; url: string }; @@ -24,6 +24,7 @@ type ResolvedPaymentRequest = { payableMints: string[]; allowedMints: string[]; amount?: Amount; + unit: string; transport: PaymentRequestTransport; }; @@ -87,13 +88,15 @@ export class PaymentRequestService { async parse(paymentRequest: string): Promise { const decodedPaymentRequest = await this.readPaymentRequest(paymentRequest); const transport = this.getPaymentRequestTransport(decodedPaymentRequest); - const payableMints = await this.findMatchingMints(decodedPaymentRequest); + const unit = normalizeUnit(decodedPaymentRequest.unit, { defaultUnit: DEFAULT_UNIT }); + const payableMints = await this.findMatchingMints(decodedPaymentRequest, unit); const allowedMints = decodedPaymentRequest.mints ?? []; return { paymentRequest: decodedPaymentRequest, payableMints, allowedMints, amount: decodedPaymentRequest.amount, + unit, transport, }; } @@ -103,14 +106,17 @@ export class PaymentRequestService { */ async prepare( request: ResolvedPaymentRequest, - options: { mintUrl: string; amount?: AmountLike }, + options: { mintUrl: string; amount?: UnitAmountLike }, ): Promise { const { mintUrl, amount } = options; this.validateMint(mintUrl, request.allowedMints); const finalAmount = this.validateAmount(request, amount); const preparedRequest = await this.resolvePreparedRequest(request, finalAmount); this.logger?.debug('Preparing payment request transaction', { mintUrl, amount: finalAmount }); - const initSend = await this.sendOperationService.init(mintUrl, finalAmount); + const initSend = await this.sendOperationService.init(mintUrl, { + amount: finalAmount, + unit: preparedRequest.unit, + }); const preparedSend = await this.sendOperationService.prepare(initSend); this.logger?.debug('Payment request transaction prepared', { mintUrl, amount: finalAmount }); return { sendOperation: preparedSend, request: preparedRequest }; @@ -200,8 +206,12 @@ export class PaymentRequestService { ); } - private async findMatchingMints(paymentRequest: PaymentRequest): Promise { - const balances = await this.proofService.getBalancesByMint({ trustedOnly: true }); + private async findMatchingMints(paymentRequest: PaymentRequest, unit: string): Promise { + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + const balances = await this.proofService.getBalancesByMint({ + trustedOnly: true, + units: [normalizedUnit], + }); const amount = paymentRequest.amount ?? Amount.zero(); const mintRequirement = paymentRequest.mints; const matchingMints: string[] = []; @@ -216,8 +226,14 @@ export class PaymentRequestService { return matchingMints; } - private validateAmount(request: ResolvedPaymentRequest, amount?: AmountLike): Amount { - const providedAmount = amount === undefined ? undefined : Amount.from(amount); + private validateAmount(request: ResolvedPaymentRequest, amount?: UnitAmountLike): Amount { + const providedAmount = + amount === undefined + ? undefined + : parseUnitAmount(amount, { + defaultUnit: request.unit, + explicitUnit: request.unit, + }).amount; if (request.amount && providedAmount && !request.amount.equals(providedAmount)) { throw new PaymentRequestError( `Amount mismatch: request specifies ${request.amount} but ${providedAmount} was provided`, @@ -242,17 +258,18 @@ export class PaymentRequestService { request.paymentRequest.transport, request.paymentRequest.id, amount, - request.paymentRequest.unit, + request.unit, request.paymentRequest.mints, request.paymentRequest.description, request.paymentRequest.singleUse, request.paymentRequest.nut10, ); - const payableMints = await this.findMatchingMints(paymentRequest); + const payableMints = await this.findMatchingMints(paymentRequest, request.unit); return { ...request, amount, + unit: request.unit, payableMints, paymentRequest, }; diff --git a/packages/core/services/ProofService.ts b/packages/core/services/ProofService.ts index a0bf0691..a0536dde 100644 --- a/packages/core/services/ProofService.ts +++ b/packages/core/services/ProofService.ts @@ -14,9 +14,12 @@ import type { BalanceSnapshot, BalancesBreakdownByMint, BalancesByMint, + BalancesByMintAndUnit, + BalancesByUnit, CoreProof, } from '../types'; import type { CounterService } from './CounterService'; +import type { ProofUnitFilter } from '../repositories'; import type { ProofRepository } from '../repositories'; import { EventBus } from '../events/EventBus'; import type { CoreEvents } from '../events/types'; @@ -26,6 +29,7 @@ import type { MintService } from './MintService'; import type { Logger } from '../logging/Logger.ts'; import type { SeedService } from './SeedService.ts'; import type { KeyRingService } from './KeyRingService.ts'; +import { DEFAULT_UNIT, assertSameUnit, normalizeUnit, normalizeUnitList } from '../amounts.ts'; import { deserializeOutputData, mapProofToCoreProof, @@ -42,6 +46,24 @@ function countBlankOutputsForAmount(amount: Amount): number { return Math.max((value - 1n).toString(2).length, 1); } +type ProofSelectionOptions = { + unit?: string; + includeFees?: boolean; +}; + +function normalizeProofSelectionOptions(options?: boolean | ProofSelectionOptions): { + unit: string; + includeFees: boolean; +} { + if (typeof options === 'boolean') { + return { unit: DEFAULT_UNIT, includeFees: options }; + } + return { + unit: normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }), + includeFees: options?.includeFees ?? true, + }; +} + export class ProofService { private readonly counterService: CounterService; private readonly proofRepository: ProofRepository; @@ -75,9 +97,16 @@ export class ProofService { * Calculates the send amount including receiver fees. * This is used when the sender pays fees for the receiver. */ - async calculateSendAmountWithFees(mintUrl: string, sendAmount: AmountLike): Promise { - const { wallet, keys, keysetId } = - await this.walletService.getWalletWithActiveKeysetId(mintUrl); + async calculateSendAmountWithFees( + mintUrl: string, + sendAmount: AmountLike, + options?: { unit?: string }, + ): Promise { + const unit = normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }); + const { wallet, keys, keysetId } = await this.walletService.getWalletWithActiveKeysetId( + mintUrl, + unit, + ); const requestedSendAmount = toAmount(sendAmount); // Split the send amount to determine number of outputs let denominations = splitAmount(requestedSendAmount, keys.keys); @@ -105,25 +134,35 @@ export class ProofService { if (inflightProofs.length === 0) { return; } - const batchedByMint: { [mintUrl: string]: CoreProof[] } = {}; + const batchedByMintAndUnit = new Map< + string, + { mintUrl: string; unit: string; proofs: CoreProof[] } + >(); for (const proof of inflightProofs) { const mintUrl = proof.mintUrl; if (!mintUrl) continue; - const batch = batchedByMint[mintUrl] ?? (batchedByMint[mintUrl] = []); - batch.push(proof); + const unit = normalizeUnit(proof.unit, { defaultUnit: DEFAULT_UNIT }); + const batchKey = `${mintUrl}::${unit}`; + const batch = + batchedByMintAndUnit.get(batchKey) ?? + (() => { + const next = { mintUrl, unit, proofs: [] }; + batchedByMintAndUnit.set(batchKey, next); + return next; + })(); + batch.proofs.push(proof); } - const mintUrls = Object.keys(batchedByMint); - for (const mintUrl of mintUrls) { - const proofs = batchedByMint[mintUrl]; + for (const { mintUrl, unit, proofs } of batchedByMintAndUnit.values()) { if (!proofs || proofs.length === 0) { continue; } this.logger?.debug('Checking inflight proofs for mint', { mintUrl, + unit, count: proofs.length, }); try { - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl, unit); const proofStates = await wallet.checkProofsStates(proofs); if (!Array.isArray(proofStates) || proofStates.length !== proofs.length) { this.logger?.warn('Malformed proof state check response', { @@ -143,12 +182,14 @@ export class ProofService { await this.setProofState(mintUrl, spentSecrets, 'spent'); this.logger?.info('Marked inflight proofs as spent after check', { mintUrl, + unit, count: spentSecrets.length, }); } } catch (error) { this.logger?.warn('Failed to check inflight proofs for mint', { mintUrl, + unit, error, }); } @@ -158,11 +199,12 @@ export class ProofService { async createOutputsAndIncrementCounters( mintUrl: string, amount: { keep: AmountLike; send: AmountLike }, - options?: { includeFees?: boolean }, + options?: { includeFees?: boolean; unit?: string }, ): Promise<{ keep: OutputData[]; send: OutputData[]; sendAmount: Amount; keepAmount: Amount }> { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); } + const unit = normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }); let requestedKeep: Amount; let requestedSend: Amount; try { @@ -171,8 +213,10 @@ export class ProofService { } catch { return { keep: [], send: [], sendAmount: Amount.zero(), keepAmount: Amount.zero() }; } - const { wallet, keys, keysetId } = - await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const { wallet, keys, keysetId } = await this.walletService.getWalletWithActiveKeysetId( + mintUrl, + unit, + ); const seed = await this.seedService.getSeed(); const currentCounter = await this.counterService.getCounter(mintUrl, keys.id); const data: { keep: OutputData[]; send: OutputData[] } = { keep: [], send: [] }; @@ -181,7 +225,7 @@ export class ProofService { let sendAmount = requestedSend; let keepAmount = requestedKeep; if (options?.includeFees && !requestedSend.isZero()) { - sendAmount = await this.calculateSendAmountWithFees(mintUrl, requestedSend); + sendAmount = await this.calculateSendAmountWithFees(mintUrl, requestedSend, { unit }); const feeAmount = sendAmount.subtract(requestedSend); // Adjust keep amount: if send increases due to fees, keep decreases keepAmount = requestedKeep.greaterThanOrEqual(feeAmount) @@ -189,6 +233,7 @@ export class ProofService { : Amount.zero(); this.logger?.debug('Fee calculation for send amount', { mintUrl, + unit, originalSendAmount: requestedSend.toString(), originalKeepAmount: requestedKeep.toString(), feeAmount: feeAmount.toString(), @@ -221,6 +266,7 @@ export class ProofService { } this.logger?.debug('Deterministic outputs created', { mintUrl, + unit, keysetId: keys.id, amount, outputs: data.keep.length + data.send.length, @@ -234,7 +280,11 @@ export class ProofService { } if (!Array.isArray(proofs) || proofs.length === 0) return; - const groupedByKeyset = this.groupProofsByKeysetId(proofs); + const normalizedProofs = proofs.map((proof) => ({ + ...proof, + unit: normalizeUnit((proof as { unit?: string }).unit), + })); + const groupedByKeyset = this.groupProofsByKeysetId(normalizedProofs); const entries = Array.from(groupedByKeyset.entries()); const tasks = entries.map(([keysetId, group]) => @@ -278,12 +328,12 @@ export class ProofService { } } - async getReadyProofs(mintUrl: string): Promise { - return this.proofRepository.getReadyProofs(mintUrl); + async getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + return this.proofRepository.getReadyProofs(mintUrl, filter); } - async getAllReadyProofs(): Promise { - return this.proofRepository.getAllReadyProofs(); + async getAllReadyProofs(filter?: ProofUnitFilter): Promise { + return this.proofRepository.getAllReadyProofs(filter); } /** @@ -352,29 +402,51 @@ export class ProofService { * @returns An object mapping mint URLs to their balances */ async getBalancesByMint(scope?: BalanceQuery): Promise { + const unit = this.getSingleBalanceUnit(scope, 'getBalancesByMint'); + const balancesByMintAndUnit = await this.getBalancesByMintAndUnit({ + ...scope, + units: [unit], + }); + + return Object.fromEntries( + Object.entries(balancesByMintAndUnit).map(([mintUrl, balancesByUnit]) => [ + mintUrl, + balancesByUnit[unit] ?? this.emptyBalanceSnapshot(unit), + ]), + ); + } + + async getBalancesByMintAndUnit(scope?: BalanceQuery): Promise { const requestedMintUrls = scope?.mintUrls ? Array.from(new Set(scope.mintUrls)) : undefined; + const requestedUnits = normalizeUnitList(scope?.units); + if (requestedUnits && requestedUnits.length === 0) { + return {}; + } const trustedMintUrls = scope?.trustedOnly ? new Set((await this.mintService.getAllTrustedMints()).map((mint) => mint.mintUrl)) : undefined; - const balances: BalancesByMint = {}; + const balances: BalancesByMintAndUnit = {}; const scopedMintUrls = requestedMintUrls?.filter( (mintUrl) => !trustedMintUrls || trustedMintUrls.has(mintUrl), ); + const proofFilter = requestedUnits ? { units: requestedUnits } : undefined; const proofs = scopedMintUrls ? ( await Promise.all( - scopedMintUrls.map((mintUrl) => this.proofRepository.getReadyProofs(mintUrl)), + scopedMintUrls.map((mintUrl) => + this.proofRepository.getReadyProofs(mintUrl, proofFilter), + ), ) ).flat() : trustedMintUrls ? ( await Promise.all( Array.from(trustedMintUrls).map((mintUrl) => - this.proofRepository.getReadyProofs(mintUrl), + this.proofRepository.getReadyProofs(mintUrl, proofFilter), ), ) ).flat() - : await this.getAllReadyProofs(); + : await this.getAllReadyProofs(proofFilter); for (const proof of proofs) { const mintUrl = proof.mintUrl; @@ -382,19 +454,24 @@ export class ProofService { continue; } - const balance = balances[mintUrl] || this.emptyBalanceSnapshot(); + const unit = normalizeUnit(proof.unit, { defaultUnit: DEFAULT_UNIT }); + const balancesForMint = balances[mintUrl] ?? (balances[mintUrl] = {}); + const balance = balancesForMint[unit] || this.emptyBalanceSnapshot(unit); if (proof.usedByOperationId) { balance.reserved = balance.reserved.add(proof.amount); } else { balance.spendable = balance.spendable.add(proof.amount); } balance.total = balance.spendable.add(balance.reserved); - balances[mintUrl] = balance; + balancesForMint[unit] = balance; } - if (scopedMintUrls) { + if (scopedMintUrls && requestedUnits) { for (const mintUrl of scopedMintUrls) { - balances[mintUrl] ??= this.emptyBalanceSnapshot(); + const balancesForMint = balances[mintUrl] ?? (balances[mintUrl] = {}); + for (const unit of requestedUnits) { + balancesForMint[unit] ??= this.emptyBalanceSnapshot(unit); + } } } @@ -406,17 +483,50 @@ export class ProofService { * @returns A single balance snapshot with spendable, reserved, and total amounts */ async getBalanceTotal(scope?: BalanceQuery): Promise { + const unit = this.getSingleBalanceUnit(scope, 'getBalanceTotal'); const balances = await this.getBalancesByMint(scope); return Object.values(balances).reduce( (total, balance) => ({ spendable: total.spendable.add(balance.spendable), reserved: total.reserved.add(balance.reserved), total: total.total.add(balance.total), + unit, }), - this.emptyBalanceSnapshot(), + this.emptyBalanceSnapshot(unit), ); } + async getBalancesByUnit(scope?: BalanceQuery): Promise { + return this.getBalanceTotalByUnit(scope); + } + + async getBalanceTotalByUnit(scope?: BalanceQuery): Promise { + const requestedUnits = normalizeUnitList(scope?.units); + if (requestedUnits && requestedUnits.length === 0) { + return {}; + } + const balancesByMintAndUnit = await this.getBalancesByMintAndUnit(scope); + const totals: BalancesByUnit = {}; + + for (const balancesByUnit of Object.values(balancesByMintAndUnit)) { + for (const [unit, balance] of Object.entries(balancesByUnit)) { + const total = totals[unit] ?? this.emptyBalanceSnapshot(unit); + total.spendable = total.spendable.add(balance.spendable); + total.reserved = total.reserved.add(balance.reserved); + total.total = total.total.add(balance.total); + totals[unit] = total; + } + } + + if (requestedUnits) { + for (const unit of requestedUnits) { + totals[unit] ??= this.emptyBalanceSnapshot(unit); + } + } + + return totals; + } + /** * Gets balance breakdowns for all mints. * @returns An object mapping mint URLs to their balance breakdowns @@ -467,8 +577,27 @@ export class ProofService { ); } - private emptyBalanceSnapshot(): BalanceSnapshot { - return { spendable: Amount.zero(), reserved: Amount.zero(), total: Amount.zero() }; + private getSingleBalanceUnit(scope: BalanceQuery | undefined, caller: string): string { + const units = normalizeUnitList(scope?.units); + if (!units || units.length === 0) { + return DEFAULT_UNIT; + } + if (units.length > 1) { + throw new ProofValidationError( + `${caller} cannot aggregate multiple units; use getBalanceTotalByUnit or getBalancesByMintAndUnit`, + ); + } + return units[0]!; + } + + private emptyBalanceSnapshot(unit = DEFAULT_UNIT): BalanceSnapshot { + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + return { + spendable: Amount.zero(), + reserved: Amount.zero(), + total: Amount.zero(), + unit: normalizedUnit, + }; } private snapshotToBreakdown(balance: BalanceSnapshot): BalanceBreakdown { @@ -508,7 +637,8 @@ export class ProofService { mintUrl: string, secrets: string[], operationId: string, - ): Promise<{ amount: Amount }> { + options?: { unit?: string }, + ): Promise<{ amount: Amount; unit: string }> { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); } @@ -516,30 +646,50 @@ export class ProofService { throw new ProofValidationError('operationId is required'); } if (!secrets || secrets.length === 0) { - return { amount: Amount.zero() }; + return { + amount: Amount.zero(), + unit: normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }), + }; + } + + const proofsToReserve = await this.proofRepository.getProofsBySecrets(mintUrl, secrets); + const proofUnits = Array.from( + new Set( + proofsToReserve.map((proof) => normalizeUnit(proof.unit, { defaultUnit: DEFAULT_UNIT })), + ), + ); + if (proofUnits.length > 1) { + throw new ProofValidationError('Cannot reserve proofs across multiple units'); + } + const unit = normalizeUnit(options?.unit ?? proofUnits[0], { defaultUnit: DEFAULT_UNIT }); + for (const proofUnit of proofUnits) { + assertSameUnit(proofUnit, unit, 'Proof reservation'); } // Repository will validate proofs are ready and not already reserved await this.proofRepository.reserveProofs(mintUrl, secrets, operationId); - // Calculate the reserved amount for the event - const reservedProofs = await this.proofRepository.getProofsByOperationId(mintUrl, operationId); + // Calculate the reserved amount from the exact input proofs, not other proofs associated + // with the operation as outputs. + const reservedProofs = await this.proofRepository.getProofsBySecrets(mintUrl, secrets); const amount = sumProofs(reservedProofs); await this.eventBus?.emit('proofs:reserved', { mintUrl, + unit, operationId, secrets, amount, }); this.logger?.debug('Proofs reserved', { mintUrl, + unit, operationId, count: secrets.length, amount, }); - return { amount }; + return { amount, unit }; } /** @@ -614,18 +764,20 @@ export class ProofService { async selectProofsToSend( mintUrl: string, amount: AmountLike, - includeFees: boolean = true, + options: boolean | ProofSelectionOptions = true, ): Promise { - const proofs = await this.proofRepository.getAvailableProofs(mintUrl); + const { unit, includeFees } = normalizeProofSelectionOptions(options); + const proofs = await this.proofRepository.getAvailableProofs(mintUrl, { unit }); const requestedAmount = toAmount(amount); const totalAmount = sumProofs(proofs); if (totalAmount.lessThan(requestedAmount)) { throw new ProofValidationError('Not enough proofs to send'); } - const wallet = await this.walletService.getWallet(mintUrl); + const wallet = await this.walletService.getWallet(mintUrl, unit); const selectedProofs = wallet.selectProofsToSend(proofs, requestedAmount, includeFees); this.logger?.debug('Selected proofs to send', { mintUrl, + unit, amount: requestedAmount.toString(), selectedProofs, count: selectedProofs.send.length, @@ -636,6 +788,7 @@ export class ProofService { const map = new Map(); for (const proof of proofs) { if (!proof.secret) throw new ProofValidationError('Proof missing secret'); + normalizeUnit((proof as { unit?: string }).unit); const keysetId = proof.id; if (!keysetId || keysetId.trim().length === 0) { throw new ProofValidationError('Proof missing keyset id'); @@ -650,8 +803,12 @@ export class ProofService { return map; } - async getProofsByKeysetId(mintUrl: string, keysetId: string): Promise { - return this.proofRepository.getProofsByKeysetId(mintUrl, keysetId); + async getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise { + return this.proofRepository.getProofsByKeysetId(mintUrl, keysetId, filter); } async hasProofsForKeyset(mintUrl: string, keysetId: string): Promise { @@ -742,9 +899,14 @@ export class ProofService { return preparedProofs; } - async createBlankOutputs(amount: AmountLike, mintUrl: string): Promise { + async createBlankOutputs( + amount: AmountLike, + mintUrl: string, + options?: { unit?: string }, + ): Promise { + const unit = normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }); const requestedAmount = toAmount(amount); - const { keys } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const { keys } = await this.walletService.getWalletWithActiveKeysetId(mintUrl, unit); if (requestedAmount.isZero()) { return []; } @@ -782,11 +944,12 @@ export class ProofService { mintUrl: string, outputData: OutputData[], changeSignatures: SerializedBlindedSignature[], - options?: { createdByOperationId?: string }, + options?: { unit?: string; createdByOperationId?: string }, ): Promise { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); } + const unit = normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }); if ( !outputData || outputData.length === 0 || @@ -813,6 +976,11 @@ export class ProofService { this.logger?.warn('Failed to create change proof', { reason, index: i }); return []; } + assertSameUnit( + normalizeUnit(keyset.unit, { defaultUnit: DEFAULT_UNIT }), + unit, + 'Change proof keyset', + ); return [output.toProof(sig, { id: keyset.id, keys: keyset.keypairs as Keys })]; }); @@ -822,6 +990,7 @@ export class ProofService { // Map to CoreProof and save const coreProofs = mapProofToCoreProof(mintUrl, 'ready', proofs, { + unit, createdByOperationId: options?.createdByOperationId, }); @@ -829,6 +998,7 @@ export class ProofService { this.logger?.info('Change proofs unblinded and saved', { mintUrl, + unit, count: coreProofs.length, operationId: options?.createdByOperationId, }); @@ -851,7 +1021,7 @@ export class ProofService { async recoverProofsFromOutputData( mintUrl: string, serializedOutputData: SerializedOutputData, - options?: { createdByOperationId?: string; persistRecoveredProofs?: boolean }, + options?: { unit?: string; createdByOperationId?: string; persistRecoveredProofs?: boolean }, ): Promise { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); @@ -860,7 +1030,8 @@ export class ProofService { throw new ProofValidationError('serializedOutputData is required'); } - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const unit = normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl, unit); // Deserialize OutputData const outputData = deserializeOutputData(serializedOutputData); @@ -917,6 +1088,7 @@ export class ProofService { await this.saveProofs( mintUrl, mapProofToCoreProof(mintUrl, 'ready', unspentProofs, { + unit, createdByOperationId: options?.createdByOperationId, }), ); @@ -924,6 +1096,7 @@ export class ProofService { this.logger?.info('Recovered proofs from output data', { mintUrl, + unit, totalRestored: restoredProofs.length, unspentCount: unspentProofs.length, spentCount: restoredProofs.length - unspentProofs.length, diff --git a/packages/core/services/TokenService.ts b/packages/core/services/TokenService.ts index 11fe9c5e..2a52f0a2 100644 --- a/packages/core/services/TokenService.ts +++ b/packages/core/services/TokenService.ts @@ -3,6 +3,7 @@ import type { Logger } from '../logging/Logger'; import type { MintService } from './MintService'; import { getDecodedToken, type Token } from '@cashu/cashu-ts'; import { ProofValidationError, TokenValidationError } from '../models/Error'; +import { DEFAULT_UNIT, assertSameUnit, normalizeUnit } from '../amounts.ts'; export class TokenService { private readonly mintService: MintService; @@ -18,7 +19,7 @@ export class TokenService { * @param mintUrl - The URL of the mint to use for fetching keysets for decoding * @returns The decoded Token object with proofs decoded using the mint's keysets */ - async decodeToken(token: Token | string, mintUrl: string): Promise { + async decodeToken(token: Token | string, mintUrl: string, expectedUnit?: string): Promise { if (!token) { this.logger?.warn('No token provided for decoding', { token }); throw new TokenValidationError('Token is required'); @@ -46,11 +47,238 @@ export class TokenService { try { const keysetIds = mintKeysets.map((keyset) => keyset.id); - return typeof token === 'string' ? getDecodedToken(token, keysetIds) : token; + const decoded = typeof token === 'string' ? getDecodedToken(token, keysetIds) : token; + const decodedForUnitResolution = + typeof token === 'string' && !encodedTokenHasExplicitUnit(token) + ? { ...decoded, unit: undefined } + : decoded; + const unit = this.resolveTokenUnit(decodedForUnitResolution, mintKeysets, expectedUnit); + return { ...decoded, unit }; } catch (err) { const errMsg = err instanceof Error ? err.message : 'Unknown error during token decoding'; this.logger?.warn('Failed to decode token', { token, mintUrl, err: errMsg }); throw new ProofValidationError(errMsg); } } + + private resolveTokenUnit(token: Token, keysets: Keyset[], expectedUnit?: string): string { + const keysetUnits = new Map( + keysets.map((keyset) => [ + keyset.id, + normalizeUnit(keyset.unit || DEFAULT_UNIT, { defaultUnit: DEFAULT_UNIT }), + ]), + ); + const resolvedProofUnits = token.proofs + .map((proof) => keysetUnits.get(proof.id)) + .filter((unit): unit is string => unit !== undefined); + const uniqueProofUnits = Array.from(new Set(resolvedProofUnits)); + + if (uniqueProofUnits.length > 1) { + throw new TokenValidationError( + `Token contains proofs from multiple units: ${uniqueProofUnits.join(', ')}`, + ); + } + + const tokenUnit = + token.unit === undefined || token.unit === null + ? undefined + : normalizeUnit(token.unit, { defaultUnit: DEFAULT_UNIT }); + const resolvedUnit = tokenUnit ?? uniqueProofUnits[0] ?? DEFAULT_UNIT; + + if (tokenUnit && uniqueProofUnits[0]) { + assertSameUnit(uniqueProofUnits[0], tokenUnit, 'Token proof keysets'); + } + if (expectedUnit !== undefined) { + assertSameUnit(resolvedUnit, expectedUnit, 'Token'); + } + + return resolvedUnit; + } +} + +function encodedTokenHasExplicitUnit(token: string): boolean { + try { + const payload = stripCashuTokenPrefix(token); + const version = payload.slice(0, 1); + const body = payload.slice(1); + + if (version === 'A') { + const json = new TextDecoder().decode(decodeBase64Url(body)); + const decoded = JSON.parse(json) as Record; + return Object.prototype.hasOwnProperty.call(decoded, 'unit'); + } + + if (version === 'B') { + return cborMapHasTextKey(decodeBase64Url(body), 'u'); + } + + return true; + } catch { + return true; + } +} + +function stripCashuTokenPrefix(token: string): string { + for (const prefix of ['web+cashu://', 'cashu://', 'cashu:']) { + if (token.startsWith(prefix)) { + return stripCashuTokenPrefix(token.slice(prefix.length)); + } + } + return token.startsWith('cashu') ? token.slice(5) : token; +} + +function decodeBase64Url(input: string): Uint8Array { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + let buffer = 0; + let bits = 0; + const bytes: number[] = []; + + for (const char of normalized) { + if (char === '=') { + break; + } + const value = BASE64_ALPHABET.indexOf(char); + if (value < 0) { + throw new Error('Invalid base64url character'); + } + buffer = (buffer << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + + return Uint8Array.from(bytes); +} + +function cborMapHasTextKey(bytes: Uint8Array, expectedKey: string): boolean { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const header = readCborHeader(view, 0); + if (header.majorType !== 5) { + return false; + } + + let offset = header.nextOffset; + for (let index = 0; index < header.length; index++) { + const key = readCborTextItem(view, offset); + offset = key.nextOffset; + if (key.value === expectedKey) { + return true; + } + offset = skipCborItem(view, offset); + } + return false; +} + +function readCborTextItem(view: DataView, offset: number): { value?: string; nextOffset: number } { + const header = readCborHeader(view, offset); + if (header.majorType !== 3) { + return { nextOffset: skipCborItem(view, offset) }; + } + + const start = header.nextOffset; + const end = start + header.length; + if (end > view.byteLength) { + throw new Error('Unexpected end of CBOR text item'); + } + return { + value: new TextDecoder().decode( + new Uint8Array(view.buffer, view.byteOffset + start, header.length), + ), + nextOffset: end, + }; +} + +function skipCborItem(view: DataView, offset: number): number { + const header = readCborHeader(view, offset); + + if (header.majorType === 0 || header.majorType === 1 || header.majorType === 7) { + return header.nextOffset; + } + + if (header.majorType === 2 || header.majorType === 3) { + const nextOffset = header.nextOffset + header.length; + if (nextOffset > view.byteLength) { + throw new Error('Unexpected end of CBOR byte/text item'); + } + return nextOffset; + } + + if (header.majorType === 4) { + let nextOffset = header.nextOffset; + for (let index = 0; index < header.length; index++) { + nextOffset = skipCborItem(view, nextOffset); + } + return nextOffset; + } + + if (header.majorType === 5) { + let nextOffset = header.nextOffset; + for (let index = 0; index < header.length; index++) { + nextOffset = skipCborItem(view, nextOffset); + nextOffset = skipCborItem(view, nextOffset); + } + return nextOffset; + } + + if (header.majorType === 6) { + return skipCborItem(view, header.nextOffset); + } + + throw new Error(`Unsupported CBOR major type: ${header.majorType}`); +} + +function readCborHeader( + view: DataView, + offset: number, +): { majorType: number; length: number; nextOffset: number } { + if (offset >= view.byteLength) { + throw new Error('Unexpected end of CBOR data'); + } + + const initialByte = view.getUint8(offset); + const majorType = initialByte >> 5; + const additionalInfo = initialByte & 0x1f; + const length = readCborLength(view, offset + 1, additionalInfo); + return { majorType, length: length.value, nextOffset: length.nextOffset }; +} + +function readCborLength( + view: DataView, + offset: number, + additionalInfo: number, +): { value: number; nextOffset: number } { + if (additionalInfo < 24) { + return { value: additionalInfo, nextOffset: offset }; + } + if (additionalInfo === 24) { + ensureCborBytes(view, offset, 1); + return { value: view.getUint8(offset), nextOffset: offset + 1 }; + } + if (additionalInfo === 25) { + ensureCborBytes(view, offset, 2); + return { value: view.getUint16(offset), nextOffset: offset + 2 }; + } + if (additionalInfo === 26) { + ensureCborBytes(view, offset, 4); + return { value: view.getUint32(offset), nextOffset: offset + 4 }; + } + if (additionalInfo === 27) { + ensureCborBytes(view, offset, 8); + const value = view.getBigUint64(offset); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('CBOR value exceeds safe integer range'); + } + return { value: Number(value), nextOffset: offset + 8 }; + } + throw new Error('Unsupported indefinite-length CBOR item'); } + +function ensureCborBytes(view: DataView, offset: number, length: number): void { + if (offset + length > view.byteLength) { + throw new Error('Unexpected end of CBOR data'); + } +} + +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; diff --git a/packages/core/services/WalletRestoreService.ts b/packages/core/services/WalletRestoreService.ts index 3be315d5..37f3674d 100644 --- a/packages/core/services/WalletRestoreService.ts +++ b/packages/core/services/WalletRestoreService.ts @@ -5,6 +5,7 @@ import type { CounterService } from './CounterService'; import type { Logger } from '../logging/Logger.ts'; import type { WalletService } from './WalletService.ts'; import type { MintRequestProvider } from '../infra/MintRequestProvider.ts'; +import { DEFAULT_UNIT, normalizeUnit } from '../amounts.ts'; export class WalletRestoreService { private readonly proofService: ProofService; @@ -32,12 +33,22 @@ export class WalletRestoreService { this.logger = logger; } - async sweepKeyset(mintUrl: string, keysetId: string, bip39seed: Uint8Array): Promise { + async sweepKeyset( + mintUrl: string, + keysetId: string, + bip39seed: Uint8Array, + unit = DEFAULT_UNIT, + ): Promise { + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); this.logger?.debug('Sweeping keyset', { mintUrl, keysetId }); - const { wallet } = await this.walletService.getWalletWithActiveKeysetId(mintUrl); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + mintUrl, + normalizedUnit, + ); const requestFn = this.requestProvider.getRequestFn(mintUrl); const sweepWallet = new Wallet(new Mint(mintUrl, { customRequest: requestFn }), { bip39seed, + unit: normalizedUnit, }); await sweepWallet.loadMint(); @@ -121,10 +132,16 @@ export class WalletRestoreService { total: sweepTotalAmount, }); - const outputResults = await this.proofService.createOutputsAndIncrementCounters(mintUrl, { + const outputAmounts = { keep: 0, send: sweepTotalAmount, - }); + }; + const outputResults = + normalizedUnit === DEFAULT_UNIT + ? await this.proofService.createOutputsAndIncrementCounters(mintUrl, outputAmounts) + : await this.proofService.createOutputsAndIncrementCounters(mintUrl, outputAmounts, { + unit: normalizedUnit, + }); const outputConfig: OutputConfig = { send: { type: 'custom', data: outputResults.send }, keep: { type: 'custom', data: outputResults.keep }, @@ -137,7 +154,12 @@ export class WalletRestoreService { ); await this.proofService.saveProofs( mintUrl, - mapProofToCoreProof(mintUrl, 'ready', [...keep, ...send]), + mapProofToCoreProof( + mintUrl, + 'ready', + [...keep, ...send], + normalizedUnit === DEFAULT_UNIT ? undefined : { unit: normalizedUnit }, + ), ); this.logger?.info('Keyset sweep completed', { @@ -155,7 +177,13 @@ export class WalletRestoreService { * Enforces the invariant: restored proofs must be >= previously stored proofs. * Throws on any validation or persistence error. No transactions are used here. */ - async restoreKeyset(mintUrl: string, wallet: Wallet, keysetId: string): Promise { + async restoreKeyset( + mintUrl: string, + wallet: Wallet, + keysetId: string, + unit = DEFAULT_UNIT, + ): Promise { + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); this.logger?.debug('Restoring keyset', { mintUrl, keysetId }); const oldProofs = await this.proofService.getProofsByKeysetId(mintUrl, keysetId); this.logger?.debug('Existing proofs before restore', { @@ -236,7 +264,12 @@ export class WalletRestoreService { await this.proofService.saveProofs( mintUrl, - mapProofToCoreProof(mintUrl, 'ready', checkedProofs.ready), + mapProofToCoreProof( + mintUrl, + 'ready', + checkedProofs.ready, + normalizedUnit === DEFAULT_UNIT ? undefined : { unit: normalizedUnit }, + ), ); this.logger?.info('Saved restored proofs for keyset', { mintUrl, diff --git a/packages/core/services/WalletService.ts b/packages/core/services/WalletService.ts index 62d8b48c..20736d0a 100644 --- a/packages/core/services/WalletService.ts +++ b/packages/core/services/WalletService.ts @@ -11,15 +11,14 @@ import type { MintService } from './MintService'; import type { Logger } from '../logging/Logger.ts'; import type { SeedService } from './SeedService.ts'; import type { MintRequestProvider } from '../infra/MintRequestProvider.ts'; +import { DEFAULT_UNIT, normalizeUnit } from '../amounts.ts'; +import { normalizeMintUrl } from '../utils.ts'; interface CachedWallet { wallet: Wallet; lastCheck: number; } -//TODO: Allow dynamic units at some point -const DEFAULT_UNIT = 'sat'; - export class WalletService { private walletCache: Map = new Map(); private readonly CACHE_TTL = 5 * 60 * 1000; @@ -44,58 +43,102 @@ export class WalletService { this.authProviderGetter = authProviderGetter; } - async getWallet(mintUrl: string): Promise { + async getWallet(mintUrl: string, unit = DEFAULT_UNIT): Promise { if (!mintUrl || mintUrl.trim().length === 0) { throw new Error('mintUrl is required'); } + const normalizedMintUrl = normalizeMintUrl(mintUrl); + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + const cacheKey = this.getWalletCacheKey(normalizedMintUrl, normalizedUnit); + // Serve from cache when fresh - const cached = this.walletCache.get(mintUrl); + const cached = this.walletCache.get(cacheKey); const now = Date.now(); if (cached && now - cached.lastCheck < this.CACHE_TTL) { - this.logger?.debug('Wallet served from cache', { mintUrl }); + this.logger?.debug('Wallet served from cache', { + mintUrl: normalizedMintUrl, + unit: normalizedUnit, + }); return cached.wallet; } // De-duplicate concurrent requests per mintUrl - const existing = this.inFlight.get(mintUrl); + const existing = this.inFlight.get(cacheKey); if (existing) return existing; - const promise = this.buildWallet(mintUrl).finally(() => { - this.inFlight.delete(mintUrl); + const promise = this.buildWallet(normalizedMintUrl, normalizedUnit).finally(() => { + this.inFlight.delete(cacheKey); }); - this.inFlight.set(mintUrl, promise); + this.inFlight.set(cacheKey, promise); return promise; } - async getWalletWithActiveKeysetId(mintUrl: string): Promise<{ + async getWalletWithActiveKeysetId( + mintUrl: string, + unit = DEFAULT_UNIT, + ): Promise<{ wallet: Wallet; keysetId: string; keyset: MintKeyset; keys: MintKeys; + unit: string; }> { - const wallet = await this.getWallet(mintUrl); + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + const wallet = await this.getWallet(mintUrl, normalizedUnit); const keyset = wallet.keyChain.getCheapestKeyset(); const mintKeys = keyset.toMintKeys(); + const mintKeyset = keyset.toMintKeyset(); if (mintKeys === null) { throw new Error('MintKeys is null. Cannot return a valid response.'); } + const keysetUnit = this.normalizeKeysetUnit(mintKeyset.unit); + if (keysetUnit !== normalizedUnit) { + throw new Error( + `Active keyset ${keyset.id} unit ${keysetUnit} does not match requested unit ${normalizedUnit}`, + ); + } + return { wallet, keysetId: keyset.id, - keyset: keyset.toMintKeyset(), + keyset: mintKeyset, keys: mintKeys, + unit: normalizedUnit, }; } /** * Clear cached wallet for a specific mint URL */ - clearCache(mintUrl: string): void { - this.walletCache.delete(mintUrl); - this.logger?.debug('Wallet cache cleared', { mintUrl }); + clearCache(mintUrl: string, unit?: string): void { + const normalizedMintUrl = normalizeMintUrl(mintUrl); + if (unit !== undefined) { + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + const cacheKey = this.getWalletCacheKey(normalizedMintUrl, normalizedUnit); + this.walletCache.delete(cacheKey); + this.inFlight.delete(cacheKey); + this.logger?.debug('Wallet cache cleared', { + mintUrl: normalizedMintUrl, + unit: normalizedUnit, + }); + return; + } + + const prefix = `${normalizedMintUrl}::`; + for (const key of this.walletCache.keys()) { + if (key.startsWith(prefix)) { + this.walletCache.delete(key); + } + } + for (const key of this.inFlight.keys()) { + if (key.startsWith(prefix)) { + this.inFlight.delete(key); + } + } + this.logger?.debug('Wallet cache cleared', { mintUrl: normalizedMintUrl }); } /** @@ -103,34 +146,50 @@ export class WalletService { */ clearAllCaches(): void { this.walletCache.clear(); + this.inFlight.clear(); this.logger?.debug('All wallet caches cleared'); } /** * Force refresh mint data and get fresh wallet */ - async refreshWallet(mintUrl: string): Promise { - this.clearCache(mintUrl); - this.inFlight.delete(mintUrl); - await this.mintService.updateMintData(mintUrl); - return this.getWallet(mintUrl); + async refreshWallet(mintUrl: string, unit = DEFAULT_UNIT): Promise { + const normalizedMintUrl = normalizeMintUrl(mintUrl); + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + this.clearCache(normalizedMintUrl, normalizedUnit); + await this.mintService.updateMintData(normalizedMintUrl); + return this.getWallet(normalizedMintUrl, normalizedUnit); } - private async buildWallet(mintUrl: string): Promise { - const { mint, keysets } = await this.mintService.ensureUpdatedMint(mintUrl); + private getWalletCacheKey(mintUrl: string, unit: string): string { + return `${normalizeMintUrl(mintUrl)}::${normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT })}`; + } + + private normalizeKeysetUnit(unit?: string | null): string { + return normalizeUnit(unit || DEFAULT_UNIT, { defaultUnit: DEFAULT_UNIT }); + } + + private async buildWallet(mintUrl: string, unit = DEFAULT_UNIT): Promise { + const normalizedMintUrl = normalizeMintUrl(mintUrl); + const normalizedUnit = normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }); + const { mint, keysets } = await this.mintService.ensureUpdatedMint(normalizedMintUrl); const validKeysets = keysets.filter( (keyset) => - keyset.keypairs && Object.keys(keyset.keypairs).length > 0 && keyset.unit === DEFAULT_UNIT, + keyset.keypairs && + Object.keys(keyset.keypairs).length > 0 && + this.normalizeKeysetUnit(keyset.unit) === normalizedUnit, ); if (validKeysets.length === 0) { - throw new Error(`No valid keysets found for mint ${mintUrl}`); + throw new Error( + `No valid keysets found for mint ${normalizedMintUrl} and unit ${normalizedUnit}`, + ); } const keysetCache = validKeysets.map((keyset) => ({ id: keyset.id, - unit: keyset.unit, + unit: this.normalizeKeysetUnit(keyset.unit), active: keyset.active, input_fee_ppk: keyset.feePpk, keys: keyset.keypairs as Keys, @@ -143,23 +202,30 @@ export class WalletService { const seed = await this.seedService.getSeed(); - const requestFn = this.requestProvider.getRequestFn(mintUrl); - const authProvider = this.authProviderGetter?.(mintUrl); - const wallet = new Wallet(new Mint(mintUrl, { customRequest: requestFn, authProvider }), { - unit: DEFAULT_UNIT, - // @ts-ignore - logger: - this.logger && this.logger.child ? this.logger.child({ module: 'Wallet' }) : undefined, - bip39seed: seed, - }); + const requestFn = this.requestProvider.getRequestFn(normalizedMintUrl); + const authProvider = this.authProviderGetter?.(normalizedMintUrl); + const wallet = new Wallet( + new Mint(normalizedMintUrl, { customRequest: requestFn, authProvider }), + { + unit: normalizedUnit, + // @ts-ignore + logger: + this.logger && this.logger.child ? this.logger.child({ module: 'Wallet' }) : undefined, + bip39seed: seed, + }, + ); wallet.loadMintFromCache(mint.mintInfo, cache); - this.walletCache.set(mintUrl, { + this.walletCache.set(this.getWalletCacheKey(normalizedMintUrl, normalizedUnit), { wallet, lastCheck: Date.now(), }); - this.logger?.info('Wallet built', { mintUrl, keysetCount: validKeysets.length }); + this.logger?.info('Wallet built', { + mintUrl: normalizedMintUrl, + unit: normalizedUnit, + keysetCount: validKeysets.length, + }); return wallet; } } diff --git a/packages/core/test/unit/DefaultSendHandler.test.ts b/packages/core/test/unit/DefaultSendHandler.test.ts index d1cda2a8..c9419122 100644 --- a/packages/core/test/unit/DefaultSendHandler.test.ts +++ b/packages/core/test/unit/DefaultSendHandler.test.ts @@ -54,6 +54,7 @@ describe('DefaultSendHandler', () => { id: keysetId, secret, mintUrl, + unit: 'sat', state: 'ready', ...overrides, }) as CoreProof; @@ -81,6 +82,7 @@ describe('DefaultSendHandler', () => { createdAt: Date.now() - 10000, updatedAt: Date.now() - 10000, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePreparedOp = ( @@ -101,6 +103,7 @@ describe('DefaultSendHandler', () => { inputProofSecrets: ['input-1', 'input-2'], outputData: createMockOutputData(['keep-1'], ['send-1']), ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeExecutingOp = ( @@ -110,6 +113,7 @@ describe('DefaultSendHandler', () => { ...makePreparedOp(id), state: 'executing', ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePendingOp = ( @@ -119,6 +123,7 @@ describe('DefaultSendHandler', () => { ...makePreparedOp(id), state: 'pending', ...overrides, + unit: overrides?.unit ?? 'sat', }); beforeEach(() => { @@ -170,8 +175,14 @@ describe('DefaultSendHandler', () => { proofService = { selectProofsToSend: mock( - async (selectedMintUrl: string, amount: Amount, includeFees = true) => { + async ( + selectedMintUrl: string, + amount: Amount, + options: boolean | { includeFees?: boolean } = true, + ) => { const proofs = await proofRepository.getAvailableProofs(selectedMintUrl); + const includeFees = + typeof options === 'boolean' ? options : (options.includeFees ?? true); return mockWallet.selectProofsToSend(proofs, amount, includeFees).send; }, ), @@ -286,7 +297,9 @@ describe('DefaultSendHandler', () => { expect(result.outputData).toBe(undefined); expect(result.inputProofSecrets).toEqual(['proof-100']); expect(proofService.createOutputsAndIncrementCounters).not.toHaveBeenCalled(); - expect(proofService.reserveProofs).toHaveBeenCalledWith(mintUrl, ['proof-100'], 'op-exact'); + expect(proofService.reserveProofs).toHaveBeenCalledWith(mintUrl, ['proof-100'], 'op-exact', { + unit: 'sat', + }); }); it('prepares a swap send when no exact match exists', async () => { @@ -299,16 +312,29 @@ describe('DefaultSendHandler', () => { expect(result.needsSwap).toBe(true); expect(result.fee).toEqual(Amount.from(1)); expect(result.outputData).toBeDefined(); - expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith(mintUrl, { - keep: Amount.from(9), - send: Amount.from(100), - }); + expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith( + mintUrl, + { + keep: Amount.from(9), + send: Amount.from(100), + }, + { unit: 'sat' }, + ); }); it('throws ProofValidationError when selected proofs do not cover fees', async () => { (proofService.selectProofsToSend as Mock).mockImplementation( - (_mintUrl: string, _amount: Amount, includeFees = true) => - Promise.resolve(includeFees ? [makeProof('input-1', 60), makeProof('input-2', 40)] : []), + ( + _mintUrl: string, + _amount: Amount, + options: boolean | { includeFees?: boolean } = true, + ) => { + const includeFees = + typeof options === 'boolean' ? options : (options.includeFees ?? true); + return Promise.resolve( + includeFees ? [makeProof('input-1', 60), makeProof('input-2', 40)] : [], + ); + }, ); await expect( @@ -383,6 +409,26 @@ describe('DefaultSendHandler', () => { 'spent', ); }); + + it('encodes custom-unit swap sends and replacement proofs with the operation unit', async () => { + const operation = makeExecutingOp('op-usd', { unit: 'usd' }); + const inputProofs = [makeProof('input-1', 60), makeProof('input-2', 50)]; + + const result = await handler.execute(buildExecuteContext(operation, inputProofs)); + + expect(result.status).toBe('PENDING'); + if (result.status === 'PENDING') { + expect(result.token?.unit).toBe('usd'); + expect(result.pending.unit).toBe('usd'); + } + expect(proofService.saveProofs).toHaveBeenCalledWith( + mintUrl, + expect.arrayContaining([ + expect.objectContaining({ secret: 'keep-1', unit: 'usd', state: 'ready' }), + expect.objectContaining({ secret: 'send-1', unit: 'usd', state: 'inflight' }), + ]), + ); + }); }); describe('finalize', () => { @@ -423,10 +469,14 @@ describe('DefaultSendHandler', () => { const receiveArgs = (mockWallet.receive as Mock).mock.calls[0]; - expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith(mintUrl, { - keep: Amount.from(99), - send: 0, - }); + expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith( + mintUrl, + { + keep: Amount.from(99), + send: 0, + }, + { unit: 'sat' }, + ); expect(receiveArgs).toBeDefined(); expect( receiveArgs?.some( @@ -486,6 +536,10 @@ describe('DefaultSendHandler', () => { expect(proofService.recoverProofsFromOutputData).toHaveBeenCalledWith( mintUrl, operation.outputData, + { + createdByOperationId: 'op-recover-swap', + unit: 'sat', + }, ); expect(proofService.setProofState).toHaveBeenCalledWith(mintUrl, ['input-1'], 'spent'); if (result.status === 'FAILED') { diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index b183dee4..a5e28972 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -67,6 +67,7 @@ describe('MeltBolt11Handler', () => { id: keysetId, secret, mintUrl, + unit: 'sat', state: 'ready', ...overrides, }) as CoreProof; @@ -116,6 +117,7 @@ describe('MeltBolt11Handler', () => { createdAt: Date.now() - 10000, updatedAt: Date.now() - 10000, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePreparedOp = ( @@ -130,7 +132,6 @@ describe('MeltBolt11Handler', () => { createdAt: Date.now() - 10000, updatedAt: Date.now() - 10000, quoteId: 'quote-123', - unit: 'sat', amount: Amount.from(100), fee_reserve: Amount.from(10), swap_fee: Amount.from(0), @@ -139,6 +140,7 @@ describe('MeltBolt11Handler', () => { inputProofSecrets: ['input-1', 'input-2'], changeOutputData: createMockOutputData(['change-1'], []), ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeExecutingOp = ( @@ -148,6 +150,7 @@ describe('MeltBolt11Handler', () => { ...makePreparedOp(id), state: 'executing', ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePendingOp = ( @@ -157,6 +160,7 @@ describe('MeltBolt11Handler', () => { ...makePreparedOp(id), state: 'pending', ...overrides, + unit: overrides?.unit ?? 'sat', }); // ============================================================================ @@ -378,12 +382,12 @@ describe('MeltBolt11Handler', () => { mintUrl, ['input-1', 'input-2'], 'op-1', + { unit: 'sat' }, ); - expect(proofService.selectProofsToSend).toHaveBeenCalledWith( - mintUrl, - Amount.from(110), - true, - ); + expect(proofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, Amount.from(110), { + unit: 'sat', + includeFees: true, + }); }); it('should select enough proofs to cover melt input fees', async () => { @@ -391,12 +395,15 @@ describe('MeltBolt11Handler', () => { const ctx = buildPrepareContext(operation); (proofService.selectProofsToSend as Mock).mockImplementation( - (_mintUrl: string, _amount: number, includeFees: boolean) => - Promise.resolve( + (_mintUrl: string, _amount: number, options: boolean | { includeFees?: boolean }) => { + const includeFees = + typeof options === 'boolean' ? options : (options.includeFees ?? true); + return Promise.resolve( includeFees ? [makeProof('input-1', 60), makeProof('input-2', 50), makeProof('input-3', 1)] : [makeProof('input-1', 60), makeProof('input-2', 50)], - ), + ); + }, ); const result = await handler.prepare(ctx); @@ -404,11 +411,10 @@ describe('MeltBolt11Handler', () => { expect(result.needsSwap).toBe(false); expect(result.inputAmount).toEqual(Amount.from(111)); expect(result.inputProofSecrets).toEqual(['input-1', 'input-2', 'input-3']); - expect(proofService.selectProofsToSend).toHaveBeenCalledWith( - mintUrl, - Amount.from(110), - true, - ); + expect(proofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, Amount.from(110), { + unit: 'sat', + includeFees: true, + }); }); it('should throw ProofValidationError when selected proofs do not cover fees', async () => { @@ -438,7 +444,56 @@ describe('MeltBolt11Handler', () => { await handler.prepare(ctx); // Change = 120 - 100 = 20 - expect(proofService.createBlankOutputs).toHaveBeenCalledWith(Amount.from(20), mintUrl); + expect(proofService.createBlankOutputs).toHaveBeenCalledWith(Amount.from(20), mintUrl, { + unit: 'sat', + }); + }); + + it('prepares custom-unit direct melts with unit-scoped selection and change outputs', async () => { + const operation = makeInitOp('op-usd', { unit: 'usd' }); + const ctx = buildPrepareContext(operation); + (mockWallet.createMeltQuoteBolt11 as Mock).mockResolvedValueOnce({ + quote: 'quote-usd', + amount: Amount.from(100), + fee_reserve: Amount.from(10), + unit: 'USD', + }); + (proofService.selectProofsToSend as Mock).mockResolvedValueOnce([ + makeProof('usd-input-1', 60), + makeProof('usd-input-2', 60), + ]); + + const result = await handler.prepare(ctx); + + expect(result.unit).toBe('usd'); + expect(result.quoteId).toBe('quote-usd'); + expect(proofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, Amount.from(110), { + unit: 'usd', + includeFees: true, + }); + expect(proofService.reserveProofs).toHaveBeenCalledWith( + mintUrl, + ['usd-input-1', 'usd-input-2'], + 'op-usd', + { unit: 'usd' }, + ); + expect(proofService.createBlankOutputs).toHaveBeenCalledWith(Amount.from(20), mintUrl, { + unit: 'usd', + }); + }); + + it('rejects melt quote unit mismatches before selecting proofs', async () => { + const operation = makeInitOp('op-usd', { unit: 'usd' }); + const ctx = buildPrepareContext(operation); + (mockWallet.createMeltQuoteBolt11 as Mock).mockResolvedValueOnce({ + quote: 'quote-sat', + amount: Amount.from(100), + fee_reserve: Amount.from(10), + unit: 'sat', + }); + + await expect(handler.prepare(ctx)).rejects.toThrow('Unit mismatch'); + expect(proofService.selectProofsToSend).not.toHaveBeenCalled(); }); it('should pass amountless invoice amount to cashu-ts in millisatoshis', async () => { @@ -480,12 +535,12 @@ describe('MeltBolt11Handler', () => { expect((proofService.selectProofsToSend as Mock).mock.calls[0]).toEqual([ mintUrl, Amount.from(110), - true, + { unit: 'sat', includeFees: true }, ]); expect((proofService.selectProofsToSend as Mock).mock.calls[1]).toEqual([ mintUrl, Amount.from(110), - true, + { unit: 'sat', includeFees: true }, ]); }); @@ -503,6 +558,7 @@ describe('MeltBolt11Handler', () => { mintUrl, ['input-1', 'input-2'], 'op-1', + { unit: 'sat' }, ); }); @@ -1146,6 +1202,10 @@ describe('MeltBolt11Handler', () => { expect(proofService.recoverProofsFromOutputData).toHaveBeenCalledWith( mintUrl, swapOutputData, + { + unit: 'sat', + createdByOperationId: 'op-1', + }, ); expect(proofService.setProofState).toHaveBeenCalledWith(mintUrl, ['input-1'], 'spent'); }); diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index dd8074b0..3b4621ad 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -55,6 +55,7 @@ describe('MeltOperationService', () => { id: keysetId, secret, mintUrl, + unit: 'sat', state: 'ready', ...overrides, }) as CoreProof; @@ -68,6 +69,7 @@ describe('MeltOperationService', () => { createdAt: Date.now() - 1000, updatedAt: Date.now() - 1000, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePreparedOp = ( @@ -77,7 +79,6 @@ describe('MeltOperationService', () => { ...makeInitOp(id), state: 'prepared', quoteId: 'quote-1', - unit: 'sat', amount: Amount.from(100), fee_reserve: Amount.from(1), swap_fee: Amount.from(0), @@ -86,6 +87,7 @@ describe('MeltOperationService', () => { inputProofSecrets: ['proof-1'], changeOutputData: { keep: [], send: [] }, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeExecutingOp = ( @@ -95,6 +97,7 @@ describe('MeltOperationService', () => { ...makePreparedOp(id), state: 'executing', ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePendingOp = ( @@ -104,6 +107,7 @@ describe('MeltOperationService', () => { ...makePreparedOp(id), state: 'pending', ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeFinalizedOp = ( @@ -116,6 +120,7 @@ describe('MeltOperationService', () => { effectiveFee: Amount.from(1), finalizedData: { preimage: 'preimage-123' }, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeLegacyFinalizedOp = (id: string): FinalizedMeltOperation => ({ @@ -131,6 +136,7 @@ describe('MeltOperationService', () => { state: 'rolled_back', error: 'Rolled back', ...overrides, + unit: overrides?.unit ?? 'sat', }); beforeEach(() => { @@ -142,6 +148,7 @@ describe('MeltOperationService', () => { prepare: mock(async ({ operation }) => makePreparedOp(operation.id, { mintUrl: operation.mintUrl, + unit: operation.unit, method: operation.method, methodData: operation.methodData, }), @@ -150,6 +157,7 @@ describe('MeltOperationService', () => { status: 'PAID', finalized: makeFinalizedOp(operation.id, { mintUrl: operation.mintUrl, + unit: operation.unit, method: operation.method, methodData: operation.methodData, }), @@ -183,6 +191,7 @@ describe('MeltOperationService', () => { mintService = { isTrustedMint: mock(async () => true), + assertMintMethodUnitSupported: mock(async () => {}), } as unknown as MintService; walletService = { @@ -220,6 +229,14 @@ describe('MeltOperationService', () => { expect(stored?.mintUrl).toBe(mintUrl); }); + it('normalizes and persists custom-unit init operations', async () => { + const operation = await service.init(mintUrl, 'bolt11', { invoice }, 'USD'); + + expect(operation.unit).toBe('usd'); + const stored = await meltOperationRepository.getById(operation.id); + expect(stored?.unit).toBe('usd'); + }); + it('normalizes AmountLike amountSats before storing the operation', async () => { const operation = await service.init(mintUrl, 'bolt11', { invoice, amountSats: 1n }); @@ -257,6 +274,34 @@ describe('MeltOperationService', () => { expect(stored?.state).toBe('prepared'); }); + it('validates NUT-05 support and uses the operation unit wallet', async () => { + const initOp = makeInitOp('op-usd', { unit: 'usd' }); + await meltOperationRepository.create(initOp); + + const prepared = await service.prepare('op-usd'); + + expect(prepared.unit).toBe('usd'); + expect(mintService.assertMintMethodUnitSupported).toHaveBeenCalledWith( + mintUrl, + 5, + 'bolt11', + 'usd', + ); + expect(walletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); + }); + + it('rejects non-sat melts when NUT-05 capability validation rejects the unit', async () => { + const initOp = makeInitOp('op-usd-rejected', { unit: 'usd' }); + await meltOperationRepository.create(initOp); + (mintService.assertMintMethodUnitSupported as Mock).mockRejectedValueOnce( + new ProofValidationError('Mint does not advertise NUT-05 support for bolt11/usd'), + ); + + await expect(service.prepare('op-usd-rejected')).rejects.toThrow(ProofValidationError); + expect(handler.prepare).not.toHaveBeenCalled(); + expect(await meltOperationRepository.getById('op-usd-rejected')).toBeNull(); + }); + it('recovers init operation when handler fails', async () => { const initOp = makeInitOp('op-2'); await meltOperationRepository.create(initOp); diff --git a/packages/core/test/unit/MeltOpsApi.test.ts b/packages/core/test/unit/MeltOpsApi.test.ts index d0725625..46779842 100644 --- a/packages/core/test/unit/MeltOpsApi.test.ts +++ b/packages/core/test/unit/MeltOpsApi.test.ts @@ -93,6 +93,24 @@ describe('MeltOpsApi', () => { expect(result).toBe(preparedOperation); }); + it('prepare passes non-sat units to the service', async () => { + await api.prepare({ + mintUrl, + method: 'bolt11', + methodData: { invoice: 'lnbc1test' }, + unit: 'USD', + }); + + expect(meltOperationService.init).toHaveBeenCalledWith( + mintUrl, + 'bolt11', + { + invoice: 'lnbc1test', + }, + 'USD', + ); + }); + it('execute resolves ids before executing', async () => { const result = await api.execute(preparedOperation.id); diff --git a/packages/core/test/unit/MeltQuoteService.test.ts b/packages/core/test/unit/MeltQuoteService.test.ts index 5d18c95d..b7366e88 100644 --- a/packages/core/test/unit/MeltQuoteService.test.ts +++ b/packages/core/test/unit/MeltQuoteService.test.ts @@ -42,6 +42,7 @@ describe('MeltQuoteService.payMeltQuote', () => { mockMintService = { isTrustedMint: mock(() => Promise.resolve(true)), + assertMintMethodUnitSupported: mock(() => Promise.resolve()), } as any; mockMeltQuoteRepo = { @@ -93,6 +94,39 @@ describe('MeltQuoteService.payMeltQuote', () => { ); }); + it('creates custom-unit melt quotes with a unit-scoped wallet and persisted unit', async () => { + const quote = { + quote: quoteId, + amount: Amount.from(100), + fee_reserve: Amount.from(10), + request: 'lnbc110...', + unit: 'USD', + state: 'PENDING' as const, + expiry: new Date(Date.now() + 1000 * 60 * 60 * 24).getTime(), + }; + const createMeltQuoteBolt11 = mock(async () => quote); + const addMeltQuote = mock(async () => {}); + mockMeltQuoteRepo.addMeltQuote = addMeltQuote; + mockWalletService.getWalletWithActiveKeysetId = mock(async () => ({ + wallet: { createMeltQuoteBolt11 }, + keysetId: 'keyset-1', + keyset: { id: 'keyset-1', unit: 'usd', active: true }, + keys: { id: 'keyset-1', unit: 'usd', keys: { '1': 'pubkey' } as any }, + })) as any; + + const result = await service.createMeltQuote(mintUrl, 'lnbc110...', { unit: 'USD' }); + + expect(mockMintService.assertMintMethodUnitSupported).toHaveBeenCalledWith( + mintUrl, + 5, + 'bolt11', + 'usd', + ); + expect(mockWalletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); + expect(result.unit).toBe('usd'); + expect(addMeltQuote).toHaveBeenCalledWith(expect.objectContaining({ mintUrl, unit: 'usd' })); + }); + it('should skip send/swap when selected proofs sum to exact amount', async () => { const quote: MeltQuote = { quote: quoteId, @@ -133,7 +167,9 @@ describe('MeltQuoteService.payMeltQuote', () => { await service.payMeltQuote(mintUrl, quoteId); // Verify selectProofsToSend was called with correct amount (before fees) - expect(mockProofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, exactAmount); + expect(mockProofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, exactAmount, { + unit: 'sat', + }); // Verify getFeesForProofs was called to calculate input fees expect(getFeesForProofsSpy).toHaveBeenCalledWith(selectedProofs); @@ -219,13 +255,16 @@ describe('MeltQuoteService.payMeltQuote', () => { await service.payMeltQuote(mintUrl, quoteId); // Verify selectProofsToSend was called - expect(mockProofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, amountWithFee); + expect(mockProofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, amountWithFee, { + unit: 'sat', + }); // Verify createBlankOutputs was called with the correct amount // sendAmount ( quote.amount = 100 + quote.fee_reserve = 10) - quote.amount = 100 expect(createBlanksSpy).toHaveBeenCalledWith( Amount.from(10), // 100 + 10 - 100 mintUrl, + { unit: 'sat' }, ); // Verify createOutputsAndIncrementCounters was called with includeFees option // selectedAmount = 150, quote.amount = 100, quote.fee_reserve = 10, swapFees = 0 @@ -237,7 +276,7 @@ describe('MeltQuoteService.payMeltQuote', () => { keep: Amount.from(40), // selectedAmount - quote.amount - quote.fee_reserve - swapFees send: Amount.from(110), // quote.amount + quote.fee_reserve }, - { includeFees: true }, + { includeFees: true, unit: 'sat' }, ); // Verify wallet.send was called with sendAmount from outputData (includes receiver fees) @@ -372,4 +411,61 @@ describe('MeltQuoteService.payMeltQuote', () => { // Verify no swap was performed expect(mockProofService.createOutputsAndIncrementCounters).not.toHaveBeenCalled(); }); + + it('pays custom-unit melt quotes without falling back to sat', async () => { + const quote: MeltQuote = { + quote: quoteId, + amount: Amount.from(100), + fee_reserve: Amount.from(10), + request: 'lnbc110...', + unit: 'USD', + mintUrl, + state: 'PENDING', + expiry: new Date(Date.now() + 1000 * 60 * 60 * 24).getTime(), + payment_preimage: 'payment_preimage', + }; + const exactAmount = quote.amount.add(quote.fee_reserve); + const selectedProofs = [makeProof(110, 'secret-usd')]; + + mockMeltQuoteRepo.getMeltQuote = mock(async () => quote); + mockProofService.selectProofsToSend = mock(async () => selectedProofs); + const setProofStateSpy = mock(async () => {}); + mockProofService.setProofState = setProofStateSpy; + const saveProofsSpy = mock(async () => {}); + mockProofService.saveProofs = saveProofsSpy; + const meltProofsBolt11Spy = mock(async () => ({ change: [makeProof(1, 'change-usd')] })); + const wallet = { + meltProofsBolt11: meltProofsBolt11Spy, + getFeesForProofs: mock(() => Amount.zero()), + }; + mockWalletService.getWalletWithActiveKeysetId = mock(async () => ({ + wallet, + keysetId: 'keyset-1', + keyset: { id: 'keyset-1', unit: 'usd', active: true }, + keys: { id: 'keyset-1', unit: 'usd', keys: { '1': 'pubkey' } as any }, + })) as any; + + await service.payMeltQuote(mintUrl, quoteId, { unit: 'usd' }); + + expect(mockMintService.assertMintMethodUnitSupported).toHaveBeenCalledWith( + mintUrl, + 5, + 'bolt11', + 'usd', + ); + expect(mockWalletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); + expect(mockProofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, exactAmount, { + unit: 'usd', + }); + expect(meltProofsBolt11Spy).toHaveBeenCalledWith( + expect.objectContaining({ unit: 'usd' }), + selectedProofs, + ); + expect(saveProofsSpy).toHaveBeenCalledWith( + mintUrl, + expect.arrayContaining([expect.objectContaining({ unit: 'usd', secret: 'change-usd' })]), + ); + expect(setProofStateSpy).toHaveBeenNthCalledWith(1, mintUrl, ['secret-usd'], 'inflight'); + expect(setProofStateSpy).toHaveBeenNthCalledWith(2, mintUrl, ['secret-usd'], 'spent'); + }); }); diff --git a/packages/core/test/unit/MemoryProofRepository.test.ts b/packages/core/test/unit/MemoryProofRepository.test.ts index 0ee38cf4..1b723249 100644 --- a/packages/core/test/unit/MemoryProofRepository.test.ts +++ b/packages/core/test/unit/MemoryProofRepository.test.ts @@ -11,6 +11,7 @@ describe('MemoryProofRepository', () => { const makeProof = (secret: string, selectedMintUrl = mintUrl): CoreProof => ({ id: 'keyset-1', + unit: 'sat', amount: Amount.from(1), secret, C: `C_${secret}`, @@ -38,4 +39,30 @@ describe('MemoryProofRepository', () => { expect(proofs).toEqual([]); }); + + it('requires proofs to carry a unit', async () => { + const proof = makeProof('missing-unit') as unknown as Omit; + delete (proof as { unit?: string }).unit; + + await expect(repository.saveProofs(mintUrl, [proof as CoreProof])).rejects.toThrow( + 'Unit is required', + ); + }); + + it('filters ready and available proofs by unit', async () => { + await repository.saveProofs(mintUrl, [ + makeProof('sat-1'), + { ...makeProof('usd-1'), unit: 'usd', amount: Amount.from(2) }, + { ...makeProof('USD-2'), unit: 'USD', amount: Amount.from(3) }, + ]); + + const readyUsd = await repository.getReadyProofs(mintUrl, { unit: 'USD' }); + const availableSat = await repository.getAvailableProofs(mintUrl, { unit: 'sat' }); + const allUsd = await repository.getAllReadyProofs({ units: ['usd'] }); + + expect(readyUsd.map((proof) => proof.secret).sort()).toEqual(['USD-2', 'usd-1']); + expect(readyUsd.every((proof) => proof.unit === 'usd')).toBe(true); + expect(availableSat.map((proof) => proof.secret)).toEqual(['sat-1']); + expect(allUsd).toHaveLength(2); + }); }); diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index 7388c33d..d477c845 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -189,6 +189,20 @@ describe('MintBolt11Handler', () => { expect(result.quoteId).toBe(importedQuote.quote); expect(result.lastObservedRemoteState).toBe('UNPAID'); }); + + it('normalizes quote unit comparison and persists the operation unit', async () => { + const usdOperation = { ...operation, unit: 'usd' }; + const usdQuote = { ...quote, unit: 'USD' }; + (wallet.createMintQuoteBolt11 as Mock).mockImplementation(async () => usdQuote); + + const result = await handler.prepare({ + ...buildPrepareContext(), + operation: usdOperation, + }); + + expect(result.unit).toBe('usd'); + expect(result.quoteId).toBe(quoteId); + }); }); describe('checkPending', () => { diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index b9797a43..7f03dd01 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -71,6 +71,7 @@ describe('MintOperationService', () => { secret, C: `C_${secret}`, mintUrl, + unit: 'sat', state: 'ready', createdByOperationId: operationId, }); @@ -156,6 +157,7 @@ describe('MintOperationService', () => { mintService = { isTrustedMint: mock(async () => true), + assertMintMethodUnitSupported: mock(async () => {}), } as unknown as MintService; walletService = { @@ -241,7 +243,7 @@ describe('MintOperationService', () => { expect(importedOperation?.lastObservedRemoteState).toBe(importedQuote.state); }); - it('importQuote rejects unsupported quote units', async () => { + it('importQuote delegates unsupported quote units to capability validation', async () => { const importedQuote: MintQuoteBolt11Response = { quote: 'quote-usd', request: 'lnbc1imported', @@ -250,9 +252,12 @@ describe('MintOperationService', () => { expiry: Math.floor(Date.now() / 1000) + 3600, state: 'PAID', }; + (mintService.assertMintMethodUnitSupported as Mock).mockRejectedValueOnce( + new Error('Mint https://mint.test does not advertise NUT-04 support for bolt11/usd'), + ); await expect(service.importQuote(mintUrl, importedQuote, 'bolt11', {})).rejects.toThrow( - "Unsupported mint unit 'usd'. Only 'sat' is currently supported.", + 'does not advertise NUT-04 support for bolt11/usd', ); expect(handler.prepare).not.toHaveBeenCalled(); diff --git a/packages/core/test/unit/MintOpsApi.test.ts b/packages/core/test/unit/MintOpsApi.test.ts index 30b7657d..2f7648dd 100644 --- a/packages/core/test/unit/MintOpsApi.test.ts +++ b/packages/core/test/unit/MintOpsApi.test.ts @@ -108,7 +108,7 @@ describe('MintOpsApi', () => { expect(mintOperationService.prepareNewQuote).toHaveBeenCalledWith( mintUrl, - Amount.from(10), + { amount: Amount.from(10), unit: 'sat' }, 'sat', 'bolt11', {}, @@ -125,7 +125,7 @@ describe('MintOpsApi', () => { expect(mintOperationService.prepareNewQuote).toHaveBeenCalledWith( mintUrl, - Amount.from(10), + { amount: Amount.from(10), unit: 'sat' }, 'sat', 'bolt11', {}, @@ -133,18 +133,22 @@ describe('MintOpsApi', () => { expect(result).toBe(pendingOperation); }); - it('prepare rejects non-sat units before delegating to the service', async () => { - await expect( - api.prepare({ - mintUrl, - amount: Amount.from(10), - unit: 'usd' as 'sat', - method: 'bolt11', - methodData: {}, - }), - ).rejects.toThrow("Unsupported mint unit 'usd'. Only 'sat' is currently supported."); - - expect(mintOperationService.prepareNewQuote).not.toHaveBeenCalled(); + it('prepare passes non-sat units to the service', async () => { + await api.prepare({ + mintUrl, + amount: Amount.from(10), + unit: 'usd', + method: 'bolt11', + methodData: {}, + }); + + expect(mintOperationService.prepareNewQuote).toHaveBeenCalledWith( + mintUrl, + { amount: Amount.from(10), unit: 'usd' }, + 'usd', + 'bolt11', + {}, + ); }); it('importQuote delegates to the mint operation service', async () => { @@ -170,20 +174,20 @@ describe('MintOpsApi', () => { expect(result).toBe(pendingOperation); }); - it('importQuote rejects non-sat quote units before delegating to the service', async () => { - await expect( - api.importQuote({ - mintUrl, - quote: { - ...quote, - unit: 'usd', - } as MintQuoteBolt11Response, - method: 'bolt11', - methodData: {}, - }), - ).rejects.toThrow("Unsupported mint unit 'usd'. Only 'sat' is currently supported."); - - expect(mintOperationService.importQuote).not.toHaveBeenCalled(); + it('importQuote passes non-sat quote units to the service', async () => { + const usdQuote = { + ...quote, + unit: 'usd', + } as MintQuoteBolt11Response; + + await api.importQuote({ + mintUrl, + quote: usdQuote, + method: 'bolt11', + methodData: {}, + }); + + expect(mintOperationService.importQuote).toHaveBeenCalledWith(mintUrl, usdQuote, 'bolt11', {}); }); it('execute only allows pending operations', async () => { diff --git a/packages/core/test/unit/MintService.test.ts b/packages/core/test/unit/MintService.test.ts index 9a4f851f..b95816b4 100644 --- a/packages/core/test/unit/MintService.test.ts +++ b/packages/core/test/unit/MintService.test.ts @@ -1,5 +1,6 @@ import { describe, it, beforeEach, expect, mock } from 'bun:test'; import { MintService } from '../../services/MintService'; +import { ProofValidationError } from '../../models/Error'; import { MemoryMintRepository } from '../../repositories/memory/MemoryMintRepository'; import { MemoryKeysetRepository } from '../../repositories/memory/MemoryKeysetRepository'; import { EventBus } from '../../events/EventBus'; @@ -277,6 +278,102 @@ describe('MintService', () => { }); }); + describe('method-unit capabilities', () => { + const mintInfoWithMethods = ( + methods4: Array<{ + method: string; + unit: string; + min_amount?: number; + max_amount?: number; + }> = [], + methods5 = methods4, + disabled4 = false, + disabled5 = false, + ): MintInfo => + ({ + ...mockMintInfo, + nuts: { + '4': { methods: methods4, disabled: disabled4 }, + '5': { methods: methods5, disabled: disabled5 }, + }, + }) as MintInfo; + + const useMintInfo = (mintInfo: MintInfo) => { + mockAdapter.fetchMintInfo = mock(() => Promise.resolve(mintInfo)); + }; + + it('allows legacy sat when NUT-04 metadata is missing', async () => { + useMintInfo({ ...mockMintInfo, nuts: {} } as MintInfo); + + const capability = await service.getMintMethodUnitCapability(testMintUrl, 4, 'bolt11', 'sat'); + + expect(capability.supported).toBe(true); + expect(capability.legacySatAllowed).toBe(true); + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'sat', 100), + ).resolves.toBeUndefined(); + }); + + it('rejects non-sat when NUT-04 metadata is missing', async () => { + useMintInfo({ ...mockMintInfo, nuts: {} } as MintInfo); + + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'usd', 100), + ).rejects.toThrow(ProofValidationError); + }); + + it('allows legacy sat when NUT-05 metadata is missing', async () => { + useMintInfo({ ...mockMintInfo, nuts: {} } as MintInfo); + + const capability = await service.getMintMethodUnitCapability(testMintUrl, 5, 'bolt11', 'sat'); + + expect(capability.supported).toBe(true); + expect(capability.legacySatAllowed).toBe(true); + }); + + it('rejects disabled NUT settings', async () => { + useMintInfo(mintInfoWithMethods([{ method: 'bolt11', unit: 'sat' }], undefined, true)); + + const capability = await service.getMintMethodUnitCapability(testMintUrl, 4, 'bolt11', 'sat'); + + expect(capability.supported).toBe(false); + expect(capability.disabled).toBe(true); + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'sat', 100), + ).rejects.toThrow(ProofValidationError); + }); + + it('requires a matching method and unit pair', async () => { + useMintInfo(mintInfoWithMethods([{ method: 'bolt11', unit: 'usd' }])); + + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'USD', 100), + ).resolves.toBeUndefined(); + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'sat', 100), + ).rejects.toThrow(ProofValidationError); + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt12', 'usd', 100), + ).rejects.toThrow(ProofValidationError); + }); + + it('enforces advertised min and max amounts', async () => { + useMintInfo( + mintInfoWithMethods([{ method: 'bolt11', unit: 'sat', min_amount: 10, max_amount: 100 }]), + ); + + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'sat', 9), + ).rejects.toThrow(ProofValidationError); + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'sat', 101), + ).rejects.toThrow(ProofValidationError); + await expect( + service.assertMintMethodUnitSupported(testMintUrl, 4, 'bolt11', 'sat', 100), + ).resolves.toBeUndefined(); + }); + }); + describe('updateMintData', () => { it('should update existing mint data', async () => { await service.addMintByUrl(testMintUrl); diff --git a/packages/core/test/unit/P2pkSendHandler.test.ts b/packages/core/test/unit/P2pkSendHandler.test.ts index acb393d2..21899ddc 100644 --- a/packages/core/test/unit/P2pkSendHandler.test.ts +++ b/packages/core/test/unit/P2pkSendHandler.test.ts @@ -59,6 +59,7 @@ describe('P2pkSendHandler', () => { id: keysetId, secret, mintUrl, + unit: 'sat', state: 'ready', ...overrides, }) as CoreProof; @@ -89,6 +90,7 @@ describe('P2pkSendHandler', () => { createdAt: Date.now() - 10000, updatedAt: Date.now() - 10000, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePreparedOp = ( @@ -109,6 +111,7 @@ describe('P2pkSendHandler', () => { inputProofSecrets: ['input-1', 'input-2'], outputData: createMockOutputData(['keep-1'], ['send-1']), ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeExecutingOp = ( @@ -118,6 +121,7 @@ describe('P2pkSendHandler', () => { ...makePreparedOp(id), state: 'executing', ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePendingOp = ( @@ -127,6 +131,7 @@ describe('P2pkSendHandler', () => { ...makePreparedOp(id), state: 'pending', ...overrides, + unit: overrides?.unit ?? 'sat', }); // ============================================================================ @@ -165,16 +170,24 @@ describe('P2pkSendHandler', () => { // Mock ProofService proofService = { - selectProofsToSend: mock(async (_mintUrl: string, amount: Amount, includeFees = true) => { - const proofs = await proofRepository.getAvailableProofs(mintUrl); - const totalAvailable = Amount.sum(proofs.map((proof) => proof.amount)); - if (totalAvailable.lessThan(amount)) { - throw new ProofValidationError( - `Insufficient balance: need ${amount}, have ${totalAvailable}`, - ); - } - return mockWallet.selectProofsToSend(proofs, amount, includeFees).send; - }), + selectProofsToSend: mock( + async ( + _mintUrl: string, + amount: Amount, + options: boolean | { includeFees?: boolean } = true, + ) => { + const includeFees = + typeof options === 'boolean' ? options : (options.includeFees ?? true); + const proofs = await proofRepository.getAvailableProofs(mintUrl); + const totalAvailable = Amount.sum(proofs.map((proof) => proof.amount)); + if (totalAvailable.lessThan(amount)) { + throw new ProofValidationError( + `Insufficient balance: need ${amount}, have ${totalAvailable}`, + ); + } + return mockWallet.selectProofsToSend(proofs, amount, includeFees).send; + }, + ), reserveProofs: mock(() => Promise.resolve({ amount: Amount.from(110) })), createOutputsAndIncrementCounters: mock(() => Promise.resolve({ @@ -342,6 +355,7 @@ describe('P2pkSendHandler', () => { mintUrl, ['input-1', 'input-2'], 'op-1', + { unit: 'sat' }, ); }); @@ -352,10 +366,40 @@ describe('P2pkSendHandler', () => { await handler.prepare(ctx); // Selected amount (110) - amount (100) - fee (1) = 9 keep - expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith(mintUrl, { - keep: Amount.from(9), - send: 0, + expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith( + mintUrl, + { + keep: Amount.from(9), + send: 0, + }, + { unit: 'sat' }, + ); + }); + + it('prepares custom-unit P2PK sends with unit-scoped proof selection and outputs', async () => { + const operation = makeInitOp('op-usd', { unit: 'usd' }); + + const result = await handler.prepare(buildPrepareContext(operation)); + + expect(result.unit).toBe('usd'); + expect(proofService.selectProofsToSend).toHaveBeenCalledWith(mintUrl, Amount.from(100), { + unit: 'usd', + includeFees: true, }); + expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith( + mintUrl, + { + keep: Amount.from(9), + send: 0, + }, + { unit: 'usd' }, + ); + expect(proofService.reserveProofs).toHaveBeenCalledWith( + mintUrl, + ['input-1', 'input-2'], + 'op-usd', + { unit: 'usd' }, + ); }); it('should throw ProofValidationError when selected proofs do not cover fees', async () => { @@ -686,7 +730,7 @@ describe('P2pkSendHandler', () => { (proofService.recoverProofsFromOutputData as Mock).mockImplementation( (_mintUrl: string, serializedOutputData: any, options?: any) => { if (serializedOutputData.send.length > 0) { - expect(options).toEqual({ persistRecoveredProofs: false }); + expect(options).toEqual({ persistRecoveredProofs: false, unit: 'sat' }); return Promise.resolve([makeProof('send-1', 100)]); } return Promise.resolve([]); @@ -705,6 +749,7 @@ describe('P2pkSendHandler', () => { }, { createdByOperationId: 'op-1', + unit: 'sat', }, ); expect(proofService.recoverProofsFromOutputData).toHaveBeenCalledWith( @@ -715,6 +760,7 @@ describe('P2pkSendHandler', () => { }, { persistRecoveredProofs: false, + unit: 'sat', }, ); if (result.status === 'PENDING') { diff --git a/packages/core/test/unit/PaymentRequestService.test.ts b/packages/core/test/unit/PaymentRequestService.test.ts index 264291ed..3996cb0a 100644 --- a/packages/core/test/unit/PaymentRequestService.test.ts +++ b/packages/core/test/unit/PaymentRequestService.test.ts @@ -29,6 +29,7 @@ describe('PaymentRequestService', () => { state: 'pending', mintUrl: testMintUrl, amount: Amount.from(100), + unit: 'sat', createdAt: Date.now(), updatedAt: Date.now(), needsSwap: false, @@ -42,16 +43,19 @@ describe('PaymentRequestService', () => { const mockToken: Token = { mint: testMintUrl, proofs: [{ id: 'keyset-1', amount: Amount.from(100), secret: 'secret-1', C: 'C-1' }], + unit: 'sat', }; const createMockPreparedSendOperation = ( mintUrl: string, amount: Amount, + unit = 'sat', ): PreparedSendOperation => ({ id: 'test-op-id', state: 'prepared', mintUrl, amount, + unit, createdAt: Date.now(), updatedAt: Date.now(), needsSwap: false, @@ -67,6 +71,7 @@ describe('PaymentRequestService', () => { amount?: Amount; allowedMints?: string[]; transport?: ResolvedPaymentRequest['transport']; + unit?: string; } = {}, ): ResolvedPaymentRequest => { const transport = options.transport ?? { type: 'inband' as const }; @@ -81,12 +86,13 @@ describe('PaymentRequestService', () => { paymentRequestTransport, 'test-id', options.amount, - 'sat', + options.unit ?? 'sat', allowedMints, ), payableMints: [...allowedMints], allowedMints, amount: options.amount, + unit: options.unit ?? 'sat', transport, }; }; @@ -101,16 +107,17 @@ describe('PaymentRequestService', () => { beforeEach(() => { mockSendOperationService = { - init: mock(async (mintUrl: string, amount: Amount) => ({ + init: mock(async (mintUrl: string, amountInput: { amount: Amount; unit: string }) => ({ id: 'test-op-id', state: 'init', mintUrl, - amount, + amount: amountInput.amount, + unit: amountInput.unit, createdAt: Date.now(), updatedAt: Date.now(), })), - prepare: mock(async (initOp: { mintUrl: string; amount: Amount }) => - createMockPreparedSendOperation(initOp.mintUrl, initOp.amount), + prepare: mock(async (initOp: { mintUrl: string; amount: Amount; unit: string }) => + createMockPreparedSendOperation(initOp.mintUrl, initOp.amount, initOp.unit), ), execute: mock(async () => ({ operation: mockPendingOperation, @@ -124,11 +131,13 @@ describe('PaymentRequestService', () => { spendable: Amount.from(1000), reserved: Amount.zero(), total: Amount.from(1000), + unit: 'sat', }, [testMintUrl2]: { spendable: Amount.from(500), reserved: Amount.zero(), total: Amount.from(500), + unit: 'sat', }, })), } as unknown as ProofService; @@ -208,6 +217,7 @@ describe('PaymentRequestService', () => { spendable: Amount.from(50), reserved: Amount.zero(), total: Amount.from(50), + unit: 'sat', }, }), ); @@ -221,6 +231,20 @@ describe('PaymentRequestService', () => { expect(result.allowedMints).toEqual([testMintUrl]); expect(result.amount).toEqual(Amount.from(100)); }); + + it('matches custom-unit payment requests against balances for that unit only', async () => { + const pr = new PaymentRequest([], 'request-id-usd', 100, 'USD', [testMintUrl]); + const encoded = pr.toEncodedRequest(); + + const result = await service.parse(encoded); + + expect(result.unit).toBe('usd'); + expect(mockProofService.getBalancesByMint).toHaveBeenCalledWith({ + trustedOnly: true, + units: ['usd'], + }); + expect(result.payableMints).toEqual([testMintUrl]); + }); }); describe('prepare', () => { @@ -232,7 +256,10 @@ describe('PaymentRequestService', () => { expect(transaction.sendOperation).toBeDefined(); expect(transaction.sendOperation.mintUrl).toBe(testMintUrl); expect(transaction.request).toBe(request); - expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, Amount.from(100)); + expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, { + amount: Amount.from(100), + unit: 'sat', + }); expect(mockSendOperationService.prepare).toHaveBeenCalled(); }); @@ -247,7 +274,10 @@ describe('PaymentRequestService', () => { amount: Amount.from(750), }); - expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, Amount.from(750)); + expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, { + amount: Amount.from(750), + unit: 'sat', + }); expect(transaction.request).not.toBe(request); expect(transaction.request.amount).toEqual(Amount.from(750)); expect(transaction.request.paymentRequest.amount).toEqual(Amount.from(750)); @@ -273,7 +303,10 @@ describe('PaymentRequestService', () => { await service.prepare(request, { mintUrl: testMintUrl }); - expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, Amount.from(100)); + expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, { + amount: Amount.from(100), + unit: 'sat', + }); }); it('should throw if no amount is provided anywhere', async () => { @@ -297,6 +330,29 @@ describe('PaymentRequestService', () => { service.prepare(request, { mintUrl: testMintUrl, amount: Amount.from(200) }), ).rejects.toThrow('Amount mismatch'); }); + + it('rejects a sat amount override for a custom-unit request', async () => { + const request = createResolvedRequest({ amount: undefined, unit: 'usd' }); + + await expect( + service.prepare(request, { + mintUrl: testMintUrl, + amount: { amount: Amount.from(100), unit: 'sat' }, + }), + ).rejects.toThrow('Unit mismatch'); + }); + + it('prepares send operations in the request unit', async () => { + const request = createResolvedRequest({ amount: Amount.from(100), unit: 'usd' }); + + const transaction = await service.prepare(request, { mintUrl: testMintUrl }); + + expect(mockSendOperationService.init).toHaveBeenCalledWith(testMintUrl, { + amount: Amount.from(100), + unit: 'usd', + }); + expect(transaction.sendOperation.unit).toBe('usd'); + }); }); describe('execute', () => { diff --git a/packages/core/test/unit/PaymentRequestsApi.test.ts b/packages/core/test/unit/PaymentRequestsApi.test.ts index 729945ec..a550c0db 100644 --- a/packages/core/test/unit/PaymentRequestsApi.test.ts +++ b/packages/core/test/unit/PaymentRequestsApi.test.ts @@ -18,6 +18,7 @@ describe('PaymentRequestsApi', () => { payableMints: ['https://mint.test'], allowedMints: ['https://mint.test'], amount: Amount.from(100), + unit: 'sat', transport: { type: 'inband' }, }; @@ -27,6 +28,7 @@ describe('PaymentRequestsApi', () => { state: 'prepared', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'sat', createdAt: Date.now(), updatedAt: Date.now(), needsSwap: false, diff --git a/packages/core/test/unit/ProofService.test.ts b/packages/core/test/unit/ProofService.test.ts index 2ab58702..6a128141 100644 --- a/packages/core/test/unit/ProofService.test.ts +++ b/packages/core/test/unit/ProofService.test.ts @@ -7,7 +7,11 @@ import { MemoryProofRepository } from '../../repositories/memory/MemoryProofRepo import { MemoryCounterRepository } from '../../repositories/memory/MemoryCounterRepository.ts'; import { CounterService } from '../../services/CounterService.ts'; import { SeedService } from '../../services/SeedService.ts'; -import { ProofOperationError, ProofValidationError } from '../../models/Error.ts'; +import { + ProofOperationError, + ProofValidationError, + UnitValidationError, +} from '../../models/Error.ts'; import type { CoreProof } from '../../types.ts'; import { OutputData } from '@cashu/cashu-ts'; @@ -23,9 +27,10 @@ describe('ProofService', () => { // Minimal wallet service stub with only used methods let walletService: { - getWalletWithActiveKeysetId: (mintUrl: string) => Promise; + getWalletWithActiveKeysetId: (mintUrl: string, unit?: string) => Promise; getWallet: ( mintUrl: string, + unit?: string, ) => Promise<{ selectProofsToSend: (proofs: any[], amount: Amount) => { send: any[] } }>; }; @@ -44,6 +49,7 @@ describe('ProofService', () => { amount: Amount.from(1), C: 'C_' as unknown as any, id: keysetId, + unit: 'sat', secret: Math.random().toString(36).slice(2), mintUrl, state: 'ready', @@ -184,6 +190,45 @@ describe('ProofService', () => { const finalCounter = await counterRepo.getCounter(mintUrl, keysetId); expect(finalCounter?.counter).toBe(6); }); + + it('uses the requested unit when creating outputs', async () => { + const getWalletWithActiveKeysetId = mock(async (_mintUrl: string, unit?: string) => { + expect(unit).toBe('usd'); + return { keys: { id: 'usd-keyset' }, keysetId: 'usd-keyset' }; + }); + walletService = { + getWalletWithActiveKeysetId, + async getWallet() { + return { + selectProofsToSend() { + return { send: [] }; + }, + }; + }, + }; + OutputData.createDeterministicData = (() => [{}]) as any; + + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await service.createOutputsAndIncrementCounters( + mintUrl, + { keep: 1, send: 0 }, + { unit: 'USD' }, + ); + + expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); + const counter = await counterRepo.getCounter(mintUrl, 'usd-keyset'); + expect(counter?.counter).toBe(1); + }); }); describe('createBlankOutputs', () => { @@ -312,6 +357,25 @@ describe('ProofService', () => { }); describe('saveProofs', () => { + it('throws when a proof is missing unit metadata', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + const proof = makeProof({ secret: 'missing-unit' }) as unknown as Omit; + delete (proof as { unit?: string }).unit; + + await expect(service.saveProofs(mintUrl, [proof as CoreProof])).rejects.toThrow( + UnitValidationError, + ); + }); + it('emits per-group events and persists on success', async () => { const service = new ProofService( counterService, @@ -547,8 +611,44 @@ describe('ProofService', () => { expect(proof2?.state).toBe('inflight'); expect(proof3?.state).toBe('spent'); expect(getWalletWithActiveKeysetId).toHaveBeenCalledTimes(2); - expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl); - expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(otherMintUrl); + expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'sat'); + expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(otherMintUrl, 'sat'); + expect(checkProofsStates).toHaveBeenCalledTimes(2); + }); + + it('checks inflight proofs separately for each unit', async () => { + const satProof = makeProof({ secret: 'sat-inflight', state: 'inflight', unit: 'sat' }); + const usdProof = makeProof({ secret: 'usd-inflight', state: 'inflight', unit: 'usd' }); + await proofRepo.saveProofs(mintUrl, [satProof, usdProof]); + proofRepo.getInflightProofs = mock(async () => [satProof, usdProof]); + + const checkProofsStates = mock(async (proofs: CoreProof[]) => { + expect(new Set(proofs.map((proof) => proof.unit)).size).toBe(1); + return proofs.map(() => ({ state: 'UNSPENT' })); + }); + const getWalletWithActiveKeysetId = mock(async () => ({ + wallet: { checkProofsStates }, + })); + walletService = { + getWalletWithActiveKeysetId, + getWallet: walletService.getWallet, + }; + + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await service.checkInflightProofs(); + + expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'sat'); + expect(getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); expect(checkProofsStates).toHaveBeenCalledTimes(2); }); @@ -711,6 +811,119 @@ describe('ProofService', () => { // Expect our wallet stub to choose p1 + p2 expect(selected.map((p) => p.secret)).toEqual(['b1', 'b2']); }); + + it('selects only proofs for the requested unit', async () => { + const getWallet = mock(async (_mintUrl: string, unit?: string) => ({ + selectProofsToSend(proofs: any[], amount: Amount) { + expect(unit).toBe('usd'); + expect(proofs.every((proof) => proof.unit === 'usd')).toBe(true); + const selected: any[] = []; + let total = Amount.zero(); + for (const proof of proofs) { + if (total.greaterThanOrEqual(amount)) break; + selected.push(proof); + total = total.add(proof.amount); + } + return { send: selected }; + }, + })); + walletService = { + async getWalletWithActiveKeysetId() { + return { keys: { id: keysetId } }; + }, + getWallet, + }; + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'sat-large', id: 'k1', amount: Amount.from(100), unit: 'sat' }), + makeProof({ secret: 'usd-1', id: 'k1', amount: Amount.from(30), unit: 'usd' }), + makeProof({ secret: 'usd-2', id: 'k1', amount: Amount.from(25), unit: 'usd' }), + ]); + + const selected = await service.selectProofsToSend(mintUrl, 50, { + unit: 'USD', + includeFees: false, + }); + + expect(getWallet).toHaveBeenCalledWith(mintUrl, 'usd'); + expect(selected.map((proof) => proof.secret)).toEqual(['usd-1', 'usd-2']); + }); + + it('does not use sat balance to satisfy an insufficient custom-unit selection', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'sat-large', id: 'k1', amount: Amount.from(100), unit: 'sat' }), + makeProof({ secret: 'usd-small', id: 'k1', amount: Amount.from(10), unit: 'usd' }), + ]); + + await expect(service.selectProofsToSend(mintUrl, 50, { unit: 'usd' })).rejects.toThrow( + ProofValidationError, + ); + }); + }); + + describe('reserveProofs', () => { + it('emits the reserved input amount without counting operation output proofs', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + const operationId = 'reserve-op'; + const events: CoreEvents['proofs:reserved'][] = []; + bus.on('proofs:reserved', (payload) => { + events.push(payload); + }); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'reserve-input', amount: Amount.from(5), unit: 'usd' }), + makeProof({ + secret: 'reserve-output', + amount: Amount.from(100), + unit: 'usd', + createdByOperationId: operationId, + }), + ]); + + const result = await service.reserveProofs(mintUrl, ['reserve-input'], operationId, { + unit: 'USD', + }); + + expect(result).toEqual({ amount: Amount.from(5), unit: 'usd' }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + mintUrl, + operationId, + secrets: ['reserve-input'], + unit: 'usd', + }); + expect(events[0]?.amount).toEqual(Amount.from(5)); + }); }); describe('balance queries', () => { @@ -740,12 +953,14 @@ describe('ProofService', () => { spendable: Amount.from(50), reserved: Amount.from(100), total: Amount.from(150), + unit: 'sat', }, }); await expect(service.getBalanceTotal({ mintUrls: [mintUrl] })).resolves.toEqual({ spendable: Amount.from(50), reserved: Amount.from(100), total: Amount.from(150), + unit: 'sat', }); await expect(service.getBalance(mintUrl)).resolves.toEqual(Amount.from(150)); await expect(service.getSpendableBalance(mintUrl)).resolves.toEqual(Amount.from(50)); @@ -760,8 +975,10 @@ describe('ProofService', () => { const originalGetReadyProofs = proofRepo.getReadyProofs.bind(proofRepo); const originalGetAllReadyProofs = proofRepo.getAllReadyProofs.bind(proofRepo); - proofRepo.getReadyProofs = mock((mintUrl: string) => originalGetReadyProofs(mintUrl)); - proofRepo.getAllReadyProofs = mock(() => originalGetAllReadyProofs()); + proofRepo.getReadyProofs = mock((mintUrl: string, filter?: any) => + originalGetReadyProofs(mintUrl, filter), + ); + proofRepo.getAllReadyProofs = mock((filter?: any) => originalGetAllReadyProofs(filter)); const service = new ProofService( counterService, @@ -788,11 +1005,12 @@ describe('ProofService', () => { spendable: Amount.from(50), reserved: Amount.from(100), total: Amount.from(150), + unit: 'sat', }, }); expect(proofRepo.getReadyProofs).toHaveBeenCalledTimes(1); - expect(proofRepo.getReadyProofs).toHaveBeenCalledWith(mintUrl); + expect(proofRepo.getReadyProofs).toHaveBeenCalledWith(mintUrl, { units: ['sat'] }); expect(proofRepo.getAllReadyProofs).not.toHaveBeenCalled(); }); @@ -800,8 +1018,10 @@ describe('ProofService', () => { const originalGetReadyProofs = proofRepo.getReadyProofs.bind(proofRepo); const originalGetAllReadyProofs = proofRepo.getAllReadyProofs.bind(proofRepo); - proofRepo.getReadyProofs = mock((mintUrl: string) => originalGetReadyProofs(mintUrl)); - proofRepo.getAllReadyProofs = mock(() => originalGetAllReadyProofs()); + proofRepo.getReadyProofs = mock((mintUrl: string, filter?: any) => + originalGetReadyProofs(mintUrl, filter), + ); + proofRepo.getAllReadyProofs = mock((filter?: any) => originalGetAllReadyProofs(filter)); const service = new ProofService( counterService, @@ -826,6 +1046,7 @@ describe('ProofService', () => { spendable: Amount.from(0), reserved: Amount.from(0), total: Amount.from(0), + unit: 'sat', }); expect(proofRepo.getReadyProofs).not.toHaveBeenCalled(); @@ -858,17 +1079,20 @@ describe('ProofService', () => { spendable: Amount.from(50), reserved: Amount.from(100), total: Amount.from(150), + unit: 'sat', }, [otherMintUrl]: { spendable: Amount.from(200), reserved: Amount.from(0), total: Amount.from(200), + unit: 'sat', }, }); await expect(service.getBalanceTotal()).resolves.toEqual({ spendable: Amount.from(250), reserved: Amount.from(100), total: Amount.from(350), + unit: 'sat', }); await expect(service.getBalances()).resolves.toEqual({ [mintUrl]: Amount.from(150), @@ -888,6 +1112,98 @@ describe('ProofService', () => { }); }); + it('keeps mixed-unit balances separated', async () => { + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + ); + + await proofRepo.saveProofs(mintUrl, [ + makeProof({ secret: 'sat-ready', amount: Amount.from(100), unit: 'sat' }), + makeProof({ secret: 'usd-ready', amount: Amount.from(40), unit: 'usd' }), + makeProof({ secret: 'usd-reserved', amount: Amount.from(10), unit: 'usd' }), + ]); + await proofRepo.saveProofs(otherMintUrl, [ + makeProof({ + secret: 'other-usd', + amount: Amount.from(7), + mintUrl: otherMintUrl, + unit: 'usd', + }), + ]); + await proofRepo.reserveProofs(mintUrl, ['usd-reserved'], operationId); + + await expect(service.getBalancesByMint()).resolves.toEqual({ + [mintUrl]: { + spendable: Amount.from(100), + reserved: Amount.zero(), + total: Amount.from(100), + unit: 'sat', + }, + }); + await expect(service.getBalancesByMint({ units: ['usd'] })).resolves.toEqual({ + [mintUrl]: { + spendable: Amount.from(40), + reserved: Amount.from(10), + total: Amount.from(50), + unit: 'usd', + }, + [otherMintUrl]: { + spendable: Amount.from(7), + reserved: Amount.zero(), + total: Amount.from(7), + unit: 'usd', + }, + }); + await expect(service.getBalancesByMintAndUnit()).resolves.toEqual({ + [mintUrl]: { + sat: { + spendable: Amount.from(100), + reserved: Amount.zero(), + total: Amount.from(100), + unit: 'sat', + }, + usd: { + spendable: Amount.from(40), + reserved: Amount.from(10), + total: Amount.from(50), + unit: 'usd', + }, + }, + [otherMintUrl]: { + usd: { + spendable: Amount.from(7), + reserved: Amount.zero(), + total: Amount.from(7), + unit: 'usd', + }, + }, + }); + await expect(service.getBalanceTotal({ units: ['sat', 'usd'] })).rejects.toThrow( + ProofValidationError, + ); + await expect(service.getBalanceTotalByUnit()).resolves.toEqual({ + sat: { + spendable: Amount.from(100), + reserved: Amount.zero(), + total: Amount.from(100), + unit: 'sat', + }, + usd: { + spendable: Amount.from(47), + reserved: Amount.from(10), + total: Amount.from(57), + unit: 'usd', + }, + }); + }); + it('filters trusted balances across canonical and legacy queries', async () => { const service = new ProofService( counterService, @@ -914,12 +1230,14 @@ describe('ProofService', () => { spendable: Amount.from(50), reserved: Amount.from(100), total: Amount.from(150), + unit: 'sat', }, }); await expect(service.getBalanceTotal({ trustedOnly: true })).resolves.toEqual({ spendable: Amount.from(50), reserved: Amount.from(100), total: Amount.from(150), + unit: 'sat', }); await expect(service.getTrustedBalances()).resolves.toEqual({ [mintUrl]: Amount.from(150), diff --git a/packages/core/test/unit/ProofStateWatcherService.test.ts b/packages/core/test/unit/ProofStateWatcherService.test.ts index a9347afd..ff2db06b 100644 --- a/packages/core/test/unit/ProofStateWatcherService.test.ts +++ b/packages/core/test/unit/ProofStateWatcherService.test.ts @@ -24,6 +24,7 @@ describe('ProofStateWatcherService', () => { secret: 'secret', C: 'C' as unknown as CoreProof['C'], mintUrl: mintUrlA, + unit: 'sat', state: 'inflight', ...overrides, }) as CoreProof; diff --git a/packages/core/test/unit/ReceiveOperationService.recovery.test.ts b/packages/core/test/unit/ReceiveOperationService.recovery.test.ts index 9dee0e7c..4e6b18cf 100644 --- a/packages/core/test/unit/ReceiveOperationService.recovery.test.ts +++ b/packages/core/test/unit/ReceiveOperationService.recovery.test.ts @@ -190,6 +190,7 @@ describe('ReceiveOperationService - recoverPendingOperations', () => { secret, C: `C_${secret}`, mintUrl, + unit: 'sat', state: 'ready', createdByOperationId: op.id, })); diff --git a/packages/core/test/unit/ReceiveOperationService.test.ts b/packages/core/test/unit/ReceiveOperationService.test.ts index ce65afe8..6c17e53a 100644 --- a/packages/core/test/unit/ReceiveOperationService.test.ts +++ b/packages/core/test/unit/ReceiveOperationService.test.ts @@ -45,7 +45,9 @@ describe('ReceiveOperationService', () => { let mockWalletReceive: Mock<(...args: any[]) => Promise>; let mockIsTrustedMint: Mock<(mintUrl: string) => Promise>; let mockEnsureUpdatedMint: Mock< - (mintUrl: string) => Promise<{ mint: { url: string }; keysets: { id: string }[] }> + ( + mintUrl: string, + ) => Promise<{ mint: { url: string }; keysets: { id: string; unit?: string }[] }> >; const makeProof = (secret: string): Proof => @@ -298,13 +300,24 @@ describe('ReceiveOperationService', () => { expect(service.init(token)).rejects.toThrow(ProofValidationError); }); - it('init rejects tokens with unsupported units', async () => { + it('init rejects token units that conflict with proof keyset units', async () => { const proofs = [makeProof('p1')]; const token: Token = { mint: mintUrl, proofs, unit: 'usd' } as Token; - expect(service.init(token)).rejects.toThrow( - "Unsupported mint unit 'usd'. Only 'sat' is currently supported.", - ); + expect(service.init(token)).rejects.toThrow('Unit mismatch: expected usd, received sat'); + }); + + it('init accepts non-sat tokens when proof keysets match the token unit', async () => { + mockEnsureUpdatedMint.mockImplementationOnce(async () => ({ + mint: { url: mintUrl }, + keysets: [{ id: keysetId, unit: 'usd' }], + })); + const proofs = [makeProof('p1')]; + const token: Token = { mint: mintUrl, proofs, unit: 'USD' } as Token; + + const operation = await service.init(token); + + expect(operation.unit).toBe('usd'); }); it('prepare throws when operation has no input proofs', async () => { @@ -549,6 +562,7 @@ describe('ReceiveOperationService', () => { secret, C: `C_${secret}`, mintUrl, + unit: 'sat', state: 'ready', createdByOperationId: executing.id, })); @@ -579,6 +593,7 @@ describe('ReceiveOperationService', () => { secret, C: `C_${secret}`, mintUrl, + unit: 'sat', state: 'ready', createdByOperationId: executing.id, })); diff --git a/packages/core/test/unit/SendOperationService.recovery.test.ts b/packages/core/test/unit/SendOperationService.recovery.test.ts index 30fbe4da..81b29b90 100644 --- a/packages/core/test/unit/SendOperationService.recovery.test.ts +++ b/packages/core/test/unit/SendOperationService.recovery.test.ts @@ -50,6 +50,7 @@ describe('SendOperationService - recoverPendingOperations', () => { id: keysetId, secret, mintUrl, + unit: 'sat', state: 'ready', ...overrides, }) as CoreProof; @@ -59,6 +60,7 @@ describe('SendOperationService - recoverPendingOperations', () => { state: 'init', mintUrl, amount: Amount.from(100), + unit: 'sat', createdAt: Date.now() - 10000, updatedAt: Date.now() - 10000, method: 'default', @@ -83,6 +85,7 @@ describe('SendOperationService - recoverPendingOperations', () => { method: 'default', methodData: {}, ...overrides, + unit: overrides?.unit ?? 'sat', }); const makeExecutingOp = ( @@ -92,6 +95,7 @@ describe('SendOperationService - recoverPendingOperations', () => { ...makePreparedOp(id), state: 'executing', ...overrides, + unit: overrides?.unit ?? 'sat', }); const makePendingOp = ( @@ -101,6 +105,7 @@ describe('SendOperationService - recoverPendingOperations', () => { ...makePreparedOp(id), state: 'pending', ...overrides, + unit: overrides?.unit ?? 'sat', }); /** diff --git a/packages/core/test/unit/SendOperationService.test.ts b/packages/core/test/unit/SendOperationService.test.ts index fd9c86b0..85ed2fc4 100644 --- a/packages/core/test/unit/SendOperationService.test.ts +++ b/packages/core/test/unit/SendOperationService.test.ts @@ -34,13 +34,14 @@ describe('SendOperationService', () => { let handlerProvider: SendHandlerProvider; let service: SendOperationService; - const makeProof = (secret: string, amount: number): CoreProof => + const makeProof = (secret: string, amount: number, unit = 'sat'): CoreProof => ({ amount: Amount.from(amount), C: `C_${secret}`, id: keysetId, secret, mintUrl, + unit, state: 'ready', }) as CoreProof; @@ -93,8 +94,15 @@ describe('SendOperationService', () => { proofService = { selectProofsToSend: mock( - async (selectedMintUrl: string, amount: Amount, includeFees: boolean = true) => { - const proofs = await proofRepo.getAvailableProofs(selectedMintUrl); + async ( + selectedMintUrl: string, + amount: Amount, + options: boolean | { includeFees?: boolean; unit?: string } = true, + ) => { + const includeFees = + typeof options === 'boolean' ? options : (options.includeFees ?? true); + const unit = typeof options === 'boolean' ? 'sat' : (options.unit ?? 'sat'); + const proofs = await proofRepo.getAvailableProofs(selectedMintUrl, { unit }); return wallet.selectProofsToSend(proofs, amount, includeFees).send; }, ), @@ -220,12 +228,35 @@ describe('SendOperationService', () => { expect(lockedDuringEvent).toBe(true); }); + it('prepares and executes a custom-unit send without selecting sat proofs', async () => { + await proofRepo.saveProofs(mintUrl, [ + makeProof('sat-proof', 100, 'sat'), + makeProof('usd-proof', 100, 'usd'), + ]); + + const initOp = await service.init(mintUrl, { amount: 100, unit: 'USD' }); + const preparedOp = await service.prepare(initOp); + const result = await service.execute(preparedOp); + + expect(preparedOp.unit).toBe('usd'); + expect(preparedOp.inputProofSecrets).toEqual(['usd-proof']); + expect(walletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); + expect(result.token.unit).toBe('usd'); + expect(result.operation.unit).toBe('usd'); + expect(result.token.proofs.map((proof) => proof.secret)).toEqual(['usd-proof']); + + const satProof = await proofRepo.getProofBySecret(mintUrl, 'sat-proof'); + expect(satProof?.state).toBe('ready'); + expect(satProof?.usedByOperationId).toBeUndefined(); + }); + it('persists explicit handler failures without running executing recovery', async () => { const preparedOp: PreparedSendOperation = { id: 'send-op-failed', state: 'prepared', mintUrl, amount: Amount.from(100), + unit: 'sat', createdAt: Date.now(), updatedAt: Date.now(), needsSwap: false, @@ -301,6 +332,7 @@ describe('SendOperationService', () => { state: 'pending', mintUrl, amount: Amount.from(100), + unit: 'sat', createdAt: Date.now(), updatedAt: Date.now(), needsSwap: true, @@ -358,6 +390,7 @@ describe('SendOperationService', () => { state: 'pending', mintUrl, amount: Amount.from(100), + unit: 'sat', createdAt: Date.now(), updatedAt: Date.now(), needsSwap: false, diff --git a/packages/core/test/unit/SendOpsApi.test.ts b/packages/core/test/unit/SendOpsApi.test.ts index 63c040c8..acfd32f9 100644 --- a/packages/core/test/unit/SendOpsApi.test.ts +++ b/packages/core/test/unit/SendOpsApi.test.ts @@ -16,6 +16,7 @@ const makePreparedOperation = (): PreparedSendOperation => ({ state: 'prepared', mintUrl, amount: Amount.from(20), + unit: 'sat', method: 'default', methodData: {}, createdAt: Date.now(), @@ -61,10 +62,17 @@ describe('SendOpsApi', () => { it('prepare calls init and prepare with default target', async () => { const result = await api.prepare({ mintUrl, amount: Amount.from(20) }); - expect(sendOperationService.init).toHaveBeenCalledWith(mintUrl, Amount.from(20), { - method: 'default', - methodData: {}, - }); + expect(sendOperationService.init).toHaveBeenCalledWith( + mintUrl, + { + amount: Amount.from(20), + unit: 'sat', + }, + { + method: 'default', + methodData: {}, + }, + ); expect(sendOperationService.prepare).toHaveBeenCalled(); expect(result).toBe(preparedOperation); }); @@ -76,10 +84,17 @@ describe('SendOpsApi', () => { target: { type: 'p2pk', pubkey: 'pubkey-1' }, }); - expect(sendOperationService.init).toHaveBeenCalledWith(mintUrl, Amount.from(20), { - method: 'p2pk', - methodData: { pubkey: 'pubkey-1' }, - }); + expect(sendOperationService.init).toHaveBeenCalledWith( + mintUrl, + { + amount: Amount.from(20), + unit: 'sat', + }, + { + method: 'p2pk', + methodData: { pubkey: 'pubkey-1' }, + }, + ); }); it('execute re-reads operation objects before executing', async () => { diff --git a/packages/core/test/unit/TokenService.test.ts b/packages/core/test/unit/TokenService.test.ts new file mode 100644 index 00000000..9d9842fb --- /dev/null +++ b/packages/core/test/unit/TokenService.test.ts @@ -0,0 +1,33 @@ +import { Amount, type Token } from '@cashu/cashu-ts'; +import { describe, expect, it, mock } from 'bun:test'; +import { TokenService } from '../../services/TokenService.ts'; +import type { MintService } from '../../services/MintService.ts'; + +describe('TokenService', () => { + const mintUrl = 'https://mint.test'; + + it('falls back to sat when a unitless token has no resolvable keyset metadata', async () => { + const mintService = { + ensureUpdatedMint: mock(async () => ({ + mint: { mintUrl }, + keysets: [], + })), + } as unknown as MintService; + const service = new TokenService(mintService); + const token: Token = { + mint: mintUrl, + proofs: [ + { + id: 'missing-keyset', + amount: Amount.from(1), + secret: 'secret-1', + C: 'C-1', + }, + ], + }; + + const decoded = await service.decodeToken(token, mintUrl); + + expect(decoded.unit).toBe('sat'); + }); +}); diff --git a/packages/core/test/unit/WalletApi.test.ts b/packages/core/test/unit/WalletApi.test.ts index c26def80..e2f3e8e4 100644 --- a/packages/core/test/unit/WalletApi.test.ts +++ b/packages/core/test/unit/WalletApi.test.ts @@ -49,6 +49,25 @@ describe('WalletApi - Trust Enforcement', () => { ), ); + const encodeLegacyTokenWithoutUnit = (token: { mint: string; proofs: Proof[] }): string => { + const legacyToken = { + token: [ + { + mint: token.mint, + proofs: token.proofs.map((proof) => ({ + ...proof, + amount: proof.amount.toNumber(), + })), + }, + ], + }; + return `cashuA${Buffer.from(JSON.stringify(legacyToken)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '')}`; + }; + const createMockMintAdapter = (): MintAdapter => ({ checkProofStates: mock(() => Promise.resolve([])), @@ -80,6 +99,10 @@ describe('WalletApi - Trust Enforcement', () => { }; mockWalletService = { + getWallet: mock(async () => ({ + receive: mock(async () => []), + getFeesForProofs: mock(() => Amount.zero()), + })), getWalletWithActiveKeysetId: mock(async () => ({ wallet: { receive: mock(async () => []), @@ -98,12 +121,40 @@ describe('WalletApi - Trust Enforcement', () => { spendable: Amount.from(10), reserved: Amount.from(5), total: Amount.from(15), + unit: 'sat', + }, + })), + getBalancesByMintAndUnit: mock(async () => ({ + [testMintUrl]: { + sat: { + spendable: Amount.from(10), + reserved: Amount.from(5), + total: Amount.from(15), + unit: 'sat', + }, + }, + })), + getBalancesByUnit: mock(async () => ({ + sat: { + spendable: Amount.from(10), + reserved: Amount.from(5), + total: Amount.from(15), + unit: 'sat', }, })), getBalanceTotal: mock(async () => ({ spendable: Amount.from(10), reserved: Amount.from(5), total: Amount.from(15), + unit: 'sat', + })), + getBalanceTotalByUnit: mock(async () => ({ + sat: { + spendable: Amount.from(10), + reserved: Amount.from(5), + total: Amount.from(15), + unit: 'sat', + }, })), saveProofs: mock(async () => {}), prepareProofsForReceiving: mock(async (proofs: any[]) => proofs), @@ -144,12 +195,14 @@ describe('WalletApi - Trust Enforcement', () => { spendable: Amount.from(10), reserved: Amount.from(5), total: Amount.from(15), + unit: 'sat', }, }); await expect(walletApi.balances.total()).resolves.toEqual({ spendable: Amount.from(10), reserved: Amount.from(5), total: Amount.from(15), + unit: 'sat', }); }); @@ -176,7 +229,10 @@ describe('WalletApi - Trust Enforcement', () => { // Should not throw await walletApi.receive(token); - expect(mockWalletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(testMintUrl); + expect(mockWalletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith( + testMintUrl, + 'sat', + ); }); it('should check trust status before processing token', async () => { @@ -218,7 +274,10 @@ describe('WalletApi - Trust Enforcement', () => { // Should not throw await walletApi.receive(encodedToken); - expect(mockWalletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(testMintUrl); + expect(mockWalletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith( + testMintUrl, + 'sat', + ); }); it('should provide clear error message for untrusted mints', async () => { @@ -273,9 +332,7 @@ describe('WalletApi - Trust Enforcement', () => { describe('restore', () => { it('should add mint during restore (creating as trusted by default)', async () => { - mockWalletService.getWalletWithActiveKeysetId.mockImplementation(async () => ({ - wallet: {}, - })); + mockWalletService.getWallet.mockImplementation(async () => ({})); mockWalletRestoreService.restoreKeyset = mock(async () => {}); @@ -283,6 +340,88 @@ describe('WalletApi - Trust Enforcement', () => { expect(mockMintService.addMintByUrl).toHaveBeenCalledWith(testMintUrl, { trusted: true }); }); + + it('restores every advertised keyset unit by default', async () => { + const satWallet = { unit: 'sat-wallet' }; + const usdWallet = { unit: 'usd-wallet' }; + mockMintService.addMintByUrl.mockImplementation(async () => ({ + mint: {}, + keysets: [ + { id: 'sat-keyset', unit: 'sat' }, + { id: 'usd-keyset', unit: 'USD' }, + ], + })); + mockWalletService.getWallet.mockImplementation(async (_mintUrl: string, unit: string) => + unit === 'usd' ? usdWallet : satWallet, + ); + mockWalletRestoreService.restoreKeyset = mock(async () => {}); + + await walletApi.restore(testMintUrl); + + expect(mockWalletService.getWallet).toHaveBeenCalledWith(testMintUrl, 'sat'); + expect(mockWalletService.getWallet).toHaveBeenCalledWith(testMintUrl, 'usd'); + expect(mockWalletRestoreService.restoreKeyset).toHaveBeenCalledWith( + testMintUrl, + satWallet, + 'sat-keyset', + 'sat', + ); + expect(mockWalletRestoreService.restoreKeyset).toHaveBeenCalledWith( + testMintUrl, + usdWallet, + 'usd-keyset', + 'usd', + ); + }); + + it('restores only requested units when a unit filter is provided', async () => { + const usdWallet = { unit: 'usd-wallet' }; + mockMintService.addMintByUrl.mockImplementation(async () => ({ + mint: {}, + keysets: [ + { id: 'sat-keyset', unit: 'sat' }, + { id: 'usd-keyset', unit: 'USD' }, + ], + })); + mockWalletService.getWallet.mockResolvedValue(usdWallet); + mockWalletRestoreService.restoreKeyset = mock(async () => {}); + + await walletApi.restore(testMintUrl, { units: ['USD'] }); + + expect(mockWalletService.getWallet).toHaveBeenCalledTimes(1); + expect(mockWalletService.getWallet).toHaveBeenCalledWith(testMintUrl, 'usd'); + expect(mockWalletRestoreService.restoreKeyset).toHaveBeenCalledTimes(1); + expect(mockWalletRestoreService.restoreKeyset).toHaveBeenCalledWith( + testMintUrl, + usdWallet, + 'usd-keyset', + 'usd', + ); + }); + }); + + describe('sweep', () => { + it('sweeps only requested units when a unit filter is provided', async () => { + const bip39seed = new Uint8Array(64).fill(1); + mockMintService.addMintByUrl.mockImplementation(async () => ({ + mint: {}, + keysets: [ + { id: 'sat-keyset', unit: 'sat' }, + { id: 'usd-keyset', unit: 'USD' }, + ], + })); + mockWalletRestoreService.sweepKeyset = mock(async () => {}); + + await walletApi.sweep(testMintUrl, bip39seed, { units: ['USD'] }); + + expect(mockWalletRestoreService.sweepKeyset).toHaveBeenCalledTimes(1); + expect(mockWalletRestoreService.sweepKeyset).toHaveBeenCalledWith( + testMintUrl, + 'usd-keyset', + bip39seed, + 'usd', + ); + }); }); describe('decodeToken', () => { @@ -297,18 +436,26 @@ describe('WalletApi - Trust Enforcement', () => { proofs: testProofs, }; - const decodeTokenMock = mock(async () => decodedToken); - mockWalletService.getWallet = mock(async (mintUrl: string) => { - return { - decodeToken: decodeTokenMock, - }; - }); + const result = await walletApi.decodeToken(encodedToken); + + expect(mockMintService.ensureUpdatedMint).toHaveBeenCalledWith(testMintUrl); + expect(result).toEqual({ ...decodedToken, unit: 'sat' }); + }); + + it('infers a unitless token unit from proof keysets when no mint URL is provided', async () => { + const token = { + mint: testMintUrl, + proofs: testProofs, + }; + const encodedToken = encodeLegacyTokenWithoutUnit(token); + mockMintService.ensureUpdatedMint.mockImplementation(async () => ({ + mint: { url: testMintUrl }, + keysets: [{ id: keysetId, unit: 'USD', active: true }], + })); const result = await walletApi.decodeToken(encodedToken); - expect(mockWalletService.getWallet).toHaveBeenCalledWith(testMintUrl); - expect(decodeTokenMock).toHaveBeenCalledWith(encodedToken); - expect(result).toEqual(decodedToken); + expect(result.unit).toBe('usd'); }); }); diff --git a/packages/core/test/unit/WalletRestoreService.test.ts b/packages/core/test/unit/WalletRestoreService.test.ts index 9b5e84fc..96530743 100644 --- a/packages/core/test/unit/WalletRestoreService.test.ts +++ b/packages/core/test/unit/WalletRestoreService.test.ts @@ -122,6 +122,32 @@ describe('WalletRestoreService', () => { }); }); + it('should sweep with a unit-scoped wallet and persist swept proofs with that unit', async () => { + const proofs = [makeProof(50, 'proof1'), makeProof(50, 'proof2')]; + + Wallet.prototype.batchRestore = mock(() => Promise.resolve({ proofs })); + Wallet.prototype.checkProofsStates = mock(() => + Promise.resolve([{ state: 'UNSPENT' } as ProofState, { state: 'UNSPENT' } as ProofState]), + ); + Wallet.prototype.getFeesForProofs = mock(() => Amount.from(1)); + + await service.sweepKeyset(mintUrl, keysetId, bip39seed, 'USD'); + + expect(walletService.getWalletWithActiveKeysetId).toHaveBeenCalledWith(mintUrl, 'usd'); + expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith( + mintUrl, + { + keep: 0, + send: Amount.from(99), + }, + { unit: 'usd' }, + ); + + const savedProofsCall = (proofService.saveProofs as any).mock.calls[0]; + expect(savedProofsCall[0]).toBe(mintUrl); + expect(savedProofsCall[1].map((proof: { unit: string }) => proof.unit)).toEqual(['usd']); + }); + it('should return early when no proofs are found', async () => { const mockBatchRestore = mock(() => Promise.resolve({ proofs: [] })); Wallet.prototype.batchRestore = mockBatchRestore; @@ -315,6 +341,15 @@ describe('WalletRestoreService', () => { }); }); + it('should persist restored proofs with the requested unit', async () => { + await service.restoreKeyset(mintUrl, mockWallet, keysetId, 'USD'); + + const savedProofsCall = (proofService.saveProofs as any).mock.calls[0]; + expect(savedProofsCall[0]).toBe(mintUrl); + expect(savedProofsCall[1]).toHaveLength(1); + expect(savedProofsCall[1][0].unit).toBe('usd'); + }); + it('should return early when no proofs are restored', async () => { mockWallet.batchRestore = mock(() => Promise.resolve({ proofs: [], lastCounterWithSignature: 0 }), diff --git a/packages/core/test/unit/WalletService.test.ts b/packages/core/test/unit/WalletService.test.ts new file mode 100644 index 00000000..82c73f33 --- /dev/null +++ b/packages/core/test/unit/WalletService.test.ts @@ -0,0 +1,132 @@ +import { deriveKeysetId } from '@cashu/cashu-ts'; +import { describe, expect, it, mock } from 'bun:test'; + +import type { Keyset } from '../../models/Keyset.ts'; +import type { Mint } from '../../models/Mint.ts'; +import { WalletService } from '../../services/WalletService.ts'; +import type { MintInfo } from '../../types.ts'; + +const mintUrl = 'https://mint.test'; + +const mintInfo: MintInfo = { + name: 'Test Mint', + version: '1.0.0', + pubkey: 'test-pubkey', + contact: [], + nuts: { + '4': { methods: [], disabled: false }, + '5': { methods: [], disabled: false }, + }, +} as MintInfo; + +const keypairs = { + '1': '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + '2': '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5', + '4': '02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9', + '8': '03774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb', +}; + +function makeMint(url = mintUrl): Mint { + return { + mintUrl: url, + name: url, + mintInfo, + trusted: true, + createdAt: 0, + updatedAt: 0, + }; +} + +function makeKeyset(unit: string): Keyset { + return { + mintUrl, + id: deriveKeysetId(keypairs, { unit }), + unit, + keypairs, + active: true, + feePpk: 0, + updatedAt: 0, + }; +} + +function makeService(keysets: Keyset[]) { + const ensureUpdatedMint = mock(async (url: string) => ({ + mint: makeMint(url), + keysets: keysets.map((keyset) => ({ ...keyset, mintUrl: url })), + })); + const updateMintData = mock(async (url: string) => ({ + mint: makeMint(url), + keysets: keysets.map((keyset) => ({ ...keyset, mintUrl: url })), + })); + const getSeed = mock(async () => new Uint8Array(64).fill(1)); + const getRequestFn = mock( + () => + async () => + ({}) as T, + ); + + const service = new WalletService( + { ensureUpdatedMint, updateMintData } as any, + { getSeed } as any, + { getRequestFn } as any, + ); + + return { service, ensureUpdatedMint, updateMintData, getRequestFn }; +} + +describe('WalletService unit scoping', () => { + it('builds sat wallets by default', async () => { + const { service } = makeService([makeKeyset('sat')]); + + const wallet = await service.getWallet(mintUrl); + + expect(wallet.unit).toBe('sat'); + }); + + it('builds and caches separate wallets per mint unit', async () => { + const { service, ensureUpdatedMint } = makeService([makeKeyset('sat'), makeKeyset('usd')]); + + const satWallet = await service.getWallet(mintUrl, 'sat'); + const usdWallet = await service.getWallet(mintUrl, 'USD'); + const cachedUsdWallet = await service.getWallet(mintUrl, 'usd'); + + expect(satWallet.unit).toBe('sat'); + expect(usdWallet.unit).toBe('usd'); + expect(usdWallet).toBe(cachedUsdWallet); + expect(satWallet).not.toBe(usdWallet); + expect(ensureUpdatedMint).toHaveBeenCalledTimes(2); + }); + + it('returns the active keyset for the requested unit', async () => { + const { service } = makeService([makeKeyset('sat'), makeKeyset('usd')]); + + const result = await service.getWalletWithActiveKeysetId(mintUrl, 'USD'); + + expect(result.unit).toBe('usd'); + expect(result.keyset.unit).toBe('usd'); + expect(result.keys.unit).toBe('usd'); + }); + + it('throws when the requested unit has no keysets', async () => { + const { service } = makeService([makeKeyset('sat')]); + + await expect(service.getWallet(mintUrl, 'usd')).rejects.toThrow( + 'No valid keysets found for mint https://mint.test and unit usd', + ); + }); + + it('can clear one unit cache without clearing other units', async () => { + const { service, ensureUpdatedMint } = makeService([makeKeyset('sat'), makeKeyset('usd')]); + + const satWallet = await service.getWallet(mintUrl, 'sat'); + const usdWallet = await service.getWallet(mintUrl, 'usd'); + service.clearCache(mintUrl, 'sat'); + + const rebuiltSatWallet = await service.getWallet(mintUrl, 'sat'); + const cachedUsdWallet = await service.getWallet(mintUrl, 'usd'); + + expect(rebuiltSatWallet).not.toBe(satWallet); + expect(cachedUsdWallet).toBe(usdWallet); + expect(ensureUpdatedMint).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/core/test/unit/amounts.test.ts b/packages/core/test/unit/amounts.test.ts new file mode 100644 index 00000000..e52a7200 --- /dev/null +++ b/packages/core/test/unit/amounts.test.ts @@ -0,0 +1,44 @@ +import { Amount } from '@cashu/cashu-ts'; +import { describe, expect, it } from 'bun:test'; + +import { assertSameUnit, DEFAULT_UNIT, normalizeUnit, parseUnitAmount } from '../../amounts.ts'; +import { UnitMismatchError, UnitValidationError } from '../../models/Error.ts'; + +describe('unit amount primitives', () => { + it('normalizes unit strings by trimming and lowercasing', () => { + expect(normalizeUnit(' SAT ')).toBe(DEFAULT_UNIT); + expect(normalizeUnit('USD')).toBe('usd'); + }); + + it('requires a unit unless a default is provided', () => { + expect(() => normalizeUnit()).toThrow(UnitValidationError); + expect(normalizeUnit(undefined, { defaultUnit: DEFAULT_UNIT })).toBe(DEFAULT_UNIT); + }); + + it('rejects empty units', () => { + expect(() => normalizeUnit(' ')).toThrow(UnitValidationError); + }); + + it('parses a bare amount as sat', () => { + const parsed = parseUnitAmount(100); + expect(parsed.amount.equals(Amount.from(100))).toBe(true); + expect(parsed.unit).toBe(DEFAULT_UNIT); + }); + + it('parses object-form amounts with normalized units', () => { + const parsed = parseUnitAmount({ amount: 100, unit: ' USD ' }); + expect(parsed.amount.equals(Amount.from(100))).toBe(true); + expect(parsed.unit).toBe('usd'); + }); + + it('throws when object and explicit units conflict', () => { + expect(() => parseUnitAmount({ amount: 100, unit: 'usd' }, { explicitUnit: 'sat' })).toThrow( + UnitMismatchError, + ); + }); + + it('compares units after normalization', () => { + expect(() => assertSameUnit(' SAT ', 'sat')).not.toThrow(); + expect(() => assertSameUnit('usd', 'sat')).toThrow(UnitMismatchError); + }); +}); diff --git a/packages/core/types.ts b/packages/core/types.ts index 4d33fd04..154b088f 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -8,12 +8,22 @@ export interface BalanceSnapshot { spendable: Amount; reserved: Amount; total: Amount; + unit: string; } export type BalancesByMint = { [mintUrl: string]: BalanceSnapshot }; +export type BalancesByMintAndUnit = { + [mintUrl: string]: { + [unit: string]: BalanceSnapshot; + }; +}; + +export type BalancesByUnit = { [unit: string]: BalanceSnapshot }; + export interface BalanceQuery { mintUrls?: string[]; + units?: string[]; trustedOnly?: boolean; } @@ -33,6 +43,7 @@ export type BalancesBreakdownByMint = { [mintUrl: string]: BalanceBreakdown }; export interface CoreProof extends Proof { mintUrl: string; + unit: string; state: ProofState; /** diff --git a/packages/core/utils.ts b/packages/core/utils.ts index 1d7eb43c..e80cf550 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -9,6 +9,7 @@ import { import type { CoreProof, ProofState } from './types'; import type { Logger } from './logging/Logger.ts'; import { TokenValidationError } from './models/Error.ts'; +import { DEFAULT_UNIT, normalizeUnit } from './amounts.ts'; // ============================================================================ // OutputData Serialization Types @@ -146,11 +147,13 @@ export function mapProofToCoreProof( mintUrl: string, state: ProofState, proofs: Proof[], - options?: { createdByOperationId?: string }, + options?: { unit?: string; createdByOperationId?: string }, ): CoreProof[] { + const unit = normalizeUnit(options?.unit, { defaultUnit: DEFAULT_UNIT }); return proofs.map((p) => ({ ...p, mintUrl, + unit, state, createdByOperationId: options?.createdByOperationId, })); diff --git a/packages/docs/.vitepress/config.ts b/packages/docs/.vitepress/config.ts index 440828e1..90902306 100644 --- a/packages/docs/.vitepress/config.ts +++ b/packages/docs/.vitepress/config.ts @@ -39,6 +39,7 @@ export default defineConfig({ { text: 'Consumer Skills', link: '/pages/consumer-skills' }, { text: 'Bip39', link: '/pages/bip39' }, { text: 'KeyRing (P2PK)', link: '/pages/keyring' }, + { text: 'Multi-Unit Support', link: '/pages/multi-unit-support' }, { text: 'Watchers & Processors', link: '/pages/watchers-processors' }, { text: 'Send Operations', link: '/pages/send-operations' }, { text: 'Receive Operations', link: '/pages/receive-operations' }, diff --git a/packages/docs/pages/bip39.md b/packages/docs/pages/bip39.md index 4aec02d7..c2f0f742 100644 --- a/packages/docs/pages/bip39.md +++ b/packages/docs/pages/bip39.md @@ -23,7 +23,7 @@ Coco will call this function whenever it needs to access the seed ## Restore -Once instantiated you can use the deterministic secret restore `coco.wallet.restore()` to restore secrets and counters based on the seed on a certain mint. Coco will get all available keysets from the mint and perform a restore for each one. +Once instantiated you can use the deterministic secret restore `coco.wallet.restore()` to restore secrets and counters based on the seed on a certain mint. Coco will get all available keysets from the mint and perform a restore for each one, including custom-unit keysets. ```ts await coco.wallet.restore('https://mint.url'); @@ -31,4 +31,18 @@ await coco.wallet.restore('https://mint.url'); // Note: Restore will automatically cache the mint info if not already present ``` +To restore only selected units, pass a unit filter: + +```ts +await coco.wallet.restore('https://mint.url', { units: ['usd'] }); +``` + +The same unit filter is available for sweeping an older seed into the current +wallet: + +```ts +await coco.wallet.sweep('https://mint.url', oldSeed); +await coco.wallet.sweep('https://mint.url', oldSeed, { units: ['usd'] }); +``` + > **Note:** The `restore()` method will add and trust the mint automatically. If you want to display mint info to the user before proceeding use `addMint()` as described in [Adding a Mint](../starting/adding-mints.md) diff --git a/packages/docs/pages/multi-unit-support.md b/packages/docs/pages/multi-unit-support.md new file mode 100644 index 00000000..ba944f79 --- /dev/null +++ b/packages/docs/pages/multi-unit-support.md @@ -0,0 +1,115 @@ +# Multi-Unit Support + +Coco supports Cashu units beyond `sat` when the mint advertises keysets and +quote methods for that unit. Bare amount inputs keep the historical default: + +```ts +await coco.ops.send.prepare({ mintUrl, amount: 100 }); +// Equivalent to { amount: 100, unit: 'sat' } +``` + +For a custom unit, pass the amount and unit together. Unit strings are trimmed +and lowercased by Coco before validation and persistence. + +```ts +await coco.ops.mint.prepare({ + mintUrl, + amount: { amount: 25, unit: 'usd' }, + method: 'bolt11', +}); + +const preparedSend = await coco.ops.send.prepare({ + mintUrl, + amount: { amount: 5, unit: 'usd' }, +}); +``` + +All operation records, events, history entries, tokens, proofs, and persisted +adapter rows include the normalized `unit`. Coco does not silently fall back to +sats for custom-unit operations. If the mint does not support the requested unit +for the selected keyset or quote method, the operation fails. + +## Balances + +Use unit-aware balance views when an app may hold more than one unit. + +```ts +const byMintAndUnit = await coco.wallet.balances.byMintAndUnit(); +const totals = await coco.wallet.balances.totalByUnit(); + +console.log(byMintAndUnit[mintUrl]?.usd?.spendable); +console.log(totals.sat?.total); +console.log(totals.usd?.total); +``` + +The legacy-shaped `byMint()` and `total()` helpers expose a single-unit view. +Without a unit filter they keep the default sat behavior. To read a custom +single-unit view, pass `units: ['usd']`. + +```ts +const usdByMint = await coco.wallet.balances.byMint({ units: ['usd'] }); +const usdTotal = await coco.wallet.balances.total({ units: ['usd'] }); +``` + +## Receiving + +Received token units are read from the token metadata and validated against the +token proofs. The receive operation keeps that unit through prepare, execute, +recovery, history, and saved proofs. + +```ts +const prepared = await coco.ops.receive.prepare({ token }); +console.log(prepared.amount, prepared.unit); + +await coco.ops.receive.execute(prepared.id); +``` + +## Melting + +Melt quote creation uses the invoice/request unit. Public `MeltQuoteService` +methods also accept unit-aware amount inputs where applicable and continue to +default bare amounts to sats. + +```ts +const prepared = await coco.ops.melt.prepare({ + mintUrl, + method: 'bolt11', + methodData: { invoice }, +}); + +console.log(prepared.amount, prepared.unit, prepared.fee_reserve); +await coco.ops.melt.execute(prepared.id); +``` + +## Payment Requests + +Payment requests carry their own unit. `paymentRequests.parse()` returns the +normalized `unit`, finds payable mints with spendable balance in that unit, and +`prepare()` validates any provided amount against the request unit. + +```ts +const request = await coco.paymentRequests.parse(encodedRequest); + +const transaction = await coco.paymentRequests.prepare(request, { + mintUrl: request.payableMints[0], + amount: request.amount ? undefined : { amount: 5, unit: request.unit }, +}); + +await coco.paymentRequests.execute(transaction); +``` + +## Restore And Sweep + +`wallet.restore()` and `wallet.sweep()` process every keyset unit advertised by +the mint by default. Pass a unit filter when restoring or sweeping a subset. + +```ts +await coco.wallet.restore(mintUrl); +await coco.wallet.restore(mintUrl, { units: ['usd'] }); + +await coco.wallet.sweep(mintUrl, oldSeed); +await coco.wallet.sweep(mintUrl, oldSeed, { units: ['usd'] }); +``` + +Legacy proofs without stored unit metadata are treated as `sat` when no keyset +unit can be recovered. diff --git a/packages/docs/starting/minting.md b/packages/docs/starting/minting.md index aa412264..db2f10b0 100644 --- a/packages/docs/starting/minting.md +++ b/packages/docs/starting/minting.md @@ -1,6 +1,8 @@ # Minting Cashu Token -The process of swapping sats for Cashu token is called "minting". To mint with Coco you prepare a mint operation, specifying a `mintUrl` and an `amount` in sats. +The process of swapping value for a Cashu token is called "minting". To mint +with Coco you prepare a mint operation, specifying a `mintUrl` and an `amount`. +Bare amounts default to sats; for custom units pass `{ amount, unit }`. Before minting, ensure the mint is added and trusted (see [Adding a Mint](./adding-mints.md)): @@ -17,6 +19,14 @@ const pendingMint = await coco.ops.mint.prepare({ }); ``` +```ts +const customUnitMint = await coco.ops.mint.prepare({ + mintUrl: 'https://minturl.com', + amount: { amount: 10, unit: 'usd' }, + method: 'bolt11', +}); +``` + The returned pending mint operation has a `request` field containing the BOLT11 payment request that needs to be paid before minting can happen. When [Watchers and Processors](../pages/watchers-processors.md) are activated (they are by default) Coco will automatically check whether the quote has been paid and redeem it automatically. You can also execute the pending operation yourself after payment. @@ -39,4 +49,5 @@ coco.on('mint-op:finalized', (payload) => { ``` For the full state machine and action reference, see -[Mint Operations](../pages/mint-operations.md). +[Mint Operations](../pages/mint-operations.md). For multi-unit behavior, see +[Multi-Unit Support](../pages/multi-unit-support.md). diff --git a/packages/docs/starting/payment-requests.md b/packages/docs/starting/payment-requests.md index 12f4bd4d..0d5e5607 100644 --- a/packages/docs/starting/payment-requests.md +++ b/packages/docs/starting/payment-requests.md @@ -13,6 +13,7 @@ const prepared = await coco.paymentRequests.parse(paymentRequest); console.log('Transport:', prepared.transport.type); console.log('Amount:', prepared.amount); +console.log('Unit:', prepared.unit); console.log('Allowed mints:', prepared.allowedMints); console.log('Matching mints:', prepared.payableMints); ``` @@ -21,6 +22,7 @@ The returned `ResolvedPaymentRequest` contains: - **transport** - How to deliver the tokens (`inband` or `http`) - **amount** - The requested amount (optional, but required for payment) +- **unit** - The requested unit, normalized to lowercase - **allowedMints** - List of allowed mints from the request - **payableMints** - Trusted mints with sufficient balance @@ -84,7 +86,17 @@ const customTx = await coco.paymentRequests.prepare(prepared, { mintUrl, amount: const customResult = await coco.paymentRequests.execute(customTx); ``` -> **Note:** If the payment request specifies an amount, providing a different amount will throw an error. The requested amount is always exact. +For custom-unit requests without an embedded amount, provide the amount and unit +together: + +```ts +const customUnitTx = await coco.paymentRequests.prepare(prepared, { + mintUrl, + amount: { amount: 5, unit: prepared.unit }, +}); +``` + +> **Note:** If the payment request specifies an amount or unit, providing a different amount or unit will throw an error. The requested amount is always exact. ## Choosing a Mint diff --git a/packages/docs/starting/sending-receiving.md b/packages/docs/starting/sending-receiving.md index 34d00913..fc7ae563 100644 --- a/packages/docs/starting/sending-receiving.md +++ b/packages/docs/starting/sending-receiving.md @@ -28,7 +28,7 @@ You can listen for receive events: ```ts coco.on('receive-op:finalized', ({ mintUrl, operation }) => { - console.log(`Received ${operation.amount} sats from ${mintUrl}`); + console.log(`Received ${operation.amount} ${operation.unit} from ${mintUrl}`); }); ``` @@ -56,15 +56,27 @@ if (userConfirmed) { } ``` +Bare amounts are sats. For custom units, pass the amount and unit together: + +```ts +const customUnitSend = await coco.ops.send.prepare({ + mintUrl: 'https://mint.url', + amount: { amount: 5, unit: 'usd' }, +}); + +console.log('Prepared unit:', customUnitSend.unit); +``` + `coco.ops.send` and `coco.ops.receive` are the canonical workflow APIs. -For send state details, see [Send Operations](../pages/send-operations.md). +For send state details, see [Send Operations](../pages/send-operations.md). For +custom unit behavior, see [Multi-Unit Support](../pages/multi-unit-support.md). ### Understanding Fees - **Exact match** (`needsSwap: false`): No fee is charged when your proofs exactly match the send amount - **Swap required** (`needsSwap: true`): A fee is charged when proofs need to be split -The `fee` field shows the exact fee in sats that will be deducted. +The `fee` field uses the same unit as the send amount. ## Token Lifecycle @@ -75,7 +87,7 @@ After sending, the token enters a "pending" state until the recipient claims it: const pending = await coco.ops.send.listInFlight(); for (const op of pending) { - console.log(`Operation ${op.id}: ${op.amount} sats, state: ${op.state}`); + console.log(`Operation ${op.id}: ${op.amount} ${op.unit}, state: ${op.state}`); } ``` @@ -117,7 +129,7 @@ Listen for send lifecycle events: ```ts // Proofs reserved, ready to execute coco.on('send:prepared', ({ operationId, operation }) => { - console.log(`Send prepared: ${operation.amount} sats`); + console.log(`Send prepared: ${operation.amount} ${operation.unit}`); }); // Token created, waiting for recipient @@ -144,7 +156,7 @@ async function sendWithConfirmation(mintUrl: string, amount: number) { const prepared = await coco.ops.send.prepare({ mintUrl, amount }); if (prepared.needsSwap) { - console.log(`This send requires a swap. Fee: ${prepared.fee} sats`); + console.log(`This send requires a swap. Fee: ${prepared.fee} ${prepared.unit}`); const proceed = await askUserConfirmation(); if (!proceed) { diff --git a/packages/expo-sqlite/src/repositories/MeltOperationRepository.ts b/packages/expo-sqlite/src/repositories/MeltOperationRepository.ts index cfe8aefe..42d85592 100644 --- a/packages/expo-sqlite/src/repositories/MeltOperationRepository.ts +++ b/packages/expo-sqlite/src/repositories/MeltOperationRepository.ts @@ -2,6 +2,7 @@ import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-c import { deserializeAmount, normalizeMeltMethodData, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -61,6 +62,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { mintUrl: row.mintUrl, method: row.method, methodData: parseMethodData(row), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -72,7 +74,6 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { const preparedData = { quoteId: row.quoteId ?? '', - unit: row.unit ?? 'sat', amount: deserializeAmount(row.amount ?? 0), fee_reserve: deserializeAmount(row.fee_reserve ?? 0), swap_fee: deserializeAmount(row.swap_fee ?? 0), @@ -119,7 +120,7 @@ const operationToParams = (operation: MeltOperation): unknown[] => { operation.method, methodDataJson, null, - null, + operation.unit, null, null, null, @@ -220,7 +221,7 @@ export class ExpoMeltOperationRepository implements MeltOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, unit = ? WHERE id = ?`, [ operation.state, @@ -228,6 +229,7 @@ export class ExpoMeltOperationRepository implements MeltOperationRepository { operation.error ?? null, operation.method, stringifyJson(operation.methodData), + operation.unit, operation.id, ], ); diff --git a/packages/expo-sqlite/src/repositories/ProofRepository.ts b/packages/expo-sqlite/src/repositories/ProofRepository.ts index c1f00674..f8fc6b96 100644 --- a/packages/expo-sqlite/src/repositories/ProofRepository.ts +++ b/packages/expo-sqlite/src/repositories/ProofRepository.ts @@ -1,7 +1,10 @@ import { + DEFAULT_UNIT, deserializeAmount, + normalizeUnit, serializeAmount, type ProofRepository, + type ProofUnitFilter, type CoreProof, type ProofState, } from '@cashu/coco-core'; @@ -10,6 +13,7 @@ import { ExpoSqliteDb, getUnixTimeSeconds } from '../db.ts'; interface ProofRow { mintUrl: string; id: string; + unit: string | null; amount: string | number; secret: string; C: string; @@ -22,6 +26,30 @@ interface ProofRow { const MAX_PROOF_SECRET_LOOKUP_BATCH_SIZE = 900; +const PROOF_COLUMNS = + 'mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId'; + +function normalizeProofUnit(proof: CoreProof): string { + return normalizeUnit((proof as { unit?: string }).unit); +} + +function getUnitFilter(filter?: ProofUnitFilter): string[] | undefined { + const units = [...(filter?.units ?? []), ...(filter?.unit ? [filter.unit] : [])]; + if (units.length === 0) return undefined; + return Array.from(new Set(units.map((unit) => normalizeUnit(unit)))); +} + +function appendUnitFilter(sql: string, params: unknown[], filter?: ProofUnitFilter): string { + const units = getUnitFilter(filter); + if (!units || units.length === 0) return sql; + if (units.length === 1) { + params.push(units[0]); + return `${sql} AND unit = ?`; + } + params.push(...units); + return `${sql} AND unit IN (${units.map(() => '?').join(', ')})`; +} + function rowToProof(r: ProofRow): CoreProof { const base = { id: r.id, @@ -34,6 +62,7 @@ function rowToProof(r: ProofRow): CoreProof { return { ...base, mintUrl: r.mintUrl, + unit: normalizeUnit(r.unit ?? undefined, { defaultUnit: DEFAULT_UNIT }), state: r.state, ...(r.usedByOperationId ? { usedByOperationId: r.usedByOperationId } : {}), ...(r.createdByOperationId ? { createdByOperationId: r.createdByOperationId } : {}), @@ -50,23 +79,28 @@ export class ExpoProofRepository implements ProofRepository { async saveProofs(mintUrl: string, proofs: CoreProof[]): Promise { if (!proofs || proofs.length === 0) return; const now = getUnixTimeSeconds(); + const normalizedProofs = proofs.map((proof) => ({ + ...proof, + unit: normalizeProofUnit(proof), + })); await this.db.transaction(async (tx) => { const selectSql = 'SELECT 1 AS x FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ? LIMIT 1'; - for (const p of proofs) { + for (const p of normalizedProofs) { const exists = await tx.get<{ x: number }>(selectSql, [mintUrl, p.secret]); if (exists) { throw new Error(`Proof with secret already exists: ${p.secret}`); } } const insertSql = - 'INSERT INTO coco_cashu_proofs (mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; - for (const p of proofs) { + 'INSERT INTO coco_cashu_proofs (mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + for (const p of normalizedProofs) { const dleqJson = p.dleq ? JSON.stringify(p.dleq) : null; const witnessJson = p.witness ? JSON.stringify(p.witness) : null; await tx.run(insertSql, [ mintUrl, p.id, + p.unit, serializeAmount(p.amount), p.secret, p.C, @@ -81,18 +115,29 @@ export class ExpoProofRepository implements ProofRepository { }); } - async getReadyProofs(mintUrl: string): Promise { + async getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const params: unknown[] = [mintUrl]; const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND state = "ready"', - [mintUrl], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND state = "ready"`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getInflightProofs(mintUrls?: string[]): Promise { + async getInflightProofs(mintUrls?: string[], filter?: ProofUnitFilter): Promise { if (!mintUrls || mintUrls.length === 0) { + const params: unknown[] = []; const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = "inflight"', + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = "inflight"`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } @@ -100,24 +145,44 @@ export class ExpoProofRepository implements ProofRepository { if (mintUrlList.length === 0) return []; const uniqueMintUrls = Array.from(new Set(mintUrlList)); const placeholders = uniqueMintUrls.map(() => '?').join(', '); + const params: unknown[] = uniqueMintUrls; const rows = await this.db.all( - `SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = "inflight" AND mintUrl IN (${placeholders})`, - uniqueMintUrls, + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = "inflight" AND mintUrl IN (${placeholders})`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getAllReadyProofs(): Promise { + async getAllReadyProofs(filter?: ProofUnitFilter): Promise { + const params: unknown[] = []; const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = "ready"', + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = "ready"`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getProofsByKeysetId(mintUrl: string, keysetId: string): Promise { + async getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise { + const params: unknown[] = [mintUrl, keysetId]; const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND id = ? AND state = "ready"', - [mintUrl, keysetId], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND id = ? AND state = "ready"`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } @@ -210,7 +275,7 @@ export class ExpoProofRepository implements ProofRepository { async getProofBySecret(mintUrl: string, secret: string): Promise { const row = await this.db.get( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?`, [mintUrl, secret], ); return row ? rowToProof(row) : null; @@ -228,7 +293,7 @@ export class ExpoProofRepository implements ProofRepository { const secretBatch = uniqueSecrets.slice(i, i + MAX_PROOF_SECRET_LOOKUP_BATCH_SIZE); const placeholders = secretBatch.map(() => '?').join(', '); const rows = await this.db.all( - `SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND secret IN (${placeholders})`, + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND secret IN (${placeholders})`, [mintUrl, ...secretBatch], ); @@ -245,23 +310,28 @@ export class ExpoProofRepository implements ProofRepository { async getProofsByOperationId(mintUrl: string, operationId: string): Promise { const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND (usedByOperationId = ? OR createdByOperationId = ?)', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND (usedByOperationId = ? OR createdByOperationId = ?)`, [mintUrl, operationId, operationId], ); return rows.map(rowToProof); } - async getAvailableProofs(mintUrl: string): Promise { + async getAvailableProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const params: unknown[] = [mintUrl]; const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND state = "ready" AND usedByOperationId IS NULL', - [mintUrl], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND state = "ready" AND usedByOperationId IS NULL`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } async getReservedProofs(): Promise { const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = "ready" AND usedByOperationId IS NOT NULL', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = "ready" AND usedByOperationId IS NOT NULL`, ); return rows.map(rowToProof); } diff --git a/packages/expo-sqlite/src/repositories/SendOperationRepository.ts b/packages/expo-sqlite/src/repositories/SendOperationRepository.ts index 587efce6..620ec8bc 100644 --- a/packages/expo-sqlite/src/repositories/SendOperationRepository.ts +++ b/packages/expo-sqlite/src/repositories/SendOperationRepository.ts @@ -7,6 +7,7 @@ import type { import { deserializeAmount, deserializeToken, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -16,6 +17,7 @@ interface SendOperationRow { id: string; mintUrl: string; amount: string | number; + unit: string | null; state: SendOperationState; createdAt: number; updatedAt: number; @@ -44,6 +46,7 @@ function rowToOperation(row: SendOperationRow): SendOperation { id: row.id, mintUrl: row.mintUrl, amount: deserializeAmount(row.amount), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, // Convert seconds to milliseconds updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -111,6 +114,7 @@ function operationToParams(op: SendOperation): unknown[] { op.id, op.mintUrl, serializeAmount(op.amount), + op.unit, op.state, createdAtSeconds, updatedAtSeconds, @@ -131,6 +135,7 @@ function operationToParams(op: SendOperation): unknown[] { op.id, op.mintUrl, serializeAmount(op.amount), + op.unit, op.state, createdAtSeconds, updatedAtSeconds, @@ -165,8 +170,8 @@ export class ExpoSendOperationRepository implements SendOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_send_operations - (id, mintUrl, amount, state, createdAt, updatedAt, error, method, methodDataJson, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, amount, unit, state, createdAt, updatedAt, error, method, methodDataJson, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -185,19 +190,20 @@ export class ExpoSendOperationRepository implements SendOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_send_operations - SET state = ?, updatedAt = ?, error = ? + SET state = ?, updatedAt = ?, error = ?, unit = ? WHERE id = ?`, - [operation.state, updatedAtSeconds, operation.error ?? null, operation.id], + [operation.state, updatedAtSeconds, operation.error ?? null, operation.unit, operation.id], ); } else { await this.db.run( `UPDATE coco_cashu_send_operations - SET state = ?, updatedAt = ?, error = ?, needsSwap = ?, fee = ?, inputAmount = ?, inputProofSecretsJson = ?, outputDataJson = ?, tokenJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, needsSwap = ?, fee = ?, inputAmount = ?, inputProofSecretsJson = ?, outputDataJson = ?, tokenJson = ? WHERE id = ?`, [ operation.state, updatedAtSeconds, operation.error ?? null, + operation.unit, operation.needsSwap ? 1 : 0, serializeAmount(operation.fee), serializeAmount(operation.inputAmount), diff --git a/packages/expo-sqlite/src/schema.ts b/packages/expo-sqlite/src/schema.ts index 6828cbb2..1cb0e811 100644 --- a/packages/expo-sqlite/src/schema.ts +++ b/packages/expo-sqlite/src/schema.ts @@ -62,6 +62,57 @@ async function addSendOperationMethodColumns(db: ExpoSqliteDb): Promise { } } +async function addProofUnitColumn(db: ExpoSqliteDb): Promise { + const columns = await getTableColumns(db, 'coco_cashu_proofs'); + + if (!columns.has('unit')) { + await db.run(`ALTER TABLE coco_cashu_proofs ADD COLUMN unit TEXT NOT NULL DEFAULT 'sat'`); + } + + await db.run(` + UPDATE coco_cashu_proofs + SET unit = COALESCE( + ( + SELECT LOWER(TRIM(coco_cashu_keysets.unit)) + FROM coco_cashu_keysets + WHERE coco_cashu_keysets.mintUrl = coco_cashu_proofs.mintUrl + AND coco_cashu_keysets.id = coco_cashu_proofs.id + AND coco_cashu_keysets.unit IS NOT NULL + AND TRIM(coco_cashu_keysets.unit) <> '' + LIMIT 1 + ), + CASE + WHEN unit IS NULL OR TRIM(unit) = '' THEN 'sat' + ELSE LOWER(TRIM(unit)) + END + ) + `); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state)', + ); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state)', + ); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state)', + ); +} + +async function addSendOperationUnitColumn(db: ExpoSqliteDb): Promise { + const columns = await getTableColumns(db, 'coco_cashu_send_operations'); + + if (!columns.has('unit')) { + await db.run( + `ALTER TABLE coco_cashu_send_operations ADD COLUMN unit TEXT NOT NULL DEFAULT 'sat'`, + ); + } + + await db.run( + "UPDATE coco_cashu_send_operations SET unit = 'sat' WHERE unit IS NULL OR TRIM(unit) = ''", + ); + await db.run('UPDATE coco_cashu_send_operations SET unit = LOWER(TRIM(unit))'); +} + async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { if (await tableExists(db, 'coco_cashu_proofs')) { await db.exec(` @@ -70,6 +121,7 @@ async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { CREATE TABLE coco_cashu_proofs ( mintUrl TEXT NOT NULL, id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', amount TEXT NOT NULL, secret TEXT NOT NULL, C TEXT NOT NULL, @@ -83,11 +135,25 @@ async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { ); INSERT INTO coco_cashu_proofs ( - mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, createdAt, + mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId ) SELECT - mintUrl, id, CAST(amount AS TEXT), secret, C, dleqJson, witnessJson, state, createdAt, + mintUrl, + id, + COALESCE( + ( + SELECT LOWER(TRIM(coco_cashu_keysets.unit)) + FROM coco_cashu_keysets + WHERE coco_cashu_keysets.mintUrl = coco_cashu_proofs_legacy_amounts.mintUrl + AND coco_cashu_keysets.id = coco_cashu_proofs_legacy_amounts.id + AND coco_cashu_keysets.unit IS NOT NULL + AND TRIM(coco_cashu_keysets.unit) <> '' + LIMIT 1 + ), + 'sat' + ), + CAST(amount AS TEXT), secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId FROM coco_cashu_proofs_legacy_amounts; @@ -96,6 +162,9 @@ async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_state ON coco_cashu_proofs(state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_state ON coco_cashu_proofs(mintUrl, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_id_state ON coco_cashu_proofs(mintUrl, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_usedByOp ON coco_cashu_proofs(usedByOperationId) WHERE usedByOperationId IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_createdByOp ON coco_cashu_proofs(createdByOperationId) WHERE createdByOperationId IS NOT NULL; `); @@ -218,6 +287,7 @@ async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { id TEXT PRIMARY KEY, mintUrl TEXT NOT NULL, amount TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', state TEXT NOT NULL CHECK (state IN ('init', 'prepared', 'executing', 'pending', 'finalized', 'rolling_back', 'rolled_back')), createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, @@ -233,11 +303,11 @@ async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { ); INSERT INTO coco_cashu_send_operations ( - id, mintUrl, amount, state, createdAt, updatedAt, error, needsSwap, fee, + id, mintUrl, amount, unit, state, createdAt, updatedAt, error, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, method, methodDataJson, tokenJson ) SELECT - id, mintUrl, CAST(amount AS TEXT), state, createdAt, updatedAt, error, needsSwap, + id, mintUrl, CAST(amount AS TEXT), 'sat', state, createdAt, updatedAt, error, needsSwap, CASE WHEN fee IS NULL THEN NULL ELSE CAST(fee AS TEXT) END, CASE WHEN inputAmount IS NULL THEN NULL ELSE CAST(inputAmount AS TEXT) END, inputProofSecretsJson, outputDataJson, method, methodDataJson, tokenJson @@ -440,6 +510,7 @@ const MIGRATIONS: readonly Migration[] = [ CREATE TABLE IF NOT EXISTS coco_cashu_proofs ( mintUrl TEXT NOT NULL, id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', amount INTEGER NOT NULL, secret TEXT NOT NULL, C TEXT NOT NULL, @@ -453,6 +524,9 @@ const MIGRATIONS: readonly Migration[] = [ CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_state ON coco_cashu_proofs(state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_state ON coco_cashu_proofs(mintUrl, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_id_state ON coco_cashu_proofs(mintUrl, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state); CREATE TABLE IF NOT EXISTS coco_cashu_mint_quotes ( mintUrl TEXT NOT NULL, @@ -948,6 +1022,14 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_proof_unit', + run: addProofUnitColumn, + }, + { + id: '026_send_operation_unit', + run: addSendOperationUnitColumn, + }, ]; // Export for testing diff --git a/packages/expo-sqlite/src/test/SendOperationRepository.test.ts b/packages/expo-sqlite/src/test/SendOperationRepository.test.ts index 7c27faea..09b0fa7b 100644 --- a/packages/expo-sqlite/src/test/SendOperationRepository.test.ts +++ b/packages/expo-sqlite/src/test/SendOperationRepository.test.ts @@ -58,6 +58,7 @@ function makeRollingBackOperation(): RollingBackSendOperation { id: 'send-op-1', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'rolling_back', method: 'default', methodData: {}, @@ -75,6 +76,7 @@ function makePendingP2pkOperation(): PendingSendOperation { id: 'send-op-p2pk', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'pending', method: 'p2pk', methodData: { pubkey: '02' + '11'.repeat(32) }, @@ -91,9 +93,9 @@ function makePendingP2pkOperation(): PendingSendOperation { token: { mint: 'https://mint.test', proofs: [{ id: 'keyset-1', amount: Amount.from(100), secret: 'send-secret', C: 'C_send' }], - unit: 'sat', + unit: 'usd', }, - } as PendingSendOperation; + }; } describe('ExpoSendOperationRepository', () => { diff --git a/packages/expo-sqlite/src/test/contract.test.ts b/packages/expo-sqlite/src/test/contract.test.ts index c651108e..439d4e9d 100644 --- a/packages/expo-sqlite/src/test/contract.test.ts +++ b/packages/expo-sqlite/src/test/contract.test.ts @@ -6,6 +6,8 @@ import { runProofRepositoryContract, runMintOperationRepositoryContract, runReceiveOperationRepositoryContract, + runSendOperationRepositoryContract, + runMeltOperationRepositoryContract, createDummyMint, createDummyKeyset, createDummyProof, @@ -124,6 +126,10 @@ runMintOperationRepositoryContract({ createRepositories }, { describe, it, expec runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMeltOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('expo-sqlite adapter transactions', () => { it('commits across repositories', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/expo-sqlite/src/test/integration.test.ts b/packages/expo-sqlite/src/test/integration.test.ts index d7858c68..e007a745 100644 --- a/packages/expo-sqlite/src/test/integration.test.ts +++ b/packages/expo-sqlite/src/test/integration.test.ts @@ -6,6 +6,7 @@ import type { ExpoSqliteRepositoriesOptions } from '../index.ts'; import { ConsoleLogger, type Logger } from '@cashu/coco-core'; const mintUrl = process.env.MINT_URL; +const customUnit = process.env.CUSTOM_UNIT; if (!mintUrl) { throw new Error('MINT_URL is not set'); @@ -88,6 +89,7 @@ runIntegrationTests( { createRepositories, mintUrl, + customUnit, logger: getTestLogger(), suiteName: 'Expo SQLite Integration Tests', }, diff --git a/packages/expo-sqlite/src/test/schema.test.ts b/packages/expo-sqlite/src/test/schema.test.ts index c3e93714..8c36db0d 100644 --- a/packages/expo-sqlite/src/test/schema.test.ts +++ b/packages/expo-sqlite/src/test/schema.test.ts @@ -156,6 +156,52 @@ describe('expo-sqlite schema migrations', () => { ); }); + it('backfills legacy proof units from keyset metadata', async () => { + await ensureSchemaUpTo(db, '025_proof_unit'); + await db.run( + `INSERT INTO coco_cashu_keysets + (mintUrl, id, keypairs, active, feePpk, updatedAt, unit) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'usd-keyset', '{}', 1, 0, 1, 'USD'], + ); + await db.run( + `INSERT INTO coco_cashu_proofs + (mintUrl, id, unit, amount, secret, C, state, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'usd-keyset', 'sat', '10', 'secret-usd', 'C-usd', 'ready', 1], + ); + + await ensureSchemaUpTo(db); + + const proof = await db.get<{ unit: string }>( + 'SELECT unit FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + ['https://mint.test', 'secret-usd'], + ); + const indexes = await db.all<{ name: string }>('PRAGMA index_list(coco_cashu_proofs)'); + + expect(proof?.unit).toBe('usd'); + expect(indexes.map((row) => row.name)).toContain('idx_coco_cashu_proofs_mint_unit_id_state'); + }); + + it('keeps legacy proof units as sat when keyset metadata is missing', async () => { + await ensureSchemaUpTo(db, '025_proof_unit'); + await db.run( + `INSERT INTO coco_cashu_proofs + (mintUrl, id, unit, amount, secret, C, state, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'missing-keyset', 'sat', '10', 'secret-legacy', 'C-legacy', 'ready', 1], + ); + + await ensureSchemaUpTo(db); + + const proof = await db.get<{ unit: string }>( + 'SELECT unit FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + ['https://mint.test', 'secret-legacy'], + ); + + expect(proof?.unit).toBe('sat'); + }); + it('backfills legacy aliases for canonical databases', async () => { await ensureSchemaUpTo(db); diff --git a/packages/indexeddb/src/env.d.ts b/packages/indexeddb/src/env.d.ts index b7e69b33..de05c52e 100644 --- a/packages/indexeddb/src/env.d.ts +++ b/packages/indexeddb/src/env.d.ts @@ -1,5 +1,6 @@ interface ImportMetaEnv { readonly VITE_MINT_URL?: string; + readonly VITE_CUSTOM_UNIT?: string; readonly VITE_TEST_LOG_LEVEL?: string; } diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index b0046a7e..8d9ede04 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -134,6 +134,7 @@ export interface CounterRow { export interface ProofRow { mintUrl: string; id: string; + unit?: string | null; amount: string | number; secret: string; C: string; @@ -172,6 +173,7 @@ export interface SendOperationRow { id: string; mintUrl: string; amount: string | number; + unit?: string | null; state: | 'init' | 'prepared' diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index 0eca0edb..973a4ab7 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -6,6 +6,11 @@ function normalizeStoredAmount(value: unknown): string | null | undefined { return String(value); } +function normalizeStoredUnit(value: unknown): string { + if (typeof value !== 'string' || value.trim().length === 0) return 'sat'; + return value.trim().toLowerCase(); +} + export async function ensureSchema(db: IdbDb): Promise { // Dexie schema with final versioned stores (flattened for first release) db.version(1).stores({ @@ -553,4 +558,95 @@ export async function ensureSchema(db: IdbDb): Promise { row.amount = normalizeStoredAmount(row.amount); }); }); + + // Version 19: Persist proof units and add unit-aware proof indexes. + db.version(19) + .stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl', + coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + }) + .upgrade(async (tx) => { + const keysets = (await tx.table('coco_cashu_keysets').toArray()) as Array<{ + mintUrl?: unknown; + id?: unknown; + unit?: unknown; + }>; + const unitByKeyset = new Map(); + for (const keyset of keysets) { + if (typeof keyset.mintUrl !== 'string' || typeof keyset.id !== 'string') continue; + unitByKeyset.set(`${keyset.mintUrl}\0${keyset.id}`, normalizeStoredUnit(keyset.unit)); + } + + await tx + .table('coco_cashu_proofs') + .toCollection() + .modify((row: { mintUrl?: unknown; id?: unknown; unit?: unknown }) => { + const keysetKey = + typeof row.mintUrl === 'string' && typeof row.id === 'string' + ? `${row.mintUrl}\0${row.id}` + : undefined; + const keysetUnit = keysetKey ? unitByKeyset.get(keysetKey) : undefined; + row.unit = normalizeStoredUnit(keysetUnit ?? row.unit); + }); + }); + + // Version 20: Persist send operation units for recovery-safe multi-unit sends. + db.version(20) + .stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl', + coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + }) + .upgrade(async (tx) => { + await tx + .table('coco_cashu_send_operations') + .toCollection() + .modify((row: { unit?: unknown }) => { + row.unit = normalizeStoredUnit(row.unit); + }); + }); + + // Version 21: Add mint/unit/keyset proof index for unit-aware keyset scans. + db.version(21).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl', + coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: '&id, state, mintUrl, [mintUrl+quoteId]', + }); } diff --git a/packages/indexeddb/src/repositories/MeltOperationRepository.ts b/packages/indexeddb/src/repositories/MeltOperationRepository.ts index ccfb2483..79f9682e 100644 --- a/packages/indexeddb/src/repositories/MeltOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MeltOperationRepository.ts @@ -2,6 +2,7 @@ import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-c import { deserializeAmount, normalizeMeltMethodData, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -37,6 +38,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { mintUrl: row.mintUrl, method: row.method as MeltOperation['method'], methodData: parseMethodData(row), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -48,7 +50,6 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { const preparedData = { quoteId: row.quoteId ?? '', - unit: row.unit ?? 'sat', amount: deserializeAmount(row.amount ?? 0), fee_reserve: deserializeAmount(row.fee_reserve ?? 0), swap_fee: deserializeAmount(row.swap_fee ?? 0), @@ -104,6 +105,7 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { error: operation.error ?? null, method: operation.method, methodDataJson, + unit: operation.unit, quoteId: null, amount: null, fee_reserve: null, diff --git a/packages/indexeddb/src/repositories/ProofRepository.ts b/packages/indexeddb/src/repositories/ProofRepository.ts index d6b4abb1..9a6f2f56 100644 --- a/packages/indexeddb/src/repositories/ProofRepository.ts +++ b/packages/indexeddb/src/repositories/ProofRepository.ts @@ -1,7 +1,24 @@ -import type { ProofRepository, CoreProof, ProofState } from '@cashu/coco-core'; -import { deserializeAmount, serializeAmount } from '@cashu/coco-core'; +import type { ProofRepository, ProofUnitFilter, CoreProof, ProofState } from '@cashu/coco-core'; +import { DEFAULT_UNIT, deserializeAmount, normalizeUnit, serializeAmount } from '@cashu/coco-core'; import type { IdbDb, ProofRow } from '../lib/db.ts'; +function normalizeProofUnit(proof: CoreProof): string { + return normalizeUnit((proof as { unit?: string }).unit); +} + +function getUnitFilter(filter?: ProofUnitFilter): Set | undefined { + const units = [...(filter?.units ?? []), ...(filter?.unit ? [filter.unit] : [])]; + if (units.length === 0) return undefined; + return new Set(units.map((unit) => normalizeUnit(unit))); +} + +function matchesUnit(row: ProofRow, unitFilter?: Set): boolean { + return ( + !unitFilter || + unitFilter.has(normalizeUnit(row.unit ?? undefined, { defaultUnit: DEFAULT_UNIT })) + ); +} + function rowToProof(r: ProofRow): CoreProof { const base = { id: r.id, @@ -14,6 +31,7 @@ function rowToProof(r: ProofRow): CoreProof { return { ...base, mintUrl: r.mintUrl, + unit: normalizeUnit(r.unit ?? undefined, { defaultUnit: DEFAULT_UNIT }), state: r.state, ...(r.usedByOperationId ? { usedByOperationId: r.usedByOperationId } : {}), ...(r.createdByOperationId ? { createdByOperationId: r.createdByOperationId } : {}), @@ -30,18 +48,23 @@ export class IdbProofRepository implements ProofRepository { async saveProofs(mintUrl: string, proofs: CoreProof[]): Promise { if (!proofs || proofs.length === 0) return; const now = Math.floor(Date.now() / 1000); + const normalizedProofs = proofs.map((proof) => ({ + ...proof, + unit: normalizeProofUnit(proof), + })); await this.db.runTransaction('rw', ['coco_cashu_proofs'], async (tx) => { const table = tx.table('coco_cashu_proofs'); - for (const p of proofs) { + for (const p of normalizedProofs) { const existing = await table.get([mintUrl, p.secret]); if (existing) { throw new Error(`Proof with secret already exists: ${p.secret}`); } } - for (const p of proofs) { + for (const p of normalizedProofs) { const row: ProofRow = { mintUrl, id: p.id, + unit: p.unit, amount: serializeAmount(p.amount), secret: p.secret, C: p.C, @@ -57,23 +80,25 @@ export class IdbProofRepository implements ProofRepository { }); } - async getReadyProofs(mintUrl: string): Promise { + async getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const unitFilter = getUnitFilter(filter); const rows = (await (this.db as any) .table('coco_cashu_proofs') .where('[mintUrl+state]') .equals([mintUrl, 'ready']) .toArray()) as ProofRow[]; - return rows.map(rowToProof); + return rows.filter((row) => matchesUnit(row, unitFilter)).map(rowToProof); } - async getInflightProofs(mintUrls?: string[]): Promise { + async getInflightProofs(mintUrls?: string[], filter?: ProofUnitFilter): Promise { + const unitFilter = getUnitFilter(filter); if (!mintUrls || mintUrls.length === 0) { const rows = (await (this.db as any) .table('coco_cashu_proofs') .where('state') .equals('inflight') .toArray()) as ProofRow[]; - return rows.map(rowToProof); + return rows.filter((row) => matchesUnit(row, unitFilter)).map(rowToProof); } const mintUrlList = mintUrls.map((url) => url.trim()).filter((url) => url.length > 0); if (mintUrlList.length === 0) return []; @@ -84,25 +109,31 @@ export class IdbProofRepository implements ProofRepository { .where('[mintUrl+state]') .anyOf(keys) .toArray()) as ProofRow[]; - return rows.map(rowToProof); + return rows.filter((row) => matchesUnit(row, unitFilter)).map(rowToProof); } - async getAllReadyProofs(): Promise { + async getAllReadyProofs(filter?: ProofUnitFilter): Promise { + const unitFilter = getUnitFilter(filter); const rows = (await (this.db as any) .table('coco_cashu_proofs') .where('state') .equals('ready') .toArray()) as ProofRow[]; - return rows.map(rowToProof); + return rows.filter((row) => matchesUnit(row, unitFilter)).map(rowToProof); } - async getProofsByKeysetId(mintUrl: string, keysetId: string): Promise { + async getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise { + const unitFilter = getUnitFilter(filter); const rows = (await (this.db as any) .table('coco_cashu_proofs') .where('[mintUrl+id+state]') .equals([mintUrl, keysetId, 'ready']) .toArray()) as ProofRow[]; - return rows.map(rowToProof); + return rows.filter((row) => matchesUnit(row, unitFilter)).map(rowToProof); } async setProofState(mintUrl: string, secrets: string[], state: ProofState): Promise { @@ -250,13 +281,14 @@ export class IdbProofRepository implements ProofRepository { return results; } - async getAvailableProofs(mintUrl: string): Promise { + async getAvailableProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const unitFilter = getUnitFilter(filter); const rows = (await (this.db as any) .table('coco_cashu_proofs') .where('[mintUrl+state]') .equals([mintUrl, 'ready']) .toArray()) as ProofRow[]; - return rows.filter((r) => !r.usedByOperationId).map(rowToProof); + return rows.filter((r) => !r.usedByOperationId && matchesUnit(r, unitFilter)).map(rowToProof); } async getReservedProofs(): Promise { diff --git a/packages/indexeddb/src/repositories/SendOperationRepository.test.ts b/packages/indexeddb/src/repositories/SendOperationRepository.test.ts index a55f917c..4203c7c1 100644 --- a/packages/indexeddb/src/repositories/SendOperationRepository.test.ts +++ b/packages/indexeddb/src/repositories/SendOperationRepository.test.ts @@ -44,6 +44,7 @@ describe('IdbSendOperationRepository', () => { id: 'op-1', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'sat', state: 'init', createdAt: 1000, updatedAt: 2000, @@ -58,6 +59,7 @@ describe('IdbSendOperationRepository', () => { id: 'op-p2pk', mintUrl: 'https://mint.test', amount: 100, + unit: 'USD', state: 'pending', createdAt: 1, updatedAt: 2, @@ -72,7 +74,7 @@ describe('IdbSendOperationRepository', () => { tokenJson: JSON.stringify({ mint: 'https://mint.test', proofs: [{ id: 'keyset-1', amount: Amount.from(100), secret: 'send-secret', C: 'C_send' }], - unit: 'sat', + unit: 'usd', }), } satisfies SendOperationRow; @@ -82,6 +84,7 @@ describe('IdbSendOperationRepository', () => { id: 'op-p2pk', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'pending', createdAt: 1000, updatedAt: 2000, @@ -96,7 +99,7 @@ describe('IdbSendOperationRepository', () => { token: { mint: 'https://mint.test', proofs: [{ id: 'keyset-1', amount: Amount.from(100), secret: 'send-secret', C: 'C_send' }], - unit: 'sat', + unit: 'usd', }, }); }); diff --git a/packages/indexeddb/src/repositories/SendOperationRepository.ts b/packages/indexeddb/src/repositories/SendOperationRepository.ts index e518a15e..99ee9986 100644 --- a/packages/indexeddb/src/repositories/SendOperationRepository.ts +++ b/packages/indexeddb/src/repositories/SendOperationRepository.ts @@ -7,6 +7,7 @@ import type { import { deserializeAmount, deserializeToken, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -42,6 +43,7 @@ function rowToOperation(row: SendOperationRow): SendOperation { id: row.id, mintUrl: row.mintUrl, amount: deserializeAmount(row.amount), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, // Convert seconds to milliseconds updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -109,6 +111,7 @@ function operationToRow(op: SendOperation): SendOperationRow { id: op.id, mintUrl: op.mintUrl, amount: serializeAmount(op.amount), + unit: op.unit, state: op.state, createdAt: createdAtSeconds, updatedAt: updatedAtSeconds, @@ -129,6 +132,7 @@ function operationToRow(op: SendOperation): SendOperationRow { id: op.id, mintUrl: op.mintUrl, amount: serializeAmount(op.amount), + unit: op.unit, state: op.state, createdAt: createdAtSeconds, updatedAt: updatedAtSeconds, diff --git a/packages/indexeddb/src/test/contract.test.ts b/packages/indexeddb/src/test/contract.test.ts index e0d0ab37..af6638a7 100644 --- a/packages/indexeddb/src/test/contract.test.ts +++ b/packages/indexeddb/src/test/contract.test.ts @@ -5,6 +5,8 @@ import { runProofRepositoryContract, runMintOperationRepositoryContract, runReceiveOperationRepositoryContract, + runSendOperationRepositoryContract, + runMeltOperationRepositoryContract, } from '@cashu/coco-adapter-tests'; import { IndexedDbRepositories } from '../index.ts'; @@ -34,3 +36,7 @@ runProofRepositoryContract({ createRepositories }, { describe, it, expect }); runMintOperationRepositoryContract({ createRepositories }, { describe, it, expect }); runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + +runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMeltOperationRepositoryContract({ createRepositories }, { describe, it, expect }); diff --git a/packages/indexeddb/src/test/integration.test.ts b/packages/indexeddb/src/test/integration.test.ts index e172292d..e5cad194 100644 --- a/packages/indexeddb/src/test/integration.test.ts +++ b/packages/indexeddb/src/test/integration.test.ts @@ -4,6 +4,7 @@ import { IndexedDbRepositories } from '../index.ts'; import { ConsoleLogger, type Logger } from '@cashu/coco-core'; const mintUrl = import.meta.env.VITE_MINT_URL || 'http://localhost:3338'; +const customUnit = import.meta.env.VITE_CUSTOM_UNIT; if (!mintUrl) { throw new Error('VITE_MINT_URL is not set'); @@ -38,6 +39,7 @@ runIntegrationTests( { createRepositories, mintUrl, + customUnit, logger: getTestLogger(), suiteName: 'IndexedDB Integration Tests', }, diff --git a/packages/react/src/lib/contexts/BalanceContext.ts b/packages/react/src/lib/contexts/BalanceContext.ts index 8c0e6819..a1ec8d72 100644 --- a/packages/react/src/lib/contexts/BalanceContext.ts +++ b/packages/react/src/lib/contexts/BalanceContext.ts @@ -1,9 +1,17 @@ -import type { BalanceSnapshot, BalancesByMint } from '@cashu/coco-core'; +import type { + BalanceSnapshot, + BalancesByMint, + BalancesByMintAndUnit, + BalancesByUnit, +} from '@cashu/coco-core'; import { createContext, useContext } from 'react'; export type WalletBalancesValue = { byMint: BalancesByMint; + byMintAndUnit: BalancesByMintAndUnit; + byUnit: BalancesByUnit; total: BalanceSnapshot; + totalByUnit: BalancesByUnit; }; export type BalanceContextValue = { diff --git a/packages/react/src/lib/hooks/operationHooks.test.tsx b/packages/react/src/lib/hooks/operationHooks.test.tsx index b20ed1ea..f33172ac 100644 --- a/packages/react/src/lib/hooks/operationHooks.test.tsx +++ b/packages/react/src/lib/hooks/operationHooks.test.tsx @@ -232,6 +232,7 @@ function createPreparedSendOperation( state: 'prepared', mintUrl: MINT_URL, amount: Amount.from(100), + unit: 'sat', method: 'default', methodData: {}, createdAt: 1_700_000_000_000, @@ -440,6 +441,31 @@ function createRolledBackMeltOperation( } describe('useSendOperation', () => { + it('passes object-form custom-unit amount inputs through to send prepare', async () => { + const { manager, send } = createSendManagerMock(); + const prepared = createPreparedSendOperation({ + amount: Amount.from(25), + unit: 'usd', + }); + const input: SendOperationPrepareInput = { + mintUrl: MINT_URL, + amount: { amount: Amount.from(25), unit: 'USD' }, + }; + + send.prepare.mockResolvedValue(prepared); + + const { result } = renderHook(() => useSendOperation(), { + wrapper: createHookWrapper(manager), + }); + + await act(async () => { + await result.current.prepare(input); + }); + + expect(send.prepare).toHaveBeenCalledWith(input); + expect(result.current.currentOperation).toEqual(prepared); + }); + it('prepares, executes the bound operation by default, and synchronizes after finalize', async () => { const { manager, send } = createSendManagerMock(); const prepared = createPreparedSendOperation(); @@ -996,6 +1022,32 @@ describe('useReceiveOperation', () => { }); describe('useMintOperation', () => { + it('passes object-form custom-unit amount inputs through to mint prepare', async () => { + const { manager, mint } = createMintManagerMock(); + const pending = createPendingMintOperation({ + amount: Amount.from(50), + unit: 'usd', + }); + const input: MintOperationPrepareInput = { + mintUrl: MINT_URL, + amount: { amount: Amount.from(50), unit: 'USD' }, + method: 'bolt11', + }; + + mint.prepare.mockResolvedValue(pending); + + const { result } = renderHook(() => useMintOperation(), { + wrapper: createHookWrapper(manager), + }); + + await act(async () => { + await result.current.prepare(input); + }); + + expect(mint.prepare).toHaveBeenCalledWith(input); + expect(result.current.currentOperation).toEqual(pending); + }); + it('binds newly prepared and imported quotes when starting unbound', async () => { const { manager, mint } = createMintManagerMock(); const pending = createPendingMintOperation(); @@ -1251,6 +1303,30 @@ describe('useMintOperation', () => { }); describe('useMeltOperation', () => { + it('passes custom-unit melt inputs through to melt prepare', async () => { + const { manager, melt } = createMeltManagerMock(); + const prepared = createPreparedMeltOperation({ unit: 'usd' }); + melt.prepare.mockResolvedValue(prepared); + + const { result } = renderHook(() => useMeltOperation(), { + wrapper: createHookWrapper(manager), + }); + + const input: MeltOperationPrepareInput = { + mintUrl: MINT_URL, + method: 'bolt11', + methodData: { invoice: 'lnbc1meltinvoice' }, + unit: 'USD', + }; + + await act(async () => { + await result.current.prepare(input); + }); + + expect(melt.prepare).toHaveBeenCalledWith(input); + expect(result.current.currentOperation).toEqual(prepared); + }); + it('prepares, executes the bound operation by default, and synchronizes after finalize', async () => { const { manager, melt } = createMeltManagerMock(); const prepared = createPreparedMeltOperation(); diff --git a/packages/react/src/lib/hooks/useBalances.test.tsx b/packages/react/src/lib/hooks/useBalances.test.tsx index 86bb60a7..87160686 100644 --- a/packages/react/src/lib/hooks/useBalances.test.tsx +++ b/packages/react/src/lib/hooks/useBalances.test.tsx @@ -30,22 +30,28 @@ async function waitForAssertion(assertion: () => void): Promise { function createManagerMock() { const byMint = vi.fn().mockResolvedValue({}); + const byMintAndUnit = vi.fn().mockResolvedValue({}); + const byUnit = vi.fn().mockResolvedValue({}); + const totalByUnit = vi.fn().mockResolvedValue({}); const manager = { wallet: { balances: { byMint, + byMintAndUnit, + byUnit, + totalByUnit, }, }, on: vi.fn(), off: vi.fn(), } as unknown as Manager; - return { manager, byMint }; + return { manager, byMint, byMintAndUnit, byUnit, totalByUnit }; } describe('useBalances', () => { it('preserves an explicit empty mintUrls scope', async () => { - const { manager, byMint } = createManagerMock(); + const { manager, byMint, byMintAndUnit, byUnit, totalByUnit } = createManagerMock(); const { result } = renderHook(() => useBalances({ mintUrls: [] }), { wrapper: createHookWrapper(manager), @@ -54,16 +60,100 @@ describe('useBalances', () => { await waitForAssertion(() => { expect(byMint).toHaveBeenCalledWith({ mintUrls: [], + units: undefined, + trustedOnly: undefined, + }); + expect(byMintAndUnit).toHaveBeenCalledWith({ + mintUrls: [], + units: undefined, + trustedOnly: undefined, + }); + expect(byUnit).toHaveBeenCalledWith({ + mintUrls: [], + units: undefined, + trustedOnly: undefined, + }); + expect(totalByUnit).toHaveBeenCalledWith({ + mintUrls: [], + units: undefined, trustedOnly: undefined, }); expect(result.current.balances).toEqual({ byMint: {}, + byMintAndUnit: {}, + byUnit: {}, total: { spendable: Amount.zero(), reserved: Amount.zero(), total: Amount.zero(), + unit: 'sat', + }, + totalByUnit: {}, + }); + }); + }); + + it('does not expose a mixed-unit legacy total for multi-unit scopes', async () => { + const { manager, byMint, byMintAndUnit, byUnit, totalByUnit } = createManagerMock(); + byMintAndUnit.mockResolvedValue({ + 'https://mint.test': { + sat: { + spendable: Amount.from(10), + reserved: Amount.zero(), + total: Amount.from(10), + unit: 'sat', }, + usd: { + spendable: Amount.from(5), + reserved: Amount.zero(), + total: Amount.from(5), + unit: 'usd', + }, + }, + }); + byUnit.mockResolvedValue({ + sat: { + spendable: Amount.from(10), + reserved: Amount.zero(), + total: Amount.from(10), + unit: 'sat', + }, + usd: { + spendable: Amount.from(5), + reserved: Amount.zero(), + total: Amount.from(5), + unit: 'usd', + }, + }); + totalByUnit.mockResolvedValue({ + sat: { + spendable: Amount.from(10), + reserved: Amount.zero(), + total: Amount.from(10), + unit: 'sat', + }, + usd: { + spendable: Amount.from(5), + reserved: Amount.zero(), + total: Amount.from(5), + unit: 'usd', + }, + }); + + const { result } = renderHook(() => useBalances({ units: ['sat', 'usd'] }), { + wrapper: createHookWrapper(manager), + }); + + await waitForAssertion(() => { + expect(byMint).not.toHaveBeenCalled(); + expect(result.current.balances.byMint).toEqual({}); + expect(result.current.balances.total).toEqual({ + spendable: Amount.zero(), + reserved: Amount.zero(), + total: Amount.zero(), + unit: 'sat', }); + expect(Object.keys(result.current.balances.totalByUnit)).toEqual(['sat', 'usd']); }); }); }); diff --git a/packages/react/src/lib/hooks/useBalances.ts b/packages/react/src/lib/hooks/useBalances.ts index 042aed94..3f228588 100644 --- a/packages/react/src/lib/hooks/useBalances.ts +++ b/packages/react/src/lib/hooks/useBalances.ts @@ -3,6 +3,8 @@ import { type BalanceQuery, type BalanceSnapshot, type BalancesByMint, + type BalancesByMintAndUnit, + type BalancesByUnit, } from '@cashu/coco-core'; import { useCallback, useEffect, useState } from 'react'; import type { WalletBalancesValue } from '../contexts/BalanceContext'; @@ -12,47 +14,84 @@ const EMPTY_BALANCE_SNAPSHOT: BalanceSnapshot = { spendable: Amount.zero(), reserved: Amount.zero(), total: Amount.zero(), + unit: 'sat', }; const EMPTY_BALANCES: WalletBalancesValue = { byMint: {}, + byMintAndUnit: {}, + byUnit: {}, total: EMPTY_BALANCE_SNAPSHOT, + totalByUnit: {}, }; const getBalanceTotal = (byMint: BalancesByMint): BalanceSnapshot => { + const first = Object.values(byMint)[0]; + const unit = first?.unit ?? 'sat'; return Object.values(byMint).reduce( (total, balance) => ({ spendable: total.spendable.add(balance.spendable), reserved: total.reserved.add(balance.reserved), total: total.total.add(balance.total), + unit, }), - EMPTY_BALANCE_SNAPSHOT, + { ...EMPTY_BALANCE_SNAPSHOT, unit }, ); }; +const aggregateByUnit = (byMintAndUnit: BalancesByMintAndUnit): BalancesByUnit => { + const totals: BalancesByUnit = {}; + for (const balancesByUnit of Object.values(byMintAndUnit)) { + for (const [unit, balance] of Object.entries(balancesByUnit)) { + const total = totals[unit] ?? { + spendable: Amount.zero(), + reserved: Amount.zero(), + total: Amount.zero(), + unit, + }; + total.spendable = total.spendable.add(balance.spendable); + total.reserved = total.reserved.add(balance.reserved); + total.total = total.total.add(balance.total); + totals[unit] = total; + } + } + return totals; +}; + const useBalances = (scope?: BalanceQuery) => { const [balances, setBalances] = useState(EMPTY_BALANCES); const manager = useManager(); const mintUrlsKey = scope?.mintUrls?.join('\0') ?? ''; + const unitsKey = scope?.units?.join('\0') ?? ''; const hasMintUrlsScope = scope?.mintUrls !== undefined; + const hasUnitsScope = scope?.units !== undefined; const trustedOnly = scope?.trustedOnly; const refresh = useCallback(async () => { try { const balanceScope: BalanceQuery | undefined = - hasMintUrlsScope || trustedOnly + hasMintUrlsScope || hasUnitsScope || trustedOnly ? { mintUrls: hasMintUrlsScope ? (mintUrlsKey ? mintUrlsKey.split('\0') : []) : undefined, + units: hasUnitsScope ? (unitsKey ? unitsKey.split('\0') : []) : undefined, trustedOnly, } : undefined; - const byMint = await manager.wallet.balances.byMint(balanceScope); - const total = getBalanceTotal(byMint); - setBalances({ byMint, total }); + const units = balanceScope?.units; + const useSingleUnitView = !units || units.length <= 1; + const byMintAndUnit = await manager.wallet.balances.byMintAndUnit(balanceScope); + const totalByUnit = + (await manager.wallet.balances.totalByUnit?.(balanceScope)) ?? + aggregateByUnit(byMintAndUnit); + const byMint = useSingleUnitView ? await manager.wallet.balances.byMint(balanceScope) : {}; + const total = useSingleUnitView ? getBalanceTotal(byMint) : EMPTY_BALANCE_SNAPSHOT; + const byUnit = + (await manager.wallet.balances.byUnit?.(balanceScope)) ?? aggregateByUnit(byMintAndUnit); + setBalances({ byMint, byMintAndUnit, byUnit, total, totalByUnit }); } catch (error) { console.error(error instanceof Error ? error : new Error(String(error))); } - }, [manager, hasMintUrlsScope, mintUrlsKey, trustedOnly]); + }, [manager, hasMintUrlsScope, hasUnitsScope, mintUrlsKey, trustedOnly, unitsKey]); useEffect(() => { void refresh(); diff --git a/packages/react/src/lib/providers/root.test.tsx b/packages/react/src/lib/providers/root.test.tsx index 14b4d3a4..7203a18c 100644 --- a/packages/react/src/lib/providers/root.test.tsx +++ b/packages/react/src/lib/providers/root.test.tsx @@ -49,6 +49,9 @@ function createManagerMock(): Manager { wallet: { balances: { byMint: vi.fn().mockResolvedValue({}), + byMintAndUnit: vi.fn().mockResolvedValue({}), + byUnit: vi.fn().mockResolvedValue({}), + totalByUnit: vi.fn().mockResolvedValue({}), }, }, on: vi.fn(), diff --git a/packages/sqlite-bun/src/repositories/MeltOperationRepository.ts b/packages/sqlite-bun/src/repositories/MeltOperationRepository.ts index f2a55956..5e22a7f9 100644 --- a/packages/sqlite-bun/src/repositories/MeltOperationRepository.ts +++ b/packages/sqlite-bun/src/repositories/MeltOperationRepository.ts @@ -2,6 +2,7 @@ import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-c import { deserializeAmount, normalizeMeltMethodData, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -61,6 +62,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { mintUrl: row.mintUrl, method: row.method, methodData: parseMethodData(row), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -72,7 +74,6 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { const preparedData = { quoteId: row.quoteId ?? '', - unit: row.unit ?? 'sat', amount: deserializeAmount(row.amount ?? 0), fee_reserve: deserializeAmount(row.fee_reserve ?? 0), swap_fee: deserializeAmount(row.swap_fee ?? 0), @@ -119,7 +120,7 @@ const operationToParams = (operation: MeltOperation): unknown[] => { operation.method, methodDataJson, null, - null, + operation.unit, null, null, null, @@ -220,7 +221,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, unit = ? WHERE id = ?`, [ operation.state, @@ -228,6 +229,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { operation.error ?? null, operation.method, stringifyJson(operation.methodData), + operation.unit, operation.id, ], ); diff --git a/packages/sqlite-bun/src/repositories/ProofRepository.ts b/packages/sqlite-bun/src/repositories/ProofRepository.ts index 17aa1aaa..886beb43 100644 --- a/packages/sqlite-bun/src/repositories/ProofRepository.ts +++ b/packages/sqlite-bun/src/repositories/ProofRepository.ts @@ -1,7 +1,10 @@ import { + DEFAULT_UNIT, deserializeAmount, + normalizeUnit, serializeAmount, type ProofRepository, + type ProofUnitFilter, type CoreProof, type ProofState, } from '@cashu/coco-core'; @@ -10,6 +13,7 @@ import { SqliteDb, getUnixTimeSeconds } from '../db.ts'; interface ProofRow { mintUrl: string; id: string; + unit: string | null; amount: string | number; secret: string; C: string; @@ -22,6 +26,30 @@ interface ProofRow { const MAX_PROOF_SECRET_LOOKUP_BATCH_SIZE = 900; +const PROOF_COLUMNS = + 'mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId'; + +function normalizeProofUnit(proof: CoreProof): string { + return normalizeUnit((proof as { unit?: string }).unit); +} + +function getUnitFilter(filter?: ProofUnitFilter): string[] | undefined { + const units = [...(filter?.units ?? []), ...(filter?.unit ? [filter.unit] : [])]; + if (units.length === 0) return undefined; + return Array.from(new Set(units.map((unit) => normalizeUnit(unit)))); +} + +function appendUnitFilter(sql: string, params: unknown[], filter?: ProofUnitFilter): string { + const units = getUnitFilter(filter); + if (!units || units.length === 0) return sql; + if (units.length === 1) { + params.push(units[0]); + return `${sql} AND unit = ?`; + } + params.push(...units); + return `${sql} AND unit IN (${units.map(() => '?').join(', ')})`; +} + function rowToProof(r: ProofRow): CoreProof { const base = { id: r.id, @@ -34,6 +62,7 @@ function rowToProof(r: ProofRow): CoreProof { return { ...base, mintUrl: r.mintUrl, + unit: normalizeUnit(r.unit ?? undefined, { defaultUnit: DEFAULT_UNIT }), state: r.state, ...(r.usedByOperationId ? { usedByOperationId: r.usedByOperationId } : {}), ...(r.createdByOperationId ? { createdByOperationId: r.createdByOperationId } : {}), @@ -50,23 +79,28 @@ export class SqliteProofRepository implements ProofRepository { async saveProofs(mintUrl: string, proofs: CoreProof[]): Promise { if (!proofs || proofs.length === 0) return; const now = getUnixTimeSeconds(); + const normalizedProofs = proofs.map((proof) => ({ + ...proof, + unit: normalizeProofUnit(proof), + })); await this.db.transaction(async (tx) => { const selectSql = 'SELECT 1 AS x FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ? LIMIT 1'; - for (const p of proofs) { + for (const p of normalizedProofs) { const exists = await tx.get<{ x: number }>(selectSql, [mintUrl, p.secret]); if (exists) { throw new Error(`Proof with secret already exists: ${p.secret}`); } } const insertSql = - 'INSERT INTO coco_cashu_proofs (mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; - for (const p of proofs) { + 'INSERT INTO coco_cashu_proofs (mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + for (const p of normalizedProofs) { const dleqJson = p.dleq ? JSON.stringify(p.dleq) : null; const witnessJson = p.witness ? JSON.stringify(p.witness) : null; await tx.run(insertSql, [ mintUrl, p.id, + p.unit, serializeAmount(p.amount), p.secret, p.C, @@ -81,18 +115,29 @@ export class SqliteProofRepository implements ProofRepository { }); } - async getReadyProofs(mintUrl: string): Promise { + async getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const params: unknown[] = [mintUrl]; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready'", - [mintUrl], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getInflightProofs(mintUrls?: string[]): Promise { + async getInflightProofs(mintUrls?: string[], filter?: ProofUnitFilter): Promise { if (!mintUrls || mintUrls.length === 0) { + const params: unknown[] = []; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'inflight'", + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'inflight'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } @@ -100,24 +145,44 @@ export class SqliteProofRepository implements ProofRepository { if (mintUrlList.length === 0) return []; const uniqueMintUrls = Array.from(new Set(mintUrlList)); const placeholders = uniqueMintUrls.map(() => '?').join(', '); + const params: unknown[] = uniqueMintUrls; const rows = await this.db.all( - `SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'inflight' AND mintUrl IN (${placeholders})`, - uniqueMintUrls, + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'inflight' AND mintUrl IN (${placeholders})`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getAllReadyProofs(): Promise { + async getAllReadyProofs(filter?: ProofUnitFilter): Promise { + const params: unknown[] = []; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'ready'", + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'ready'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getProofsByKeysetId(mintUrl: string, keysetId: string): Promise { + async getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise { + const params: unknown[] = [mintUrl, keysetId]; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND id = ? AND state = 'ready'", - [mintUrl, keysetId], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND id = ? AND state = 'ready'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } @@ -210,7 +275,7 @@ export class SqliteProofRepository implements ProofRepository { async getProofBySecret(mintUrl: string, secret: string): Promise { const row = await this.db.get( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?`, [mintUrl, secret], ); return row ? rowToProof(row) : null; @@ -228,7 +293,7 @@ export class SqliteProofRepository implements ProofRepository { const secretBatch = uniqueSecrets.slice(i, i + MAX_PROOF_SECRET_LOOKUP_BATCH_SIZE); const placeholders = secretBatch.map(() => '?').join(', '); const rows = await this.db.all( - `SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND secret IN (${placeholders})`, + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND secret IN (${placeholders})`, [mintUrl, ...secretBatch], ); @@ -245,23 +310,28 @@ export class SqliteProofRepository implements ProofRepository { async getProofsByOperationId(mintUrl: string, operationId: string): Promise { const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND (usedByOperationId = ? OR createdByOperationId = ?)', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND (usedByOperationId = ? OR createdByOperationId = ?)`, [mintUrl, operationId, operationId], ); return rows.map(rowToProof); } - async getAvailableProofs(mintUrl: string): Promise { + async getAvailableProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const params: unknown[] = [mintUrl]; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready' AND usedByOperationId IS NULL", - [mintUrl], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready' AND usedByOperationId IS NULL`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } async getReservedProofs(): Promise { const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'ready' AND usedByOperationId IS NOT NULL", + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'ready' AND usedByOperationId IS NOT NULL`, ); return rows.map(rowToProof); } diff --git a/packages/sqlite-bun/src/repositories/SendOperationRepository.ts b/packages/sqlite-bun/src/repositories/SendOperationRepository.ts index bbeedb95..d1287d6d 100644 --- a/packages/sqlite-bun/src/repositories/SendOperationRepository.ts +++ b/packages/sqlite-bun/src/repositories/SendOperationRepository.ts @@ -7,6 +7,7 @@ import type { import { deserializeAmount, deserializeToken, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -16,6 +17,7 @@ interface SendOperationRow { id: string; mintUrl: string; amount: string | number; + unit: string | null; state: SendOperationState; createdAt: number; updatedAt: number; @@ -44,6 +46,7 @@ function rowToOperation(row: SendOperationRow): SendOperation { id: row.id, mintUrl: row.mintUrl, amount: deserializeAmount(row.amount), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, // Convert seconds to milliseconds updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -111,6 +114,7 @@ function operationToParams(op: SendOperation): unknown[] { op.id, op.mintUrl, serializeAmount(op.amount), + op.unit, op.state, createdAtSeconds, updatedAtSeconds, @@ -131,6 +135,7 @@ function operationToParams(op: SendOperation): unknown[] { op.id, op.mintUrl, serializeAmount(op.amount), + op.unit, op.state, createdAtSeconds, updatedAtSeconds, @@ -165,8 +170,8 @@ export class SqliteSendOperationRepository implements SendOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_send_operations - (id, mintUrl, amount, state, createdAt, updatedAt, error, method, methodDataJson, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, amount, unit, state, createdAt, updatedAt, error, method, methodDataJson, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -185,19 +190,20 @@ export class SqliteSendOperationRepository implements SendOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_send_operations - SET state = ?, updatedAt = ?, error = ? + SET state = ?, updatedAt = ?, error = ?, unit = ? WHERE id = ?`, - [operation.state, updatedAtSeconds, operation.error ?? null, operation.id], + [operation.state, updatedAtSeconds, operation.error ?? null, operation.unit, operation.id], ); } else { await this.db.run( `UPDATE coco_cashu_send_operations - SET state = ?, updatedAt = ?, error = ?, needsSwap = ?, fee = ?, inputAmount = ?, inputProofSecretsJson = ?, outputDataJson = ?, tokenJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, needsSwap = ?, fee = ?, inputAmount = ?, inputProofSecretsJson = ?, outputDataJson = ?, tokenJson = ? WHERE id = ?`, [ operation.state, updatedAtSeconds, operation.error ?? null, + operation.unit, operation.needsSwap ? 1 : 0, serializeAmount(operation.fee), serializeAmount(operation.inputAmount), diff --git a/packages/sqlite-bun/src/schema.ts b/packages/sqlite-bun/src/schema.ts index 1adf2477..efd18324 100644 --- a/packages/sqlite-bun/src/schema.ts +++ b/packages/sqlite-bun/src/schema.ts @@ -62,6 +62,57 @@ async function addSendOperationMethodColumns(db: SqliteDb): Promise { } } +async function addProofUnitColumn(db: SqliteDb): Promise { + const columns = await getTableColumns(db, 'coco_cashu_proofs'); + + if (!columns.has('unit')) { + await db.run(`ALTER TABLE coco_cashu_proofs ADD COLUMN unit TEXT NOT NULL DEFAULT 'sat'`); + } + + await db.run(` + UPDATE coco_cashu_proofs + SET unit = COALESCE( + ( + SELECT LOWER(TRIM(coco_cashu_keysets.unit)) + FROM coco_cashu_keysets + WHERE coco_cashu_keysets.mintUrl = coco_cashu_proofs.mintUrl + AND coco_cashu_keysets.id = coco_cashu_proofs.id + AND coco_cashu_keysets.unit IS NOT NULL + AND TRIM(coco_cashu_keysets.unit) <> '' + LIMIT 1 + ), + CASE + WHEN unit IS NULL OR TRIM(unit) = '' THEN 'sat' + ELSE LOWER(TRIM(unit)) + END + ) + `); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state)', + ); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state)', + ); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state)', + ); +} + +async function addSendOperationUnitColumn(db: SqliteDb): Promise { + const columns = await getTableColumns(db, 'coco_cashu_send_operations'); + + if (!columns.has('unit')) { + await db.run( + `ALTER TABLE coco_cashu_send_operations ADD COLUMN unit TEXT NOT NULL DEFAULT 'sat'`, + ); + } + + await db.run( + "UPDATE coco_cashu_send_operations SET unit = 'sat' WHERE unit IS NULL OR TRIM(unit) = ''", + ); + await db.run('UPDATE coco_cashu_send_operations SET unit = LOWER(TRIM(unit))'); +} + async function migrateAmountColumnsToText(db: SqliteDb): Promise { if (await tableExists(db, 'coco_cashu_proofs')) { await db.exec(` @@ -70,6 +121,7 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { CREATE TABLE coco_cashu_proofs ( mintUrl TEXT NOT NULL, id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', amount TEXT NOT NULL, secret TEXT NOT NULL, C TEXT NOT NULL, @@ -83,11 +135,25 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { ); INSERT INTO coco_cashu_proofs ( - mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, createdAt, + mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId ) SELECT - mintUrl, id, CAST(amount AS TEXT), secret, C, dleqJson, witnessJson, state, createdAt, + mintUrl, + id, + COALESCE( + ( + SELECT LOWER(TRIM(coco_cashu_keysets.unit)) + FROM coco_cashu_keysets + WHERE coco_cashu_keysets.mintUrl = coco_cashu_proofs_legacy_amounts.mintUrl + AND coco_cashu_keysets.id = coco_cashu_proofs_legacy_amounts.id + AND coco_cashu_keysets.unit IS NOT NULL + AND TRIM(coco_cashu_keysets.unit) <> '' + LIMIT 1 + ), + 'sat' + ), + CAST(amount AS TEXT), secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId FROM coco_cashu_proofs_legacy_amounts; @@ -96,6 +162,9 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_state ON coco_cashu_proofs(state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_state ON coco_cashu_proofs(mintUrl, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_id_state ON coco_cashu_proofs(mintUrl, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_usedByOp ON coco_cashu_proofs(usedByOperationId) WHERE usedByOperationId IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_createdByOp ON coco_cashu_proofs(createdByOperationId) WHERE createdByOperationId IS NOT NULL; `); @@ -218,6 +287,7 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { id TEXT PRIMARY KEY, mintUrl TEXT NOT NULL, amount TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', state TEXT NOT NULL CHECK (state IN ('init', 'prepared', 'executing', 'pending', 'finalized', 'rolling_back', 'rolled_back')), createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, @@ -233,11 +303,11 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { ); INSERT INTO coco_cashu_send_operations ( - id, mintUrl, amount, state, createdAt, updatedAt, error, needsSwap, fee, + id, mintUrl, amount, unit, state, createdAt, updatedAt, error, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, method, methodDataJson, tokenJson ) SELECT - id, mintUrl, CAST(amount AS TEXT), state, createdAt, updatedAt, error, needsSwap, + id, mintUrl, CAST(amount AS TEXT), 'sat', state, createdAt, updatedAt, error, needsSwap, CASE WHEN fee IS NULL THEN NULL ELSE CAST(fee AS TEXT) END, CASE WHEN inputAmount IS NULL THEN NULL ELSE CAST(inputAmount AS TEXT) END, inputProofSecretsJson, outputDataJson, method, methodDataJson, tokenJson @@ -440,6 +510,7 @@ const MIGRATIONS: readonly Migration[] = [ CREATE TABLE IF NOT EXISTS coco_cashu_proofs ( mintUrl TEXT NOT NULL, id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', amount INTEGER NOT NULL, secret TEXT NOT NULL, C TEXT NOT NULL, @@ -453,6 +524,9 @@ const MIGRATIONS: readonly Migration[] = [ CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_state ON coco_cashu_proofs(state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_state ON coco_cashu_proofs(mintUrl, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_id_state ON coco_cashu_proofs(mintUrl, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state); CREATE TABLE IF NOT EXISTS coco_cashu_mint_quotes ( mintUrl TEXT NOT NULL, @@ -948,6 +1022,14 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_proof_unit', + run: addProofUnitColumn, + }, + { + id: '026_send_operation_unit', + run: addSendOperationUnitColumn, + }, ]; // Export for testing diff --git a/packages/sqlite-bun/src/test/SendOperationRepository.test.ts b/packages/sqlite-bun/src/test/SendOperationRepository.test.ts index a6a96577..357c2fd5 100644 --- a/packages/sqlite-bun/src/test/SendOperationRepository.test.ts +++ b/packages/sqlite-bun/src/test/SendOperationRepository.test.ts @@ -12,6 +12,7 @@ function makeRollingBackOperation(): RollingBackSendOperation { id: 'send-op-1', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'rolling_back', method: 'default', methodData: {}, @@ -29,6 +30,7 @@ function makePendingP2pkOperation(): PendingSendOperation { id: 'send-op-p2pk', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'pending', method: 'p2pk', methodData: { pubkey: '02' + '11'.repeat(32) }, @@ -45,9 +47,9 @@ function makePendingP2pkOperation(): PendingSendOperation { token: { mint: 'https://mint.test', proofs: [{ id: 'keyset-1', amount: Amount.from(100), secret: 'send-secret', C: 'C_send' }], - unit: 'sat', + unit: 'usd', }, - } as PendingSendOperation; + }; } describe('SqliteSendOperationRepository', () => { diff --git a/packages/sqlite-bun/src/test/contract.test.ts b/packages/sqlite-bun/src/test/contract.test.ts index a4d1cc89..a44ecc78 100644 --- a/packages/sqlite-bun/src/test/contract.test.ts +++ b/packages/sqlite-bun/src/test/contract.test.ts @@ -6,6 +6,8 @@ import { runProofRepositoryContract, runMintOperationRepositoryContract, runReceiveOperationRepositoryContract, + runSendOperationRepositoryContract, + runMeltOperationRepositoryContract, createDummyMint, createDummyKeyset, createDummyProof, @@ -50,6 +52,10 @@ runMintOperationRepositoryContract({ createRepositories }, { describe, it, expec runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMeltOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('sqlite-bun adapter transactions', () => { it('commits across repositories', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/sqlite-bun/src/test/integration.test.ts b/packages/sqlite-bun/src/test/integration.test.ts index 39c8844f..7e879029 100644 --- a/packages/sqlite-bun/src/test/integration.test.ts +++ b/packages/sqlite-bun/src/test/integration.test.ts @@ -8,6 +8,7 @@ import { ConsoleLogger, type Logger } from '@cashu/coco-core'; const expect = bunExpect as unknown as IntegrationTestRunner['expect']; const mintUrl = process.env.MINT_URL; +const customUnit = process.env.CUSTOM_UNIT; if (!mintUrl) { throw new Error('MINT_URL is not set'); @@ -39,6 +40,7 @@ runIntegrationTests( { createRepositories, mintUrl, + customUnit, logger: getTestLogger(), suiteName: 'SQLite Bun Integration Tests', }, diff --git a/packages/sqlite-bun/src/test/schema.test.ts b/packages/sqlite-bun/src/test/schema.test.ts index b3731e41..87d5488a 100644 --- a/packages/sqlite-bun/src/test/schema.test.ts +++ b/packages/sqlite-bun/src/test/schema.test.ts @@ -172,6 +172,52 @@ describe('sqlite-bun schema migrations', () => { ); }); + it('backfills legacy proof units from keyset metadata', async () => { + await ensureSchemaUpTo(db, '025_proof_unit'); + await db.run( + `INSERT INTO coco_cashu_keysets + (mintUrl, id, keypairs, active, feePpk, updatedAt, unit) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'usd-keyset', '{}', 1, 0, 1, 'USD'], + ); + await db.run( + `INSERT INTO coco_cashu_proofs + (mintUrl, id, unit, amount, secret, C, state, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'usd-keyset', 'sat', '10', 'secret-usd', 'C-usd', 'ready', 1], + ); + + await ensureSchemaUpTo(db); + + const proof = await db.get<{ unit: string }>( + 'SELECT unit FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + ['https://mint.test', 'secret-usd'], + ); + const indexes = await db.all<{ name: string }>('PRAGMA index_list(coco_cashu_proofs)'); + + expect(proof?.unit).toBe('usd'); + expect(indexes.map((row) => row.name)).toContain('idx_coco_cashu_proofs_mint_unit_id_state'); + }); + + it('keeps legacy proof units as sat when keyset metadata is missing', async () => { + await ensureSchemaUpTo(db, '025_proof_unit'); + await db.run( + `INSERT INTO coco_cashu_proofs + (mintUrl, id, unit, amount, secret, C, state, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'missing-keyset', 'sat', '10', 'secret-legacy', 'C-legacy', 'ready', 1], + ); + + await ensureSchemaUpTo(db); + + const proof = await db.get<{ unit: string }>( + 'SELECT unit FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + ['https://mint.test', 'secret-legacy'], + ); + + expect(proof?.unit).toBe('sat'); + }); + it('backfills legacy aliases for canonical databases', async () => { await ensureSchemaUpTo(db); diff --git a/packages/sqlite3/src/repositories/MeltOperationRepository.ts b/packages/sqlite3/src/repositories/MeltOperationRepository.ts index c65d1534..ecc71f65 100644 --- a/packages/sqlite3/src/repositories/MeltOperationRepository.ts +++ b/packages/sqlite3/src/repositories/MeltOperationRepository.ts @@ -2,6 +2,7 @@ import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-c import { deserializeAmount, normalizeMeltMethodData, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -61,6 +62,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { mintUrl: row.mintUrl, method: row.method, methodData: parseMethodData(row), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -72,7 +74,6 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { const preparedData = { quoteId: row.quoteId ?? '', - unit: row.unit ?? 'sat', amount: deserializeAmount(row.amount ?? 0), fee_reserve: deserializeAmount(row.fee_reserve ?? 0), swap_fee: deserializeAmount(row.swap_fee ?? 0), @@ -119,7 +120,7 @@ const operationToParams = (operation: MeltOperation): unknown[] => { operation.method, methodDataJson, null, - null, + operation.unit, null, null, null, @@ -220,7 +221,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, unit = ? WHERE id = ?`, [ operation.state, @@ -228,6 +229,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { operation.error ?? null, operation.method, stringifyJson(operation.methodData), + operation.unit, operation.id, ], ); diff --git a/packages/sqlite3/src/repositories/ProofRepository.ts b/packages/sqlite3/src/repositories/ProofRepository.ts index 17aa1aaa..886beb43 100644 --- a/packages/sqlite3/src/repositories/ProofRepository.ts +++ b/packages/sqlite3/src/repositories/ProofRepository.ts @@ -1,7 +1,10 @@ import { + DEFAULT_UNIT, deserializeAmount, + normalizeUnit, serializeAmount, type ProofRepository, + type ProofUnitFilter, type CoreProof, type ProofState, } from '@cashu/coco-core'; @@ -10,6 +13,7 @@ import { SqliteDb, getUnixTimeSeconds } from '../db.ts'; interface ProofRow { mintUrl: string; id: string; + unit: string | null; amount: string | number; secret: string; C: string; @@ -22,6 +26,30 @@ interface ProofRow { const MAX_PROOF_SECRET_LOOKUP_BATCH_SIZE = 900; +const PROOF_COLUMNS = + 'mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId'; + +function normalizeProofUnit(proof: CoreProof): string { + return normalizeUnit((proof as { unit?: string }).unit); +} + +function getUnitFilter(filter?: ProofUnitFilter): string[] | undefined { + const units = [...(filter?.units ?? []), ...(filter?.unit ? [filter.unit] : [])]; + if (units.length === 0) return undefined; + return Array.from(new Set(units.map((unit) => normalizeUnit(unit)))); +} + +function appendUnitFilter(sql: string, params: unknown[], filter?: ProofUnitFilter): string { + const units = getUnitFilter(filter); + if (!units || units.length === 0) return sql; + if (units.length === 1) { + params.push(units[0]); + return `${sql} AND unit = ?`; + } + params.push(...units); + return `${sql} AND unit IN (${units.map(() => '?').join(', ')})`; +} + function rowToProof(r: ProofRow): CoreProof { const base = { id: r.id, @@ -34,6 +62,7 @@ function rowToProof(r: ProofRow): CoreProof { return { ...base, mintUrl: r.mintUrl, + unit: normalizeUnit(r.unit ?? undefined, { defaultUnit: DEFAULT_UNIT }), state: r.state, ...(r.usedByOperationId ? { usedByOperationId: r.usedByOperationId } : {}), ...(r.createdByOperationId ? { createdByOperationId: r.createdByOperationId } : {}), @@ -50,23 +79,28 @@ export class SqliteProofRepository implements ProofRepository { async saveProofs(mintUrl: string, proofs: CoreProof[]): Promise { if (!proofs || proofs.length === 0) return; const now = getUnixTimeSeconds(); + const normalizedProofs = proofs.map((proof) => ({ + ...proof, + unit: normalizeProofUnit(proof), + })); await this.db.transaction(async (tx) => { const selectSql = 'SELECT 1 AS x FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ? LIMIT 1'; - for (const p of proofs) { + for (const p of normalizedProofs) { const exists = await tx.get<{ x: number }>(selectSql, [mintUrl, p.secret]); if (exists) { throw new Error(`Proof with secret already exists: ${p.secret}`); } } const insertSql = - 'INSERT INTO coco_cashu_proofs (mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; - for (const p of proofs) { + 'INSERT INTO coco_cashu_proofs (mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + for (const p of normalizedProofs) { const dleqJson = p.dleq ? JSON.stringify(p.dleq) : null; const witnessJson = p.witness ? JSON.stringify(p.witness) : null; await tx.run(insertSql, [ mintUrl, p.id, + p.unit, serializeAmount(p.amount), p.secret, p.C, @@ -81,18 +115,29 @@ export class SqliteProofRepository implements ProofRepository { }); } - async getReadyProofs(mintUrl: string): Promise { + async getReadyProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const params: unknown[] = [mintUrl]; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready'", - [mintUrl], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getInflightProofs(mintUrls?: string[]): Promise { + async getInflightProofs(mintUrls?: string[], filter?: ProofUnitFilter): Promise { if (!mintUrls || mintUrls.length === 0) { + const params: unknown[] = []; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'inflight'", + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'inflight'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } @@ -100,24 +145,44 @@ export class SqliteProofRepository implements ProofRepository { if (mintUrlList.length === 0) return []; const uniqueMintUrls = Array.from(new Set(mintUrlList)); const placeholders = uniqueMintUrls.map(() => '?').join(', '); + const params: unknown[] = uniqueMintUrls; const rows = await this.db.all( - `SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'inflight' AND mintUrl IN (${placeholders})`, - uniqueMintUrls, + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'inflight' AND mintUrl IN (${placeholders})`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getAllReadyProofs(): Promise { + async getAllReadyProofs(filter?: ProofUnitFilter): Promise { + const params: unknown[] = []; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'ready'", + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'ready'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } - async getProofsByKeysetId(mintUrl: string, keysetId: string): Promise { + async getProofsByKeysetId( + mintUrl: string, + keysetId: string, + filter?: ProofUnitFilter, + ): Promise { + const params: unknown[] = [mintUrl, keysetId]; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND id = ? AND state = 'ready'", - [mintUrl, keysetId], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND id = ? AND state = 'ready'`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } @@ -210,7 +275,7 @@ export class SqliteProofRepository implements ProofRepository { async getProofBySecret(mintUrl: string, secret: string): Promise { const row = await this.db.get( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?`, [mintUrl, secret], ); return row ? rowToProof(row) : null; @@ -228,7 +293,7 @@ export class SqliteProofRepository implements ProofRepository { const secretBatch = uniqueSecrets.slice(i, i + MAX_PROOF_SECRET_LOOKUP_BATCH_SIZE); const placeholders = secretBatch.map(() => '?').join(', '); const rows = await this.db.all( - `SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND secret IN (${placeholders})`, + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND secret IN (${placeholders})`, [mintUrl, ...secretBatch], ); @@ -245,23 +310,28 @@ export class SqliteProofRepository implements ProofRepository { async getProofsByOperationId(mintUrl: string, operationId: string): Promise { const rows = await this.db.all( - 'SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND (usedByOperationId = ? OR createdByOperationId = ?)', + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND (usedByOperationId = ? OR createdByOperationId = ?)`, [mintUrl, operationId, operationId], ); return rows.map(rowToProof); } - async getAvailableProofs(mintUrl: string): Promise { + async getAvailableProofs(mintUrl: string, filter?: ProofUnitFilter): Promise { + const params: unknown[] = [mintUrl]; const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready' AND usedByOperationId IS NULL", - [mintUrl], + appendUnitFilter( + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE mintUrl = ? AND state = 'ready' AND usedByOperationId IS NULL`, + params, + filter, + ), + params, ); return rows.map(rowToProof); } async getReservedProofs(): Promise { const rows = await this.db.all( - "SELECT mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, usedByOperationId, createdByOperationId FROM coco_cashu_proofs WHERE state = 'ready' AND usedByOperationId IS NOT NULL", + `SELECT ${PROOF_COLUMNS} FROM coco_cashu_proofs WHERE state = 'ready' AND usedByOperationId IS NOT NULL`, ); return rows.map(rowToProof); } diff --git a/packages/sqlite3/src/repositories/SendOperationRepository.ts b/packages/sqlite3/src/repositories/SendOperationRepository.ts index 188d29ae..387c09fe 100644 --- a/packages/sqlite3/src/repositories/SendOperationRepository.ts +++ b/packages/sqlite3/src/repositories/SendOperationRepository.ts @@ -7,6 +7,7 @@ import type { import { deserializeAmount, deserializeToken, + normalizeUnit, serializeAmount, stringifyJson, } from '@cashu/coco-core'; @@ -16,6 +17,7 @@ interface SendOperationRow { id: string; mintUrl: string; amount: string | number; + unit: string | null; state: SendOperationState; createdAt: number; updatedAt: number; @@ -44,6 +46,7 @@ function rowToOperation(row: SendOperationRow): SendOperation { id: row.id, mintUrl: row.mintUrl, amount: deserializeAmount(row.amount), + unit: normalizeUnit(row.unit ?? 'sat'), createdAt: row.createdAt * 1000, // Convert seconds to milliseconds updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, @@ -111,6 +114,7 @@ function operationToParams(op: SendOperation): unknown[] { op.id, op.mintUrl, serializeAmount(op.amount), + op.unit, op.state, createdAtSeconds, updatedAtSeconds, @@ -131,6 +135,7 @@ function operationToParams(op: SendOperation): unknown[] { op.id, op.mintUrl, serializeAmount(op.amount), + op.unit, op.state, createdAtSeconds, updatedAtSeconds, @@ -165,8 +170,8 @@ export class SqliteSendOperationRepository implements SendOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_send_operations - (id, mintUrl, amount, state, createdAt, updatedAt, error, method, methodDataJson, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, amount, unit, state, createdAt, updatedAt, error, method, methodDataJson, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -185,19 +190,20 @@ export class SqliteSendOperationRepository implements SendOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_send_operations - SET state = ?, updatedAt = ?, error = ? + SET state = ?, updatedAt = ?, error = ?, unit = ? WHERE id = ?`, - [operation.state, updatedAtSeconds, operation.error ?? null, operation.id], + [operation.state, updatedAtSeconds, operation.error ?? null, operation.unit, operation.id], ); } else { await this.db.run( `UPDATE coco_cashu_send_operations - SET state = ?, updatedAt = ?, error = ?, needsSwap = ?, fee = ?, inputAmount = ?, inputProofSecretsJson = ?, outputDataJson = ?, tokenJson = ? + SET state = ?, updatedAt = ?, error = ?, unit = ?, needsSwap = ?, fee = ?, inputAmount = ?, inputProofSecretsJson = ?, outputDataJson = ?, tokenJson = ? WHERE id = ?`, [ operation.state, updatedAtSeconds, operation.error ?? null, + operation.unit, operation.needsSwap ? 1 : 0, serializeAmount(operation.fee), serializeAmount(operation.inputAmount), diff --git a/packages/sqlite3/src/schema.ts b/packages/sqlite3/src/schema.ts index 1adf2477..efd18324 100644 --- a/packages/sqlite3/src/schema.ts +++ b/packages/sqlite3/src/schema.ts @@ -62,6 +62,57 @@ async function addSendOperationMethodColumns(db: SqliteDb): Promise { } } +async function addProofUnitColumn(db: SqliteDb): Promise { + const columns = await getTableColumns(db, 'coco_cashu_proofs'); + + if (!columns.has('unit')) { + await db.run(`ALTER TABLE coco_cashu_proofs ADD COLUMN unit TEXT NOT NULL DEFAULT 'sat'`); + } + + await db.run(` + UPDATE coco_cashu_proofs + SET unit = COALESCE( + ( + SELECT LOWER(TRIM(coco_cashu_keysets.unit)) + FROM coco_cashu_keysets + WHERE coco_cashu_keysets.mintUrl = coco_cashu_proofs.mintUrl + AND coco_cashu_keysets.id = coco_cashu_proofs.id + AND coco_cashu_keysets.unit IS NOT NULL + AND TRIM(coco_cashu_keysets.unit) <> '' + LIMIT 1 + ), + CASE + WHEN unit IS NULL OR TRIM(unit) = '' THEN 'sat' + ELSE LOWER(TRIM(unit)) + END + ) + `); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state)', + ); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state)', + ); + await db.run( + 'CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state)', + ); +} + +async function addSendOperationUnitColumn(db: SqliteDb): Promise { + const columns = await getTableColumns(db, 'coco_cashu_send_operations'); + + if (!columns.has('unit')) { + await db.run( + `ALTER TABLE coco_cashu_send_operations ADD COLUMN unit TEXT NOT NULL DEFAULT 'sat'`, + ); + } + + await db.run( + "UPDATE coco_cashu_send_operations SET unit = 'sat' WHERE unit IS NULL OR TRIM(unit) = ''", + ); + await db.run('UPDATE coco_cashu_send_operations SET unit = LOWER(TRIM(unit))'); +} + async function migrateAmountColumnsToText(db: SqliteDb): Promise { if (await tableExists(db, 'coco_cashu_proofs')) { await db.exec(` @@ -70,6 +121,7 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { CREATE TABLE coco_cashu_proofs ( mintUrl TEXT NOT NULL, id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', amount TEXT NOT NULL, secret TEXT NOT NULL, C TEXT NOT NULL, @@ -83,11 +135,25 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { ); INSERT INTO coco_cashu_proofs ( - mintUrl, id, amount, secret, C, dleqJson, witnessJson, state, createdAt, + mintUrl, id, unit, amount, secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId ) SELECT - mintUrl, id, CAST(amount AS TEXT), secret, C, dleqJson, witnessJson, state, createdAt, + mintUrl, + id, + COALESCE( + ( + SELECT LOWER(TRIM(coco_cashu_keysets.unit)) + FROM coco_cashu_keysets + WHERE coco_cashu_keysets.mintUrl = coco_cashu_proofs_legacy_amounts.mintUrl + AND coco_cashu_keysets.id = coco_cashu_proofs_legacy_amounts.id + AND coco_cashu_keysets.unit IS NOT NULL + AND TRIM(coco_cashu_keysets.unit) <> '' + LIMIT 1 + ), + 'sat' + ), + CAST(amount AS TEXT), secret, C, dleqJson, witnessJson, state, createdAt, usedByOperationId, createdByOperationId FROM coco_cashu_proofs_legacy_amounts; @@ -96,6 +162,9 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_state ON coco_cashu_proofs(state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_state ON coco_cashu_proofs(mintUrl, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_id_state ON coco_cashu_proofs(mintUrl, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_usedByOp ON coco_cashu_proofs(usedByOperationId) WHERE usedByOperationId IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_createdByOp ON coco_cashu_proofs(createdByOperationId) WHERE createdByOperationId IS NOT NULL; `); @@ -218,6 +287,7 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { id TEXT PRIMARY KEY, mintUrl TEXT NOT NULL, amount TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', state TEXT NOT NULL CHECK (state IN ('init', 'prepared', 'executing', 'pending', 'finalized', 'rolling_back', 'rolled_back')), createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, @@ -233,11 +303,11 @@ async function migrateAmountColumnsToText(db: SqliteDb): Promise { ); INSERT INTO coco_cashu_send_operations ( - id, mintUrl, amount, state, createdAt, updatedAt, error, needsSwap, fee, + id, mintUrl, amount, unit, state, createdAt, updatedAt, error, needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, method, methodDataJson, tokenJson ) SELECT - id, mintUrl, CAST(amount AS TEXT), state, createdAt, updatedAt, error, needsSwap, + id, mintUrl, CAST(amount AS TEXT), 'sat', state, createdAt, updatedAt, error, needsSwap, CASE WHEN fee IS NULL THEN NULL ELSE CAST(fee AS TEXT) END, CASE WHEN inputAmount IS NULL THEN NULL ELSE CAST(inputAmount AS TEXT) END, inputProofSecretsJson, outputDataJson, method, methodDataJson, tokenJson @@ -440,6 +510,7 @@ const MIGRATIONS: readonly Migration[] = [ CREATE TABLE IF NOT EXISTS coco_cashu_proofs ( mintUrl TEXT NOT NULL, id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat', amount INTEGER NOT NULL, secret TEXT NOT NULL, C TEXT NOT NULL, @@ -453,6 +524,9 @@ const MIGRATIONS: readonly Migration[] = [ CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_state ON coco_cashu_proofs(state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_state ON coco_cashu_proofs(mintUrl, state); CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_id_state ON coco_cashu_proofs(mintUrl, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_state ON coco_cashu_proofs(mintUrl, unit, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_mint_unit_id_state ON coco_cashu_proofs(mintUrl, unit, id, state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_proofs_unit_state ON coco_cashu_proofs(unit, state); CREATE TABLE IF NOT EXISTS coco_cashu_mint_quotes ( mintUrl TEXT NOT NULL, @@ -948,6 +1022,14 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_proof_unit', + run: addProofUnitColumn, + }, + { + id: '026_send_operation_unit', + run: addSendOperationUnitColumn, + }, ]; // Export for testing diff --git a/packages/sqlite3/src/test/SendOperationRepository.test.ts b/packages/sqlite3/src/test/SendOperationRepository.test.ts index 84fdd59e..35edf94c 100644 --- a/packages/sqlite3/src/test/SendOperationRepository.test.ts +++ b/packages/sqlite3/src/test/SendOperationRepository.test.ts @@ -8,6 +8,7 @@ function makeRollingBackOperation(): RollingBackSendOperation { id: 'send-op-1', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'rolling_back', method: 'default', methodData: {}, @@ -25,6 +26,7 @@ function makePendingP2pkOperation(): PendingSendOperation { id: 'send-op-p2pk', mintUrl: 'https://mint.test', amount: Amount.from(100), + unit: 'usd', state: 'pending', method: 'p2pk', methodData: { pubkey: '02' + '11'.repeat(32) }, @@ -41,9 +43,9 @@ function makePendingP2pkOperation(): PendingSendOperation { token: { mint: 'https://mint.test', proofs: [{ id: 'keyset-1', amount: Amount.from(100), secret: 'send-secret', C: 'C_send' }], - unit: 'sat', + unit: 'usd', }, - } as PendingSendOperation; + }; } describe('SqliteSendOperationRepository', () => { diff --git a/packages/sqlite3/src/test/contract.test.ts b/packages/sqlite3/src/test/contract.test.ts index 110b8a76..2775ce87 100644 --- a/packages/sqlite3/src/test/contract.test.ts +++ b/packages/sqlite3/src/test/contract.test.ts @@ -6,6 +6,8 @@ import { runProofRepositoryContract, runMintOperationRepositoryContract, runReceiveOperationRepositoryContract, + runSendOperationRepositoryContract, + runMeltOperationRepositoryContract, createDummyMint, createDummyKeyset, createDummyProof, @@ -50,6 +52,10 @@ runMintOperationRepositoryContract({ createRepositories }, { describe, it, expec runReceiveOperationRepositoryContract({ createRepositories }, { describe, it, expect }); +runSendOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMeltOperationRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('sqlite3 adapter transactions', () => { it('commits across repositories', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/sqlite3/src/test/integration.test.ts b/packages/sqlite3/src/test/integration.test.ts index 8dcbba88..0674dc62 100644 --- a/packages/sqlite3/src/test/integration.test.ts +++ b/packages/sqlite3/src/test/integration.test.ts @@ -5,6 +5,7 @@ import { SqliteRepositories } from '../index.ts'; import { ConsoleLogger, type Logger } from '@cashu/coco-core'; const mintUrl = process.env.MINT_URL; +const customUnit = process.env.CUSTOM_UNIT; if (!mintUrl) { throw new Error('MINT_URL is not set'); @@ -36,6 +37,7 @@ runIntegrationTests( { createRepositories, mintUrl, + customUnit, logger: getTestLogger(), suiteName: 'SQLite3 Integration Tests', }, diff --git a/packages/sqlite3/src/test/schema.test.ts b/packages/sqlite3/src/test/schema.test.ts index 119ffecb..e4776022 100644 --- a/packages/sqlite3/src/test/schema.test.ts +++ b/packages/sqlite3/src/test/schema.test.ts @@ -103,6 +103,52 @@ describe('sqlite3 schema migrations', () => { ); }); + it('backfills legacy proof units from keyset metadata', async () => { + await ensureSchemaUpTo(db, '025_proof_unit'); + await db.run( + `INSERT INTO coco_cashu_keysets + (mintUrl, id, keypairs, active, feePpk, updatedAt, unit) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'usd-keyset', '{}', 1, 0, 1, 'USD'], + ); + await db.run( + `INSERT INTO coco_cashu_proofs + (mintUrl, id, unit, amount, secret, C, state, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'usd-keyset', 'sat', '10', 'secret-usd', 'C-usd', 'ready', 1], + ); + + await ensureSchemaUpTo(db); + + const proof = await db.get<{ unit: string }>( + 'SELECT unit FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + ['https://mint.test', 'secret-usd'], + ); + const indexes = await db.all<{ name: string }>('PRAGMA index_list(coco_cashu_proofs)'); + + expect(proof?.unit).toBe('usd'); + expect(indexes.map((row) => row.name)).toContain('idx_coco_cashu_proofs_mint_unit_id_state'); + }); + + it('keeps legacy proof units as sat when keyset metadata is missing', async () => { + await ensureSchemaUpTo(db, '025_proof_unit'); + await db.run( + `INSERT INTO coco_cashu_proofs + (mintUrl, id, unit, amount, secret, C, state, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ['https://mint.test', 'missing-keyset', 'sat', '10', 'secret-legacy', 'C-legacy', 'ready', 1], + ); + + await ensureSchemaUpTo(db); + + const proof = await db.get<{ unit: string }>( + 'SELECT unit FROM coco_cashu_proofs WHERE mintUrl = ? AND secret = ?', + ['https://mint.test', 'secret-legacy'], + ); + + expect(proof?.unit).toBe('sat'); + }); + it('backfills legacy aliases for canonical databases', async () => { await ensureSchemaUpTo(db);