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
56 changes: 43 additions & 13 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default function WithdrawCryptoPage() {
setError: setWithdrawError,
chargeDetails,
setChargeDetails,
transactionHash,
setTransactionHash,
paymentDetails,
setPaymentDetails,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.')
Expand Down Expand Up @@ -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<ReturnType<typeof recordPayment>> | 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')
Expand Down Expand Up @@ -407,6 +436,7 @@ export default function WithdrawCryptoPage() {
sendMoney,
isCrossChainWithdrawal,
recordPayment,
transactionHash,
setCurrentView,
setTransactionHash,
setPaymentDetails,
Expand Down
16 changes: 16 additions & 0 deletions src/features/payments/flows/contribute-pot/useContributePotFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -241,6 +252,7 @@ export function useContributePotFlow() {
createCharge,
sendMoney,
recordPayment,
txHash,
setCharge,
setTxHash,
setPayment,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function ContributePotInputView() {
isInsufficientBalance,
isLoggedIn,
isLoading,
txHash,
totalAmount,
totalCollected,
contributors,
Expand All @@ -46,17 +47,24 @@ 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()
}
}

// 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
Expand Down Expand Up @@ -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}
/>
Expand Down
14 changes: 14 additions & 0 deletions src/features/payments/flows/direct-send/useDirectSendFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -175,6 +184,7 @@ export function useDirectSendFlow() {
usdAmount,
attachment,
walletAddress,
txHash,
createCharge,
sendMoney,
recordPayment,
Expand All @@ -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,
Expand Down
16 changes: 12 additions & 4 deletions src/features/payments/flows/direct-send/views/SendInputView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,24 @@ export function SendInputView() {
isInsufficientBalance,
isLoggedIn,
isLoading,
txHash,
setAmount,
setAttachment,
executePayment,
} = useDirectSendFlow()

// 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 (
Expand Down Expand Up @@ -103,7 +107,11 @@ export function SendInputView() {
<SendWithPeanutCta
onClick={handleSubmit}
disabled={isButtonDisabled}
loading={isLoading}
// OR in `!!txHash` so the button keeps its spinner
// after the on-chain leg fired — without it the user
// sees a disabled button + error toast with no
// signal that funds are processing BE-side.
loading={isLoading || !!txHash}
insufficientBalance={isInsufficientBalance}
/>
{isInsufficientBalance && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -362,6 +373,7 @@ export function useSemanticRequestFlow() {
createCharge,
sendMoney,
recordPayment,
txHash,
queryClient,
updateUrlWithChargeId,
setCharge,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -581,6 +596,7 @@ export function useSemanticRequestFlow() {
sendMoney,
sendTransactions,
recordPayment,
txHash,
queryClient,
setTxHash,
setPayment,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -254,9 +264,9 @@ export function SemanticRequestConfirmView() {
<div className="flex flex-col gap-4">
{errorMessage ? (
<Button
disabled={isLoading || isCalculatingRoute}
disabled={isLoading || isCalculatingRoute || isPostOnChain}
onClick={handleRetry}
loading={isLoading || isCalculatingRoute}
loading={isLoading || isCalculatingRoute || isPostOnChain}
shadowSize="4"
className="w-full"
icon="retry"
Expand All @@ -266,15 +276,15 @@ export function SemanticRequestConfirmView() {
</Button>
) : isCardPioneer ? (
<SendWithPeanutCta
disabled={isLoading || isCalculatingRoute || isFeeEstimationError}
disabled={isLoading || isCalculatingRoute || isFeeEstimationError || isPostOnChain}
onClick={handleConfirm}
loading={isLoading || isCalculatingRoute}
loading={isLoading || isCalculatingRoute || isPostOnChain}
/>
) : (
<Button
disabled={isLoading || isCalculatingRoute || isFeeEstimationError}
disabled={isLoading || isCalculatingRoute || isFeeEstimationError || isPostOnChain}
onClick={handleConfirm}
loading={isLoading || isCalculatingRoute}
loading={isLoading || isCalculatingRoute || isPostOnChain}
shadowSize="4"
className="w-full"
icon="arrow-up-right"
Expand Down
Loading
Loading