-
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 5 commits
33cb733
09199fa
b45de3d
d71c473
7da06ad
a36aa99
50e7e3d
d8798af
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 |
|---|---|---|
|
|
@@ -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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +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 { requireMantecaDepositAddress } from '@/utils/manteca.utils' | ||
| import { useTranslations } from 'next-intl' | ||
|
|
||
| interface MantecaReviewStepProps { | ||
|
|
@@ -62,9 +62,31 @@ 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. 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 }) | ||
|
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: Add coverage for the pre-claim entity lookup This is the safety boundary that keeps a one-shot link from funding the wrong Manteca entity, but no test renders MantecaReviewStep or exercises this branch. A later refactor could ignore initData.depositAddress or let claimLinkSecure run after an init error, stranding a BRL link after the entity cutoff without any suite failure. Add component tests that assert the API-served address is passed to claimLinkSecure and that an init error calls neither claimLinkSecure nor withdraw. The QR-pay and bank-withdraw signSpend recipient selections should likewise be pinned because they move funds.
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: [claude-opus] Claim-link offramp fails closed on a field the merged API does not serve — hard deploy-order dependency on the peanut-api-ts half
Unlike qr-pay and the bank-withdraw page — where the author deliberately kept a constant fallback, so those degrade safely — this path has no fallback by design, which is the right safety call but makes the FE unshippable ahead of the API. Second, coupled evidence in the same direction: once the API does serve an entity address, a BRL claim will be claimed to the CRYPTO_GLOBAL address, while the merged legacy withdraw route still validates the funding transfer against This is almost certainly the paired half in the open peanut-api-ts#1487 (its
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: [claude-opus] Claim-link offramp fails closed on a field the merged API does not serve
|
||
| if (initError) { | ||
| setError(t('manteca.errors.generic')) | ||
| return | ||
| } | ||
| const depositAddress = requireMantecaDepositAddress(initData?.depositAddress) | ||
|
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: Deploy the response field before requiring it The current API policy branch returns the price-lock fields from /manteca/withdraw/init but not depositAddress. With that response, initData?.depositAddress is undefined here, requireMantecaDepositAddress returns null, and every regional claim-link offramp aborts before spending the link. Deploy and verify the paired API response for every supported currency before releasing this UI, or gate this client path until that contract is live.
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: Deploy the response field before requiring it The current API policy branch returns the price-lock fields from /manteca/withdraw/init but not depositAddress. With that response, initData?.depositAddress is undefined here, requireMantecaDepositAddress returns null, and every regional claim-link offramp aborts before spending the link. Deploy and verify the paired API response for every supported currency before releasing this UI, or gate this client path until that contract is live.
Contributor
Author
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. Fixed in d8798af with the gate you suggested: the strict requirement now applies only to an ENTITY-AWARE init response (legalEntity present); a pre-entity response falls back to the legacy constant, which that API still validates — so the deploy window where this UI meets the older API keeps claim links working, and once api#1487 (which DOES serve depositAddress+legalEntity from /withdraw/init) deploys, missing/invalid addresses fail closed exactly as before. Deploy order (API first) also stated in the PR body. The opus MINOR (BRL offramp classified QR_PAY at the shared CRYPTO_GLOBAL address) is already fixed on the API side: api#1487's src/rain/prepare-kind.ts makes classifyRainPrepare overlap-aware via the client-declared kind, with tests pinning the shared-address disambiguation. |
||
| 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({ | ||
| address: MANTECA_DEPOSIT_ADDRESS, | ||
| address: depositAddress, | ||
| link: claimLink, | ||
| }) | ||
|
|
||
|
|
@@ -74,7 +96,7 @@ const MantecaReviewStep: FC<MantecaReviewStepProps> = ({ | |
| } | ||
|
|
||
| // 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) | ||
|
|
@@ -102,7 +124,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, | ||
|
|
@@ -113,7 +139,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 | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| /** | ||
| * 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' | ||
|
|
||
| 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]) | ||
|
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: Click the actual withdraw action in the claim-flow tests
|
||
| } | ||
|
|
||
| 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('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(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 () => { | ||
| 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() | ||
| }) | ||
| }) | ||
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.