From 8b7c9e4d367043290b64c4356cf55ded34a74c3b Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 22 Jul 2026 23:18:20 +0200 Subject: [PATCH] Support keysets v2, improve and extend prioritization of inactive keysets to swap or spend them --- src/models/WalletStore.ts | 23 +++--- src/screens/WalletScreen.tsx | 2 +- src/services/bitcoin/bitcoinUtils.ts | 3 +- src/services/cashu/cashuUtils.ts | 81 ++++++++++++++++++- .../wallet/operations/sendOperationApi.ts | 6 +- .../wallet/operations/transferOperationApi.ts | 15 +++- src/services/wallet/sendTask.ts | 14 +--- 7 files changed, 114 insertions(+), 30 deletions(-) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index dd3916f6..56df75a6 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -123,19 +123,18 @@ export const WalletStoreModel = types return mintsStore.findByUrl(mintUrl) as Mint }, getOptimalKeyset(mintInstance: Mint, unit: MintUnit) { - const optimalKeyset: MintKeyset =mintInstance.keysets! - .filter((k: MintKeyset) => k.unit === unit && k.active) + // Mirrors cashu-ts v4.7 KeyChain.getCheapestKeyset: among active keysets for + // this unit with a valid hex id (v00 `00…` or v2 `01…`; excludes deprecated + // base64 keysets that cannot create outputs), pick the lowest input fee. + const isHexKeysetId = (id: string) => /^[0-9a-f]+$/i.test(id) + + const optimalKeyset: MintKeyset | undefined = mintInstance.keysets! + .filter((k: MintKeyset) => k.unit === unit && k.active && isHexKeysetId(k.id)) .sort((a: MintKeyset, b: MintKeyset) => { - // Prioritize keysets that start with '00' - const aStartsWith00 = a.id.startsWith('00') ? 1 : 0; - const bStartsWith00 = b.id.startsWith('00') ? 1 : 0; - - if (aStartsWith00 !== bStartsWith00) { - return bStartsWith00 - aStartsWith00; - } - - // If both start with '00' or neither do, sort by input_fee_ppk - return (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0); + const feeDelta = (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0) + if (feeDelta !== 0) return feeDelta + // Equal fee: prefer the newer keyset version (v2 `01…` over v0 `00…`) + return b.id.localeCompare(a.id) })[0] if(!optimalKeyset) { diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index e5ea084b..d2442604 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -833,7 +833,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) { {/* Stub to preview the NWC card UI in the simulator (NWC unavailable there). */} {__DEV__ && ( - {renderNwcCard('NWC conn', 100)} + {renderNwcCard('Wisp', 100)} )} {isNwcVisible && ( diff --git a/src/services/bitcoin/bitcoinUtils.ts b/src/services/bitcoin/bitcoinUtils.ts index 258e7db5..4b59ac2a 100644 --- a/src/services/bitcoin/bitcoinUtils.ts +++ b/src/services/bitcoin/bitcoinUtils.ts @@ -137,8 +137,7 @@ export const isBitcoinAddress = (address: string): boolean => /** * Are non-mainnet addresses payable in this build? * - * Debug builds only. Development is the one situation where paying a regtest address is - * the POINT rather than a mistake: the CDK fakewallet backend settles onchain melts + * Debug builds only. The CDK fakewallet backend settles onchain melts * against a regtest chain, so a release-only guard would make the whole rail * untestable end to end. * diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index 5bd875f3..90274319 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -274,16 +274,95 @@ const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Pro * @param getFeesForProofs Mint fee for a given proof set (wraps * `cashuWallet.getFeesForProofs`). * @param options.maxIterations Convergence guard (default 32). + * @param options.priorityProofs Proofs to spend first (e.g. proofs from + * inactive/legacy keysets, to rotate that ecash off per + * NUT-02). MUST be a subset of `proofs`. When they cover + * the fee-inclusive target on their own, selection stays + * entirely within them; otherwise ALL of them are spent + * and the rest of the pool tops up the remainder. * @throws VALIDATION_ERROR if available proofs cannot cover the converged total. */ const selectProofsToSendWithFeeReserve = function ( targetAmount: number, proofs: Proof[], getFeesForProofs: (selected: Proof[]) => number, - options?: {maxIterations?: number; caller?: string}, + options?: {maxIterations?: number; caller?: string; priorityProofs?: Proof[]}, ): {proofsToSend: Proof[]; feeReserve: number} { const maxIterations = options?.maxIterations ?? 32 const caller = options?.caller ?? 'selectProofsToSendWithFeeReserve' + const priorityProofs = options?.priorityProofs ?? [] + + // No priority set → plain lowest-count selection over the whole pool. + if (priorityProofs.length === 0) { + return selectFromPoolWithFeeReserve(targetAmount, proofs, getFeesForProofs, maxIterations, caller) + } + + const priorityAmount = getProofsAmount(priorityProofs) + + // 1) If the priority proofs can cover the fee-inclusive target on their own, + // spend ONLY from them (a minimal subset) — rotating that ecash off without + // touching the rest of the pool. This is the optimal, fee-lean case. + if (priorityAmount >= targetAmount) { + try { + return selectFromPoolWithFeeReserve(targetAmount, priorityProofs, getFeesForProofs, maxIterations, caller) + } catch { + // Priority alone can't cover target + its own input fee; fall through to + // draining all priority proofs and topping up from the rest. + } + } + + // 2) Drain ALL priority proofs, then add the minimal set of remaining proofs + // needed to cover targetAmount + the input fee on the COMBINED set. Iterated + // to a fixed point because each added top-up proof can raise the fee (NUT-02). + const prioritySecrets = new Set(priorityProofs.map(p => p.secret)) + const restProofs = proofs.filter(p => !prioritySecrets.has(p.secret)) + const totalAvailable = getProofsAmount(proofs) + + let proofsToSend = priorityProofs + let feeReserve = getFeesForProofs(proofsToSend) + + let guard = 0 + while (getProofsAmount(proofsToSend) < targetAmount + feeReserve && guard++ < maxIterations) { + const amountWithFees = targetAmount + feeReserve + if (totalAvailable < amountWithFees) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAvailable, amountWithFees, caller}, + ) + } + // restTarget > 0 here: the loop condition means sum(proofsToSend) < amountWithFees, + // and sum(proofsToSend) >= priorityAmount, so amountWithFees > priorityAmount. + // The guard above also proves restTarget <= sum(restProofs), so getProofsToSend + // never throws for insufficiency here. + const restTarget = amountWithFees - priorityAmount + const topUp = getProofsToSend(restTarget, restProofs) + proofsToSend = [...priorityProofs, ...topUp] + feeReserve = getFeesForProofs(proofsToSend) + } + + if (getProofsAmount(proofsToSend) < targetAmount + feeReserve) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAvailable, amountWithFees: targetAmount + feeReserve, caller}, + ) + } + + return {proofsToSend, feeReserve} +} + +/** + * Lowest-count fee-reserve selection over a single pool (the un-prioritized core + * of `selectProofsToSendWithFeeReserve`). See that function's docblock. + */ +const selectFromPoolWithFeeReserve = function ( + targetAmount: number, + proofs: Proof[], + getFeesForProofs: (selected: Proof[]) => number, + maxIterations: number, + caller: string, +): {proofsToSend: Proof[]; feeReserve: number} { const totalAvailable = getProofsAmount(proofs) let proofsToSend = getProofsToSend(targetAmount, proofs) diff --git a/src/services/wallet/operations/sendOperationApi.ts b/src/services/wallet/operations/sendOperationApi.ts index 27a13629..2e004e49 100644 --- a/src/services/wallet/operations/sendOperationApi.ts +++ b/src/services/wallet/operations/sendOperationApi.ts @@ -225,6 +225,10 @@ async function prepare(input: PrepareSendInput): Promise { } else { // ── Auto-select path ──────────────────────────────────────────── const inactiveKeysetIds = getInactiveKeysetIds(mintInstance) + const inactiveIdSet = new Set(inactiveKeysetIds) + const inactiveProofs = inactiveKeysetIds.length > 0 + ? proofsFromMint.filter(p => inactiveIdSet.has(p.id)) + : [] let candidates: Proof[] = inactiveKeysetIds.length > 0 ? prioritizeFromInactiveKeysets(mintInstance, amount, unit, proofsFromMint) : CashuUtils.getProofsToSend(amount, proofsFromMint) @@ -247,7 +251,7 @@ async function prepare(input: PrepareSendInput): Promise { amount, proofsFromMint, selected => walletInstance.getFeesForProofs(selected).toNumber(), - {caller: 'SendOperationApi.prepare'}, + {caller: 'SendOperationApi.prepare', priorityProofs: inactiveProofs}, )) } catch (e: any) { throw new ValidationError('There is not enough funds to send this amount.', { diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 683df56e..c0594597 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -302,6 +302,19 @@ async function prepare(input: PrepareTransferInput): Promise k.unit === unit && !k.active) + .map(k => k.id), + ) + const inactiveProofs = inactiveKeysetIds.size > 0 + ? proofsFromMint.filter(p => inactiveKeysetIds.has(p.id)) + : [] + // Select proofs covering amount + the network fee_reserve + the mint's per-proof // input fee on the selected proofs — `amount + fee_reserve + input_fee`, which is // what both NUT-05 and NUT-30 require the inputs to cover. The helper iterates to a @@ -316,7 +329,7 @@ async function prepare(input: PrepareTransferInput): Promise walletInstance.getFeesForProofs(selected).toNumber(), - {caller: 'TransferOperationApi.prepare'}, + {caller: 'TransferOperationApi.prepare', priorityProofs: inactiveProofs}, )) } catch (e: any) { throw new ValidationError('There is not enough funds to send this amount.', { diff --git a/src/services/wallet/sendTask.ts b/src/services/wallet/sendTask.ts index 270237a6..cd3dfa69 100644 --- a/src/services/wallet/sendTask.ts +++ b/src/services/wallet/sendTask.ts @@ -6,29 +6,19 @@ import { TransactionType, } from '../../models/Transaction' import {rootStoreInstance} from '../../models' -import {CashuUtils, CashuProof} from '../cashu/cashuUtils' -import AppError, {Err} from '../../utils/AppError' +import {CashuUtils} from '../cashu/cashuUtils' import { - Mint as CashuMint, - Wallet as CashuWallet, - CheckStateEnum, MintKeyset, - ProofState as CashuProofState, - getEncodedToken, - normalizeProofAmounts, } from '@cashu/cashu-ts' -import { MAX_SWAP_INPUT_SIZE, TransactionTaskResult, WalletTask } from '../walletService' +import { TransactionTaskResult } from '../walletService' import { Mint, MintBalance } from '../../models/Mint' import { Proof } from '../../models/Proof' -import { poller } from '../../utils/poller' import { WalletUtils } from './utils' import { MintUnit } from './currency' const { - mintsStore, proofsStore, transactionsStore, - walletStore } = rootStoreInstance export const SEND_TASK = 'sendTask'