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
|
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) {
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.', {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user