diff --git a/src/app/(mobile-ui)/fix-card-signature/page.tsx b/src/app/(mobile-ui)/fix-card-signature/page.tsx new file mode 100644 index 000000000..2ceb92b00 --- /dev/null +++ b/src/app/(mobile-ui)/fix-card-signature/page.tsx @@ -0,0 +1,155 @@ +'use client' + +/** + * Hidden support page: /fix-card-signature + * + * Guided repair for accounts whose card auto-funding approval can never + * validate (nonce-bricked or undeployed kernel — see useCardSignatureRepair). + * Not linked from anywhere; support DMs the URL to affected users. Two passkey + * taps: repair the wallet state, then re-grant auto-funding (the backend + * kicks off a funding run the moment the new approval is stored). + */ + +import { useEffect, useState } from 'react' +import { Button } from '@/components/0_Bruddle/Button' +import { Card } from '@/components/0_Bruddle/Card' +import NavHeader from '@/components/Global/NavHeader' +import { findActiveCard } from '@/components/Card/cardState.utils' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useZeroDev } from '@/hooks/useZeroDev' +import { useCardSignatureRepair } from '@/hooks/wallet/useCardSignatureRepair' +import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' + +export default function FixCardSignaturePage() { + const { address } = useZeroDev() + const { overview, isLoading: isOverviewLoading } = useRainCardOverview() + const { diagnosis, isDiagnosing, isRepairing, error, diagnose, repair } = useCardSignatureRepair() + const { grant, isGranting } = useGrantSessionKey() + const [grantDone, setGrantDone] = useState(false) + const [grantErrorMessage, setGrantErrorMessage] = useState(null) + + const card = findActiveCard(overview) + + // Keyed on address: the zerodev address hydrates asynchronously after the + // layout unblocks, so a mount-only effect would diagnose before it exists + // and never retry — dead page on a cold load from a support DM. + useEffect(() => { + if (address) void diagnose() + }, [address, diagnose]) + + const needsRepair = diagnosis !== null && diagnosis.state !== 'healthy' + const busy = isDiagnosing || isRepairing || isGranting + + const handleRepair = async () => { + setGrantErrorMessage(null) + await repair() + } + + const handleGrant = async () => { + setGrantErrorMessage(null) + const result = await grant() + if (result.ok) { + setGrantDone(true) + } else if (result.error.kind !== 'user-cancelled') { + setGrantErrorMessage( + result.error.kind === 'no-card' + ? 'No active card found on this account — please contact support.' + : 'Re-enabling did not complete — please try again or contact support.' + ) + } + } + + return ( +
+ +
+

+ This tool repairs a wallet state issue that stops your card from funding itself automatically. It + takes up to two quick passkey confirmations. +

+ + {(isDiagnosing || (!address && !diagnosis)) &&

Checking your wallet…

} + + {!isDiagnosing && !diagnosis && error && ( + + )} + + {diagnosis && ( + +
+ 1. Repair wallet state + {needsRepair ? (isRepairing ? '⏳' : '⚠️ needed') : '✅'} +
+ {needsRepair && ( + <> +

+ {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.'} +

+ + + )} +
+ )} + + {diagnosis?.state === 'healthy' && ( + +
+ 2. Re-enable automatic funding + {grantDone ? '✅' : isGranting ? '⏳' : ''} +
+ {grantDone ? ( +

+ All set! Automatic funding is back on and a funding run has been started — your card + balance should update within a few minutes. +

+ ) : ( + <> +

+ One more confirmation re-enables automatic card funding with a fresh permission. +

+ + {!isOverviewLoading && !card && ( +

+ No active card found on this account — please contact support. +

+ )} + + )} +
+ )} + + {(error || grantErrorMessage) &&

{error ?? grantErrorMessage}

} + + {diagnosis && diagnosis.state !== 'undeployed' && ( +

+ Diagnostics: nonce {diagnosis.currentNonce} / floor {diagnosis.validNonceFrom} +

+ )} +
+
+ ) +} diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 7f7bf4594..3b32ed05f 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.`

Deposit reference

- {onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} + {shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'}

{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/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index 6b8ad3925..bdbc8dddd 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -361,7 +361,7 @@ export const countryData: CountryData[] = [ id: 'BGR', type: 'country', title: 'Bulgaria', - currency: 'BGN', + currency: 'EUR', path: 'bulgaria', iso2: 'BG', iso3: 'BGR', @@ -2597,10 +2597,12 @@ export const countryData: CountryData[] = [ export const COUNTRY_SPECIFIC_METHODS: Record = {} -// bridge EAA country codes, source: https://apidocs.bridge.xyz/docs/sepa-euro-transactions +// countries enabled for Bridge bank transfers — SEPA zone (source: https://apidocs.bridge.xyz/docs/sepa-euro-transactions, +// incl. the 2025/26 SEPA joiners AL/MD/ME/MK/RS) plus US // note: this is a map of 3-letter country codes to 2-letter country codes, for flags to work, bridge expects 3 letter codes export const BRIDGE_ALPHA3_TO_ALPHA2: { [key: string]: string } = { ALA: 'AX', + ALB: 'AL', AND: 'AD', AUT: 'AT', BEL: 'BE', @@ -2627,13 +2629,17 @@ export const BRIDGE_ALPHA3_TO_ALPHA2: { [key: string]: string } = { MLT: 'MT', MTQ: 'MQ', MYT: 'YT', + MDA: 'MD', + MNE: 'ME', NLD: 'NL', + MKD: 'MK', NOR: 'NO', POL: 'PL', PRT: 'PT', REU: 'RE', ROU: 'RO', MAF: 'MF', + SRB: 'RS', SVK: 'SK', SVN: 'SI', ESP: 'ES', 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 b895da7b3..8895f97c4 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -146,9 +146,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" @@ -162,7 +168,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 }) =>
{description}
, +})) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => })) +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: ({ + visible, + title, + ctas, + onClose, + }: { + visible: boolean + title: React.ReactNode + ctas?: Array<{ text: string; onClick?: () => void }> + onClose: () => void + }) => + visible ? ( +
+

{title}

+ {ctas?.map((cta) => ( + + ))} + +
+ ) : 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 bf30ed0e2..231494b92 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 { resolveBridgeAccountHolderName } 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/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts index b1b5da58b..f539cfdf8 100644 --- a/src/constants/countryCurrencyMapping.ts +++ b/src/constants/countryCurrencyMapping.ts @@ -14,7 +14,7 @@ const countryCurrencyMappings: CountryCurrencyMapping[] = [ { currencyCode: 'EUR', currencyName: 'Euro', country: 'Eurozone', flagCode: 'eu' }, // Non-Eurozone SEPA Countries - { currencyCode: 'BGN', currencyName: 'Bulgarian Lev', country: 'Bulgaria', flagCode: 'bg', path: 'bulgaria' }, + { currencyCode: 'ALL', currencyName: 'Albanian Lek', country: 'Albania', flagCode: 'al', path: 'albania' }, { currencyCode: 'CZK', currencyName: 'Czech Koruna', @@ -25,9 +25,18 @@ const countryCurrencyMappings: CountryCurrencyMapping[] = [ { currencyCode: 'DKK', currencyName: 'Danish Krone', country: 'Denmark', flagCode: 'dk', path: 'denmark' }, { currencyCode: 'HUF', currencyName: 'Hungarian Forint', country: 'Hungary', flagCode: 'hu', path: 'hungary' }, { currencyCode: 'ISK', currencyName: 'Icelandic Krona', country: 'Iceland', flagCode: 'is', path: 'iceland' }, + { currencyCode: 'MDL', currencyName: 'Moldovan Leu', country: 'Moldova', flagCode: 'md', path: 'moldova' }, + { + currencyCode: 'MKD', + currencyName: 'Macedonian Denar', + country: 'North Macedonia', + flagCode: 'mk', + path: 'macedonia', + }, { currencyCode: 'NOK', currencyName: 'Norwegian Krone', country: 'Norway', flagCode: 'no', path: 'norway' }, { currencyCode: 'PLN', currencyName: 'Polish Zloty', country: 'Poland', flagCode: 'pl', path: 'poland' }, { currencyCode: 'RON', currencyName: 'Romanian Leu', country: 'Romania', flagCode: 'ro', path: 'romania' }, + { currencyCode: 'RSD', currencyName: 'Serbian Dinar', country: 'Serbia', flagCode: 'rs', path: 'serbia' }, { currencyCode: 'SEK', currencyName: 'Swedish Krona', country: 'Sweden', flagCode: 'se', path: 'sweden' }, { currencyCode: 'CHF', currencyName: 'Swiss Franc', country: 'Switzerland', flagCode: 'ch', path: 'switzerland' }, { @@ -136,7 +145,22 @@ export function isNonEuroSepaCountry(currencyCode: string | undefined): boolean // explicit list of non-EUR SEPA currencies // SEPA includes EU countries that use their own currency - const nonEurSepaCurrencies = ['GBP', 'PLN', 'SEK', 'DKK', 'CZK', 'HUF', 'RON', 'BGN', 'ISK', 'NOK', 'CHF'] + const nonEurSepaCurrencies = [ + 'GBP', + 'PLN', + 'SEK', + 'DKK', + 'CZK', + 'HUF', + 'RON', + 'ISK', + 'NOK', + 'CHF', + 'ALL', + 'MDL', + 'MKD', + 'RSD', + ] return nonEurSepaCurrencies.includes(upper) } diff --git a/src/constants/routes.ts b/src/constants/routes.ts index cd6814876..cd5577f9a 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -45,6 +45,7 @@ export const DEDICATED_ROUTES = [ 'recover-funds', 'card-recovery', 'recover-wallet', + 'fix-card-signature', // Public pages (existing) 'm', // merchant landing pages (/m/[slug]) — added on main; register so the catch-all never treats it as a recipient diff --git a/src/content b/src/content index f3c0ef661..e3a05f777 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit f3c0ef661f1e890250de7892419018c0a0af59a7 +Subproject commit e3a05f77716ca51da8e35880dca34db747a99969 diff --git a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts new file mode 100644 index 000000000..6b7f4e176 --- /dev/null +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for useCardSignatureRepair — the /fix-card-signature diagnose/repair hook. + * + * Money-path invariant under test: accounts whose kernel has + * `validNonceFrom > currentNonce` can NEVER validate an enable-mode card + * approval (Kernel v3.1 rejects installs below the floor with InvalidNonce), + * and the ONLY unbrick is a root userOp calling + * `invalidateNonce(validNonceFrom + 1)` on the account itself. Undeployed + * accounts instead need a deploy (migration no-op). The hook must pick the + * right repair call for each diagnosis and must confirm the repair against + * re-read on-chain state, never the bundle receipt. + */ + +import { renderHook, act } from '@testing-library/react' +import { encodeFunctionData } from 'viem' + +const USER_ADDRESS = '0x00000000000000000000000000000000000000aa' +const USDC = '0x1111111111111111111111111111111111111111' + +// Defined inside the factory (jest.mock is hoisted above module consts); the +// test body reads it back through the mocked module. +jest.mock('@zerodev/sdk', () => ({ + KernelV3AccountAbi: [ + { type: 'function', name: 'currentNonce', inputs: [], outputs: [{ type: 'uint32' }], stateMutability: 'view' }, + { + type: 'function', + name: 'validNonceFrom', + inputs: [], + outputs: [{ type: 'uint32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'invalidateNonce', + inputs: [{ name: 'nonce', type: 'uint32' }], + outputs: [], + stateMutability: 'payable', + }, + ], +})) +import { KernelV3AccountAbi as KERNEL_ABI } from '@zerodev/sdk' + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: USDC, +})) + +const mockGetCode = jest.fn() +const mockReadContract = jest.fn() +jest.mock('@/app/actions/clients', () => ({ + peanutPublicClient: { + getCode: (...args: unknown[]) => mockGetCode(...args), + readContract: (...args: unknown[]) => mockReadContract(...args), + }, +})) + +const mockSendUserOp = jest.fn() +jest.mock('@/hooks/useZeroDev', () => ({ + useZeroDev: () => ({ address: USER_ADDRESS, handleSendUserOpEncoded: mockSendUserOp }), +})) + +const mockRebuildClient = jest.fn() +jest.mock('@/context/kernelClient.context', () => ({ + useKernelClient: () => ({ rebuildClientForChain: mockRebuildClient }), +})) + +import { useCardSignatureRepair } from '../useCardSignatureRepair' + +/** Point the on-chain reads at a fake account state. */ +const chainState = (state: { deployed: boolean; cn?: number; vnf?: number }) => { + mockGetCode.mockResolvedValue(state.deployed ? '0xdeadbeef' : undefined) + mockReadContract.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'currentNonce') return Promise.resolve(state.cn) + if (functionName === 'validNonceFrom') return Promise.resolve(state.vnf) + return Promise.reject(new Error(`unexpected read: ${functionName}`)) + }) +} + +beforeEach(() => { + jest.clearAllMocks() + mockSendUserOp.mockResolvedValue({ userOpHash: '0xhash', receipt: null }) + mockRebuildClient.mockResolvedValue({}) +}) + +describe('diagnose', () => { + it('classifies an account with no code as undeployed', async () => { + chainState({ deployed: false }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + expect(await result.current.diagnose()).toEqual({ state: 'undeployed' }) + }) + expect(result.current.diagnosis).toEqual({ state: 'undeployed' }) + }) + + it('classifies validNonceFrom > currentNonce as nonce-bricked', async () => { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + expect(result.current.diagnosis).toEqual({ state: 'nonce-bricked', currentNonce: 2, validNonceFrom: 3 }) + }) + + it('classifies validNonceFrom <= currentNonce as healthy', async () => { + chainState({ deployed: true, cn: 1, vnf: 0 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + expect(result.current.diagnosis).toEqual({ state: 'healthy', currentNonce: 1, validNonceFrom: 0 }) + }) +}) + +describe('repair', () => { + it('nonce-bricked → sends invalidateNonce(validNonceFrom + 1) to the account itself, then rebuilds', async () => { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + // The repair userOp lands and the floor is cleared before the confirm poll. + mockSendUserOp.mockImplementation(async () => { + chainState({ deployed: true, cn: 4, vnf: 4 }) + return { userOpHash: '0xhash', receipt: null } + }) + + await act(async () => { + expect(await result.current.repair()).toEqual({ state: 'healthy', currentNonce: 4, validNonceFrom: 4 }) + }) + + expect(mockSendUserOp).toHaveBeenCalledWith( + [ + { + to: USER_ADDRESS, + value: 0n, + data: encodeFunctionData({ abi: KERNEL_ABI, functionName: 'invalidateNonce', args: [4] }), + }, + ], + '42161' + ) + expect(mockRebuildClient).toHaveBeenCalledWith('42161') + expect(result.current.diagnosis).toEqual({ state: 'healthy', currentNonce: 4, validNonceFrom: 4 }) + }) + + it('undeployed → sends the migration no-op (deploys the account), then rebuilds', async () => { + chainState({ deployed: false }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + mockSendUserOp.mockImplementation(async () => { + chainState({ deployed: true, cn: 1, vnf: 0 }) + return { userOpHash: '0xhash', receipt: null } + }) + + await act(async () => { + expect(await result.current.repair()).toEqual({ state: 'healthy', currentNonce: 1, validNonceFrom: 0 }) + }) + + const [[calls, chainId]] = mockSendUserOp.mock.calls + expect(chainId).toBe('42161') + expect(calls).toHaveLength(1) + // The no-op is a zero-value USDC self-transfer — the SDK wrapper adds the migration. + expect(calls[0].to).toBe(USDC) + expect(calls[0].value).toBe(0n) + expect(mockRebuildClient).toHaveBeenCalledWith('42161') + }) + + it('does nothing when already healthy', async () => { + chainState({ deployed: true, cn: 1, vnf: 0 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + await result.current.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 { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + // The userOp "lands" but state never changes (e.g. reverted userOp + // inside a successful bundle) — the confirm poll must not trust the + // receipt and must give up with a retryable error. + let repaired: unknown + await act(async () => { + const promise = result.current.repair() + await jest.runAllTimersAsync() + repaired = await promise + }) + + expect(repaired).toBeNull() + expect(result.current.error).toMatch(/did not confirm/) + expect(mockRebuildClient).not.toHaveBeenCalled() + } finally { + jest.useRealTimers() + } + }) +}) diff --git a/src/hooks/wallet/useCardSignatureRepair.ts b/src/hooks/wallet/useCardSignatureRepair.ts new file mode 100644 index 000000000..fcf21ac61 --- /dev/null +++ b/src/hooks/wallet/useCardSignatureRepair.ts @@ -0,0 +1,183 @@ +import { useCallback, useState } from 'react' +import type { Address } from 'viem' +import { encodeFunctionData } from 'viem' +import { KernelV3AccountAbi } from '@zerodev/sdk' +import { peanutPublicClient } from '@/app/actions/clients' +import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' +import { useKernelClient } from '@/context/kernelClient.context' +import { useZeroDev } from '@/hooks/useZeroDev' +import { buildMigrationNoopCall } from '@/utils/kernelMigration.utils' + +/** + * Diagnose-and-repair for kernel accounts whose card auto-balance approval can + * never validate, no matter how correctly it was granted: + * + * - `nonce-bricked`: the account's `validNonceFrom` is AHEAD of `currentNonce` + * (left behind by the 2025-09-18 root-validator migration wave). Kernel + * v3.1 rejects every enable-mode validation installed below the + * `validNonceFrom` floor with `InvalidNonce()` (0x756688fe), so the backend + * sweep fails hourly forever. Repair: a root-passkey userOp calling + * `invalidateNonce(validNonceFrom + 1)` on the account itself — the kernel + * syncs `currentNonce` up to the floor, unbricking enable mode. + * - `undeployed`: the account is still counterfactual, and (for pre-cutoff + * accounts) stored approvals bake a v0.0.3 initCode that derives a different + * address → every replay reverts AA14. Repair: any root userOp deploys the + * account with its true initCode (the migration no-op — the SDK wrapper + * also swaps the root validator in the same op). + * + * After a successful repair the caller re-grants (useGrantSessionKey), which + * signs against the fresh live nonce and re-stores the approval server-side. + */ + +type CardSignatureDiagnosis = + | { state: 'undeployed' } + | { state: 'nonce-bricked'; currentNonce: number; validNonceFrom: number } + | { state: 'healthy'; currentNonce: number; validNonceFrom: number } + +interface RepairState { + diagnosis: CardSignatureDiagnosis | null + isDiagnosing: boolean + isRepairing: boolean + error: string | null +} + +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 = 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 +// 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)) + +async function readDiagnosis(address: Address): Promise { + const code = await peanutPublicClient.getCode({ address }) + if (!code || code === '0x') return { state: 'undeployed' } + const [currentNonce, validNonceFrom] = await Promise.all([ + peanutPublicClient.readContract({ address, abi: KernelV3AccountAbi, functionName: 'currentNonce' }), + peanutPublicClient.readContract({ address, abi: KernelV3AccountAbi, functionName: 'validNonceFrom' }), + ]) + const cn = Number(currentNonce) + const vnf = Number(validNonceFrom) + return vnf > cn + ? { state: 'nonce-bricked', currentNonce: cn, validNonceFrom: vnf } + : { state: 'healthy', currentNonce: cn, validNonceFrom: vnf } +} + +export const useCardSignatureRepair = () => { + const { address, handleSendUserOpEncoded } = useZeroDev() + const { rebuildClientForChain } = useKernelClient() + const [state, setState] = useState({ + diagnosis: null, + isDiagnosing: false, + isRepairing: false, + error: null, + }) + + const diagnose = useCallback(async (): Promise => { + if (!address) return null + setState((s) => ({ ...s, isDiagnosing: true, error: null })) + try { + const diagnosis = await readDiagnosis(address as Address) + setState((s) => ({ ...s, diagnosis, isDiagnosing: false })) + return diagnosis + } catch (error) { + setState((s) => ({ ...s, isDiagnosing: false, error: (error as Error).message })) + return null + } + }, [address]) + + /** + * Sends the repair userOp for the current diagnosis, confirms it landed by + * re-reading on-chain state (never trusting the bundle receipt — a + * reverted userOp still yields a successful bundle), and rebuilds the + * kernel client so subsequent signatures (the re-grant) bind fresh state. + * Returns the post-repair diagnosis, or null on failure. + */ + const repair = useCallback(async (): Promise => { + 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' + ? { + to: address as Address, + value: 0n, + data: encodeFunctionData({ + abi: KernelV3AccountAbi, + functionName: 'invalidateNonce', + args: [diagnosis.validNonceFrom + 1], + }), + } + : buildMigrationNoopCall(address as Address) + await handleSendUserOpEncoded([call], CHAIN_ID) + + let confirmed: CardSignatureDiagnosis | null = null + for (let attempt = 0; attempt < CONFIRM_RETRIES; attempt++) { + const fresh = await readDiagnosis(address as Address) + if (fresh.state === 'healthy') { + confirmed = fresh + break + } + if (attempt < CONFIRM_RETRIES - 1) await delay(CONFIRM_INTERVAL_MS) + } + if (!confirmed) { + setState((s) => ({ + ...s, + isRepairing: false, + error: 'The repair did not confirm on-chain in time — please retry in a moment', + })) + return null + } + // The repair op may have run the root-validator migration; rebuild so + // the re-grant signs via the current validator, not a stale wrapper. + await rebuildClientForChain(CHAIN_ID) + setState((s) => ({ ...s, diagnosis: confirmed, isRepairing: false })) + return confirmed + } catch (error) { + 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]) + + return { + diagnosis: state.diagnosis, + isDiagnosing: state.isDiagnosing, + isRepairing: state.isRepairing, + error: state.error, + diagnose, + repair, + } +} 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) +}