Skip to content
Open
11 changes: 8 additions & 3 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: [moonshotai/kimi-k3] API-served depositAddress used without runtime validation before signing fund transfers

qr-pay (and identically withdraw/manteca at recipient: (priceLock.depositAddress as 0x${string} | undefined) ?? MANTECA_DEPOSIT_ADDRESS) now takes the recipient for an irreversible spend from the /manteca/qr-payment/init (or /withdraw/init) response. The only 'check' is a TypeScript as 0x${string}`` cast, which is a compile-time assertion and validates nothing at runtime. Two concrete failure modes: (1) ?? only falls back on null/undefined, so an API that returns `depositAddress: ""` (or any non-null malformed value) bypasses the constant fallback and is signed to as the recipient — MantecaReviewStep correctly guards truthiness with `if (initData?.depositAddress)`, but qr-pay and withdraw do not; (2) a validly formatted but wrong address returned by an API bug, stale lock, or manipulated response is now signed to directly, whereas previously the recipient was a compile-time constant. The PR's own reasoning for the claim-link flow (wrong recipient strands funds; the QA note about a server-side 400 at complete time does not recover funds already on-chain) applies equally to signSpend here. Fix: before using the served value, run a runtime check — e.g. `viem`'s `isAddress(depositAddress)` (and non-empty) — and fall back to the constant with the `[manteca-entity-legacy-funding]` log (or abort) when it fails; ideally compare against the small static set of known legal-entity addresses rather than trusting an arbitrary address from the wire.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: [claude-opus] Manteca spend recipient now comes from the API with no test on either page

This diff changes WHERE user USDC is sent in two additional flows and ships no test for either. src/app/(mobile-ui)/qr-pay/page.tsx:762 now prefers finalPaymentLock.depositAddress over the per-rail constants, and src/app/(mobile-ui)/withdraw/manteca/page.tsx:389 now prefers priceLock.depositAddress over MANTECA_DEPOSIT_ADDRESS. CONTRIBUTING.md:495 is explicit — "if code moves money or mutates shared state, it needs a test before merge" — and the spend recipient is the money-moving decision: pick the wrong address after the 2026-09-14 entity split and the USDC lands at an entity that cannot settle the operation.

Evidence that nothing covers it: grep -rn depositAddress src --include=*.test.tsx returns only rhino/sentry/add-money hits, no Manteca lock. The qr-pay suite already mocks the exact seams — mockSignSpend (src/app/(mobile-ui)/qr-pay/tests/qr-pay-states.test.tsx:119) and mockMantecaApi.initiateQrPayment, whose default lock (line ~600) has NO depositAddress — so today's green suite exercises only the fallback branch and would stay green if the new ?? were inverted or dropped. The withdraw/manteca page has no tests directory at all.

Untested cases to name: (1) qr-pay, lock returns depositAddress → signSpend called with that address, NOT the PIX/non-PIX constant (assert on mockSignSpend.mock.calls[0][0].recipient); (2) qr-pay, lock omits depositAddress → falls back to NON_AR for EQrType.PIX and AR otherwise; (3) withdraw/manteca, priceLock.depositAddress present vs absent → same two assertions. Case (1) is ~5 lines on top of the existing harness.

(qrType === EQrType.PIX ? MANTECA_QR_DEPOSIT_ADDRESS_NON_AR : MANTECA_QR_DEPOSIT_ADDRESS_AR),
rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'QR_PAY',
})
Expand Down
5 changes: 4 additions & 1 deletion src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@
setErrorMessage(t('errors.completeAccountSetup'))
return true
},
[t]

Check warning on line 281 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 +287,7 @@
(!countryConfig?.needsBankCode || selectedBank != null) &&
(!countryConfig?.needsAccountType || accountType != null)
)
}, [selectedBank, accountType, countryConfig, destinationAddress, setErrorMessage])

Check warning on line 290 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 +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: (priceLock.depositAddress as `0x${string}` | undefined) ?? MANTECA_DEPOSIT_ADDRESS,
rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower),
kind: 'FIAT_OFFRAMP',
})
Expand Down Expand Up @@ -535,7 +538,7 @@

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

Check warning on line 541 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
20 changes: 19 additions & 1 deletion src/components/Claim/Link/views/MantecaReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,27 @@ 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.
let depositAddress: string = MANTECA_DEPOSIT_ADDRESS
const { data: initData, error: initError } = await mantecaApi.initiateWithdraw({ amount, currency })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

MantecaReviewStep now calls mantecaApi.initiateWithdraw and aborts the whole flow unless the reply carries a valid depositAddress (src/components/Claim/Link/views/MantecaReviewStep.tsx:76-85, requireMantecaDepositAddress returns null for a missing field). In the pinned peanut-api-ts checkout, /manteca/withdraw/init replies with exactly {priceLockCode, price, expiresAt, usdAmount, fiatAmount, currency} — no depositAddress (src/routes/manteca/withdraw.ts:193-200). Against that API every regional (MercadoPago/PIX) claim-link withdrawal shows manteca.errors.generic and never claims, i.e. the feature is 100% down.

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 MANTECA_RECEIVE_ADDRESS_ARG only (src/routes/manteca/withdraw.ts:325-327), and the Rain offramp path rejects any recipient other than that constant (src/routes/manteca/withdraw.ts:1219-1230).

This is almost certainly the paired half in the open peanut-api-ts#1487 (its legalEntity.ts documents acceptedAddresses tolerance during rollout), which I cannot read in full — so major, not blocking. What to confirm before merge: (a) that #1487 adds depositAddress to the /manteca/withdraw/init response specifically, not only to /manteca/qr-payment/init; (b) that the legacy tx-hash withdraw route accepts the entity address as well as the ARG constant. And state the deploy order in the PR: the API side must be live before this frontend.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

MantecaReviewStep now calls mantecaApi.initiateWithdraw({amount,currency}) and aborts the whole flow when depositAddress is absent (requireMantecaDepositAddress → null → generic error, no claim, no withdraw). In the pinned peanut-api-ts checkout, /manteca/withdraw/init (src/routes/manteca/withdraw.ts:84) replies with exactly {priceLockCode, price, expiresAt, usdAmount, fiatAmount, currency} — no depositAddress. Against that API every Manteca claim-link claim (MercadoPago/PIX) is dead on arrival; unlike qr-pay and the bank-withdraw page, this path has no constant fallback by design. That checkout holds only merged code, so the serving half is almost certainly the open peanut-api-ts#1487 (Manteca legal-entity deposit routing) that I cannot read — this is a deploy-order dependency, not necessarily a design error: the API half must be deployed before this frontend, and that should be stated on the PR. Worth confirming with the API author while you're there: /manteca/withdraw still validates the funding transfer against MANTECA_RECEIVE_ADDRESS_ARG only (src/routes/manteca/withdraw.ts:325-330), so once depositAddress is served for a non-CRYPTO_ARG entity (e.g. a BRL claim), that tx-hash validator must accept the served address too — the one-shot link is already spent by then. #1487's legalEntity.ts acceptedAddresses looks like it covers this, but it is not in the visible diff.

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

Expand Down
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
Loading