From eccdb7dfaa3c2b53d383748eb993a2fe298eafb9 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 13 Jul 2026 18:53:02 +0200 Subject: [PATCH 01/13] feat: enable SEPA bank rail for AL/MD/ME/MK/RS + treat Bulgaria as Eurozone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge now supports SEPA for Albania, Moldova, Montenegro, North Macedonia and Serbia (TASK-20580) — surface the bank rail as available instead of 'Soon' for deposit and withdraw, and show the standard EUR-accounts-only warning for the four that keep a domestic currency. Bulgaria adopted the euro in Jan 2026: badge EUR instead of BGN and drop the stale non-EUR SEPA warning. --- src/components/AddMoney/consts/index.ts | 7 ++++++- src/constants/countryCurrencyMapping.ts | 28 +++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index 6b8ad3925..d1d2cd282 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', @@ -2601,6 +2601,7 @@ export const COUNTRY_SPECIFIC_METHODS: Record = // 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 +2628,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/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) } From 5f612dc0e47c670c469328cfd98073ee0d555b7c Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 13 Jul 2026 19:05:48 +0200 Subject: [PATCH 02/13] =?UTF-8?q?docs:=20retitle=20bank-country=20map=20?= =?UTF-8?q?=E2=80=94=20contents=20outgrew=20the=20'EAA=20codes'=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map already carried US/GB/CH and now the 2025/26 SEPA joiners; the old label invited a 'clean up non-EEA entries' edit that would disable live rails. --- src/components/AddMoney/consts/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index d1d2cd282..bdbc8dddd 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -2597,7 +2597,8 @@ 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', From 997924a4d3ac4bae96df8a28a66a358faf8d5dc3 Mon Sep 17 00:00:00 2001 From: ab <78670703+abalinda@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:32:34 +0200 Subject: [PATCH 03/13] Refactor Macedonian Denar entry formatting Signed-off-by: ab <78670703+abalinda@users.noreply.github.com> --- src/constants/countryCurrencyMapping.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts index f539cfdf8..564b365a8 100644 --- a/src/constants/countryCurrencyMapping.ts +++ b/src/constants/countryCurrencyMapping.ts @@ -26,13 +26,7 @@ const countryCurrencyMappings: CountryCurrencyMapping[] = [ { 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: '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' }, From 976d8f4c769d4e3c4f3228c3306af20d7d2725d9 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Mon, 13 Jul 2026 19:54:04 +0200 Subject: [PATCH 04/13] =?UTF-8?q?style:=20re-wrap=20MKD=20row=20=E2=80=94?= =?UTF-8?q?=20prettier=20print-width=20forces=20multi-line,=20format=20che?= =?UTF-8?q?ck=20gates=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/countryCurrencyMapping.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts index 564b365a8..f539cfdf8 100644 --- a/src/constants/countryCurrencyMapping.ts +++ b/src/constants/countryCurrencyMapping.ts @@ -26,7 +26,13 @@ const countryCurrencyMappings: CountryCurrencyMapping[] = [ { 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: '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' }, From 0adb18f3c69c3b6ba262f67cfdee8b543c9e958e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 08:38:10 -0300 Subject: [PATCH 05/13] =?UTF-8?q?feat(card):=20/fix-card-signature=20?= =?UTF-8?q?=E2=80=94=20guided=20repair=20for=20enable-bricked=20kernels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accounts carry validNonceFrom > currentNonce (left by the 2025-09-18 root-validator migration wave); Kernel v3.1 rejects every enable-mode card approval below that floor with InvalidNonce, so auto-balance fails hourly forever and no re-grant can help — the account state itself is the poison. A fourth account is undeployed with a v0.0.3-initCode approval (AA14). Hidden support page (not linked anywhere): diagnoses the account on-chain, sends the matching repair userOp (invalidateNonce(floor+1), or the migration no-op deploy), confirms against re-read chain state, then re-grants — the existing session-approve route stores the fresh approval and kicks off a funding run immediately. --- .../(mobile-ui)/fix-card-signature/page.tsx | 145 ++++++++++++ src/constants/routes.ts | 1 + .../__tests__/useCardSignatureRepair.test.ts | 208 ++++++++++++++++++ src/hooks/wallet/useCardSignatureRepair.ts | 151 +++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 src/app/(mobile-ui)/fix-card-signature/page.tsx create mode 100644 src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts create mode 100644 src/hooks/wallet/useCardSignatureRepair.ts 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..d8f82d00a --- /dev/null +++ b/src/app/(mobile-ui)/fix-card-signature/page.tsx @@ -0,0 +1,145 @@ +'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 { useCardSignatureRepair } from '@/hooks/wallet/useCardSignatureRepair' +import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' + +export default function FixCardSignaturePage() { + const { overview } = useRainCardOverview() + const { diagnosis, isDiagnosing, isRepairing, error, diagnose, repair } = useCardSignatureRepair() + const { grant, isGranting, lastError } = useGrantSessionKey() + const [grantDone, setGrantDone] = useState(false) + const [grantErrorMessage, setGrantErrorMessage] = useState(null) + + const card = findActiveCard(overview) + + useEffect(() => { + void diagnose() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + 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 &&

Checking your wallet…

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

+ + {!card && ( +

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

+ )} + + )} +
+ )} + + {(error || grantErrorMessage || (lastError && lastError.kind !== 'user-cancelled' && !grantDone)) && ( +

+ {error ?? grantErrorMessage ?? 'Something went wrong — please try again.'} +

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

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

+ )} +
+
+ ) +} 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/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts new file mode 100644 index 000000000..39ec5a820 --- /dev/null +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -0,0 +1,208 @@ +/** + * 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', + }, + ], +})) +// eslint-disable-next-line import/order +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('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..19de764df --- /dev/null +++ b/src/hooks/wallet/useCardSignatureRepair.ts @@ -0,0 +1,151 @@ +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. + */ + +export 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 = 5 +const CONFIRM_INTERVAL_MS = 1500 + +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 => { + const diagnosis = state.diagnosis + if (!address || !diagnosis || diagnosis.state === 'healthy') return diagnosis + setState((s) => ({ ...s, isRepairing: true, error: null })) + try { + 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) { + setState((s) => ({ ...s, isRepairing: false, error: (error as Error).message })) + return null + } + }, [address, state.diagnosis, handleSendUserOpEncoded, rebuildClientForChain]) + + return { + diagnosis: state.diagnosis, + isDiagnosing: state.isDiagnosing, + isRepairing: state.isRepairing, + error: state.error, + diagnose, + repair, + } +} From 8289fe222f6210845f0b07e56c9f850d6ae03f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 09:20:27 -0300 Subject: [PATCH 06/13] chore: drop eslint-disable for a rule this config doesn't define --- src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts index 39ec5a820..f6c932e2b 100644 --- a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -38,7 +38,6 @@ jest.mock('@zerodev/sdk', () => ({ }, ], })) -// eslint-disable-next-line import/order import { KernelV3AccountAbi as KERNEL_ABI } from '@zerodev/sdk' jest.mock('@/constants/zerodev.consts', () => ({ From aac3020e9cdca8aecc7d956623e32e2d7f451119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 09:38:40 -0300 Subject: [PATCH 07/13] review: harden /fix-card-signature per /code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - diagnose keyed on the async zerodev address (mount-only effect left the page dead on a cold load from a support DM — the exact target flow) - repair() re-diagnoses live state before sending, so a retry after a confirm-poll timeout can't re-send an already-consumed invalidateNonce - guard the Kernel v3.1 invalidation cap (floor > currentNonce+10 has no valid target — fail with clear copy instead of burning doomed passkey taps) - step 2 respects the card-overview loading state instead of flashing 'no active card — contact support' at users on the success path - 'Check again' affordance after a failed diagnosis; dismissed passkey sheets are quiet no-ops, matching the grant path; drop the dead lastError render condition and the type export from the hook file --- .../(mobile-ui)/fix-card-signature/page.tsx | 38 ++++++++++------ .../__tests__/useCardSignatureRepair.test.ts | 44 +++++++++++++++++++ src/hooks/wallet/useCardSignatureRepair.ts | 40 +++++++++++++++-- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/app/(mobile-ui)/fix-card-signature/page.tsx b/src/app/(mobile-ui)/fix-card-signature/page.tsx index d8f82d00a..2ceb92b00 100644 --- a/src/app/(mobile-ui)/fix-card-signature/page.tsx +++ b/src/app/(mobile-ui)/fix-card-signature/page.tsx @@ -16,22 +16,26 @@ 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 { overview } = useRainCardOverview() + const { address } = useZeroDev() + const { overview, isLoading: isOverviewLoading } = useRainCardOverview() const { diagnosis, isDiagnosing, isRepairing, error, diagnose, repair } = useCardSignatureRepair() - const { grant, isGranting, lastError } = useGrantSessionKey() + 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(() => { - void diagnose() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (address) void diagnose() + }, [address, diagnose]) const needsRepair = diagnosis !== null && diagnosis.state !== 'healthy' const busy = isDiagnosing || isRepairing || isGranting @@ -64,7 +68,13 @@ export default function FixCardSignaturePage() { takes up to two quick passkey confirmations.

- {isDiagnosing &&

Checking your wallet…

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

Checking your wallet…

} + + {!isDiagnosing && !diagnosis && error && ( + + )} {diagnosis && ( @@ -114,11 +124,15 @@ export default function FixCardSignaturePage() { shadowSize="4" className="w-full" onClick={handleGrant} - disabled={busy || !card} + disabled={busy || isOverviewLoading || !card} > - {isGranting ? 'Waiting for confirmation…' : 'Re-enable funding'} + {isGranting + ? 'Waiting for confirmation…' + : isOverviewLoading + ? 'Loading your card…' + : 'Re-enable funding'} - {!card && ( + {!isOverviewLoading && !card && (

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

@@ -128,11 +142,7 @@ export default function FixCardSignaturePage() {
)} - {(error || grantErrorMessage || (lastError && lastError.kind !== 'user-cancelled' && !grantDone)) && ( -

- {error ?? grantErrorMessage ?? 'Something went wrong — please try again.'} -

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

{error ?? grantErrorMessage}

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

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 }) =>
{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('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.`

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/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