{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 daa566a9c..7aae89c8c 100644
--- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
+++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx
@@ -1,7 +1,8 @@
'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'
import { Icon } from '@/components/Global/Icons/Icon'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
@@ -40,6 +41,18 @@ 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)
+ // 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)
+ // 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 = () =>
@@ -63,11 +76,53 @@ export function CancelDepositActions({
}
}
- // Render the active cancel button (if any) alongside any failure message.
+ const armCancel = (noun: string, run: () => Promise) => {
+ setPendingCancel({ noun, run })
+ setConfirmOpen(true)
+ }
+
+ const confirmThenRun = async () => {
+ if (!pendingCancel || isCancelRunning.current) return
+ isCancelRunning.current = true
+ setConfirmOpen(false)
+ try {
+ await wrapAction(pendingCancel.run)
+ } finally {
+ isCancelRunning.current = false
+ }
+ }
+
+ // Render the active cancel button (if any) alongside the shared
+ // confirmation modal and any failure message.
const withError = (button: ReactNode) => (
{button}
{error && }
+ setConfirmOpen(false)}
+ icon="ban"
+ 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,7 +139,7 @@ export function CancelDepositActions({
- wrapAction(async () => {
+ armCancel('deposit', async () => {
const result = await cancelOnramp(transaction.id)
if (result.error) throw new Error(result.error)
})
@@ -101,7 +156,7 @@ export function CancelDepositActions({
- wrapAction(async () => {
+ armCancel('deposit', async () => {
const result = await mantecaApi.cancelDeposit(transaction.id)
if (result.error) throw new Error(result.error)
})
@@ -123,7 +178,7 @@ export function CancelDepositActions({
label="Cancel Request"
disabled={!!isLoading}
onClick={() =>
- wrapAction(async () => {
+ armCancel('request', async () => {
const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId
if (!bridgeTransferId) {
throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction')
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..93dc9497d
--- /dev/null
+++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx
@@ -0,0 +1,155 @@
+/**
+ * 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('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()
+
+ 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..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,12 +37,14 @@ 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}
-
+ {/* 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/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)
+ })
+})
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)
+}