-
Notifications
You must be signed in to change notification settings - Fork 14
feat: Manteca entity deposit addresses from the API (TASK-22107) #2933
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
33cb733
09199fa
b45de3d
d71c473
7da06ad
a36aa99
50e7e3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 })) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -51,7 +52,6 @@ | |
| import PointsCard from '@/components/Common/PointsCard' | ||
| import { | ||
| MANTECA_COUNTRIES_CONFIG, | ||
| MANTECA_DEPOSIT_ADDRESS, | ||
| MantecaAccountType, | ||
| isMantecaSupportedCountryCode, | ||
| type MantecaBankCode, | ||
|
|
@@ -283,7 +283,7 @@ | |
|
|
||
| const isCompleteBankDetails = useMemo<boolean>(() => { | ||
| return ( | ||
| !!destinationAddress.trim() && | ||
| (!countryConfig?.needsBankCode || selectedBank != null) && | ||
| (!countryConfig?.needsAccountType || accountType != null) | ||
| ) | ||
|
|
@@ -292,7 +292,7 @@ | |
| const handleBankDetailsSubmit = useCallback(async () => { | ||
| // prevent duplicate requests from rapid clicks | ||
| if (isLockingPrice) return | ||
|
|
||
| if (!destinationAddress.trim()) { | ||
| setErrorMessage(t('errors.enterAccountAddress')) | ||
| return | ||
|
|
@@ -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), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), | ||
| kind: 'FIAT_OFFRAMP', | ||
| }) | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| if (!usdAmount || usdAmount === '0.00' || isNaN(Number(usdAmount)) || balance === undefined) { | ||
|
|
||
There was a problem hiding this comment.
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
signSpendbut never gives/inita distinct validdepositAddressor assertsrecipient, 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 tosignSpend.