From b23141983aee8041f5ef95ed240d0814426218f1 Mon Sep 17 00:00:00 2001 From: peachbits Date: Wed, 2 Sep 2026 21:54:38 -0700 Subject: [PATCH] Lock the send slider once a broadcast has been attempted One intended Bitcoin send became five real payments (Asana 1217135300337949). Every broadcast succeeded over the HTTP fallback, the engine's saveTx then threw, and the send scene treated that throw as a failed send: a generic network error card plus a re-armed slider. Because saveTx marks the inputs spent before it throws, each retry re-quoted on the remaining UTXOs, so every slide was a fresh real payment. A failure reported by broadcastTx does not prove the transaction is absent from the network. A server can relay it and still fail to answer, and the network conditions that broke the broadcast also make any immediate "did it land?" check unreliable. So the scene now treats the broadcast call as a one-way boundary: - A ref is set immediately before broadcastTx is called. Once set, the finally block never re-arms the slider, on success or on failure, and the slider renders locked as "Send Attempted" for the life of the scene. The ref (rather than state) is what the handler reads, so the FIO no-bundled retry recursion cannot re-arm it either. - Failures at or after that boundary no longer show the generic error. They show "Transaction Status Unknown" telling the user the transaction may have reached the network, to check a block explorer or wait for a confirmation email, and that sending again risks a duplicate payment. Separate copy distinguishes a broadcast that reported failure from an error after a successful one. - Failures before the boundary (PIN, the beforeTransaction hook, the FIO fee check, signing) still show the existing errors and still re-arm the slider, because nothing can have been sent. The hook failure also now resets the slider instead of leaving it spinning. SafeSlider gains an explicit `lockedText` prop for this. Overloading the existing `disabled` prop would have shown a completed slider's `disabledText` (which defaults to "Enter an Amount") in place of the spinner whenever any parent disabled a slider mid-callback, affecting the other fourteen scenes that use it. The locked-state card is longer than a normal error and the slider floats over the bottom of the scroll view, so the existing needsScrollToEnd mechanism scrolls it into view. Verified on the iOS simulator against the real send scene with the broadcast stubbed out, for a post-broadcast throw, an ambiguous broadcast failure, and a pre-broadcast signing failure. --- CHANGELOG.md | 2 + src/components/scenes/SendScene2.tsx | 61 +++++++++++++++++++++++++++- src/components/themed/SafeSlider.tsx | 18 ++++++-- src/locales/en_US.ts | 6 +++ src/locales/strings/enUS.json | 4 ++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c67c4be8f..49bb9d8c010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- changed: Lock the send confirmation slider for the rest of the scene once a broadcast has been attempted, whether the broadcast reported success or failure, and replace the generic failure card with a message that the transaction may have gone through, pointing at the block explorer or confirmation email before trying again. + ## 4.51.0 (staging) - added: Push info-server attestation tokens into edge-core-js via `setAttestationToken` so the login server can skip CAPTCHA for attested devices, and allow `LOGIN_SERVER` / `INFO_SERVER` env overrides for local E2E stacks. diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 050a7e8ddf2..e40739e88ae 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -259,6 +259,14 @@ const SendComponent: React.FC = props => { AddressEntryMethod | undefined >(undefined) const [hasPendingTx, setHasPendingTx] = useState(false) + // Once a broadcast has been attempted, the confirm slider never re-arms on + // this scene, whether the broadcast reported success or failure. A failure + // report does not prove the transaction is absent from the network, and a + // re-armed slider after a real broadcast is an invitation to pay twice. + // The ref is what the send handler reads (it survives the FIO retry + // recursion); the state is what drives the render. + const broadcastAttemptedRef = React.useRef(false) + const [broadcastAttempted, setBroadcastAttempted] = useState(false) const [fioSender, setFioSender] = useState({ fioAddress: fioPendingRequest?.payer_fio_address ?? '', fioWallet: null, @@ -1308,10 +1316,14 @@ const SendComponent: React.FC = props => { 'Error from before transaction route param hook: ', String(e) ) + resetSlider() return } isSendingRef.current = true + // Set once broadcastTx resolves, so the catch below can tell a broadcast + // that reported failure apart from an error after a successful one. + let broadcastSucceeded = false try { // Check the OBT data fee and error if we are sending to a FIO address but NOT if we are paying // a FIO request since we want to make sure that can go through. @@ -1324,12 +1336,20 @@ const SendComponent: React.FC = props => { } const signedTx = await coreWallet.signTx(edgeTransaction) + + // From this point on the transaction may reach the network, so lock + // the slider for the life of this scene no matter what happens next. + // The render-side flag is set in the finally block, so the slider + // keeps its spinner while the attempt is in flight. + broadcastAttemptedRef.current = true + let broadcastedTx: EdgeTransaction if (alternateBroadcast != null) { broadcastedTx = await alternateBroadcast(signedTx) } else { broadcastedTx = await coreWallet.broadcastTx(signedTx) } + broadcastSucceeded = true // Figure out metadata (preserve Zano alias if provided) let payeeName: string | undefined @@ -1507,6 +1527,33 @@ const SendComponent: React.FC = props => { const errorCasted = err instanceof Error ? err : new Error(String(err)) let error = err + if (broadcastAttemptedRef.current) { + // No happy path and no generic "network error": the broadcast was + // attempted, so tell the user exactly what we know and point them + // at the explorer before they consider sending again. + logActivity( + `Error ${ + broadcastSucceeded ? 'after' : 'during' + } broadcastTx (txid ${edgeTransaction.txid}): ${String(err)}` + ) + setError( + new I18nError( + lstrings.send_broadcast_failure_title, + sprintf( + broadcastSucceeded + ? lstrings.send_broadcast_post_error_message_s + : lstrings.send_broadcast_failure_message_s, + errorCasted.message + ) + ) + ) + // The locked-state card is longer than a normal error, and the + // slider floats over the bottom of the scroll view. Scroll it into + // view so the whole message is readable without scrolling by hand. + needsScrollToEnd.current = true + return + } + if (errorCasted.name === 'ErrorAlgoRecipientNotActivated') { error = new I18nError( lstrings.send_confirmation_algo_recipient_not_activated_s, @@ -1562,7 +1609,14 @@ const SendComponent: React.FC = props => { setError(error) } finally { isSendingRef.current = false - resetSlider() + // The slider is idempotent once a broadcast has been attempted. Only a + // failure before that boundary (PIN, hooks, FIO fee check, signing) + // leaves the slider re-armed, because nothing could have been sent. + if (broadcastAttemptedRef.current) { + setBroadcastAttempted(true) + } else { + resetSlider() + } } } ) @@ -1880,6 +1934,11 @@ const SendComponent: React.FC = props => { diff --git a/src/components/themed/SafeSlider.tsx b/src/components/themed/SafeSlider.tsx index 6d5cc5911ce..4b228f932e7 100644 --- a/src/components/themed/SafeSlider.tsx +++ b/src/components/themed/SafeSlider.tsx @@ -26,6 +26,14 @@ interface Props { width?: number confirmText?: string disabledText?: string + /** + * When set, the slider is frozen in its completed position and shows this + * text instead of the spinner or the normal slide text. Use it to lock a + * slider after its action has run (for example, after a broadcast attempt) + * so it can never be slid again on this scene. Independent of `disabled`, + * which means "not ready yet" and shows `disabledText`. + */ + lockedText?: string testID?: string onSlidingComplete: (reset: () => void) => Promise | void @@ -36,6 +44,7 @@ export const SafeSlider: React.FC = props => { confirmText, disabledText, disabled = false, + lockedText, onSlidingComplete, parentStyle, testID = 'confirmSliderThumb' @@ -49,8 +58,11 @@ export const SafeSlider: React.FC = props => { const { width = theme.confirmationSliderWidth } = props const upperBound = width - theme.confirmationSliderThumbWidth const widthStyle = { width } - const sliderDisabled = disabled || completed - const sliderText = !sliderDisabled + const locked = lockedText != null + const sliderDisabled = disabled || completed || locked + const sliderText = locked + ? lockedText + : !sliderDisabled ? confirmText ?? lstrings.send_confirmation_slide_to_confirm : disabledText ?? lstrings.select_exchange_amount_short @@ -136,7 +148,7 @@ export const SafeSlider: React.FC = props => { /> - {completed ? ( + {completed && !locked ? (