Option to optimize backup size for big wallets by ecash swap, receive very large ecash tokens in batches

This commit is contained in:
minibits-cash
2024-10-15 23:16:44 +02:00
parent 8c4500400f
commit c834342a2c
9 changed files with 373 additions and 37 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.1.9-beta.22",
"version": "0.1.9-beta.23",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
+1 -1
View File
@@ -126,7 +126,7 @@
},
"scan": "Scan"
},
"copyAsEncodedTokens": "Copy as encoded tokens",
"copyAsEncodedTokens": "Copy as sendable tokens",
"copyContactPublicKey": "Copy contact's public key",
"copyMnemonicBackupWorkaround": "To apply backup to your existing funds, send your entire balance to yourself. Otherwise, only funds from this point onward can be restored.",
"copyProofs": "Copy proofs",
+217 -22
View File
@@ -5,9 +5,10 @@ import {
ViewStyle,
View,
Switch,
Alert,
} from 'react-native'
import {btoa, fromByteArray} from 'react-native-quick-base64'
import {useThemeColor, spacing, typography} from '../theme'
import {useThemeColor, spacing, typography, colors} from '../theme'
import {
Button,
Icon,
@@ -18,6 +19,7 @@ import {
ErrorModal,
InfoModal,
Loading,
BottomModal,
} from '../components'
import {SettingsStackScreenProps} from '../navigation'
import {useHeader} from '../utils/useHeader'
@@ -25,6 +27,7 @@ import {log} from '../services/logService'
import AppError from '../utils/AppError'
import { Proof } from '../models/Proof'
import { useStores } from '../models'
import EventEmitter from '../utils/eventEmitter'
import { CashuUtils, ProofV3, TokenV3 } from '../services/cashu/cashuUtils'
import Clipboard from '@react-native-clipboard/clipboard'
import { translate } from '../i18n'
@@ -32,10 +35,14 @@ import { ProofsStoreSnapshot } from '../models/ProofsStore'
import { getSnapshot } from 'mobx-state-tree'
import { ContactsStoreSnapshot } from '../models/ContactsStore'
import { MintsStoreSnapshot } from '../models/MintsStore'
import { Database } from '../services'
import { Database, TransactionTaskResult, WalletTask, WalletTaskResult } from '../services'
import { Transaction, TransactionStatus } from '../models/Transaction'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
import { verticalScale } from '@gocodingnow/rn-size-matters'
interface ExportBackupScreenProps extends SettingsStackScreenProps<'ExportBackup'> {}
const OPTIMIZE_FROM_PROOFS_COUNT = 100
export const ExportBackupScreen: FC<ExportBackupScreenProps> =
function ExportBackup(_props) {
@@ -56,9 +63,18 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
const [error, setError] = useState<AppError | undefined>()
const [orphanedProofs, setOrphanedProofs] = useState<Proof[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSendAllSentToQueue, setIsSendAllSentToQueue] = useState<boolean>(false)
const [isReceiveBatchSentToQueue, setIsReceiveBatchSentToQueue] = useState<boolean>(false)
const [totalSentProofsCount, setTotalSentProofsCount] = useState<number>(0)
const [totalReceiveErrorCount, setTotalReceiveErrorCount] = useState<number>(0)
const [totalReceiveCompleteCount, setTotalReceiveCompleteCount] = useState<number>(0)
const [isEcashInBackup, setIsEcashInBackup] = useState(true)
const [isMintsInBackup, setIsMintsInBackup] = useState(true)
const [isContactsInBackup, setIsContactsInBackup] = useState(true)
const [isResultModalVisible, setIsResultModalVisible] = useState(false)
const [resultModalInfo, setResultModalInfo] = useState<
{status: TransactionStatus; title?: string, message: string} | undefined
>()
useEffect(() => {
@@ -85,7 +101,101 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
return () => {}
}, [])
useEffect(() => {
const handleSendAllResult = async (result: TransactionTaskResult) => {
log.trace('[handleSendAllResults] event handler triggered')
if (!isSendAllSentToQueue) { return false }
// runs per each mint and unit
if (result.transaction && result.transaction.status === TransactionStatus.PENDING) {
// now we batch receive the pending encoded token
// this forces the proofs swap with the mint for standard denomination amounts
const encodedTokenToReceive: string = result.encodedTokenToSend
const tokenToReceive = CashuUtils.decodeToken(encodedTokenToReceive)
const {totalAmount: tokenAmount} = CashuUtils.getTokenAmounts(tokenToReceive)
const proofsCount = tokenToReceive.token[0].proofs.length
setTotalSentProofsCount(prev => prev + proofsCount)
setIsReceiveBatchSentToQueue(true)
WalletTask.receiveBatch(
tokenToReceive,
tokenAmount,
tokenToReceive.memo as string,
encodedTokenToReceive
)
}
}
if(isSendAllSentToQueue) {
EventEmitter.on('ev_sendTask_result', handleSendAllResult)
}
return () => {
EventEmitter.off('ev_sendTask_result', handleSendAllResult)
}
}, [isSendAllSentToQueue])
useEffect(() => {
// runs for every receive in a batch
const handleReceiveTaskResult = async (result: TransactionTaskResult) => {
log.trace('handleReceiveTaskResult event handler triggered')
const {error} = result
if (error) {
setTotalReceiveErrorCount(prev => prev + 1)
} else {
setTotalReceiveCompleteCount(prev => prev + 1)
}
}
if(isReceiveBatchSentToQueue) {
EventEmitter.on('ev_receiveTask_result', handleReceiveTaskResult)
}
}, [isReceiveBatchSentToQueue])
useEffect(() => {
// runs for every receive in a batch
const showProofOptimizationResult = async () => {
log.trace('handleReceiveTaskResult event handler triggered')
setIsLoading(false)
const currentProofsCount = proofsStore.proofsCount
let message = `Original proofs count: ${totalSentProofsCount}, Optimized proofs count: ${currentProofsCount}`
if (totalReceiveErrorCount > 0) {
message += `, errors: ${totalReceiveErrorCount}`
}
setResultModalInfo({
status: totalReceiveErrorCount > 0 ? TransactionStatus.ERROR : TransactionStatus.COMPLETED,
message,
})
setIsResultModalVisible(true)
}
if(totalReceiveCompleteCount > 0 || totalReceiveCompleteCount > 0) {
showProofOptimizationResult()
}
}, [totalReceiveErrorCount, totalReceiveCompleteCount])
const toggleResultModal = () =>
setIsResultModalVisible(previousState => !previousState)
const toggleBackupEcashSwitch = () =>
setIsEcashInBackup(previousState => !previousState)
@@ -98,6 +208,31 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
setIsContactsInBackup(previousState => !previousState)
const optimizeProofAmountsStart = function () {
Alert.alert(
'Optimize ecash proofs',
'Do you want to swap your wallet ecash for proofs with optimal denominations? The size of your backup will decrease.',
[
{
text: translate('common.cancel'),
style: 'cancel',
onPress: () => { /* Action canceled */ },
},
{
text: translate('common.confirm'),
onPress: async () => {
// Moves all wallet proofs to pending in transactions split by mints and by units and in offline mode
setIsLoading(true)
setIsSendAllSentToQueue(true)
WalletTask.sendAll()
},
},
],
)
}
const copyBackup = function () {
try {
setIsLoading(true)
@@ -123,14 +258,15 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
}
if(isEcashInBackup) {
// This is emptied in snapshot postprocess!
const proofsSnapshot = getSnapshot(proofsStore.proofs)
// proofsStore is emptied in snapshot postprocess!
const proofsSnapshot = getSnapshot(proofsStore.proofs)
// Do not include orphaned proofs as they can not be imported without mintUrl
const cleaned = proofsSnapshot.filter(p => p.mintUrl && p.mintUrl.length > 0)
exportedProofsStore = {
proofs: cleaned,
pendingProofs: getSnapshot(proofsStore.pendingProofs),
pendingProofs: getSnapshot(proofsStore.pendingProofs) || [],
pendingByMintSecrets: getSnapshot(proofsStore.pendingByMintSecrets)
}
}
@@ -259,6 +395,7 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
const copyOrphanedProofs = function() {
log.trace({orphanedProofs})
if(orphanedProofs.length > 0) {
Clipboard.setString(JSON.stringify(orphanedProofs))
}
@@ -447,13 +584,21 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
subText={`Number of proofs: ${proofsStore.proofsCount + proofsStore.pendingProofsCount}`}
RightComponent={
<View style={$rightContainer}>
{proofsStore.proofsCount > OPTIMIZE_FROM_PROOFS_COUNT && (
<Button
preset='secondary'
onPress={optimizeProofAmountsStart}
textStyle={{lineHeight: verticalScale(16), fontSize: verticalScale(14)}}
style={{minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny)}}
text={'Optimize'}
/>
)}
<Switch
onValueChange={toggleBackupEcashSwitch}
value={isEcashInBackup}
/>
</View>
}
topSeparator
}
/>
)}
{mintsStore.mintCount > 0 && (
@@ -468,7 +613,7 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
/>
</View>
}
topSeparator
topSeparator={proofsStore.proofsCount + proofsStore.pendingProofsCount > 0 ? true : false}
/>
)}
{contactsStore.count > 0 && (
@@ -483,7 +628,7 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
/>
</View>
}
topSeparator
topSeparator={mintsStore.mintCount > 0 ? true : false}
/>
)}
</>
@@ -492,16 +637,19 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
/>
<View style={$bottomContainer}>
<View style={{
flexDirection: 'row',
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.small,
paddingRight: spacing.medium
paddingRight: spacing.medium,
marginLeft: -spacing.medium
}}
>
<Icon icon='faInfoCircle' />
<Icon icon='faInfoCircle' containerStyle={{marginRight: spacing.extraSmall}}/>
<Text
style={{color: hint}}
size='xs'
size='xs'
preset='formHelper'
text='You will still need your seed phrase when using this backup to recover your wallet.'
/>
</View>
@@ -528,15 +676,61 @@ export const ExportBackupScreen: FC<ExportBackupScreenProps> =
tx="copyAsEncodedTokens"
textStyle={{fontSize: 14}}
/>
<Button
preset="tertiary"
onPress={copyOrphanedProofs}
text="Copy orphaned proofs"
textStyle={{fontSize: 14, marginLeft: spacing.small}}
/>
{orphanedProofs.length > 0 && (
<Button
preset="tertiary"
onPress={copyOrphanedProofs}
text="Copy orphaned proofs"
textStyle={{fontSize: 14, marginLeft: spacing.small}}
/>
)}
</View>
)}
</View>
</View>
<BottomModal
isVisible={isResultModalVisible ? true : false}
ContentComponent={
<>
{resultModalInfo?.status === TransactionStatus.COMPLETED && (
<>
<ResultModalInfo
icon={'faCheckCircle'}
iconColor={colors.palette.success200}
title={resultModalInfo.title || translate('common.success')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx='common.close'
onPress={toggleResultModal}
/>
</View>
</>
)}
{(resultModalInfo?.status === TransactionStatus.ERROR ||
resultModalInfo?.status === TransactionStatus.BLOCKED) && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.focus300}
title={resultModalInfo?.title as string || translate('transactionCommon.receiveFailed')}
message={resultModalInfo?.message as string}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'common.close'}
onPress={toggleResultModal}
/>
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
</Screen>
)
}
@@ -561,7 +755,7 @@ const $contentContainer: TextStyle = {
const $card: ViewStyle = {
marginBottom: spacing.small,
paddingTop: 0,
//paddingTop: 0,
}
const $buttonContainer: ViewStyle = {
@@ -588,7 +782,8 @@ const $rightContainer: ViewStyle = {
padding: spacing.extraSmall,
// alignSelf: 'center',
marginLeft: spacing.tiny,
marginRight: -10
marginRight: -10,
flexDirection: 'row'
}
const $bottomContainer: ViewStyle = {
+39 -7
View File
@@ -19,7 +19,7 @@ import {
import {Mint} from '../models/Mint'
import {Transaction, TransactionStatus} from '../models/Transaction'
import {useStores} from '../models'
import {TransactionTaskResult, WalletTask} from '../services'
import {MAX_SWAP_INPUT_SIZE, TransactionTaskResult, WalletTask} from '../services'
import {log} from '../services/logService'
import AppError, { Err } from '../utils/AppError'
import EventEmitter from '../utils/eventEmitter'
@@ -45,6 +45,7 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
const [encodedToken, setEncodedToken] = useState<string | undefined>()
const [amountToReceive, setAmountToReceive] = useState<string>('0')
const [unit, setUnit] = useState<MintUnit>('sat')
const [totalReceived, setTotalReceived] = useState<number>(0)
const [receivedAmount, setReceivedAmount] = useState<string>('0')
const [transactionStatus, setTransactionStatus] = useState<
TransactionStatus | undefined
@@ -103,14 +104,17 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
}
if (receivedAmount && receivedAmount > 0) {
// accumulate received amount in case of multiple receives in batch
setTotalReceived(prev => prev + receivedAmount)
const currency = getCurrency(unit)
setReceivedAmount(`${numbro(receivedAmount / currency.precision).format({thousandSeparated: true, mantissa: currency.mantissa})}`)
setReceivedAmount(`${numbro(totalReceived / currency.precision).format({thousandSeparated: true, mantissa: currency.mantissa})}`)
}
setIsResultModalVisible(true)
}
// Subscribe to the 'sendCompleted' event
if(isReceiveTaskSentToQueue) {
EventEmitter.on('ev_receiveTask_result', handleReceiveTaskResult)
EventEmitter.on('ev_receiveOfflinePrepareTask_result', handleReceiveTaskResult)
@@ -123,6 +127,20 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
}
}, [isReceiveTaskSentToQueue])
useEffect(() => {
const updateReceivedAmount = async () => {
log.trace('[updateTotalReceived] start')
const currency = getCurrency(unit)
setReceivedAmount(`${numbro(totalReceived / currency.precision).format({thousandSeparated: true, mantissa: currency.mantissa})}`)
}
if(totalReceived > 0) {
updateReceivedAmount()
}
}, [totalReceived])
const resetState = function () {
setToken(undefined)
setEncodedToken(undefined)
@@ -174,17 +192,31 @@ export const ReceiveScreen: FC<WalletStackScreenProps<'Receive'>> = observer(
const receiveToken = async function () {
setIsLoading(true)
setIsReceiveTaskSentToQueue(true)
setIsLoading(true)
setIsReceiveTaskSentToQueue(true)
const amountToReceiveInt = round(toNumber(amountToReceive) * getCurrency(unit).precision, 0)
const proofsCount = token!.token.flatMap(entry => entry.proofs).length
WalletTask.receive(
if(proofsCount > MAX_SWAP_INPUT_SIZE) {
WalletTask.receiveBatch(
token as TokenV3,
amountToReceiveInt,
memo,
encodedToken as string,
)
)
} else {
WalletTask.receive(
token as TokenV3,
amountToReceiveInt,
memo,
encodedToken as string,
)
}
}
+3 -1
View File
@@ -69,7 +69,9 @@ export const RecoveryOptionsScreen: FC<AppStackScreenProps<'RecoveryOptions'>> =
}
}
EventEmitter.on('ev__syncStateWithMintTask_result', removeSpentByMintTaskResult)
if(isSyncStateSentToQueue) {
EventEmitter.on('ev__syncStateWithMintTask_result', removeSpentByMintTaskResult)
}
return () => {
EventEmitter.off('ev__syncStateWithMintTask_result', removeSpentByMintTaskResult)
+6
View File
@@ -382,6 +382,11 @@ const findMinExcess = function (requestedAmount: number, proofs: Proof[]): Proof
return selectedProofs;
}
/*
* This function attempts to find exact match combination of proofs for a transaction amount.
* If not found, minimal number of proofs exceeding the amount is selected
* It is intended to minimize number of swaps and possible fees.
*/
const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Proof[] {
const proofsAmount = getProofsAmount(proofs)
if(requestedAmount > proofsAmount) {
@@ -400,6 +405,7 @@ const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Pro
return findMinExcess(requestedAmount, proofs);
}
/**
* removes a set of tokens from another set of tokens, and returns the remaining.
* @param proofs
+1 -1
View File
@@ -885,7 +885,7 @@ const updateInputToken = function (id: number, inputToken: string) {
const db = getInstance()
db.execute(query, params)
log.debug('[updateInputToken]', 'Transaction outputToken updated', {id, inputToken})
log.debug('[updateInputToken]', 'Transaction inputToken updated in database', {id})
const updatedTx = getTransactionById(id as number)
+2 -3
View File
@@ -119,9 +119,8 @@ export const receiveTask = async function (
// Increase the proofs counter before the mint call so that in case the response
// is not received our recovery index counts for sigs the mint has already issued
const amountPreferences = getDefaultAmountPreference(amountToReceive)
const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences)
const tokenEntries: TokenEntryV3[] = token.token
const proofsToReceive = tokenEntries[0].proofs as ProofV3[]
const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences)
const proofsToReceive = token.token.flatMap(entry => entry.proofs)
const mintFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive)
log.trace('[receiveTask]', 'amountPreferences', {amountPreferences, transactionId: transaction.id})
+103 -1
View File
@@ -31,6 +31,8 @@ import { MintUnit, formatCurrency, getCurrency } from './wallet/currency'
import { MinibitsClient } from './minibitsService'
export const MAX_SWAP_INPUT_SIZE = 50
type WalletTaskService = {
syncPendingStateWithMints: () => Promise<void>
syncSpendableStateWithMints: () => Promise<void>
@@ -71,6 +73,12 @@ type WalletTaskService = {
memo: string,
encodedToken: string,
) => Promise<void>
receiveBatch: (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<void>
receiveOfflinePrepare: (
token: TokenV3,
amountToReceive: number,
@@ -87,6 +95,7 @@ type WalletTaskService = {
memo: string,
selectedProofs: Proof[]
) => Promise<void>
sendAll: () => Promise<void>
topup: (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
@@ -202,6 +211,65 @@ const receive = async function (
return
}
/*
* Receive big tokens in batches to keep mint load reasonable.
* Used when optimizing wallet proof amounts but might become the default.
*/
const receiveBatch = async function (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
) {
const maxBatchSize = MAX_SWAP_INPUT_SIZE
const mintUrl = token.token[0].mint
const proofsToReceive = token.token.flatMap(entry => entry.proofs)
const unit = token.unit
if (proofsToReceive.length > maxBatchSize) {
let index =0
for (let i = 0; i < proofsToReceive.length; i += maxBatchSize) {
index++
const batch = proofsToReceive.slice(i, i + maxBatchSize)
const batchAmount = CashuUtils.getProofsAmount(batch)
const batchToken: TokenV3 = {
token: [
{
mint: mintUrl,
proofs: batch
}
],
memo: `${memo} #${index}`,
unit
}
const batchEncodedToken = CashuUtils.encodeToken(batchToken)
// Queued WalletTask
receive(
batchToken,
batchAmount,
`${memo} #${index}`,
batchEncodedToken,
)
}
} else {
// If the length is less than or equal to 100, do normal receive
receive(
token,
amountToReceive,
memo,
encodedToken,
)
}
}
const receiveOfflinePrepare = async function (
token: TokenV3,
@@ -259,6 +327,39 @@ const send = async function (
}
/*
* sendAll moves all proofs to pending to prepare to swap them for standard amount preference
* This decreases the total number of proofs held by the wallet. Used to optimize exported backup size.
*/
const sendAll = async function (): Promise<void> {
log.trace('[sendAll] start')
if (mintsStore.mintCount === 0) {
return
}
// Move all proofs by mint units to pending as in offline mode (do not ask for swap)
for (const mint of mintsStore.allMints) {
for (const unit of mint.units) {
const proofsToOptimize = proofsStore.getByMint(mint.mintUrl, { isPending: false, unit })
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
const mintBalance = mint.balances
// Queued WalletTask.send
send(
mintBalance!,
proofsAmount,
unit,
`Optimize proof amounts`,
proofsToOptimize // forces offline mode
)
}
}
}
const topup = async function (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
@@ -1642,7 +1743,6 @@ const _extractZapSenderData = function (str: string) {
}
export const WalletTask: WalletTaskService = {
syncPendingStateWithMints,
syncSpendableStateWithMints,
@@ -1654,9 +1754,11 @@ export const WalletTask: WalletTaskService = {
handleClaim,
receiveEventsFromRelays,
receive,
receiveBatch,
receiveOfflinePrepare,
receiveOfflineComplete,
send,
sendAll,
transfer,
topup,
}