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'