From 6d473c7224e1c3885b4fba3e54807a8dd1825060 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:20:22 +0300 Subject: [PATCH 1/5] fix(withdraw): complete the user-facing charge on collateral-routed withdraws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-chain crypto withdraws funded from Rain card collateral succeeded on-chain but never completed their charge intent — the page skipped recordPayment and never passed chargeId, so the charge rotted PENDING until the reaper failed it. History hides both rows (phantom + abandoned-draft filters), so users watched their balance drop with no Activity entry (98 charges / 55 users in 14 days). Mirror the direct-send contract: pass chargeDetails.uuid into sendMoney (the backend uses the charge as the prep and settles it in /submit's trusted completion) and always recordPayment — mixed spends rely on it, collateral-only re-enters trusted completion idempotently as the recovery net. recordPayment failures after a collateral-routed same-chain spend degrade to the success view instead of a false 'failed': the funds have already moved. Also refresh the three stale hook/service comments that instructed callers to SKIP recordPayment on collateral-only — the guidance that seeded this bug. --- .../crypto-withdraw-confirm.test.tsx | 345 ++++++++++++++++++ src/app/(mobile-ui)/withdraw/crypto/page.tsx | 64 +++- src/hooks/wallet/useSendMoney.ts | 5 +- src/hooks/wallet/useSpendBundle.ts | 15 +- src/services/rain.ts | 5 +- 5 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx new file mode 100644 index 0000000000..9c5a4beb71 --- /dev/null +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -0,0 +1,345 @@ +/** + * Crypto Withdraw — Confirm Flow Tests + * + * Regression net for the "successful withdrawal stuck PENDING / missing from + * Activity" bug: same-chain collateral-routed withdraws must pass the charge + * uuid into sendMoney (so the backend settles the charge server-side) and must + * ALWAYS call recordPayment (mixed spends rely on it; collateral-only uses it + * as the idempotent recovery net). A recordPayment failure after funds moved + * must degrade to the success view on collateral-routed paths, while + * smart-only keeps its historical hard-fail behavior. + * + * Strategy (same as withdraw-states.test.tsx): mock every hook/service at the + * module level; assert on the context-setter mocks rather than re-rendering. + */ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +// ---------- module-level mocks ---------- + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn(), back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), + useSearchParams: () => ({ get: () => null }), + usePathname: () => '/withdraw/crypto', +})) + +const mockCaptureMessage = jest.fn() +jest.mock('@sentry/nextjs', () => ({ + captureMessage: (...args: any[]) => mockCaptureMessage(...args), +})) + +const mockPosthogCapture = jest.fn() +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { capture: (...args: any[]) => mockPosthogCapture(...args), init: jest.fn() }, +})) + +jest.mock('use-haptic', () => ({ + useHaptic: () => ({ triggerHaptic: jest.fn() }), +})) + +jest.mock('@/hooks/useSafeBack', () => ({ + useSafeBack: () => jest.fn(), +})) + +jest.mock('@/context', () => { + const ReactActual = jest.requireActual('react') + return { + tokenSelectorContext: ReactActual.createContext({ resetTokenContextProvider: jest.fn() }), + } +}) + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + PEANUT_WALLET_TOKEN_DECIMALS: 6, +})) + +jest.mock('@/constants/general.consts', () => ({ + ROUTE_NOT_FOUND_ERROR: 'No route found', +})) + +jest.mock('@/constants/analytics.consts', () => ({ + ANALYTICS_EVENTS: { + WITHDRAW_CONFIRMED: 'withdraw_confirmed', + WITHDRAW_COMPLETED: 'withdraw_completed', + WITHDRAW_FAILED: 'withdraw_failed', + }, +})) + +jest.mock('@/utils/token.utils', () => ({ + NATIVE_TOKEN_ADDRESS: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', +})) + +jest.mock('@/utils/cross-chain-fee.utils', () => ({ + isWithdrawFeeDisproportionate: () => false, +})) + +jest.mock('@/utils/balance.utils', () => ({ + isAmountWithinBalance: () => true, +})) + +jest.mock('@/utils/withdraw.utils', () => ({ + isBelowRhinoMinDeposit: () => false, +})) + +jest.mock('@/utils/general.utils', () => ({ + isTxReverted: (receipt: any) => receipt?.status === 'reverted', +})) + +jest.mock('@/utils/url.utils', () => ({ + appBaseUrl: () => 'https://peanut.test', +})) + +jest.mock('@/utils/friendly-error.utils', () => ({ + ErrorHandler: (err: any) => err?.message ?? 'Something went wrong', +})) + +jest.mock('@/interfaces/peanut-sdk-types', () => ({ + EPeanutLinkType: { native: 0, erc20: 1 }, +})) + +jest.mock('@/services/charges', () => ({ + chargesApi: { create: jest.fn(), get: jest.fn() }, +})) + +jest.mock('@/services/requests', () => ({ + requestsApi: { create: jest.fn() }, +})) + +// ---------- view mocks ---------- + +jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({ + __esModule: true, + default: (props: any) => ( + + ), +})) + +jest.mock('@/components/Withdraw/views/Initial.withdraw.view', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/features/payments/shared/components/PaymentSuccessView', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/Global/AddressLink', () => ({ + __esModule: true, + default: (props: any) => {props.address}, +})) + +jest.mock('@/components/Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/Slider', () => ({ + Slider: () =>
, +})) + +// ---------- flow hooks ---------- + +const CHARGE_UUID = 'charge-uuid-123' +const RECIPIENT = '0x1111111111111111111111111111111111111111' +const USER_ADDRESS = '0x2222222222222222222222222222222222222222' + +const mockSetCurrentView = jest.fn() +const mockSetPaymentDetails = jest.fn() +const mockSetTransactionHash = jest.fn() +const mockSetPaymentError = jest.fn() +const mockSetWithdrawError = jest.fn() + +const chargeDetails = { + uuid: CHARGE_UUID, + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + tokenAmount: '50', + tokenDecimals: 6, + tokenType: '1', + chainId: '42161', + requestLink: { recipientAddress: RECIPIENT }, +} + +const withdrawData = { + address: RECIPIENT, + token: { address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', symbol: 'USDC', decimals: 6, price: 1 }, + chain: { chainId: 42161, name: 'Arbitrum' }, + amount: '50', +} + +const mockWithdrawFlow = { + amountToWithdraw: '50', + usdAmount: '50', + setAmountToWithdraw: jest.fn(), + currentView: 'CONFIRM', + setCurrentView: mockSetCurrentView, + withdrawData, + setWithdrawData: jest.fn(), + showCompatibilityModal: false, + setShowCompatibilityModal: jest.fn(), + isPreparingReview: false, + setIsPreparingReview: jest.fn(), + paymentError: null, + setPaymentError: mockSetPaymentError, + setError: mockSetWithdrawError, + chargeDetails, + setChargeDetails: jest.fn(), + setTransactionHash: mockSetTransactionHash, + paymentDetails: null, + setPaymentDetails: mockSetPaymentDetails, + resetWithdrawFlow: jest.fn(), +} + +jest.mock('@/context/WithdrawFlowContext', () => ({ + useWithdrawFlow: () => mockWithdrawFlow, +})) + +const mockSendMoney = jest.fn() +const mockSendTransactions = jest.fn() +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ + isConnected: true, + address: USER_ADDRESS, + sendMoney: mockSendMoney, + sendTransactions: mockSendTransactions, + spendableBalance: 100n * 10n ** 6n, + }), +})) + +// Same-chain, same-token route: isXChain/isDiffToken false → sendMoney path. +const mockCrossChainTransfer = { + transactions: [{ to: RECIPIENT, value: 0n, data: '0x' }], + receiveAmount: '50', + payAmount: null, + feeUsd: 0, + minDepositLimitUsd: 0, + isCalculating: false, + isXChain: false, + isDiffToken: false, + error: null, + calculate: jest.fn(), + reset: jest.fn(), +} +jest.mock('@/features/payments/shared/hooks/useCrossChainTransfer', () => ({ + useCrossChainTransfer: () => mockCrossChainTransfer, +})) + +const mockRecordPayment = jest.fn() +jest.mock('@/features/payments/shared/hooks/usePaymentRecorder', () => ({ + usePaymentRecorder: () => ({ + isRecording: false, + error: null, + recordPayment: mockRecordPayment, + reset: jest.fn(), + }), +})) + +import WithdrawCryptoPage from '../page' + +// ---------- helpers ---------- + +const PAYMENT_RESULT = { uuid: 'payment-1' } + +const confirm = async () => { + render() + fireEvent.click(screen.getByTestId('confirm-withdraw')) +} + +beforeEach(() => { + jest.clearAllMocks() + mockRecordPayment.mockResolvedValue(PAYMENT_RESULT) +}) + +// ---------- tests ---------- + +describe('crypto withdraw confirm — charge completion', () => { + it('passes the charge uuid to sendMoney so collateral-only spends settle the charge server-side', async () => { + mockSendMoney.mockResolvedValue({ + txHash: '0xbetx', + userOpHash: undefined, + receipt: null, + strategy: 'collateral-only', + intentId: CHARGE_UUID, + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockSendMoney).toHaveBeenCalledWith( + RECIPIENT, + '50', + expect.objectContaining({ kind: 'CRYPTO_WITHDRAW', chargeId: CHARGE_UUID }) + ) + // recordPayment fires as the idempotent recovery net (backend already + // settled the charge via /submit's trusted completion). + expect(mockRecordPayment).toHaveBeenCalledWith( + expect.objectContaining({ chargeId: CHARGE_UUID, txHash: '0xbetx', payerAddress: USER_ADDRESS }) + ) + expect(mockSetPaymentDetails).toHaveBeenCalledWith(PAYMENT_RESULT) + }) + + it('records the payment for mixed same-chain spends (used to be skipped → charge rotted PENDING)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-1', + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + // The mined tx hash (not the userOp hash) must reach the validator. + expect(mockRecordPayment).toHaveBeenCalledWith( + expect.objectContaining({ chargeId: CHARGE_UUID, txHash: '0xmined' }) + ) + expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_completed', expect.anything()) + }) + + it('still shows success when recordPayment fails after a collateral-routed spend (funds already moved)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: '0xbetx', + userOpHash: undefined, + receipt: null, + strategy: 'collateral-only', + intentId: CHARGE_UUID, + }) + mockRecordPayment.mockRejectedValue(new Error('network blip')) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) + expect(mockCaptureMessage).toHaveBeenCalled() + // No user-facing failure for a withdrawal that succeeded on-chain. + expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) + expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + }) + + it('keeps surfacing recordPayment failures on the smart-only path (its only completion trigger)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'smart-only', + intentId: undefined, + }) + mockRecordPayment.mockRejectedValue(new Error('record failed')) + + await confirm() + + await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) + expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + expect(mockSetWithdrawError).toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 61aad6eb70..68f504041d 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -326,9 +326,9 @@ export default function WithdrawCryptoPage() { // sendTransactions mixed path. let finalTxHash: Hex | undefined let receipt: TransactionReceipt | null = null - // 'collateral-only' | 'smart-only' | 'mixed' — drives whether we call - // recordPayment (smart-only) or rely on the Rain webhook → - // TransactionIntent reconciliation path (collateral-only / mixed). + // 'collateral-only' | 'smart-only' | 'mixed' — how the spend was + // funded; drives how strictly the recordPayment result is treated + // (see the recordPayment note below). let strategy: 'collateral-only' | 'smart-only' | 'mixed' | undefined // Backend TransactionIntent id — used to navigate to the unified // receipt page for collateral/mixed spends. @@ -341,7 +341,16 @@ export default function WithdrawCryptoPage() { receipt: r, strategy: s, intentId: i, - } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { kind: 'CRYPTO_WITHDRAW' }) + } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { + kind: 'CRYPTO_WITHDRAW', + // Lets the backend settle the charge directly when the spend + // routes through Rain card collateral (collateral-only): the + // charge intent becomes the withdrawal preparation and + // /submit completes it server-side. Without this the charge + // rots PENDING and the successful withdrawal never shows in + // Activity (same contract as direct-send / request-pay). + chargeId: chargeDetails.uuid, + }) receipt = r strategy = s intentId = i @@ -373,25 +382,27 @@ export default function WithdrawCryptoPage() { if (!finalTxHash) throw new Error('Withdrawal returned no transaction identifier') - // Skip recordPayment when funds moved via Rain collateral on a - // SAME-chain withdrawal — the charge indexer watches for - // smart-account-outgoing transfers, but a coordinator-driven - // withdraw moves USDC from the collateral proxy and would leave - // the Charge unmatched ("failed" in history). The Rain webhook + - // TransactionIntent reconciliation is the source of truth there. - // - // 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). Without this call the intent - // gets stuck at PENDING because nothing else triggers the - // transition for the bridge-path (depositWithId, mode='pay') - // flow we use for non-stable destinations. + // Record the payment against the charge on EVERY path — completing + // the user-facing charge is what makes the withdrawal appear in + // Activity: + // - collateral-only: /prepare tagged the charge (chargeId above) + // and /submit completed it server-side; recordPayment re-enters + // the same trusted-completion path (idempotent) — the designed + // recovery net when /submit's post-mining bookkeeping fails. + // - mixed: the kernel sent a plain usdc.transfer(recipient) that + // the on-chain validator matches — the normal recordPayment path. + // - smart-only / cross-chain: unchanged — recordPayment has always + // been their only charge-completion trigger. + // This used to be SKIPPED for collateral-routed same-chain + // withdraws, which left the charge PENDING forever: history hides + // never-paid charges as abandoned drafts and hides the rain-prepare + // intent as a phantom, so a successful withdrawal was completely + // invisible in Activity while the balance dropped. const routedThroughCollateral = strategy === 'collateral-only' || strategy === 'mixed' - const skipRecordPayment = routedThroughCollateral && !isCrossChainWithdrawal + const collateralRoutedSameChain = routedThroughCollateral && !isCrossChainWithdrawal let payment: Awaited> | null = null - if (!skipRecordPayment) { + try { payment = await recordPayment({ chargeId: chargeDetails.uuid, chainId: PEANUT_WALLET_CHAIN.id.toString(), @@ -399,6 +410,19 @@ export default function WithdrawCryptoPage() { tokenAddress: PEANUT_WALLET_TOKEN as Address, payerAddress: address as Address, }) + } catch (err) { + // Funds already moved on-chain. On collateral-routed same-chain + // paths a recordPayment hiccup must not read as a failed + // withdrawal (collateral-only is already settled server-side; + // mixed then just stays PENDING, exactly like pre-fix) — + // degrade to the success view without payment details. Other + // paths keep throwing, as they always have. + if (!collateralRoutedSameChain) throw err + console.error('recordPayment failed after collateral-routed withdrawal (funds moved):', err) + captureMessage('withdraw: recordPayment failed after collateral-routed spend', { + level: 'warning', + extra: { chargeId: chargeDetails.uuid, txHash: finalTxHash, strategy }, + }) } setTransactionHash(finalTxHash) diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index c86a4e97d5..733aa78032 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -22,8 +22,9 @@ type SendMoneyParams = { kind?: RainCollateralKind /** When this send pays a Peanut request/charge, the charge uuid. If the spend * routes entirely through Rain collateral the backend completes the charge - * itself — the result's `strategy === 'collateral-only'` is the caller's - * signal to skip `recordPayment`. Ignored for other strategies. */ + * itself; callers should still `recordPayment` afterwards — the backend + * treats that as an idempotent re-entry of the trusted-completion path, + * and every other strategy relies on it to complete the charge. */ chargeId?: string /** Optional UI hook — fires once routing is picked, before any signing prompt. */ onStrategyDecided?: (strategy: Exclude) => void diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index a7a2dbcd7d..5ce1614196 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -44,11 +44,16 @@ export interface SpendBundleInput { * the Rain collateral webhook can be reconciled and categorized correctly * in history (instead of showing up as a generic "card payment"). */ kind: RainCollateralKind - /** When this spend pays a Peanut request/charge AND it routes entirely through - * Rain collateral (`collateral-only`), the charge uuid. The backend uses the - * charge intent itself as the prep and marks it COMPLETED on confirm — so the - * caller MUST skip its own `recordPayment` when `strategy === 'collateral-only'`. - * Ignored for `smart-only` and `mixed` (those keep the recordPayment path). */ + /** When this spend pays a Peanut request/charge, the charge uuid. On the + * `collateral-only` strategy the backend uses the charge intent itself as + * the prep and completes it on confirm; a follow-up `recordPayment` is + * still safe — the backend routes it through the same trusted-completion + * path (idempotent), which doubles as the recovery net when /submit's + * post-mining bookkeeping fails. Ignored for `smart-only` and `mixed`, + * where `recordPayment` remains the charge-completion trigger — so + * charge-backed callers should ALWAYS record the payment afterwards + * (skipping it leaves the charge PENDING forever and the spend invisible + * in Activity). */ chargeId?: string /** Extra calls to include in the kernel UserOp (for approve+deposit-style flows). * If present, collateral-only routing is NOT eligible — calls must run from the kernel. */ diff --git a/src/services/rain.ts b/src/services/rain.ts index 2ebd1be8b1..946a845f81 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -96,8 +96,9 @@ export interface PrepareRainWithdrawalInput { * `amount` (which is only the collateral shortfall). History shows this. */ totalAmountCents?: string /** When this withdrawal pays a Peanut request/charge, the charge uuid. - * The backend then uses the charge intent itself as the prep and marks it - * COMPLETED on confirm — so the FE must NOT also call `recordPayment`. */ + * The backend then uses the charge intent itself as the prep and + * completes it on confirm; a follow-up `recordPayment` re-enters the + * same trusted-completion path (idempotent). */ chargeId?: string } From fcad26581f20094c3c5a26ed5b93a0f9d555178a Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:25:02 +0300 Subject: [PATCH 2/5] fix(withdraw): only record mixed same-chain spends with a mined receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review find: on a receipt-wait timeout the kernel path returns only the userOp hash. Feeding that to the validator for an untagged mixed charge can never match a tx — retry exhaustion would flip the successful withdrawal to FAILED, worse than the pre-fix stuck PENDING. Skip the record with a Sentry breadcrumb in that case (collateral-only stays safe: backend-broadcast tx hash + trusted-path settlement). Also pin cross-chain rethrow + this skip in tests. --- .../crypto-withdraw-confirm.test.tsx | 40 ++++++++++++++ src/app/(mobile-ui)/withdraw/crypto/page.tsx | 55 ++++++++++++------- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index 9c5a4beb71..dedca60187 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -257,6 +257,7 @@ const confirm = async () => { beforeEach(() => { jest.clearAllMocks() mockRecordPayment.mockResolvedValue(PAYMENT_RESULT) + Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false }) }) // ---------- tests ---------- @@ -319,6 +320,9 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + // The failing call MUST have been attempted — pre-fix code never called + // recordPayment on this path, which is the bug. + expect(mockRecordPayment).toHaveBeenCalled() expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) expect(mockCaptureMessage).toHaveBeenCalled() // No user-facing failure for a withdrawal that succeeded on-chain. @@ -326,6 +330,42 @@ describe('crypto withdraw confirm — charge completion', () => { expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) }) + it('skips recordPayment when a mixed spend has no mined receipt (a userOp hash would poison the validator)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: null, + strategy: 'mixed', + intentId: 'prep-intent-1', + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockRecordPayment).not.toHaveBeenCalled() + expect(mockCaptureMessage).toHaveBeenCalled() + expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) + expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) + }) + + it('keeps rethrowing recordPayment failures on cross-chain withdrawals (mixed funding included)', async () => { + Object.assign(mockCrossChainTransfer, { isXChain: true }) + mockSendTransactions.mockResolvedValue({ + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-2', + }) + mockRecordPayment.mockRejectedValue(new Error('record failed')) + + await confirm() + + await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + expect(mockSetWithdrawError).toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + }) + it('keeps surfacing recordPayment failures on the smart-only path (its only completion trigger)', async () => { mockSendMoney.mockResolvedValue({ txHash: undefined, diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 68f504041d..ed6985dccb 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -401,28 +401,45 @@ export default function WithdrawCryptoPage() { const routedThroughCollateral = strategy === 'collateral-only' || strategy === 'mixed' const collateralRoutedSameChain = routedThroughCollateral && !isCrossChainWithdrawal + // An untagged mixed same-chain charge goes through the on-chain + // validator, which needs a MINED tx hash — a userOp hash can never + // match, and validator retry-exhaustion would flip the successful + // withdrawal to FAILED (worse than the pre-fix stuck-PENDING). If + // the receipt wait timed out, skip the record (pre-fix behavior) + // and leave a breadcrumb. collateral-only is safe regardless: its + // hash is a backend-broadcast EVM tx and the tagged charge settles + // via the trusted path. + const mixedWithoutMinedHash = strategy === 'mixed' && !isCrossChainWithdrawal && !receipt?.transactionHash + let payment: Awaited> | null = null - try { - payment = await recordPayment({ - chargeId: chargeDetails.uuid, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - txHash: finalTxHash, - tokenAddress: PEANUT_WALLET_TOKEN as Address, - payerAddress: address as Address, - }) - } catch (err) { - // Funds already moved on-chain. On collateral-routed same-chain - // paths a recordPayment hiccup must not read as a failed - // withdrawal (collateral-only is already settled server-side; - // mixed then just stays PENDING, exactly like pre-fix) — - // degrade to the success view without payment details. Other - // paths keep throwing, as they always have. - if (!collateralRoutedSameChain) throw err - console.error('recordPayment failed after collateral-routed withdrawal (funds moved):', err) - captureMessage('withdraw: recordPayment failed after collateral-routed spend', { + if (mixedWithoutMinedHash) { + captureMessage('withdraw: skipping recordPayment — mixed spend without mined receipt', { level: 'warning', - extra: { chargeId: chargeDetails.uuid, txHash: finalTxHash, strategy }, + extra: { chargeId: chargeDetails.uuid, userOpOrTxHash: finalTxHash, strategy }, }) + } else { + try { + payment = await recordPayment({ + chargeId: chargeDetails.uuid, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + txHash: finalTxHash, + tokenAddress: PEANUT_WALLET_TOKEN as Address, + payerAddress: address as Address, + }) + } catch (err) { + // Funds already moved on-chain. On collateral-routed same-chain + // paths a recordPayment hiccup must not read as a failed + // withdrawal (collateral-only is already settled server-side; + // mixed then just stays PENDING, exactly like pre-fix) — + // degrade to the success view without payment details. Other + // paths keep throwing, as they always have. + if (!collateralRoutedSameChain) throw err + console.error('recordPayment failed after collateral-routed withdrawal (funds moved):', err) + captureMessage('withdraw: recordPayment failed after collateral-routed spend', { + level: 'warning', + extra: { chargeId: chargeDetails.uuid, txHash: finalTxHash, strategy }, + }) + } } setTransactionHash(finalTxHash) From 255b083564ffdf5a5eb5f19322c715db0a4686ce Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:31:19 +0300 Subject: [PATCH 3/5] test(withdraw): type the confirm-flow test mocks (keep new code eslint-clean) --- .../__tests__/crypto-withdraw-confirm.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index dedca60187..a06b3457cf 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -25,13 +25,13 @@ jest.mock('next/navigation', () => ({ const mockCaptureMessage = jest.fn() jest.mock('@sentry/nextjs', () => ({ - captureMessage: (...args: any[]) => mockCaptureMessage(...args), + captureMessage: (...args: unknown[]) => mockCaptureMessage(...args), })) const mockPosthogCapture = jest.fn() jest.mock('posthog-js', () => ({ __esModule: true, - default: { capture: (...args: any[]) => mockPosthogCapture(...args), init: jest.fn() }, + default: { capture: (...args: unknown[]) => mockPosthogCapture(...args), init: jest.fn() }, })) jest.mock('use-haptic', () => ({ @@ -84,7 +84,7 @@ jest.mock('@/utils/withdraw.utils', () => ({ })) jest.mock('@/utils/general.utils', () => ({ - isTxReverted: (receipt: any) => receipt?.status === 'reverted', + isTxReverted: (receipt: { status?: string } | null) => receipt?.status === 'reverted', })) jest.mock('@/utils/url.utils', () => ({ @@ -92,7 +92,7 @@ jest.mock('@/utils/url.utils', () => ({ })) jest.mock('@/utils/friendly-error.utils', () => ({ - ErrorHandler: (err: any) => err?.message ?? 'Something went wrong', + ErrorHandler: (err: unknown) => (err instanceof Error ? err.message : 'Something went wrong'), })) jest.mock('@/interfaces/peanut-sdk-types', () => ({ @@ -111,7 +111,7 @@ jest.mock('@/services/requests', () => ({ jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({ __esModule: true, - default: (props: any) => ( + default: (props: { onConfirm: () => void }) => ( @@ -135,7 +135,7 @@ jest.mock('@/components/Global/ActionModal', () => ({ jest.mock('@/components/Global/AddressLink', () => ({ __esModule: true, - default: (props: any) => {props.address}, + default: (props: { address: string }) => {props.address}, })) jest.mock('@/components/Global/PeanutLoading', () => ({ From 1c728a8fbd674899e6837d2f90e61ee24f3a05ed Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:40:30 +0300 Subject: [PATCH 4/5] test(withdraw): pin cross-chain mixed-without-receipt keeps recording (CodeRabbit) --- .../crypto-withdraw-confirm.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index a06b3457cf..588f59897b 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -348,6 +348,25 @@ describe('crypto withdraw confirm — charge completion', () => { expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) }) + it('still records cross-chain mixed spends without a mined receipt (skip guard is same-chain only)', async () => { + Object.assign(mockCrossChainTransfer, { isXChain: true }) + mockSendTransactions.mockResolvedValue({ + userOpHash: '0xuserop', + receipt: null, + strategy: 'mixed', + intentId: 'prep-intent-3', + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + // Cross-chain keeps recording whatever hash it has (pre-existing + // behavior): the BE validator's cross-chain branch completes from the + // source-chain submission and never runs same-chain tx matching, so + // the mixed-without-receipt skip must not apply here. + expect(mockRecordPayment).toHaveBeenCalledWith(expect.objectContaining({ txHash: '0xuserop' })) + }) + it('keeps rethrowing recordPayment failures on cross-chain withdrawals (mixed funding included)', async () => { Object.assign(mockCrossChainTransfer, { isXChain: true }) mockSendTransactions.mockResolvedValue({ From 2b967a5c6306e9caea62e67ae4bcb87be2b4c9ba Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:42:57 +0300 Subject: [PATCH 5/5] review: purge two more skip-recordPayment comments, pin mixed tolerance + isDiffToken routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured review pass: the bug-seeding 'skip recordPayment' guidance survived in two more comments (useSpendBundle prepare call-site, useWallet sendMoney return doc) — the exact rot this PR exists to purge. Also close the two coverage gaps a scoping regression could slip through: mixed same-chain tolerance and token-boundary-only routing. --- .../crypto-withdraw-confirm.test.tsx | 24 ++++++++++++++++++- src/hooks/wallet/useSpendBundle.ts | 6 +++-- src/hooks/wallet/useWallet.ts | 9 ++++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index 588f59897b..f44f8cbb99 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -330,6 +330,24 @@ describe('crypto withdraw confirm — charge completion', () => { expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) }) + it('still shows success when recordPayment fails after a mixed same-chain spend (tolerance covers mixed too)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-1', + }) + mockRecordPayment.mockRejectedValue(new Error('network blip')) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockRecordPayment).toHaveBeenCalled() + expect(mockCaptureMessage).toHaveBeenCalled() + expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) + }) + it('skips recordPayment when a mixed spend has no mined receipt (a userOp hash would poison the validator)', async () => { mockSendMoney.mockResolvedValue({ txHash: undefined, @@ -368,7 +386,11 @@ describe('crypto withdraw confirm — charge completion', () => { }) it('keeps rethrowing recordPayment failures on cross-chain withdrawals (mixed funding included)', async () => { - Object.assign(mockCrossChainTransfer, { isXChain: true }) + // isDiffToken-only on purpose: same-chain USDC→ETH historically got + // silently downgraded to a plain transfer (see isCrossChainWithdrawal) + // — pin that token-boundary-only also routes through sendTransactions + // and keeps the strict rethrow. + Object.assign(mockCrossChainTransfer, { isDiffToken: true }) mockSendTransactions.mockResolvedValue({ userOpHash: '0xuserop', receipt: { transactionHash: '0xmined', status: 'success' }, diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 5ce1614196..11b5021fb7 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -189,8 +189,10 @@ export const useSpendBundle = () => { recipientAddress: recipient!, directTransfer: true, kind, - // When set, the backend completes the charge directly on - // confirm — caller must skip recordPayment for this strategy. + // When set, the backend uses the charge as the prep and + // completes it directly on confirm; a follow-up + // recordPayment re-enters the same trusted-completion + // path idempotently (see SpendBundleInput.chargeId). chargeId, }) diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 122db2eb10..9960296dc7 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -120,9 +120,12 @@ export const useWallet = () => { }) // `strategy` lets same-chain callers distinguish "funds left the smart // account" (smart-only) from "funds left Rain collateral" (collateral- - // only/mixed). The latter is reconciled server-side via Rain's webhook - // → TransactionIntent, so callers can skip legacy recordPayment paths - // that would otherwise leave an unmatched Charge in history. + // only/mixed). Charge-backed callers should still recordPayment on + // every strategy: collateral-only re-enters the backend's idempotent + // trusted-completion path, and the others rely on it to complete the + // charge — skipping it leaves the charge PENDING and the spend + // invisible in Activity. (Mixed callers: only record a MINED tx + // hash, never the bare userOp hash — see the withdraw page.) // `intentId` is the receipt handle for collateral/mixed spends — // `/receipt/?kind=`. return {