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 ? (