+ {diagnosis.state === 'nonce-bricked'
+ ? 'Your wallet is blocking new card permissions after an earlier security upgrade. One confirmation clears it.'
+ : 'Your wallet needs a one-time on-chain activation before card permissions can work.'}
+
diff --git a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts
index f6c932e2b..6b7f4e176 100644
--- a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts
+++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts
@@ -178,6 +178,50 @@ describe('repair', () => {
expect(mockSendUserOp).not.toHaveBeenCalled()
})
+ it('retry after a confirm timeout skips the send when the first op already landed', async () => {
+ chainState({ deployed: true, cn: 2, vnf: 3 })
+ const { result } = renderHook(() => useCardSignatureRepair())
+ await act(async () => {
+ await result.current.diagnose()
+ })
+
+ // Between the stale diagnosis and the retry tap, the first repair op landed.
+ chainState({ deployed: true, cn: 4, vnf: 4 })
+
+ await act(async () => {
+ expect(await result.current.repair()).toEqual({ state: 'healthy', currentNonce: 4, validNonceFrom: 4 })
+ })
+ expect(mockSendUserOp).not.toHaveBeenCalled()
+ expect(mockRebuildClient).toHaveBeenCalledWith('42161')
+ })
+
+ it('refuses to send a doomed op when the floor is beyond the kernel invalidation cap', async () => {
+ chainState({ deployed: true, cn: 2, vnf: 20 })
+ const { result } = renderHook(() => useCardSignatureRepair())
+ await act(async () => {
+ await result.current.diagnose()
+ })
+ await act(async () => {
+ expect(await result.current.repair()).toBeNull()
+ })
+ expect(mockSendUserOp).not.toHaveBeenCalled()
+ expect(result.current.error).toMatch(/manual repair/)
+ })
+
+ it('treats a dismissed passkey sheet as a quiet no-op, not an error', async () => {
+ chainState({ deployed: true, cn: 2, vnf: 3 })
+ const { result } = renderHook(() => useCardSignatureRepair())
+ await act(async () => {
+ await result.current.diagnose()
+ })
+ mockSendUserOp.mockRejectedValue(new Error('Signing failed: The operation was not allowed'))
+ await act(async () => {
+ expect(await result.current.repair()).toBeNull()
+ })
+ expect(result.current.error).toBeNull()
+ expect(result.current.isRepairing).toBe(false)
+ })
+
it('surfaces a retryable error when the repair never confirms on-chain', async () => {
jest.useFakeTimers()
try {
diff --git a/src/hooks/wallet/useCardSignatureRepair.ts b/src/hooks/wallet/useCardSignatureRepair.ts
index 19de764df..41dc0bfba 100644
--- a/src/hooks/wallet/useCardSignatureRepair.ts
+++ b/src/hooks/wallet/useCardSignatureRepair.ts
@@ -29,7 +29,7 @@ import { buildMigrationNoopCall } from '@/utils/kernelMigration.utils'
* signs against the fresh live nonce and re-stores the approval server-side.
*/
-export type CardSignatureDiagnosis =
+type CardSignatureDiagnosis =
| { state: 'undeployed' }
| { state: 'nonce-bricked'; currentNonce: number; validNonceFrom: number }
| { state: 'healthy'; currentNonce: number; validNonceFrom: number }
@@ -46,6 +46,15 @@ const CHAIN_ID = String(PEANUT_WALLET_CHAIN.id)
// included the repair userOp (same hazard ensureRootValidatorMigrated guards).
const CONFIRM_RETRIES = 5
const CONFIRM_INTERVAL_MS = 1500
+// Kernel v3.1 rejects invalidateNonce more than MAX_NONCE_INCREMENT_SIZE (10)
+// above currentNonce AND at-or-below validNonceFrom — a floor further than 10
+// ahead of the nonce has no valid invalidation target and needs manual repair.
+const MAX_NONCE_INCREMENT_SIZE = 10
+
+const isUserCancelled = (message: string) => {
+ const m = message.toLowerCase()
+ return m.includes('user rejected') || m.includes('cancelled') || m.includes('not allowed')
+}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -94,10 +103,30 @@ export const useCardSignatureRepair = () => {
* Returns the post-repair diagnosis, or null on failure.
*/
const repair = useCallback(async (): Promise => {
- const diagnosis = state.diagnosis
- if (!address || !diagnosis || diagnosis.state === 'healthy') return diagnosis
+ if (!address || !state.diagnosis || state.diagnosis.state === 'healthy') return state.diagnosis
setState((s) => ({ ...s, isRepairing: true, error: null }))
try {
+ // Re-diagnose against live state, not the mount-time snapshot: a
+ // retry after a confirm-poll timeout must not re-send an
+ // invalidateNonce the first op already consumed (it would revert).
+ const diagnosis = await readDiagnosis(address as Address)
+ if (diagnosis.state === 'healthy') {
+ await rebuildClientForChain(CHAIN_ID)
+ setState((s) => ({ ...s, diagnosis, isRepairing: false }))
+ return diagnosis
+ }
+ if (
+ diagnosis.state === 'nonce-bricked' &&
+ diagnosis.validNonceFrom + 1 > diagnosis.currentNonce + MAX_NONCE_INCREMENT_SIZE
+ ) {
+ setState((s) => ({
+ ...s,
+ diagnosis,
+ isRepairing: false,
+ error: 'This wallet needs a manual repair — please contact support.',
+ }))
+ return null
+ }
const call =
diagnosis.state === 'nonce-bricked'
? {
@@ -135,7 +164,10 @@ export const useCardSignatureRepair = () => {
setState((s) => ({ ...s, diagnosis: confirmed, isRepairing: false }))
return confirmed
} catch (error) {
- setState((s) => ({ ...s, isRepairing: false, error: (error as Error).message }))
+ const message = (error as Error).message ?? String(error)
+ // A dismissed passkey sheet is not a failure — clear busy quietly,
+ // matching how the grant path treats user-cancelled.
+ setState((s) => ({ ...s, isRepairing: false, error: isUserCancelled(message) ? null : message }))
return null
}
}, [address, state.diagnosis, handleSendUserOpEncoded, rebuildClientForChain])
From d0e51efdc33050f6df0c3aff85daa6e05aab88f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?=
Date: Tue, 14 Jul 2026 09:46:18 -0300
Subject: [PATCH 08/13] =?UTF-8?q?review:=20widen=20the=20repair=20confirm?=
=?UTF-8?q?=20poll=20to=20~12s=20(CodeRabbit)=20=E2=80=94=20fewer=20false?=
=?UTF-8?q?=20negatives=20on=20laggy=20RPCs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/hooks/wallet/useCardSignatureRepair.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/hooks/wallet/useCardSignatureRepair.ts b/src/hooks/wallet/useCardSignatureRepair.ts
index 41dc0bfba..fcf21ac61 100644
--- a/src/hooks/wallet/useCardSignatureRepair.ts
+++ b/src/hooks/wallet/useCardSignatureRepair.ts
@@ -44,7 +44,7 @@ interface RepairState {
const CHAIN_ID = String(PEANUT_WALLET_CHAIN.id)
// Post-repair confirmation poll: the public RPC can lag the bundler that
// included the repair userOp (same hazard ensureRootValidatorMigrated guards).
-const CONFIRM_RETRIES = 5
+const CONFIRM_RETRIES = 8
const CONFIRM_INTERVAL_MS = 1500
// Kernel v3.1 rejects invalidateNonce more than MAX_NONCE_INCREMENT_SIZE (10)
// above currentNonce AND at-or-below validNonceFrom — a floor further than 10
From d550911cc5c0479cc0c0c9ec7c6f15367267ce27 Mon Sep 17 00:00:00 2001
From: Aleksandar Balinda
Date: Tue, 14 Jul 2026 17:24:27 +0200
Subject: [PATCH 09/13] fix(deposits): confirm before cancelling + uniform
shortened reference
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A user cancelled a funded bank deposit while trying to report a problem:
the cancel button sits next to the support link and fired instantly with
no confirmation (PostHog: 'Issues with this transaction?' at 09:03:00,
'Cancel deposit' 11s later), making the arriving wire unmatchable.
The drawer also showed the FULL deposit reference while Add Money shows
the intentionally shortened 10-char form (some banks cap reference
fields; Bridge matches on the partial) — the two-different-codes
confusion that sent the user hunting in the drawer to begin with.
---
.../provider-actions/CancelDepositActions.tsx | 84 ++++++++---
.../__tests__/CancelDepositActions.test.tsx | 140 ++++++++++++++++++
.../BridgeDepositInstructions.tsx | 13 +-
.../BridgeDepositInstructions.test.tsx | 59 ++++++++
4 files changed, 272 insertions(+), 24 deletions(-)
create mode 100644 src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
create mode 100644 src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx
diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
index daa566a9c..dbf333f43 100644
--- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
+++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
@@ -2,6 +2,7 @@
import { useState, type ReactNode } from 'react'
import { Button } from '@/components/0_Bruddle/Button'
+import ActionModal from '@/components/Global/ActionModal'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { Icon } from '@/components/Global/Icons/Icon'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
@@ -40,6 +41,10 @@ export function CancelDepositActions({
}) {
const queryClient = useQueryClient()
const [error, setError] = useState(null)
+ // Cancels are irreversible and the button sits next to the support link —
+ // a real user cancelled a funded deposit while trying to report a problem
+ // (no way to match the wire once cancelled). Every cancel confirms first.
+ const [pendingCancel, setPendingCancel] = useState<{ noun: string; run: () => Promise } | null>(null)
if (!setIsLoading || !onClose) return null
const refetchAndClose = () =>
@@ -63,11 +68,45 @@ export function CancelDepositActions({
}
}
- // Render the active cancel button (if any) alongside any failure message.
+ const confirmThenRun = async () => {
+ if (!pendingCancel) return
+ setPendingCancel(null)
+ await wrapAction(pendingCancel.run)
+ }
+
+ // Render the active cancel button (if any) alongside the shared
+ // confirmation modal and any failure message.
const withError = (button: ReactNode) => (
{button}
{error && }
+ setPendingCancel(null)}
+ icon="ban"
+ iconContainerClassName="bg-purple-1"
+ iconProps={{ className: 'text-black' }}
+ title={`Cancel this ${pendingCancel?.noun ?? 'deposit'}?`}
+ modalClassName="!z-[9999] pointer-events-auto"
+ description={
+ <>
+ This can't be undone. If you already sent the bank transfer, don't cancel — a
+ cancelled deposit can no longer be matched to your account, and the money will be returned to
+ your bank instead.
+ >
+ }
+ modalPanelClassName="max-w-sm mx-8 !z-[9999] pointer-events-auto"
+ contentContainerClassName="relative pointer-events-auto"
+ classOverlay="!bg-black/40 !z-[9998]"
+ ctas={[
+ {
+ text: `Yes, cancel ${pendingCancel?.noun ?? 'deposit'}`,
+ shadowSize: '4',
+ className: 'md:py-2',
+ onClick: confirmThenRun,
+ },
+ ]}
+ />
)
@@ -84,9 +123,12 @@ export function CancelDepositActions({
- wrapAction(async () => {
- const result = await cancelOnramp(transaction.id)
- if (result.error) throw new Error(result.error)
+ setPendingCancel({
+ noun: 'deposit',
+ run: async () => {
+ const result = await cancelOnramp(transaction.id)
+ if (result.error) throw new Error(result.error)
+ },
})
}
/>
@@ -101,9 +143,12 @@ export function CancelDepositActions({
- wrapAction(async () => {
- const result = await mantecaApi.cancelDeposit(transaction.id)
- if (result.error) throw new Error(result.error)
+ setPendingCancel({
+ noun: 'deposit',
+ run: async () => {
+ const result = await mantecaApi.cancelDeposit(transaction.id)
+ if (result.error) throw new Error(result.error)
+ },
})
}
/>
@@ -123,17 +168,20 @@ export function CancelDepositActions({
label="Cancel Request"
disabled={!!isLoading}
onClick={() =>
- wrapAction(async () => {
- const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId
- if (!bridgeTransferId) {
- throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction')
- }
- // Bridge cancel must succeed before we cancel the
- // charge — otherwise the onramp orphans on Bridge's
- // side while the user sees the request as cancelled.
- const bridgeResult = await cancelOnramp(bridgeTransferId)
- if (bridgeResult.error) throw new Error(bridgeResult.error)
- await chargesApi.cancel(transaction.id)
+ setPendingCancel({
+ noun: 'request',
+ run: async () => {
+ const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId
+ if (!bridgeTransferId) {
+ throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction')
+ }
+ // Bridge cancel must succeed before we cancel the
+ // charge — otherwise the onramp orphans on Bridge's
+ // side while the user sees the request as cancelled.
+ const bridgeResult = await cancelOnramp(bridgeTransferId)
+ if (bridgeResult.error) throw new Error(bridgeResult.error)
+ await chargesApi.cancel(transaction.id)
+ },
})
}
/>
diff --git a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
new file mode 100644
index 000000000..1260a6673
--- /dev/null
+++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
@@ -0,0 +1,140 @@
+/**
+ * CancelDepositActions — cancel buttons for pending bank-deposit flows.
+ *
+ * Regression tests for the instant-cancel bug: the cancel button fired
+ * cancelOnramp straight from onClick with no confirmation, sitting right next
+ * to the support link — a real user cancelled a funded deposit while trying to
+ * report a problem, making the wire unmatchable. Every cancel must go through
+ * an explicit confirmation first. Nested primitives are stubbed so only this
+ * component's own logic is under test.
+ */
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+
+const mockCancelOnramp = jest.fn()
+jest.mock('@/app/actions/onramp', () => ({
+ cancelOnramp: (...args: unknown[]) => mockCancelOnramp(...args),
+}))
+const mockMantecaCancel = jest.fn()
+jest.mock('@/services/manteca', () => ({
+ mantecaApi: { cancelDeposit: (...args: unknown[]) => mockMantecaCancel(...args) },
+}))
+jest.mock('@/services/charges', () => ({
+ chargesApi: { cancel: jest.fn() },
+}))
+const mockInvalidateQueries = jest.fn()
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+}))
+jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() }))
+jest.mock('@/components/TransactionDetails/transaction-predicates', () => ({
+ isMantecaOnrampEntry: () => false,
+ isRequestEntry: () => false,
+}))
+jest.mock('@/hooks/useTransactionHistory', () => ({
+ EHistoryUserRole: { SENDER: 'SENDER' },
+}))
+jest.mock('@/components/0_Bruddle/Button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ }: {
+ children: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ }) => (
+
+ ),
+}))
+jest.mock('@/components/Global/ErrorAlert', () => ({
+ __esModule: true,
+ default: ({ description }: { description: string }) =>
+ ) : null,
+}))
+
+// import must come after jest.mock
+import { CancelDepositActions } from '../CancelDepositActions'
+
+const pendingBridgeOnramp = {
+ id: 'tx-1',
+ direction: 'bank_deposit',
+ status: 'pending',
+ extraDataForDrawer: { depositInstructions: { deposit_message: 'BRGTESTREF1234567890' } },
+} as unknown as import('@/components/TransactionDetails/transactionTransformer').TransactionDetails
+
+const renderCancel = () =>
+ render(
+
+ )
+
+beforeEach(() => {
+ mockCancelOnramp.mockReset().mockResolvedValue({})
+ mockMantecaCancel.mockReset()
+ mockInvalidateQueries.mockReset().mockResolvedValue(undefined)
+})
+
+describe('CancelDepositActions confirmation gate', () => {
+ it('does NOT cancel on the first click — it asks for confirmation instead', () => {
+ renderCancel()
+
+ expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByText('Cancel deposit'))
+
+ expect(mockCancelOnramp).not.toHaveBeenCalled()
+ expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
+ expect(screen.getByText('Cancel this deposit?')).toBeInTheDocument()
+ })
+
+ it('cancels only after the user confirms', async () => {
+ renderCancel()
+
+ fireEvent.click(screen.getByText('Cancel deposit'))
+ fireEvent.click(screen.getByText('Yes, cancel deposit'))
+
+ await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledWith('tx-1'))
+ })
+
+ it('dismissing the confirmation leaves the deposit untouched', () => {
+ renderCancel()
+
+ fireEvent.click(screen.getByText('Cancel deposit'))
+ fireEvent.click(screen.getByText('Dismiss'))
+
+ expect(mockCancelOnramp).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx
index 0d9b016a5..2223f7c08 100644
--- a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx
+++ b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx
@@ -36,12 +36,13 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa
}
value={
- {/* Display can wrap / be truncated visually via CSS, but
- the copyable text MUST be the full reference — Bridge
- won't reconcile a deposit if the user enters the
- truncated form. */}
- {instructions.deposit_message}
-
+ {/* Shortened to 10 chars to match the Add Money screen — some
+ banks (e.g. Wise) cap reference fields at 10 chars, and
+ Bridge matches deposits on the partial reference. Showing
+ the full form only here made users think they wired with
+ the "wrong" code (two-different-codes confusion). */}
+ {instructions.deposit_message?.slice(0, 10)}
+
}
hideBottomBorder={false}
diff --git a/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx
new file mode 100644
index 000000000..38872cc8b
--- /dev/null
+++ b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * BridgeDepositInstructions — the deposit-instructions block in the
+ * transaction drawer.
+ *
+ * The reference here must be the SAME shortened 10-char form the Add Money
+ * screen shows (some banks cap reference fields at 10 chars; Bridge matches
+ * deposits on the partial reference). Showing the full form only here made a
+ * user believe they wired with the wrong code — the two-different-codes
+ * confusion. Nested primitives are stubbed.
+ */
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+
+jest.mock('@/components/Payment/PaymentInfoRow', () => ({
+ PaymentInfoRow: ({ label, value }: { label: React.ReactNode; value: React.ReactNode }) => (
+
+ {label}
+ {value}
+
+ ),
+}))
+jest.mock('@/components/Global/CopyToClipboard', () => ({
+ __esModule: true,
+ default: ({ textToCopy }: { textToCopy: string }) => ,
+}))
+jest.mock('@/components/Global/MoreInfo', () => ({ __esModule: true, default: () => }))
+jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => }))
+
+// import must come after jest.mock
+import { BridgeDepositInstructions } from '../BridgeDepositInstructions'
+
+const FULL_REFERENCE = 'BRGTESTREF1234567890'
+const SHORT_REFERENCE = FULL_REFERENCE.slice(0, 10)
+
+const transaction = {
+ extraDataForDrawer: {
+ depositInstructions: {
+ deposit_message: FULL_REFERENCE,
+ bank_name: 'Deutsche Bank',
+ bank_address: 'Frankfurt, Germany',
+ account_holder_name: 'Peanut Protocol',
+ iban: 'DE89370400440532013000',
+ bic: 'COBADEFFXXX',
+ },
+ },
+} as unknown as import('@/components/TransactionDetails/transactionTransformer').TransactionDetails
+
+describe('BridgeDepositInstructions deposit message', () => {
+ it('shows and copies the same shortened 10-char reference as the Add Money screen', () => {
+ render()
+
+ expect(screen.getByText(SHORT_REFERENCE)).toBeInTheDocument()
+ expect(screen.queryByText(FULL_REFERENCE)).not.toBeInTheDocument()
+
+ const copyTexts = screen.getAllByTestId('copy').map((el) => el.getAttribute('data-text'))
+ expect(copyTexts).toContain(SHORT_REFERENCE)
+ expect(copyTexts).not.toContain(FULL_REFERENCE)
+ })
+})
From 6347c3b2d26467efe6383e473692dd7c1af79848 Mon Sep 17 00:00:00 2001
From: Aleksandar Balinda
Date: Tue, 14 Jul 2026 18:05:05 +0200
Subject: [PATCH 10/13] fix(support): make SupportDrawer intercept clicks when
opened inside a vaul drawer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root cause of the phantom deposit cancels: vaul sets pointer-events:none
on body while the transaction drawer is open, and the SupportDrawer's
backdrop/panel never re-enabled pointer events — the whole support
overlay was click-transparent, so taps on it fell through to the receipt
underneath, where 'Cancel deposit' sits adjacent to the support link and
fired irreversibly (PostHog: support link 15:05:47.7 → fall-through
click on Cancel deposit 15:05:48.8 → BE cancel 15:05:49.4).
Also per code review: single shortDepositReference() helper for the
6 reference sites across both screens, separate confirm-modal visibility
from the armed action (noun flashed mid-fade), drop no-op icon props.
---
.../components/AddMoneyBankDetails.tsx | 10 ++--
.../__tests__/SupportDrawer.test.tsx | 25 ++++++++
src/components/Global/SupportDrawer/index.tsx | 10 +++-
.../provider-actions/CancelDepositActions.tsx | 60 +++++++++----------
.../BridgeDepositInstructions.tsx | 16 ++---
src/utils/format.utils.ts | 11 ++++
6 files changed, 87 insertions(+), 45 deletions(-)
diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx
index 7cbf2592d..44af109d3 100644
--- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx
+++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx
@@ -9,7 +9,7 @@ import { useRouter, useParams } from 'next/navigation'
import { useCallback, useEffect, useMemo } from 'react'
import { countryData } from '@/components/AddMoney/consts'
import { formatCurrencyAmount } from '@/utils/currency'
-import { formatBankAccountDisplay } from '@/utils/format.utils'
+import { formatBankAccountDisplay, shortDepositReference } from '@/utils/format.utils'
import { applyBridgeCrossCurrencyFee, getCurrencyConfig, getCurrencySymbol } from '@/utils/bridge.utils'
import { RequestFulfillmentBankFlowStep, useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext'
import { formatAmount } from '@/utils/general.utils'
@@ -236,7 +236,7 @@ ${routingLabel}: ${routingValue}`
}
bankDetails += `
-Deposit Reference: ${onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'}
+Deposit Reference: ${shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'}
Please use these details to complete your bank transfer.`
@@ -274,11 +274,11 @@ Please use these details to complete your bank transfer.`
{onrampData?.depositInstructions?.depositMessage && (
@@ -416,7 +416,7 @@ Please use these details to complete your bank transfer.`
title="Double check in your bank before sending:"
items={[
`Amount: ${formattedCurrencyAmount} (exact)`,
- `Reference: ${onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} (included)`,
+ `Reference: ${shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'} (included)`,
]}
/>
diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
index 9ae96095b..59c94c252 100644
--- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
+++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
@@ -149,6 +149,31 @@ describe('SupportDrawer — Crisp load-failure fallback', () => {
})
})
+describe('SupportDrawer — pointer-events when opened inside a vaul drawer', () => {
+ beforeEach(() => {
+ mockUseCrispUserData.mockReset().mockReturnValue({ userId: undefined, email: undefined })
+ mockUseCrispTokenId.mockReset().mockReturnValue(undefined)
+ mockIsCapacitor.mockReset().mockReturnValue(false)
+ })
+
+ it('backdrop and panel explicitly re-enable pointer events while open', () => {
+ // vaul sets pointer-events:none on while the transaction drawer is
+ // open. Without an explicit pointer-events-auto, the support overlay
+ // inherits none and becomes click-transparent — taps fall through to the
+ // receipt underneath (one landed on "Cancel deposit" and cancelled a
+ // user's funded bank deposit).
+ const { container } = render()
+
+ const backdrop = container.querySelector('[aria-hidden="true"]')
+ const panel = screen.getByRole('dialog', { name: 'Support' })
+
+ expect(backdrop?.className).toContain('pointer-events-auto')
+ expect(panel.className).toContain('pointer-events-auto')
+ expect(backdrop?.className).not.toContain('pointer-events-none')
+ expect(panel.className).not.toContain('pointer-events-none')
+ })
+})
+
describe('SupportDrawer Crisp session gate — native (Capacitor)', () => {
beforeEach(() => {
mockUseCrispUserData.mockReset()
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index 83dc7d740..3fe482d63 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -131,9 +131,15 @@ const SupportDrawer = () => {
return (
<>
{/* backdrop */}
+ {/* pointer-events-auto is load-bearing on BOTH divs: when this drawer is
+ opened from inside a vaul drawer (transaction receipt), vaul sets
+ pointer-events:none on and these divs inherit it — the whole
+ support overlay becomes click-transparent, and taps fall through to
+ the receipt underneath (a fall-through tap on "Cancel deposit"
+ cancelled a user's funded bank deposit). */}
setIsSupportModalOpen(false)}
aria-hidden="true"
@@ -147,7 +153,7 @@ const SupportDrawer = () => {
aria-label="Support"
aria-modal={isSupportModalOpen}
className={`fixed inset-x-0 bottom-0 z-[999999] flex max-h-[85vh] flex-col rounded-t-[10px] border bg-background pt-4 ${
- isSupportModalOpen ? 'translate-y-0' : 'pointer-events-none translate-y-full'
+ isSupportModalOpen ? 'pointer-events-auto translate-y-0' : 'pointer-events-none translate-y-full'
}`}
style={{
transform: isSupportModalOpen ? `translateY(${dragOffset}px)` : 'translateY(100%)',
diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
index dbf333f43..0ec0dc8f4 100644
--- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
+++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
@@ -45,6 +45,10 @@ export function CancelDepositActions({
// a real user cancelled a funded deposit while trying to report a problem
// (no way to match the wire once cancelled). Every cancel confirms first.
const [pendingCancel, setPendingCancel] = useState<{ noun: string; run: () => Promise } | null>(null)
+ // Visibility is separate from pendingCancel so the noun stays rendered
+ // during the modal's fade-out (nulling it mid-fade flashed 'deposit'
+ // over 'request' titles).
+ const [confirmOpen, setConfirmOpen] = useState(false)
if (!setIsLoading || !onClose) return null
const refetchAndClose = () =>
@@ -68,9 +72,14 @@ export function CancelDepositActions({
}
}
+ const armCancel = (noun: string, run: () => Promise) => {
+ setPendingCancel({ noun, run })
+ setConfirmOpen(true)
+ }
+
const confirmThenRun = async () => {
if (!pendingCancel) return
- setPendingCancel(null)
+ setConfirmOpen(false)
await wrapAction(pendingCancel.run)
}
@@ -81,11 +90,9 @@ export function CancelDepositActions({
{button}
{error && }
setPendingCancel(null)}
+ visible={confirmOpen}
+ onClose={() => setConfirmOpen(false)}
icon="ban"
- iconContainerClassName="bg-purple-1"
- iconProps={{ className: 'text-black' }}
title={`Cancel this ${pendingCancel?.noun ?? 'deposit'}?`}
modalClassName="!z-[9999] pointer-events-auto"
description={
@@ -123,12 +130,9 @@ export function CancelDepositActions({
- setPendingCancel({
- noun: 'deposit',
- run: async () => {
- const result = await cancelOnramp(transaction.id)
- if (result.error) throw new Error(result.error)
- },
+ armCancel('deposit', async () => {
+ const result = await cancelOnramp(transaction.id)
+ if (result.error) throw new Error(result.error)
})
}
/>
@@ -143,12 +147,9 @@ export function CancelDepositActions({
- setPendingCancel({
- noun: 'deposit',
- run: async () => {
- const result = await mantecaApi.cancelDeposit(transaction.id)
- if (result.error) throw new Error(result.error)
- },
+ armCancel('deposit', async () => {
+ const result = await mantecaApi.cancelDeposit(transaction.id)
+ if (result.error) throw new Error(result.error)
})
}
/>
@@ -168,20 +169,17 @@ export function CancelDepositActions({
label="Cancel Request"
disabled={!!isLoading}
onClick={() =>
- setPendingCancel({
- noun: 'request',
- run: async () => {
- const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId
- if (!bridgeTransferId) {
- throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction')
- }
- // Bridge cancel must succeed before we cancel the
- // charge — otherwise the onramp orphans on Bridge's
- // side while the user sees the request as cancelled.
- const bridgeResult = await cancelOnramp(bridgeTransferId)
- if (bridgeResult.error) throw new Error(bridgeResult.error)
- await chargesApi.cancel(transaction.id)
- },
+ armCancel('request', async () => {
+ const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId
+ if (!bridgeTransferId) {
+ throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction')
+ }
+ // Bridge cancel must succeed before we cancel the
+ // charge — otherwise the onramp orphans on Bridge's
+ // side while the user sees the request as cancelled.
+ const bridgeResult = await cancelOnramp(bridgeTransferId)
+ if (bridgeResult.error) throw new Error(bridgeResult.error)
+ await chargesApi.cancel(transaction.id)
})
}
/>
diff --git a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx
index 2223f7c08..18e75a1e6 100644
--- a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx
+++ b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx
@@ -7,6 +7,7 @@ import CopyToClipboard from '@/components/Global/CopyToClipboard'
import MoreInfo from '@/components/Global/MoreInfo'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
import { BRIDGE_DEFAULT_ACCOUNT_HOLDER_NAME } from '@/constants/payment.consts'
+import { shortDepositReference } from '@/utils/format.utils'
import { formatIban } from '@/utils/general.utils'
/**
@@ -36,13 +37,14 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa
}
value={
- {/* Shortened to 10 chars to match the Add Money screen — some
- banks (e.g. Wise) cap reference fields at 10 chars, and
- Bridge matches deposits on the partial reference. Showing
- the full form only here made users think they wired with
- the "wrong" code (two-different-codes confusion). */}
- {instructions.deposit_message?.slice(0, 10)}
-
+ {/* Same shortened form as the Add Money screen — rationale on
+ shortDepositReference. Showing the full form only here made
+ users think they wired with the "wrong" code. */}
+ {shortDepositReference(instructions.deposit_message)}
+
}
hideBottomBorder={false}
diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts
index 773f78129..90c061ba4 100644
--- a/src/utils/format.utils.ts
+++ b/src/utils/format.utils.ts
@@ -53,3 +53,14 @@ export const formatBankAccountDisplay = (value: string | undefined, type?: 'iban
}
export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
+
+// Some banks (e.g. Wise) cap the transfer-reference field at 10 characters, and
+// Bridge matches incoming deposits on this partial reference. Every surface that
+// shows or copies a Bridge deposit reference must use this same shortened form —
+// different lengths on different screens made a user believe he wired with the
+// wrong code (two-different-codes confusion, peanut-ui#2416).
+export function shortDepositReference(reference: string): string
+export function shortDepositReference(reference: string | undefined): string | undefined
+export function shortDepositReference(reference: string | undefined): string | undefined {
+ return reference?.slice(0, 10)
+}
From 3e1f71e760f45cc839afc982d262c7f25b625ae0 Mon Sep 17 00:00:00 2001
From: Aleksandar Balinda
Date: Tue, 14 Jul 2026 18:14:03 +0200
Subject: [PATCH 11/13] fix(deposits): guard confirm CTA against double-tap
firing the cancel twice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CodeRabbit finding: two taps during the modal fade both see the armed
action and start concurrent cancellations. Ref guard, not state — a
same-frame double-tap lands before any re-render.
---
.../provider-actions/CancelDepositActions.tsx | 15 ++++++++++++---
.../__tests__/CancelDepositActions.test.tsx | 15 +++++++++++++++
2 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
index 0ec0dc8f4..7aae89c8c 100644
--- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
+++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, type ReactNode } from 'react'
+import { useRef, useState, type ReactNode } from 'react'
import { Button } from '@/components/0_Bruddle/Button'
import ActionModal from '@/components/Global/ActionModal'
import ErrorAlert from '@/components/Global/ErrorAlert'
@@ -49,6 +49,10 @@ export function CancelDepositActions({
// during the modal's fade-out (nulling it mid-fade flashed 'deposit'
// over 'request' titles).
const [confirmOpen, setConfirmOpen] = useState(false)
+ // Ref, not state: a double-tap on the confirm CTA during the modal's
+ // fade-out lands both clicks before a re-render, so a state guard would
+ // let the cancel fire twice. Refs are synchronous.
+ const isCancelRunning = useRef(false)
if (!setIsLoading || !onClose) return null
const refetchAndClose = () =>
@@ -78,9 +82,14 @@ export function CancelDepositActions({
}
const confirmThenRun = async () => {
- if (!pendingCancel) return
+ if (!pendingCancel || isCancelRunning.current) return
+ isCancelRunning.current = true
setConfirmOpen(false)
- await wrapAction(pendingCancel.run)
+ try {
+ await wrapAction(pendingCancel.run)
+ } finally {
+ isCancelRunning.current = false
+ }
}
// Render the active cancel button (if any) alongside the shared
diff --git a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
index 1260a6673..93dc9497d 100644
--- a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
+++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
@@ -128,6 +128,21 @@ describe('CancelDepositActions confirmation gate', () => {
await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledWith('tx-1'))
})
+ it('double-tapping the confirm button fires the cancel exactly once', async () => {
+ // keep the cancel in-flight so the second tap lands while the first runs
+ let resolveCancel: (v: unknown) => void
+ mockCancelOnramp.mockReturnValue(new Promise((resolve) => (resolveCancel = resolve)))
+ renderCancel()
+
+ fireEvent.click(screen.getByText('Cancel deposit'))
+ const confirmButton = screen.getByText('Yes, cancel deposit')
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+
+ resolveCancel!({})
+ await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledTimes(1))
+ })
+
it('dismissing the confirmation leaves the deposit untouched', () => {
renderCancel()
From 2acc377877428f53d56fa80dbaf6a9e5360225e5 Mon Sep 17 00:00:00 2001
From: Aleksandar Balinda
Date: Wed, 15 Jul 2026 14:41:54 +0200
Subject: [PATCH 12/13] content: publish card-legal contact hotfix to
production
Bumps src/content to peanut-content hotfix 32f5b91 (off current prod
pointer f3c0ef66): removes personal phone number + swaps help@ -> support@
in the card legal docs. Isolated from the in-flight tos-v1 rework so it
passes content verification and ships to peanut.me now.
---
src/content | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/content b/src/content
index f3c0ef661..32f5b91db 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit f3c0ef661f1e890250de7892419018c0a0af59a7
+Subproject commit 32f5b91dbbc43ad1e01c4798bfc76f57d38c558f
From d64a2a4dd8d33593af0faecf390b789379681a49 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Wed, 15 Jul 2026 16:50:33 +0000
Subject: [PATCH 13/13] =?UTF-8?q?content:=20publish=20latest=20to=20produc?=
=?UTF-8?q?tion=20(src/content=20=E2=86=92=20peanut-content@e3a05f7)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps src/content on production (main) from 32f5b91 → e3a05f7
(peanut-content latest = mono@83166e9). Single-file submodule
pointer change. Publishes content already merged + mirrored from mono that the
dev-targeted auto-PRs never promote to main.
---
src/content | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/content b/src/content
index 32f5b91db..e3a05f777 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 32f5b91dbbc43ad1e01c4798bfc76f57d38c558f
+Subproject commit e3a05f77716ca51da8e35880dca34db747a99969