diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 444385677..ef83e7d0e 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -58,6 +58,7 @@ export default function WithdrawCryptoPage() { setError: setWithdrawError, chargeDetails, setChargeDetails, + transactionHash, setTransactionHash, paymentDetails, setPaymentDetails, @@ -85,8 +86,14 @@ export default function WithdrawCryptoPage() { // local state for transaction execution const [isSendingTx, setIsSendingTx] = useState(false) - // combined processing state - const isProcessing = useMemo(() => isSendingTx || isRecording, [isSendingTx, isRecording]) + // combined processing state. Includes `transactionHash` so the Confirm + // button stays disabled (and the view shows the spinner) once the + // on-chain leg has fired — re-running the handler would re-spend. + // Sentry PEANUT-UI-QH9 / 2026-06-01. + const isProcessing = useMemo( + () => isSendingTx || isRecording || !!transactionHash, + [isSendingTx, isRecording, transactionHash] + ) // helper to manage errors consistently const setError = useCallback( @@ -271,6 +278,16 @@ export default function WithdrawCryptoPage() { }, [withdrawData, chargeDetails, isXChain, isDiffToken]) const handleConfirmWithdrawal = useCallback(async () => { + // Post-on-chain safety gate. Once sendMoney/sendTransactions has + // returned a tx hash (set on the WithdrawFlow context via + // setTransactionHash before recordPayment), re-running this handler + // would re-fire the wallet → external-address transfer. Sentry + // PEANUT-UI-QH9 / 2026-06-01 first surfaced this for the Bridge + // offramp; the audit found the same shape here. Context state (not + // local hook state) is what makes the gate survive view transitions + // and back-navigation. + if (transactionHash) return + if (!chargeDetails || !withdrawData || !amountToWithdraw || !address) { console.error('Withdraw data, active charge details, or amount missing for final confirmation') setError('Essential withdrawal information is missing.') @@ -362,21 +379,33 @@ export default function WithdrawCryptoPage() { // gets stuck at PENDING because nothing else triggers the // transition for the bridge-path (depositWithId, mode='pay') // flow we use for non-stable destinations. + // Mark the on-chain leg done BEFORE recordPayment so the gate at + // the top of this handler engages on any retry — including the + // skipRecordPayment branch (Rain-collateral same-chain, where + // settlement runs through the Rain webhook). The Konrad incident + // was: sendMoney succeeded → BE ack timed out → user retried. + // Setting the gate here makes that retry a no-op regardless of + // which post-chain path was taken. + setTransactionHash(finalTxHash) + const routedThroughCollateral = strategy === 'collateral-only' || strategy === 'mixed' const skipRecordPayment = routedThroughCollateral && !isCrossChainWithdrawal - let payment: Awaited> | null = null - if (!skipRecordPayment) { - payment = await recordPayment({ - chargeId: chargeDetails.uuid, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - txHash: finalTxHash, - tokenAddress: PEANUT_WALLET_TOKEN as Address, - payerAddress: address as Address, - }) - } + // Cross-chain withdraws ALWAYS need recordPayment to fire — the + // BE validator's cross-chain branch transitions the charge intent + // to COMPLETED directly (trusts the source-chain submission since + // Rhino owns delivery downstream). Skip path (collateral-only + + // same-chain) is reconciled by the Rain webhook instead. + const payment = skipRecordPayment + ? null + : await recordPayment({ + chargeId: chargeDetails.uuid, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + txHash: finalTxHash, + tokenAddress: PEANUT_WALLET_TOKEN as Address, + payerAddress: address as Address, + }) - setTransactionHash(finalTxHash) setPaymentDetails(payment) triggerHaptic() setCurrentView('STATUS') @@ -407,6 +436,7 @@ export default function WithdrawCryptoPage() { sendMoney, isCrossChainWithdrawal, recordPayment, + transactionHash, setCurrentView, setTransactionHash, setPaymentDetails, diff --git a/src/features/payments/flows/contribute-pot/useContributePotFlow.ts b/src/features/payments/flows/contribute-pot/useContributePotFlow.ts index 077874664..bf5bce0d0 100644 --- a/src/features/payments/flows/contribute-pot/useContributePotFlow.ts +++ b/src/features/payments/flows/contribute-pot/useContributePotFlow.ts @@ -165,6 +165,17 @@ export function useContributePotFlow() { return { success: false } } + // Post-on-chain safety gate: once sendMoney has set txHash on + // the flow context, do NOT re-run this handler — re-firing + // sendMoney would produce a second on-chain tx attributed to + // the same charge (Sentry PEANUT-UI-QH9, 2026-06-01). Returning + // success=false is critical: the external-wallet branch in the + // input view conditions setCurrentView('EXTERNAL_WALLET') on + // res.success, so a fake-success short-circuit would mis-route + // a user who already paid via Peanut wallet into the external- + // wallet flow for the same pot contribution. + if (txHash) return { success: false } + setIsLoading(true) clearError() @@ -241,6 +252,7 @@ export function useContributePotFlow() { createCharge, sendMoney, recordPayment, + txHash, setCharge, setTxHash, setPayment, @@ -262,6 +274,10 @@ export function useContributePotFlow() { attachment, charge, payment, + // txHash is the post-on-chain gate — truthy iff sendMoney already + // fired. Consumers MUST disable pay buttons (Peanut wallet AND + // external wallet) when set; re-running would double-pay. Lives on + // flow context so the gate survives view transitions. txHash, error, isLoading: isLoading || isCreatingCharge || isRecording, diff --git a/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx b/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx index 1fe3520ef..20105512c 100644 --- a/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx +++ b/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx @@ -37,6 +37,7 @@ export function ContributePotInputView() { isInsufficientBalance, isLoggedIn, isLoading, + txHash, totalAmount, totalCollected, contributors, @@ -46,9 +47,16 @@ export function ContributePotInputView() { setCurrentView, } = useContributePotFlow() + // `txHash` is the post-on-chain gate (see useContributePotFlow). While + // set, BOTH pay paths must be disabled — re-firing executeContribution + // (Peanut wallet) would double-pay on-chain, and opening the external- + // wallet view for the same charge would let the user pay it again from + // their EOA. Sentry PEANUT-UI-QH9 / 2026-06-01. + const isPostOnChain = !!txHash + // handle submit - directly execute contribution const handlePayWithPeanut = () => { - if (canProceed && hasSufficientBalance && !isLoading) { + if (canProceed && hasSufficientBalance && !isLoading && !isPostOnChain) { executeContribution() } } @@ -56,7 +64,7 @@ export function ContributePotInputView() { // handle External Wallet click const [isExternalWalletLoading, setIsExternalWalletLoading] = useState(false) const handleOpenExternalWalletFlow = async () => { - if (canProceed && !isLoading) { + if (canProceed && !isLoading && !isPostOnChain) { setIsExternalWalletLoading(true) try { const res = await executeContribution(true, true) // return after creating charge @@ -122,7 +130,11 @@ export function ContributePotInputView() { recipientUserId={recipient?.userId} recipientUsername={recipient?.username} onPayWithPeanut={handlePayWithPeanut} - isPaymentLoading={isLoading && !isExternalWalletLoading} + // Treat post-on-chain as `loading` for the action list — + // disables BOTH pay buttons + renders a spinner so the + // user doesn't perceive the page as frozen. The actual + // settlement is happening BE-side regardless. + isPaymentLoading={(isLoading && !isExternalWalletLoading) || isPostOnChain} isExternalWalletLoading={isExternalWalletLoading} onPayWithExternalWallet={handleOpenExternalWalletFlow} /> diff --git a/src/features/payments/flows/direct-send/useDirectSendFlow.ts b/src/features/payments/flows/direct-send/useDirectSendFlow.ts index 61df60aa2..1ea75555e 100644 --- a/src/features/payments/flows/direct-send/useDirectSendFlow.ts +++ b/src/features/payments/flows/direct-send/useDirectSendFlow.ts @@ -113,6 +113,15 @@ export function useDirectSendFlow() { return } + // Post-on-chain safety gate: once sendMoney has set txHash on the + // flow context, do NOT re-run this handler — re-firing sendMoney + // would produce a second on-chain tx attributed to the same charge + // (Sentry PEANUT-UI-QH9 / 2026-06-01 — Konrad's offramp shape; same + // shape exists in this flow). Using context state (not local hook + // state) is what makes the gate survive view-transitions; see + // the gate-altitude rationale in DirectSendFlowContext. + if (txHash) return + setIsLoading(true) clearError() @@ -175,6 +184,7 @@ export function useDirectSendFlow() { usdAmount, attachment, walletAddress, + txHash, createCharge, sendMoney, recordPayment, @@ -197,6 +207,10 @@ export function useDirectSendFlow() { attachment, charge, payment, + // txHash is the post-on-chain gate — truthy iff sendMoney already + // fired. Consumers MUST disable pay buttons (and not offer a + // retryable error UX) when this is set; re-running would call + // sendMoney again and double-pay. txHash, error, isLoading: isLoading || isCreatingCharge || isRecording, diff --git a/src/features/payments/flows/direct-send/views/SendInputView.tsx b/src/features/payments/flows/direct-send/views/SendInputView.tsx index 45e6f68ce..da67a7315 100644 --- a/src/features/payments/flows/direct-send/views/SendInputView.tsx +++ b/src/features/payments/flows/direct-send/views/SendInputView.tsx @@ -38,6 +38,7 @@ export function SendInputView() { isInsufficientBalance, isLoggedIn, isLoading, + txHash, setAmount, setAttachment, executePayment, @@ -45,13 +46,16 @@ export function SendInputView() { // handle submit - directly execute payment const handleSubmit = () => { - if (canProceed && hasSufficientBalance && !isLoading) { + if (canProceed && hasSufficientBalance && !isLoading && !txHash) { executePayment() } } - // determine button text and state - const isButtonDisabled = !canProceed || (isLoggedIn && !hasSufficientBalance) || isLoading + // determine button text and state. `txHash` keeps the button + // disabled after sendMoney has fired so a confirm timeout (or any + // post-on-chain error) can't lead to a second click → second on-chain + // tx. Sentry PEANUT-UI-QH9 / 2026-06-01. + const isButtonDisabled = !canProceed || (isLoggedIn && !hasSufficientBalance) || isLoading || !!txHash const isAmountEntered = !!amount && parseFloat(amount) > 0 return ( @@ -103,7 +107,11 @@ export function SendInputView() { {isInsufficientBalance && ( diff --git a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts index 5729171c7..695eec70a 100644 --- a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts +++ b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts @@ -260,6 +260,17 @@ export function useSemanticRequestFlow() { return { success: false } } + // Post-on-chain safety gate: once sendMoney has set txHash on + // the flow context, do NOT re-run this handler — re-firing + // sendMoney would produce a second on-chain tx attributed to + // the same charge (Sentry PEANUT-UI-QH9, 2026-06-01). Returning + // success=false is critical: the external-wallet branch in the + // input view conditions `setCurrentView('EXTERNAL_WALLET')` on + // res.success, so a fake-success short-circuit would mis-route + // a user who already paid via Peanut wallet into the external- + // wallet flow for the same charge. + if (txHash) return { success: false } + setIsLoading(true) clearError() @@ -362,6 +373,7 @@ export function useSemanticRequestFlow() { createCharge, sendMoney, recordPayment, + txHash, queryClient, updateUrlWithChargeId, setCharge, @@ -476,6 +488,9 @@ export function useSemanticRequestFlow() { return } + // Post-on-chain safety gate — see handlePayment above for full reasoning. + if (txHash) return + setIsLoading(true) clearError() @@ -581,6 +596,7 @@ export function useSemanticRequestFlow() { sendMoney, sendTransactions, recordPayment, + txHash, queryClient, setTxHash, setPayment, @@ -615,6 +631,11 @@ export function useSemanticRequestFlow() { attachment, charge, payment, + // txHash is the post-on-chain gate — truthy iff sendMoney already + // fired. Consumers (input + confirm views) MUST disable pay buttons + // (Peanut wallet AND external wallet) when this is set; re-running + // would call sendMoney again and double-pay. Lives on flow context + // (not in usePaymentRecorder) so the gate survives view transitions. txHash, error, isLoading: isLoading || isCreatingCharge || isFetchingCharge || isRecording || isCalculatingRoute, diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx index c64f880b0..ff2f8db74 100644 --- a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx +++ b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx @@ -57,11 +57,20 @@ export function SemanticRequestConfirmView() { selectedTokenData, urlToken, isTokenDenominated, + txHash, goBackToInitial, executePayment, prepareRoute, } = useSemanticRequestFlow() + // `txHash` is the post-on-chain gate (see useSemanticRequestFlow). On + // cross-chain confirm, this is the most-likely-to-fire path because the + // BE ack involves Rhino routing — Sentry PEANUT-UI-QH9-shape failures + // are most plausible here. Disable Confirm + Retry once sendMoney has + // returned a hash; the executePayment handler also early-returns + // defensively. + const isPostOnChain = !!txHash + // get the display symbol for the requested amount const displayTokenSymbol = useMemo(() => { if (isTokenDenominated && urlToken) { @@ -144,13 +153,14 @@ export function SemanticRequestConfirmView() { // handle confirm const handleConfirm = () => { - if (!isLoading && !isCalculatingRoute) { + if (!isLoading && !isCalculatingRoute && !isPostOnChain) { executePayment() } } // handle retry const handleRetry = async () => { + if (isPostOnChain) return if (errorMessage) { // retry route calculation await prepareRoute() @@ -254,9 +264,9 @@ export function SemanticRequestConfirmView() {
{errorMessage ? (