From 1f63333336e5b789761f58efe765cb045bc1b3a9 Mon Sep 17 00:00:00 2001
From: Nikolas De Giorgis
Date: Thu, 21 May 2026 10:56:29 +0100
Subject: [PATCH 1/5] send: extract tx proposal hook
Regular send and Lightning top up both need the same transaction
proposal lifecycle: debounce user edits, ignore stale proposals,
expose fee and amount details, and convert proposed amounts to fiat.
Keep that behavior in one hook so future fixes to proposal loading
or error handling apply to both flows instead of being copied between
screens.
---
.../web/src/routes/account/send/send.tsx | 134 +++++-----------
.../routes/account/send/use-tx-proposal.ts | 149 ++++++++++++++++++
2 files changed, 187 insertions(+), 96 deletions(-)
create mode 100644 frontends/web/src/routes/account/send/use-tx-proposal.ts
diff --git a/frontends/web/src/routes/account/send/send.tsx b/frontends/web/src/routes/account/send/send.tsx
index d26b57b7b2..f7fab6e8e5 100644
--- a/frontends/web/src/routes/account/send/send.tsx
+++ b/frontends/web/src/routes/account/send/send.tsx
@@ -26,8 +26,9 @@ import { CoinInput } from './components/inputs/coin-input';
import { FiatInput } from './components/inputs/fiat-input';
import { NoteInput } from './components/inputs/note-input';
import { FiatValue } from '@/components/amount/fiat-value';
-import { TProposalError, txProposalErrorHandling } from './services';
+import { TProposalError } from './services';
import { CoinControl } from './coin-control';
+import { useTxProposal } from './use-tx-proposal';
import { connectKeystore } from '@/api/keystores';
import { SubTitle } from '@/components/title';
import { RatesContext } from '@/contexts/RatesContext';
@@ -67,9 +68,6 @@ export const Send = ({
const { btcUnit, defaultCurrency } = useContext(RatesContext);
const selectedUTXOsRef = useRef({});
const [utxoDialogActive, setUtxoDialogActive] = useState(false);
- // in case there are multiple parallel tx proposals we can ignore all other but the last one
- const lastProposal = useRef | null>(null);
- const proposeTimeout = useRef | null>(null);
// state used for the "Receiver address" input - what the user types or the account's address that is selected
const [recipientInput, setRecipientInput] = useState('');
@@ -77,18 +75,12 @@ export const Send = ({
const [selectedReceiverAccount, setSelectedReceiverAccount] = useState(null);
const [amount, setAmount] = useState('');
const [fiatAmount, setFiatAmount] = useState('');
- const [valid, setValid] = useState(false);
const [sendAll, setSendAll] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
- const [isUpdatingProposal, setIsUpdatingProposal] = useState(false);
const [note, setNote] = useState('');
const [customFee, setCustomFee] = useState('');
const [errorHandling, setErrorHandling] = useState({});
- const [proposedFee, setProposedFee] = useState();
- const [proposedTotal, setProposedTotal] = useState();
- const [proposedAmount, setProposedAmount] = useState();
- const [recipientDisplayAddress, setRecipientDisplayAddress] = useState('');
const [feeTarget, setFeeTarget] = useState();
const [sendResult, setSendResult] = useState();
@@ -98,23 +90,6 @@ export const Send = ({
const balance = useAccountBalance(account.code, btcUnit);
- const handleContinue = () => {
- setSendAll(false);
- setIsConfirming(false);
- setRecipientInput('');
- setSelectedReceiverAccount(null);
- setProposedAmount(undefined);
- setProposedFee(undefined);
- setProposedTotal(undefined);
- setRecipientDisplayAddress('');
- setFiatAmount('');
- setAmount('');
- setNote('');
- setCustomFee('');
- setSendResult(undefined);
- selectedUTXOsRef.current = {};
- };
-
const handleRetry = () => {
setSendResult(undefined);
};
@@ -197,75 +172,42 @@ export const Send = ({
}
}, [account.coinCode, defaultCurrency, t]);
- const txProposal = useCallback((
- updateFiat: boolean,
- result: accountApi.TTxProposalResult,
- ) => {
- setValid(result.success);
- if (result.success) {
- setErrorHandling({});
- setProposedFee(result.fee);
- setProposedAmount(result.amount);
- setProposedTotal(result.total);
- setRecipientDisplayAddress(result.recipientDisplayAddress);
- setIsUpdatingProposal(false);
- if (updateFiat) {
- convertToFiat(result.amount.amount);
- }
- } else {
- const errorHandling = txProposalErrorHandling(result.errorCode);
- setErrorHandling(errorHandling);
- setIsUpdatingProposal(false);
-
- if (
- errorHandling.amountError
- || Object.keys(errorHandling).length === 0
- ) {
- setProposedFee(undefined);
- }
- setRecipientDisplayAddress('');
- }
- }, [convertToFiat]);
-
- const validateAndDisplayFee = useCallback((
- updateFiat: boolean = true,
- ) => {
- setProposedTotal(undefined);
- setErrorHandling({});
- const txInput = getValidTxInputData();
- if (!txInput) {
- return;
- }
- if (proposeTimeout.current) {
- clearTimeout(proposeTimeout.current);
- proposeTimeout.current = null;
- }
- setIsUpdatingProposal(true);
- // defer the transaction proposal
- proposeTimeout.current = setTimeout(async () => {
- let proposePromise;
- try {
- proposePromise = accountApi.proposeTx(account.code, txInput);
- // keep this as the last known proposal
- lastProposal.current = proposePromise;
- const result = await proposePromise;
- // continue only if this is the most recent proposal
- if (proposePromise === lastProposal.current) {
- txProposal(updateFiat, result);
- }
- } catch (error) {
- if (proposePromise === lastProposal.current) {
- setValid(false);
- console.error('Failed to propose transaction:', error);
- }
- } finally {
- // cleanup regardless of success or failure
- if (proposePromise === lastProposal.current) {
- lastProposal.current = null;
- }
- }
- }, 400); // Delay the proposal by 400 ms
- }, [account.code, getValidTxInputData, txProposal]);
+ const clearFeeOnError = useCallback((errorHandling: TProposalError) => (
+ !!errorHandling.amountError
+ || Object.keys(errorHandling).length === 0
+ ), []);
+
+ const {
+ clearProposal,
+ isUpdatingProposal,
+ proposedAmount,
+ proposedFee,
+ proposedTotal,
+ recipientDisplayAddress,
+ setRecipientDisplayAddress,
+ valid,
+ validateAndDisplayFee,
+ } = useTxProposal({
+ accountCode: account.code,
+ clearFeeOnError,
+ getValidTxInputData,
+ onProposedAmount: convertToFiat,
+ setErrorHandling,
+ });
+
+ const handleContinue = () => {
+ setSendAll(false);
+ setIsConfirming(false);
+ setRecipientInput('');
+ setSelectedReceiverAccount(null);
+ clearProposal();
+ setFiatAmount('');
+ setAmount('');
+ setNote('');
+ setCustomFee('');
+ setSendResult(undefined);
+ selectedUTXOsRef.current = {};
+ };
useEffect(() => {
validateAndDisplayFee(updateFiat);
diff --git a/frontends/web/src/routes/account/send/use-tx-proposal.ts b/frontends/web/src/routes/account/send/use-tx-proposal.ts
new file mode 100644
index 0000000000..9aa21da4c4
--- /dev/null
+++ b/frontends/web/src/routes/account/send/use-tx-proposal.ts
@@ -0,0 +1,149 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import type { Dispatch, SetStateAction } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import * as accountApi from '@/api/account';
+import { TProposalError, txProposalErrorHandling } from './services';
+
+type TUseTxProposalProps = {
+ accountCode?: accountApi.AccountCode;
+ clearFeeOnError?: (errorHandling: TProposalError) => boolean;
+ clearOnInvalidInput?: boolean;
+ getValidTxInputData: () => Required | false;
+ onProposedAmount?: (amount: string) => void;
+ setErrorHandling: Dispatch>;
+};
+
+const clearFeeOnErrorDefault = () => true;
+
+export const useTxProposal = ({
+ accountCode,
+ clearFeeOnError = clearFeeOnErrorDefault,
+ clearOnInvalidInput = false,
+ getValidTxInputData,
+ onProposedAmount,
+ setErrorHandling,
+}: TUseTxProposalProps) => {
+ const lastProposal = useRef | null>(null);
+ const proposeTimeout = useRef | null>(null);
+
+ const [valid, setValid] = useState(false);
+ const [isUpdatingProposal, setIsUpdatingProposal] = useState(false);
+ const [proposedFee, setProposedFee] = useState();
+ const [proposedTotal, setProposedTotal] = useState();
+ const [proposedAmount, setProposedAmount] = useState();
+ const [recipientDisplayAddress, setRecipientDisplayAddress] = useState('');
+
+ const cancelPendingProposal = useCallback(() => {
+ if (proposeTimeout.current) {
+ clearTimeout(proposeTimeout.current);
+ proposeTimeout.current = null;
+ }
+ lastProposal.current = null;
+ setIsUpdatingProposal(false);
+ }, []);
+
+ useEffect(() => () => {
+ if (proposeTimeout.current) {
+ clearTimeout(proposeTimeout.current);
+ proposeTimeout.current = null;
+ }
+ lastProposal.current = null;
+ }, []);
+
+ const clearProposal = useCallback(() => {
+ setValid(false);
+ setIsUpdatingProposal(false);
+ setProposedAmount(undefined);
+ setProposedFee(undefined);
+ setProposedTotal(undefined);
+ setRecipientDisplayAddress('');
+ }, []);
+
+ const handleProposal = useCallback((
+ updateFiat: boolean,
+ result: accountApi.TTxProposalResult,
+ ) => {
+ setValid(result.success);
+ if (result.success) {
+ setErrorHandling({});
+ setProposedFee(result.fee);
+ setProposedAmount(result.amount);
+ setProposedTotal(result.total);
+ setRecipientDisplayAddress(result.recipientDisplayAddress);
+ setIsUpdatingProposal(false);
+ if (updateFiat) {
+ onProposedAmount?.(result.amount.amount);
+ }
+ return;
+ }
+
+ const nextErrorHandling = txProposalErrorHandling(result.errorCode);
+ setErrorHandling(nextErrorHandling);
+ setIsUpdatingProposal(false);
+
+ if (clearFeeOnError(nextErrorHandling)) {
+ setProposedFee(undefined);
+ }
+ setRecipientDisplayAddress('');
+ }, [clearFeeOnError, onProposedAmount, setErrorHandling]);
+
+ const validateAndDisplayFee = useCallback((
+ updateFiat: boolean = true,
+ ) => {
+ cancelPendingProposal();
+ setProposedTotal(undefined);
+ setErrorHandling({});
+ const txInput = getValidTxInputData();
+ if (!txInput || !accountCode) {
+ if (clearOnInvalidInput) {
+ clearProposal();
+ }
+ return;
+ }
+ setIsUpdatingProposal(true);
+ proposeTimeout.current = setTimeout(async () => {
+ let proposePromise: Promise | null = null;
+ try {
+ proposePromise = accountApi.proposeTx(accountCode, txInput);
+ lastProposal.current = proposePromise;
+ const result = await proposePromise;
+ if (proposePromise === lastProposal.current) {
+ handleProposal(updateFiat, result);
+ }
+ } catch (error) {
+ if (proposePromise === lastProposal.current) {
+ setValid(false);
+ setIsUpdatingProposal(false);
+ console.error('Failed to propose transaction:', error);
+ }
+ } finally {
+ if (proposePromise === lastProposal.current) {
+ lastProposal.current = null;
+ setIsUpdatingProposal(false);
+ }
+ }
+ }, 400);
+ }, [
+ accountCode,
+ cancelPendingProposal,
+ clearOnInvalidInput,
+ clearProposal,
+ getValidTxInputData,
+ handleProposal,
+ setErrorHandling,
+ ]);
+
+ return {
+ cancelPendingProposal,
+ clearProposal,
+ isUpdatingProposal,
+ proposedAmount,
+ proposedFee,
+ proposedTotal,
+ recipientDisplayAddress,
+ setRecipientDisplayAddress,
+ valid,
+ validateAndDisplayFee,
+ };
+};
From b00fa3479e241ae046a3de5325c67e9e6cdd07a1 Mon Sep 17 00:00:00 2001
From: Nikolas De Giorgis
Date: Thu, 21 May 2026 10:56:37 +0100
Subject: [PATCH 2/5] send: make result actions optional
Lightning top up reuses the send result component but does not offer
a follow-up transaction action. Rendering the secondary button without
an onContinue handler creates a visible no-op control.
Only render the secondary action when a caller provides onContinue,
while leaving the normal send result behavior unchanged.
---
.../routes/account/send/components/result.tsx | 39 ++++++++++++-------
1 file changed, 25 insertions(+), 14 deletions(-)
diff --git a/frontends/web/src/routes/account/send/components/result.tsx b/frontends/web/src/routes/account/send/components/result.tsx
index 9879677170..e3bdbbb5af 100644
--- a/frontends/web/src/routes/account/send/components/result.tsx
+++ b/frontends/web/src/routes/account/send/components/result.tsx
@@ -12,9 +12,12 @@ import { CopyableInput } from '@/components/copy/Copy';
type TProps = {
children?: ReactNode;
code: AccountCode;
- onContinue: () => void;
+ doneRoute?: string;
+ onContinue?: () => void;
onRetry: () => void;
result: TSendTx | undefined;
+ showSuccessActions?: boolean;
+ successMessage?: string;
};
/**
@@ -25,12 +28,16 @@ type TProps = {
export const SendResult = ({
children,
code,
- result,
+ doneRoute,
onContinue,
+ result,
onRetry,
+ showSuccessActions = true,
+ successMessage,
}: TProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
+ const donePath = doneRoute || `/account/${code}`;
if (!result) {
return null;
@@ -47,7 +54,7 @@ export const SendResult = ({
-