Skip to content
55 changes: 55 additions & 0 deletions src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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 }))
})
})
13 changes: 10 additions & 3 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Cover the page-level spend recipient wiring

The new helper tests prove only the helper in isolation, while the claim-link test covers only the third flow. The existing QR page suite invokes signSpend but never gives /init a distinct valid depositAddress or asserts recipient, and the bank-withdraw page has no test at all. A future swap back to a fallback constant or use of the stale lock would still leave all new tests green while sending funds to the wrong entity. Add submit-path tests on both pages that return a distinct API address and assert the exact recipient passed to signSpend.

finalPaymentLock.depositAddress,
qrType === EQrType.PIX ? MANTECA_QR_DEPOSIT_ADDRESS_NON_AR : MANTECA_QR_DEPOSIT_ADDRESS_AR
),
rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'QR_PAY',
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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<string, unknown>) => {
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<string, jest.Mock>) => void) =>
cb(
new Proxy({} as Record<string, jest.Mock>, {
get: () => jest.fn(),
})
),
}))

// Amount entry, simplified to a single input driving BOTH denominations.
jest.mock('@/components/Global/AmountInput', () => (props: Record<string, unknown>) => {
const setPrimary = props.setPrimaryAmount as (v: string) => void
const setSecondary = props.setSecondaryAmount as (v: string) => void
return (
<input
data-testid="amount-input"
onChange={(e) => {
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(
<IntlWrapper>
<QueryClientProvider client={queryClient}>
<MantecaWithdrawPage />
</QueryClientProvider>
</IntlWrapper>
)
}

async function driveToWithdraw(priceLock: Record<string, unknown>) {
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 }))
})
})
7 changes: 5 additions & 2 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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'
Expand Down Expand Up @@ -51,7 +52,6 @@
import PointsCard from '@/components/Common/PointsCard'
import {
MANTECA_COUNTRIES_CONFIG,
MANTECA_DEPOSIT_ADDRESS,
MantecaAccountType,
isMantecaSupportedCountryCode,
type MantecaBankCode,
Expand Down Expand Up @@ -283,7 +283,7 @@

const isCompleteBankDetails = useMemo<boolean>(() => {
return (
!!destinationAddress.trim() &&

Check warning on line 286 in src/app/(mobile-ui)/withdraw/manteca/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useCallback has a missing dependency: 'setErrorMessage'. Either include it or remove the dependency array
(!countryConfig?.needsBankCode || selectedBank != null) &&
(!countryConfig?.needsAccountType || accountType != null)
)
Expand All @@ -292,7 +292,7 @@
const handleBankDetailsSubmit = useCallback(async () => {
// prevent duplicate requests from rapid clicks
if (isLockingPrice) return

Check warning on line 295 in src/app/(mobile-ui)/withdraw/manteca/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has an unnecessary dependency: 'setErrorMessage'. Either exclude it or remove the dependency array
if (!destinationAddress.trim()) {
setErrorMessage(t('errors.enterAccountAddress'))
return
Expand Down Expand Up @@ -383,7 +383,10 @@
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Exercise the bank-withdraw recipient at the signing boundary

The new utility test proves only that resolveOfframpSpendRecipient returns its input; no test renders this page and observes the argument passed to signSpend. A later regression that restores the constant here, selects the wrong lock, or bypasses the resolver leaves that suite green while signing a money transfer to the wrong entity. Add a page-level test that seeds a price lock with a distinct served address, confirms the withdrawal, and asserts that exact address reaches signSpend.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: Keep BRL collateral offramps out of QR classification

For a collateral-only BRL bank withdrawal, this API-served recipient is also the non-AR QR funding address recognized by the current Rain prepare classifier. useSignSpendBundle forwards it to /rain/cards/withdraw/prepare, whose current server contract ignores the client-supplied FIAT_OFFRAMP kind and classifies by recipient, so the preparation becomes a duplicate QR_PAY; a failed or cancelled funding leg is then surfaced and reconciled as a QR payment instead of an offramp. Update the paired API classification/route context for this entity address and pin the BRL direct-transfer case.

rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'FIAT_OFFRAMP',
})
Expand Down Expand Up @@ -542,7 +545,7 @@
// Use hasPendingTransactions to prevent race condition with optimistic updates
// isLoading covers the gap between sendMoney completing and API withdraw completing
if (hasPendingTransactions || isLoading) {
return

Check warning on line 548 in src/app/(mobile-ui)/withdraw/manteca/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array
}

if (!usdAmount || usdAmount === '0.00' || isNaN(Number(usdAmount)) || balance === undefined) {
Expand Down
Loading
Loading