Retire three cashu-ts workarounds made redundant by 4.10

Each of these existed only because the library lacked something it now has.

mintInfoSummary widened GetInfoResponse with icon_url, tos_url and urls —
fields every mint publishes but cashu-ts did not type. 4.10 (#1004) carries
all three plus time and max_array_length, so the intersection is gone and
MintInfo is a plain alias.

mintUrl reimplemented cashu-ts's canonical form because normalizeUrl was
@internal; its docblock carried a standing obligation to keep the copy in
step by hand. 4.8 (#845) renamed it normalizeMintUrl and made it public, so
the copy is replaced by delegation and that obligation is gone. The https
requirement is ours and stricter, so it stays, applied to the normalized url.
A side effect worth naming: cashu-ts also rejects credentials, query strings,
fragments and percent-encoded paths, which previously fell to the later
new CashuMint() call — delegating moves those rejections strictly earlier.

WalletStore read nuts['19'] behind four @ts-ignore comments to decide whether
to record a NUT-19 in-flight request. isSupported(19).supported drops the
ignores and is deliberately stricter: cashu-ts reports supported only when
cached_endpoints is non-empty, whereas a bare truthy nuts.19 object also
matched a mint advertising the key with no cache behind it. Recording an
in-flight request there was never useful, since the replay it enables depends
on the mint actually caching the response.

Adds proofSelectionRotating.test.ts to pin the one behavior change 4.10 makes
that a device cannot reach: the default selector moved to selectProofsRotating
(#818), which force-includes whole stale keyset buckets. It is decision-neutral
— it documents both selectors rather than blessing one — and guards the
invariant that matters either way: with includeFees, the selected set must
cover the target plus the input fee on itself.

Verified: tsc --noEmit unchanged against baseline (89 pre-existing, none new),
46 suites / 629 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-09-04 15:58:29 +02:00
co-authored by Claude Opus 5
parent f5b7c24699
commit 0efd00cc5b
4 changed files with 223 additions and 40 deletions
+186
View File
@@ -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<string, string> = {}
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)
})
}
}
})
+4 -8
View File
@@ -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)
}
+9 -2
View File
@@ -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 ===
+24 -30
View File
@@ -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
}