> = {
CARD_CLOSED_BETA: 'I was testing the Peanut card before you knew it existed. IYKYK 🤫💳',
CARD_ALPHA: 'I tested the Peanut card while it was still held together with tape and hope 🩹💳',
ARBITRUM: 'Peanut × Arbitrum. Fast chains, faster money 🔵',
+ TRON: 'Peanut × Tron. I came in on the rail most dollars already ride 🔺',
MANICERO: 'Small maní, big energy. I earned the Manicero badge 🥜',
TOUCHED_GRASS: 'Touched grass badge. Proof that I do go outside 🌱',
+ SURF_UP: "Caught the wave early. Surf's up 🏄",
+ SPLITTER: 'I split the bill before it was cool. Now I skip the line 🫰',
OFFRAMP_USER: 'I migrated to Peanut. New home, same money, one shiny badge 🥜',
PSYOPS_DIVISION: 'Enlisted in the Peanut Psyops Division. The influence game is real 🧠',
EVENT_ALUMNI: 'Old school. I was in the room before most of you 🎟️',
ETHFLORIPA_HUB: 'Ilha da Magia, baby. Coconuts and consensus 🥥',
IRL_NOMADS: 'Nomad mode on. My office is wherever the wifi is ☕',
WAITLIST_SKIP: "Got the skip pass. It's not what you know, it's who invites you 🔑",
+ ENS: 'One ENS name, no address to copy. Money still landed 🔷',
}
// Share text is:
diff --git a/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx b/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx
new file mode 100644
index 0000000000..9ae648ef3c
--- /dev/null
+++ b/src/components/Global/QRScannerOverlay/__tests__/base58-case.test.tsx
@@ -0,0 +1,134 @@
+/** @jest-environment jsdom */
+/**
+ * QR scanner case handling (TASK-21111 regression pin).
+ *
+ * The load-bearing claim: `processQRCode` must hand the RAW scan to
+ * `recognizeQr`, never the lowercased copy it keeps for routing. Base58 chain
+ * addresses carry meaning in their case — an uppercase L is a valid Solana
+ * character while a lowercase l is not, and every Tron address starts with an
+ * uppercase T — so lowercasing first made roughly half of all Solana addresses
+ * and every Tron address fall through to "Unrecognized QR code".
+ *
+ * `recognizeQr` itself was always correct and is covered by its own suite; only
+ * the wiring in this component was wrong, so the guard has to live here.
+ */
+import React from 'react'
+import { act, screen, waitFor } from '@testing-library/react'
+import { renderWithIntl } from '@/test-utils/intl'
+
+const mockPush = jest.fn()
+
+jest.mock('@/assets', () => ({}))
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+ usePathname: () => '/home',
+ useSearchParams: () => new URLSearchParams(),
+}))
+jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } }))
+jest.mock('use-haptic', () => ({ useHaptic: () => ({ triggerHaptic: jest.fn() }) }))
+jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() }))
+jest.mock('@/app/actions/ens', () => ({ resolveEns: jest.fn() }))
+jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() }))
+jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, openExternalUrl: jest.fn() }))
+jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: jest.fn() }) }))
+jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: { user: { username: 'satoshi' } } }) }))
+jest.mock('@/context/ModalsContext', () => ({
+ useModalsContext: () => ({ isQRScannerOpen: true, setIsQRScannerOpen: jest.fn() }),
+}))
+jest.mock('@/components/Global/QRBottomDrawer', () => ({ __esModule: true, default: () => null }))
+jest.mock('@/components/Global/Modal', () => ({
+ __esModule: true,
+ default: ({ title, visible, children }: { title?: string; visible: boolean; children: React.ReactNode }) =>
+ visible ? (
+
+
{title}
+ {children}
+
+ ) : null,
+}))
+
+// Capture the scan callback so a test can feed it a payload directly.
+let onScan: (data: string) => Promise<{ success: boolean; error?: string }>
+jest.mock('@/components/Global/QRScanner', () => ({
+ __esModule: true,
+ default: (props: { onScan: (data: string) => Promise<{ success: boolean; error?: string }> }) => {
+ onScan = props.onScan
+ return null
+ },
+}))
+
+import QRScannerOverlay from '../index'
+
+// Real, publicly known addresses. The Solana one holds an uppercase L, the
+// character that a `.toLowerCase()` turns into the one letter base58 excludes.
+const SOLANA_WITH_UPPERCASE_L = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'
+const TRON = 'TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC'
+const EVM_CHECKSUMMED = '0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed'
+// EIP-55 says case only carries a checksum when the address is neither all-lower
+// nor all-upper, so this one is valid and must survive.
+const EVM_UPPERCASE = '0X5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED'
+// Mixed case, so the checksum is real — and wrong. viem must keep rejecting it.
+const EVM_BAD_CHECKSUM = '0xAbCdEf1234567890123456789012345678901234'
+const BECH32_UPPERCASE = 'BC1QAR0SRRR7XFKVY5L643LYDNW9RE59GTZZWF5MDQ'
+const BOLT11_UPPERCASE = 'LNBC1230N1PJJ2LX9PP5ABC123'
+
+const scan = async (data: string) => {
+ renderWithIntl()
+ await act(async () => {
+ await onScan(data)
+ })
+}
+
+describe('QRScannerOverlay case handling', () => {
+ beforeEach(() => {
+ mockPush.mockClear()
+ })
+
+ describe('base58 addresses — case is data, so recognize the raw scan', () => {
+ it('recognizes a Solana address whose case a lowercase pass would destroy', async () => {
+ await scan(SOLANA_WITH_UPPERCASE_L)
+ expect(screen.getByText('Solana not supported yet.')).toBeInTheDocument()
+ expect(screen.queryByText('Unrecognized QR code')).not.toBeInTheDocument()
+ })
+
+ it('recognizes a Tron address, which always starts with an uppercase T', async () => {
+ await scan(TRON)
+ expect(screen.getByText('Tron not supported yet.')).toBeInTheDocument()
+ })
+ })
+
+ describe('all-uppercase payloads — QR alphanumeric mode uppercases, so retry lowercased', () => {
+ it('accepts an uppercase EVM address', async () => {
+ await scan(EVM_UPPERCASE)
+ expect(screen.getByText('ℹ️ Payment Confirmation')).toBeInTheDocument()
+ })
+
+ it('accepts an uppercase bech32 Bitcoin address', async () => {
+ await scan(BECH32_UPPERCASE)
+ expect(screen.getByText('Bitcoin not supported yet.')).toBeInTheDocument()
+ })
+
+ it('accepts an uppercase Lightning invoice', async () => {
+ await scan(BOLT11_UPPERCASE)
+ expect(screen.getByText('Bitcoin not supported yet.')).toBeInTheDocument()
+ })
+ })
+
+ describe('mixed case — the case is the user’s, so it must be honoured', () => {
+ it('accepts a checksummed EVM address', async () => {
+ await scan(EVM_CHECKSUMMED)
+ expect(screen.getByText('ℹ️ Payment Confirmation')).toBeInTheDocument()
+ })
+
+ it('rejects an EVM address with a bad EIP-55 checksum rather than laundering it', async () => {
+ await scan(EVM_BAD_CHECKSUM)
+ expect(screen.getByText('Unrecognized QR code')).toBeInTheDocument()
+ expect(screen.queryByText('ℹ️ Payment Confirmation')).not.toBeInTheDocument()
+ })
+ })
+
+ it('still routes a Peanut URL', async () => {
+ await scan('https://peanut.example.org/satoshi')
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/satoshi'))
+ })
+})
diff --git a/src/components/Global/QRScannerOverlay/index.tsx b/src/components/Global/QRScannerOverlay/index.tsx
index d78c907df6..91305138bf 100644
--- a/src/components/Global/QRScannerOverlay/index.tsx
+++ b/src/components/Global/QRScannerOverlay/index.tsx
@@ -232,7 +232,14 @@ export default function QRScannerOverlay() {
let redirectUrl: string | undefined = undefined
let toConfirmUrl: string | undefined = undefined
const normalized = data.toLowerCase()
- const recognized = recognizeQr(normalized)
+ // Recognize the RAW scan: in base58 the case IS the address (a lowercase l
+ // is not a Solana character, and Tron anchors on an uppercase T), so
+ // lowercasing first lost ~half of all Solana and every Tron address.
+ // Retry lowercased only when the payload is all-uppercase — QR alphanumeric
+ // mode encodes uppercase only, so that case came from the encoder. Any
+ // lowercase letter means the case is the user's, and a mixed-case EIP-55
+ // checksum must stay rejectable instead of laundered into a payable address.
+ const recognized = recognizeQr(data) ?? (data === data.toUpperCase() ? recognizeQr(normalized) : null)
const getLogData = () => {
if (recognized === EQrType.PIX_KEY) {
diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
index f6c0e4f712..f392be7ab7 100644
--- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
+++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
@@ -47,10 +47,6 @@ jest.mock('@/hooks/useCrispUserData', () => ({
jest.mock('@/hooks/useCrispTokenId', () => ({
useCrispTokenId: () => mockUseCrispTokenId(),
}))
-jest.mock('@/hooks/useCrispProxyUrl', () => ({
- useCrispProxyUrl: (_data: unknown, _msg: unknown, tokenId?: string) =>
- tokenId ? `/crisp-proxy?crisp_token_id=${tokenId}` : '/crisp-proxy',
-}))
jest.mock('../../PeanutLoading', () => ({
__esModule: true,
default: () => ,
@@ -83,7 +79,7 @@ describe('SupportDrawer Crisp session gate — web iframe', () => {
expect(screen.getByTestId('peanut-loading')).toBeInTheDocument()
})
- it('mounts a token-bound iframe once the logged-in user’s token resolves', () => {
+ it('mounts a clean-URL iframe once the logged-in user’s token resolves', () => {
mockUseCrispUserData.mockReturnValue({ userId: 'user-abc', email: 'a@b.com' })
mockUseCrispTokenId.mockReturnValue('token-abc')
@@ -91,7 +87,8 @@ describe('SupportDrawer Crisp session gate — web iframe', () => {
const iframe = supportIframe()
expect(iframe).toBeInTheDocument()
- expect(iframe).toHaveAttribute('src', '/crisp-proxy?crisp_token_id=token-abc')
+ // postmortem F5: nothing user-identifying (nor the bearer token) in the URL
+ expect(iframe).toHaveAttribute('src', '/crisp-proxy')
})
it('mounts the anonymous proxy immediately for a logged-out visitor (no userId, no token)', () => {
@@ -106,6 +103,81 @@ describe('SupportDrawer Crisp session gate — web iframe', () => {
})
})
+describe('SupportDrawer — crisp-proxy init handshake (postmortem F5: no PII in URLs)', () => {
+ beforeEach(() => {
+ mockUseCrispUserData.mockReset().mockReturnValue({
+ userId: 'user-abc',
+ username: 'peanut-user',
+ email: 'a@b.com',
+ fullName: 'Ada Lovelace',
+ walletAddressLink: 'https://arbiscan.io/address/0xabc',
+ })
+ mockUseCrispTokenId.mockReset().mockReturnValue('token-abc')
+ mockIsCapacitor.mockReset().mockReturnValue(false)
+ })
+
+ // sends a request "from" the given window; the real proxy iframe's
+ // contentWindow is the only sender the drawer may answer
+ const requestInit = (origin: string, source: object) => {
+ const event = new MessageEvent('message', {
+ data: { type: 'CRISP_PROXY_REQUEST_INIT' },
+ origin,
+ })
+ // MessageEvent's init rejects a non-Window `source`; define it directly instead
+ Object.defineProperty(event, 'source', { value: source })
+ act(() => {
+ window.dispatchEvent(event)
+ })
+ }
+
+ const mountedProxyWindow = () => {
+ const proxyWindow = (supportIframe() as HTMLIFrameElement).contentWindow as Window
+ return { proxyWindow, postSpy: jest.spyOn(proxyWindow, 'postMessage') }
+ }
+
+ it('replies to CRISP_PROXY_REQUEST_INIT with the payload, addressed to the asking iframe', () => {
+ render()
+ const { proxyWindow, postSpy } = mountedProxyWindow()
+
+ requestInit(window.location.origin, proxyWindow)
+
+ expect(postSpy).toHaveBeenCalledWith(
+ {
+ type: 'CRISP_PROXY_INIT',
+ payload: expect.objectContaining({
+ tokenId: 'token-abc',
+ userData: expect.objectContaining({
+ userId: 'user-abc',
+ username: 'peanut-user',
+ email: 'a@b.com',
+ fullName: 'Ada Lovelace',
+ walletAddressLink: 'https://arbiscan.io/address/0xabc',
+ }),
+ }),
+ },
+ window.location.origin
+ )
+ })
+
+ it('ignores init requests from a foreign origin', () => {
+ render()
+ const { proxyWindow, postSpy } = mountedProxyWindow()
+
+ requestInit('https://evil.example', proxyWindow)
+
+ expect(postSpy).not.toHaveBeenCalled()
+ })
+
+ it('ignores same-origin init requests from a window that is not the mounted proxy iframe', () => {
+ render()
+ const stranger = { postMessage: jest.fn() }
+
+ requestInit(window.location.origin, stranger)
+
+ expect(stranger.postMessage).not.toHaveBeenCalled()
+ })
+})
+
describe('SupportDrawer — Crisp load-failure fallback', () => {
beforeEach(() => {
mockUseCrispUserData.mockReset().mockReturnValue({ userId: undefined, email: undefined })
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index 0b2e506a5a..8a499c67fe 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -1,15 +1,21 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
-import { useTranslations } from 'next-intl'
+import { useTranslations, useLocale } from 'next-intl'
import { useModalsContext } from '@/context/ModalsContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
import { useCrispTokenId } from '@/hooks/useCrispTokenId'
-import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl'
import { useVisualViewport } from '@/hooks/useVisualViewport'
import PeanutLoading from '../PeanutLoading'
import { Button } from '@/components/0_Bruddle/Button'
-import { SUPPORT_EMAIL } from '@/constants/crisp'
+import {
+ SUPPORT_EMAIL,
+ CRISP_LOCALE_BY_APP_LOCALE,
+ CRISP_PROXY_REQUEST_INIT_MSG,
+ CRISP_PROXY_INIT_MSG,
+ type CrispInitPayload,
+} from '@/constants/crisp'
+import type { AppLocale } from '@/i18n/app/config'
import { isCapacitor } from '@/utils/capacitor'
const DISMISS_THRESHOLD = 100
@@ -29,7 +35,37 @@ const SupportDrawer = () => {
// Bumping this key remounts the iframe, giving the user a clean retry.
const [iframeKey, setIframeKey] = useState(0)
- const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage, crispTokenId)
+ const locale = useLocale() as AppLocale
+ const crispLocale = CRISP_LOCALE_BY_APP_LOCALE[locale] ?? 'en'
+
+ // The proxy iframe pulls this via the postMessage handshake — user data and the
+ // Crisp token never appear in its URL (postmortem F5: a query string leaks into
+ // Vercel logs, browser history, Referer headers, and analytics $current_url).
+ // A ref keeps the reply current without re-registering the message listener;
+ // written in an effect, not during render, so a discarded render can't leak
+ // an uncommitted identity to the proxy.
+ const initPayload: CrispInitPayload = {
+ locale: crispLocale,
+ tokenId: crispTokenId,
+ userData,
+ prefilledMessage,
+ }
+ const initPayloadRef = useRef(initPayload)
+ useEffect(() => {
+ initPayloadRef.current = initPayload
+ })
+
+ // The handshake pull happens once at iframe boot; later changes (email/name
+ // resolving mid-session, a new prefill) are pushed over the same channel so
+ // Crisp never keeps a stale identity. Token/locale changes remount the iframe
+ // via its key instead — those need a session re-bind, not a data update.
+ const iframeRef = useRef(null)
+ useEffect(() => {
+ iframeRef.current?.contentWindow?.postMessage(
+ { type: CRISP_PROXY_INIT_MSG, payload: initPayloadRef.current },
+ window.location.origin
+ )
+ }, [userData, prefilledMessage])
// Crisp's composer sits at the very bottom of the iframe, so the panel's bottom
// edge is the thing the iOS keyboard covers. Only measured while the drawer is
@@ -37,8 +73,8 @@ const SupportDrawer = () => {
const { height: visibleHeight, keyboardInset } = useVisualViewport(isSupportModalOpen)
/*
- * The proxy iframe boots the ENTIRE Next.js app at /crisp-proxy, and its src
- * recomputes from a dozen async user-data fields — every change reloads it.
+ * The proxy iframe boots the ENTIRE Next.js app at /crisp-proxy, and its key
+ * recomputes when the token or locale changes — each change reloads it.
* Mounted eagerly, that meant a hidden full app instance rebooting over and
* over behind every screen; on low-memory iPhones the accumulated pressure
* crashed the WKWebView content process mid-signup, hard-resetting the app
@@ -57,6 +93,13 @@ const SupportDrawer = () => {
setIframeKey((k) => k + 1)
}, [])
+ // a token/locale change replaces the iframe (see the key below) — clear the
+ // previous proxy's status so the loader shows until the new one reports
+ useEffect(() => {
+ setIsCrispReady(false)
+ setIsCrispFailed(false)
+ }, [crispTokenId, crispLocale])
+
// A logged-in user's token is computed asynchronously (SHA-256 of their userId).
// Until it resolves we must NOT load the proxy: a token-less load makes Crisp fall
// back to the shared anonymous session persisted on client.crisp.chat, which on a
@@ -128,12 +171,25 @@ const SupportDrawer = () => {
setDragOffset(0)
}, [dragOffset, setIsSupportModalOpen])
- // listen for crisp ready once — persists across open/close cycles
+ // listen for crisp messages once — persists across open/close cycles.
+ // Registered at drawer mount, long before the iframe can mount (hasBeenOpened
+ // gate), so the proxy's init request can never race past this listener.
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
- if (event.data?.type === 'CRISP_READY') {
+ if (
+ event.data?.type === CRISP_PROXY_REQUEST_INIT_MSG &&
+ event.source === iframeRef.current?.contentWindow
+ ) {
+ // the proxy iframe asks for its init payload — reply only to OUR
+ // mounted iframe (not any same-origin frame), and directly to it,
+ // never broadcast
+ ;(event.source as Window | null)?.postMessage(
+ { type: CRISP_PROXY_INIT_MSG, payload: initPayloadRef.current },
+ window.location.origin
+ )
+ } else if (event.data?.type === 'CRISP_READY') {
setIsCrispReady(true)
setIsCrispFailed(false)
} else if (event.data?.type === 'CRISP_FAILED') {
@@ -233,8 +289,12 @@ const SupportDrawer = () => {
)}
{!isCapacitor() && hasBeenOpened && !isAwaitingToken && (