Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 5 additions & 1 deletion 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 { 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'
Expand Down Expand Up @@ -278,7 +279,7 @@
setErrorMessage(t('errors.completeAccountSetup'))
return true
},
[t]

Check warning on line 282 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
)

const isCompleteBankDetails = useMemo<boolean>(() => {
Expand All @@ -287,7 +288,7 @@
(!countryConfig?.needsBankCode || selectedBank != null) &&
(!countryConfig?.needsAccountType || accountType != null)
)
}, [selectedBank, accountType, countryConfig, destinationAddress, setErrorMessage])

Check warning on line 291 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

const handleBankDetailsSubmit = useCallback(async () => {
// prevent duplicate requests from rapid clicks
Expand Down Expand Up @@ -383,7 +384,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: pickMantecaDepositAddress(priceLock.depositAddress, MANTECA_DEPOSIT_ADDRESS),
Comment thread
jjramirezn marked this conversation as resolved.
Outdated
Comment thread
jjramirezn marked this conversation as resolved.
Outdated
rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'FIAT_OFFRAMP',
})
Expand Down Expand Up @@ -535,7 +539,7 @@

useEffect(() => {
resetState()
}, [])

Check warning on line 542 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

useEffect(() => {
// Skip balance check if transaction is being processed
Expand Down
29 changes: 26 additions & 3 deletions src/components/Claim/Link/views/MantecaReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -62,9 +63,24 @@ const MantecaReviewStep: FC<MantecaReviewStepProps> = ({
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.
const { data: initData, error: initError } = await mantecaApi.initiateWithdraw({ amount, currency })
Comment thread
jjramirezn marked this conversation as resolved.
Comment thread
jjramirezn marked this conversation as resolved.
Comment thread
jjramirezn marked this conversation as resolved.
if (initError) {
setError(t('manteca.errors.generic'))
return
}
const depositAddress = pickMantecaDepositAddress(initData?.depositAddress, MANTECA_DEPOSIT_ADDRESS)
Comment thread
jjramirezn marked this conversation as resolved.
Outdated

// 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,
})

Expand Down Expand Up @@ -102,7 +118,11 @@ const MantecaReviewStep: FC<MantecaReviewStepProps> = ({
}
}

const { data, error: withdrawError } = await mantecaApi.withdraw({
const {
data,
error: withdrawError,
message: withdrawMessage,
} = await mantecaApi.withdraw({
amount,
destinationAddress: destinationAddress.toLowerCase(),
txHash,
Expand All @@ -113,7 +133,10 @@ const MantecaReviewStep: FC<MantecaReviewStepProps> = ({
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
}
Expand Down
113 changes: 113 additions & 0 deletions src/components/Claim/Link/views/__tests__/MantecaReviewStep.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IntlWrapper>
<MantecaReviewStep
setCurrentStep={setCurrentStep}
claimLink="https://peanut.me/claim#p=test"
destinationAddress="somepixkey@bank.br"
amount="10.00"
currency="BRL"
/>
</IntlWrapper>
)
return { setCurrentStep }
}

function clickConfirm() {
// The single primary action button on the review card.
fireEvent.click(screen.getAllByRole('button')[0])
Comment thread
jjramirezn marked this conversation as resolved.
Outdated
}

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()
})
})
10 changes: 10 additions & 0 deletions src/services/manteca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 = {
Expand Down
48 changes: 48 additions & 0 deletions src/utils/__tests__/manteca.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* 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('the zero address is syntactically valid but never a recipient — falls back AND reports', () => {
expect(pickMantecaDepositAddress('0x0000000000000000000000000000000000000000', FALLBACK)).toBe(FALLBACK)
expect(mockCapture).toHaveBeenCalledTimes(1)
})

test('a malformed served value falls back AND reports to Sentry', () => {
expect(pickMantecaDepositAddress('not-an-address', FALLBACK)).toBe(FALLBACK)
expect(pickMantecaDepositAddress('0x1234', FALLBACK)).toBe(FALLBACK)
expect(pickMantecaDepositAddress(42 as unknown, FALLBACK)).toBe(FALLBACK)
expect(mockCapture).toHaveBeenCalledTimes(3)
})
})
32 changes: 32 additions & 0 deletions src/utils/manteca.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { isAddress, zeroAddress, 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 }) &&
served.toLowerCase() !== zeroAddress
) {
return served as Address
}
if (served != null && served !== '') {
Sentry.captureMessage('Manteca depositAddress from API failed validation — using constant fallback', {
level: 'error',
extra: { served: String(served).slice(0, 64) },
})
}
return fallback
}
Loading