diff --git a/package-lock.json b/package-lock.json
index 983f776e2..2c4107a4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,7 +14,7 @@
"@table-library/react-table-library": "^4.0.23",
"bootstrap": "^5.3.2",
"classnames": "^2.3.2",
- "formik": "^2.2.9",
+ "formik": "^2.4.5",
"i18next": "^22.0.4",
"i18next-browser-languagedetector": "^7.0.1",
"qrcode": "^1.5.1",
@@ -4625,6 +4625,15 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hoist-non-react-statics": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
+ "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==",
+ "dependencies": {
+ "@types/react": "*",
+ "hoist-non-react-statics": "^3.3.0"
+ }
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -9868,9 +9877,9 @@
}
},
"node_modules/formik": {
- "version": "2.2.9",
- "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
- "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz",
+ "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==",
"funding": [
{
"type": "individual",
@@ -9878,18 +9887,24 @@
}
],
"dependencies": {
+ "@types/hoist-non-react-statics": "^3.3.1",
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
- "tslib": "^1.10.0"
+ "tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
+ "node_modules/formik/node_modules/tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -21114,7 +21129,8 @@
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "dev": true
},
"node_modules/tsutils": {
"version": "3.21.0",
@@ -25834,6 +25850,15 @@
"@types/node": "*"
}
},
+ "@types/hoist-non-react-statics": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
+ "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==",
+ "requires": {
+ "@types/react": "*",
+ "hoist-non-react-statics": "^3.3.0"
+ }
+ },
"@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -29832,17 +29857,25 @@
}
},
"formik": {
- "version": "2.2.9",
- "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
- "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz",
+ "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==",
"requires": {
+ "@types/hoist-non-react-statics": "^3.3.1",
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
- "tslib": "^1.10.0"
+ "tslib": "^2.0.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ }
}
},
"forwarded": {
@@ -38040,7 +38073,8 @@
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "dev": true
},
"tsutils": {
"version": "3.21.0",
diff --git a/package.json b/package.json
index 3b5b11c72..b3e6a063f 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@table-library/react-table-library": "^4.0.23",
"bootstrap": "^5.3.2",
"classnames": "^2.3.2",
- "formik": "^2.2.9",
+ "formik": "^2.4.5",
"i18next": "^22.0.4",
"i18next-browser-languagedetector": "^7.0.1",
"qrcode": "^1.5.1",
diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx
index 5e5ede36a..0a60c98e0 100644
--- a/src/components/Accordion.tsx
+++ b/src/components/Accordion.tsx
@@ -25,7 +25,7 @@ const Accordion = ({
setIsOpen((current) => !current)}
disabled={disabled}
>
@@ -50,7 +50,7 @@ const Accordion = ({
-
+
{children}
diff --git a/src/components/Earn.module.css b/src/components/Earn.module.css
index f42076758..44c842f4f 100644
--- a/src/components/Earn.module.css
+++ b/src/components/Earn.module.css
@@ -20,16 +20,6 @@
font-size: 1.2rem;
}
-.earn hr {
- border-color: rgba(222, 222, 222, 1);
- opacity: 100%;
-}
-
-:root[data-theme='dark'] .earn hr {
- border-color: var(--bs-gray-800);
- opacity: 100%;
-}
-
.offerLoader {
height: 10rem;
border-radius: 0.25rem;
diff --git a/src/components/MnemonicWordInput.tsx b/src/components/MnemonicWordInput.tsx
index fcbf93a23..eb61abc8b 100644
--- a/src/components/MnemonicWordInput.tsx
+++ b/src/components/MnemonicWordInput.tsx
@@ -15,7 +15,7 @@ const MnemonicWordInput = ({ index, value, setValue, isValid, disabled }: Mnemon
return (
{index + 1}.
- [number, number] = (feeValues) => {
- const feeTargetInSatsPerVByte = feeValues.tx_fees! / 1_000
+const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => {
+ if (txFee.unit !== 'sats/kilo-vbyte') {
+ throw new Error('This function can only be used with unit `sats/kilo-vbyte`')
+ }
+ const feeTargetInSatsPerVByte = txFee.value! / 1_000
const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte)
- const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeValues.tx_fees_factor!)
+ const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + txFeeFactor)
return [minFeeSatsPerVByte, maxFeeSatsPerVByte]
}
@@ -23,15 +26,17 @@ const useMiningFeeText = ({ feeConfigValues }: { feeConfigValues?: FeeValues })
const miningFeeText = useMemo(() => {
if (!feeConfigValues) return null
- if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
+ if (!isValidNumber(feeConfigValues.tx_fees?.value) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
- const unit = toTxFeeValueUnit(feeConfigValues.tx_fees)
- if (!unit) {
+ if (!feeConfigValues.tx_fees?.unit) {
return null
- } else if (unit === 'blocks') {
- return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees })
+ } else if (feeConfigValues.tx_fees.unit === 'blocks') {
+ return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees.value })
} else {
- const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(feeConfigValues)
+ const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(
+ feeConfigValues.tx_fees,
+ feeConfigValues.tx_fees_factor!,
+ )
const fractionDigits = 2
if (minFeeSatsPerVByte.toFixed(fractionDigits) === maxFeeSatsPerVByte.toFixed(fractionDigits)) {
diff --git a/src/components/Receive.module.css b/src/components/Receive.module.css
index 31adaff6f..23b573eea 100644
--- a/src/components/Receive.module.css
+++ b/src/components/Receive.module.css
@@ -1,16 +1,5 @@
-.receive hr {
- border-color: rgba(222, 222, 222, 1);
- opacity: 100%;
-}
-
-:root[data-theme='dark'] .receive hr {
- border-color: var(--bs-gray-800);
- opacity: 100%;
-}
-
.receive button {
font-weight: 500;
- border-color: rgba(222, 222, 222, 1);
}
.receive form input {
diff --git a/src/components/Send/AmountInputField.module.css b/src/components/Send/AmountInputField.module.css
new file mode 100644
index 000000000..281596345
--- /dev/null
+++ b/src/components/Send/AmountInputField.module.css
@@ -0,0 +1,13 @@
+.inputLoader {
+ height: 3.5rem;
+ border-radius: 0.25rem;
+}
+
+.input {
+ height: 3.5rem;
+ width: 100%;
+}
+
+.button {
+ font-size: 0.875rem !important;
+}
diff --git a/src/components/Send/AmountInputField.tsx b/src/components/Send/AmountInputField.tsx
new file mode 100644
index 000000000..b21fb135e
--- /dev/null
+++ b/src/components/Send/AmountInputField.tsx
@@ -0,0 +1,143 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import * as rb from 'react-bootstrap'
+import classNames from 'classnames'
+import { useField, useFormikContext } from 'formik'
+import * as Api from '../../libs/JmWalletApi'
+import Sprite from '../Sprite'
+import { AccountBalanceSummary } from '../../context/BalanceSummary'
+import { noop } from '../../utils'
+import styles from './AmountInputField.module.css'
+
+export type AmountValue = {
+ value: Api.AmountSats | null
+ isSweep: boolean
+}
+
+export type AmountInputFieldProps = {
+ name: string
+ label: string
+ className?: string
+ sourceJarBalance?: AccountBalanceSummary
+ isLoading: boolean
+ disabled?: boolean
+}
+
+export const AmountInputField = ({
+ name,
+ label,
+ className,
+ sourceJarBalance,
+ isLoading,
+ disabled = false,
+}: AmountInputFieldProps) => {
+ const { t } = useTranslation()
+ const [field] = useField(name)
+ const form = useFormikContext()
+
+ const amountFieldValue = useMemo(() => {
+ if (field.value?.isSweep) {
+ if (!sourceJarBalance) return ''
+ return `${sourceJarBalance.calculatedAvailableBalanceInSats}`
+ }
+
+ return field.value?.value ?? ''
+ }, [sourceJarBalance, field])
+
+ return (
+ <>
+
+ {label}
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {field.value?.isSweep === true ? (
+
+
+ {
+ form.setFieldValue(field.name, form.initialValues[field.name], true)
+ }}
+ disabled={disabled}
+ >
+
+
+ <>{t('send.button_clear_sweep')}>
+
+
+
+ ) : (
+
+
+ {
+ const value = e.target.value
+ form.setFieldValue(
+ field.name,
+ {
+ value: value === '' ? null : parseInt(value, 10),
+ fromJar: null,
+ },
+ true,
+ )
+ }}
+ isInvalid={form.touched[field.name] && !!form.errors[field.name]}
+ disabled={disabled}
+ />
+ {
+ if (!sourceJarBalance) return
+ form.setFieldValue(
+ field.name,
+ {
+ value: 0,
+ isSweep: true,
+ },
+ true,
+ )
+ }}
+ disabled={disabled || !sourceJarBalance}
+ >
+
+
+ {t('send.button_sweep')}
+
+
+ {form.errors[field.name]}
+
+
+ )}
+ >
+ )}
+
+ >
+ )
+}
diff --git a/src/components/Send/CollaboratorsSelector.tsx b/src/components/Send/CollaboratorsSelector.tsx
index e9f93c817..a3a0755b2 100644
--- a/src/components/Send/CollaboratorsSelector.tsx
+++ b/src/components/Send/CollaboratorsSelector.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { useField, useFormikContext } from 'formik'
import { useSettings } from '../../context/SettingsContext'
import * as rb from 'react-bootstrap'
import classNames from 'classnames'
@@ -7,45 +8,54 @@ import styles from './CollaboratorsSelector.module.css'
import { isValidNumCollaborators } from './helpers'
type CollaboratorsSelectorProps = {
- numCollaborators: number | null
- setNumCollaborators: (val: number | null) => void
+ name: string
minNumCollaborators: number
+ maxNumCollaborators: number
disabled?: boolean
}
const CollaboratorsSelector = ({
- numCollaborators,
- setNumCollaborators,
+ name,
minNumCollaborators,
+ maxNumCollaborators,
disabled = false,
}: CollaboratorsSelectorProps) => {
const { t } = useTranslation()
const settings = useSettings()
- const [usesCustomNumCollaborators, setUsesCustomNumCollaborators] = useState(false)
+ const [field] = useField(name)
+ const form = useFormikContext()
+
+ const [customNumCollaboratorsInput, setCustomNumCollaboratorsInput] = useState()
const defaultCollaboratorsSelection = useMemo(() => {
const start = Math.max(minNumCollaborators, 8)
return [start, start + 1, start + 2]
}, [minNumCollaborators])
+ const usesCustomNumCollaborators = useMemo(() => {
+ return field.value === undefined || String(field.value) === customNumCollaboratorsInput
+ }, [field.value, customNumCollaboratorsInput])
+
const validateAndSetCustomNumCollaborators = (candidate: string) => {
const parsed = parseInt(candidate, 10)
if (isValidNumCollaborators(parsed, minNumCollaborators)) {
- setNumCollaborators(parsed)
+ form.setFieldValue(field.name, parsed, true)
} else {
- setNumCollaborators(null)
+ form.setFieldValue(field.name, undefined, true)
}
}
return (
- {t('send.label_num_collaborators', { numCollaborators })}
+
+ {t('send.label_num_collaborators', { numCollaborators: field.value })}
+
{t('send.description_num_collaborators')}
{defaultCollaboratorsSelection.map((number) => {
- const isSelected = !usesCustomNumCollaborators && numCollaborators === number
+ const isSelected = !usesCustomNumCollaborators && field.value === number
return (
{
- setUsesCustomNumCollaborators(false)
- setNumCollaborators(number)
+ validateAndSetCustomNumCollaborators(String(number))
}}
disabled={disabled}
>
@@ -66,32 +75,27 @@ const CollaboratorsSelector = ({
{
- setUsesCustomNumCollaborators(true)
+ setCustomNumCollaboratorsInput(e.target.value)
validateAndSetCustomNumCollaborators(e.target.value)
}}
- onClick={(e) => {
- // @ts-ignore - FIXME: "Property 'value' does not exist on type 'EventTarget'"
- if (e.target.value !== '') {
- setUsesCustomNumCollaborators(true)
- // @ts-ignore - FIXME: "Property 'value' does not exist on type 'EventTarget'"
- validateAndSetCustomNumCollaborators(e.target.value)
+ onClick={(e: any) => {
+ const val = e.target?.value
+ if (val !== undefined && val !== '') {
+ setCustomNumCollaboratorsInput(val)
+ validateAndSetCustomNumCollaborators(val)
}
}}
disabled={disabled}
/>
- {usesCustomNumCollaborators && (
-
- {t('send.error_invalid_num_collaborators', { minNumCollaborators, maxNumCollaborators: 99 })}
-
- )}
+ {form.errors[field.name]}
)
diff --git a/src/components/Send/DestinationInputField.module.css b/src/components/Send/DestinationInputField.module.css
new file mode 100644
index 000000000..419e00af4
--- /dev/null
+++ b/src/components/Send/DestinationInputField.module.css
@@ -0,0 +1,9 @@
+.inputLoader {
+ height: 3.5rem;
+ border-radius: 0.25rem;
+}
+
+.input {
+ height: 3.5rem;
+ width: 100%;
+}
diff --git a/src/components/Send/DestinationInputField.tsx b/src/components/Send/DestinationInputField.tsx
new file mode 100644
index 000000000..c83dc69f4
--- /dev/null
+++ b/src/components/Send/DestinationInputField.tsx
@@ -0,0 +1,166 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import * as rb from 'react-bootstrap'
+import classNames from 'classnames'
+import { useField, useFormikContext } from 'formik'
+import * as Api from '../../libs/JmWalletApi'
+import Sprite from '../Sprite'
+import { jarName } from '../jars/Jar'
+import JarSelectorModal from '../JarSelectorModal'
+
+import { WalletInfo } from '../../context/WalletContext'
+import { noop } from '../../utils'
+import styles from './DestinationInputField.module.css'
+
+export type DestinationValue = {
+ value: Api.BitcoinAddress | null
+ fromJar: JarIndex | null
+}
+
+export type DestinationInputFieldProps = {
+ name: string
+ label: string
+ className?: string
+ walletInfo?: WalletInfo
+ sourceJarIndex?: JarIndex
+ loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise
+ isLoading: boolean
+ disabled?: boolean
+}
+
+export const DestinationInputField = ({
+ name,
+ label,
+ className,
+ walletInfo,
+ sourceJarIndex,
+ loadNewWalletAddress,
+ isLoading,
+ disabled = false,
+}: DestinationInputFieldProps) => {
+ const { t } = useTranslation()
+ const [field] = useField(name)
+ const form = useFormikContext()
+
+ const [destinationJarPickerShown, setDestinationJarPickerShown] = useState(false)
+
+ return (
+ <>
+ {!isLoading && walletInfo && (
+ <>
+ setDestinationJarPickerShown(false)}
+ onConfirm={(selectedJar) => {
+ const abortCtrl = new AbortController()
+
+ return loadNewWalletAddress({
+ signal: abortCtrl.signal,
+ jarIndex: selectedJar,
+ })
+ .then((address) => {
+ if (abortCtrl.signal.aborted) return
+ form.setFieldValue(
+ field.name,
+ {
+ value: address,
+ fromJar: selectedJar,
+ },
+ true,
+ )
+
+ setDestinationJarPickerShown(false)
+ })
+ .catch((err) => {
+ if (abortCtrl.signal.aborted) return
+ setDestinationJarPickerShown(false)
+ })
+ }}
+ />
+ >
+ )}
+
+ {label}
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {field.value.fromJar !== null ? (
+
+
+ {
+ form.setFieldValue(field.name, form.initialValues[field.name], true)
+ }}
+ disabled={disabled}
+ >
+
+
+
+
+
+ ) : (
+
+
+ {
+ const value = e.target.value
+ form.setFieldValue(
+ field.name,
+ {
+ value: value === '' ? null : value,
+ fromJar: null,
+ },
+ true,
+ )
+ }}
+ isInvalid={form.touched[field.name] && !!form.errors[field.name]}
+ disabled={disabled}
+ />
+ setDestinationJarPickerShown(true)}
+ disabled={disabled || !walletInfo}
+ >
+
+
+
+
+ {form.errors[field.name]}
+
+
+ )}
+ >
+ )}
+
+ >
+ )
+}
diff --git a/src/components/Send/Send.module.css b/src/components/Send/Send.module.css
deleted file mode 100644
index e4539b2f1..000000000
--- a/src/components/Send/Send.module.css
+++ /dev/null
@@ -1,158 +0,0 @@
-.input {
- height: 3.5rem;
- width: 100%;
-}
-
-.jarInput {
- padding-right: 5rem;
-}
-
-/* Chrome, Safari, Edge, Opera */
-input::-webkit-outer-spin-button,
-input::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
-}
-
-/* Firefox */
-input[type='number'] {
- -moz-appearance: textfield;
-}
-
-.buttonJarSelector {
- position: absolute;
- top: 50%;
- right: 0.75rem;
- transform: translate(0%, -50%);
- padding: 0.2rem 0.5rem !important;
- border: none !important;
-}
-
-:root[data-theme='dark'] .buttonJarSelector {
- color: var(--bs-gray-800) !important;
-}
-:root[data-theme='dark'] .buttonJarSelector:hover {
- color: var(--bs-white) !important;
-}
-
-.buttonSweep {
- width: 6rem;
- height: 2rem;
- position: absolute;
- top: 50%;
- right: 0.75rem;
- transform: translate(0%, -50%);
- padding: 0.2rem 0.5rem !important;
-}
-
-:global .position-relative .form-control.is-invalid + button {
- right: 2.1875rem !important;
-}
-
-:root[data-theme='dark'] .buttonSweep {
- color: var(--bs-gray-800) !important;
-}
-:root[data-theme='dark'] .buttonSweep:hover {
- color: var(--bs-white) !important;
-}
-
-.buttonSweepItem {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.2rem;
- padding-right: 0.2rem;
- font-size: 0.9rem;
-}
-
-.sweepBreakdown {
- font-size: 0.8rem;
-}
-
-.sweepBreakdownTable {
- color: var(--bs-black);
-}
-
-:root[data-theme='dark'] .sweepBreakdownTable {
- color: var(--bs-white);
-}
-
-.sweepBreakdownTable .balanceCol {
- text-align: right;
-}
-
-.accordionButton {
- background-color: transparent;
- color: var(--bs-black);
- font-size: 0.8rem;
- height: 1rem;
- padding: 0;
- box-shadow: none;
-}
-
-.sweepBreakdownAnchor {
- font-size: 0.8rem;
- color: var(--bs-black);
-}
-
-:root[data-theme='dark'] .sweepBreakdownAnchor {
- color: var(--bs-white) !important;
-}
-
-:root[data-theme='dark'] .sweepBreakdownParagraph {
- color: var(--bs-white) !important;
-}
-
-.inputLoader {
- height: 3.5rem;
- border-radius: 0.25rem;
-}
-
-.serviceRunning .sendForm,
-.serviceRunning .collaboratorsSelector,
-.serviceRunning .sendButton {
- filter: blur(2px);
-}
-
-.accordionButton {
- border: none;
- width: 100%;
- background-color: transparent;
- color: var(--bs-black);
- display: flex;
- justify-content: flex-end;
- font-size: 0.8rem;
- height: 1rem;
- padding: 0;
- box-shadow: none;
-}
-
-.accordionButton:hover {
- text-decoration: underline;
-}
-
-.accordionButton::after {
- width: 0rem;
- height: 0rem;
- background-image: none;
-}
-
-:root[data-theme='dark'] .accordionButton {
- color: var(--bs-white);
-}
-
-.sourceJarsContainer {
- display: flex;
- flex-wrap: wrap;
- flex-direction: row;
- justify-content: center;
- align-items: flex-start;
- gap: 2rem;
- color: var(--bs-body-color);
- margin-bottom: 1.5rem;
-}
-
-.sourceJarsPlaceholder {
- width: 100%;
- height: 8rem;
-}
diff --git a/src/components/Send/SendForm.module.css b/src/components/Send/SendForm.module.css
new file mode 100644
index 000000000..e9cc4d501
--- /dev/null
+++ b/src/components/Send/SendForm.module.css
@@ -0,0 +1,3 @@
+.blurred {
+ filter: blur(2px);
+}
diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx
new file mode 100644
index 000000000..0941999f4
--- /dev/null
+++ b/src/components/Send/SendForm.tsx
@@ -0,0 +1,412 @@
+import { useState, useMemo } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import { Formik, FormikErrors, FormikProps } from 'formik'
+import * as rb from 'react-bootstrap'
+import * as Api from '../../libs/JmWalletApi'
+import ToggleSwitch from '../ToggleSwitch'
+import Sprite from '../Sprite'
+import { SourceJarSelector } from './SourceJarSelector'
+import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert'
+import { DestinationInputField, DestinationValue } from './DestinationInputField'
+import { AmountInputField, AmountValue } from './AmountInputField'
+import CollaboratorsSelector from './CollaboratorsSelector'
+import { SweepBreakdown } from './SweepBreakdown'
+import FeeBreakdown from './FeeBreakdown'
+import Accordion from '../Accordion'
+import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal'
+import { useEstimatedMaxCollaboratorFee, FeeValues } from '../../hooks/Fees'
+import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements'
+import { formatSats } from '../../utils'
+import {
+ MAX_NUM_COLLABORATORS,
+ isValidAddress,
+ isValidAmount,
+ isValidJarIndex,
+ isValidNumCollaborators,
+} from './helpers'
+import { AccountBalanceSummary } from '../../context/BalanceSummary'
+import { WalletInfo } from '../../context/WalletContext'
+import styles from './SendForm.module.css'
+
+type CollaborativeTransactionOptionsProps = {
+ selectedAmount?: AmountValue
+ selectedNumCollaborators?: number
+ sourceJarBalance?: AccountBalanceSummary
+ isLoading: boolean
+ disabled?: boolean
+ minNumCollaborators: number
+ numCollaborators: number | null
+ setNumCollaborators: (val: number | null) => void
+ feeConfigValues?: FeeValues
+ reloadFeeConfigValues: () => void
+}
+
+function CollaborativeTransactionOptions({
+ selectedAmount,
+ selectedNumCollaborators,
+ sourceJarBalance,
+ isLoading,
+ disabled,
+ minNumCollaborators,
+ feeConfigValues,
+ reloadFeeConfigValues,
+}: CollaborativeTransactionOptionsProps) {
+ const { t } = useTranslation()
+
+ const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState()
+ const [showFeeConfigModal, setShowFeeConfigModal] = useState(false)
+
+ const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({
+ feeConfigValues,
+ amount:
+ selectedAmount?.isSweep && sourceJarBalance
+ ? sourceJarBalance.calculatedAvailableBalanceInSats
+ : selectedAmount?.value ?? null,
+ numCollaborators: selectedNumCollaborators ?? null,
+ isCoinjoin: true,
+ })
+
+ return (
+ <>
+
+
+
+
+ {t('send.fee_breakdown.title', {
+ maxCollaboratorFee: estimatedMaxCollaboratorFee
+ ? `≤${formatSats(estimatedMaxCollaboratorFee)} sats`
+ : '...',
+ })}
+
+
+ {
+ setActiveFeeConfigModalSection('cj_fee')
+ setShowFeeConfigModal(true)
+ }}
+ className="text-decoration-underline link-secondary"
+ />
+ ),
+ }}
+ />
+
+
+
+ {
+ setActiveFeeConfigModalSection('tx_fee')
+ setShowFeeConfigModal(true)
+ }}
+ className="text-decoration-underline link-secondary"
+ />
+ ),
+ }}
+ />
+
+
+ {sourceJarBalance && (
+ {
+ setActiveFeeConfigModalSection('cj_fee')
+ setShowFeeConfigModal(true)
+ }}
+ />
+ )}
+
+ {showFeeConfigModal && (
+ reloadFeeConfigValues()}
+ onHide={() => setShowFeeConfigModal(false)}
+ defaultActiveSectionKey={activeFeeConfigModalSection}
+ />
+ )}
+
+ >
+ )
+}
+
+type SubmitButtonProps = {
+ isLoading: boolean
+ isSubmitting: boolean
+ isCoinJoin: boolean
+ isPreconditionFulfilled: boolean
+ disabled?: boolean
+}
+
+function SubmitButton({ isLoading, isSubmitting, isCoinJoin, isPreconditionFulfilled, disabled }: SubmitButtonProps) {
+ const { t } = useTranslation()
+
+ const submitButtonOptions = useMemo(() => {
+ if (isSubmitting) {
+ return {
+ variant: 'dark',
+ element: (
+ <>
+
+ {t('send.text_sending')}
+ >
+ ),
+ }
+ }
+
+ if (!isLoading) {
+ if (!isCoinJoin) {
+ return {
+ variant: 'danger',
+ element: <>{t('send.button_send_without_improved_privacy')}>,
+ }
+ } else if (!isPreconditionFulfilled) {
+ return {
+ variant: 'warning',
+ element: <>{t('send.button_send_despite_warning')}>,
+ }
+ }
+ }
+
+ return {
+ variant: 'dark',
+ element: <>{t('send.button_send')}>,
+ }
+ }, [isLoading, isSubmitting, isCoinJoin, isPreconditionFulfilled, t])
+
+ return (
+
+ {submitButtonOptions.element}
+
+ )
+}
+
+export interface SendFormValues {
+ sourceJarIndex?: JarIndex
+ destination?: DestinationValue
+ amount?: AmountValue
+ numCollaborators?: number
+ isCoinJoin: boolean
+}
+
+interface InnerSendFormProps {
+ props: FormikProps
+ className?: string
+ isLoading: boolean
+ walletInfo?: WalletInfo
+ loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise
+ minNumCollaborators: number
+ feeConfigValues?: FeeValues
+ reloadFeeConfigValues: () => void
+ disabled?: boolean
+}
+
+const InnerSendForm = ({
+ props,
+ className,
+ isLoading,
+ walletInfo,
+ loadNewWalletAddress,
+ minNumCollaborators,
+ feeConfigValues,
+ reloadFeeConfigValues,
+ disabled = false,
+}: InnerSendFormProps) => {
+ const { t } = useTranslation()
+
+ const jarBalances = useMemo(() => {
+ if (!walletInfo) return []
+ return Object.values(walletInfo.balanceSummary.accountBalances).sort(
+ (lhs, rhs) => lhs.accountIndex - rhs.accountIndex,
+ )
+ }, [walletInfo])
+
+ const sourceJarUtxos = useMemo(() => {
+ if (!walletInfo || props.values.sourceJarIndex === undefined) return null
+ return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === props.values.sourceJarIndex)
+ }, [walletInfo, props.values.sourceJarIndex])
+
+ const sourceJarCoinjoinPreconditionSummary = useMemo(() => {
+ if (sourceJarUtxos === null) return null
+ return buildCoinjoinRequirementSummary(sourceJarUtxos)
+ }, [sourceJarUtxos])
+
+ const sourceJarBalance =
+ props.values.sourceJarIndex !== undefined ? jarBalances[props.values.sourceJarIndex] : undefined
+
+ const showCoinjoinPreconditionViolationAlert =
+ !isLoading && !disabled && props.values.isCoinJoin && sourceJarCoinjoinPreconditionSummary?.isFulfilled === false
+
+ return (
+ <>
+
+
+ {showCoinjoinPreconditionViolationAlert && (
+
+
+
+ )}
+
+
+
+
+
+ {props.values.amount?.isSweep && sourceJarBalance && (
+
+
+
+ )}
+
+
+
+ props.setFieldValue('isCoinJoin', isToggled, true)}
+ disabled={disabled || isLoading}
+ />
+
+
+ {/* direct-send options: empty on purpose */}
+
+
+ props.setFieldValue('numCollaborators', val, true)}
+ feeConfigValues={feeConfigValues}
+ reloadFeeConfigValues={reloadFeeConfigValues}
+ />
+
+
+
+
+
+ >
+ )
+}
+
+type SendFormProps = Omit & {
+ initialValues: SendFormValues
+ onSubmit: (values: SendFormValues) => Promise
+ formRef?: React.Ref>
+ blurred?: boolean
+}
+
+export const SendForm = ({
+ initialValues,
+ onSubmit,
+ formRef,
+ blurred = false,
+ walletInfo,
+ minNumCollaborators,
+ ...innerProps
+}: SendFormProps) => {
+ const { t } = useTranslation()
+
+ const validate = (values: SendFormValues) => {
+ const errors = {} as FormikErrors
+ /** source jar */
+ if (!isValidJarIndex(values.sourceJarIndex ?? -1)) {
+ errors.sourceJarIndex = t('send.feedback_invalid_source_jar')
+ }
+ /** source jar - end */
+
+ /** destination address */
+ if (!isValidAddress(values.destination?.value || null)) {
+ errors.destination = t('send.feedback_invalid_destination_address')
+ }
+ if (!!values.destination?.value && walletInfo?.addressSummary[values.destination.value]) {
+ if (walletInfo.addressSummary[values.destination.value].status !== 'new') {
+ errors.destination = t('send.feedback_reused_address')
+ }
+ }
+ /** destination address - end */
+
+ /** amount */
+ if (!isValidAmount(values.amount?.value ?? null, values.amount?.isSweep || false)) {
+ errors.amount = t('send.feedback_invalid_amount')
+ }
+ /** amount - end */
+
+ /** collaborators */
+ if (values.isCoinJoin && !isValidNumCollaborators(values.numCollaborators ?? null, minNumCollaborators)) {
+ errors.numCollaborators = t('send.error_invalid_num_collaborators', {
+ minNumCollaborators,
+ maxNumCollaborators: MAX_NUM_COLLABORATORS,
+ })
+ }
+ /** collaborators - end */
+
+ return errors
+ }
+
+ return (
+
+ {(props) => {
+ return (
+
+ )
+ }}
+
+ )
+}
diff --git a/src/components/Send/SourceJarSelector.module.css b/src/components/Send/SourceJarSelector.module.css
new file mode 100644
index 000000000..c715f24ca
--- /dev/null
+++ b/src/components/Send/SourceJarSelector.module.css
@@ -0,0 +1,15 @@
+.sourceJarsContainer {
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 2rem;
+ color: var(--bs-body-color);
+ margin-bottom: 1.5rem;
+}
+
+.sourceJarsPlaceholder {
+ width: 100%;
+ height: 8rem;
+}
diff --git a/src/components/Send/SourceJarSelector.tsx b/src/components/Send/SourceJarSelector.tsx
new file mode 100644
index 000000000..3456797ef
--- /dev/null
+++ b/src/components/Send/SourceJarSelector.tsx
@@ -0,0 +1,80 @@
+import { useMemo } from 'react'
+import { useField, useFormikContext } from 'formik'
+import * as rb from 'react-bootstrap'
+import { jarFillLevel, SelectableJar } from '../jars/Jar'
+import { noop } from '../../utils'
+import { WalletInfo } from '../../context/WalletContext'
+import styles from './SourceJarSelector.module.css'
+
+export type SourceJarSelectorProps = {
+ name: string
+ label: string
+ className?: string
+ variant: 'default' | 'warning'
+ walletInfo?: WalletInfo
+ isLoading: boolean
+ disabled?: boolean
+}
+
+export const SourceJarSelector = ({
+ name,
+ label,
+ walletInfo,
+ variant,
+ isLoading,
+ disabled = false,
+}: SourceJarSelectorProps) => {
+ const [field] = useField(name)
+ const form = useFormikContext()
+
+ const jarBalances = useMemo(() => {
+ if (!walletInfo) return []
+ return Object.values(walletInfo.balanceSummary.accountBalances).sort(
+ (lhs, rhs) => lhs.accountIndex - rhs.accountIndex,
+ )
+ }, [walletInfo])
+
+ return (
+ <>
+
+ {label}
+ {!walletInfo || jarBalances.length === 0 ? (
+
+
+
+ ) : (
+
+ {jarBalances.map((it) => (
+ 0}
+ isSelected={it.accountIndex === field.value}
+ fillLevel={jarFillLevel(
+ it.calculatedTotalBalanceInSats,
+ walletInfo.balanceSummary.calculatedTotalBalanceInSats,
+ )}
+ variant={it.accountIndex === field.value ? variant : undefined}
+ onClick={(jarIndex) => form.setFieldValue(field.name, jarIndex, true)}
+ />
+ ))}
+
+ )}
+
+
+ {form.errors[field.name]}
+
+ >
+ )
+}
diff --git a/src/components/Send/SweepBreakdown.module.css b/src/components/Send/SweepBreakdown.module.css
new file mode 100644
index 000000000..3675891d0
--- /dev/null
+++ b/src/components/Send/SweepBreakdown.module.css
@@ -0,0 +1,55 @@
+.sweepBreakdown {
+ font-size: 0.8rem;
+}
+
+.sweepBreakdownTable {
+ color: var(--bs-dark);
+}
+
+:root[data-theme='dark'] .sweepBreakdownTable {
+ color: var(--bs-light);
+}
+
+.sweepBreakdownTable .balanceCol {
+ text-align: right;
+}
+
+.sweepBreakdownAnchor {
+ font-size: 0.8rem;
+ color: var(--bs-dark);
+}
+
+:root[data-theme='dark'] .sweepBreakdownAnchor {
+ color: var(--bs-light) !important;
+}
+
+:root[data-theme='dark'] .sweepBreakdownParagraph {
+ color: var(--bs-light) !important;
+}
+
+.accordionButton {
+ background-color: transparent;
+ color: var(--bs-dark);
+ display: flex;
+ justify-content: flex-end;
+ font-size: 0.8rem;
+ height: 1rem;
+ padding: 0;
+ box-shadow: none;
+ border: none;
+ width: 100%;
+}
+
+.accordionButton:hover {
+ text-decoration: underline;
+}
+
+.accordionButton::after {
+ width: 0rem;
+ height: 0rem;
+ background-image: none;
+}
+
+:root[data-theme='dark'] .accordionButton {
+ color: var(--bs-light);
+}
diff --git a/src/components/Send/SweepBreakdown.tsx b/src/components/Send/SweepBreakdown.tsx
new file mode 100644
index 000000000..0ade15161
--- /dev/null
+++ b/src/components/Send/SweepBreakdown.tsx
@@ -0,0 +1,107 @@
+import { Trans, useTranslation } from 'react-i18next'
+import * as rb from 'react-bootstrap'
+import { AccountBalanceSummary } from '../../context/BalanceSummary'
+import Balance from '../Balance'
+import { SATS } from '../../utils'
+
+import styles from './SweepBreakdown.module.css'
+
+type SweepAccordionToggleProps = {
+ eventKey: string
+}
+function SweepAccordionToggle({ eventKey }: SweepAccordionToggleProps) {
+ const { t } = useTranslation()
+ return (
+
+ )
+}
+
+type SweepBreakdownProps = {
+ jarBalance: AccountBalanceSummary
+}
+export function SweepBreakdown({ jarBalance }: SweepBreakdownProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+
+
+
+ {t('send.sweep_amount_breakdown_total_balance')} |
+
+
+ |
+
+
+ {t('send.sweep_amount_breakdown_frozen_balance')} |
+
+
+ |
+
+
+ {t('send.sweep_amount_breakdown_estimated_amount')} |
+
+
+ |
+
+
+
+
+
+ A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that have
+ been
+
+ frozen
+
+ or
+
+ time-locked
+
+ . Mining fees and collaborator fees will be deducted from the amount so as to leave zero change. The
+ exact transaction amount can only be calculated by JoinMarket at the point when the transaction is made.
+ Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the
+
+ JoinMarket documentation
+
+ for more details.
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/Send/helpers.ts b/src/components/Send/helpers.ts
index 501593684..b284c2f3d 100644
--- a/src/components/Send/helpers.ts
+++ b/src/components/Send/helpers.ts
@@ -1,5 +1,7 @@
import { isValidNumber } from '../../utils'
+export const MAX_NUM_COLLABORATORS = 99
+
export const initialNumCollaborators = (minValue: number) => {
if (minValue > 8) {
return minValue + pseudoRandomNumber(0, 2)
@@ -25,5 +27,10 @@ export const isValidAmount = (candidate: number | null, isSweep: boolean) => {
}
export const isValidNumCollaborators = (candidate: number | null, minNumCollaborators: number) => {
- return candidate !== null && isValidNumber(candidate) && candidate >= minNumCollaborators && candidate <= 99
+ return (
+ candidate !== null &&
+ isValidNumber(candidate) &&
+ candidate >= minNumCollaborators &&
+ candidate <= MAX_NUM_COLLABORATORS
+ )
}
diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx
index ad71aa1f5..9dedc9f4c 100644
--- a/src/components/Send/index.tsx
+++ b/src/components/Send/index.tsx
@@ -1,60 +1,81 @@
-import { useEffect, useState, useMemo, useRef, FormEventHandler } from 'react'
+import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { Link } from 'react-router-dom'
-import { Trans, useTranslation } from 'react-i18next'
+import { FormikProps } from 'formik'
+import { useTranslation } from 'react-i18next'
import * as rb from 'react-bootstrap'
-import classNames from 'classnames'
import * as Api from '../../libs/JmWalletApi'
import PageTitle from '../PageTitle'
-import ToggleSwitch from '../ToggleSwitch'
import Sprite from '../Sprite'
-import Balance from '../Balance'
import { ConfirmModal } from '../Modal'
-import { jarFillLevel, jarName, SelectableJar } from '../jars/Jar'
-import JarSelectorModal from '../JarSelectorModal'
import { PaymentConfirmModal } from '../PaymentConfirmModal'
-import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert'
-import CollaboratorsSelector from './CollaboratorsSelector'
-import Accordion from '../Accordion'
import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal'
-import { useFeeConfigValues, useEstimatedMaxCollaboratorFee } from '../../hooks/Fees'
-
+import { useFeeConfigValues } from '../../hooks/Fees'
import { useReloadCurrentWalletInfo, useCurrentWalletInfo, CurrentWallet } from '../../context/WalletContext'
import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoContext'
import { useLoadConfigValue } from '../../context/ServiceConfigContext'
-import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements'
-
import { routes } from '../../constants/routes'
import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config'
-import { SATS, formatSats, isValidNumber, scrollToTop } from '../../utils'
-
-import {
- initialNumCollaborators,
- isValidAddress,
- isValidAmount,
- isValidJarIndex,
- isValidNumCollaborators,
-} from './helpers'
-import styles from './Send.module.css'
-import FeeBreakdown from './FeeBreakdown'
-
-const IS_COINJOIN_DEFAULT_VAL = true
+import { scrollToTop } from '../../utils'
+import { initialNumCollaborators } from './helpers'
+import { SendForm, SendFormValues } from './SendForm'
const INITIAL_DESTINATION = null
const INITIAL_SOURCE_JAR_INDEX = null
const INITIAL_AMOUNT = null
+const INITIAL_IS_COINJOIN = true
-type SweepAccordionToggleProps = {
- eventKey: string
+type MaxFeeConfigMissingAlertProps = {
+ onSuccess: () => void
}
-function SweepAccordionToggle({ eventKey }: SweepAccordionToggleProps) {
+
+function MaxFeeConfigMissingAlert({ onSuccess }: MaxFeeConfigMissingAlertProps) {
const { t } = useTranslation()
+
+ const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState()
+ const [showFeeConfigModal, setShowFeeConfigModal] = useState(false)
+
return (
-
+ <>
+ {showFeeConfigModal && (
+ setShowFeeConfigModal(false)}
+ defaultActiveSectionKey={activeFeeConfigModalSection}
+ />
+ )}
+
+ {t('send.taker_error_message_max_fees_config_missing')}
+
+ {
+ setActiveFeeConfigModalSection('cj_fee')
+ setShowFeeConfigModal(true)
+ }}
+ >
+ {t('settings.show_fee_config')}
+
+
+ >
)
}
+const createInitialValues = (minNumCollaborators: number) => {
+ return {
+ sourceJarIndex: INITIAL_SOURCE_JAR_INDEX ?? undefined,
+ destination: {
+ value: INITIAL_DESTINATION,
+ fromJar: null,
+ },
+ amount: {
+ value: INITIAL_AMOUNT,
+ isSweep: false,
+ },
+ isCoinJoin: INITIAL_IS_COINJOIN,
+ numCollaborators: initialNumCollaborators(minNumCollaborators),
+ }
+}
+
type SendProps = {
wallet: CurrentWallet
}
@@ -73,12 +94,7 @@ export default function Send({ wallet }: SendProps) {
const [alert, setAlert] = useState()
const [isSending, setIsSending] = useState(false)
- const [isCoinjoin, setIsCoinjoin] = useState(IS_COINJOIN_DEFAULT_VAL)
const [minNumCollaborators, setMinNumCollaborators] = useState(JM_MINIMUM_MAKERS_DEFAULT)
- const [isSweep, setIsSweep] = useState(false)
- const [destinationJarPickerShown, setDestinationJarPickerShown] = useState(false)
- const [destinationJar, setDestinationJar] = useState(null)
- const [destinationIsReusedAddress, setDestinationIsReusedAddress] = useState(false)
const [feeConfigValues, reloadFeeConfigValues] = useFeeConfigValues()
const maxFeesConfigMissing = useMemo(
@@ -86,8 +102,6 @@ export default function Send({ wallet }: SendProps) {
feeConfigValues && (feeConfigValues.max_cj_fee_abs === undefined || feeConfigValues.max_cj_fee_rel === undefined),
[feeConfigValues],
)
- const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState()
- const [showFeeConfigModal, setShowFeeConfigModal] = useState(false)
const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState()
@@ -107,12 +121,6 @@ export default function Send({ wallet }: SendProps) {
[walletInfo, isInitializing, waitForUtxosToBeSpent],
)
- const [destination, setDestination] = useState(INITIAL_DESTINATION)
- const [sourceJarIndex, setSourceJarIndex] = useState(INITIAL_SOURCE_JAR_INDEX)
- const [amount, setAmount] = useState(INITIAL_AMOUNT)
- // see https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/USAGE.md#try-out-a-coinjoin-using-sendpaymentpy
- const [numCollaborators, setNumCollaborators] = useState(initialNumCollaborators(minNumCollaborators))
-
const sortedAccountBalances = useMemo(() => {
if (!walletInfo) return []
return Object.values(walletInfo.balanceSummary.accountBalances).sort(
@@ -120,72 +128,33 @@ export default function Send({ wallet }: SendProps) {
)
}, [walletInfo])
- const accountBalance = useMemo(() => {
- if (sourceJarIndex === null) return null
- return sortedAccountBalances[sourceJarIndex]
- }, [sortedAccountBalances, sourceJarIndex])
-
- const sourceJarUtxos = useMemo(() => {
- if (!walletInfo || sourceJarIndex === null) return null
- return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === sourceJarIndex)
- }, [walletInfo, sourceJarIndex])
+ const [showConfirmAbortModal, setShowConfirmAbortModal] = useState(false)
+ const [showConfirmSendModal, setShowConfirmSendModal] = useState()
- const sourceJarCoinjoinPreconditionSummary = useMemo(() => {
- if (sourceJarUtxos === null) return null
- return buildCoinjoinRequirementSummary(sourceJarUtxos)
- }, [sourceJarUtxos])
+ const initialValues = useMemo(() => createInitialValues(minNumCollaborators), [minNumCollaborators])
- const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({
- feeConfigValues,
- amount: isSweep && accountBalance ? accountBalance.calculatedAvailableBalanceInSats : amount,
- numCollaborators,
- isCoinjoin,
- })
+ const formRef = useRef>(null)
- const [showConfirmAbortModal, setShowConfirmAbortModal] = useState(false)
- const [showConfirmSendModal, setShowConfirmSendModal] = useState(false)
- const submitButtonRef = useRef(null)
- const submitButtonOptions = useMemo(() => {
- if (!isLoading) {
- if (!isCoinjoin) {
- return {
- variant: 'danger',
- text: t('send.button_send_without_improved_privacy'),
- }
- } else if (sourceJarCoinjoinPreconditionSummary?.isFulfilled === false) {
- return {
- variant: 'warning',
- text: t('send.button_send_despite_warning'),
- }
- }
- }
-
- return {
- variant: 'dark',
- text: t('send.button_send'),
- }
- }, [isLoading, isCoinjoin, sourceJarCoinjoinPreconditionSummary, t])
-
- const formIsValid = useMemo(() => {
- return (
- isValidAddress(destination) &&
- !destinationIsReusedAddress &&
- isValidJarIndex(sourceJarIndex ?? -1) &&
- isValidAmount(amount, isSweep) &&
- (isCoinjoin ? isValidNumCollaborators(numCollaborators, minNumCollaborators) : true)
- )
- }, [
- destination,
- sourceJarIndex,
- amount,
- numCollaborators,
- minNumCollaborators,
- isCoinjoin,
- isSweep,
- destinationIsReusedAddress,
- ])
-
- useEffect(() => setAmount(isSweep ? 0 : null), [isSweep])
+ const loadNewWalletAddress = useCallback(
+ (props: { signal: AbortSignal; jarIndex: JarIndex }): Promise => {
+ return Api.getAddressNew({
+ ...wallet,
+ signal: props.signal,
+ mixdepth: props.jarIndex,
+ })
+ .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed'))))
+ .then((data) => {
+ return data.address
+ })
+ .catch((err) => {
+ if (!props.signal.aborted) {
+ setAlert({ variant: 'danger', message: err.message })
+ }
+ throw err
+ })
+ },
+ [wallet, setAlert, t],
+ )
// This callback is responsible for updating `waitForUtxosToBeSpent` while
// the wallet is synchronizing. The wallet needs some time after a tx is sent
@@ -277,7 +246,6 @@ export default function Send({ wallet }: SendProps) {
if (abortCtrl.signal.aborted) return
setMinNumCollaborators(minimumMakers)
- setNumCollaborators(initialNumCollaborators(minimumMakers))
})
.catch((err) => {
if (abortCtrl.signal.aborted) return
@@ -293,20 +261,6 @@ export default function Send({ wallet }: SendProps) {
[isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, t],
)
- useEffect(
- function checkReusedDestinationAddressHook() {
- if (destination !== null && walletInfo?.addressSummary[destination]) {
- if (walletInfo.addressSummary[destination].status !== 'new') {
- setDestinationIsReusedAddress(true)
- return
- }
- }
-
- setDestinationIsReusedAddress(false)
- },
- [walletInfo, destination],
- )
-
const sendPayment = async (sourceJarIndex: JarIndex, destination: Api.BitcoinAddress, amountSats: Api.AmountSats) => {
setAlert(undefined)
setPaymentSuccessfulInfoAlert(undefined)
@@ -420,551 +374,163 @@ export default function Send({ wallet }: SendProps) {
})
}
- const onSubmit: FormEventHandler = async (e) => {
- e.preventDefault()
-
- if (isLoading || isOperationDisabled) return
+ const onSubmit = async (values: SendFormValues) => {
+ if (isLoading || isOperationDisabled || isSending) return
setPaymentSuccessfulInfoAlert(undefined)
- const form = e.currentTarget
- const isValid = formIsValid
+ const isValid =
+ values.amount !== undefined && values.sourceJarIndex !== undefined && values.destination !== undefined
if (isValid) {
- if (isSweep && amount !== 0) {
+ if (values.amount!.isSweep === true && values.amount!.value !== 0) {
console.error('Sanity check failed: Sweep amount mismatch. This should not happen.')
return
}
- if (sourceJarIndex === null || !destination || amount === null || (isCoinjoin && numCollaborators === null)) {
+ if (
+ values.destination!.value === null ||
+ values.amount!.value === null ||
+ (values.isCoinJoin === true && values.numCollaborators === undefined)
+ ) {
console.error('Sanity check failed: Form is invalid and is missing required values. This should not happen.')
return
}
- if (!showConfirmSendModal) {
- setShowConfirmSendModal(true)
+ if (showConfirmSendModal === undefined) {
+ setShowConfirmSendModal(values)
return
}
- setShowConfirmSendModal(false)
+ setShowConfirmSendModal(undefined)
- const success = isCoinjoin
- ? await startCoinjoin(sourceJarIndex, destination, amount, numCollaborators!)
- : await sendPayment(sourceJarIndex, destination, amount)
+ const success = values.isCoinJoin
+ ? await startCoinjoin(
+ values.sourceJarIndex!,
+ values.destination!.value!,
+ values.amount!.value,
+ values.numCollaborators!,
+ )
+ : await sendPayment(values.sourceJarIndex!, values.destination!.value!, values.amount!.value)
if (success) {
- setSourceJarIndex(INITIAL_SOURCE_JAR_INDEX)
- setDestination(INITIAL_DESTINATION)
- setDestinationJar(null)
- setAmount(INITIAL_AMOUNT)
- setNumCollaborators(initialNumCollaborators(minNumCollaborators))
- setIsCoinjoin(IS_COINJOIN_DEFAULT_VAL)
- setIsSweep(false)
-
- form.reset()
+ formRef.current?.resetForm({ values: initialValues })
}
scrollToTop()
}
}
- const amountFieldValue = useMemo(() => {
- if (amount === null || !isValidNumber(amount)) return ''
-
- if (isSweep) {
- if (!accountBalance) return ''
- return `${accountBalance.calculatedAvailableBalanceInSats}`
- }
-
- return amount.toString()
- }, [accountBalance, amount, isSweep])
-
- const frozenOrLockedWarning = useMemo(() => {
- if (!accountBalance || amountFieldValue === '') return <>>
-
- return (
-
-
-
-
-
-
-
-
- {t('send.sweep_amount_breakdown_total_balance')} |
-
-
- |
-
-
- {t('send.sweep_amount_breakdown_frozen_balance')} |
-
-
- |
-
-
- {t('send.sweep_amount_breakdown_estimated_amount')} |
-
-
- |
-
-
-
-
-
- A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that
- have been
-
- frozen
-
- or
-
- time-locked
-
- . Mining fees and collaborator fees will be deducted from the amount so as to leave zero change. The
- exact transaction amount can only be calculated by JoinMarket at the point when the transaction is
- made. Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the
-
- JoinMarket documentation
-
- for more details.
-
-
-
-
-
-
- )
- }, [amountFieldValue, accountBalance, t])
-
return (
- <>
-
-
-
- <>
- {isMakerRunning && (
-
-
-
- {t('send.text_maker_running')}
-
-
-
-
-
-
- )}
- {isCoinjoinInProgress && (
-
-
-
-
-
+
+
+
+ <>
+ {isMakerRunning && (
+
+
+
+ {t('send.text_maker_running')}
+
+
+
+
+
+
+ )}
+ {isCoinjoinInProgress && (
+
+
+
+
-
- {t('send.text_coinjoin_already_running')}
-
-
-
abortCoinjoin()}
- >
-
-
- {t('global.abort')}
-
-
-
- )}
- >
-
- {maxFeesConfigMissing && (
-
- {t('send.taker_error_message_max_fees_config_missing')}
-
- {
- setActiveFeeConfigModalSection('cj_fee')
- setShowFeeConfigModal(true)
- }}
- >
- {t('settings.show_fee_config')}
-
-
- )}
- {alert && (
-
- {alert.message}
-
- )}
- {paymentSuccessfulInfoAlert && (
- <>
-
-
- {paymentSuccessfulInfoAlert.message}
-
- >
- )}
- {!isLoading && walletInfo && (
-
setDestinationJarPickerShown(false)}
- onConfirm={(selectedJar) => {
- const abortCtrl = new AbortController()
- return Api.getAddressNew({
- ...wallet,
- signal: abortCtrl.signal,
- mixdepth: selectedJar,
- })
- .then((res) =>
- res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')),
- )
- .then((data) => {
- if (abortCtrl.signal.aborted) return
- setDestination(data.address)
- setDestinationJar(selectedJar)
- setDestinationJarPickerShown(false)
- })
- .catch((err) => {
- if (abortCtrl.signal.aborted) return
- setAlert({ variant: 'danger', message: err.message })
- setDestinationJarPickerShown(false)
- })
- }}
- />
- )}
-
-
-
- {t('send.label_source_jar')}
- {!walletInfo || sortedAccountBalances.length === 0 ? (
-
-
-
- ) : (
-
- {sortedAccountBalances.map((it) => (
- 0}
- isSelected={it.accountIndex === sourceJarIndex}
- fillLevel={jarFillLevel(
- it.calculatedTotalBalanceInSats,
- walletInfo.balanceSummary.calculatedTotalBalanceInSats,
- )}
- variant={
- it.accountIndex === sourceJarIndex &&
- isCoinjoin &&
- sourceJarCoinjoinPreconditionSummary?.isFulfilled === false
- ? 'warning'
- : undefined
- }
- onClick={(jarIndex) => setSourceJarIndex(jarIndex)}
- />
- ))}
-
- )}
-
-
- {!isLoading &&
- !isOperationDisabled &&
- isCoinjoin &&
- sourceJarCoinjoinPreconditionSummary?.isFulfilled === false && (
-
-
- )}
-
-
- {t('send.label_recipient')}
-
- {isLoading ? (
-
-
-
- ) : (
- <>
-
{
- const value = e.target.value
- setDestination(value === '' ? null : value)
- }}
- isInvalid={(destination !== null && !isValidAddress(destination)) || destinationIsReusedAddress}
- disabled={isOperationDisabled || destinationJar !== null}
- />
- {destinationIsReusedAddress && (
-
- {t('send.feedback_reused_address')}
-
- )}
- {!destinationIsReusedAddress && (
- {
- if (destinationJar !== null) {
- setDestinationJar(null)
- setDestination(INITIAL_DESTINATION)
- } else {
- setDestinationJarPickerShown(true)
- }
- }}
- disabled={isOperationDisabled}
- >
- {destinationJar !== null ? (
-
-
-
- ) : (
-
-
-
- )}
-
- )}
- >
- )}
-
-
-
- {t('send.label_amount')}
-
- {isLoading ? (
-
-
-
- ) : (
- <>
-
setAmount(parseInt(e.target.value, 10))}
- isInvalid={amount !== null && !isValidAmount(amount, isSweep)}
- disabled={isSweep || isOperationDisabled}
- />
- setIsSweep((current) => !current)}
- disabled={isOperationDisabled || sourceJarIndex === null}
- >
-
- {isSweep ? (
- <>{t('send.button_clear_sweep')}>
- ) : (
- <>
-
- {t('send.button_sweep')}
- >
- )}
-
-
- >
- )}
+
+ {t('send.text_coinjoin_already_running')}
+
+
+ abortCoinjoin()}
+ >
+
+
+ {t('global.abort')}
+
+
-
- {t('send.feedback_invalid_amount')}
-
- {isSweep && <>{frozenOrLockedWarning}>}
-
-
-
- setIsCoinjoin(isToggled)}
- disabled={isLoading || isOperationDisabled}
- />
-
-
-
-
-
-
- {t('send.fee_breakdown.title', {
- maxCollaboratorFee: estimatedMaxCollaboratorFee
- ? `≤${formatSats(estimatedMaxCollaboratorFee)} sats`
- : '...',
- })}
-
-
- {
- setActiveFeeConfigModalSection('cj_fee')
- setShowFeeConfigModal(true)
- }}
- className="text-decoration-underline link-secondary"
- />
- ),
- }}
- />
-
-
-
- {
- setActiveFeeConfigModalSection('tx_fee')
- setShowFeeConfigModal(true)
- }}
- className="text-decoration-underline link-secondary"
- />
- ),
- }}
- />
-
-
- {accountBalance && (
- {
- setActiveFeeConfigModalSection('cj_fee')
- setShowFeeConfigModal(true)
- }}
- />
- )}
-
- {showFeeConfigModal && (
- reloadFeeConfigValues()}
- onHide={() => setShowFeeConfigModal(false)}
- defaultActiveSectionKey={activeFeeConfigModalSection}
- />
- )}
-
+ )}
+ >
+
+
+ {maxFeesConfigMissing &&
reloadFeeConfigValues()} />}
+
+ {alert && (
+
+ {alert.message}
+
+ )}
+
+ {paymentSuccessfulInfoAlert && (
+ <>
+
+
+
-
-
-
+
+ {paymentSuccessfulInfoAlert.message}
+
+ >
+ )}
+
+
+
+ {showConfirmAbortModal && (
+ setShowConfirmAbortModal(false)}
+ onConfirm={() => abortCoinjoin()}
>
- {isSending ? (
-
-
- {t('send.text_sending')}
-
- ) : (
- <>{submitButtonOptions.text}>
- )}
-
- {showConfirmAbortModal && (
-
setShowConfirmAbortModal(false)}
- onConfirm={() => abortCoinjoin()}
- >
- {t('send.confirm_abort_modal.text_body')}
-
- )}
- {showConfirmSendModal && (
-
setShowConfirmSendModal(false)}
- onConfirm={() => {
- submitButtonRef.current?.click()
- }}
- data={{
- sourceJarIndex: sourceJarIndex!,
- destination: destination!,
- amount: parseInt(amountFieldValue, 10),
- isSweep,
- isCoinjoin,
- numCollaborators: numCollaborators!,
- feeConfigValues,
- }}
- />
- )}
-
- >
+ {t('send.confirm_abort_modal.text_body')}
+
+ )}
+
+ {showConfirmSendModal && (
+ setShowConfirmSendModal(undefined)}
+ onConfirm={() => {
+ formRef.current?.submitForm()
+ }}
+ data={{
+ sourceJarIndex: showConfirmSendModal.sourceJarIndex,
+ destination: showConfirmSendModal.destination?.value!,
+ amount: showConfirmSendModal.amount!.isSweep
+ ? sortedAccountBalances[showConfirmSendModal.sourceJarIndex!].calculatedAvailableBalanceInSats
+ : showConfirmSendModal.amount!.value!,
+ isSweep: showConfirmSendModal.amount!.isSweep,
+ isCoinjoin: showConfirmSendModal.isCoinJoin,
+ numCollaborators: showConfirmSendModal.numCollaborators!,
+ feeConfigValues,
+ }}
+ />
+ )}
+
)
}
diff --git a/src/components/Wallet.tsx b/src/components/Wallet.tsx
index ebfa1f076..9246026b9 100644
--- a/src/components/Wallet.tsx
+++ b/src/components/Wallet.tsx
@@ -3,12 +3,12 @@ import { Link } from 'react-router-dom'
import { Formik, FormikErrors } from 'formik'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
-import { walletDisplayName } from '../utils'
import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
import Sprite from './Sprite'
import { routes } from '../constants/routes'
-import styles from './Wallet.module.css'
+import { walletDisplayName } from '../utils'
import { WalletFileName } from '../libs/JmWalletApi'
+import styles from './Wallet.module.css'
interface WalletLockFormProps {
walletFileName: WalletFileName
@@ -82,9 +82,8 @@ const WalletUnlockForm = ({ walletFileName, unlockWallet }: WalletUnlockFormProp
}
const onSubmit = useCallback(
- async (values) => {
- const { password } = values
- await unlockWallet(walletFileName, password)
+ async (values: WalletUnlockFormValues) => {
+ await unlockWallet(walletFileName, values.password)
},
[walletFileName, unlockWallet],
)
diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx
index 881ffcda4..50960daab 100644
--- a/src/components/settings/FeeConfigModal.tsx
+++ b/src/components/settings/FeeConfigModal.tsx
@@ -1,37 +1,19 @@
import { forwardRef, useRef, useCallback, useEffect, useState } from 'react'
import * as rb from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
-import { Formik, FormikErrors, FormikProps } from 'formik'
+import { Formik, FormikErrors, FormikProps, Field } from 'formik'
import classNames from 'classnames'
-import { FEE_CONFIG_KEYS, TxFeeValueUnit, toTxFeeValueUnit, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
-import { useUpdateConfigValues } from '../../context/ServiceConfigContext'
-import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils'
import Sprite from '../Sprite'
-import SegmentedTabs from '../SegmentedTabs'
-import styles from './FeeConfigModal.module.css'
+import { TxFeeInputField, validateTxFee } from './TxFeeInputField'
+import { FEE_CONFIG_KEYS, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
+import { useUpdateConfigValues } from '../../context/ServiceConfigContext'
import { isDebugFeatureEnabled } from '../../constants/debugFeatures'
import ToggleSwitch from '../ToggleSwitch'
+import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils'
+import styles from './FeeConfigModal.module.css'
const __dev_allowFeeValuesReset = isDebugFeatureEnabled('allowFeeValuesReset')
-type SatsPerKiloVByte = number
-
-const TX_FEES_BLOCKS_MIN = 1
-const TX_FEES_BLOCKS_MAX = 1_000
-
-/**
- * When the fee target is low, JM sometimes constructs transactions, which are
- * declined from being relayed. In order to mitigate such situations, the
- * minimum fee target (when provided in sats/vbyte) must be higher than
- * 1 sats/vbyte, till the problem is addressed. Once resolved, this
- * can be lowered to 1 sats/vbyte again.
- * See https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1262295463
- * Last checked on 2022-10-06.
- */
-const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte
-// 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb")
-const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000
-const TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN = 1_001 // actual min of `tx_fees` if unit is sats/kilo-vbyte
const TX_FEES_FACTOR_MIN = 0 // 0%
/**
* For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`),
@@ -63,8 +45,8 @@ type FeeFormValues = FeeValues & {
interface FeeConfigFormProps {
initialValues: FeeFormValues
- validate: (values: FeeFormValues, txFeesUnit: TxFeeValueUnit) => FormikErrors
- onSubmit: (values: FeeFormValues, txFeesUnit: TxFeeValueUnit) => void
+ validate: (values: FeeFormValues) => FormikErrors
+ onSubmit: (values: FeeFormValues) => void
defaultActiveSectionKey?: FeeConfigSectionKey
}
@@ -75,14 +57,12 @@ const FeeConfigForm = forwardRef(
) => {
const { t, i18n } = useTranslation()
- const [txFeesUnit, setTxFeesUnit] = useState(toTxFeeValueUnit(initialValues.tx_fees) || 'blocks')
-
return (
validate(values, txFeesUnit)}
- onSubmit={(values) => onSubmit(values, txFeesUnit)}
+ validate={(values) => validate(values)}
+ onSubmit={(values) => onSubmit(values)}
>
{({ handleSubmit, setFieldValue, handleBlur, validateForm, values, touched, errors, isSubmitting }) => (
@@ -203,102 +183,8 @@ const FeeConfigForm = forwardRef(
{t('settings.fees.description_general_fee_settings')}
- {t('settings.fees.label_tx_fees')}
- {txFeesUnit && (
-
- {
- const value = tab.value as TxFeeValueUnit
- setTxFeesUnit(value)
- if (values.tx_fees) {
- if (value === 'sats/kilo-vbyte') {
- setFieldValue('tx_fees', values.tx_fees * 1_000, false)
- } else {
- setFieldValue('tx_fees', Math.round(values.tx_fees / 1_000), false)
- }
- }
- setTimeout(() => validateForm(), 4)
- }}
- initialValue={txFeesUnit}
- disabled={isSubmitting}
- />
-
- )}
-
- {t(
- txFeesUnit === 'sats/kilo-vbyte'
- ? 'settings.fees.description_tx_fees_satspervbyte'
- : 'settings.fees.description_tx_fees_blocks',
- )}
-
-
-
-
- {txFeesUnit === 'sats/kilo-vbyte' ? (
- <>
- / vB
- >
- ) : (
-
- )}
-
-
- {txFeesUnit === 'sats/kilo-vbyte' ? (
- {
- const value = parseFloat(e.target.value)
- setFieldValue('tx_fees', value * 1_000, true)
- }}
- isValid={touched.tx_fees && !errors.tx_fees}
- isInvalid={touched.tx_fees && !!errors.tx_fees}
- min={TX_FEES_SATSPERKILOVBYTE_MIN / 1_000}
- max={TX_FEES_SATSPERKILOVBYTE_MAX / 1_000}
- step={0.001}
- />
- ) : (
- {
- const value = parseInt(e.target.value, 10)
- setFieldValue('tx_fees', value, true)
- }}
- isValid={touched.tx_fees && !errors.tx_fees}
- isInvalid={touched.tx_fees && !!errors.tx_fees}
- min={TX_FEES_BLOCKS_MIN}
- max={TX_FEES_BLOCKS_MAX}
- step={1}
- />
- )}
- {errors.tx_fees}
-
-
+
@@ -361,13 +247,13 @@ export default function FeeConfigModal({
const loadFeeConfigValues = useLoadFeeConfigValues()
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
- const [loadError, setLoadError] = useState(false)
+ const [loadError, setLoadError] = useState()
const [saveErrorMessage, setSaveErrorMessage] = useState()
- const [feeFormValues, setFeeFormValues] = useState(null)
+ const [feeFormValues, setFeeFormValues] = useState()
const formRef = useRef>(null)
useEffect(() => {
- setLoadError(false)
+ setLoadError(undefined)
const abortCtrl = new AbortController()
if (show) {
@@ -379,10 +265,10 @@ export default function FeeConfigModal({
setIsLoading(false)
setFeeFormValues(val)
})
- .catch((e) => {
+ .catch((error) => {
if (abortCtrl.signal.aborted) return
setIsLoading(false)
- setLoadError(true)
+ setLoadError(error)
})
} else {
setSaveErrorMessage(undefined)
@@ -393,60 +279,9 @@ export default function FeeConfigModal({
}
}, [show, loadFeeConfigValues])
- const submit = async (feeValues: FeeValues, txFeesUnit: TxFeeValueUnit) => {
- const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined)
- if (!allValuesPresent) return
-
- let adjustedTxFees = feeValues.tx_fees!
- if (txFeesUnit === 'sats/kilo-vbyte') {
- // There is one special case for value `tx_fees`:
- // Users are allowed to specify the value in "sats/vbyte", but this might
- // be interpreted by JM as "targeted blocks". This adaption makes sure
- // that it is in fact closer to what the user actually expects, albeit it
- // can be surprising that the value is slightly different as specified.
- adjustedTxFees = Math.max(adjustedTxFees, TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN)
- }
-
- const updates = [
- {
- key: FEE_CONFIG_KEYS.tx_fees,
- value: String(adjustedTxFees),
- },
- {
- key: FEE_CONFIG_KEYS.tx_fees_factor,
- value: String(feeValues.tx_fees_factor),
- },
- {
- key: FEE_CONFIG_KEYS.max_cj_fee_abs,
- value: String(feeValues.max_cj_fee_abs),
- },
- {
- key: FEE_CONFIG_KEYS.max_cj_fee_rel,
- value: String(feeValues.max_cj_fee_rel),
- },
- ]
-
- setSaveErrorMessage(undefined)
- setIsSubmitting(true)
- try {
- await updateConfigValues({ updates })
-
- setIsSubmitting(false)
- onSuccess && onSuccess()
- onHide()
- } catch (err: any) {
- setIsSubmitting(false)
- setSaveErrorMessage((_) =>
- t('settings.fees.error_saving_fee_config_failed', {
- reason: err.message || t('global.errors.reason_unknown'),
- }),
- )
- }
- }
-
const validate = useCallback(
- (values: FeeFormValues, txFeesUnit: TxFeeValueUnit) => {
- const errors = {} as FormikErrors
+ (values: FeeFormValues) => {
+ const errors = {} as FormikErrors
if (values.enableValidation === false) {
// do not validate form to enable resetting the values
@@ -465,32 +300,9 @@ export default function FeeConfigModal({
})
}
- if (txFeesUnit === 'sats/kilo-vbyte') {
- if (
- !isValidNumber(values.tx_fees) ||
- values.tx_fees! < TX_FEES_SATSPERKILOVBYTE_MIN ||
- values.tx_fees! > TX_FEES_SATSPERKILOVBYTE_MAX
- ) {
- errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', {
- min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, {
- maximumFractionDigits: Math.log10(1_000),
- }),
- max: (TX_FEES_SATSPERKILOVBYTE_MAX / 1_000).toLocaleString(undefined, {
- maximumFractionDigits: Math.log10(1_000),
- }),
- })
- }
- } else {
- if (
- !isValidNumber(values.tx_fees) ||
- values.tx_fees! < TX_FEES_BLOCKS_MIN ||
- values.tx_fees! > TX_FEES_BLOCKS_MAX
- ) {
- errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_blocks', {
- min: TX_FEES_BLOCKS_MIN.toLocaleString(),
- max: TX_FEES_BLOCKS_MAX.toLocaleString(),
- })
- }
+ const txFeeErrors = validateTxFee(values.tx_fees, t)
+ if (txFeeErrors.value) {
+ errors.tx_fees = txFeeErrors.value
}
if (
@@ -519,6 +331,44 @@ export default function FeeConfigModal({
[t],
)
+ const submit = async (values: FeeFormValues) => {
+ const updates = [
+ {
+ key: FEE_CONFIG_KEYS.tx_fees,
+ value: String(values.tx_fees?.value ?? ''),
+ },
+ {
+ key: FEE_CONFIG_KEYS.tx_fees_factor,
+ value: String(values.tx_fees_factor ?? ''),
+ },
+ {
+ key: FEE_CONFIG_KEYS.max_cj_fee_abs,
+ value: String(values.max_cj_fee_abs ?? ''),
+ },
+ {
+ key: FEE_CONFIG_KEYS.max_cj_fee_rel,
+ value: String(values.max_cj_fee_rel ?? ''),
+ },
+ ]
+
+ setSaveErrorMessage(undefined)
+ setIsSubmitting(true)
+ try {
+ await updateConfigValues({ updates })
+
+ setIsSubmitting(false)
+ onSuccess && onSuccess()
+ onHide()
+ } catch (err: any) {
+ setIsSubmitting(false)
+ setSaveErrorMessage((_) =>
+ t('settings.fees.error_saving_fee_config_failed', {
+ reason: err.message || t('global.errors.reason_unknown'),
+ }),
+ )
+ }
+ }
+
const cancel = useCallback(() => {
onCancel && onCancel()
onHide()
@@ -601,10 +451,10 @@ export default function FeeConfigModal({
variant="outline-dark"
className="position-relative"
onClick={() => {
- formRef.current?.setFieldValue('max_cj_fee_abs', '', false)
- formRef.current?.setFieldValue('max_cj_fee_rel', '', false)
- formRef.current?.setFieldValue('tx_fees', '', false)
- formRef.current?.setFieldValue('tx_fees_factor', '', false)
+ formRef.current?.setFieldValue('max_cj_fee_abs', undefined, false)
+ formRef.current?.setFieldValue('max_cj_fee_rel', undefined, false)
+ formRef.current?.setFieldValue('tx_fees', undefined, false)
+ formRef.current?.setFieldValue('tx_fees_factor', undefined, false)
setTimeout(() => formRef.current?.validateForm(), 4)
}}
disabled={isLoading || isSubmitting}
diff --git a/src/components/settings/TxFeeInputField.tsx b/src/components/settings/TxFeeInputField.tsx
new file mode 100644
index 000000000..344d68ed4
--- /dev/null
+++ b/src/components/settings/TxFeeInputField.tsx
@@ -0,0 +1,196 @@
+import * as rb from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { TFunction } from 'i18next'
+import { FieldProps, FormikErrors } from 'formik'
+import { TxFeeValueUnit, TxFee } from '../../hooks/Fees'
+import { isValidNumber } from '../../utils'
+import Sprite from '../Sprite'
+import SegmentedTabs from '../SegmentedTabs'
+
+type SatsPerKiloVByte = number
+
+const TX_FEES_BLOCKS_MIN = 1
+const TX_FEES_BLOCKS_MAX = 1_000
+
+const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_001 // actual min of `tx_fees` if unit is sats/kilo-vbyte
+// 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb")
+const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000
+
+const adjustTxFees = (val: TxFee) => {
+ if (val.unit === 'sats/kilo-vbyte') {
+ // There is one special case for value `tx_fees`:
+ // Users are allowed to specify the value in "sats/vbyte", but this might
+ // be interpreted by JM as "targeted blocks". This adaption makes sure
+ // that it is in fact closer to what the user actually expects, albeit it
+ // can be surprising that the value is slightly different as specified.
+ return {
+ ...val,
+ value: Math.max(val.value, TX_FEES_SATSPERKILOVBYTE_MIN),
+ }
+ }
+ return val
+}
+
+export const validateTxFee = (val: TxFee | undefined, t: TFunction): FormikErrors => {
+ const errors = {} as FormikErrors
+
+ if (val?.unit === 'sats/kilo-vbyte') {
+ if (
+ !isValidNumber(val.value) ||
+ val.value < TX_FEES_SATSPERKILOVBYTE_MIN ||
+ val.value > TX_FEES_SATSPERKILOVBYTE_MAX
+ ) {
+ errors.value = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', {
+ min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, {
+ maximumFractionDigits: Math.log10(1_000),
+ }),
+ max: (TX_FEES_SATSPERKILOVBYTE_MAX / 1_000).toLocaleString(undefined, {
+ maximumFractionDigits: Math.log10(1_000),
+ }),
+ })
+ }
+ } else {
+ if (!isValidNumber(val?.value) || val?.value! < TX_FEES_BLOCKS_MIN || val?.value! > TX_FEES_BLOCKS_MAX) {
+ errors.value = t('settings.fees.feedback_invalid_tx_fees_blocks', {
+ min: TX_FEES_BLOCKS_MIN.toLocaleString(),
+ max: TX_FEES_BLOCKS_MAX.toLocaleString(),
+ })
+ }
+ }
+
+ return errors
+}
+
+type TxFeeInputFieldProps = FieldProps & {
+ label: string
+}
+
+export const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => {
+ const { t } = useTranslation()
+
+ return (
+ <>
+ {label}
+
+
+ {
+ const unit = tab.value as TxFeeValueUnit
+
+ if (field.value) {
+ if (unit === 'sats/kilo-vbyte') {
+ form.setFieldValue(
+ field.name,
+ adjustTxFees({
+ value: Math.round(field.value.value * 1_000),
+ unit,
+ }),
+ true,
+ )
+ } else {
+ form.setFieldValue(
+ field.name,
+ adjustTxFees({
+ value: Math.round(field.value.value / 1_000),
+ unit,
+ }),
+ true,
+ )
+ }
+ }
+ }}
+ initialValue={field.value?.unit || 'blocks'}
+ disabled={form.isSubmitting}
+ />
+
+
+ {t(
+ field.value?.unit === 'sats/kilo-vbyte'
+ ? 'settings.fees.description_tx_fees_satspervbyte'
+ : 'settings.fees.description_tx_fees_blocks',
+ )}
+
+
+
+
+ {field.value?.unit === 'sats/kilo-vbyte' ? (
+ <>
+ / vB
+ >
+ ) : (
+
+ )}
+
+
+ {field.value?.unit === 'sats/kilo-vbyte' ? (
+ {
+ const value = parseFloat(e.target.value)
+ form.setFieldValue(
+ field.name,
+ adjustTxFees({
+ value: Math.round(value * 1_000),
+ unit: 'sats/kilo-vbyte',
+ }),
+ true,
+ )
+ }}
+ isValid={form.touched[field.name] && !form.errors[field.name]}
+ isInvalid={form.touched[field.name] && !!form.errors[field.name]}
+ min={TX_FEES_SATSPERKILOVBYTE_MIN / 1_000}
+ max={TX_FEES_SATSPERKILOVBYTE_MAX / 1_000}
+ step={0.001}
+ />
+ ) : (
+ {
+ const value = parseInt(e.target.value, 10)
+ form.setFieldValue(
+ field.name,
+ adjustTxFees({
+ value,
+ unit: 'blocks',
+ }),
+ true,
+ )
+ }}
+ isValid={form.touched[field.name] && !form.errors[field.name]}
+ isInvalid={form.touched[field.name] && !!form.errors[field.name]}
+ min={TX_FEES_BLOCKS_MIN}
+ max={TX_FEES_BLOCKS_MAX}
+ step={1}
+ />
+ )}
+ {form.errors[field.name]}
+
+
+ >
+ )
+}
diff --git a/src/constants/features.ts b/src/constants/features.ts
index 236fc6c39..c3fb168d1 100644
--- a/src/constants/features.ts
+++ b/src/constants/features.ts
@@ -3,11 +3,13 @@ import { ServiceInfo } from '../context/ServiceInfoContext'
interface Features {
importWallet: SemVer
rescanChain: SemVer
+ directSendTxFee: SemVer
}
const features: Features = {
importWallet: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1461
rescanChain: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1461
+ directSendTxFee: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1597
}
type Feature = keyof Features
diff --git a/src/hooks/Fees.ts b/src/hooks/Fees.ts
index e4fe61a2d..28d7b3054 100644
--- a/src/hooks/Fees.ts
+++ b/src/hooks/Fees.ts
@@ -4,8 +4,13 @@ import { AmountSats } from '../libs/JmWalletApi'
import { isValidNumber } from '../utils'
export type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte'
+export type TxFeeValue = number
+export type TxFee = {
+ value: TxFeeValue
+ unit: TxFeeValueUnit
+}
-export const toTxFeeValueUnit = (val?: number): TxFeeValueUnit | undefined => {
+export const toTxFeeValueUnit = (val?: TxFeeValue): TxFeeValueUnit | undefined => {
if (val === undefined || !Number.isInteger(val) || val < 1) return undefined
return val <= 1_000 ? 'blocks' : 'sats/kilo-vbyte'
}
@@ -18,7 +23,7 @@ export const FEE_CONFIG_KEYS = {
}
export interface FeeValues {
- tx_fees?: number
+ tx_fees?: TxFee
tx_fees_factor?: number
max_cj_fee_abs?: number
max_cj_fee_rel?: number
@@ -42,7 +47,12 @@ export const useLoadFeeConfigValues = () => {
const parsedMaxFeeRel = parseFloat(policy.max_cj_fee_rel || '')
const feeValues: FeeValues = {
- tx_fees: isValidNumber(parsedTxFees) ? parsedTxFees : undefined,
+ tx_fees: isValidNumber(parsedTxFees)
+ ? {
+ value: parsedTxFees,
+ unit: toTxFeeValueUnit(parsedTxFees) || 'blocks',
+ }
+ : undefined,
tx_fees_factor: isValidNumber(parsedTxFeesFactor) ? parsedTxFeesFactor : undefined,
max_cj_fee_abs: isValidNumber(parsedMaxFeeAbs) ? parsedMaxFeeAbs : undefined,
max_cj_fee_rel: isValidNumber(parsedMaxFeeRel) ? parsedMaxFeeRel : undefined,
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 423c6dcbd..dff6d5092 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -280,6 +280,8 @@
"text_coinjoin_already_running": "A collaborative transaction is currently in progress.",
"label_recipient": "Recipient",
"placeholder_recipient": "Enter address or choose jar from wallet...",
+ "feedback_invalid_source_jar": "Please select a jar to send from.",
+ "feedback_invalid_destination_address": "Please enter valid a destination address.",
"feedback_reused_address": "This address is already used. To preserve your privacy please choose another one.",
"label_source_jar": "Send from",
"title_jar_selector": "Select a jar from your wallet to send the funds to.",
diff --git a/src/index.css b/src/index.css
index ef2b62471..077dfffa3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -345,11 +345,14 @@ main {
text-align: justify;
}
+.cursor-not-allowed {
+ cursor: not-allowed !important;
+}
.cursor-pointer {
- cursor: pointer;
+ cursor: pointer !important;
}
.cursor-wait {
- cursor: wait;
+ cursor: wait !important;
}
/* Fullscreen Overlays */
@@ -555,7 +558,6 @@ h2 {
}
/* Wallets Styles */
-
.wallets a.wallet-name {
text-decoration: none;
color: var(--bs-body-color);
@@ -565,14 +567,7 @@ h2 {
text-decoration: underline;
}
-.wallets .btn.btn-outline-dark:not(:hover) {
- border-color: rgba(222, 222, 222, 1);
-}
-:root[data-theme='dark'] .wallets .btn.btn-outline-dark:not(:hover) {
- border-color: var(--bs-gray-800);
-}
/* Alpha Warning */
-
.warning-card-wrapper {
position: fixed;
height: 100%;
@@ -663,29 +658,37 @@ h2 {
:root[data-theme='dark'] .btn:not(.btn-light) {
--bs-btn-color: var(--bs-white);
}
-
-:root[data-theme='dark'] .btn-dark {
- background-color: var(--bs-gray-800);
- border-color: var(--bs-gray-800);
-}
-
:root[data-theme='dark'] .input-group-text {
background-color: var(--bs-gray-800);
}
-:root[data-theme='dark'] .btn-dark:hover {
- background-color: var(--bs-gray-dark);
- border-color: var(--bs-gray-dark) !important;
+:root[data-theme='dark'] .btn-dark {
+ --bs-btn-color: var(--bs-light);
+ --bs-btn-bg: var(--bs-gray-800);
+ --bs-btn-border-color: var(--bs-btn-bg);
+ --bs-btn-hover-bg: var(--bs-gray-dark);
+ --bs-btn-hover-border-color: var(--bs-btn-hover-bg);
+ --bs-btn-active-color: #fff;
+ --bs-btn-active-bg: var(--bs-btn-hover-bg);
+ --bs-btn-active-border-color: var(--bs-btn-active-bg);
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-light);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-btn-border-color);
}
:root[data-theme='dark'] .btn-outline-dark {
- color: var(--bs-white);
- border-color: var(--bs-gray-800);
-}
-
-:root[data-theme='dark'] .btn-outline-dark:hover {
- background-color: var(--bs-gray-dark) !important;
- border-color: var(--bs-gray-dark) !important;
+ --bs-btn-color: var(--bs-light);
+ --bs-btn-border-color: var(--bs-gray-800);
+ --bs-btn-hover-bg: var(--bs-gray-dark);
+ --bs-btn-hover-border-color: var(--bs-btn-hover-bg);
+ --bs-btn-active-color: #fff;
+ --bs-btn-active-bg: var(--bs-btn-hover-bg);
+ --bs-btn-active-border-color: var(--bs-btn-active-bg);
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-light);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-btn-border-color);
}
:root[data-theme='dark'] .offcanvas {
@@ -693,7 +696,7 @@ h2 {
}
:root[data-theme='dark'] .spinner-border {
- border-color: var(--bs-white) !important;
+ border-color: var(--bs-light) !important;
border-right-color: transparent !important;
}
@@ -706,13 +709,14 @@ h2 {
}
:root[data-theme='dark'] .form-control {
+ background-color: var(--bs-light);
color: var(--bs-body-bg);
- background-color: var(--bs-white);
}
:root[data-theme='dark'] .form-control:disabled,
:root[data-theme='dark'] .form-select:disabled,
-.form-control[readonly] {
+:root[data-theme='dark'] .form-control[readonly] {
+ --bs-border-color: var(--bs-gray-500);
background-color: var(--bs-gray-500);
}
@@ -721,15 +725,15 @@ h2 {
}
:root[data-theme='dark'] .link-dark {
- color: var(--bs-white) !important;
- text-decoration-color: RGBA(var(--bs-white), var(--bs-link-underline-opacity, 1)) !important;
+ color: var(--bs-light) !important;
+ text-decoration-color: rgba(var(--bs-light), var(--bs-link-underline-opacity, 1)) !important;
}
:root[data-theme='dark'] .toast {
background-color: transparent;
}
:root[data-theme='dark'] .toast-header {
- color: var(--bs-white);
+ color: var(--bs-light);
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 ca85ae7a3..c28b26f4d 100644
--- a/src/libs/JmWalletApi.ts
+++ b/src/libs/JmWalletApi.ts
@@ -112,6 +112,7 @@ interface DirectSendRequest {
mixdepth: Mixdepth
destination: BitcoinAddress
amount_sats: AmountSats
+ txfee?: number
}
interface DoCoinjoinRequest {
@@ -390,7 +391,10 @@ const postDirectSend = async ({ token, signal, walletFileName }: WalletRequestCo
return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletFileName)}/taker/direct-send`, {
method: 'POST',
headers: { ...Helper.buildAuthHeader(token) },
- body: JSON.stringify(req),
+ body: JSON.stringify({
+ ...req,
+ txfee: req.txfee ? String(req.txfee) : undefined,
+ }),
signal,
})
}