mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
* 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
348 lines
12 KiB
TypeScript
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,
|
|
}
|
|
})()
|