diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index e325b5a8e9..a3b92d314b 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -1525,3 +1525,58 @@ describe('GROUP 6: Edge Cases', () => { }) }) }) + +// ============================================================ +// GROUP: Entity deposit recipient (2026-09-14 Manteca split) +// ============================================================ +describe('GROUP: Entity deposit recipient wiring', () => { + const LEGACY_AR_ADDRESS = '0x6E945f8EC93061f5f11Edc5e6Fb4A70BeB514e97' + const LEGACY_NON_AR_ADDRESS = '0x49200bF84dC26349C86ce040019063FeCE88CB1c' + const DISTINCT_SERVED = '0x49200bF84dC26349C86ce040019063FeCE88CB1c' + + async function payWithLock(lockExtra: Record) { + mockMantecaApi.initiateQrPayment.mockResolvedValue({ + code: 'LOCK123', + type: 'QR3_PAYMENT', + companyId: 'c1', + userId: 'u1', + userNumberId: 'un1', + userExternalId: 'ue1', + paymentRecipientName: 'Test Merchant', + paymentRecipientLegalId: 'legal1', + paymentAssetAmount: '12000', + paymentAsset: 'ARS', + paymentPrice: '1200', + paymentAgainstAmount: '10', + paymentAgainst: 'USD', + expireAt: '2026-04-16T23:59:59Z', + creationTime: '2026-04-16T00:00:00Z', + ...lockExtra, + }) + + renderQrPay({ qrCode: 'mercadopago://pay?id=123', type: 'MERCADO_PAGO', t: '1' }) + await waitFor(() => { + expect(screen.getByText('Test Merchant')).toBeInTheDocument() + }) + const payButton = screen.getByRole('button', { name: 'Pay' }) + await act(async () => { + fireEvent.click(payButton) + }) + await waitFor(() => expect(mockSignSpend).toHaveBeenCalledTimes(1)) + } + + test('the spend is signed to the API-served entity depositAddress, not the constant', async () => { + // A DISTINCT address (the non-AR wallet for a MERCADO_PAGO QR, which + // the constant fallback would never pick) proves the wire value wins. + await payWithLock({ depositAddress: DISTINCT_SERVED }) + + expect(mockSignSpend).toHaveBeenCalledWith(expect.objectContaining({ recipient: DISTINCT_SERVED })) + }) + + test('an older API without depositAddress falls back to the per-rail constant', async () => { + await payWithLock({}) + + expect(mockSignSpend).toHaveBeenCalledWith(expect.objectContaining({ recipient: LEGACY_AR_ADDRESS })) + expect(mockSignSpend).not.toHaveBeenCalledWith(expect.objectContaining({ recipient: LEGACY_NON_AR_ADDRESS })) + }) +}) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 5643351b08..9032033e49 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -27,6 +27,7 @@ import { SessionKeyGrantRequiredError } from '@/hooks/wallet/spendPreflight' import { friendlyError } from '@/utils/friendly-error.utils' import { useFriendlyError } from '@/hooks/useFriendlyError' import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { pickMantecaDepositAddress } from '@/utils/manteca.utils' import { rainCentsToUsdcUnits, isAmountWithinBalance } from '@/utils/balance.utils' import { formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' @@ -753,9 +754,15 @@ export default function QRPayPage() { const requiredUsdcAmount = parseUnits(finalPaymentLock.paymentAgainstAmount, PEANUT_WALLET_TOKEN_DECIMALS) signedArtifact = await signSpend({ requiredUsdcAmount, - // Per-rail Manteca QR funding wallet: Pix → non-AR, everything else → AR - // (same binary heuristic as the backend's getQrReceiveAddress). - recipient: qrType === EQrType.PIX ? MANTECA_QR_DEPOSIT_ADDRESS_NON_AR : MANTECA_QR_DEPOSIT_ADDRESS_AR, + // Entity-aware deposit address served by the API (per-entity + // balances from 2026-09-14) — the backend resolves the entity + // from the QR and the paying Manteca account. The per-rail + // constants remain only as a fallback for an older API that + // does not return the field yet. + recipient: pickMantecaDepositAddress( + finalPaymentLock.depositAddress, + qrType === EQrType.PIX ? MANTECA_QR_DEPOSIT_ADDRESS_NON_AR : MANTECA_QR_DEPOSIT_ADDRESS_AR + ), rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), kind: 'QR_PAY', }) diff --git a/src/app/(mobile-ui)/withdraw/manteca/__tests__/withdraw-manteca-recipient.test.tsx b/src/app/(mobile-ui)/withdraw/manteca/__tests__/withdraw-manteca-recipient.test.tsx new file mode 100644 index 0000000000..8b0ef75e0c --- /dev/null +++ b/src/app/(mobile-ui)/withdraw/manteca/__tests__/withdraw-manteca-recipient.test.tsx @@ -0,0 +1,230 @@ +/** + * Bank-withdraw signing boundary (2026-09-14 Manteca entity split). + * + * Drives the page through amount → lock-price → review → Withdraw and pins + * the money decision AT the signSpend boundary: the depositAddress served + * by /withdraw/init survives the priceLock state handoff and is the exact + * recipient signed to; an older API without the field falls back to the + * legacy constant. Mock strategy mirrors qr-pay-states.test.tsx: mock every + * hook/service at module level, drive the rendered page. + */ +/* eslint-disable react/display-name */ +import React from 'react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +// ---------- module-level mocks ---------- + +const mockSearchParams = new Map() +jest.mock('next/navigation', () => ({ + useSearchParams: () => ({ get: (key: string) => mockSearchParams.get(key) ?? null }), + useRouter: () => ({ push: jest.fn(), back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), + usePathname: () => '/withdraw/manteca', + useParams: () => ({}), +})) +jest.mock('next/image', () => (props: Record) => { + return React.createElement('img', props as object) +}) + +const mockSignSpend = jest.fn() +jest.mock('@/hooks/wallet/useSignSpendBundle', () => ({ + useSignSpendBundle: () => ({ signSpend: mockSignSpend }), +})) +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ spendableBalance: 1_000_000_000n, formattedSpendableBalance: '1000.00' }), +})) +jest.mock('@/hooks/wallet/useStaleSessionGuard', () => ({ + useStaleSessionGuard: () => jest.fn(async () => false), +})) +jest.mock('@/hooks/wallet/spendPreflight', () => ({ + SessionKeyGrantRequiredError: class SessionKeyGrantRequiredError extends Error {}, +})) +jest.mock('@/hooks/useRainCardOverview', () => ({ + useRainCardOverview: () => ({ overview: null }), +})) +jest.mock('@/hooks/useSafeBack', () => ({ useSafeBack: () => jest.fn() })) +jest.mock('@/hooks/useFriendlyError', () => ({ + useFriendlyError: () => (e: unknown) => ({ kind: 'message', message: String(e) }), +})) +jest.mock('@/hooks/wallet/usePendingTransactions', () => ({ + usePendingTransactions: () => ({ hasPendingTransactions: false }), +})) +jest.mock('@/hooks/useIdentityVerification', () => ({ + useIdentityVerification: () => ({ isVerified: true }), +})) +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ rails: [], nextActions: [] }), +})) +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { user: { userId: 'user-1' } }, isAuthed: true, fetchUser: jest.fn() }), +})) +jest.mock('@/utils/regions.utils', () => ({ + ...jest.requireActual('@/utils/regions.utils'), + isVerifiedForCountry: () => true, + deriveProviderRejection: () => null, +})) +jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({ + useMultiPhaseKycFlow: () => ({ + isLoading: false, + error: null, + phase: null, + start: jest.fn(), + reset: jest.fn(), + config: null, + sdkToken: null, + handleInitiateKyc: jest.fn(), + }), +})) +jest.mock('@/hooks/useCurrency', () => ({ + useCurrency: () => ({ + code: 'ars', + price: { sell: '1300', buy: '1300' }, + isLoading: false, + refetch: jest.fn(), + }), +})) +jest.mock('@/features/limits/hooks/useLimitsValidation', () => ({ + useLimitsValidation: () => ({ isBlocking: false, isWarning: false, currency: 'USD' }), +})) +jest.mock('@/features/limits/utils', () => ({ + ...jest.requireActual('@/features/limits/utils'), + getLimitsWarningCardProps: () => null, + isBrUserEligibleForLimitIncrease: () => false, +})) +jest.mock('@/context/ModalsContext', () => ({ + useModalsContext: () => ({ setIsSupportModalOpen: jest.fn(), openSupportWithMessage: jest.fn() }), +})) +jest.mock('@/components/Kyc/InitiateKycModal', () => ({ InitiateKycModal: () => null })) +jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) +jest.mock('@/components/Kyc/SumsubKycWrapper', () => ({ SumsubKycWrapper: () => null })) +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/RateUnavailable/RateGateScreen', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/SoundPlayer', () => ({ SoundPlayer: () => null })) +jest.mock('@/components/Withdraw/views/PixKeySend.view', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/Banner/MantecaTransfersMaintenanceView', () => ({ + MantecaTransfersMaintenanceView: () => null, +})) +jest.mock('@/config/underMaintenance.config', () => ({ + __esModule: true, + default: { disabledMantecaCurrencies: [] }, + underMaintenanceConfig: { disabledMantecaCurrencies: [] }, +})) +jest.mock('@/utils/network-triage', () => ({ + captureNetworkTriagedFailure: jest.fn(), + isNetworkLayerFailure: () => false, +})) +jest.mock('posthog-js', () => ({ capture: jest.fn(), default: { capture: jest.fn() } })) +jest.mock('@sentry/nextjs', () => ({ + captureException: jest.fn(), + captureMessage: jest.fn(), + withScope: (cb: (scope: Record) => void) => + cb( + new Proxy({} as Record, { + get: () => jest.fn(), + }) + ), +})) + +// Amount entry, simplified to a single input driving BOTH denominations. +jest.mock('@/components/Global/AmountInput', () => (props: Record) => { + const setPrimary = props.setPrimaryAmount as (v: string) => void + const setSecondary = props.setSecondaryAmount as (v: string) => void + return ( + { + setSecondary(e.target.value) + setPrimary((Number(e.target.value) * 1300).toFixed(2)) + }} + /> + ) +}) + +const mockInitiateWithdraw = jest.fn() +const mockWithdrawWithSignedTx = jest.fn() +jest.mock('@/services/manteca', () => ({ + ...jest.requireActual('@/services/manteca'), + mantecaApi: { + initiateWithdraw: (...args: unknown[]) => mockInitiateWithdraw(...args), + withdrawWithSignedTx: (...args: unknown[]) => mockWithdrawWithSignedTx(...args), + }, +})) + +import MantecaWithdrawPage from '../page' + +const SERVED_ADDRESS = '0x49200bF84dC26349C86ce040019063FeCE88CB1c' +const LEGACY_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' + +function renderPage() { + mockSearchParams.clear() + mockSearchParams.set('country', 'argentina') + mockSearchParams.set('method', 'bank') + mockSearchParams.set('destination', '0000003100064523644259') + mockSearchParams.set('isSavedAccount', 'true') + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + + ) +} + +async function driveToWithdraw(priceLock: Record) { + mockInitiateWithdraw.mockResolvedValue({ data: priceLock }) + renderPage() + + fireEvent.change(await screen.findByTestId('amount-input'), { target: { value: '10' } }) + fireEvent.click(screen.getByRole('button', { name: /continue/i })) + await waitFor(() => expect(mockInitiateWithdraw).toHaveBeenCalledTimes(1)) + + const withdrawButton = await screen.findByRole('button', { name: /withdraw/i }) + await act(async () => { + fireEvent.click(withdrawButton) + }) + await waitFor(() => expect(mockSignSpend).toHaveBeenCalledTimes(1)) +} + +beforeEach(() => { + jest.clearAllMocks() + mockSignSpend.mockResolvedValue({ + strategy: 'smart-only', + signedUserOp: { + signedUserOp: { sender: '0x1', nonce: '0x0', callData: '0x', signature: '0x' }, + chainId: '42161', + entryPointAddress: '0xentry', + }, + }) + mockWithdrawWithSignedTx.mockResolvedValue({ data: { id: 'synthetic-1' } }) +}) + +describe('bank-withdraw recipient at the signing boundary', () => { + test('the API-served entity depositAddress survives the priceLock handoff and reaches signSpend', async () => { + await driveToWithdraw({ + priceLockCode: 'pl-1', + price: '1300', + expiresAt: '2026-09-14T00:00:00Z', + usdAmount: '10', + fiatAmount: '13000.00', + currency: 'ars', + depositAddress: SERVED_ADDRESS, + }) + + expect(mockSignSpend).toHaveBeenCalledWith(expect.objectContaining({ recipient: SERVED_ADDRESS })) + }) + + test('an older API without the field falls back to the legacy constant', async () => { + await driveToWithdraw({ + priceLockCode: 'pl-1', + price: '1300', + expiresAt: '2026-09-14T00:00:00Z', + usdAmount: '10', + fiatAmount: '13000.00', + currency: 'ars', + }) + + expect(mockSignSpend).toHaveBeenCalledWith(expect.objectContaining({ recipient: LEGACY_ADDRESS })) + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index eef479318d..757993b962 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -8,6 +8,7 @@ import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { SessionKeyGrantRequiredError } from '@/hooks/wallet/spendPreflight' import { friendlyError } from '@/utils/friendly-error.utils' import { useFriendlyError } from '@/hooks/useFriendlyError' +import { resolveOfframpSpendRecipient } from '@/utils/manteca.utils' import { rainCentsToUsdcUnits, isAmountWithinBalance } from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' @@ -51,7 +52,6 @@ import { usePointsCalculation } from '@/hooks/usePointsCalculation' import PointsCard from '@/components/Common/PointsCard' import { MANTECA_COUNTRIES_CONFIG, - MANTECA_DEPOSIT_ADDRESS, MantecaAccountType, isMantecaSupportedCountryCode, type MantecaBankCode, @@ -383,7 +383,10 @@ function MantecaBankWithdrawFlow() { const requiredUsdcAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) signedArtifact = await signSpend({ requiredUsdcAmount, - recipient: MANTECA_DEPOSIT_ADDRESS, + // Entity-aware deposit address served by /withdraw/init + // (per-entity balances from 2026-09-14); the constant is + // only the fallback for an older API without the field. + recipient: resolveOfframpSpendRecipient(priceLock), rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), kind: 'FIAT_OFFRAMP', }) diff --git a/src/components/Claim/Link/views/MantecaReviewStep.tsx b/src/components/Claim/Link/views/MantecaReviewStep.tsx index fd0746b8fd..3235c93ebe 100644 --- a/src/components/Claim/Link/views/MantecaReviewStep.tsx +++ b/src/components/Claim/Link/views/MantecaReviewStep.tsx @@ -11,7 +11,9 @@ import { MercadoPagoStep } from '@/types/manteca.types' import { type Dispatch, type FC, type SetStateAction, useState } from 'react' import useClaimLink from '@/components/Claim/useClaimLink' import * as Sentry from '@sentry/nextjs' +import { pickMantecaDepositAddress, requireMantecaDepositAddress } from '@/utils/manteca.utils' import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' +import type { Address } from 'viem' import { useTranslations } from 'next-intl' interface MantecaReviewStepProps { @@ -62,9 +64,42 @@ const MantecaReviewStep: FC = ({ setError(null) setIsSubmitting(true) + // Entity-aware deposit address (per-entity balances from + // 2026-09-14): ask /withdraw/init where THIS currency's + // offramp must be funded BEFORE spending the one-shot claim + // link. This path FAILS CLOSED on any init problem — error, + // missing field, malformed or zero address — because no funds + // have moved yet and the user can retry, while claiming to a + // guessed address and then failing would irreversibly strand + // the link's funds at the wrong entity. (The signed flows keep + // a constant fallback because the backend validates their + // recipient before anything is broadcast; nothing validates a + // link claim.) + const { data: initData, error: initError } = await mantecaApi.initiateWithdraw({ amount, currency }) + if (initError) { + setError(t('manteca.errors.generic')) + return + } + // Strict only against an ENTITY-AWARE API (it marks its + // responses with legalEntity): there the served address is + // the one truth and anything else fails closed. A pre-entity + // API omits both fields and still validates the legacy + // constant — falling back keeps claim links working during + // the deploy window where this UI meets the older API. + let depositAddress: Address | null + if (initData?.legalEntity) { + depositAddress = requireMantecaDepositAddress(initData?.depositAddress) + if (!depositAddress) { + setError(t('manteca.errors.generic')) + return + } + } else { + depositAddress = pickMantecaDepositAddress(initData?.depositAddress, MANTECA_DEPOSIT_ADDRESS) + } + // Use secure SDK claim (password stays client-side, only signature sent to backend) const txHash = await claimLinkSecure({ - address: MANTECA_DEPOSIT_ADDRESS, + address: depositAddress, link: claimLink, }) @@ -74,7 +109,7 @@ const MantecaReviewStep: FC = ({ } // Associate the claim with user if logged in - // CRITICAL: This is blocking for Manteca because claims to MANTECA_DEPOSIT_ADDRESS + // CRITICAL: This is blocking for Manteca because claims to the Manteca deposit address // won't appear in history without this association (recipientAddress != user address) try { await sendLinksApi.associateClaim(txHash) @@ -102,7 +137,11 @@ const MantecaReviewStep: FC = ({ } } - const { data, error: withdrawError } = await mantecaApi.withdraw({ + const { + data, + error: withdrawError, + message: withdrawMessage, + } = await mantecaApi.withdraw({ amount, destinationAddress: destinationAddress.toLowerCase(), txHash, @@ -113,7 +152,10 @@ const MantecaReviewStep: FC = ({ if (withdrawError === 'TAX_ID_MISMATCH' || withdrawError === 'CUIT_MISMATCH') { setError(t('manteca.ownAccountOnly')) } else { - setError(withdrawError || t('manteca.errors.generic')) + // Prefer the API's human-written message over the raw + // wire code — CLAIM_STORE_UNAVAILABLE as literal screen + // text helps nobody whose funds already left the link. + setError(withdrawMessage || withdrawError || t('manteca.errors.generic')) } return } diff --git a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx new file mode 100644 index 0000000000..994e73ab8d --- /dev/null +++ b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx @@ -0,0 +1,149 @@ +/** + * MantecaReviewStep — the claim-link offramp's pre-claim entity lookup. + * + * This is the safety boundary that keeps a ONE-SHOT claim link from funding + * the wrong Manteca entity after the 2026-09-14 split: + * - the API-served depositAddress from /withdraw/init must be the address + * the link is claimed to, + * - an init failure must abort BEFORE the link is spent — no claim, no + * withdraw — because the link cannot be re-claimed. + */ +import React from 'react' +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' + +const mockInitiateWithdraw = jest.fn() +const mockWithdraw = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { + initiateWithdraw: (...args: unknown[]) => mockInitiateWithdraw(...args), + withdraw: (...args: unknown[]) => mockWithdraw(...args), + }, +})) + +const mockAssociateClaim = jest.fn() +jest.mock('@/services/sendLinks', () => ({ + sendLinksApi: { associateClaim: (...args: unknown[]) => mockAssociateClaim(...args) }, +})) + +const mockClaimLinkSecure = jest.fn() +jest.mock('@/components/Claim/useClaimLink', () => ({ + __esModule: true, + default: () => ({ claimLink: mockClaimLinkSecure }), +})) + +jest.mock('@/hooks/useCurrency', () => ({ + useCurrency: () => ({ price: { sell: '1300' }, isLoading: false, refetch: jest.fn() }), +})) + +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn() })) + +jest.mock('@/components/0_Bruddle/Toast', () => ({ + ...jest.requireActual('@/components/0_Bruddle/Toast'), + useToast: () => ({ toast: jest.fn(), success: jest.fn(), error: jest.fn(), info: jest.fn() }), +})) + +import MantecaReviewStep from '../MantecaReviewStep' + +const SERVED_ADDRESS = '0x49200bF84dC26349C86ce040019063FeCE88CB1c' + +function renderStep() { + const setCurrentStep = jest.fn() + render( + + + + ) + return { setCurrentStep } +} + +function clickConfirm() { + // The single enabled primary action ("Withdraw") on the review card — + // by accessible name, never by position: a stale instance's disabled + // button at index 0 turned the click into a silent no-op on CI. + fireEvent.click(screen.getByRole('button', { name: /withdraw/i })) +} + +beforeEach(() => { + jest.clearAllMocks() + mockClaimLinkSecure.mockResolvedValue('0x' + 'ab'.repeat(32)) + mockAssociateClaim.mockResolvedValue(undefined) + mockWithdraw.mockResolvedValue({ data: { id: 'synthetic-1' } }) +}) + +describe('MantecaReviewStep — pre-claim entity lookup', () => { + test('claims the link to the API-served entity deposit address', async () => { + mockInitiateWithdraw.mockResolvedValue({ + data: { priceLockCode: 'pl-1', legalEntity: 'CRYPTO_ARG', depositAddress: SERVED_ADDRESS }, + }) + + renderStep() + clickConfirm() + + await waitFor(() => expect(mockClaimLinkSecure).toHaveBeenCalledTimes(1)) + expect(mockInitiateWithdraw).toHaveBeenCalledWith({ amount: '10.00', currency: 'BRL' }) + expect(mockClaimLinkSecure).toHaveBeenCalledWith(expect.objectContaining({ address: SERVED_ADDRESS })) + await waitFor(() => expect(mockWithdraw).toHaveBeenCalledTimes(1)) + }) + + test('a PRE-ENTITY API response (no legalEntity) falls back to the legacy constant — deploy-window safe', async () => { + // The older API omits both fields and still validates the legacy + // constant, so the claim must proceed rather than abort: this is the + // window where the new UI meets the not-yet-deployed API. + mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1' } }) + + renderStep() + clickConfirm() + + await waitFor(() => expect(mockClaimLinkSecure).toHaveBeenCalled()) + expect(mockClaimLinkSecure).toHaveBeenCalledWith( + expect.objectContaining({ address: '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' }) + ) + }) + + test('FAILS CLOSED when the API returns no depositAddress — the one-shot link is never spent', async () => { + mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1', legalEntity: 'CRYPTO_ARG' } }) + + renderStep() + clickConfirm() + + await waitFor(() => expect(mockInitiateWithdraw).toHaveBeenCalledTimes(1)) + expect(mockClaimLinkSecure).not.toHaveBeenCalled() + expect(mockWithdraw).not.toHaveBeenCalled() + }) + + test('FAILS CLOSED on a malformed or zero served address', async () => { + for (const bad of ['', 'not-an-address', '0x0000000000000000000000000000000000000000']) { + cleanup() + jest.clearAllMocks() + mockClaimLinkSecure.mockResolvedValue('0x' + 'ab'.repeat(32)) + mockInitiateWithdraw.mockResolvedValue({ + data: { priceLockCode: 'pl-1', legalEntity: 'CRYPTO_ARG', depositAddress: bad }, + }) + + renderStep() + clickConfirm() + + await waitFor(() => expect(mockInitiateWithdraw).toHaveBeenCalledTimes(1)) + expect(mockClaimLinkSecure).not.toHaveBeenCalled() + } + }) + + test('an init error aborts BEFORE the one-shot link is spent — no claim, no withdraw', async () => { + mockInitiateWithdraw.mockResolvedValue({ error: 'Failed to lock withdraw price.' }) + + renderStep() + clickConfirm() + + await waitFor(() => expect(mockInitiateWithdraw).toHaveBeenCalledTimes(1)) + expect(mockClaimLinkSecure).not.toHaveBeenCalled() + expect(mockWithdraw).not.toHaveBeenCalled() + expect(mockAssociateClaim).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Claim/__tests__/claim-auth-header.test.ts b/src/components/Claim/__tests__/claim-auth-header.test.ts new file mode 100644 index 0000000000..52c809e1b5 --- /dev/null +++ b/src/components/Claim/__tests__/claim-auth-header.test.ts @@ -0,0 +1,82 @@ +/** + * /claim carries the session token when one exists. Without it the backend's + * optional auth never fires, so a claim paid to a Manteca entity address + * (owned by no user) is unattributable — the claim-link → withdraw flow then + * has no SEND_LINK_CLAIM intent to verify transfer ownership against. + * Anonymous claimers must keep working with no header at all. + */ + +import { executeClaim } from '../useClaimLink' + +jest.mock('@/utils/peanut-link.utils', () => ({ + getParamsFromLink: jest.fn(() => ({ + password: 'link-secret', + contractVersion: 'v4.2', + chainId: '42161', + depositIdx: '17', + })), + generateKeysFromString: jest.fn(() => ({ privateKey: '0xprivate' })), +})) +jest.mock('@/utils/peanut-claim.utils', () => ({ + getContractAddress: jest.fn(() => '0xcontract'), + signWithdrawalMessage: jest.fn(async () => ({ signature: '0xsigned', recipient: '0xrecipient' })), +})) +jest.mock('@/constants/rhino.consts', () => ({ RHINO_SDA_ENABLED: false })) +jest.mock('@/services/rhino-sda', () => ({ provisionSdaTransfer: jest.fn() })) + +const mockGetAuthToken = jest.fn() +jest.mock('@/utils/auth-token', () => ({ + getAuthToken: () => mockGetAuthToken(), +})) + +const depositDetails = { + pubKey20: '0xpubkey', + amount: '1000000', + tokenAddress: '0xtoken', + contractType: 0, + claimed: false, + requiresMFA: false, + timestamp: 1_725_000_000, + tokenId: '0', + senderAddress: '0xsender', +} + +describe('/claim auth header', () => { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + jest.clearAllMocks() + }) + + async function runClaim() { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ transactionHash: '0xclaimhash' }), + }) + global.fetch = mockFetch as typeof fetch + await executeClaim({ + link: 'https://peanut.me/claim?i=17', + recipientAddress: '0xrecipient', + depositDetails, + } as never) + return mockFetch.mock.calls[0][1] as RequestInit + } + + it('attaches the bearer token for an authenticated session', async () => { + mockGetAuthToken.mockReturnValue('jwt-123') + + const init = await runClaim() + + expect((init.headers as Record).Authorization).toBe('Bearer jwt-123') + }) + + it('sends NO auth header for an anonymous claimer', async () => { + mockGetAuthToken.mockReturnValue(null) + + const init = await runClaim() + + expect((init.headers as Record).Authorization).toBeUndefined() + }) +}) diff --git a/src/components/Claim/useClaimLink.tsx b/src/components/Claim/useClaimLink.tsx index 22ff9d2a95..a0b61c3d40 100644 --- a/src/components/Claim/useClaimLink.tsx +++ b/src/components/Claim/useClaimLink.tsx @@ -1,5 +1,6 @@ 'use client' +import { getAuthToken } from '@/utils/auth-token' import { generateKeysFromString, getParamsFromLink } from '@/utils/peanut-link.utils' import { getContractAddress, signWithdrawalMessage } from '@/utils/peanut-claim.utils' import { evmChainIdToRhinoName } from '@/constants/rhino.consts' @@ -30,9 +31,16 @@ const JSON_HEADERS = { 'Content-Type': 'application/json' } as const * Helper to make POST requests with consistent error handling */ async function postJson(url: string, body: Record): Promise { + // /claim takes OPTIONAL auth: with a session token the backend can own + // the SEND_LINK_CLAIM intent even when the recipient address is not the + // caller's own (the claim-link → Manteca flow pays a corporate entity + // address — without the token that claim is unattributable and the + // withdraw route cannot verify ownership of the transfer). Anonymous + // claimers simply send no header, exactly as before. + const token = getAuthToken() const response = await fetch(url, { method: 'POST', - headers: JSON_HEADERS, + headers: { ...JSON_HEADERS, ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), }) diff --git a/src/services/manteca.ts b/src/services/manteca.ts index a5afa5a9e6..0731876141 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -73,6 +73,11 @@ export type QrPaymentLock = { paymentAgainst: string expireAt: string creationTime: string + /** Entity-aware Manteca deposit address served by the API (per-entity + * balances from 2026-09-14). Optional only while an older API without + * the field may still be deployed — prefer it over local constants. */ + depositAddress?: Address + legalEntity?: string } export type QrPaymentResponse = @@ -113,6 +118,11 @@ export type WithdrawPriceLock = { usdAmount: string fiatAmount: string currency: string + /** Entity-aware Manteca deposit address served by the API (per-entity + * balances from 2026-09-14). Optional only while an older API without + * the field may still be deployed — prefer it over local constants. */ + depositAddress?: Address + legalEntity?: string } export const mantecaApi = { diff --git a/src/utils/__tests__/manteca.utils.test.ts b/src/utils/__tests__/manteca.utils.test.ts new file mode 100644 index 0000000000..222494d5cd --- /dev/null +++ b/src/utils/__tests__/manteca.utils.test.ts @@ -0,0 +1,79 @@ +/** + * pickMantecaDepositAddress decides WHERE user USDC is irreversibly sent + * (qr-pay, bank withdraw, claim-link offramp), so every branch is pinned: + * the API-served entity address wins when valid, and anything else — empty + * string, malformed value, absent field — falls back to the local constant + * (with a Sentry report when a served value existed but failed validation). + */ +import { pickMantecaDepositAddress, requireMantecaDepositAddress, resolveOfframpSpendRecipient } from '../manteca.utils' + +const mockCapture = jest.fn() +jest.mock('@sentry/nextjs', () => ({ + captureMessage: (...args: unknown[]) => mockCapture(...args), +})) + +const FALLBACK = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' as const +const SERVED = '0x6E945f8EC93061f5f11Edc5e6Fb4A70BeB514e97' + +beforeEach(() => mockCapture.mockClear()) + +describe('pickMantecaDepositAddress', () => { + test('a valid API-served address wins over the constant', () => { + expect(pickMantecaDepositAddress(SERVED, FALLBACK)).toBe(SERVED) + expect(mockCapture).not.toHaveBeenCalled() + }) + + test('an absent field falls back silently (older API during rollout)', () => { + expect(pickMantecaDepositAddress(undefined, FALLBACK)).toBe(FALLBACK) + expect(pickMantecaDepositAddress(null, FALLBACK)).toBe(FALLBACK) + expect(mockCapture).not.toHaveBeenCalled() + }) + + test('an empty string does NOT slip past the fallback (?? would let it through)', () => { + expect(pickMantecaDepositAddress('', FALLBACK)).toBe(FALLBACK) + expect(mockCapture).not.toHaveBeenCalled() + }) + + test('the zero address is syntactically valid but never a recipient — falls back AND reports', () => { + expect(pickMantecaDepositAddress('0x0000000000000000000000000000000000000000', FALLBACK)).toBe(FALLBACK) + expect(mockCapture).toHaveBeenCalledTimes(1) + }) + + test('a malformed served value falls back AND reports to Sentry', () => { + expect(pickMantecaDepositAddress('not-an-address', FALLBACK)).toBe(FALLBACK) + expect(pickMantecaDepositAddress('0x1234', FALLBACK)).toBe(FALLBACK) + expect(pickMantecaDepositAddress(42 as unknown, FALLBACK)).toBe(FALLBACK) + expect(mockCapture).toHaveBeenCalledTimes(3) + }) +}) + +describe('requireMantecaDepositAddress (fail-closed paths)', () => { + test('a valid address passes', () => { + expect(requireMantecaDepositAddress(SERVED)).toBe(SERVED) + expect(mockCapture).not.toHaveBeenCalled() + }) + + test('missing, empty, malformed, and zero all return null AND report — never a fallback', () => { + for (const bad of [undefined, null, '', '0x1234', '0x0000000000000000000000000000000000000000']) { + expect(requireMantecaDepositAddress(bad)).toBeNull() + } + expect(mockCapture).toHaveBeenCalledTimes(5) + }) +}) + +describe('resolveOfframpSpendRecipient (bank-withdraw priceLock → signSpend handoff)', () => { + test("the price lock's API-served entity address is the exact spend recipient", () => { + expect(resolveOfframpSpendRecipient({ depositAddress: SERVED })).toBe(SERVED) + }) + + test('an older API without the field falls back to the legacy constant', () => { + expect(resolveOfframpSpendRecipient({})).toBe('0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053') + expect(resolveOfframpSpendRecipient(null)).toBe('0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053') + }) + + test('a malformed served value never becomes the recipient', () => { + expect(resolveOfframpSpendRecipient({ depositAddress: '0x1234' })).toBe( + '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' + ) + }) +}) diff --git a/src/utils/manteca.utils.ts b/src/utils/manteca.utils.ts new file mode 100644 index 0000000000..3c8026ef34 --- /dev/null +++ b/src/utils/manteca.utils.ts @@ -0,0 +1,67 @@ +import { isAddress, zeroAddress, type Address } from 'viem' +import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' +import * as Sentry from '@sentry/nextjs' + +/** + * Pick the Manteca deposit recipient for a spend: the API-served + * entity-aware address when it is a real EVM address, else the local + * constant fallback. + * + * The wire value decides where user USDC is irreversibly sent, so it gets a + * RUNTIME check — a TypeScript cast validates nothing, and `??` alone would + * let an empty string (or any malformed value) through. A served value that + * exists but fails validation is reported to Sentry: after the 2026-09-14 + * entity split the constant fallback may fund the wrong entity, so ops must + * see it happening. + */ +export function pickMantecaDepositAddress(served: unknown, fallback: Address): Address { + if ( + typeof served === 'string' && + served.length > 0 && + isAddress(served, { strict: false }) && + served.toLowerCase() !== zeroAddress + ) { + return served as Address + } + if (served != null && served !== '') { + Sentry.captureMessage('Manteca depositAddress from API failed validation — using constant fallback', { + level: 'error', + extra: { served: String(served).slice(0, 64) }, + }) + } + return fallback +} + +/** + * Strict variant for the claim-link flow, which spends a ONE-SHOT link with + * no server-side pre-broadcast validation: a missing, malformed, or zero + * address must ABORT the flow (returns null), never fall back — after the + * 2026-09-14 entity split a constant fallback can irreversibly strand the + * link's funds at the wrong entity. + */ +export function requireMantecaDepositAddress(served: unknown): Address | null { + if ( + typeof served === 'string' && + served.length > 0 && + isAddress(served, { strict: false }) && + served.toLowerCase() !== zeroAddress + ) { + return served as Address + } + Sentry.captureMessage('Manteca depositAddress missing or invalid on a fail-closed path', { + level: 'error', + extra: { served: served == null ? String(served) : String(served).slice(0, 64) }, + }) + return null +} + +/** + * The bank-withdraw page's spend recipient, extracted so the priceLock → + * signSpend handoff is testable without the page harness: the price lock's + * API-served entity address when valid, else the legacy constant (the + * backend validates this recipient before anything broadcasts, so the + * fallback is safe there — unlike the claim-link path above). + */ +export function resolveOfframpSpendRecipient(priceLock: { depositAddress?: string } | null): Address { + return pickMantecaDepositAddress(priceLock?.depositAddress, MANTECA_DEPOSIT_ADDRESS as Address) +}