diff --git a/src/components/App.jsx b/src/components/App.jsx
index 9003184da..354551850 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,8 @@ import Earn from './Earn'
import Receive from './Receive'
import CurrentWalletMagic from './CurrentWalletMagic'
import CurrentWalletAdvanced from './CurrentWalletAdvanced'
+import FidelityBond from './FidelityBond'
+import { FidelityBondDevOnly } from './FidelityBondDevOnly'
import Settings from './Settings'
import Navbar from './Navbar'
import Layout from './Layout'
@@ -129,6 +132,12 @@ export default function App() {
} />
} />
} />
+ {isFeatureEnabled('fidelityBonds') && (
+ } />
+ )}
+ {isFeatureEnabled('fidelityBondsDevOnly') && (
+ } />
+ )}
>
)}
diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx
index 6dfa10cef..c37d8f19b 100644
--- a/src/components/CopyButton.tsx
+++ b/src/components/CopyButton.tsx
@@ -56,8 +56,9 @@ export function CopyButton({ value, onSuccess, onError, children, ...props }: Pr
interface CopyButtonWithConfirmationProps extends CopyButtonProps {
text: string
- successText: string
- successTextTimeout: number
+ successText?: string
+ successTextTimeout?: number
+ disabled?: boolean
}
export function CopyButtonWithConfirmation({
diff --git a/src/components/CurrentWalletAdvanced.tsx b/src/components/CurrentWalletAdvanced.tsx
index 9a95784d6..e6722afb0 100644
--- a/src/components/CurrentWalletAdvanced.tsx
+++ b/src/components/CurrentWalletAdvanced.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
import * as rb from 'react-bootstrap'
-import { useTranslation } from 'react-i18next'
+import { Trans, useTranslation } from 'react-i18next'
// @ts-ignore
import DisplayAccounts from './DisplayAccounts'
// @ts-ignore
@@ -9,12 +10,16 @@ import DisplayAccountUTXOs from './DisplayAccountUTXOs'
import DisplayUTXOs from './DisplayUTXOs'
// @ts-ignore
import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
+import { isFeatureEnabled } from '../constants/features'
+import { routes } from '../constants/routes'
import styles from './CurrentWalletAdvanced.module.css'
type Utxos = any[]
type Alert = { message: string; variant: string }
export default function CurrentWalletAdvanced() {
+ const featureFidelityBondsEnabled = isFeatureEnabled('fidelityBonds')
+
const { t } = useTranslation()
const currentWallet = useCurrentWallet()
const walletInfo = useCurrentWalletInfo()
@@ -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/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..a607b2bae
--- /dev/null
+++ b/src/components/FidelityBond.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import { Link } from 'react-router-dom'
+import * as rb from 'react-bootstrap'
+
+// @ts-ignore
+import PageTitle from './PageTitle'
+import { isFeatureEnabled } from '../constants/features'
+
+import { routes } from '../constants/routes'
+import styles from './FidelityBond.module.css'
+
+export default function FidelityBond() {
+ const featureEnabled = isFeatureEnabled('fidelityBonds')
+ const featureAdvancedEnabled = isFeatureEnabled('fidelityBondsDevOnly')
+
+ const { t } = useTranslation()
+
+ if (!featureEnabled) {
+ return (
+
+
Feature not enabled
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ Fidelity Bonds are currently only available in developer mode.
+ {featureAdvancedEnabled && (
+ <>
+ {' '}
+
+ Switch to developer view.
+
+ >
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/FidelityBondDevOnly.tsx b/src/components/FidelityBondDevOnly.tsx
new file mode 100644
index 000000000..8e57612d7
--- /dev/null
+++ b/src/components/FidelityBondDevOnly.tsx
@@ -0,0 +1,255 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import * as rb from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import { Link } from 'react-router-dom'
+import { useCurrentWallet, useReloadCurrentWalletInfo, Utxos } from '../context/WalletContext'
+import { CopyButtonWithConfirmation } from '../components/CopyButton'
+import { isFeatureEnabled } from '../constants/features'
+
+// @ts-ignore
+import PageTitle from './PageTitle'
+// @ts-ignore
+import DisplayUTXOs from './DisplayUTXOs'
+
+import { routes } from '../constants/routes'
+import * as Api from '../libs/JmWalletApi'
+import styles from './FidelityBond.module.css'
+import LockdateForm, {
+ toYearsRange,
+ lockdateToTimestamp,
+ DEFAULT_MAX_TIMELOCK_YEARS,
+} from './fidelity_bond/LockdateForm'
+
+type AlertWithMessage = rb.AlertProps & { message: string }
+
+const locktimeDisplayString = (lockdate: Api.Lockdate) => {
+ return new Date(lockdateToTimestamp(lockdate)).toUTCString()
+}
+
+interface DepositFormAdvancedProps {
+ title: React.ReactElement
+ [key: string]: unknown
+}
+const DepositFormAdvanced = ({ title, ...props }: DepositFormAdvancedProps) => {
+ const { t } = useTranslation()
+ const currentWallet = useCurrentWallet()
+
+ const yearsRange = useMemo(() => toYearsRange(-1, DEFAULT_MAX_TIMELOCK_YEARS), [])
+ const [lockdate, setLockdate] = useState(null)
+ const [address, setAddress] = useState(null)
+ const [addressLockdate, setAddressLockdate] = useState(null)
+ const addressLocktimeString = useMemo(
+ () => (addressLockdate ? locktimeDisplayString(addressLockdate) : null),
+ [addressLockdate]
+ )
+ const [isLoading, setIsLoading] = useState(true)
+ const [alert, setAlert] = useState(null)
+
+ useEffect(() => {
+ if (!currentWallet) return
+ if (!lockdate) return
+
+ const abortCtrl = new AbortController()
+ const { name: walletName, token } = currentWallet
+
+ setAlert(null)
+
+ setAddress(null)
+ setAddressLockdate(null)
+
+ setIsLoading(true)
+
+ Api.getAddressTimelockNew({ walletName, token, lockdate, signal: abortCtrl.signal })
+ .then((res) =>
+ res.ok ? res.json() : Api.Helper.throwError(res, t('fidelity_bond.error_loading_timelock_address_failed'))
+ )
+ .then((data) => {
+ if (abortCtrl.signal.aborted) return
+
+ setAddress(data.address)
+ setAddressLockdate(lockdate)
+ })
+ // show the loader a little longer to avoid flickering
+ .then((_) => new Promise((r) => setTimeout(r, 200)))
+ .catch((err) => {
+ !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message })
+ })
+ .finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
+
+ return () => abortCtrl.abort()
+ }, [currentWallet, lockdate, t])
+
+ return (
+
+
+ {title}
+
+ {alert && {alert.message} }
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+ {t('global.loading')}
+
+ ) : (
+
+
+ Expires at: {addressLocktimeString}
+
+
+ )}
+
+
+
+ {!isLoading && address && (
+ <>
+
{address}
+
+ {' '}
+
+ >
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+export const FidelityBondDevOnly = () => {
+ const featureEnabled = isFeatureEnabled('fidelityBondsDevOnly')
+ const { t } = useTranslation()
+ const currentWallet = useCurrentWallet()
+ const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
+ const [fidelityBonds, setFidelityBonds] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [alert, setAlert] = useState(null)
+
+ useEffect(() => {
+ if (!currentWallet) {
+ setAlert({ variant: 'danger', message: t('current_wallet.error_loading_failed') })
+ setIsLoading(false)
+ return
+ }
+
+ const abortCtrl = new AbortController()
+
+ setAlert(null)
+ setIsLoading(true)
+ setFidelityBonds(null)
+
+ reloadCurrentWalletInfo({ signal: abortCtrl.signal })
+ .then((info) => {
+ if (info) {
+ const timelockedUtxos = info.data.utxos.utxos.filter((utxo) => utxo.locktime)
+ setFidelityBonds(timelockedUtxos)
+ }
+ })
+ .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])
+
+ if (!featureEnabled) {
+ return (
+
+
Feature not enabled
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Switch to default view.
+
+
+
+
+
+
+
+ You are in developer mode. It is assumed that you know what you are doing.
+
+
+ e.g. a transaction creating a Fidelity Bond should have no change , etc.
+
+
+
+
+ {isLoading ? (
+
+
+ {t('global.loading')}
+
+ ) : (
+ <>
+ {alert && {alert.message} }
+
+ {fidelityBonds && (
+ <>
+
+ Fidelity Bond}
+ />
+
+
+ {fidelityBonds.length > 0 && (
+
+
{t('current_wallet_advanced.title_fidelity_bonds')}
+
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/components/fidelity_bond/LockdateForm.tsx b/src/components/fidelity_bond/LockdateForm.tsx
new file mode 100644
index 000000000..945c0df8b
--- /dev/null
+++ b/src/components/fidelity_bond/LockdateForm.tsx
@@ -0,0 +1,135 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import * as rb from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+
+import * as Api from '../../libs/JmWalletApi'
+
+const dateToLockdate = (date: Date): Api.Lockdate =>
+ `${date.getUTCFullYear()}-${date.getUTCMonth() >= 9 ? '' : '0'}${1 + date.getUTCMonth()}` as Api.Lockdate
+
+export const lockdateToTimestamp = (lockdate: Api.Lockdate): number => {
+ const split = lockdate.split('-')
+ return Date.UTC(parseInt(split[0], 10), parseInt(split[1], 10) - 1, 1)
+}
+
+// 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
+
+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 }
+}
+
+const initialLockdate = (now: Date, range: YearsRange): Api.Lockdate => {
+ const year = now.getUTCFullYear()
+ const month = now.getUTCMonth()
+ return dateToLockdate(new Date(Date.UTC(year + Math.max(range.min + 1, 1), month, 1)))
+}
+
+interface LockdateFormProps {
+ initialValue?: Api.Lockdate
+ onChange: (lockdate: Api.Lockdate) => void
+ yearsRange?: YearsRange
+ now?: Date
+}
+
+const LockdateForm = ({
+ onChange,
+ now = new Date(),
+ yearsRange = toYearsRange(0, DEFAULT_MAX_TIMELOCK_YEARS),
+ initialValue = initialLockdate(now, yearsRange),
+}: LockdateFormProps) => {
+ const { t } = useTranslation()
+
+ const currentYear = useMemo(() => now.getUTCFullYear(), [now])
+ const currentMonth = useMemo(() => now.getUTCMonth() + 1, [now]) // utc month ranges from [0, 11]
+
+ const initialDate = new Date(lockdateToTimestamp(initialValue))
+ const [lockdateYear, setLockdateYear] = useState(initialDate.getUTCFullYear())
+ const [lockdateMonth, setLockdateMonth] = useState(initialDate.getUTCMonth() + 1)
+
+ const minMonth = useCallback(() => {
+ if (lockdateYear > currentYear + yearsRange.min) {
+ return 1
+ }
+
+ return currentMonth + 1 // can be '13' - which means it never is valid and user must adapt 'year'.
+ }, [lockdateYear, currentYear, currentMonth, yearsRange])
+
+ const isLockdateYearValid = useMemo(
+ () => lockdateYear >= currentYear + yearsRange.min && lockdateYear <= currentYear + yearsRange.max,
+ [lockdateYear, currentYear, yearsRange]
+ )
+ const isLockdateMonthValid = useMemo(() => lockdateMonth >= minMonth(), [lockdateMonth, minMonth])
+
+ useEffect(() => {
+ if (!isLockdateYearValid || !isLockdateMonthValid) return
+
+ const date = new Date(Date.UTC(lockdateYear, lockdateMonth - 1, 1))
+ onChange(dateToLockdate(date))
+ }, [lockdateYear, lockdateMonth, isLockdateYearValid, isLockdateMonthValid, onChange])
+
+ return (
+
+
+
+
+ Year
+
+ setLockdateYear(parseInt(e.target.value, 10))}
+ isInvalid={!isLockdateYearValid}
+ />
+
+
+ Please provide a valid value.
+
+
+
+
+
+
+
+ Month
+
+ setLockdateMonth(parseInt(e.target.value, 10))}
+ isInvalid={!isLockdateMonthValid}
+ />
+
+
+ Please provide a valid value.
+
+
+
+
+
+ )
+}
+
+export default LockdateForm
diff --git a/src/constants/features.ts b/src/constants/features.ts
index d765f6210..49a09dddb 100644
--- a/src/constants/features.ts
+++ b/src/constants/features.ts
@@ -1,11 +1,15 @@
interface Features {
skipWalletBackupConfirmation: boolean
+ fidelityBonds: boolean
+ fidelityBondsDevOnly: boolean
}
const devMode = process.env.NODE_ENV === 'development'
const features: Features = {
skipWalletBackupConfirmation: devMode,
+ fidelityBonds: devMode,
+ fidelityBondsDevOnly: devMode,
}
type Feature = keyof Features
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index 73b034f7f..cdb4f2417 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -6,6 +6,8 @@ export const routes = {
receive: '/receive',
earn: '/earn',
settings: '/settings',
+ fidelityBonds: '/fidelity-bonds',
+ fidelityBondsDevOnly: '/fidelity-bonds-dev',
wallet: '/wallet',
createWallet: '/create-wallet',
}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 6f3ebc1d4..92e322da5 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -1,4 +1,12 @@
{
+ "global": {
+ "loading": "Loading",
+ "button_copy_text": "Copy",
+ "button_copy_text_confirmed": "Copied"
+ },
+ "app": {
+ "alert_no_connection": "No connection to backend: {{ connectionError }}"
+ },
"navbar": {
"title": "Jam",
"button_create_wallet": "Create Wallet",
@@ -10,9 +18,6 @@
"menu_mobile_settings": "Settings",
"menu_mobile_wallets": "Wallets"
},
- "app": {
- "alert_no_connection": "No connection to backend: {{ connectionError }}"
- },
"footer": {
"warning": "This is alpha software. <1>Read this before using.1>",
"warning_alert_title": "Warning",
diff --git a/src/index.css b/src/index.css
index 94aeba17c..120a725d0 100644
--- a/src/index.css
+++ b/src/index.css
@@ -615,3 +615,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..20fbd062b 100644
--- a/src/libs/JmWalletApi.ts
+++ b/src/libs/JmWalletApi.ts
@@ -29,9 +29,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 {
@@ -147,8 +147,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,
})