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..f44f8cbb99
--- /dev/null
+++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx
@@ -0,0 +1,426 @@
+/**
+ * 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: unknown[]) => mockCaptureMessage(...args),
+}))
+
+const mockPosthogCapture = jest.fn()
+jest.mock('posthog-js', () => ({
+ __esModule: true,
+ default: { capture: (...args: unknown[]) => 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: { status?: string } | null) => receipt?.status === 'reverted',
+}))
+
+jest.mock('@/utils/url.utils', () => ({
+ appBaseUrl: () => 'https://peanut.test',
+}))
+
+jest.mock('@/utils/friendly-error.utils', () => ({
+ ErrorHandler: (err: unknown) => (err instanceof Error ? 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: { onConfirm: () => void }) => (
+
+ ),
+}))
+
+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: { address: string }) => {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)
+ Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false })
+})
+
+// ---------- 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'))
+ // 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.
+ expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything())
+ 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,
+ 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('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 () => {
+ // 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' },
+ 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,
+ 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..ed6985dccb 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,32 +382,64 @@ 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
+
+ // 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
- 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,
+ if (mixedWithoutMinedHash) {
+ captureMessage('withdraw: skipping recordPayment — mixed spend without mined receipt', {
+ level: 'warning',
+ 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)
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..11b5021fb7 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. */
@@ -184,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 {
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
}