Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
Comment thread
jjramirezn marked this conversation as resolved.
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),
Comment thread
jjramirezn marked this conversation as resolved.
Comment thread
jjramirezn marked this conversation as resolved.
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