diff --git a/public/sprite.svg b/public/sprite.svg index 81eb76d88..fce6ba322 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -136,6 +136,10 @@ + + + + diff --git a/src/components/App.jsx b/src/components/App.jsx index 962b098ae..b09f31fc6 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react' import { Route, Routes, Navigate } from 'react-router-dom' import * as rb from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' +import { isFeatureEnabled } from '../constants/features' import Wallets from './Wallets' import CreateWallet from './CreateWallet' import Jam from './Jam' @@ -10,6 +11,7 @@ import Earn from './Earn' import Receive from './Receive' import CurrentWalletMagic from './CurrentWalletMagic' import CurrentWalletAdvanced from './CurrentWalletAdvanced' +import FidelityBond from './FidelityBond' import Settings from './Settings' import Navbar from './Navbar' import Layout from './Layout' @@ -129,6 +131,9 @@ export default function App() { } /> } /> } /> + {isFeatureEnabled('fidelityBonds') && ( + } /> + )} )} diff --git a/src/components/Cheatsheet.tsx b/src/components/Cheatsheet.tsx index b8e3993cf..e3785451a 100644 --- a/src/components/Cheatsheet.tsx +++ b/src/components/Cheatsheet.tsx @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom' import { Trans, useTranslation } from 'react-i18next' import { routes } from '../constants/routes' import Sprite from './Sprite' +import { isFeatureEnabled } from '../constants/features' import styles from './Cheatsheet.module.css' interface CheatsheetProps { @@ -41,6 +42,7 @@ function ListItem({ number, children, ...props }: PropsWithChildren @@ -95,17 +97,22 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
{t('cheatsheet.item_2.description')}
- +
- Optional: Lock funds in a fidelity bond. + Optional: Lock funds + in a fidelity bond.
{t('cheatsheet.item_3.description')} -
{/* the following phrase is intentionally not translated because it will be removed soon */} - Feature not implemented yet. Coming soon! + {!featureFidelityBondsEnabled && ( + <> +
+ Feature not implemented yet. Coming soon! + + )}
diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 9fe7b66f9..e8dd31128 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -84,6 +84,7 @@ export function CopyButton({ successTextTimeout = 1_500, className, showSprites = true, + ...props }: CopyButtonProps) { const [showValueCopiedConfirmation, setShowValueCopiedConfirmation] = useState(false) const [valueCopiedFlag, setValueCopiedFlag] = useState(0) @@ -101,6 +102,7 @@ export function CopyButton({ return ( utxo.locktime) - setFidelityBonds(lockedOutputs) + const fbOutputs = unspentOutputs.filter((utxo) => utxo.locktime) + setFidelityBonds(fbOutputs) } }) .catch((err) => { @@ -76,21 +81,45 @@ export default function CurrentWalletAdvanced() { {!isLoading && walletInfo && ( )} - {!!fidelityBonds?.length && ( -
-
{t('current_wallet_advanced.title_fidelity_bonds')}
- -
- )} + +
+
{t('current_wallet_advanced.title_fidelity_bonds')}
+ {isLoading && ( +
+ + + +
+ )} + + {!isLoading && fidelityBonds && ( + <> + {fidelityBonds.length === 0 ? ( + + <> + + No Fidelity Bond present. + + {featureFidelityBondsEnabled && ( + <> + {' '} + + + Create a Fidelity Bond. + + + + )} + + + ) : ( + + )} + + )} +
<> - { - setShowUTXO(!showUTXO) - }} - className={isLoading ? 'mt-3 mb-3 pe-auto' : 'mb-3'} - > + setShowUTXO(!showUTXO)} className="mb-3"> {showUTXO ? t('current_wallet_advanced.button_hide_utxos') : t('current_wallet_advanced.button_show_utxos')} diff --git a/src/components/DisplayUTXOs.jsx b/src/components/DisplayUTXOs.jsx index 6b693b9fa..3cd9e0dcf 100644 --- a/src/components/DisplayUTXOs.jsx +++ b/src/components/DisplayUTXOs.jsx @@ -7,6 +7,7 @@ import Alert from './Alert' import { useSettings } from '../context/SettingsContext' import { useCurrentWallet } from '../context/WalletContext' import { useServiceInfo } from '../context/ServiceInfoContext' +import { isLocked } from '../hooks/BalanceSummary' import * as Api from '../libs/JmWalletApi' const Utxo = ({ utxo, ...props }) => { @@ -21,7 +22,7 @@ const Utxo = ({ utxo, ...props }) => { const isOperationEnabled = useCallback(() => { const noServiceIsRunning = serviceInfo && !serviceInfo.makerRunning && !serviceInfo.coinjoinInProgress - const isUnfreezeEnabled = !utxo.locktime || new Date(utxo.locktime).getTime() < Date.now() + const isUnfreezeEnabled = !isLocked(utxo) const allowedToExecute = !utxo.frozen || isUnfreezeEnabled return noServiceIsRunning && allowedToExecute diff --git a/src/components/FidelityBond.module.css b/src/components/FidelityBond.module.css new file mode 100644 index 000000000..273c836f3 --- /dev/null +++ b/src/components/FidelityBond.module.css @@ -0,0 +1,4 @@ +/* Firefox */ +.fidelity-bond input[type='number'] { + -moz-appearance: unset !important; +} diff --git a/src/components/FidelityBond.tsx b/src/components/FidelityBond.tsx new file mode 100644 index 000000000..523738893 --- /dev/null +++ b/src/components/FidelityBond.tsx @@ -0,0 +1,262 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import * as rb from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +import { useServiceInfo } from '../context/ServiceInfoContext' +import { useLoadConfigValue } from '../context/ServiceConfigContext' +import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, Account } from '../context/WalletContext' + +import Sprite from './Sprite' +// @ts-ignore +import DisplayUTXOs from './DisplayUTXOs' +// @ts-ignore +import PageTitle from './PageTitle' + +import FidelityBondDetailsSetupForm from './fidelity_bond/FidelityBondDetailsSetupForm' +import * as Api from '../libs/JmWalletApi' +import { isLocked } from '../hooks/BalanceSummary' +import { routes } from '../constants/routes' +import styles from './FidelityBond.module.css' + +type AlertWithMessage = rb.AlertProps & { message: string } + +const collaborativeSweepToFidelityBond = async ( + requestContext: Api.WalletRequestContext, + account: Account, + timelockedDestinationAddress: Api.BitcoinAddress, + counterparties: number +): Promise => { + const res = await Api.postCoinjoin(requestContext, { + mixdepth: parseInt(account.account, 10), + destination: timelockedDestinationAddress, + counterparties, + amount_sats: 0, // sweep + }) + + if (!res.ok) { + await Api.Helper.throwError(res) + } +} + +const directSweepToFidelityBond = async ( + requestContext: Api.WalletRequestContext, + account: Account, + timelockedDestinationAddress: Api.BitcoinAddress +) => { + const res = await Api.postDirectSend(requestContext, { + mixdepth: parseInt(account.account, 10), + destination: timelockedDestinationAddress, + amount_sats: 0, // sweep + }) + + if (!res.ok) { + await Api.Helper.throwError(res) + } +} + +const MakerIsRunningAlert = () => { + const { t } = useTranslation() + return ( + + + + {t('fidelity_bond.text_maker_running')} + + + + + + + ) +} + +const TakerIsRunningAlert = () => { + const { t } = useTranslation() + return ( + + {t('fidelity_bond.text_coinjoin_already_running')} + + ) +} + +export default function FidelityBond() { + const { t } = useTranslation() + const currentWallet = useCurrentWallet() + const currentWalletInfo = useCurrentWalletInfo() + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const serviceInfo = useServiceInfo() + const loadConfigValue = useLoadConfigValue() + + const isCoinjoinInProgress = useMemo(() => serviceInfo && serviceInfo.coinjoinInProgress, [serviceInfo]) + const isMakerRunning = useMemo(() => serviceInfo && serviceInfo.makerRunning, [serviceInfo]) + const isOperationDisabled = useMemo( + () => isMakerRunning || isCoinjoinInProgress, + [isMakerRunning, isCoinjoinInProgress] + ) + + const [alert, setAlert] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isSending, setIsSending] = useState(false) + const [isInitiateTxSuccess, setIsInitiateTxSuccess] = useState(false) + const [initiateTxError, setInitiateTxError] = useState(undefined) + const isInitiateTxError = useMemo(() => initiateTxError !== undefined, [initiateTxError]) + + const utxos = useMemo(() => currentWalletInfo?.data.utxos.utxos, [currentWalletInfo]) + const fidelityBonds = useMemo(() => utxos?.filter((utxo) => utxo.locktime), [utxos]) + const activeFidelityBonds = useMemo(() => fidelityBonds?.filter((it) => isLocked(it)), [fidelityBonds]) + + useEffect(() => { + const abortCtrl = new AbortController() + + setAlert(null) + setIsLoading(true) + + reloadCurrentWalletInfo({ signal: abortCtrl.signal }) + .catch((err) => { + const message = err.message || t('current_wallet.error_loading_failed') + !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message }) + }) + .finally(() => { + !abortCtrl.signal.aborted && setIsLoading(false) + }) + + return () => abortCtrl.abort() + }, [currentWallet, reloadCurrentWalletInfo, t]) + + /** + * Initiate sending funds to a timelocked address. + * Defaults to sweep with a collaborative transaction. + * If the selected utxo is a single expired FB, "direct-send" is used. + * + * The transaction will have no change output. + */ + const onSubmit = async ( + selectedAccount: Account, + selectedLockdate: Api.Lockdate, + timelockedDestinationAddress: Api.BitcoinAddress + ) => { + if (isSending) return + if (!currentWallet) return + if (!utxos) return + + const abortCtrl = new AbortController() + const { name: walletName, token } = currentWallet + const requestContext = { walletName, token, signal: abortCtrl.signal } + + setIsSending(true) + try { + const accountIndex = parseInt(selectedAccount.account, 10) + const spendableUtxos = utxos + .filter((it) => it.mixdepth === accountIndex) + .filter((it) => !it.frozen) + .filter((it) => !isLocked(it)) + + // If the selected utxo is a single expired FB, send via "direct-send". + const useDirectSend = spendableUtxos.length === 1 && !!spendableUtxos[0].locktime + + if (useDirectSend) { + await directSweepToFidelityBond(requestContext, selectedAccount, timelockedDestinationAddress) + } else { + const minimumMakers = await loadConfigValue({ + signal: abortCtrl.signal, + key: { section: 'POLICY', field: 'minimum_makers' }, + }).then((data) => parseInt(data.value, 10)) + + // TODO: how many counterparties to use? is "minimum" for fbs okay? + await collaborativeSweepToFidelityBond( + requestContext, + selectedAccount, + timelockedDestinationAddress, + minimumMakers + ) + } + setIsInitiateTxSuccess(true) + } catch (error) { + setInitiateTxError(error) + throw error + } finally { + setIsSending(false) + } + } + + return ( +
+ + +
+ + + See the documentation about Fidelity Bonds + {' '} + for more information. + +
+ + {alert && {alert.message}} + +
+ {isLoading ? ( +
+
+ ) : ( + <> + {currentWallet && currentWalletInfo && serviceInfo && activeFidelityBonds && ( + <> + {isInitiateTxSuccess || isInitiateTxError ? ( + <> + {isInitiateTxSuccess ? ( + <> + + The transaction to create your Fidelity Bond has been successfully initiated. + + {isCoinjoinInProgress && } + + ) : ( + + Error while initiating your Fidelity Bond transaction. + + )} + + ) : ( + <> + {isOperationDisabled ? ( + + <> + {isMakerRunning && } + {isCoinjoinInProgress && } + + + ) : ( + <> + {activeFidelityBonds.length === 0 ? ( + + ) : ( +
+
{t('current_wallet_advanced.title_fidelity_bonds')}
+ +
+ )} + + )} + + )} + + )} + + )} +
+
+ ) +} diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx index 81f8b1469..c1b4cb1e7 100644 --- a/src/components/Jam.jsx +++ b/src/components/Jam.jsx @@ -13,7 +13,7 @@ import ToggleSwitch from './ToggleSwitch' import Sprite from './Sprite' import Balance from './Balance' import ScheduleProgress from './ScheduleProgress' -import { useBalanceSummary } from '../hooks/BalanceSummary' +import { isLocked, useBalanceSummary } from '../hooks/BalanceSummary' // When running the scheduler with internal destination addresses, the funds // will end up on those 3 mixdepths (one UTXO each). @@ -58,7 +58,7 @@ const useSchedulerPreconditionSummary = (walletInfoOrNull, startAccountIndex) => return utxos .filter((it) => it.mixdepth === startAccountIndex) .filter((it) => !it.frozen) - .filter((it) => !it.locktime) + .filter((it) => !isLocked(it)) }, [walletInfoOrNull, startAccountIndex]) const [summary, setSummary] = useState(DEFAULT_PRECONDITION_SUMMARY) diff --git a/src/components/Jars.jsx b/src/components/Jars.jsx index 593d5f4e9..7f44502a7 100644 --- a/src/components/Jars.jsx +++ b/src/components/Jars.jsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useSettings } from '../context/SettingsContext' import styles from './Jars.module.css' import Sprite from './Sprite' @@ -32,7 +32,10 @@ const Jar = ({ accountIndex, balance, fill, onClick }) => { } const Jars = ({ accountBalances, totalBalance, onClick }) => { - const sortedAccountBalances = (accountBalances || []).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex) + const sortedAccountBalances = useMemo(() => { + if (!accountBalances) return [] + return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex) + }, [accountBalances]) // Classifies the account balance into one of four groups: // - More than half of the total balance diff --git a/src/components/Send.jsx b/src/components/Send.jsx index 87def18b1..a748c47d9 100644 --- a/src/components/Send.jsx +++ b/src/components/Send.jsx @@ -12,7 +12,7 @@ import { useLoadConfigValue } from '../context/ServiceConfigContext' import { useSettings } from '../context/SettingsContext' import { useBalanceSummary } from '../hooks/BalanceSummary' import * as Api from '../libs/JmWalletApi' -import { btcToSats, SATS, formatBtc, formatSats } from '../utils' +import { SATS, formatBtc, formatSats } from '../utils' import { routes } from '../constants/routes' import styles from './Send.module.css' @@ -237,16 +237,10 @@ export default function Send() { const [formIsValid, setFormIsValid] = useState(false) const balanceSummary = useBalanceSummary(walletInfo) - const accountBalanceOrNull = useMemo(() => { - const eligibleAccountBalances = - balanceSummary && balanceSummary.accountBalances.filter((it) => it.accountIndex === account) - - if (!eligibleAccountBalances || eligibleAccountBalances.length !== 1) { - return null - } - - return eligibleAccountBalances[0] - }, [balanceSummary, account]) + const accountBalanceOrNull = useMemo( + () => (balanceSummary && balanceSummary.accountBalances[account]) || null, + [balanceSummary, account] + ) useEffect(() => { if ( @@ -622,16 +616,16 @@ export default function Send() { disabled={isOperationDisabled} > {balanceSummary && - balanceSummary.accountBalances + Object.values(balanceSummary.accountBalances) .sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex) - .map(({ accountIndex, totalBalance }) => ( + .map(({ accountIndex, totalBalance, calculatedTotalBalanceInSats }) => ( ))} diff --git a/src/components/fidelity_bond/AccountCheckbox.tsx b/src/components/fidelity_bond/AccountCheckbox.tsx new file mode 100644 index 000000000..7e44ff62c --- /dev/null +++ b/src/components/fidelity_bond/AccountCheckbox.tsx @@ -0,0 +1,30 @@ +import React, { PropsWithChildren, useCallback } from 'react' +import { Account } from '../../context/WalletContext' +import CheckboxCard, { CheckboxCardProps } from './CheckboxCard' + +interface AccountCheckboxProps extends CheckboxCardProps { + account: Account + onAccountSelected: (account: Account, checked: boolean) => void +} + +const AccountCheckbox = ({ + account, + onAccountSelected, + children, + ...props +}: PropsWithChildren) => { + const onChange = useCallback( + (e: React.ChangeEvent) => { + return onAccountSelected(account, e.target.checked) + }, + [account, onAccountSelected] + ) + + return ( + + {children} + + ) +} + +export default AccountCheckbox diff --git a/src/components/fidelity_bond/AccountSelector.tsx b/src/components/fidelity_bond/AccountSelector.tsx new file mode 100644 index 000000000..7f882f7f7 --- /dev/null +++ b/src/components/fidelity_bond/AccountSelector.tsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState, useMemo } from 'react' +import * as rb from 'react-bootstrap' + +import { Account } from '../../context/WalletContext' +// @ts-ignore +import { useSettings } from '../../context/SettingsContext' + +// @ts-ignore +import Balance from '../Balance' +import PercentageBar from './PercentageBar' +import AccountCheckbox from './AccountCheckbox' +import { WalletBalanceSummary } from '../../hooks/BalanceSummary' + +type SelectableAccount = Account & { disabled?: boolean } + +interface AccountSelectorProps { + balanceSummary: WalletBalanceSummary + accounts: SelectableAccount[] + onChange: (selectedAccount: Account | null) => void +} +const AccountSelector = ({ balanceSummary, accounts, onChange }: AccountSelectorProps) => { + const settings = useSettings() + const [selected, setSelected] = useState(null) + + const selectableAccounts = useMemo(() => { + return accounts.filter((it) => !it.disabled) + }, [accounts]) + + useEffect(() => { + setSelected(null) + }, [selectableAccounts]) + + useEffect(() => { + onChange(selected) + }, [selected, onChange]) + + return ( + + {accounts.map((it) => { + const accountIndex = parseInt(it.account, 10) + const availableAccountBalance = balanceSummary.accountBalances[accountIndex].calculatedAvailableBalanceInSats + + const percentageOfTotal = + balanceSummary.calculatedTotalBalanceInSats > 0 + ? (100 * availableAccountBalance) / balanceSummary.calculatedTotalBalanceInSats + : undefined + return ( + + { + setSelected((current) => { + if (current === account) { + return null + } + return account + }) + }} + > + {' '} + <> + {percentageOfTotal !== undefined && ( + + )} + +
Jar #{it.account}
+
+ +
+ {percentageOfTotal !== undefined && ( +
+ {`${percentageOfTotal.toFixed(2)}%`} +
+ )} +
+ +
+
+ ) + })} +
+ ) +} + +export default AccountSelector diff --git a/src/components/fidelity_bond/CheckboxCard.module.css b/src/components/fidelity_bond/CheckboxCard.module.css new file mode 100644 index 000000000..cdc709d8f --- /dev/null +++ b/src/components/fidelity_bond/CheckboxCard.module.css @@ -0,0 +1,26 @@ +.checkbox-card { + width: 100%; + position: relative; + cursor: pointer; +} + +.checkbox-card.disabled { + cursor: unset; +} + +.sprite-container { + display: flex; + justify-content: center; + align-items: center; + margin: 1rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: rgba(222, 222, 222, 1); + color: rgba(66, 66, 66, 1); +} + +.checked .sprite-container { + background-color: rgba(39, 174, 96, 1); + color: var(--bs-white); +} diff --git a/src/components/fidelity_bond/CheckboxCard.tsx b/src/components/fidelity_bond/CheckboxCard.tsx new file mode 100644 index 000000000..89d5c6688 --- /dev/null +++ b/src/components/fidelity_bond/CheckboxCard.tsx @@ -0,0 +1,41 @@ +import React, { useRef, PropsWithChildren } from 'react' +import * as rb from 'react-bootstrap' + +import Sprite from '../Sprite' +import styles from './CheckboxCard.module.css' + +export interface CheckboxCardProps extends rb.FormCheckProps {} + +const CheckboxCard = ({ children, checked, disabled, ...props }: PropsWithChildren) => { + const ref = useRef(null) + return ( + <> + { + e.preventDefault() + e.stopPropagation() + + !disabled && ref.current?.click() + }} + onKeyDown={(e: React.KeyboardEvent) => { + e.key === ' ' && ref.current?.click() + }} + > + +
+
+ {checked && } + {!checked && disabled && } +
+ <>{children} +
+
+ + ) +} + +export default CheckboxCard diff --git a/src/components/fidelity_bond/FidelityBondDetailsSetupForm.tsx b/src/components/fidelity_bond/FidelityBondDetailsSetupForm.tsx new file mode 100644 index 000000000..d32310e55 --- /dev/null +++ b/src/components/fidelity_bond/FidelityBondDetailsSetupForm.tsx @@ -0,0 +1,319 @@ +import React, { useEffect, useMemo, useState } from 'react' +import * as rb from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +import { WalletInfo, CurrentWallet, Account } from '../../context/WalletContext' +// @ts-ignore +import { useSettings } from '../../context/SettingsContext' + +// @ts-ignore +import Balance from '../../components/Balance' +// @ts-ignore +import ToggleSwitch from '../../components/ToggleSwitch' +import { useBalanceSummary, WalletBalanceSummary } from '../../hooks/BalanceSummary' + +import AccountSelector from './AccountSelector' +import LockdateForm from './LockdateForm' +import { toYearsRange, DEFAULT_MAX_TIMELOCK_YEARS } from './fb_utils' + +import * as Api from '../../libs/JmWalletApi' +import { isDebugFeatureEnabled } from '../../constants/debugFeatures' + +type AlertWithMessage = rb.AlertProps & { message: string } + +interface SelectAccountStepProps { + balanceSummary: WalletBalanceSummary + accounts: Account[] + onChange: (account: Account | null) => void +} + +const SelectAccountStep = ({ balanceSummary, accounts, onChange }: SelectAccountStepProps) => { + const [selectedAccount, setSelectedAccount] = useState(null) + + useEffect(() => { + onChange(selectedAccount) + }, [selectedAccount, onChange]) + + return ( + <> +

Select Account

+ + + ) +} + +interface SelectLockdateStepProps { + onChange: (lockdate: Api.Lockdate | null) => void +} +const SelectLockdateStep = ({ onChange }: SelectLockdateStepProps) => { + const yearsRange = useMemo(() => { + if (isDebugFeatureEnabled('allowCreatingExpiredFidelityBond')) { + return toYearsRange(-1, DEFAULT_MAX_TIMELOCK_YEARS) + } + return toYearsRange(0, DEFAULT_MAX_TIMELOCK_YEARS) + }, []) + + return ( + <> +

Select duration

+ + + + + + + + + ) +} + +interface ConfirmationStepProps { + balanceSummary: WalletBalanceSummary + account: Account + lockdate: Api.Lockdate + destinationAddress: Api.BitcoinAddress + confirmed: boolean + onChange: (confirmed: boolean) => void +} + +const ConfirmationStep = ({ + balanceSummary, + account, + lockdate, + destinationAddress, + confirmed, + onChange, +}: ConfirmationStepProps) => { + const { t } = useTranslation() + const settings = useSettings() + + const accountAvailableBalanceInSats = useMemo( + () => balanceSummary.accountBalances[parseInt(account.account, 10)].calculatedAvailableBalanceInSats, + [balanceSummary, account] + ) + + const relativeSizeToTotalBalance = useMemo(() => { + if (balanceSummary.calculatedTotalBalanceInSats <= 0) return 0 + return accountAvailableBalanceInSats / balanceSummary.calculatedTotalBalanceInSats + }, [accountAvailableBalanceInSats, balanceSummary]) + + return ( + <> +

Confirmation

+

Please review the summary of your inputs carefully.

+ + + + + + + Account + #{account.account} + + + + Fidelity Bond Size +
Excluding transaction fees
+ + + + + + + Relative size to your total balance + {(relativeSizeToTotalBalance * 100).toFixed(2)} % + + + Locked until + {lockdate} + + + + Timelocked Destination Address +
{destinationAddress}
+ + + +
+
+
+ +
+ {/* TODO: reset the toggle value (once that is implemented) when a user leaves the page, e.g. "Back" button */} + onChange(isToggled)} + /> +
+ + ) +} + +interface FidelityBondDetailsSetupFormProps { + currentWallet: CurrentWallet + walletInfo: WalletInfo + onSubmit: (account: Account, lockdate: Api.Lockdate, timelockedAddress: Api.BitcoinAddress) => Promise +} + +const FidelityBondDetailsSetupForm = ({ currentWallet, walletInfo, onSubmit }: FidelityBondDetailsSetupFormProps) => { + const { t } = useTranslation() + const balanceSummary = useBalanceSummary(walletInfo) + const accounts = useMemo(() => walletInfo.data.display.walletinfo.accounts, [walletInfo]) + + const [alert, setAlert] = useState(null) + const [step, setStep] = useState(0) + const [selectedAccount, setSelectedAccount] = useState(null) + const [selectedLockdate, setSelectedLockdate] = useState(null) + const [timelockedDestinationAddress, setTimelockedDestinationAddress] = useState(null) + const [userConfirmed, setUserConfirmed] = useState(false) + + useEffect(() => { + if (selectedAccount === null) { + setStep(0) + } + }, [selectedAccount]) + + useEffect(() => { + // TODO: toggle button has no way to reflect this change currently + setUserConfirmed(false) + }, [step, selectedAccount, selectedLockdate]) + + useEffect(() => { + if (!selectedLockdate) return + + setAlert(null) + setTimelockedDestinationAddress(null) + + const abortCtrl = new AbortController() + Api.getAddressTimelockNew({ + walletName: currentWallet.name, + token: currentWallet.token, + signal: abortCtrl.signal, + lockdate: selectedLockdate, + }) + .then((res) => + res.ok ? res.json() : Api.Helper.throwError(res, t('fidelity_bond.error_loading_timelock_address_failed')) + ) + .then((data) => setTimelockedDestinationAddress(data.address)) + .catch((err) => { + const message = err.message || t('fidelity_bond.error_loading_timelock_address_failed') + setAlert({ variant: 'danger', message }) + }) + + return () => abortCtrl.abort() + }, [currentWallet, selectedLockdate, t]) + + return ( +
+
+

Step {step + 1}

+ {step > 0 && ( + setStep(step - 1)} + > + {t('global.back')} + + )} +
+ + {balanceSummary && ( +
+ + + setStep(1)} + > + {t('global.next')} + +
+ )} + + {balanceSummary && selectedAccount && ( +
+ + + setStep(2)} + > + {t('global.next')} + + + setStep(0)}> + {t('global.back')} + +
+ )} + + {balanceSummary && selectedAccount && selectedLockdate && ( +
+ {alert ? ( + {alert.message} + ) : ( + <> + {timelockedDestinationAddress === null ? ( +
+
+ ) : ( + + )} + + )} + + + userConfirmed && + timelockedDestinationAddress && + onSubmit(selectedAccount, selectedLockdate, timelockedDestinationAddress) + } + > + {t('fidelity_bond.create_form.button_create')} + + + setStep(1)}> + {t('global.back')} + +
+ )} +
+ ) +} + +export default FidelityBondDetailsSetupForm diff --git a/src/components/fidelity_bond/LockdateForm.test.tsx b/src/components/fidelity_bond/LockdateForm.test.tsx new file mode 100644 index 000000000..8d320cd28 --- /dev/null +++ b/src/components/fidelity_bond/LockdateForm.test.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import { act } from 'react-dom/test-utils' +import user from '@testing-library/user-event' +import { render, screen } from '../../testUtils' +import * as Api from '../../libs/JmWalletApi' +import * as fb from './fb_utils' + +import LockdateForm, { _minMonth, _selectableMonths, _selectableYears } from './LockdateForm' + +describe('', () => { + const now = new Date(Date.UTC(2009, 0, 3)) + const setup = (onChange: (lockdate: Api.Lockdate | null) => void) => { + render() + } + + it('should render without errors', () => { + act(() => setup(() => {})) + + expect(screen.getByTestId('select-lockdate-year')).toBeVisible() + expect(screen.getByTestId('select-lockdate-month')).toBeVisible() + }) + + it('should initialize 3 month ahead by default', () => { + const onChange = jest.fn() + + act(() => setup(onChange)) + + expect(onChange).toHaveBeenCalledWith(fb.lockdate.initial(now)) + }) + + it('should be able to select 10 years by default', () => { + const expectedSelectableYears = 10 + const currentYear = now.getUTCFullYear() + + let selectedLockdate: Api.Lockdate | null = null + const onChange = (lockdate: Api.Lockdate | null) => (selectedLockdate = lockdate) + + act(() => setup(onChange)) + + const yearDropdown = screen.getByTestId('select-lockdate-year') + + for (let i = 0; i < expectedSelectableYears; i++) { + const yearValue = currentYear + i + expect(() => user.selectOptions(yearDropdown, [`${yearValue}`])).not.toThrow() + expect(new Date(fb.lockdate.toTimestamp(selectedLockdate!)).getUTCFullYear()).toBe(yearValue) + } + + const unavailableYearPast = `${currentYear - 1}` + expect(() => user.selectOptions(yearDropdown, [unavailableYearPast])).toThrow() + + const unavailableYearFuture = `${currentYear + expectedSelectableYears + 1}` + expect(() => user.selectOptions(yearDropdown, [unavailableYearFuture])).toThrow() + }) + + it('should not be able to select current month', () => { + const currentYear = now.getUTCFullYear() + const currentMonth = now.getUTCMonth() + 1 // utc month ranges from [0, 11] + + let selectedLockdate: Api.Lockdate | null = null + const onChange = (lockdate: Api.Lockdate | null) => (selectedLockdate = lockdate) + + act(() => setup(onChange)) + + const initialLockdate = selectedLockdate + expect(initialLockdate).not.toBeNull() + + const monthDropdown = screen.getByTestId('select-lockdate-month') + + act(() => user.selectOptions(monthDropdown, [`${currentMonth}`])) + expect(selectedLockdate).toBe(initialLockdate) // select lockdate has not changed + + const expectedLockdate = fb.lockdate.fromTimestamp(Date.UTC(currentYear, currentMonth + 3 - 1)) + act(() => user.selectOptions(monthDropdown, [`${currentMonth + 3}`])) + expect(selectedLockdate).toBe(expectedLockdate) + + act(() => user.selectOptions(monthDropdown, [`${currentMonth}`])) + expect(selectedLockdate).toBe(expectedLockdate) // select lockdate has not changed + }) + + describe('_minMonth', () => { + const yearsRange = fb.toYearsRange(0, 10) + const yearsRangeMinusOne = fb.toYearsRange(-1, 10) + const yearsRangePlusOne = fb.toYearsRange(1, 10) + + const january2009 = new Date(Date.UTC(2009, 0)) + const july2009 = new Date(Date.UTC(2009, 6)) + const december2009 = new Date(Date.UTC(2009, 11)) + + it('should calculate min month correctly', () => { + expect(_minMonth(2009, yearsRange, january2009)).toBe(2) + expect(_minMonth(2009, yearsRange, july2009)).toBe(8) + expect(_minMonth(2009, yearsRange, december2009)).toBe(13) + + expect(_minMonth(2009, yearsRangeMinusOne, january2009)).toBe(1) + expect(_minMonth(2009, yearsRangeMinusOne, july2009)).toBe(1) + expect(_minMonth(2009, yearsRangeMinusOne, december2009)).toBe(1) + + expect(_minMonth(2009, yearsRangePlusOne, january2009)).toBe(13) + expect(_minMonth(2009, yearsRangePlusOne, july2009)).toBe(13) + expect(_minMonth(2009, yearsRangePlusOne, december2009)).toBe(13) + }) + }) + + describe('_selectableMonth', () => { + const yearsRange = fb.toYearsRange(0, 2) + + const january2009 = new Date(Date.UTC(2009, 0)) + const july2009 = new Date(Date.UTC(2009, 6)) + const december2009 = new Date(Date.UTC(2009, 11)) + + it('should display month name', () => { + const selectableMonths = _selectableMonths(2009, yearsRange, january2009) + expect(selectableMonths).toHaveLength(12) + + expect(selectableMonths[0].displayValue).toBe('January') + expect(selectableMonths[11].displayValue).toBe('December') + }) + + it('should set disabled flag correctly for january', () => { + const selectableMonths2008 = _selectableMonths(2008, yearsRange, january2009) + expect(selectableMonths2008).toHaveLength(12) + expect(selectableMonths2008[0].disabled).toBe(true) + expect(selectableMonths2008[11].disabled).toBe(true) + + const selectableMonths2009 = _selectableMonths(2009, yearsRange, january2009) + expect(selectableMonths2009).toHaveLength(12) + expect(selectableMonths2009[0].disabled).toBe(true) + expect(selectableMonths2009[1].disabled).toBe(false) + expect(selectableMonths2009[11].disabled).toBe(false) + + const selectableMonths2010 = _selectableMonths(2010, yearsRange, january2009) + expect(selectableMonths2010).toHaveLength(12) + expect(selectableMonths2010[0].disabled).toBe(false) + expect(selectableMonths2010[11].disabled).toBe(false) + }) + + it('should set disabled flag correctly for july', () => { + const selectableMonths2008 = _selectableMonths(2008, yearsRange, july2009) + expect(selectableMonths2008).toHaveLength(12) + expect(selectableMonths2008[0].disabled).toBe(true) + expect(selectableMonths2008[11].disabled).toBe(true) + + const selectableMonths2009 = _selectableMonths(2009, yearsRange, july2009) + expect(selectableMonths2009).toHaveLength(12) + expect(selectableMonths2009[0].disabled).toBe(true) + expect(selectableMonths2009[1].disabled).toBe(true) + expect(selectableMonths2009[6].disabled).toBe(true) + expect(selectableMonths2009[7].disabled).toBe(false) + expect(selectableMonths2009[11].disabled).toBe(false) + + const selectableMonths2010 = _selectableMonths(2010, yearsRange, july2009) + expect(selectableMonths2010).toHaveLength(12) + expect(selectableMonths2010[0].disabled).toBe(false) + expect(selectableMonths2010[11].disabled).toBe(false) + }) + + it('should set disabled flag correctly for december', () => { + const selectableMonths2008 = _selectableMonths(2008, yearsRange, december2009) + expect(selectableMonths2008).toHaveLength(12) + expect(selectableMonths2008[0].disabled).toBe(true) + expect(selectableMonths2008[11].disabled).toBe(true) + + const selectableMonths2009 = _selectableMonths(2009, yearsRange, december2009) + expect(selectableMonths2009).toHaveLength(12) + expect(selectableMonths2009[0].disabled).toBe(true) + expect(selectableMonths2009[11].disabled).toBe(true) + + const selectableMonths2010 = _selectableMonths(2010, yearsRange, december2009) + expect(selectableMonths2010).toHaveLength(12) + expect(selectableMonths2010[0].disabled).toBe(false) + expect(selectableMonths2010[11].disabled).toBe(false) + }) + }) + + describe('_selectableYears', () => { + const yearsRange = fb.toYearsRange(0, 2) + const yearsRangeMinusOne = fb.toYearsRange(-1, 2) + const yearsRangePlusOne = fb.toYearsRange(1, 2) + + const january2009 = new Date(Date.UTC(2009, 0)) + const july2009 = new Date(Date.UTC(2009, 6)) + const december2009 = new Date(Date.UTC(2009, 11)) + + it('should calculate selectable years correctly', () => { + expect(_selectableYears(yearsRange, january2009)).toEqual([2009, 2010]) + expect(_selectableYears(yearsRange, july2009)).toEqual([2009, 2010]) + expect(_selectableYears(yearsRange, december2009)).toEqual([2010, 2011]) + + expect(_selectableYears(yearsRangeMinusOne, january2009)).toEqual([2008, 2009, 2010]) + expect(_selectableYears(yearsRangeMinusOne, july2009)).toEqual([2008, 2009, 2010]) + expect(_selectableYears(yearsRangeMinusOne, december2009)).toEqual([2009, 2010, 2011]) + + expect(_selectableYears(yearsRangePlusOne, january2009)).toEqual([2010]) + expect(_selectableYears(yearsRangePlusOne, july2009)).toEqual([2010]) + expect(_selectableYears(yearsRangePlusOne, december2009)).toEqual([2011]) + }) + }) +}) diff --git a/src/components/fidelity_bond/LockdateForm.tsx b/src/components/fidelity_bond/LockdateForm.tsx new file mode 100644 index 000000000..b8e37f58d --- /dev/null +++ b/src/components/fidelity_bond/LockdateForm.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useMemo, useState } from 'react' +import * as rb from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +import * as Api from '../../libs/JmWalletApi' +import * as fb from './fb_utils' + +const monthFormatter = (locales: string) => new Intl.DateTimeFormat(locales, { month: 'long' }) + +const DEFAULT_MONTH_FORMATTER = monthFormatter('en-US') + +const getOrCreateMonthFormatter = (locale: string) => + DEFAULT_MONTH_FORMATTER.resolvedOptions().locale === locale ? DEFAULT_MONTH_FORMATTER : monthFormatter(locale) + +const displayMonth = (date: Date, locale: string = 'en-US') => { + return getOrCreateMonthFormatter(locale).format(date) +} + +type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 + +// exported for tests only +export const _minMonth = (year: number, yearsRange: fb.YearsRange, now = new Date()): Month | 13 => { + if (year > now.getUTCFullYear() + yearsRange.min) return 1 as Month + if (year < now.getUTCFullYear() + yearsRange.min) return 13 + return (now.getUTCMonth() + 1 + 1) as Month | 13 +} + +type SelectableMonth = { + value: Month + displayValue: string + disabled: boolean +} + +// exported for tests only +export const _selectableMonths = ( + year: number, + yearsRange: fb.YearsRange, + now = new Date(), + locale?: string +): SelectableMonth[] => { + const minMonth = _minMonth(year, yearsRange, now) + return Array(12) + .fill('') + .map((_, index) => (index + 1) as Month) + .map((month) => ({ + value: month, + displayValue: displayMonth(new Date(Date.UTC(year, month - 1, 1)), locale), + disabled: month < minMonth, + })) +} + +// exported for tests only +export const _selectableYears = (yearsRange: fb.YearsRange, now = new Date()): number[] => { + const years = yearsRange.max - yearsRange.min + const extra = yearsRange.min + (now.getUTCMonth() === 11 ? 1 : 0) + return Array(years) + .fill('') + .map((_, index) => index + now.getUTCFullYear() + extra) +} + +interface LockdateFormProps { + onChange: (lockdate: Api.Lockdate | null) => void + yearsRange?: fb.YearsRange + now?: Date +} + +const LockdateForm = ({ onChange, now, yearsRange }: LockdateFormProps) => { + const { i18n } = useTranslation() + const _now = useMemo(() => now || new Date(), [now]) + const _yearsRange = useMemo(() => yearsRange || fb.DEFAULT_TIMELOCK_YEARS_RANGE, [yearsRange]) + + const initialValue = useMemo(() => fb.lockdate.initial(_now, _yearsRange), [_now, _yearsRange]) + const initialDate = useMemo(() => new Date(fb.lockdate.toTimestamp(initialValue)), [initialValue]) + const initialYear = useMemo(() => initialDate.getUTCFullYear(), [initialDate]) + const initialMonth = useMemo(() => (initialDate.getUTCMonth() + 1) as Month, [initialDate]) + + const [lockdateYear, setLockdateYear] = useState(initialYear) + const [lockdateMonth, setLockdateMonth] = useState(initialMonth) + + const selectableYears = useMemo(() => _selectableYears(_yearsRange, _now), [_yearsRange, _now]) + const selectableMonths = useMemo( + () => _selectableMonths(lockdateYear, _yearsRange, _now, i18n.resolvedLanguage || i18n.language), + [lockdateYear, _yearsRange, _now, i18n] + ) + + const isLockdateYearValid = useMemo(() => selectableYears.includes(lockdateYear), [lockdateYear, selectableYears]) + const isLockdateMonthValid = useMemo( + () => + selectableMonths + .filter((it) => !it.disabled) + .map((it) => it.value) + .includes(lockdateMonth), + [lockdateMonth, selectableMonths] + ) + + useEffect(() => { + if (isLockdateYearValid && isLockdateMonthValid) { + const timestamp = Date.UTC(lockdateYear, lockdateMonth - 1, 1) + onChange(fb.lockdate.fromTimestamp(timestamp)) + } else { + onChange(null) + } + }, [lockdateYear, lockdateMonth, isLockdateYearValid, isLockdateMonthValid, onChange]) + + return ( + + + + + + Year + + setLockdateYear(parseInt(e.target.value, 10))} + required + isInvalid={!isLockdateYearValid} + data-testid="select-lockdate-year" + > + {selectableYears.map((year) => ( + + ))} + + + + + Please provide a valid value. + + + + + + + + Month + + setLockdateMonth(parseInt(e.target.value, 10) as Month)} + required + isInvalid={!isLockdateMonthValid} + data-testid="select-lockdate-month" + > + {selectableMonths.map((it) => ( + + ))} + + + + Please provide a valid value. + + + + + + + ) +} + +export default LockdateForm diff --git a/src/components/fidelity_bond/PercentageBar.module.css b/src/components/fidelity_bond/PercentageBar.module.css new file mode 100644 index 000000000..9da599626 --- /dev/null +++ b/src/components/fidelity_bond/PercentageBar.module.css @@ -0,0 +1,17 @@ +.percentage-bar { + position: absolute; + height: 100%; + background-color: rgba(22, 22, 22, 0.1); +} + +:root[data-theme='dark'] .percentage-bar { + background-color: rgba(222, 222, 222, 0.1); +} + +.percentage-bar.highlight { + background-color: rgba(39, 174, 96, 0.2); +} + +:root[data-theme='dark'] .percentage-bar.highlight { + background-color: rgba(39, 174, 96, 0.1); +} diff --git a/src/components/fidelity_bond/PercentageBar.tsx b/src/components/fidelity_bond/PercentageBar.tsx new file mode 100644 index 000000000..28e9f2051 --- /dev/null +++ b/src/components/fidelity_bond/PercentageBar.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import styles from './PercentageBar.module.css' + +interface PercentageBarProps { + percentage: number + highlight?: boolean +} + +const PercentageBar = ({ percentage, highlight = false }: PercentageBarProps) => { + return ( +
+ ) +} + +export default PercentageBar diff --git a/src/components/fidelity_bond/fb_utils.test.ts b/src/components/fidelity_bond/fb_utils.test.ts new file mode 100644 index 000000000..6ece7990e --- /dev/null +++ b/src/components/fidelity_bond/fb_utils.test.ts @@ -0,0 +1,52 @@ +import * as fb from './fb_utils' + +import { Lockdate } from '../../libs/JmWalletApi' + +describe('fb_utils', () => { + describe('lockdate', () => { + it('should convert timestamp to lockdate', () => { + expect(fb.lockdate.fromTimestamp(-1)).toBe('1969-12') + expect(fb.lockdate.fromTimestamp(0)).toBe('1970-01') + expect(fb.lockdate.fromTimestamp(Date.UTC(2008, 9, 31))).toBe('2008-10') + expect(fb.lockdate.fromTimestamp(Date.UTC(2009, 0, 3, 1, 42, 13, 37))).toBe('2009-01') + expect(fb.lockdate.fromTimestamp(Date.UTC(2022, 1, 18))).toBe('2022-02') + expect(fb.lockdate.fromTimestamp(Date.UTC(2999, 11))).toBe('2999-12') + expect(fb.lockdate.fromTimestamp(Date.UTC(10_000, 10))).toBe('10000-11') + + expect(() => fb.lockdate.fromTimestamp(NaN)).toThrowError('Unsupported input: NaN') + }) + + it('should convert lockdate to timestamp', () => { + expect(fb.lockdate.toTimestamp('2008-10')).toBe(Date.UTC(2008, 9, 1)) + expect(fb.lockdate.toTimestamp('2009-01')).toBe(Date.UTC(2009, 0, 1)) + expect(fb.lockdate.toTimestamp('2999-12')).toBe(Date.UTC(2999, 11)) + + expect(() => fb.lockdate.toTimestamp('-1' as Lockdate)).toThrowError('Unsupported format') + expect(() => fb.lockdate.toTimestamp('2008-1' as Lockdate)).toThrowError('Unsupported format') + expect(() => fb.lockdate.toTimestamp('10000-01' as Lockdate)).toThrowError('Unsupported format') + expect(() => fb.lockdate.toTimestamp('' as Lockdate)).toThrowError('Unsupported format') + expect(() => fb.lockdate.toTimestamp('any' as Lockdate)).toThrowError('Unsupported format') + }) + + it('should create an initial lockdate', () => { + const rangeZero = fb.toYearsRange(0, 10) + const rangeMinusOneYear = fb.toYearsRange(-1, 10) + const rangePlusOneYear = fb.toYearsRange(1, 10) + + // verify the default "months ahead" for the initial lockdate to prevent unintentional changes + expect(fb.__INITIAL_LOCKDATE_MONTH_AHEAD).toBe(3) + + expect(fb.lockdate.initial(new Date(Date.UTC(2009, 0, 3)), rangeZero)).toBe('2009-05') + expect(fb.lockdate.initial(new Date(Date.UTC(2009, 0, 3)), rangeMinusOneYear)).toBe('2009-05') + expect(fb.lockdate.initial(new Date(Date.UTC(2009, 0, 3)), rangePlusOneYear)).toBe('2010-01') + + expect(fb.lockdate.initial(new Date(Date.UTC(2008, 9, 31)), rangeZero)).toBe('2009-02') + expect(fb.lockdate.initial(new Date(Date.UTC(2008, 9, 31)), rangeMinusOneYear)).toBe('2009-02') + expect(fb.lockdate.initial(new Date(Date.UTC(2008, 9, 31)), rangePlusOneYear)).toBe('2009-10') + + expect(fb.lockdate.initial(new Date(Date.UTC(2009, 11, 3)), rangeZero)).toBe('2010-04') + expect(fb.lockdate.initial(new Date(Date.UTC(2009, 11, 3)), rangeMinusOneYear)).toBe('2010-04') + expect(fb.lockdate.initial(new Date(Date.UTC(2009, 11, 3)), rangePlusOneYear)).toBe('2010-12') + }) + }) +}) diff --git a/src/components/fidelity_bond/fb_utils.ts b/src/components/fidelity_bond/fb_utils.ts new file mode 100644 index 000000000..bd983aa6d --- /dev/null +++ b/src/components/fidelity_bond/fb_utils.ts @@ -0,0 +1,79 @@ +import { Lockdate } from '../../libs/JmWalletApi' + +type Milliseconds = number + +export type YearsRange = { + min: number + max: number +} + +export const toYearsRange = (min: number, max: number): YearsRange => { + if (max <= min) { + throw new Error('Invalid values for range of years.') + } + return { min, max } +} + +// A maximum of years for a timelock to be accepted. +// This is useful in simple mode - when it should be prevented that users +// lock up their coins for an awful amount of time by accident. +// In "advanced" mode, this can be dropped or increased substantially. +export const DEFAULT_MAX_TIMELOCK_YEARS = 10 +export const DEFAULT_TIMELOCK_YEARS_RANGE = toYearsRange(0, DEFAULT_MAX_TIMELOCK_YEARS) + +// The months ahead for the initial lock date. +// It is recommended to start locking for a period of between 3 months and 1 years initially. +// This value should be at the lower end of this recommendation. +// See https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/fidelity-bonds.md#what-amount-of-bitcoins-to-lock-up-and-for-how-long +// for more information (last checked on 2022-06-13). +// Exported for tests only! +export const __INITIAL_LOCKDATE_MONTH_AHEAD = 3 + +export const lockdate = (() => { + const _fromDate = (date: Date): Lockdate => { + return `${date.getUTCFullYear()}-${date.getUTCMonth() >= 9 ? '' : '0'}${1 + date.getUTCMonth()}` as Lockdate + } + const fromTimestamp = (timestamp: Milliseconds): Lockdate => { + if (Number.isNaN(timestamp)) throw new Error('Unsupported input: NaN') + return _fromDate(new Date(timestamp)) + } + const toTimestamp = (lockdate: Lockdate): Milliseconds => { + const split = lockdate.split('-') + if (split.length !== 2 || split[0].length !== 4 || split[1].length !== 2) { + throw new Error('Unsupported format') + } + + const year = parseInt(split[0], 10) + const month = parseInt(split[1], 10) + if (Number.isNaN(year) || Number.isNaN(month)) { + throw new Error('Unsupported format') + } + return Date.UTC(year, month - 1, 1) + } + + /** + * Returns a lockdate an initial lockdate in the future. + * + * This method tries to provide a date that is at least + * {@link __INITIAL_LOCKDATE_MONTH_AHEAD} months after {@link now}. + * + * @param now the reference date + * @param range a min/max range of years + * @returns an initial lockdate + */ + const initial = (now: Date, range: YearsRange = DEFAULT_TIMELOCK_YEARS_RANGE): Lockdate => { + const year = now.getUTCFullYear() + const month = now.getUTCMonth() + + const minMonthAhead = Math.max(range.min * 12, __INITIAL_LOCKDATE_MONTH_AHEAD + 1) + const initYear = year + Math.floor((month + minMonthAhead) / 12) + const initMonth = (month + minMonthAhead) % 12 + return fromTimestamp(Date.UTC(initYear, initMonth, 1)) + } + + return { + fromTimestamp, + toTimestamp, + initial, + } +})() diff --git a/src/constants/debugFeatures.ts b/src/constants/debugFeatures.ts index ab9bf5d1e..fe20ea92f 100644 --- a/src/constants/debugFeatures.ts +++ b/src/constants/debugFeatures.ts @@ -1,11 +1,13 @@ interface DebugFeatures { insecureScheduleTesting: boolean + allowCreatingExpiredFidelityBond: boolean } const devMode = process.env.NODE_ENV === 'development' const debugFeatures: DebugFeatures = { insecureScheduleTesting: true, + allowCreatingExpiredFidelityBond: true, } type DebugFeature = keyof DebugFeatures diff --git a/src/constants/features.ts b/src/constants/features.ts index d765f6210..3931da248 100644 --- a/src/constants/features.ts +++ b/src/constants/features.ts @@ -1,11 +1,13 @@ interface Features { skipWalletBackupConfirmation: boolean + fidelityBonds: boolean } const devMode = process.env.NODE_ENV === 'development' const features: Features = { skipWalletBackupConfirmation: devMode, + fidelityBonds: devMode, } type Feature = keyof Features diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 73b034f7f..2e3c82862 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -6,6 +6,7 @@ export const routes = { receive: '/receive', earn: '/earn', settings: '/settings', + fidelityBonds: '/fidelity-bonds', wallet: '/wallet', createWallet: '/create-wallet', } diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index 9119f8048..652739456 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -8,21 +8,19 @@ export interface CurrentWallet { token: string } -export type Satoshi = number - // TODO: move these interfaces to JmWalletApi, once distinct types are used as return value instead of plain "Response" export type Utxo = { address: string path: string label: string - value: Satoshi + value: Api.AmountSats tries: number tries_remaining: number external: boolean mixdepth: number confirmations: number frozen: boolean - utxo: string + utxo: Api.UtxoId locktime?: string } diff --git a/src/hooks/BalanceSummary.test.tsx b/src/hooks/BalanceSummary.test.tsx index 2668b07f3..83391de45 100644 --- a/src/hooks/BalanceSummary.test.tsx +++ b/src/hooks/BalanceSummary.test.tsx @@ -2,27 +2,57 @@ import React from 'react' import { render } from '../testUtils' import { act } from 'react-dom/test-utils' -import { useBalanceSummary, WalletBalanceSummary } from './BalanceSummary' +import { useBalanceSummary, WalletBalanceSummary, isLocked } from './BalanceSummary' import { WalletInfo, Utxo } from '../context/WalletContext' -function setup(walletInfo: WalletInfo | null) { - const returnVal: { data: WalletBalanceSummary | null | undefined } = { data: undefined } - const TestComponent: React.FunctionComponent = () => { - returnVal.data = useBalanceSummary(walletInfo) - return <> - } +const now = Date.UTC(2009, 0, 3) + +describe('isLocked', () => { + it('should detect timelocked utxo as locked', () => { + const utxo = { + // timelocked, not yet expired + locktime: 'any', + path: `m/84'/1'/0'/0/1:${now / 1_000 + 1}`, + } as Utxo + expect(isLocked(utxo, now)).toBe(true) + }) - render() + it('should detect expired timelocked utxo as unlocked', () => { + const utxo = { + // timelocked, but expired + locktime: 'any', + path: `m/84'/1'/0'/0/1:${now / 1_000 - 1}`, + } as Utxo + expect(isLocked(utxo, now)).toBe(false) + }) - return returnVal -} + it('should detect non-timelocked utxo as unlocked', () => { + const utxo = { + // not timelocked + path: `m/84'/1'/0'/0/1`, + } as Utxo + expect(isLocked(utxo, now)).toBe(false) + }) +}) describe('BalanceSummary', () => { + function setup(walletInfo: WalletInfo | null, refTime: number) { + const returnVal: { data: WalletBalanceSummary | null | undefined } = { data: undefined } + const TestComponent: React.FunctionComponent = () => { + returnVal.data = useBalanceSummary(walletInfo, refTime) + return <> + } + + render() + + return returnVal + } + it('should handle missing wallet info without errors', () => { let balanceSummary: WalletBalanceSummary | null | undefined act(() => { - balanceSummary = setup(null).data + balanceSummary = setup(null, now).data }) expect(balanceSummary).toBeNull() @@ -32,21 +62,24 @@ describe('BalanceSummary', () => { let balanceSummary: WalletBalanceSummary | null | undefined act(() => { - balanceSummary = setup({ - data: { - utxos: { - utxos: [], - }, - display: { - walletinfo: { - wallet_name: 'test.jmdat', - total_balance: '2.00000000', - available_balance: '1.00000000', - accounts: [], + balanceSummary = setup( + { + data: { + utxos: { + utxos: [], + }, + display: { + walletinfo: { + wallet_name: 'test.jmdat', + total_balance: '2.00000000', + available_balance: '1.00000000', + accounts: [], + }, }, }, }, - }).data + now + ).data }) expect(balanceSummary).not.toBeNull() @@ -58,150 +91,169 @@ describe('BalanceSummary', () => { let balanceSummary: WalletBalanceSummary | null | undefined act(() => { - balanceSummary = setup({ - data: { - utxos: { - utxos: [ - { - value: 1, - mixdepth: 0, - frozen: false, - } as Utxo, - { - value: 2, - mixdepth: 0, - frozen: false, - locktime: '2099-12', - } as Utxo, - { - value: 3, - mixdepth: 0, - confirmations: 0, - frozen: true, - } as Utxo, - ], - }, - display: { - walletinfo: { - wallet_name: 'test.jmdat', - total_balance: '0.00000006', - available_balance: '0.00000001', - accounts: [], + balanceSummary = setup( + { + data: { + utxos: { + utxos: [ + { + value: 1, + mixdepth: 0, + frozen: false, + } as Utxo, + { + value: 2, + mixdepth: 0, + frozen: false, + // unfrozen but not yet expired + locktime: '2999-12', + path: `m/84'/1'/0'/0/2:${now / 1_000 + 1}`, + } as Utxo, + { + value: 3, + mixdepth: 0, + confirmations: 0, + frozen: true, + } as Utxo, + { + value: 4, + mixdepth: 0, + // unfrozen and expired + frozen: false, + locktime: '1970-01', + path: `m/84'/1'/0'/0/2:${now / 1_000 - 1}`, + } as Utxo, + ], + }, + display: { + walletinfo: { + wallet_name: 'test.jmdat', + total_balance: '0.00000010', + available_balance: '0.00000005', + accounts: [], + }, }, }, }, - }).data + now + ).data }) expect(balanceSummary).not.toBeNull() - expect(balanceSummary!.totalBalance).toBe('0.00000006') - expect(balanceSummary!.availableBalance).toBe('0.00000001') - expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(1) + expect(balanceSummary!.totalBalance).toBe('0.00000010') + expect(balanceSummary!.availableBalance).toBe('0.00000005') + expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(10) + expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(5) expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(5) - expect(balanceSummary!.accountBalances).toEqual([]) + expect(balanceSummary!.accountBalances).toEqual({}) }) it('should populate account balance data', () => { let balanceSummary: WalletBalanceSummary | null | undefined act(() => { - balanceSummary = setup({ - data: { - utxos: { - utxos: [ - { - value: 111111111, - mixdepth: 1, - } as Utxo, - { - value: 222222222, - mixdepth: 2, - } as Utxo, - { - value: 11111111, - mixdepth: 2, - frozen: true, - } as Utxo, - { - value: 333333333, - mixdepth: 3, - confirmations: 0, - frozen: true, - } as Utxo, - ], - }, - display: { - walletinfo: { - wallet_name: 'test.jmdat', - total_balance: '6.66666666', - available_balance: '3.22222222', - accounts: [ + balanceSummary = setup( + { + data: { + utxos: { + utxos: [ { - account: '0', - account_balance: '0.00000000', - available_balance: '0.00000000', - branches: [], - }, + value: 111111111, + mixdepth: 1, + } as Utxo, { - account: '1', - account_balance: '1.11111111', - available_balance: '1.11111111', - branches: [], - }, + value: 222222222, + mixdepth: 2, + } as Utxo, { - account: '2', - account_balance: '2.22222222', - available_balance: '2.11111111', - branches: [], - }, + value: 11111111, + mixdepth: 2, + frozen: true, + } as Utxo, { - account: '3', - account_balance: '3.33333333', - available_balance: '0.00000000', - branches: [], - }, + value: 333333333, + mixdepth: 3, + confirmations: 0, + frozen: true, + } as Utxo, ], }, + display: { + walletinfo: { + wallet_name: 'test.jmdat', + total_balance: '6.77777777', + available_balance: '3.33333333', + accounts: [ + { + account: '0', + account_balance: '0.00000000', + available_balance: '0.00000000', + branches: [], + }, + { + account: '1', + account_balance: '1.11111111', + available_balance: '1.11111111', + branches: [], + }, + { + account: '2', + account_balance: '2.33333333', + available_balance: '2.22222222', + branches: [], + }, + { + account: '3', + account_balance: '3.33333333', + available_balance: '0.00000000', + branches: [], + }, + ], + }, + }, }, }, - }).data + now + ).data }) expect(balanceSummary).not.toBeNull() - expect(balanceSummary!.totalBalance).toBe('6.66666666') - expect(balanceSummary!.availableBalance).toBe('3.22222222') - expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(322222222) + expect(balanceSummary!.totalBalance).toBe('6.77777777') + expect(balanceSummary!.availableBalance).toBe('3.33333333') + expect(balanceSummary!.calculatedTotalBalanceInSats).toBe(677777777) + expect(balanceSummary!.calculatedAvailableBalanceInSats).toBe(333333333) expect(balanceSummary!.calculatedFrozenOrLockedBalanceInSats).toBe(344444444) - expect(balanceSummary!.accountBalances).toHaveLength(4) - - const accountSummaryByIndex = (accountIndex: number) => - balanceSummary!.accountBalances!.filter((it) => it.accountIndex === accountIndex).reduce((_, obj) => obj) + expect(Object.keys(balanceSummary!.accountBalances)).toHaveLength(4) - const account0 = accountSummaryByIndex(0) + const account0 = balanceSummary!.accountBalances[0] expect(account0.accountIndex).toBe(0) expect(account0.totalBalance).toBe('0.00000000') expect(account0.availableBalance).toBe('0.00000000') + expect(account0.calculatedTotalBalanceInSats).toBe(0) expect(account0.calculatedAvailableBalanceInSats).toBe(0) expect(account0.calculatedFrozenOrLockedBalanceInSats).toBe(0) - const account1 = accountSummaryByIndex(1) + const account1 = balanceSummary!.accountBalances[1] expect(account1.accountIndex).toBe(1) expect(account1.totalBalance).toBe('1.11111111') expect(account1.availableBalance).toBe('1.11111111') + expect(account1.calculatedTotalBalanceInSats).toBe(111111111) expect(account1.calculatedAvailableBalanceInSats).toBe(111111111) expect(account1.calculatedFrozenOrLockedBalanceInSats).toBe(0) - const account2 = accountSummaryByIndex(2) + const account2 = balanceSummary!.accountBalances[2] expect(account2.accountIndex).toBe(2) - expect(account2.totalBalance).toBe('2.22222222') - expect(account2.availableBalance).toBe('2.11111111') - expect(account2.calculatedAvailableBalanceInSats).toBe(211111111) + expect(account2.totalBalance).toBe('2.33333333') + expect(account2.availableBalance).toBe('2.22222222') + expect(account2.calculatedTotalBalanceInSats).toBe(233333333) + expect(account2.calculatedAvailableBalanceInSats).toBe(222222222) expect(account2.calculatedFrozenOrLockedBalanceInSats).toBe(11111111) - const account3 = accountSummaryByIndex(3) + const account3 = balanceSummary!.accountBalances[3] expect(account3.accountIndex).toBe(3) expect(account3.totalBalance).toBe('3.33333333') expect(account3.availableBalance).toBe('0.00000000') + expect(account3.calculatedTotalBalanceInSats).toBe(333333333) expect(account3.calculatedAvailableBalanceInSats).toBe(0) expect(account3.calculatedFrozenOrLockedBalanceInSats).toBe(333333333) }) diff --git a/src/hooks/BalanceSummary.ts b/src/hooks/BalanceSummary.ts index d0df33048..15a1b99c1 100644 --- a/src/hooks/BalanceSummary.ts +++ b/src/hooks/BalanceSummary.ts @@ -1,6 +1,10 @@ -import { useMemo } from 'react' -import { btcToSats } from '../utils' -import { WalletInfo, Satoshi, BalanceString, Utxos } from '../context/WalletContext' +import { useEffect, useMemo, useState } from 'react' +import { WalletInfo, BalanceString, Utxos, Utxo } from '../context/WalletContext' + +import { AmountSats } from '../libs/JmWalletApi' + +type Milliseconds = number +type Seconds = number interface BalanceSummary { totalBalance: BalanceString @@ -18,31 +22,58 @@ interface BalanceSummary { } type BalanceSummarySupport = BalanceSummary & { + /** + * @description Manually calculated total balance in sats. + */ + calculatedTotalBalanceInSats: AmountSats /** * @description Manually calculated available balance in sats. * Same as {@link BalanceSummary.availableBalance} except address reuse is taken into account. */ - calculatedAvailableBalanceInSats: Satoshi + calculatedAvailableBalanceInSats: AmountSats /** * @description Manually calculated frozen or locked balance in sats. */ - calculatedFrozenOrLockedBalanceInSats: Satoshi + calculatedFrozenOrLockedBalanceInSats: AmountSats } type AccountBalanceSummary = BalanceSummarySupport & { accountIndex: number } +type AccountBalances = { + [key: number]: AccountBalanceSummary +} + export type WalletBalanceSummary = BalanceSummarySupport & { - accountBalances: AccountBalanceSummary[] + accountBalances: AccountBalances +} + +export const isLocked = (utxo: Utxo, refTime: Milliseconds = Date.now()) => { + if (!utxo.locktime) return false + + const pathAndLocktime = utxo.path.split(':') + if (pathAndLocktime.length !== 2) return false + + const locktimeUnixTimestamp: Seconds = parseInt(pathAndLocktime[1], 10) + if (Number.isNaN(locktimeUnixTimestamp)) return false + + return locktimeUnixTimestamp * 1_000 >= refTime } -const calculateFrozenOrLockedBalance = (utxos: Utxos) => { - const frozenOrLockedUtxos = utxos.filter((utxo) => utxo.frozen || utxo.locktime) +const calculateFrozenOrLockedBalance = (utxos: Utxos, refTime: Milliseconds = Date.now()) => { + const frozenOrLockedUtxos = utxos.filter((utxo) => utxo.frozen || isLocked(utxo, refTime)) return frozenOrLockedUtxos.reduce((acc, utxo) => acc + utxo.value, 0) } -const useBalanceSummary = (currentWalletInfo: WalletInfo | null): WalletBalanceSummary | null => { +const useBalanceSummary = (currentWalletInfo: WalletInfo | null, now?: Milliseconds): WalletBalanceSummary | null => { + const [refTime, setRefTime] = useState(now !== undefined ? now : Date.now()) + + useEffect(() => { + if (!currentWalletInfo) return + setRefTime(now !== undefined ? now : Date.now()) + }, [now, currentWalletInfo]) + const balanceSummary = useMemo(() => { if (!currentWalletInfo) { return null @@ -63,48 +94,60 @@ const useBalanceSummary = (currentWalletInfo: WalletInfo | null): WalletBalanceS return acc }, {} as { [key: string]: Utxos }) + const totalCalculatedByAccount = Object.fromEntries( + Object.entries(utxosByAccount).map(([account, utxos]) => { + return [account, utxos.reduce((acc, utxo) => acc + utxo.value, 0)] + }) + ) const frozenOrLockedCalculatedByAccount = Object.fromEntries( Object.entries(utxosByAccount).map(([account, utxos]) => { - return [account, calculateFrozenOrLockedBalance(utxos)] + return [account, calculateFrozenOrLockedBalance(utxos, refTime)] }) ) - const accountsBalanceSummary = accounts.map(({ account, account_balance, available_balance }) => { - const accountBalanceSummary: BalanceSummary = { - totalBalance: account_balance, - availableBalance: available_balance, - } - - const accountFrozenOrLockedCalculated = frozenOrLockedCalculatedByAccount[account] || 0 - const accountAvailableCalculatedInSats = - btcToSats(accountBalanceSummary.totalBalance!) - accountFrozenOrLockedCalculated - return { - ...accountBalanceSummary, - calculatedAvailableBalanceInSats: accountAvailableCalculatedInSats, - calculatedFrozenOrLockedBalanceInSats: accountFrozenOrLockedCalculated, - accountIndex: parseInt(account, 10), - } as AccountBalanceSummary - }) - - const walletFrozenOrLockedCalculated = Object.values(frozenOrLockedCalculatedByAccount).reduce( + const accountsBalanceSummary = accounts + .map(({ account, account_balance, available_balance }) => { + const accountBalanceSummary: BalanceSummary = { + totalBalance: account_balance, + availableBalance: available_balance, + } + const accountTotalCalculated: AmountSats = totalCalculatedByAccount[account] || 0 + const accountFrozenOrLockedCalculated: AmountSats = frozenOrLockedCalculatedByAccount[account] || 0 + const accountAvailableCalculated: AmountSats = accountTotalCalculated - accountFrozenOrLockedCalculated + return { + ...accountBalanceSummary, + calculatedTotalBalanceInSats: accountTotalCalculated, + calculatedFrozenOrLockedBalanceInSats: accountFrozenOrLockedCalculated, + calculatedAvailableBalanceInSats: accountAvailableCalculated, + accountIndex: parseInt(account, 10), + } as AccountBalanceSummary + }) + .reduce((acc, curr) => ({ ...acc, [curr.accountIndex]: curr }), {} as AccountBalances) + + const walletTotalCalculated: AmountSats = Object.values(totalCalculatedByAccount).reduce( + (acc, totalSats) => acc + totalSats, + 0 + ) + + const walletFrozenOrLockedCalculated: AmountSats = Object.values(frozenOrLockedCalculatedByAccount).reduce( (acc, frozenOrLockedSats) => acc + frozenOrLockedSats, 0 ) - const walletAvailableCalculatedInSats = - btcToSats(walletBalanceSummary.totalBalance!) - walletFrozenOrLockedCalculated + const walletAvailableCalculated = walletTotalCalculated - walletFrozenOrLockedCalculated return { ...walletBalanceSummary, accountBalances: accountsBalanceSummary, - calculatedAvailableBalanceInSats: walletAvailableCalculatedInSats, + calculatedTotalBalanceInSats: walletTotalCalculated, calculatedFrozenOrLockedBalanceInSats: walletFrozenOrLockedCalculated, + calculatedAvailableBalanceInSats: walletAvailableCalculated, } } catch (e) { console.warn('"useBalanceSummary" hook cannot determine balance format', e) return null } - }, [currentWalletInfo]) + }, [currentWalletInfo, refTime]) return balanceSummary } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index ff9322276..41afe04bd 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1,8 +1,15 @@ { "global": { + "loading": "Loading", + "button_copy_text": "Copy", + "button_copy_text_confirmed": "Copied", + "next": "Next", "back": "Back", "close": "Close" }, + "app": { + "alert_no_connection": "No connection to backend: {{ connectionError }}" + }, "navbar": { "title": "Jam", "button_create_wallet": "Create Wallet", @@ -13,9 +20,6 @@ "menu_mobile": "Menu", "menu_mobile_settings": "Settings" }, - "app": { - "alert_no_connection": "No connection to backend: {{ connectionError }}" - }, "footer": { "warning": "This is beta software.
<1>Read this before using.", "warning_alert_title": "Warning", @@ -272,6 +276,17 @@ "description": "Still confused? Dig into the <2>documentation." } }, + "fidelity_bond": { + "title": "Fidelity Bonds (Experimental)", + "subtitle": "Fidelity Bonds prevent Sybil attacks by deliberately increasing the cost of creating cryptographic identities. Creating a fidelity bond will increase your chances of being picked for a collaborative transaction.", + "text_maker_running": "Earn is active. Stop the service in order to create a Fidelity Bond.", + "text_coinjoin_already_running": "A collaborative transaction is currently in progress.", + "create_form": { + "confirmation_toggle_title": "I have reviewed my inputs carefully", + "confirmation_toggle_subtitle": "I understand that the locked funds will be inaccessible for the given duration", + "button_create": "Create Fidelity Bond" + } + }, "scheduler": { "title": "Jam Scheduler (Experimental)", "subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of yourself and others. Every scheduled transaction is a collaborative transaction. The scheduler will use all available funds that aren't frozen.", diff --git a/src/index.css b/src/index.css index f319c07ae..362ed878c 100644 --- a/src/index.css +++ b/src/index.css @@ -284,7 +284,7 @@ main { } .unstyled { - text-decoration: none; + text-decoration: none !important; } /* Navbar Styles */ @@ -529,6 +529,10 @@ h2 { :root[data-theme='dark'] .card { background-color: var(--bs-gray-900); +} + +:root[data-theme='dark'] + .card:not(.border-success):not(.border-danger):not(.border-warning):not(.border-primary):not(.border-info) { border-color: var(--bs-gray-800) !important; } @@ -607,3 +611,12 @@ h2 { :root[data-theme='dark'] .link-dark { color: var(--bs-white); } + +:root[data-theme='dark'] .toast { + background-color: transparent; +} +:root[data-theme='dark'] .toast-header { + color: var(--bs-white); + background-color: var(--bs-gray-800); + border-color: var(--bs-gray-900); +} diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index d7d2ddc34..4a423a5c7 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -16,8 +16,12 @@ type ApiToken = string type WalletName = string type Mixdepth = number -type AmountSats = BigInt -type BitcoinAddress = string +export type AmountSats = number // TODO: should be BigInt! Remove once every caller migrated to TypeScript. +export type BitcoinAddress = string + +type Vout = number +type TxId = string +export type UtxoId = `${TxId}:${Vout}` type WithWalletName = { walletName: WalletName @@ -29,9 +33,9 @@ type WithMixdepth = { type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' type YYYY = `2${Digit}${Digit}${Digit}` type MM = '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '10' | '11' | '12' -type Locktime = `${YYYY}-${MM}` -type WithLocktime = { - locktime: Locktime +export type Lockdate = `${YYYY}-${MM}` +type WithLockdate = { + lockdate: Lockdate } interface ApiRequestContext { @@ -83,7 +87,7 @@ interface DoCoinjoinRequest { } interface FreezeRequest { - utxo: string + utxo: UtxoId freeze: boolean } @@ -147,8 +151,8 @@ const getAddressNew = async ({ token, signal, walletName, mixdepth }: WalletRequ }) } -const getAddressTimelockNew = async ({ token, signal, walletName, locktime }: WalletRequestContext & WithLocktime) => { - return await fetch(`${basePath()}/v1/wallet/${walletName}/address/timelock/new/${locktime}`, { +const getAddressTimelockNew = async ({ token, signal, walletName, lockdate }: WalletRequestContext & WithLockdate) => { + return await fetch(`${basePath()}/v1/wallet/${walletName}/address/timelock/new/${lockdate}`, { headers: { ...Authorization(token) }, signal, }) diff --git a/src/utils.ts b/src/utils.ts index a5eace964..99a6aa50c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,13 @@ +const BTC_FORMATTER = new Intl.NumberFormat('en-US', { + minimumIntegerDigits: 1, + minimumFractionDigits: 8, +}) + +const SATS_FORMATTER = new Intl.NumberFormat('en-US', { + minimumIntegerDigits: 1, + minimumFractionDigits: 0, +}) + export const BTC = 'BTC' export const SATS = 'sats' @@ -13,12 +23,7 @@ export const formatBtc = (value: number) => { const decimalPoint = '\u002E' const nbHalfSpace = '\u202F' - const formatter = new Intl.NumberFormat('en-US', { - minimumIntegerDigits: 1, - minimumFractionDigits: 8, - }) - - const numberString = formatter.format(value) + const numberString = BTC_FORMATTER.format(value) const [integerPart, fractionalPart] = numberString.split(decimalPoint) @@ -31,10 +36,5 @@ export const formatBtc = (value: number) => { } export const formatSats = (value: number) => { - const formatter = new Intl.NumberFormat('en-US', { - minimumIntegerDigits: 1, - minimumFractionDigits: 0, - }) - - return formatter.format(value) + return SATS_FORMATTER.format(value) }