{sortedActionMethods.map((method) => {
if (flow === 'request' && method.id === 'exchange-or-wallet') {
- const shouldShowPeanutBalanceModal = !isUsePeanutBalanceModalShown && hasSufficientPeanutBalance
return (
-
{
- if (shouldShowPeanutBalanceModal) {
- setShowUsePeanutBalanceModal(true)
- }
- }}
- >
- {/* Disable daimo pay button if peanut balance is enough to pay for the request */}
-
+
+
{
+ // Check balance before showing Daimo widget
+ if (!isUsePeanutBalanceModalShown && hasSufficientPeanutBalance) {
+ setSelectedPaymentMethod(method)
+ setShowUsePeanutBalanceModal(true)
+ return false // Don't show Daimo yet
+ }
+ return true // Proceed with Daimo
+ }}
+ />
)
}
@@ -316,6 +326,7 @@ export default function ActionList({
onClose={() => {
setShowUsePeanutBalanceModal(false)
setIsUsePeanutBalanceModalShown(true)
+ setSelectedPaymentMethod(null)
}}
title="Use your Peanut balance instead"
description={
@@ -328,9 +339,25 @@ export default function ActionList({
shadowSize: '4',
onClick: () => {
setShowUsePeanutBalanceModal(false)
+ setIsUsePeanutBalanceModalShown(true)
+ setSelectedPaymentMethod(null)
setTriggerPayWithPeanut(true)
},
},
+ {
+ text: 'Continue',
+ shadowSize: '4',
+ variant: 'stroke',
+ onClick: () => {
+ setShowUsePeanutBalanceModal(false)
+ setIsUsePeanutBalanceModalShown(true)
+ // Proceed with the method the user originally selected
+ if (selectedPaymentMethod) {
+ handleMethodClick(selectedPaymentMethod, true) // true = bypass modal check
+ }
+ setSelectedPaymentMethod(null)
+ },
+ },
]}
iconContainerClassName="bg-primary-1"
preventClose={false}
diff --git a/src/components/Common/ActionListDaimoPayButton.tsx b/src/components/Common/ActionListDaimoPayButton.tsx
index 347c95a95..29b966e67 100644
--- a/src/components/Common/ActionListDaimoPayButton.tsx
+++ b/src/components/Common/ActionListDaimoPayButton.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState, useRef } from 'react'
+import { useCallback, useState, useRef, useEffect } from 'react'
import IconStack from '../Global/IconStack'
import { useAppDispatch, usePaymentStore } from '@/redux/hooks'
import { paymentActions } from '@/redux/slices/payment-slice'
@@ -14,9 +14,14 @@ import { ActionListCard } from '../ActionListCard'
interface ActionListDaimoPayButtonProps {
handleContinueWithPeanut: () => void
showConfirmModal: boolean
+ onBeforeShow?: () => boolean | Promise
}
-const ActionListDaimoPayButton = ({ handleContinueWithPeanut, showConfirmModal }: ActionListDaimoPayButtonProps) => {
+const ActionListDaimoPayButton = ({
+ handleContinueWithPeanut,
+ showConfirmModal,
+ onBeforeShow,
+}: ActionListDaimoPayButtonProps) => {
const dispatch = useAppDispatch()
const searchParams = useSearchParams()
const method = ACTION_METHODS.find((method) => method.id === 'exchange-or-wallet')
@@ -130,7 +135,7 @@ const ActionListDaimoPayButton = ({ handleContinueWithPeanut, showConfirmModal }
}
}
},
- [chargeDetails, completeDaimoPayment, dispatch]
+ [chargeDetails, completeDaimoPayment, dispatch, peanutWalletAddress]
)
if (!method || !parsedPaymentData) return null
@@ -142,11 +147,21 @@ const ActionListDaimoPayButton = ({ handleContinueWithPeanut, showConfirmModal }
toAddress={parsedPaymentData.recipient.resolvedAddress}
onPaymentCompleted={handleCompleteDaimoPayment}
onBeforeShow={async () => {
+ // First check if parent wants to intercept (e.g. show balance modal)
+ if (onBeforeShow) {
+ const shouldProceed = await onBeforeShow()
+ if (!shouldProceed) {
+ return false
+ }
+ }
+
+ // Then check invite modal
if (!confirmLoseInvite && showConfirmModal) {
setShowInviteModal(true)
return false
}
- // Don't reset confirmLoseInvite here - let it be reset only when modal is closed or payment is initiated
+
+ // Finally initiate payment
return await handleInitiateDaimoPayment()
}}
disabled={!usdAmount}
diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx
index 49061d39c..a70edd939 100644
--- a/src/components/Payment/PaymentForm/index.tsx
+++ b/src/components/Payment/PaymentForm/index.tsx
@@ -235,12 +235,8 @@ export const PaymentForm = ({
}
} else {
// regular send/pay
- if (
- !showRequestPotInitialView && // don't apply balance check on request pot payment initial view
- isActivePeanutWallet &&
- areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)
- ) {
- // peanut wallet payment
+ if (isActivePeanutWallet && areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)) {
+ // peanut wallet payment - ALWAYS check balance (including request pots)
const walletNumeric = parseFloat(String(peanutWalletBalance).replace(/,/g, ''))
if (walletNumeric < parsedInputAmount) {
dispatch(paymentActions.setError('Insufficient balance'))
@@ -372,8 +368,8 @@ export const PaymentForm = ({
if (inviteError) {
setInviteError(false)
}
- // Invites will be handled in the payment page, skip this step for request pots initial view
- if (!showRequestPotInitialView && isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) {
+ // Handle insufficient balance - redirect to add money
+ if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) {
// If the user doesn't have app access, accept the invite before claiming the link
if (recipient.recipientType === 'USERNAME' && !user?.user.hasAppAccess) {
const isAccepted = await handleAcceptInvite()
@@ -514,10 +510,7 @@ export const PaymentForm = ({
return 'Send'
}
- if (showRequestPotInitialView) {
- return 'Pay'
- }
-
+ // Check insufficient balance BEFORE other conditions
if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) {
return (
@@ -530,6 +523,10 @@ export const PaymentForm = ({
)
}
+ if (showRequestPotInitialView) {
+ return 'Pay'
+ }
+
if (isActivePeanutWallet) {
return (
@@ -546,12 +543,11 @@ export const PaymentForm = ({
}
const getButtonIcon = (): IconName | undefined => {
- if (!showRequestPotInitialView && !isExternalWalletConnected && isExternalWalletFlow) return 'wallet-outline'
+ if (!isExternalWalletConnected && isExternalWalletFlow) return 'wallet-outline'
- if (!showRequestPotInitialView && isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow)
- return 'arrow-down'
+ if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) return 'arrow-down'
- if (!showRequestPotInitialView && !isProcessing && isActivePeanutWallet && !isExternalWalletFlow)
+ if (!isProcessing && isActivePeanutWallet && !isExternalWalletFlow && !showRequestPotInitialView)
return 'arrow-up-right'
return undefined
diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts
index b2646ff8a..16570630e 100644
--- a/src/hooks/usePaymentInitiator.ts
+++ b/src/hooks/usePaymentInitiator.ts
@@ -1,4 +1,5 @@
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants'
+import { BALANCE_DECREASE, INITIATE_PAYMENT } from '@/constants/query.consts'
import { tokenSelectorContext } from '@/context'
import { useWallet } from '@/hooks/wallet/useWallet'
import { type ParsedURL } from '@/lib/url-parser/types/payment'
@@ -26,6 +27,7 @@ import { getRoute, type PeanutCrossChainRoute } from '@/services/swap'
import { estimateTransactionCostUsd } from '@/app/actions/tokens'
import { captureException } from '@sentry/nextjs'
import { useRouter } from 'next/navigation'
+import { useQueryClient } from '@tanstack/react-query'
enum ELoadingStep {
IDLE = 'Idle',
@@ -82,6 +84,7 @@ export const usePaymentInitiator = () => {
const router = useRouter()
const config = useConfig()
const { chain: connectedWalletChain } = useWagmiAccount()
+ const queryClient = useQueryClient()
const [slippagePercentage, setSlippagePercentage] = useState
(undefined)
const [unsignedTx, setUnsignedTx] = useState(null)
@@ -602,12 +605,22 @@ export const usePaymentInitiator = () => {
]
)
- // @dev TODO: Refactor to TanStack Query mutation for architectural consistency
- // Current: This async function works correctly (protected by isProcessing state)
- // but is NOT tracked by usePendingTransactions mutation system.
- // Future improvement: Wrap in useMutation for consistency with other balance-decreasing ops.
- // mutationKey: [BALANCE_DECREASE, INITIATE_PAYMENT]
- // Complexity: HIGH - complex state/Redux integration. Low priority.
+ // @dev Architecture Note: initiatePayment flow and mutation tracking
+ //
+ // Current: This async function uses state-based lifecycle (isProcessing, loadingStep)
+ // rather than TanStack Query mutations. This is INTENTIONAL because:
+ // 1. It has two phases: charge preparation (no balance change) + payment execution (balance decrease)
+ // 2. Only the payment execution phase triggers balance-decreasing mutations (via sendMoney/sendTransactions)
+ // 3. sendMoney already properly wraps mutations with mutationKey: [BALANCE_DECREASE, SEND_MONEY]
+ // 4. usePendingTransactions tracks ALL balance-decreasing operations globally
+ //
+ // Bug fix (2025-10): Added guard at line 648 to prevent premature payment execution when
+ // fetching existing charges. Previously, fetching an existing charge (chargeCreated=false)
+ // without skipChargeCreation=true would fall through and trigger sendMoney() prematurely,
+ // causing optimistic balance updates before user confirmed payment.
+ //
+ // Future consideration: Could wrap entire initiatePayment in useMutation, but complexity is HIGH
+ // due to two-phase flow, Redux integration, and multiple return points. Current architecture works.
//
// initiate and process payments
const initiatePayment = useCallback(
@@ -628,19 +641,25 @@ export const usePaymentInitiator = () => {
console.log('Proceeding with charge details:', determinedChargeDetails.uuid)
// 2. handle charge state
- if (
- payload.returnAfterChargeCreation || // For request pot payment, return after charge creation
+ // Return early if:
+ // a) Explicitly told to return after charge creation (request pot initial view)
+ // b) Charge was just created AND needs special handling (external wallet/cross-chain)
+ // c) Fetching existing charge WITHOUT explicit skipChargeCreation (not from CONFIRM view)
+ const shouldReturnAfterCharge =
+ payload.returnAfterChargeCreation ||
(chargeCreated &&
(payload.isExternalWalletFlow ||
!isPeanutWallet ||
(isPeanutWallet &&
(!areEvmAddressesEqual(determinedChargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) ||
- determinedChargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString()))))
- ) {
+ determinedChargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString())))) ||
+ // NEW: If charge exists (not created) and we're NOT explicitly skipping charge creation,
+ // then we're in "prepare" mode and shouldn't execute payment yet
+ (!chargeCreated && payload.chargeId && !payload.skipChargeCreation)
+
+ if (shouldReturnAfterCharge) {
console.log(
- `Charge created. Transitioning to Confirm view for: ${
- payload.isExternalWalletFlow ? 'Add Money Flow' : 'External Wallet'
- }.`
+ `Charge ready. Returning without payment execution. (chargeCreated: ${chargeCreated}, skipChargeCreation: ${payload.skipChargeCreation})`
)
setLoadingStep('Charge Created')
return { status: 'Charge Created', charge: determinedChargeDetails, success: false }