mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Support keysets v2, improve and extend prioritization of inactive keysets to swap or spend them
This commit is contained in:
+11
-12
@@ -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) {
|
||||
|
||||
@@ -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__ && (
|
||||
<View style={$nwcContainer}>
|
||||
{renderNwcCard('NWC conn', 100)}
|
||||
{renderNwcCard('Wisp', 100)}
|
||||
</View>
|
||||
)}
|
||||
{isNwcVisible && (
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -225,6 +225,10 @@ async function prepare(input: PrepareSendInput): Promise<PreparedSendData> {
|
||||
} 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<PreparedSendData> {
|
||||
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.', {
|
||||
|
||||
@@ -302,6 +302,19 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
|
||||
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
|
||||
// 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<PreparedTransferDat
|
||||
amount + feeReserve,
|
||||
proofsFromMint,
|
||||
selected => 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.', {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user