jam/src/lib/utils.ts
Thebora Kompanioni 64ecd7ad77
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
feat: plan scheduled sweep (#1339)
* refactor: add reason to scheduler error messages

* fix: dismiss schedule confirm dialog on error

* chore(i18n): distinct scheduler starting/stopping messages

* chore(sweep): remove redundant manual destination validation

* chore(sweep): initial sweep options

* chore(ui): add slider component

* chore(sweep): add rounding chance option

* feat(sweep): ability to add/remove destinations

* chore(sweep): use plan tumbler mutation

* refactor(sweep): use tumblerstartMutation

* refactor(sweep): externalize SweepForm

* feat(sweep): ability to set min/max number of collaborators

* chore(sweep): increase time_lambda_seconds in insecure test options

* chore(sweep): fix progress step of quick sweep

* chore(sweep): start refactor schedule entry

* chore(sweep): preserve raw data in ScheduleEntry

* feat(sweep): show destination addresses

* chore(sweep): scroll to top after start/stop

* chore(ui): add Item component

* fix: only abort single taker runs on send page

* chore(sweep): schedule entries with Item component

* chore(sweep): show wait time before next action

* chore(sweep): schedule entries in accordion

* chore(sweep): remove scheduler.progress_current_state_wait_before_next message

* chore(sweep): tab for completed entries

* refactor(sweep): align Schedule object structure

* chore(sweep): display error message on failed actions

* chore(sweep): display completed scheduled sweep

* chore(sweep): move stop button from child component to page

* feat(sweep): ability to review scheduled sweep plan

* chore(sweep): increase schedule polling from 3s to 10s

* chore(sweep): hourglass icon for waiting alerts

* chore(sweep): adapt buttons, labels and alert texts

* fix(sweep): default min confirmations between phases

* chore(sweep): improve time estimate on sweeps

* chore(sweep): show pending tasks in details

* feat(sweep): ability to configure mintxcount

* chore(storybook): add slider, sidebar and item stories

* test(sweep): verify add/remove sweep destination input

* test: load decorators from story meta config

* chore(sweep): add makeSessionIdleTimeoutSeconds to form values

* chore(sweep): reload schedule if not stale or terminated

* chore(sweep): more details in sections

* chore(sweep): reorder entry details

* chore(sweep): show transaction info in destination section

* chore(sweep): estimate duration with fixed runtime info of maker phases

* chore(regtest): setup dummy wallet fidelity bonds

* fix(sweep): maker idle timeout less than runtime duration

* chore(sweep): use taker_utxo_age as min confs in sweep preconditions

* chore(build): add task test:coverage

* chore(sweep): max number of destination addresses

* chore(sweep): maxNumberOfDestinations
2026-07-23 13:06:40 +02:00

348 lines
12 KiB
TypeScript

import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { JM_WALLET_FILE_EXTENSION, type OfferType } from '@/constants/jm'
import type { BlockHeight, Factor, Milliseconds, Minutes, MnemonicPhrase, Seconds } from '@/types/global'
const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…`
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export type Unit = 'BTC' | 'sats'
export const BTC: Unit = 'BTC'
export const SATS: Unit = 'sats'
export type TimeInterval = Milliseconds
// can be any of ['sw0reloffer', 'swreloffer', 'reloffer']
export const isRelativeOffer = (offertype: OfferType) => offertype.includes('reloffer')
// can be any of ['sw0absoffer', 'swabsoffer', 'absoffer']
export const isAbsoluteOffer = (offertype: OfferType) => offertype.includes('absoffer')
export type WalletFileName = `${string}${typeof JM_WALLET_FILE_EXTENSION}`
export function isWalletFileName(val: unknown): val is WalletFileName {
return typeof val === 'string' && val.endsWith(JM_WALLET_FILE_EXTENSION)
}
/**
* Formats a wallet name by removing the .jmdat extension
* @param name The full wallet name with .jmdat extension
* @returns The wallet name without the .jmdat extension
*/
export const sanitizeWalletName = (name: WalletFileName) => name.replace(JM_WALLET_FILE_EXTENSION, '')
export const walletDisplayNameToFileName = (name: string) => (name + JM_WALLET_FILE_EXTENSION) as WalletFileName
export const walletDisplayName = (fileName: WalletFileName) => sanitizeWalletName(fileName)
export const sortWallets = (
wallets: WalletFileName[],
activeWalletFileName: WalletFileName | null = null,
): WalletFileName[] => {
if (activeWalletFileName && wallets.includes(activeWalletFileName)) {
return [activeWalletFileName, ...sortWallets(wallets.filter((a) => a !== activeWalletFileName))]
} else {
return wallets.toSorted((a, b) => a.localeCompare(b))
}
}
export const noop: () => Promise<void> = async () => {}
export const shortenStringMiddle = (value: string, chars = 8, separator = HORIZONTAL_ELLIPSIS) => {
const prefixLength = Math.max(Math.floor(chars / 2), 1)
if (value.length <= prefixLength * 2) {
return `${value}`
}
return `${value.substring(0, prefixLength)}${separator}${value.substring(value.length - prefixLength)}`
}
export function debounce<T extends unknown[], U>(callback: (...args: T) => PromiseLike<U> | U, wait: number) {
let timer: ReturnType<typeof setTimeout> | undefined
return (...args: T): Promise<U> => {
clearTimeout(timer)
return new Promise((resolve) => {
timer = setTimeout(() => resolve(callback(...args)), wait)
})
}
}
export function setIntervalDebounced(
callback: () => PromiseLike<void> | void,
delay: number,
onTimerIdChanged: (timerId: NodeJS.Timeout) => void,
onError: (error: unknown, loop: () => void) => void = (_, loop) => loop(),
) {
;(function loop() {
onTimerIdChanged(
setTimeout(() => {
try {
Promise.resolve(callback())
.then(() => loop())
.catch((error) => onError(error, loop))
} catch (error: unknown) {
onError(error, loop)
}
}, delay),
)
})()
}
export const satsToBtc = (value: string) => Number.parseInt(value, 10) / 100_000_000
const isAsciiDigits = (value: string) => {
if (value.length === 0) return false
for (const char of value) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || codePoint < 48 || codePoint > 57) return false
}
return true
}
export const tryBtcToSat = (value: string): number | undefined => {
const trimmed = value.trim()
if (trimmed === '') return undefined
let sign = 1
let numericPart = trimmed
if (numericPart.startsWith('-')) {
sign = -1
numericPart = numericPart.slice(1)
} else if (numericPart.startsWith('+')) {
numericPart = numericPart.slice(1)
}
// Keep this strict and simple: only plain decimal strings (no scientific notation).
if (numericPart.includes('e') || numericPart.includes('E')) return undefined
const dotIndex = numericPart.indexOf('.')
const hasAtMostOneDot = dotIndex === -1 || !numericPart.includes('.', dotIndex + 1)
if (!hasAtMostOneDot) return undefined
const wholePartRaw = dotIndex === -1 ? numericPart : numericPart.slice(0, dotIndex)
const fractionalPartRaw = dotIndex === -1 ? '' : numericPart.slice(dotIndex + 1)
// Expect: [+-]?\\d+\\.?\\d*
if (!isAsciiDigits(wholePartRaw)) return undefined
if (fractionalPartRaw !== '' && !isAsciiDigits(fractionalPartRaw)) return undefined
const wholePart = Number.parseInt(wholePartRaw, 10)
const fractionalPart =
fractionalPartRaw === '' ? 0 : Number.parseInt((fractionalPartRaw + '00000000').slice(0, 8), 10)
const sats = wholePart * 100_000_000 + fractionalPart
return sats === 0 ? 0 : sign * sats
}
export const SEGWIT_ACTIVATION_BLOCK: BlockHeight = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86
export const AVERAGE_BLOCKS_PER_HOUR: number = Math.round((1 / 10) * 60)
export const AVERAGE_BLOCKS_PER_DAY: number = AVERAGE_BLOCKS_PER_HOUR * 24
export const AVERAGE_BLOCKS_PER_YEAR: number = 365 * AVERAGE_BLOCKS_PER_DAY
export const MEAN_DURATION_BETWEEN_BLOCKS_MINUTES: Minutes = 60 / AVERAGE_BLOCKS_PER_HOUR
export const MEAN_DURATION_BETWEEN_BLOCKS_SECONDS: Seconds = MEAN_DURATION_BETWEEN_BLOCKS_MINUTES * 60
// if applicable, the genesis date can be used as minimum `since` timestamp
export const BITCOIN_GENESIS_DATE = new Date('2009-01-03T18:15:05Z')
export const DUMMY_SEED_PHRASE: MnemonicPhrase =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split(' ')
export const isValidNumber = (val: unknown): val is number => {
return Number.isFinite(val)
}
export const isValidInteger = (val: unknown): val is number => {
return Number.isSafeInteger(val)
}
const BTC_NUMBER_FORMAT_OPTIONS: Intl.NumberFormatOptions = {
minimumFractionDigits: 8,
maximumFractionDigits: 8,
roundingMode: 'trunc',
}
export const formatBtc = (value: number) => {
return value.toLocaleString(undefined, BTC_NUMBER_FORMAT_OPTIONS)
}
export interface BtcParts {
/** The sign character ('-') or undefined for positive */
sign: string | undefined
/** Integer digits without grouping (e.g. "12345") */
integerPart: string
/** Fraction digits (e.g. "67890123") */
fractionalPart: string
/** Full locale-formatted string for display attributes */
formatted: string
}
/**
* Decompose a BTC value into semantic parts using Intl.NumberFormat.formatToParts().
* Handles all locales including it-IT (12.345,67890123) and ar-EG (١٢٬٣٤٥٫٦٧٨٩٠١٢٣).
*/
export const getBtcParts = (value: number): BtcParts => {
const parts = new Intl.NumberFormat(undefined, BTC_NUMBER_FORMAT_OPTIONS).formatToParts(value)
const sign = parts.find((p) => p.type === 'minusSign')?.value
const integerPart =
parts
.filter((p) => p.type === 'integer')
.map((p) => p.value)
.join('') || '0'
const fractionalPart = parts
.filter((p) => p.type === 'fraction')
.map((p) => p.value)
.join('')
const formatted = parts.map((p) => p.value).join('')
return { sign, integerPart, fractionalPart, formatted }
}
export const formatSats = (value: number) => {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
roundingMode: 'trunc',
})
}
export const factorToPercentage = (val: Factor, precision = 6) => {
// Value cannot just be multiplied
// e.g. ✗ 0.000027 * 100 == 0.0026999999999999997
// but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027
return Number((val * 100).toFixed(precision))
}
export const percentageToFactorString = (val: number, precision = 6) => {
return (val / 100).toFixed(precision)
}
export const percentageToFactor = (val: number, precision = 6): Factor => {
return Number(percentageToFactorString(val, precision))
}
export type SemanticVersion = { major: number; minor: number; patch: number; raw?: string }
export const UNKNOWN_VERSION: SemanticVersion = { major: 0, minor: 0, patch: 0, raw: 'unknown' }
const versionRegex = new RegExp(/^v?(\d+)\.(\d+)\.(\d+).*$/)
export const parseSemanticVersion = (raw?: string): SemanticVersion => {
const result = versionRegex.exec(raw || '')
if (!result || result.length < 4) {
return UNKNOWN_VERSION
}
return {
major: Number.parseInt(result[1], 10),
minor: Number.parseInt(result[2], 10),
patch: Number.parseInt(result[3], 10),
raw,
}
}
export const delayedPromise = async (delay: Milliseconds | undefined = 210) => {
await new Promise<void>((resolve) => setTimeout(resolve, delay))
}
/**
* When the clamp function is called, the following steps are taken:
*
* - If any argument is NaN, return NaN.
* - Let max be %Math_max%(value, lower).
* - Let min be %Math_min%(max, upper).
* - Return min.
*/
export const clamp = (value: number, lower: number, upper: number) => {
if (Number.isNaN(value) || Number.isNaN(lower) || Number.isNaN(upper)) return Number.NaN
return Math.min(Math.max(value, lower), upper)
}
// not cryptographically random; returned number is in range [min, max] (both inclusive);
export const pseudoRandomInteger = (min: number, max: number) => {
return Math.round(pseudoRandomFloat(min, max))
}
// not cryptographically random; returned number is in range [min, max] (both inclusive);
export const pseudoRandomFloat = (min: number, max: number) => {
return clamp(Math.random() * (max - min) + min, min, max)
}
export const median = (values: number[]): number | null => {
if (values.length === 0) return null
// eslint-disable-next-line unicorn/no-array-sort -- toSorted() not supported in target browsers
const sorted = [...values].sort((a, b) => a - b)
const middleIndex = Math.floor(sorted.length / 2)
const median = sorted.length % 2 !== 0 ? sorted[middleIndex] : (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
return Number.isFinite(median) ? median : null
}
/**
* Scrolls to the top of the page.
*
* Hint: There is a small delay before the scrolling is initiated,
* in order to mitigate some weird browser behaviour, where it
* did not properly work without a timeout.
*/
export const scrollToTop = (options?: ScrollOptions) => {
setTimeout(() => window.scrollTo({ behavior: 'smooth', ...options, top: 0, left: 0 }), 21)
}
export const time = (() => {
type Unit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'
// These values don't need to be exact.
// They are only used to approximate a human readable
// representation of a time interval--e.g. "in 2 months".
const UNIT_MILLIS: { [key in Unit]: Milliseconds } = {
year: 24 * 60 * 60 * 1_000 * 365,
month: (24 * 60 * 60 * 1_000 * 365) / 12, // ~30.42 days
day: 24 * 60 * 60 * 1_000,
hour: 60 * 60 * 1_000,
minute: 60 * 1_000,
second: 1_000,
}
const humanReadableDuration = ({
from = Date.now(),
to,
locale,
}: {
from?: Milliseconds
to: Milliseconds
locale: string
}) => humanReadableRelativeTimeInterval(timeInterval({ from, to }), locale || 'en')
const timeInterval = ({ from = Date.now(), to }: { from?: Milliseconds; to: Milliseconds }): TimeInterval => {
return to - from
}
const humanReadableRelativeTimeInterval = (timeInterval: TimeInterval, locale: string) => {
const rtf = new Intl.RelativeTimeFormat(locale || 'en', { numeric: 'always', style: 'long' })
const sortedUnits = (Object.keys(UNIT_MILLIS) as Unit[])
.toSorted((lhs, rhs) => UNIT_MILLIS[lhs] - UNIT_MILLIS[rhs])
.toReversed()
for (const unit of sortedUnits) {
const limit = UNIT_MILLIS[unit]
if (Math.abs(timeInterval) > limit) {
return rtf.format(Math.round(timeInterval / limit), unit)
}
}
return rtf.format(Math.round(timeInterval / UNIT_MILLIS['second']), 'second')
}
return {
timeInterval,
humanReadableDuration,
humanReadableRelativeTimeInterval,
}
})()