-
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 3 commits
33cb733
09199fa
b45de3d
d71c473
7da06ad
a36aa99
50e7e3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -278,7 +279,7 @@ | |
| setErrorMessage(t('errors.completeAccountSetup')) | ||
| return true | ||
| }, | ||
| [t] | ||
| ) | ||
|
|
||
| const isCompleteBankDetails = useMemo<boolean>(() => { | ||
|
|
@@ -287,7 +288,7 @@ | |
| (!countryConfig?.needsBankCode || selectedBank != null) && | ||
| (!countryConfig?.needsAccountType || accountType != null) | ||
| ) | ||
| }, [selectedBank, accountType, countryConfig, destinationAddress, setErrorMessage]) | ||
|
|
||
| const handleBankDetailsSubmit = useCallback(async () => { | ||
| // prevent duplicate requests from rapid clicks | ||
|
|
@@ -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), | ||
|
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: Cover the bank-withdraw recipient at the signing boundary This line changes the recipient of an irreversible spend, but no withdraw-page test proves that the depositAddress returned by initiateWithdraw survives the priceLock state handoff and reaches signSpend. The utility tests cannot catch page wiring regressions, while the QR and claim-link paths now have boundary coverage. Add a focused page or extracted-flow test that returns a distinct API address and asserts the exact signSpend recipient, including the missing-field behavior if that fallback remains.
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: [claude-opus] BRL offramp now funds the QR non-AR address, so peanut-api-ts classifies the Rain prepare as QR_PAY After this change the bank-withdraw page signs to the API-served entity address (page.tsx:390). Per the paired API PR's legalEntity.ts, an off-ramp outside Argentina resolves to CRYPTO_GLOBAL, and MANTECA_ENTITY_DEPOSIT_ADDRESS[CRYPTO_GLOBAL] is the SAME value as MANTECA_QR_RECEIVE_ADDRESS_NON_AR (0x49200bF84dC26349C86ce040019063FeCE88CB1c). So a BRL bank withdrawal now sends collateral to an address that peanut-api-ts treats as a QR wallet. On the collateral-only strategy the recipient is forwarded verbatim to POST /rain/cards/withdraw/prepare (src/hooks/wallet/useSpendBundle.ts:195, useSignSpendBundle.ts:225), and the API derives the intent kind from the destination alone: peanut-api-ts/src/rain/prepare-kind.ts:36-77 — MANTECA_QR_ADDRESSES_LOWER is checked before MANTECA_OFFRAMP_ADDRESSES_LOWER (which still holds only MANTECA_RECEIVE_ADDRESS_ARG), so classifyRainPrepare returns QR_PAY for what is a FIAT_OFFRAMP. Call site: peanut-api-ts/src/routes/rain/withdraw.ts:559, and the client-sent kind is explicitly ignored there. Impact is bookkeeping, not funds: the row is born metadata.isDuplicate = true either way, so it stays hidden in history — until markIntentFailed clears the stamp on a real submit failure, at which point a Brazilian user sees a failed 'QR payment' entry for a bank withdrawal, and OFFRAMP/QR_PAY volume splits are skewed. Unlike the recipient validators (withdraw.ts:327, :763, :1219), an acceptedAddresses list cannot fix this one: once both flows fund CRYPTO_GLOBAL the address is genuinely ambiguous, so the classifier needs another signal (the client-declared kind cross-checked against a live offramp intent, or an entity+flow lookup) rather than a wider address set. This is almost certainly the other half's work: peanut-api-ts#1487 is the matching PR and I cannot see all of its files (16 unshown), so it may already carry a prepare-kind.ts change — worth confirming there before acting. Flagging as major-class cross-repo risk but low real-world severity. |
||
| rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), | ||
| kind: 'FIAT_OFFRAMP', | ||
| }) | ||
|
|
@@ -535,7 +539,7 @@ | |
|
|
||
| useEffect(() => { | ||
| resetState() | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| // Skip balance check if transaction is being processed | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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 }) | ||
|
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 = pickMantecaDepositAddress(initData?.depositAddress, MANTECA_DEPOSIT_ADDRESS) | ||
|
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. BLOCKING: Fail closed when a claim recipient is missing or invalid After the 2026-09-14 cutoff, a BRL claim whose /withdraw/init response has the older 200 shape, or contains a malformed or zero depositAddress, reaches this line and selects MANTECA_DEPOSIT_ADDRESS. claimLinkSecure then irreversibly sends the one-shot link to the legacy entity, while the paired API accepts only the new BRL entity address and rejects /withdraw, stranding the funds. Require a valid API-served address in this claim-link path and abort before claimLinkSecure; retain a compatibility fallback only in paths where the backend validates the signed artifact before broadcast. |
||
|
|
||
| // 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, | ||
| }) | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| 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]) | ||
|
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('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() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 })) { | ||
|
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: Reject the zero address before signing The guard treats |
||
| 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 | ||
| } | ||
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.