diff --git a/__tests__/proofSelectionRotating.test.ts b/__tests__/proofSelectionRotating.test.ts new file mode 100644 index 00000000..3b8757d1 --- /dev/null +++ b/__tests__/proofSelectionRotating.test.ts @@ -0,0 +1,186 @@ +/** + * cashu-ts proof selection: `selectProofsRGLI` vs `selectProofsRotating`. + * + * cashu-ts 4.8 (#818) changed the Wallet's DEFAULT selector from `selectProofsRGLI` + * to `selectProofsRotating`, and 4.10 is the first version the wallet ships with + * that default. The two differ only in the presence of STALE-keyset proofs, where + * rotating force-includes whole stale buckets — dust and all — so balances migrate + * onto the mint's current keyset. On a fee-charging mint that is a real, visible + * cost: the fee scales with the NUMBER of inputs (NUT-02). + * + * This matters to Minibits specifically because the wallet ALREADY has a rotation + * policy of its own — `CashuUtils.selectProofsToSendWithFeeReserve` is called with + * `priorityProofs: inactiveProofs` by both SendOperationApi and TransferOperationApi. + * Under RGLI that intent was silently discarded: cashu-ts re-selected from the + * proofs the wallet passed and just took the cheapest set. Under rotating the + * wallet's stated intent is actually honored. So the fee delta below is not the + * library overriding the wallet — it is the library finally doing what the wallet + * asked for. + * + * These cases are NOT reachable on a device: getting proofs onto an inactive keyset + * requires the mint to sign with a keyset it has retired. A unit test is the only + * place the behavior can be pinned, which is why it lives here. + * + * Deliberately decision-neutral. It documents BOTH selectors rather than asserting + * one is correct, so it stays valid whether the wallet keeps the 4.10 default or + * pins `selectProofs: selectProofsRGLI` in the CashuWallet options. + * + * @jest-environment node + */ +import { + KeyChain, + deriveKeysetId, + getPubKeyFromPrivKey, + selectProofsRGLI, + selectProofsRotating, +} from '@cashu/cashu-ts' +import type {MintKeys, MintKeyset, Proof} from '@cashu/cashu-ts' +import {bytesToHex} from '@noble/curves/utils.js' + +const MINT_URL = 'https://mint.test/sat' +const AMOUNTS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] + +/** 1000 ppk = exactly 1 sat per input proof, so fees are legible in assertions. */ +const FEE_PPK = 1000 + +/** A keyset whose id genuinely derives from its keys, at the given id version. */ +const makeKeyset = (seedByte: number, active: boolean, versionByte: number) => { + const keys: Record = {} + for (let i = 0; i < AMOUNTS.length; i++) { + const priv = new Uint8Array(32) + priv[31] = seedByte + priv[30] = i + 1 + keys[String(AMOUNTS[i])] = bytesToHex(getPubKeyFromPrivKey(priv)) + } + const id = deriveKeysetId(keys, {unit: 'sat', input_fee_ppk: FEE_PPK, versionByte}) + return { + meta: {id, unit: 'sat', active, input_fee_ppk: FEE_PPK} as MintKeyset, + keys: {id, unit: 'sat', active, keys} as MintKeys, + } +} + +// The shape a mint migration leaves behind: the old keyset that signed the user's +// existing ecash goes inactive, a new one is issued and becomes active. +const stale = makeKeyset(0x11, false, 0) +const current = makeKeyset(0x22, true, 1) + +const keyChain = KeyChain.fromCache( + MINT_URL, + 'sat', + KeyChain.mintToCacheDTO(MINT_URL, [stale.meta, current.meta], [stale.keys, current.keys]), +) + +let counter = 0 +const proof = (keysetId: string, amount: number): Proof => { + counter++ + return { + id: keysetId, + amount, + secret: `secret-${counter}`, + C: `02${String(counter).padStart(64, '0')}`, + } as unknown as Proof +} + +const sum = (ps: Proof[]) => ps.reduce((acc, p) => acc + Number(p.amount), 0) +/** NUT-02: fee = ceil(Σ input_fee_ppk / 1000). At 1000 ppk that is one sat per input. */ +const feeFor = (ps: Proof[]) => Math.ceil((ps.length * FEE_PPK) / 1000) +const staleCount = (ps: Proof[]) => ps.filter(p => p.id === stale.meta.id).length + +describe('when every proof is on the ACTIVE keyset', () => { + // The overwhelmingly common case, and the one a device test can reach: there is + // nothing stale to prefer, so the two selectors cannot diverge. + const pool = [128, 64, 32, 16, 8, 4].map(a => proof(current.meta.id, a)) + + test('RGLI and rotating select the same number of inputs', () => { + const rgli = selectProofsRGLI(pool, 100, keyChain, true, false) + const rotating = selectProofsRotating(pool, 100, keyChain, true, false) + + expect(rotating.send.length).toBe(rgli.send.length) + expect(sum(rotating.send as Proof[])).toBe(sum(rgli.send as Proof[])) + }) + + test('so the fee is identical — no upgrade cost for an all-current wallet', () => { + const rgli = selectProofsRGLI(pool, 100, keyChain, true, false) + const rotating = selectProofsRotating(pool, 100, keyChain, true, false) + + expect(feeFor(rotating.send as Proof[])).toBe(feeFor(rgli.send as Proof[])) + }) +}) + +describe('when STALE-keyset proofs are present', () => { + // 20 dust proofs stranded on the retired keyset, plus usable current denominations. + const pool = [ + ...Array.from({length: 20}, () => proof(stale.meta.id, 1)), + ...[64, 32, 16].map(a => proof(current.meta.id, a)), + ] + + test('RGLI ignores staleness and takes the cheapest set', () => { + const {send} = selectProofsRGLI(pool, 50, keyChain, true, false) + + // One 64 covers 50 + its own 1 sat fee, so RGLI spends a single input and + // leaves all 20 dust proofs stranded exactly where they were. + expect(send.length).toBe(1) + expect(staleCount(send as Proof[])).toBe(0) + }) + + test('rotating force-includes the whole stale bucket', () => { + const {send} = selectProofsRotating(pool, 50, keyChain, true, false) + + expect(staleCount(send as Proof[])).toBe(20) + expect(send.length).toBeGreaterThan(20) + }) + + test('which costs materially more in input fees — the upgrade consequence', () => { + const rgli = selectProofsRGLI(pool, 50, keyChain, true, false) + const rotating = selectProofsRotating(pool, 50, keyChain, true, false) + + // 1 sat vs 21. The user buys consolidation of 20 dust proofs for 20 extra sats. + expect(feeFor(rgli.send as Proof[])).toBe(1) + expect(feeFor(rotating.send as Proof[])).toBe(21) + }) +}) + +describe('the invariant both selectors must hold', () => { + // The regression guard that actually matters. With includeFees=true — which is + // what cashu-ts's own prepareSwapToSend passes — the selected set must cover the + // target PLUS the input fee on itself. If this ever breaks, sends fail on + // fee-charging mints with "Not enough funds available for swap". + const scenarios: Array<{name: string; target: number; pool: Proof[]}> = [ + { + name: 'mixed stale dust and current denominations', + target: 100, + pool: [ + ...[1, 1, 1, 2, 4, 8].map(a => proof(stale.meta.id, a)), + ...[128, 64, 32, 16].map(a => proof(current.meta.id, a)), + ], + }, + { + name: 'all current', + target: 100, + pool: [128, 64, 32, 16, 8, 4].map(a => proof(current.meta.id, a)), + }, + { + name: 'stale bucket alone covers the target', + target: 12, + pool: [ + ...[8, 4, 2, 1].map(a => proof(stale.meta.id, a)), + ...[64, 32].map(a => proof(current.meta.id, a)), + ], + }, + ] + + for (const {name, target, pool} of scenarios) { + for (const [label, select] of [ + ['RGLI', selectProofsRGLI], + ['rotating', selectProofsRotating], + ] as const) { + test(`${label} covers target + its own fee — ${name}`, () => { + const {send} = select(pool, target, keyChain, true, false) + const selected = send as Proof[] + + expect(selected.length).toBeGreaterThan(0) + expect(sum(selected) - feeFor(selected)).toBeGreaterThanOrEqual(target) + }) + } + } +}) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 6f32ace2..ada28092 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -636,8 +636,7 @@ export const WalletStoreModel = types } } - // @ts-ignore - if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { + if (cashuWallet.getMintInfo().isSupported(19).supported && !options?.inFlightRequest) { Database.addInFlightRequest(transactionId, receiveParams) } @@ -751,8 +750,7 @@ export const WalletStoreModel = types } } - // @ts-ignore - if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { + if (cashuWallet.getMintInfo().isSupported(19).supported && !options?.inFlightRequest) { Database.addInFlightRequest(transactionId, sendParams) } @@ -1061,8 +1059,7 @@ export const WalletStoreModel = types // request hits the mint's NUT-19 cache and returns the same signatures. // Identical outputs depend on the counter NOT having advanced, which holds: // onCountersReserved never fired, so we never wrote it back. - // @ts-ignore - if (cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { + if (cashuWallet.getMintInfo().isSupported(19).supported && !options?.inFlightRequest) { Database.addInFlightRequest(transactionId, mintParams) } @@ -1163,8 +1160,7 @@ export const WalletStoreModel = types } } - // @ts-ignore - if(cashuWallet.getMintInfo().nuts['19'] && !options?.inFlightRequest) { + if (cashuWallet.getMintInfo().isSupported(19).supported && !options?.inFlightRequest) { Database.addInFlightRequest(transactionId, mintParams) } diff --git a/src/screens/Mints/mintInfoSummary.ts b/src/screens/Mints/mintInfoSummary.ts index 8e546c84..089485a3 100644 --- a/src/screens/Mints/mintInfoSummary.ts +++ b/src/screens/Mints/mintInfoSummary.ts @@ -15,8 +15,15 @@ import { IconTypes } from '../../components' import { TxKeyPath } from '../../i18n' import { MintUnit, MintUnits } from '../../services/wallet/currency' -/** `icon_url` is not in cashu-ts' type yet, but every mint publishes it. */ -export type MintInfo = GetInfoResponse & {icon_url?: string; tos_url?: string; urls?: string[]} +/** + * A mint's NUT-06 info, named for what it is at this layer. + * + * This used to widen `GetInfoResponse` with `icon_url`, `tos_url` and `urls`, + * which mints publish but cashu-ts did not type. cashu-ts 4.10 carries all + * three (plus `time` and `max_array_length`), so the widening is gone and the + * response type is taken as-is. + */ +export type MintInfo = GetInfoResponse // === NUT support === diff --git a/src/services/cashu/mintUrl.ts b/src/services/cashu/mintUrl.ts index b549e103..aa67d55e 100644 --- a/src/services/cashu/mintUrl.ts +++ b/src/services/cashu/mintUrl.ts @@ -1,3 +1,4 @@ +import {normalizeMintUrl as cashuNormalizeMintUrl} from '@cashu/cashu-ts' import AppError, {Err} from '../../utils/AppError' /** @@ -34,52 +35,45 @@ export const isOnionMintUrl = function (mintUrl: string): boolean { /** * Normalize a mint url to its canonical form, or throw AppError(VALIDATION_ERROR). * - * Two rules, from different authorities: + * The canonicalization itself is cashu-ts's `normalizeMintUrl`, not a copy of it. + * That matters because WalletStore finds cached CashuMint/CashuWallet instances by + * comparing our stored string against `CashuMint.mintUrl` (`m.mintUrl === mintUrl`, + * `w.mint.mintUrl === mintUrl`), and `new CashuMint(url)` stores exactly what this + * function returns. Any drift between the two spellings would make every cache + * lookup miss and the wallet would treat one mint as two. * - * 1. NUT-00 requires the trailing slash be gone. On the v3 token: "The mint URL - * must be stripped of any trailing slashes (/)"; on v4: "The mint URL MUST be - * normalized by stripping any trailing slashes (/)". That is the whole of what - * the spec mandates — it says nothing about case or any other form. + * This used to reimplement the rule (`parsed.href` with trailing slashes stripped) + * because cashu-ts's version was `@internal`. cashu-ts 4.8 renamed it to + * `normalizeMintUrl` and made it public, so the copy — and the standing obligation + * to keep it in step by hand — is gone. * - * 2. cashu-ts canonicalizes further, and we MUST match it. `new CashuMint(url)` - * stores `normalizeUrl(url)` = `parsed.href` with trailing slashes stripped, - * which also lowercases scheme and host and drops a default port. WalletStore - * compares our stored string against that value directly (`m.mintUrl === - * mintUrl`, `w.mint.mintUrl === mintUrl`) to find cached CashuMint/CashuWallet - * instances. Normalizing the raw input instead would let `https://Mint.Example` - * be stored while cashu-ts holds `https://mint.example`: every cache lookup - * misses, and the wallet would treat the two spellings as two different mints. - * cashu-ts's normalizeUrl is @internal (not exported), hence the reimplementation - * here — it must be kept in step with it. + * cashu-ts enforces NUT-00's trailing-slash rule plus its own canonical form + * (lowercased scheme and host, default port dropped), and rejects credentials, + * query strings, fragments and percent-encoded paths. Those rejections used to be + * left to the `new CashuMint()` call further down; delegating moves them here, + * which is strictly earlier and therefore better. * * The https requirement is ours alone and stricter than cashu-ts, which permits - * http for any host. - * - * cashu-ts additionally rejects credentials, query strings, fragments and - * percent-encoded paths. Those are deliberately NOT re-checked here: both callers - * construct a CashuMint against the url before anything is stored, so cashu-ts - * raises them itself — duplicating the rules would only invite drift. + * http for any host — so it stays here, applied to the normalized url. */ export const normalizeMintUrl = function (mintUrl: string): string { if (!mintUrl || !mintUrl.trim()) { throw new AppError(Err.VALIDATION_ERROR, 'Mint URL is required.') } - let parsed: URL + let normalized: string try { - parsed = new URL(mintUrl.trim()) + normalized = cashuNormalizeMintUrl(mintUrl.trim()) } catch { + // cashu-ts raises CTSError; the wallet speaks AppError. throw new AppError(Err.VALIDATION_ERROR, 'Invalid Mint URL.', {mintUrl}) } - // Protocol equality, not `startsWith('https')` — the latter also accepts a - // scheme merely PREFIXED with https (`https-evil://host` parses fine). - if (parsed.protocol !== 'https:' && !isOnionMintUrl(parsed.href)) { + // Checked on the NORMALIZED url, so the scheme has already been lowercased and + // the host is the parsed hostname rather than a substring of the raw input. + if (!normalized.startsWith('https:') && !isOnionMintUrl(normalized)) { throw new AppError(Err.VALIDATION_ERROR, 'Mint URL needs to start with https.', {mintUrl}) } - // `href` first (canonical), THEN strip: the parser appends a trailing slash to - // an origin-only url, so stripping last removes both that and any the caller - // typed. Identical to cashu-ts normalizeUrl. - return parsed.href.replace(/\/+$/, '') + return normalized }