Optimize ecash denominations per mint

This commit is contained in:
minibits-cash
2026-05-28 17:14:17 +02:00
parent a8c3ee78dc
commit 872a602a31
6 changed files with 189 additions and 121 deletions

View File

@@ -2,5 +2,7 @@
"[javascriptreact][typescriptreact][javascript][typescript]": {
"editor.insertSpaces": true,
"editor.tabSize": 4
}
},
"claudeCode.disableLoginPrompt": true,
"claudeCode.selectedModel": "claude-opus-4-7"
}

View File

@@ -70,7 +70,8 @@ notifee.registerForegroundService(async (notification) => {
if(notification.data.task === SWAP_DENOMINATION_TASK) {
log.debug(`[registerForegroundService] Submitting task ${SWAP_DENOMINATION_TASK} to the queue.`)
WalletTask.swapByDenominationQueue(notification.data.data)
const payload = notification.data.data || {}
WalletTask.swapByDenominationQueue(payload.denomination, payload.mintUrl)
}

View File

@@ -15,7 +15,7 @@ export const MintBalanceSelector = observer(function (props: {
selectedMintBalance?: MintBalance
unit: MintUnit
title: string
confirmTitle: string
confirmTitle?: string
confirmIcon?: IconTypes
secondaryConfirmTitle?: string
secondaryConfirmIcon?: IconTypes
@@ -23,8 +23,8 @@ export const MintBalanceSelector = observer(function (props: {
collapsible?: boolean
onMintBalanceSelect: any
onSecondaryMintBalanceSelect?: any
onCancel: any
onMintBalanceConfirm: any
onCancel?: any
onMintBalanceConfirm?: any
}) {
const collapsible = props.collapsible === false ? false : true // default true
@@ -120,45 +120,51 @@ export const MintBalanceSelector = observer(function (props: {
</>
}
/>
<View style={$bottomContainer}>
{!isKeyboardVisible && (
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
{props.secondaryConfirmTitle && (
<Button
text={props.secondaryConfirmTitle}
LeftAccessory={() =>
(props.secondaryConfirmIcon && (
<Icon icon={props.secondaryConfirmIcon} color={secondaryIconColor} size={spacing.medium}/>
))
}
preset="secondary"
onPress={props.onSecondaryMintBalanceSelect}
style={{marginRight: spacing.medium}}
/>
)}
<Button
text={props.confirmTitle}
LeftAccessory={() =>
<Icon icon={props.confirmIcon ? props.confirmIcon : 'faCheckCircle'} color="white" size={spacing.medium}/>
}
onPress={props.onMintBalanceConfirm}
style={{marginRight: spacing.medium}}
/>
{(props.confirmTitle || props.secondaryConfirmTitle || props.onCancel) && (
<View style={$bottomContainer}>
{!isKeyboardVisible && (
<View style={[$buttonContainer, {marginTop: spacing.large}]}>
<Button
preset="secondary"
LeftAccessory={() =>
(props.cancelIcon && (
<Icon icon={props.cancelIcon} color={secondaryIconColor} size={spacing.medium}/>
))
}
tx={'commonCancel'}
onPress={props.onCancel}
/>
{props.secondaryConfirmTitle && props.onSecondaryMintBalanceSelect && (
<Button
text={props.secondaryConfirmTitle}
LeftAccessory={() =>
(props.secondaryConfirmIcon && (
<Icon icon={props.secondaryConfirmIcon} color={secondaryIconColor} size={spacing.medium}/>
))
}
preset="secondary"
onPress={props.onSecondaryMintBalanceSelect}
style={{marginRight: spacing.medium}}
/>
)}
{props.confirmTitle && props.onMintBalanceConfirm && (
<Button
text={props.confirmTitle}
LeftAccessory={() =>
<Icon icon={props.confirmIcon ? props.confirmIcon : 'faCheckCircle'} color="white" size={spacing.medium}/>
}
onPress={props.onMintBalanceConfirm}
style={{marginRight: spacing.medium}}
/>
)}
{props.onCancel && (
<Button
preset="secondary"
LeftAccessory={() =>
(props.cancelIcon && (
<Icon icon={props.cancelIcon} color={secondaryIconColor} size={spacing.medium}/>
))
}
tx={'commonCancel'}
onPress={props.onCancel}
/>
)}
</View>
)}
</View>
)}
</View>
)}
</View>
)
})

View File

@@ -1,4 +1,4 @@
import React, {useState, useEffect, useLayoutEffect} from 'react'
import React, {useState, useEffect, useLayoutEffect, useMemo} from 'react'
import {
TextStyle,
ViewStyle,
@@ -31,6 +31,9 @@ import {TransactionStatus} from '../models/Transaction'
import {ResultModalInfo} from './Wallet/ResultModalInfo'
import {verticalScale} from '@gocodingnow/rn-size-matters'
import {StaticScreenProps, useNavigation} from '@react-navigation/native'
import {MintBalanceSelector} from './Mints/MintBalanceSelector'
import {MintBalance} from '../models/Mint'
import {MintUnit} from '../services/wallet/currency'
const OPTIMIZE_DENOMINATION_THRESHOLD = 5
@@ -57,8 +60,20 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
})
}, [])
// For now this screen only deals with the 'sat' unit, as denominations are unit-bound.
const unit: MintUnit = 'sat'
const mintBalances = useMemo<MintBalance[]>(
() => proofsStore.getMintBalancesWithUnit(unit),
// eslint-disable-next-line react-hooks/exhaustive-deps
[proofsStore.balances],
)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
const [selectedMintBalance, setSelectedMintBalance] = useState<MintBalance | undefined>(
() => proofsStore.getMintBalanceWithMaxBalance(unit) ?? mintBalances[0],
)
const [denominationCounts, setDenominationCounts] = useState<{denomination: number; count: number}[]>([])
const [activeDenomination, setActiveDenomination] = useState<number | null>(null)
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
@@ -68,9 +83,10 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
>()
useEffect(() => {
const counts = buildDenominationCounts()
const counts = buildDenominationCounts(selectedMintBalance?.mintUrl)
setDenominationCounts(counts)
}, [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMintBalance?.mintUrl])
useEffect(() => {
const handleResult = async (result: WalletTaskResult) => {
@@ -85,8 +101,8 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
})
setIsResultModalVisible(true)
// Refresh denomination counts after optimization
const counts = buildDenominationCounts()
// Refresh denomination counts after optimization for the selected mint
const counts = buildDenominationCounts(selectedMintBalance?.mintUrl)
setDenominationCounts(counts)
setActiveDenomination(null)
}
@@ -98,14 +114,15 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
return () => {
EventEmitter.off(`ev_${SWAP_DENOMINATION_TASK}_result`, handleResult)
}
}, [activeDenomination])
}, [activeDenomination, selectedMintBalance?.mintUrl])
const buildDenominationCounts = () => {
const allProofs = Array.from(proofsStore.proofs.values()).filter(
p => p.state === 'UNSPENT',
)
const buildDenominationCounts = (mintUrl?: string) => {
if (!mintUrl) {
return []
}
const mintProofs = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit})
const countMap: Record<number, number> = {}
for (const proof of allProofs) {
for (const proof of mintProofs) {
countMap[proof.amount] = (countMap[proof.amount] ?? 0) + 1
}
return Object.entries(countMap)
@@ -113,7 +130,15 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
.sort((a, b) => a.denomination - b.denomination)
}
const onMintBalanceSelect = (balance: MintBalance) => {
setSelectedMintBalance(balance)
}
const optimizeDenomination = async (denomination: number) => {
if (!selectedMintBalance) {
return
}
const enabled = await NotificationService.areNotificationsEnabled()
if (!enabled) {
@@ -128,10 +153,13 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
if (Platform.OS === 'android') {
await NotificationService.createTaskNotification(
`Optimizing denomination ${denomination}...`,
{task: SWAP_DENOMINATION_TASK, data: denomination},
{
task: SWAP_DENOMINATION_TASK,
data: {denomination, mintUrl: selectedMintBalance.mintUrl},
},
)
} else {
WalletTask.swapByDenominationQueue(denomination)
WalletTask.swapByDenominationQueue(denomination, selectedMintBalance.mintUrl)
}
} catch (e: any) {
handleError(e)
@@ -171,22 +199,35 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
title="Optimize ecash"
scrollY={scrollY}
/>
<Card
content={`Only denominations with more than ${OPTIMIZE_DENOMINATION_THRESHOLD} proofs can be optimized - by swapping them with the mint for lower number of ecash notes with higher amounts.`}
style={[$card, {marginTop: -spacing.extraLarge * 1.5}]}
<View style={[$selectorContainer, {marginTop: -spacing.extraLarge * 1.5}]}>
{mintBalances.length > 0 ? (
<MintBalanceSelector
mintBalances={mintBalances}
selectedMintBalance={selectedMintBalance}
unit={unit}
title="Select mint to optimize"
onMintBalanceSelect={onMintBalanceSelect}
/>
) : (
<Card content="No mints with balance to optimize." style={$card} />
)}
</View>
<Text
text={`Only denominations with more than ${OPTIMIZE_DENOMINATION_THRESHOLD} proofs can be optimized - by swapping them with the mint for lower number of ecash notes with higher amounts.`}
preset="formHelper"
style={[$hintText, {color: hint}]}
/>
<Card
ContentComponent={
<>
{denominationCounts.length === 0 ? (
<ListItem text="No ecash proofs found." />
<ListItem text="No ecash proofs for the selected mint." />
) : (
denominationCounts.map(({denomination, count}, index) => (
<ListItem
key={denomination}
text={`${denomination}`}
leftIcon='faMoneyBill1'
//subText={`Count: ${count}`}
RightComponent={
<View style={$rightContainer}>
<Text preset="formHelper" text={`${count}x`} style={{color: hint, marginRight: spacing.small}} />
@@ -284,6 +325,15 @@ const $card: ViewStyle = {
marginHorizontal: spacing.small,
}
const $selectorContainer: ViewStyle = {
marginHorizontal: spacing.small,
}
const $hintText: TextStyle = {
marginHorizontal: spacing.medium,
marginBottom: spacing.small,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
@@ -296,8 +346,3 @@ const $rightContainer: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
}
const $hintContainer: ViewStyle = {
marginHorizontal: spacing.medium,
marginBottom: spacing.small,
}

View File

@@ -594,10 +594,14 @@ const swapAllTask = async function (): Promise<WalletTaskResult> {
}
/*
* swapByDenominationTask swaps only proofs with the given denomination (amount) across all mints.
* swapByDenominationTask swaps only proofs with the given denomination (amount)
* for a single mint identified by mintUrl.
*/
const swapByDenominationTask = async function (denomination: number): Promise<WalletTaskResult> {
log.trace('[swapByDenominationTask] start', {denomination})
const swapByDenominationTask = async function (
denomination: number,
mintUrl: string,
): Promise<WalletTaskResult> {
log.trace('[swapByDenominationTask] start', {denomination, mintUrl})
if (mintsStore.mintCount === 0) {
return {
@@ -606,67 +610,44 @@ const swapByDenominationTask = async function (denomination: number): Promise<Wa
}
}
const mint = mintsStore.findByUrl(mintUrl)
if (!mint) {
return {
taskFunction: SWAP_DENOMINATION_TASK,
message: `Mint ${mintUrl} not found.`,
}
}
let initialProofsCount = 0
let finalProofsCount = 0
const errors: string[] = []
const maxBatchSize = MAX_SWAP_INPUT_SIZE
for (const mint of mintsStore.allMints) {
for (const unit of mint.units) {
const allProofs = proofsStore.getByMint(mint.mintUrl, {state: 'UNSPENT', unit, ascending: true})
const proofsToOptimize = allProofs.filter(p => p.amount === denomination)
for (const unit of mint.units) {
const allProofs = proofsStore.getByMint(mint.mintUrl, {state: 'UNSPENT', unit, ascending: true})
const proofsToOptimize = allProofs.filter(p => p.amount === denomination)
if (proofsToOptimize.length === 0) {
continue
}
if (proofsToOptimize.length === 0) {
continue
}
initialProofsCount += proofsToOptimize.length
const mintBalance = mint.balances
initialProofsCount += proofsToOptimize.length
const mintBalance = mint.balances
if (proofsToOptimize.length > maxBatchSize) {
let index = 0
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
index++
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
const batchAmount = CashuUtils.getProofsAmount(batch)
const sendResult = await sendTask(
mintBalance!,
batchAmount,
unit,
`Optimize denomination ${denomination} #${index}`,
batch,
)
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
const receiveResult = await receiveBatchTask(
tokenToReceive,
tokenAmount,
tokenToReceive.memo as string,
encodedTokenToReceive,
)
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState: 'PENDING'})
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
finalProofsCount += receiveResult.receivedProofsCount
}
if (receiveResult.error) {
errors.push(receiveResult.error.message)
}
}
} else {
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
if (proofsToOptimize.length > maxBatchSize) {
let index = 0
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
index++
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
const batchAmount = CashuUtils.getProofsAmount(batch)
const sendResult = await sendTask(
mintBalance!,
proofsAmount,
batchAmount,
unit,
`Optimize denomination ${denomination}`,
proofsToOptimize,
`Optimize denomination ${denomination} #${index}`,
batch,
)
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
@@ -680,7 +661,7 @@ const swapByDenominationTask = async function (denomination: number): Promise<Wa
encodedTokenToReceive,
)
await syncStateWithMintTask({proofsToSync: proofsToOptimize, mintUrl: mint.mintUrl, proofState: 'PENDING'})
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState: 'PENDING'})
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
finalProofsCount += receiveResult.receivedProofsCount
@@ -689,6 +670,36 @@ const swapByDenominationTask = async function (denomination: number): Promise<Wa
errors.push(receiveResult.error.message)
}
}
} else {
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
const sendResult = await sendTask(
mintBalance!,
proofsAmount,
unit,
`Optimize denomination ${denomination}`,
proofsToOptimize,
)
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
const receiveResult = await receiveBatchTask(
tokenToReceive,
tokenAmount,
tokenToReceive.memo as string,
encodedTokenToReceive,
)
await syncStateWithMintTask({proofsToSync: proofsToOptimize, mintUrl: mint.mintUrl, proofState: 'PENDING'})
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
finalProofsCount += receiveResult.receivedProofsCount
}
if (receiveResult.error) {
errors.push(receiveResult.error.message)
}
}
}
@@ -709,11 +720,14 @@ const swapAllQueue = async function (): Promise<void> {
)
}
const swapByDenominationQueue = async function (denomination: number): Promise<void> {
const swapByDenominationQueue = async function (
denomination: number,
mintUrl: string,
): Promise<void> {
const now = new Date().getTime()
return SyncQueue.addPrioritizedTask(
`swapDenominationTask-${denomination}-${now}`,
async () => await swapByDenominationTask(denomination),
async () => await swapByDenominationTask(denomination, mintUrl),
)
}

View File

@@ -108,7 +108,7 @@ type WalletTaskService = {
draftTransactionId?: number,
) => Promise<TransactionTaskResult>
swapAllQueue: () => Promise<void>
swapByDenominationQueue: (denomination: number) => Promise<void>
swapByDenominationQueue: (denomination: number, mintUrl: string) => Promise<void>
topupQueueAwaitable: (
mintBalanceToTopup: MintBalance,
amountToTopup: number,