chore(fb): freeze utxo after creating fb

This commit is contained in:
theborakompanioni 2026-07-31 17:23:45 +02:00
parent edf97c30a7
commit 25246e1690
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
8 changed files with 129 additions and 31 deletions

3
.env
View file

@ -23,3 +23,6 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000
VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10
VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS=true
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1000

View file

@ -3,3 +3,5 @@ VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1

View file

@ -6,11 +6,15 @@ import { useQuery } from '@tanstack/react-query'
import { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY,
} from '@/constants/jam'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { Utxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import type { WalletFileName } from '@/lib/utils'
import { delayedPromise, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import type { JarIndex } from '@/types/global'
import { useFidelityBondMutations } from '../fidelity-bond/useFidelityBondMutations'
@ -283,6 +287,7 @@ export function useCreateFidelityBondWizard(
setStep('creating')
try {
// TODO: refactor: use the sweep functionality from `useFidelityBondSweep` here
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
@ -291,9 +296,6 @@ export function useCreateFidelityBondWizard(
destination: address,
},
})
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const utxo of frozenUtxos) {
@ -301,12 +303,33 @@ export function useCreateFidelityBondWizard(
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
throwOnError: true,
})
} catch {
// logged via onError
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO.')
}
}
// freeze resulting fidelity bond output
if (JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS) {
try {
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
}
}
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
await walletInfo.refetch()
} catch {
// Best-effort rollback — unfreeze UTXOs that were frozen for this operation
@ -316,8 +339,9 @@ export function useCreateFidelityBondWizard(
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
} catch {
// logged via onError
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
}
}
setFrozenUtxos([])

View file

@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS } from '@/constants/jam'
import { useApiClient } from '@/hooks/useApiClient'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
@ -113,7 +114,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
const txResult = await sweep({
destination: destinationAddress,
tryFreezeAfterBroadcast: true,
tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
})
setTxResult(txResult)

View file

@ -1,6 +1,7 @@
import { useMemo } from 'react'
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useMutation } from '@tanstack/react-query'
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY } from '@/constants/jam'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { delayedPromise, type WalletFileName } from '@/lib/utils'
@ -81,26 +82,6 @@ export function useFidelityBondSweep({
},
})
if (tryFreezeAfterBroadcast) {
try {
await delayedPromise(1_000) // provide some time for the backend to catch up, small delay of 1s seems to be enough
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continueing.')
}
}
try {
await onBroadcastSuccess?.(result)
} catch (error: unknown) {
console.warn('onBroadcastSuccess failed', error)
}
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const u of frozen) {
try {
@ -115,6 +96,27 @@ export function useFidelityBondSweep({
}
}
if (tryFreezeAfterBroadcast) {
try {
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
}
}
try {
await onBroadcastSuccess?.(result)
} catch (error: unknown) {
console.warn('onBroadcastSuccess failed', error)
}
await walletInfoRefetch()
return result
} catch (_ignoredOnPurpose: unknown) {

View file

@ -8,7 +8,7 @@ import {
JM_NG_DEFAULT_TUMBLER_PARAMS,
JM_WALLET_FILE_EXTENSION,
} from './jm'
import { parseAsIntOrDefault } from './meta-env-utils'
import { parseAsBooleanOrDefault, parseAsIntOrDefault } from './meta-env-utils'
export const APP_DISPLAY_VERSION = (() => {
return parseSemanticVersion(packageInfoVersion)
@ -183,6 +183,23 @@ export const JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT = Math.max(
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
)
const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT = true
export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS = parseAsBooleanOrDefault(
import.meta.env.VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT,
)
// provide some time for the backend to catch up after fb is created, small delay of ~1s seems to be enough
export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT_DELAY: Milliseconds = 1000
export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_MIN_DELAY: Milliseconds = 21
export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY = Math.max(
parseAsIntOrDefault(
import.meta.env.VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY,
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT_DELAY,
),
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_MIN_DELAY,
)
/**
* A gaplimit threshold at which a warning is displayed that with the given value a
* decline in performance is to be expected. Importing 500 addresses (per jar!) leads to

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseAsIntOrDefault } from './meta-env-utils'
import { parseAsIntOrDefault, parseAsBooleanOrDefault } from './meta-env-utils'
describe('parseAsIntOrDefault', () => {
it('should parse input as int or default', () => {
@ -23,3 +23,27 @@ describe('parseAsIntOrDefault', () => {
expect(parseAsIntOrDefault(Number.MIN_SAFE_INTEGER - 1, 21)).toBe(21)
})
})
describe('parseAsBooleanOrDefault', () => {
it('should parse input as boolean or default', () => {
expect(parseAsBooleanOrDefault('true', false)).toBe(true)
expect(parseAsBooleanOrDefault('yes', false)).toBe(true)
expect(parseAsBooleanOrDefault('1', false)).toBe(true)
expect(parseAsBooleanOrDefault('false', true)).toBe(false)
expect(parseAsBooleanOrDefault('no', true)).toBe(false)
expect(parseAsBooleanOrDefault('0', true)).toBe(false)
expect(parseAsBooleanOrDefault(true, false)).toBe(true)
expect(parseAsBooleanOrDefault(false, true)).toBe(false)
expect(parseAsBooleanOrDefault(1, false)).toBe(true)
expect(parseAsBooleanOrDefault(0, true)).toBe(false)
expect(parseAsBooleanOrDefault(undefined, true)).toBe(true)
expect(parseAsBooleanOrDefault('', true)).toBe(true)
expect(parseAsBooleanOrDefault(null, true)).toBe(true)
expect(parseAsBooleanOrDefault(String(Number.NaN), true)).toBe(true)
expect(parseAsBooleanOrDefault(Number.MAX_SAFE_INTEGER + 1, true)).toBe(true)
expect(parseAsBooleanOrDefault(Number.MIN_SAFE_INTEGER - 1, true)).toBe(true)
})
})

View file

@ -3,3 +3,28 @@ export const parseAsIntOrDefault = (value: any, defaultValue: number): number =>
const parsed = Number.parseInt(`${value ?? Number.NaN}`, 10)
return Number.isSafeInteger(parsed) ? parsed : defaultValue
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- okay when trying to parse arbitrary value on purpose
export const parseAsBooleanOrDefault = (value: any, defaultValue: boolean): boolean => {
if (typeof value === 'boolean') {
return value
} else if (typeof value === 'string') {
const lowercased = value.trim().toLowerCase()
if (['true', 'yes', '1'].includes(lowercased)) {
return true
}
if (['false', 'no', '0'].includes(lowercased)) {
return false
}
} else {
const asNumber = parseAsIntOrDefault(value, Number.NaN)
if (asNumber === 1) {
return true
}
if (asNumber === 0) {
return false
}
}
return defaultValue
}