From 25246e1690984cd2a509f06eb5fc6884c72c46df Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Fri, 31 Jul 2026 17:23:45 +0200 Subject: [PATCH] chore(fb): freeze utxo after creating fb --- .env | 3 ++ .env.test | 2 + .../useCreateFidelityBondWizard.ts | 40 ++++++++++++++---- src/components/earn/RenewBondDialog.tsx | 3 +- .../fidelity-bond/useFidelityBondSweep.ts | 42 ++++++++++--------- src/constants/jam.ts | 19 ++++++++- src/constants/meta-env-utils.test.ts | 26 +++++++++++- src/constants/meta-env-utils.ts | 25 +++++++++++ 8 files changed, 129 insertions(+), 31 deletions(-) diff --git a/.env b/.env index 3a29ce53..d42a352f 100644 --- a/.env +++ b/.env @@ -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 diff --git a/.env.test b/.env.test index f379760f..214a58a6 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts index fd578798..8170b344 100644 --- a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts +++ b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts @@ -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([]) diff --git a/src/components/earn/RenewBondDialog.tsx b/src/components/earn/RenewBondDialog.tsx index c561be06..6896c20d 100644 --- a/src/components/earn/RenewBondDialog.tsx +++ b/src/components/earn/RenewBondDialog.tsx @@ -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) diff --git a/src/components/earn/fidelity-bond/useFidelityBondSweep.ts b/src/components/earn/fidelity-bond/useFidelityBondSweep.ts index 6ea0e603..340f889f 100644 --- a/src/components/earn/fidelity-bond/useFidelityBondSweep.ts +++ b/src/components/earn/fidelity-bond/useFidelityBondSweep.ts @@ -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) { diff --git a/src/constants/jam.ts b/src/constants/jam.ts index 617282be..11c91024 100644 --- a/src/constants/jam.ts +++ b/src/constants/jam.ts @@ -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 diff --git a/src/constants/meta-env-utils.test.ts b/src/constants/meta-env-utils.test.ts index 0cb7236a..6c2c4d00 100644 --- a/src/constants/meta-env-utils.test.ts +++ b/src/constants/meta-env-utils.test.ts @@ -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) + }) +}) diff --git a/src/constants/meta-env-utils.ts b/src/constants/meta-env-utils.ts index 37324584..cc410856 100644 --- a/src/constants/meta-env-utils.ts +++ b/src/constants/meta-env-utils.ts @@ -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 +}