Support keysets v2, improve and extend prioritization of inactive keysets to swap or spend them

This commit is contained in:
minibits-cash
2026-08-04 14:46:58 +02:00
parent e2416dacee
commit 8b7c9e4d36
7 changed files with 114 additions and 30 deletions
+11 -12
View File
@@ -123,19 +123,18 @@ export const WalletStoreModel = types
return mintsStore.findByUrl(mintUrl) as Mint return mintsStore.findByUrl(mintUrl) as Mint
}, },
getOptimalKeyset(mintInstance: Mint, unit: MintUnit) { getOptimalKeyset(mintInstance: Mint, unit: MintUnit) {
const optimalKeyset: MintKeyset =mintInstance.keysets! // Mirrors cashu-ts v4.7 KeyChain.getCheapestKeyset: among active keysets for
.filter((k: MintKeyset) => k.unit === unit && k.active) // 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) => { .sort((a: MintKeyset, b: MintKeyset) => {
// Prioritize keysets that start with '00' const feeDelta = (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0)
const aStartsWith00 = a.id.startsWith('00') ? 1 : 0; if (feeDelta !== 0) return feeDelta
const bStartsWith00 = b.id.startsWith('00') ? 1 : 0; // Equal fee: prefer the newer keyset version (v2 `01…` over v0 `00…`)
return b.id.localeCompare(a.id)
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);
})[0] })[0]
if(!optimalKeyset) { if(!optimalKeyset) {
+1 -1
View File
@@ -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). */} {/* Stub to preview the NWC card UI in the simulator (NWC unavailable there). */}
{__DEV__ && ( {__DEV__ && (
<View style={$nwcContainer}> <View style={$nwcContainer}>
{renderNwcCard('NWC conn', 100)} {renderNwcCard('Wisp', 100)}
</View> </View>
)} )}
{isNwcVisible && ( {isNwcVisible && (
+1 -2
View File
@@ -137,8 +137,7 @@ export const isBitcoinAddress = (address: string): boolean =>
/** /**
* Are non-mainnet addresses payable in this build? * Are non-mainnet addresses payable in this build?
* *
* Debug builds only. Development is the one situation where paying a regtest address is * Debug builds only. The CDK fakewallet backend settles onchain melts
* the POINT rather than a mistake: the CDK fakewallet backend settles onchain melts
* against a regtest chain, so a release-only guard would make the whole rail * against a regtest chain, so a release-only guard would make the whole rail
* untestable end to end. * untestable end to end.
* *
+80 -1
View File
@@ -274,16 +274,95 @@ const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Pro
* @param getFeesForProofs Mint fee for a given proof set (wraps * @param getFeesForProofs Mint fee for a given proof set (wraps
* `cashuWallet.getFeesForProofs`). * `cashuWallet.getFeesForProofs`).
* @param options.maxIterations Convergence guard (default 32). * @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. * @throws VALIDATION_ERROR if available proofs cannot cover the converged total.
*/ */
const selectProofsToSendWithFeeReserve = function ( const selectProofsToSendWithFeeReserve = function (
targetAmount: number, targetAmount: number,
proofs: Proof[], proofs: Proof[],
getFeesForProofs: (selected: Proof[]) => number, getFeesForProofs: (selected: Proof[]) => number,
options?: {maxIterations?: number; caller?: string}, options?: {maxIterations?: number; caller?: string; priorityProofs?: Proof[]},
): {proofsToSend: Proof[]; feeReserve: number} { ): {proofsToSend: Proof[]; feeReserve: number} {
const maxIterations = options?.maxIterations ?? 32 const maxIterations = options?.maxIterations ?? 32
const caller = options?.caller ?? 'selectProofsToSendWithFeeReserve' 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) const totalAvailable = getProofsAmount(proofs)
let proofsToSend = getProofsToSend(targetAmount, proofs) let proofsToSend = getProofsToSend(targetAmount, proofs)
@@ -225,6 +225,10 @@ async function prepare(input: PrepareSendInput): Promise<PreparedSendData> {
} else { } else {
// ── Auto-select path ──────────────────────────────────────────── // ── Auto-select path ────────────────────────────────────────────
const inactiveKeysetIds = getInactiveKeysetIds(mintInstance) 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 let candidates: Proof[] = inactiveKeysetIds.length > 0
? prioritizeFromInactiveKeysets(mintInstance, amount, unit, proofsFromMint) ? prioritizeFromInactiveKeysets(mintInstance, amount, unit, proofsFromMint)
: CashuUtils.getProofsToSend(amount, proofsFromMint) : CashuUtils.getProofsToSend(amount, proofsFromMint)
@@ -247,7 +251,7 @@ async function prepare(input: PrepareSendInput): Promise<PreparedSendData> {
amount, amount,
proofsFromMint, proofsFromMint,
selected => walletInstance.getFeesForProofs(selected).toNumber(), selected => walletInstance.getFeesForProofs(selected).toNumber(),
{caller: 'SendOperationApi.prepare'}, {caller: 'SendOperationApi.prepare', priorityProofs: inactiveProofs},
)) ))
} catch (e: any) { } catch (e: any) {
throw new ValidationError('There is not enough funds to send this amount.', { throw new ValidationError('There is not enough funds to send this amount.', {
@@ -302,6 +302,19 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet
// Prefer spending proofs from inactive/legacy keysets so this withdrawal also
// rotates that ecash off (NUT-02: wallets SHOULD prioritize moving proofs off
// inactive keysets). Mirrors the Send path's inactive-first selection. The
// outputs (melt change) still go to the active keyset chosen in getWallet.
const inactiveKeysetIds = new Set(
(mintInstance.keysets ?? [])
.filter(k => 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 // 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 // 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 // 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<PreparedTransferDat
amount + feeReserve, amount + feeReserve,
proofsFromMint, proofsFromMint,
selected => walletInstance.getFeesForProofs(selected).toNumber(), selected => walletInstance.getFeesForProofs(selected).toNumber(),
{caller: 'TransferOperationApi.prepare'}, {caller: 'TransferOperationApi.prepare', priorityProofs: inactiveProofs},
)) ))
} catch (e: any) { } catch (e: any) {
throw new ValidationError('There is not enough funds to send this amount.', { throw new ValidationError('There is not enough funds to send this amount.', {
+2 -12
View File
@@ -6,29 +6,19 @@ import {
TransactionType, TransactionType,
} from '../../models/Transaction' } from '../../models/Transaction'
import {rootStoreInstance} from '../../models' import {rootStoreInstance} from '../../models'
import {CashuUtils, CashuProof} from '../cashu/cashuUtils' import {CashuUtils} from '../cashu/cashuUtils'
import AppError, {Err} from '../../utils/AppError'
import { import {
Mint as CashuMint,
Wallet as CashuWallet,
CheckStateEnum,
MintKeyset, MintKeyset,
ProofState as CashuProofState,
getEncodedToken,
normalizeProofAmounts,
} from '@cashu/cashu-ts' } from '@cashu/cashu-ts'
import { MAX_SWAP_INPUT_SIZE, TransactionTaskResult, WalletTask } from '../walletService' import { TransactionTaskResult } from '../walletService'
import { Mint, MintBalance } from '../../models/Mint' import { Mint, MintBalance } from '../../models/Mint'
import { Proof } from '../../models/Proof' import { Proof } from '../../models/Proof'
import { poller } from '../../utils/poller'
import { WalletUtils } from './utils' import { WalletUtils } from './utils'
import { MintUnit } from './currency' import { MintUnit } from './currency'
const { const {
mintsStore,
proofsStore, proofsStore,
transactionsStore, transactionsStore,
walletStore
} = rootStoreInstance } = rootStoreInstance
export const SEND_TASK = 'sendTask' export const SEND_TASK = 'sendTask'