Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 60 additions & 1 deletion src/components/scenes/SendScene2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,14 @@ const SendComponent: React.FC<Props> = props => {
AddressEntryMethod | undefined
>(undefined)
const [hasPendingTx, setHasPendingTx] = useState<boolean>(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<boolean>(false)
const [broadcastAttempted, setBroadcastAttempted] = useState<boolean>(false)
const [fioSender, setFioSender] = useState<FioSenderInfo>({
fioAddress: fioPendingRequest?.payer_fio_address ?? '',
fioWallet: null,
Expand Down Expand Up @@ -1308,10 +1316,14 @@ const SendComponent: React.FC<Props> = 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.
Expand All @@ -1324,12 +1336,20 @@ const SendComponent: React.FC<Props> = 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting the ref here is right for the lock. Using the same ref at line 1530 to choose the message is what causes trouble: deterministic rejections thrown from inside broadcastTx now read as ambiguous.

EosEngine.broadcastTx maps tx_cpu_usage_exceeded / tx_net_usage_exceeded / ram_usage_exceeded onto ErrorEosInsufficientCpu / Net / Ram after the node has explicitly refused the transaction. Those now hit the early return, so an EOS user short on CPU is told the transaction "may still have gone through" and that "sending again could result in a duplicate payment", and the slider locks for the life of the scene. Nothing was accepted, and the actual fix (stake more CPU) is not reachable without backing out of the whole send flow.

Splitting the two decisions keeps the design intact: lock unconditionally, as you do now, but let the existing errorCasted.name branches supply the copy when the engine has named a deterministic rejection, and fall through to Transaction Status Unknown for everything else.

For the record on the neighbouring branches: ErrorAlgoRecipientNotActivated is unaffected (Algorand throws it from signTx, before this line), and the 504 branch is correctly superseded, since a 504 during a broadcast is genuinely ambiguous.


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
Expand Down Expand Up @@ -1507,6 +1527,33 @@ const SendComponent: React.FC<Props> = 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)}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edgeTransaction.txid is empty here for UTXO sends, which is the incident this PR exists for. makeSpend returns txid: '' (UtxoEngine.ts), and the real txid is assigned during signTx inside the engine, on the far side of the core bridge, so the scene's copy never receives it. signedTx holds it but is const-scoped inside the try.

The log line the error card sends the user hunting for therefore records no txid at all. Hoisting let signedTx: EdgeTransaction | undefined above the try and logging signedTx?.txid ?? edgeTransaction.txid fixes it.

)
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path returns without calling onDone, so a send launched from a ramp sell or a gift-card purchase ends with no completion callback, and the locked slider means the user cannot reach one by retrying either. The flow is simply stranded on the send scene.

onDone already takes an error first (moonpayRampPlugin rethrows it, banxaRampPlugin and GiftCardPurchaseScene also accept it), so onDone?.(errorCasted) here would let the plugin flow terminate on its own terms while the scene keeps its honest messaging.

Not a regression against develop, where the generic error card skipped onDone too, but locking the slider is what makes it terminal.

}

if (errorCasted.name === 'ErrorAlgoRecipientNotActivated') {
error = new I18nError(
lstrings.send_confirmation_algo_recipient_not_activated_s,
Expand Down Expand Up @@ -1562,7 +1609,14 @@ const SendComponent: React.FC<Props> = 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()
}
}
}
)
Expand Down Expand Up @@ -1880,6 +1934,11 @@ const SendComponent: React.FC<Props> = props => {
<EdgeAnim enter={{ type: 'fadeInDown', distance: 120 }}>
<SafeSlider
disabledText={disabledText}
lockedText={
broadcastAttempted
? lstrings.send_confirmation_slider_locked
: undefined
}
onSlidingComplete={handleSliderComplete}
disabled={disableSlider}
/>
Expand Down
18 changes: 15 additions & 3 deletions src/components/themed/SafeSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void
Expand All @@ -36,6 +44,7 @@ export const SafeSlider: React.FC<Props> = props => {
confirmText,
disabledText,
disabled = false,
lockedText,
onSlidingComplete,
parentStyle,
testID = 'confirmSliderThumb'
Expand All @@ -49,8 +58,11 @@ export const SafeSlider: React.FC<Props> = 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

Expand Down Expand Up @@ -136,7 +148,7 @@ export const SafeSlider: React.FC<Props> = props => {
/>
</Animated.View>
</GestureDetector>
{completed ? (
{completed && !locked ? (
<ActivityIndicator
color={theme.iconTappable}
style={styles.activityIndicator}
Expand Down
6 changes: 6 additions & 0 deletions src/locales/en_US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,12 @@ const strings = {
transaction_failure_504_message: '504 Server Timeout - Retry Transaction',
transaction_success: 'Transaction Success',
transaction_success_message: 'Your transaction has been successfully sent.',
send_broadcast_failure_title: 'Transaction Status Unknown',
send_broadcast_failure_message_s:
'The network returned an error while broadcasting this transaction, but it may still have gone through. Before trying again, check the transaction in a block explorer or wait for a confirmation email from the recipient or service. Sending again could result in a duplicate payment.\n\nError: %s',
send_broadcast_post_error_message_s:
'This transaction was broadcast to the network, but an error occurred afterwards. Check a block explorer to confirm its status. Sending again could result in a duplicate payment.\n\nError: %s',
send_confirmation_slider_locked: 'Send Attempted',
incorrect_pin: 'Incorrect PIN',
invalid_spend_request: 'Invalid Spend Request',
invalid_custom_fee: 'Minimum custom fee is',
Expand Down
4 changes: 4 additions & 0 deletions src/locales/strings/enUS.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@
"transaction_failure_504_message": "504 Server Timeout - Retry Transaction",
"transaction_success": "Transaction Success",
"transaction_success_message": "Your transaction has been successfully sent.",
"send_broadcast_failure_title": "Transaction Status Unknown",
"send_broadcast_failure_message_s": "The network returned an error while broadcasting this transaction, but it may still have gone through. Before trying again, check the transaction in a block explorer or wait for a confirmation email from the recipient or service. Sending again could result in a duplicate payment.\n\nError: %s",
"send_broadcast_post_error_message_s": "This transaction was broadcast to the network, but an error occurred afterwards. Check a block explorer to confirm its status. Sending again could result in a duplicate payment.\n\nError: %s",
"send_confirmation_slider_locked": "Send Attempted",
"incorrect_pin": "Incorrect PIN",
"invalid_spend_request": "Invalid Spend Request",
"invalid_custom_fee": "Minimum custom fee is",
Expand Down
Loading