From 33cb733332bb89993c1a49522cc85835d52f9fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 2 Sep 2026 23:51:35 -0300 Subject: [PATCH 1/8] feat: fund Manteca flows at the API-served entity deposit address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manteca removes the shared balance pool on 2026-09-14 — each legal entity then holds its own balance, and a deposit at the wrong entity's address no longer funds the operation. The API (peanut-api-ts #1487) resolves the entity and returns depositAddress on /qr-payment/init and /withdraw/init; this makes every funding flow consume it: - qr-pay signs the spend to the lock's depositAddress, - the Manteca withdraw page signs to the price lock's depositAddress, - the claim-link offramp calls /withdraw/init BEFORE spending the one-shot link and aborts (funds unmoved, retryable) if init fails — claiming to a hardcoded address and then failing would strand the link's funds at the wrong entity. The mirrored constants remain only as a fallback for an older API that does not return the field yet. TASK-22107 --- src/app/(mobile-ui)/qr-pay/page.tsx | 11 +++++++--- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 5 ++++- .../Claim/Link/views/MantecaReviewStep.tsx | 20 ++++++++++++++++++- src/services/manteca.ts | 10 ++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 5643351b08..fcb743e896 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -753,9 +753,14 @@ 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: + (finalPaymentLock.depositAddress as `0x${string}` | undefined) ?? + (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/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index eef479318d..1de162523c 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -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: (priceLock.depositAddress as `0x${string}` | undefined) ?? MANTECA_DEPOSIT_ADDRESS, 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..058a804b19 100644 --- a/src/components/Claim/Link/views/MantecaReviewStep.tsx +++ b/src/components/Claim/Link/views/MantecaReviewStep.tsx @@ -62,9 +62,27 @@ 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. If init fails outright, abort — no funds have moved + // and the user can retry; claiming to a hardcoded address and + // then failing would strand the link's funds at the wrong + // entity. The constant remains only for an older API that + // does not return the field yet. + let depositAddress: string = MANTECA_DEPOSIT_ADDRESS + const { data: initData, error: initError } = await mantecaApi.initiateWithdraw({ amount, currency }) + if (initError) { + setError(t('manteca.errors.generic')) + return + } + if (initData?.depositAddress) { + depositAddress = initData.depositAddress + } + // 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, }) 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 = { From 09199fac3421e98fd91e529d373b2979ba878ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 3 Sep 2026 00:08:32 -0300 Subject: [PATCH 2/8] fix: runtime-validate the API-served address; test every money-moving recipient decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip review fixes. pickMantecaDepositAddress runtime-validates the wire value with viem isAddress before it becomes a spend recipient — a TypeScript cast validates nothing, and ?? alone would let an empty string through — falling back to the constant and reporting to Sentry when a served value exists but fails validation. All three flows use it. Tests pin the money decision at both levels: the util's full branch matrix, and MantecaReviewStep component tests asserting the API-served address reaches claimLinkSecure, the constant fallback for an older API, and that an init error aborts BEFORE the one-shot link is spent — no claim, no associate, no withdraw. --- src/app/(mobile-ui)/qr-pay/page.tsx | 8 +- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 3 +- .../Claim/Link/views/MantecaReviewStep.tsx | 6 +- .../__tests__/MantecaReviewStep.test.tsx | 113 ++++++++++++++++++ src/utils/__tests__/manteca.utils.test.ts | 43 +++++++ src/utils/manteca.utils.ts | 27 +++++ 6 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx create mode 100644 src/utils/__tests__/manteca.utils.test.ts create mode 100644 src/utils/manteca.utils.ts diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index fcb743e896..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' @@ -758,9 +759,10 @@ export default function QRPayPage() { // 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: - (finalPaymentLock.depositAddress as `0x${string}` | undefined) ?? - (qrType === EQrType.PIX ? MANTECA_QR_DEPOSIT_ADDRESS_NON_AR : MANTECA_QR_DEPOSIT_ADDRESS_AR), + 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/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 1de162523c..b152667fd6 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 { pickMantecaDepositAddress } 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' @@ -386,7 +387,7 @@ function MantecaBankWithdrawFlow() { // 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: (priceLock.depositAddress as `0x${string}` | undefined) ?? MANTECA_DEPOSIT_ADDRESS, + recipient: pickMantecaDepositAddress(priceLock.depositAddress, MANTECA_DEPOSIT_ADDRESS), 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 058a804b19..7dcf40caa4 100644 --- a/src/components/Claim/Link/views/MantecaReviewStep.tsx +++ b/src/components/Claim/Link/views/MantecaReviewStep.tsx @@ -12,6 +12,7 @@ import { type Dispatch, type FC, type SetStateAction, useState } from 'react' import useClaimLink from '@/components/Claim/useClaimLink' import * as Sentry from '@sentry/nextjs' import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' +import { pickMantecaDepositAddress } from '@/utils/manteca.utils' import { useTranslations } from 'next-intl' interface MantecaReviewStepProps { @@ -70,15 +71,12 @@ const MantecaReviewStep: FC = ({ // then failing would strand the link's funds at the wrong // entity. The constant remains only for an older API that // does not return the field yet. - let depositAddress: string = MANTECA_DEPOSIT_ADDRESS const { data: initData, error: initError } = await mantecaApi.initiateWithdraw({ amount, currency }) if (initError) { setError(t('manteca.errors.generic')) return } - if (initData?.depositAddress) { - depositAddress = initData.depositAddress - } + const depositAddress = pickMantecaDepositAddress(initData?.depositAddress, MANTECA_DEPOSIT_ADDRESS) // Use secure SDK claim (password stays client-side, only signature sent to backend) const txHash = await claimLinkSecure({ 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..ce67201e04 --- /dev/null +++ b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx @@ -0,0 +1,113 @@ +/** + * 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 { 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() })) + +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' +const LEGACY_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' + +function renderStep() { + const setCurrentStep = jest.fn() + render( + + + + ) + return { setCurrentStep } +} + +function clickConfirm() { + // The single primary action button on the review card. + fireEvent.click(screen.getAllByRole('button')[0]) +} + +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', 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('falls back to the constant when an older API returns no depositAddress', async () => { + mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1' } }) + + renderStep() + clickConfirm() + + await waitFor(() => expect(mockClaimLinkSecure).toHaveBeenCalledTimes(1)) + expect(mockClaimLinkSecure).toHaveBeenCalledWith(expect.objectContaining({ address: LEGACY_ADDRESS })) + }) + + 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/utils/__tests__/manteca.utils.test.ts b/src/utils/__tests__/manteca.utils.test.ts new file mode 100644 index 0000000000..4326bcc45f --- /dev/null +++ b/src/utils/__tests__/manteca.utils.test.ts @@ -0,0 +1,43 @@ +/** + * 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 } 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('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) + }) +}) diff --git a/src/utils/manteca.utils.ts b/src/utils/manteca.utils.ts new file mode 100644 index 0000000000..fc0f485f20 --- /dev/null +++ b/src/utils/manteca.utils.ts @@ -0,0 +1,27 @@ +import { isAddress, type Address } from 'viem' +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 })) { + 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 +} From b45de3d0e9c7de4067f2b69b45f44a71392202cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 3 Sep 2026 00:12:53 -0300 Subject: [PATCH 3/8] fix: surface the API's human message instead of a raw wire code on claim-link errors The API's legacy withdraw route now returns CLAIM_STORE_UNAVAILABLE and TX_ALREADY_USED with carefully-worded messages; rendering the raw code helps nobody whose funds already left the one-shot link. The fallback error branch prefers the message. --- src/components/Claim/Link/views/MantecaReviewStep.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Claim/Link/views/MantecaReviewStep.tsx b/src/components/Claim/Link/views/MantecaReviewStep.tsx index 7dcf40caa4..871edfa2f2 100644 --- a/src/components/Claim/Link/views/MantecaReviewStep.tsx +++ b/src/components/Claim/Link/views/MantecaReviewStep.tsx @@ -118,7 +118,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, @@ -129,7 +133,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 } From d71c4738fcfeb42237622671fbfa306537135738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 3 Sep 2026 00:34:06 -0300 Subject: [PATCH 4/8] fix: reject the zero address; pin the qr-pay page-level recipient wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip round-2 fixes. The runtime validator rejects the zero address — syntactically valid to isAddress but never a recipient; a config or serialization defect returning it must fall back to the constant, not sign an unrecoverable transfer. Page-level tests on qr-pay pin the wiring the helper tests cannot see: a distinct API-served depositAddress reaches signSpend as the exact recipient, and an older API without the field falls back to the per-rail constant. The bank-withdraw page's identical one-line wiring into the same tested util is documented as such in the PR notes — it has no page harness, and a several-hundred-line mock scaffold to assert one argument would be disproportionate. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 55 +++++++++++++++++++ src/utils/__tests__/manteca.utils.test.ts | 5 ++ src/utils/manteca.utils.ts | 9 ++- 3 files changed, 67 insertions(+), 2 deletions(-) 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/utils/__tests__/manteca.utils.test.ts b/src/utils/__tests__/manteca.utils.test.ts index 4326bcc45f..69475ea178 100644 --- a/src/utils/__tests__/manteca.utils.test.ts +++ b/src/utils/__tests__/manteca.utils.test.ts @@ -34,6 +34,11 @@ describe('pickMantecaDepositAddress', () => { 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) diff --git a/src/utils/manteca.utils.ts b/src/utils/manteca.utils.ts index fc0f485f20..f8a687cf98 100644 --- a/src/utils/manteca.utils.ts +++ b/src/utils/manteca.utils.ts @@ -1,4 +1,4 @@ -import { isAddress, type Address } from 'viem' +import { isAddress, zeroAddress, type Address } from 'viem' import * as Sentry from '@sentry/nextjs' /** @@ -14,7 +14,12 @@ import * as Sentry from '@sentry/nextjs' * see it happening. */ export function pickMantecaDepositAddress(served: unknown, fallback: Address): Address { - if (typeof served === 'string' && served.length > 0 && isAddress(served, { strict: false })) { + if ( + typeof served === 'string' && + served.length > 0 && + isAddress(served, { strict: false }) && + served.toLowerCase() !== zeroAddress + ) { return served as Address } if (served != null && served !== '') { From 7da06adbbcdf998468fe583f5248c18ed98e5842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 3 Sep 2026 09:01:11 -0300 Subject: [PATCH 5/8] fix: claim-link fails closed on any invalid served address; withdraw handoff extracted and tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip round-4 fixes. The claim-link path spends a one-shot link with no server-side pre-broadcast validation, so it now FAILS CLOSED on any init problem — error, missing field, malformed or zero address — via requireMantecaDepositAddress; the constant fallback survives only in the signed flows, whose recipient the backend validates before anything broadcasts. Component tests pin the abort for every bad shape. The bank-withdraw priceLock → signSpend handoff is extracted to resolveOfframpSpendRecipient and tested (served address wins, missing field falls back, malformed never becomes the recipient), shrinking the untested page wiring to a single call. --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 5 ++- .../Claim/Link/views/MantecaReviewStep.tsx | 24 ++++++++----- .../__tests__/MantecaReviewStep.test.tsx | 21 ++++++++--- src/utils/__tests__/manteca.utils.test.ts | 33 ++++++++++++++++- src/utils/manteca.utils.ts | 35 +++++++++++++++++++ 5 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index b152667fd6..757993b962 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -8,7 +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 { pickMantecaDepositAddress } from '@/utils/manteca.utils' +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' @@ -52,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, @@ -387,7 +386,7 @@ function MantecaBankWithdrawFlow() { // 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: pickMantecaDepositAddress(priceLock.depositAddress, MANTECA_DEPOSIT_ADDRESS), + 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 871edfa2f2..e42194937c 100644 --- a/src/components/Claim/Link/views/MantecaReviewStep.tsx +++ b/src/components/Claim/Link/views/MantecaReviewStep.tsx @@ -11,8 +11,7 @@ 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 { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' -import { pickMantecaDepositAddress } from '@/utils/manteca.utils' +import { requireMantecaDepositAddress } from '@/utils/manteca.utils' import { useTranslations } from 'next-intl' interface MantecaReviewStepProps { @@ -66,17 +65,24 @@ const MantecaReviewStep: FC = ({ // 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. If init fails outright, abort — no funds have moved - // and the user can retry; claiming to a hardcoded address and - // then failing would strand the link's funds at the wrong - // entity. The constant remains only for an older API that - // does not return the field yet. + // 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 } - const depositAddress = pickMantecaDepositAddress(initData?.depositAddress, MANTECA_DEPOSIT_ADDRESS) + const depositAddress = requireMantecaDepositAddress(initData?.depositAddress) + if (!depositAddress) { + setError(t('manteca.errors.generic')) + return + } // Use secure SDK claim (password stays client-side, only signature sent to backend) const txHash = await claimLinkSecure({ @@ -90,7 +96,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) diff --git a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx index ce67201e04..7f1d43ee74 100644 --- a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx +++ b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx @@ -46,7 +46,6 @@ jest.mock('@/components/0_Bruddle/Toast', () => ({ import MantecaReviewStep from '../MantecaReviewStep' const SERVED_ADDRESS = '0x49200bF84dC26349C86ce040019063FeCE88CB1c' -const LEGACY_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' function renderStep() { const setCurrentStep = jest.fn() @@ -89,14 +88,28 @@ describe('MantecaReviewStep — pre-claim entity lookup', () => { await waitFor(() => expect(mockWithdraw).toHaveBeenCalledTimes(1)) }) - test('falls back to the constant when an older API returns no depositAddress', async () => { + test('FAILS CLOSED when the API returns no depositAddress — the one-shot link is never spent', async () => { mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1' } }) renderStep() clickConfirm() - await waitFor(() => expect(mockClaimLinkSecure).toHaveBeenCalledTimes(1)) - expect(mockClaimLinkSecure).toHaveBeenCalledWith(expect.objectContaining({ address: LEGACY_ADDRESS })) + 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']) { + jest.clearAllMocks() + mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1', 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 () => { diff --git a/src/utils/__tests__/manteca.utils.test.ts b/src/utils/__tests__/manteca.utils.test.ts index 69475ea178..222494d5cd 100644 --- a/src/utils/__tests__/manteca.utils.test.ts +++ b/src/utils/__tests__/manteca.utils.test.ts @@ -5,7 +5,7 @@ * 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 } from '../manteca.utils' +import { pickMantecaDepositAddress, requireMantecaDepositAddress, resolveOfframpSpendRecipient } from '../manteca.utils' const mockCapture = jest.fn() jest.mock('@sentry/nextjs', () => ({ @@ -46,3 +46,34 @@ describe('pickMantecaDepositAddress', () => { 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 index f8a687cf98..3c8026ef34 100644 --- a/src/utils/manteca.utils.ts +++ b/src/utils/manteca.utils.ts @@ -1,4 +1,5 @@ import { isAddress, zeroAddress, type Address } from 'viem' +import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' import * as Sentry from '@sentry/nextjs' /** @@ -30,3 +31,37 @@ export function pickMantecaDepositAddress(served: unknown, fallback: Address): A } 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) +} From a36aa9911b2a134f187f4be77080a0db626de01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 3 Sep 2026 09:18:01 -0300 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20click=20Withdraw=20by=20name=20?= =?UTF-8?q?=E2=80=94=20the=20first=20button=20is=20the=20destination=20row?= =?UTF-8?q?'s=20Copy=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim-flow tests pressed buttons[0], which is the Copy control on CI's render order, so every new case failed with the submit handler unexecuted. Click the Withdraw action by accessible name, clean up between the malformed-address iterations, and give the Sentry mock the captureMessage the strict validator calls. --- .../Link/views/__tests__/MantecaReviewStep.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx index 7f1d43ee74..21a91f030a 100644 --- a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx +++ b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx @@ -9,7 +9,7 @@ * withdraw — because the link cannot be re-claimed. */ import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react' import { IntlWrapper } from '@/test-utils/intl' const mockInitiateWithdraw = jest.fn() @@ -36,7 +36,7 @@ jest.mock('@/hooks/useCurrency', () => ({ useCurrency: () => ({ price: { sell: '1300' }, isLoading: false, refetch: jest.fn() }), })) -jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn() })) jest.mock('@/components/0_Bruddle/Toast', () => ({ ...jest.requireActual('@/components/0_Bruddle/Toast'), @@ -64,8 +64,10 @@ function renderStep() { } function clickConfirm() { - // The single primary action button on the review card. - fireEvent.click(screen.getAllByRole('button')[0]) + // 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(() => { @@ -101,7 +103,9 @@ describe('MantecaReviewStep — pre-claim entity lookup', () => { 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', depositAddress: bad } }) renderStep() From 50e7e3d6600c1b835c53435fec37e21ecb29d23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 3 Sep 2026 09:24:22 -0300 Subject: [PATCH 7/8] test: pin the bank-withdraw recipient at the signing boundary, page-level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the page amount → lock-price → review → Withdraw with the full mock harness (qr-pay-states pattern) and asserts the depositAddress served by /withdraw/init survives the priceLock state handoff as the exact signSpend recipient — plus the missing-field fallback to the legacy constant. A regression that restores the constant, selects a stale lock, or bypasses the resolver now fails this suite. --- .../withdraw-manteca-recipient.test.tsx | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/app/(mobile-ui)/withdraw/manteca/__tests__/withdraw-manteca-recipient.test.tsx 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 })) + }) +}) From d8798af7252356b46dcd79b3c4acdd0ec2635220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 4 Sep 2026 16:40:20 -0300 Subject: [PATCH 8/8] fix(claim): authenticated /claim + deploy-window fallback for the entity address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - /claim now carries the session token when one exists. The backend's optional auth is what lets it own the SEND_LINK_CLAIM intent for a claim paid to a Manteca entity address (owned by no user) — with the raw unauthenticated fetch that ownership never materialized, and the withdraw route had nothing to verify the transfer against. Anonymous claimers still send no header. - The claim-link strict requirement is gated on an ENTITY-AWARE init response (legalEntity present): the pre-entity API omits both fields and still validates the legacy constant, so falling back keeps claim links working through the deploy window where this UI meets the older API. Once the API serves entity fields, missing/invalid addresses fail closed exactly as before. --- .../Claim/Link/views/MantecaReviewStep.tsx | 23 ++++-- .../__tests__/MantecaReviewStep.test.tsx | 25 +++++- .../Claim/__tests__/claim-auth-header.test.ts | 82 +++++++++++++++++++ src/components/Claim/useClaimLink.tsx | 10 ++- 4 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 src/components/Claim/__tests__/claim-auth-header.test.ts diff --git a/src/components/Claim/Link/views/MantecaReviewStep.tsx b/src/components/Claim/Link/views/MantecaReviewStep.tsx index e42194937c..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 { requireMantecaDepositAddress } from '@/utils/manteca.utils' +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 { @@ -78,10 +80,21 @@ const MantecaReviewStep: FC = ({ setError(t('manteca.errors.generic')) return } - const depositAddress = requireMantecaDepositAddress(initData?.depositAddress) - if (!depositAddress) { - 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) diff --git a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx index 21a91f030a..994e73ab8d 100644 --- a/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx +++ b/src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx @@ -79,7 +79,9 @@ beforeEach(() => { describe('MantecaReviewStep — pre-claim entity lookup', () => { test('claims the link to the API-served entity deposit address', async () => { - mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1', depositAddress: SERVED_ADDRESS } }) + mockInitiateWithdraw.mockResolvedValue({ + data: { priceLockCode: 'pl-1', legalEntity: 'CRYPTO_ARG', depositAddress: SERVED_ADDRESS }, + }) renderStep() clickConfirm() @@ -90,12 +92,27 @@ describe('MantecaReviewStep — pre-claim entity lookup', () => { await waitFor(() => expect(mockWithdraw).toHaveBeenCalledTimes(1)) }) - test('FAILS CLOSED when the API returns no depositAddress — the one-shot link is never spent', async () => { + 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() @@ -106,7 +123,9 @@ describe('MantecaReviewStep — pre-claim entity lookup', () => { cleanup() jest.clearAllMocks() mockClaimLinkSecure.mockResolvedValue('0x' + 'ab'.repeat(32)) - mockInitiateWithdraw.mockResolvedValue({ data: { priceLockCode: 'pl-1', depositAddress: bad } }) + mockInitiateWithdraw.mockResolvedValue({ + data: { priceLockCode: 'pl-1', legalEntity: 'CRYPTO_ARG', depositAddress: bad }, + }) renderStep() clickConfirm() 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), })