diff --git a/src/components/Global/Banner/index.tsx b/src/components/Global/Banner/index.tsx index d1e2d9d98..fbddaecc8 100644 --- a/src/components/Global/Banner/index.tsx +++ b/src/components/Global/Banner/index.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect } from 'react' import { usePathname } from 'next/navigation' import { MaintenanceBanner } from './MaintenanceBanner' import { MarqueeWrapper } from '../MarqueeWrapper' @@ -8,6 +9,7 @@ import { HandThumbsUp } from '@/assets' import Image from 'next/image' import { useModalsContext } from '@/context/ModalsContext' import { GIT_COMMIT_HASH, IS_PRODUCTION } from '@/constants/general.consts' +import { getRunMode, isRealMoneyMode, logRunMode } from '@/utils/mode' export function Banner() { const pathname = usePathname() @@ -29,10 +31,21 @@ export function Banner() { function FeedbackBanner() { const { setIsSupportModalOpen } = useModalsContext() + // Log run-mode once on mount (dev only). Big yellow banner in the + // browser console so you can never confuse sandbox for staging at a + // glance. Real-money modes get a red banner instead. + useEffect(() => { + if (IS_PRODUCTION) return + logRunMode() + }, []) + const handleClick = () => { setIsSupportModalOpen(true) } + const mode = !IS_PRODUCTION ? getRunMode() : null + const realMoney = !IS_PRODUCTION && isRealMoneyMode() + return ( diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 522cf9dcb..3b0fec432 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -21,6 +21,7 @@ import { formatAmount, formatDate, isStableCoin, formatCurrency } from '@/utils/ import { formatPoints } from '@/utils/format.utils' import { getAvatarUrl } from '@/utils/history.utils' import { formatIban, printableAddress, shortenAddress, shortenStringLong, slugify } from '@/utils/general.utils' +import { maskAccountIdentifier } from '@/utils/account-mask.utils' import { cancelOnramp } from '@/app/actions/onramp' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' @@ -529,9 +530,15 @@ export const TransactionDetailsReceipt = ({ {isGuestBankClaim ? transaction.bankAccountDetails.identifier - : formatIban(transaction.bankAccountDetails.identifier)} + : maskAccountIdentifier( + transaction.bankAccountDetails.identifier, + transaction.bankAccountDetails.type + )} {!isGuestBankClaim && ( + // Copy yields the FULL identifier — masking is for + // visual privacy on shared screens / receipts; the user + // owns the account and may need to paste it elsewhere. ({})) +jest.mock('@/assets/payment-apps', () => ({ MERCADO_PAGO: '', PIX: '', SIMPLEFI: '' })) + +type Account = NonNullable + +const aliceUser: Account = { + identifier: '0xAliceWalletAddressForTesting000000000000', + type: 'WALLET_SMART', + isUser: true, + username: 'alice', + fullName: 'Alice Wonderland', + userId: 'user-alice', + showFullName: false, +} + +const bobUser: Account = { + identifier: '0xBobWalletAddressForTesting00000000000000', + type: 'WALLET_SMART', + isUser: true, + username: 'bob', + fullName: 'Bob Builder', + userId: 'user-bob', + showFullName: false, +} + +const externalEoa: Account = { + identifier: '0xExternalAddress000000000000000000000000', + type: 'WALLET_EXTERNAL', + isUser: false, +} + +const ibanAccountES: Account = { + identifier: 'ES2700750984220607080217', + type: 'IBAN', + isUser: false, +} + +const baseEntry = (overrides: Partial): HistoryEntry => ({ + uuid: 'test-uuid-' + Math.random().toString(36).slice(2), + type: EHistoryEntryType.DIRECT_SEND, + timestamp: new Date('2026-04-01T12:00:00Z'), + amount: '1000000', + chainId: '42161', + tokenSymbol: 'USDC', + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + status: EHistoryStatus.COMPLETED, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + ...overrides, +}) + +interface ExpectedShape { + direction?: string + userName?: string + transactionCardType?: string + isLinkTransaction?: boolean + bankAccountDetailsDefined?: boolean + /** Whether `isPeerActuallyUser` was true — proxied via `isVerified` + * when input has `isVerified=true` (since isPeerActuallyUser gates + * the `isVerified` output). */ + isPeerActuallyUser?: boolean + cardPaymentDefined?: boolean +} + +interface TestCase { + name: string + entry: HistoryEntry + expect: ExpectedShape +} + +const cases: TestCase[] = [ + // ───── DIRECT_SEND ───── + { + name: 'DIRECT_SEND × SENDER → outgoing send to user', + entry: baseEntry({ type: EHistoryEntryType.DIRECT_SEND, userRole: EHistoryUserRole.SENDER, recipientAccount: aliceUser, isVerified: true }), + expect: { direction: 'send', transactionCardType: 'send', userName: 'alice', isPeerActuallyUser: true, isLinkTransaction: false }, + }, + { + name: 'DIRECT_SEND × RECIPIENT → incoming receive from user', + entry: baseEntry({ type: EHistoryEntryType.DIRECT_SEND, userRole: EHistoryUserRole.RECIPIENT, senderAccount: bobUser, recipientAccount: aliceUser, isVerified: true }), + expect: { direction: 'receive', transactionCardType: 'receive', userName: 'bob', isPeerActuallyUser: true }, + }, + + // ───── SEND_LINK ───── + { + name: 'SEND_LINK × SENDER (claimed by peanut user) → send to claimer username', + entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.SENDER, recipientAccount: aliceUser, isVerified: true }), + expect: { direction: 'send', transactionCardType: 'send', userName: 'alice', isPeerActuallyUser: true, isLinkTransaction: false }, + }, + { + name: 'SEND_LINK × SENDER (unclaimed) → still send via link, no peer', + entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.SENDER, status: EHistoryStatus.PENDING, recipientAccount: { ...externalEoa }, isVerified: true }), + expect: { direction: 'send', transactionCardType: 'send', isPeerActuallyUser: false, isLinkTransaction: true }, + }, + { + name: 'SEND_LINK × RECIPIENT (claimed by external addr) → claim_external', + entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: externalEoa }), + expect: { direction: 'claim_external', transactionCardType: 'claim_external', userName: externalEoa.identifier, isLinkTransaction: true }, + }, + { + name: 'SEND_LINK × BOTH → cancelled-by-self (link tx, peer = self)', + entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.BOTH, recipientAccount: aliceUser }), + expect: { isLinkTransaction: true }, + }, + + // ───── BRIDGE_OFFRAMP ───── + { + name: 'BRIDGE_OFFRAMP → bank_withdraw with bankAccountDetails populated', + entry: baseEntry({ type: EHistoryEntryType.BRIDGE_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), + expect: { direction: 'bank_withdraw', transactionCardType: 'bank_withdraw', userName: 'Bank Account', bankAccountDetailsDefined: true }, + }, + + // ───── MANTECA_OFFRAMP — bankAccountDetails plumbed (legacy bug fixed in PR-B) ───── + { + name: 'MANTECA_OFFRAMP → bank_withdraw with bankAccountDetails populated (post-PR-B)', + entry: baseEntry({ type: EHistoryEntryType.MANTECA_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), + expect: { direction: 'bank_withdraw', transactionCardType: 'bank_withdraw', bankAccountDetailsDefined: true }, + }, + + // ───── BRIDGE_ONRAMP / MANTECA_ONRAMP ───── + { + name: 'BRIDGE_ONRAMP → bank_deposit', + entry: baseEntry({ type: EHistoryEntryType.BRIDGE_ONRAMP, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: aliceUser }), + expect: { direction: 'bank_deposit', transactionCardType: 'bank_deposit', userName: 'Bank Account' }, + }, + { + name: 'MANTECA_ONRAMP → bank_deposit', + entry: baseEntry({ type: EHistoryEntryType.MANTECA_ONRAMP, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: aliceUser }), + expect: { direction: 'bank_deposit', transactionCardType: 'bank_deposit', userName: 'Bank Account' }, + }, + + // ───── DEPOSIT (CRYPTO_DEPOSIT legacy) ───── + { + name: 'DEPOSIT regular → add, with sender identifier (legacy: never marks peer as user)', + entry: baseEntry({ type: EHistoryEntryType.DEPOSIT, userRole: EHistoryUserRole.RECIPIENT, senderAccount: bobUser, recipientAccount: aliceUser, isVerified: true }), + // Legacy DEPOSIT case sets isPeerActuallyUser=false even when sender is a user. + // PR-B's TRANSACTION_INTENT/CRYPTO_DEPOSIT branch will improve on this; legacy stays as-is. + expect: { direction: 'add', transactionCardType: 'add', userName: bobUser.identifier, isPeerActuallyUser: false }, + }, + { + name: 'DEPOSIT zero-amount test transaction → "Enjoy Peanut!"', + entry: baseEntry({ type: EHistoryEntryType.DEPOSIT, userRole: EHistoryUserRole.RECIPIENT, amount: '0', recipientAccount: aliceUser }), + expect: { direction: 'add', transactionCardType: 'add', userName: 'Enjoy Peanut!' }, + }, + + // ───── QR PAYMENTS ───── + { + name: 'MANTECA_QR_PAYMENT → qr_payment / pay', + entry: baseEntry({ type: EHistoryEntryType.MANTECA_QR_PAYMENT, userRole: EHistoryUserRole.SENDER, recipientAccount: { identifier: 'merchant-xyz', type: 'MERCHANT', isUser: false } }), + expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'merchant-xyz' }, + }, + + // ───── PERK_REWARD ───── + { + name: 'PERK_REWARD → receive Peanut Reward', + entry: baseEntry({ type: EHistoryEntryType.PERK_REWARD, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: aliceUser }), + expect: { direction: 'receive', transactionCardType: 'receive', userName: 'Peanut Reward' }, + }, + + // ═════════════════════════════════════════════════════════════════════ + // TRANSACTION_INTENT — current state (some passing, some failing today) + // ═════════════════════════════════════════════════════════════════════ + + { + name: 'TRANSACTION_INTENT × P2P_SEND × SENDER → outgoing send', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + extraData: { kind: 'P2P_SEND' }, + isVerified: true, + }), + expect: { direction: 'send', transactionCardType: 'send', userName: 'alice', isPeerActuallyUser: true }, + }, + { + name: 'TRANSACTION_INTENT × P2P_SEND × RECIPIENT → incoming receive (already patched in playtest)', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.RECIPIENT, + senderAccount: bobUser, + recipientAccount: aliceUser, + extraData: { kind: 'P2P_SEND' }, + isVerified: true, + }), + expect: { direction: 'receive', transactionCardType: 'receive', userName: 'bob', isPeerActuallyUser: true }, + }, + { + name: 'TRANSACTION_INTENT × QR_PAY → qr_payment to merchant', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: { identifier: 'merchant-xyz', type: 'MERCHANT', isUser: false }, + extraData: { kind: 'QR_PAY' }, + }), + expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'merchant-xyz' }, + }, + { + name: 'TRANSACTION_INTENT × LINK_CREATE × SENDER → "Sent via link"', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: externalEoa, + extraData: { kind: 'LINK_CREATE' }, + }), + expect: { direction: 'send', transactionCardType: 'send', userName: 'Sent via link', isLinkTransaction: true }, + }, + { + name: 'TRANSACTION_INTENT × CRYPTO_WITHDRAW × SENDER → withdraw to external account', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: externalEoa, + extraData: { kind: 'CRYPTO_WITHDRAW' }, + }), + expect: { direction: 'withdraw', transactionCardType: 'withdraw', userName: externalEoa.identifier }, + }, + { + name: 'TRANSACTION_INTENT × CRYPTO_WITHDRAW × RECIPIENT → add (already patched in playtest)', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.RECIPIENT, + senderAccount: { identifier: '0xSomeone0000000000000000000000000000000000', type: 'WALLET_EXTERNAL', isUser: false }, + recipientAccount: aliceUser, + extraData: { kind: 'CRYPTO_WITHDRAW' }, + }), + expect: { direction: 'add', transactionCardType: 'add', userName: '0xSomeone0000000000000000000000000000000000' }, + }, + { + name: 'TRANSACTION_INTENT × FIAT_OFFRAMP × SENDER → bank_withdraw', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + extraData: { kind: 'FIAT_OFFRAMP' }, + }), + expect: { direction: 'bank_withdraw', transactionCardType: 'bank_withdraw', userName: 'Bank Account' }, + }, + { + name: 'TRANSACTION_INTENT × CARD_SPEND with merchant → qr_payment / pay', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + extraData: { kind: 'CARD_SPEND', merchantName: 'Acme Coffee', rainTransactionId: 'rain-123' }, + }), + expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'Acme Coffee', cardPaymentDefined: true }, + }, + { + name: 'TRANSACTION_INTENT × CARD_SPEND with no merchant → fallback "Card payment"', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + extraData: { kind: 'CARD_SPEND' }, + }), + expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'Card payment', cardPaymentDefined: true }, + }, + { + name: 'TRANSACTION_INTENT × OTHER + parentRainTxId → card refund', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.RECIPIENT, + recipientAccount: aliceUser, + extraData: { kind: 'OTHER', parentRainTxId: 'rain-456', merchantName: 'Acme Coffee' }, + }), + expect: { direction: 'receive', transactionCardType: 'receive', userName: 'Refund from Acme Coffee', cardPaymentDefined: true }, + }, + + // ═════════════════════════════════════════════════════════════════════ + // KNOWN BUGS — these tests SHOULD PASS after PR-B; today they FAIL + // ═════════════════════════════════════════════════════════════════════ + + { + name: '[PR-B] TRANSACTION_INTENT × CRYPTO_DEPOSIT → add (currently misroutes to default=send)', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.RECIPIENT, + senderAccount: bobUser, + recipientAccount: aliceUser, + extraData: { kind: 'CRYPTO_DEPOSIT' }, + isVerified: true, + }), + expect: { direction: 'add', transactionCardType: 'add', userName: 'bob', isPeerActuallyUser: true }, + }, + { + name: '[PR-B] TRANSACTION_INTENT × LINK_CREATE × RECIPIENT (claimed by user) → receive from claimer (currently always "Sent via link")', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.RECIPIENT, + senderAccount: bobUser, + recipientAccount: aliceUser, + extraData: { kind: 'LINK_CREATE' }, + isVerified: true, + }), + expect: { direction: 'receive', transactionCardType: 'receive', userName: 'bob', isPeerActuallyUser: true }, + }, + { + name: '[PR-B] TRANSACTION_INTENT × FIAT_OFFRAMP plumbs bankAccountDetails for the country flag', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + extraData: { kind: 'FIAT_OFFRAMP' }, + }), + expect: { bankAccountDetailsDefined: true }, + }, + { + name: '[PR-B] TRANSACTION_INTENT × CRYPTO_WITHDRAW plumbs bankAccountDetails when recipient is IBAN', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + extraData: { kind: 'CRYPTO_WITHDRAW' }, + }), + expect: { bankAccountDetailsDefined: true }, + }, + { + name: '[PR-B] MANTECA_OFFRAMP plumbs bankAccountDetails (independent legacy bug)', + entry: baseEntry({ type: EHistoryEntryType.MANTECA_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), + expect: { bankAccountDetailsDefined: true }, + }, + + // ─── PR-D: reaper-failed copy ───────────────────────────────────────── + { + name: '[PR-D] reaper-failed P2P_SEND (failReason=p2p_send_timeout) renders user-friendly copy', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + status: EHistoryStatus.FAILED, + recipientAccount: aliceUser, + extraData: { kind: 'P2P_SEND', failReason: 'p2p_send_timeout' }, + }), + expect: { userName: "Send didn't complete" }, + }, + { + name: '[PR-D] reaper-failed OFFRAMP renders bank-transfer copy', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + status: EHistoryStatus.FAILED, + recipientAccount: ibanAccountES, + extraData: { kind: 'FIAT_OFFRAMP', failReason: 'offramp_timeout' }, + }), + expect: { userName: "Bank transfer didn't complete" }, + }, + { + name: '[PR-D] non-reaper FAILED (no _timeout suffix) keeps original userName', + entry: baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + status: EHistoryStatus.FAILED, + recipientAccount: aliceUser, + extraData: { kind: 'P2P_SEND', failReason: 'validator_max_retries' }, + }), + // Falls through — userName remains 'alice' from the kind=P2P_SEND branch. + expect: { userName: 'alice' }, + }, +] + +describe('mapTransactionDataForDrawer', () => { + it.each(cases)('$name', ({ entry, expect: e }) => { + const result = mapTransactionDataForDrawer(entry).transactionDetails + + if (e.direction !== undefined) expect(result.direction).toBe(e.direction) + if (e.userName !== undefined) expect(result.userName).toBe(e.userName) + if (e.transactionCardType !== undefined) expect(result.extraDataForDrawer?.transactionCardType).toBe(e.transactionCardType) + if (e.isLinkTransaction !== undefined) expect(result.extraDataForDrawer?.isLinkTransaction).toBe(e.isLinkTransaction) + if (e.cardPaymentDefined !== undefined) expect(!!result.extraDataForDrawer?.cardPayment).toBe(e.cardPaymentDefined) + if (e.bankAccountDetailsDefined !== undefined) expect(!!result.bankAccountDetails).toBe(e.bankAccountDetailsDefined) + if (e.isPeerActuallyUser !== undefined) { + // isPeerActuallyUser isn't directly exposed; isVerified output is gated by it + // (isVerified = entry.isVerified && isPeerActuallyUser). Cases that assert this + // set entry.isVerified=true, so output isVerified === isPeerActuallyUser. + expect(result.isVerified).toBe(e.isPeerActuallyUser) + } + }) + + describe('TRANSACTION_INTENT default arm (forward-compat / regression guard)', () => { + it('renders an unhandled kind as something explicit, not silent fallthrough', () => { + const entry = baseEntry({ + type: EHistoryEntryType.TRANSACTION_INTENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + extraData: { kind: 'SOMETHING_NEW_THAT_BACKEND_ADDED' }, + }) + const result = mapTransactionDataForDrawer(entry).transactionDetails + // Today: direction='send', userName=alice's identifier (since it's the recipient). + // After PR-B's assertNever + Sentry breadcrumb, this should still produce a rendering + // (defensive rendering) but log the unknown kind. Asserting the rendering survives. + expect(result.direction).toBeDefined() + expect(result.extraDataForDrawer?.kind).toBe('SOMETHING_NEW_THAT_BACKEND_ADDED') + }) + }) +}) diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 3a9747253..5031ae7d0 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -19,6 +19,35 @@ import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/servic * @fileoverview maps raw transaction history data from the api/hook to the format needed by ui components. */ +/** + * Should the receipt drawer's `bankAccountDetails` row render for this entry? + * + * Original gate (pre-decomplexify) only fired for legacy `BRIDGE_OFFRAMP` and + * `BANK_SEND_LINK_CLAIM × RECIPIENT`. Post-decomplexify, the same flows arrive + * as `TRANSACTION_INTENT` with `kind ∈ {FIAT_OFFRAMP, CRYPTO_WITHDRAW}`, so the + * gate must include those too — otherwise the IBAN row disappears and the + * country-by-IBAN flag fallback in `getBankAccountCountryCode` kicks in, + * showing an EU flag for an ES IBAN (Hugo's screenshot d on 2026-04-29). + * + * Also includes legacy `MANTECA_OFFRAMP` — independent legacy bug, was never + * plumbed before. Catches Argentina/Brazil rail withdrawals. + */ +function shouldPlumbBankAccountDetails(entry: HistoryEntry): boolean { + if (entry.type === EHistoryEntryType.BRIDGE_OFFRAMP) return true + if (entry.type === EHistoryEntryType.MANTECA_OFFRAMP) return true + if ( + entry.type === EHistoryEntryType.BANK_SEND_LINK_CLAIM && + entry.userRole === EHistoryUserRole.RECIPIENT + ) { + return true + } + if (entry.type === EHistoryEntryType.TRANSACTION_INTENT) { + const kind = entry.extraData?.kind as string | undefined + if (kind === 'FIAT_OFFRAMP' || kind === 'CRYPTO_WITHDRAW') return true + } + return false +} + export type RewardData = { symbol: string formatAmount: (amount: number | bigint) => string @@ -28,6 +57,27 @@ export type RewardData = { // Configure reward tokens here export const REWARD_TOKENS: { [key: string]: RewardData } = {} +/** + * User-facing copy for reaper-failed rows. Keyed by the failReason string + * the BE reaper writes (`${kindStr.toLowerCase()}_timeout`). + * + * Why per-kind: a generic "Transaction failed" is ambiguous ("did funds + * move?"). These strings make clear the action never happened — no funds + * moved, no chain TX exists. Don't show a Cancel button; the row is already + * terminal. + */ +const REAPER_FAIL_COPY: Record = { + p2p_send_timeout: "Send didn't complete", + p2p_request_fulfill_timeout: "Payment didn't complete", + send_link_timeout: "Link didn't complete", + send_link_claim_timeout: "Claim didn't complete", + crypto_withdraw_timeout: "Withdrawal didn't complete", + qr_pay_timeout: "QR payment didn't complete", + onramp_timeout: "Bank deposit didn't arrive", + offramp_timeout: "Bank transfer didn't complete", + refund_timeout: "Refund didn't complete", +} + /** * defines the structure of the data expected by the transaction details drawer component. * includes ui-specific fields derived from the original history entry. @@ -403,11 +453,52 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact switch (kind) { case 'P2P_SEND': case 'REQUEST_PAY': - direction = 'send' - transactionCardType = 'send' - nameForDetails = - entry.recipientAccount?.username || entry.recipientAccount?.identifier || 'Recipient' - isPeerActuallyUser = !!entry.recipientAccount?.isUser + // Bridge-fulfilled requests render as bank-request + // fulfillments on the sender side (mirrors legacy REQUEST + // case at line 268). Viewer is paying via bank rails. + if ( + kind === 'REQUEST_PAY' && + entry.extraData?.fulfillmentType === 'bridge' && + entry.userRole === 'SENDER' + ) { + direction = 'bank_request_fulfillment' + transactionCardType = 'bank_request_fulfillment' + nameForDetails = + entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? 'Recipient' + fullName = entry.recipientAccount?.fullName ?? '' + showFullName = entry.recipientAccount?.showFullName + isPeerActuallyUser = + !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser + break + } + if (entry.userRole === 'RECIPIENT') { + // Viewer is on the receiving side. Two sub-cases: + // (a) Senderaccount.identifier is set → an actual paid + // send. Render as a receive. + // (b) Sender is empty → unfulfilled request the viewer + // created. Render as a request_received with a + // neutral label ("Request" — the FE's + // TransactionDetailsHeaderCard already styles this). + const senderResolved = !!entry.senderAccount?.identifier + if (senderResolved) { + direction = 'receive' + transactionCardType = 'receive' + nameForDetails = + entry.senderAccount?.username || entry.senderAccount?.identifier || 'Sender' + isPeerActuallyUser = !!entry.senderAccount?.isUser + } else { + direction = 'request_received' + transactionCardType = 'request' + nameForDetails = 'Request' + isPeerActuallyUser = false + } + } else { + direction = 'send' + transactionCardType = 'send' + nameForDetails = + entry.recipientAccount?.username || entry.recipientAccount?.identifier || 'Recipient' + isPeerActuallyUser = !!entry.recipientAccount?.isUser + } break case 'QR_PAY': direction = 'qr_payment' @@ -416,23 +507,99 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact isPeerActuallyUser = false break case 'LINK_CREATE': - direction = 'send' - transactionCardType = 'send' - nameForDetails = 'Sent via link' - isLinkTx = true - isPeerActuallyUser = false + if (entry.userRole === 'RECIPIENT') { + // The viewer claimed someone else's link. Mirrors the legacy + // SEND_LINK × RECIPIENT branch — show the sender as the peer. + if (entry.senderAccount?.isUser) { + direction = 'receive' + transactionCardType = 'receive' + nameForDetails = + entry.senderAccount?.username || + entry.senderAccount?.identifier || + 'Received via Link' + fullName = entry.senderAccount?.fullName ?? '' + showFullName = entry.senderAccount?.showFullName + isPeerActuallyUser = true + isLinkTx = false + } else { + direction = 'receive' + transactionCardType = 'receive' + nameForDetails = + entry.senderAccount?.username || + entry.senderAccount?.identifier || + 'Received via Link' + fullName = entry.senderAccount?.fullName ?? '' + isPeerActuallyUser = false + isLinkTx = true + } + } else { + // SENDER (creator). Resolve claimer if it's a peanut user; + // otherwise show the link-shaped placeholder. + if (entry.recipientAccount?.isUser) { + direction = 'send' + transactionCardType = 'send' + nameForDetails = + entry.recipientAccount?.username ?? entry.recipientAccount?.identifier + fullName = entry.recipientAccount?.fullName ?? '' + showFullName = entry.recipientAccount?.showFullName + isPeerActuallyUser = true + isLinkTx = false + } else { + direction = 'send' + transactionCardType = 'send' + nameForDetails = 'Sent via link' + isLinkTx = true + isPeerActuallyUser = false + } + } + break + case 'CRYPTO_DEPOSIT': + // Incoming on-chain deposit. If the sender resolved to a known + // Peanut user, surface their username + clickable avatar + // (improvement over the legacy DEPOSIT branch which always + // forced isPeerActuallyUser=false). + direction = 'add' + transactionCardType = 'add' + nameForDetails = + entry.senderAccount?.username || entry.senderAccount?.identifier || 'Deposit Source' + fullName = entry.senderAccount?.fullName ?? '' + showFullName = entry.senderAccount?.showFullName + isPeerActuallyUser = !!entry.senderAccount?.isUser break case 'CRYPTO_WITHDRAW': - direction = 'withdraw' - transactionCardType = 'withdraw' - nameForDetails = entry.recipientAccount?.identifier || 'External Account' - isPeerActuallyUser = false + if (entry.userRole === 'RECIPIENT') { + // The viewer received crypto from someone else's withdraw + // (e.g. another user sent to this user's wallet via a + // CRYPTO_WITHDRAW). Render as a deposit-style row. + direction = 'add' + transactionCardType = 'add' + nameForDetails = + entry.senderAccount?.username || entry.senderAccount?.identifier || 'External Wallet' + isPeerActuallyUser = !!entry.senderAccount?.isUser + } else { + direction = 'withdraw' + transactionCardType = 'withdraw' + nameForDetails = entry.recipientAccount?.identifier || 'External Account' + isPeerActuallyUser = false + } break case 'FIAT_OFFRAMP': - direction = 'bank_withdraw' - transactionCardType = 'bank_withdraw' - nameForDetails = 'Bank Account' - isPeerActuallyUser = false + if (entry.userRole === 'RECIPIENT') { + // Multi-user fulfillment edge case — viewer received a + // bank withdraw initiated by another user. Render as a + // receive (USDC arrives in viewer's wallet from offramp + // funder). + direction = 'receive' + transactionCardType = 'receive' + nameForDetails = + entry.senderAccount?.username || entry.senderAccount?.identifier || 'Bank Account' + isPeerActuallyUser = !!entry.senderAccount?.isUser + } else { + direction = 'bank_withdraw' + transactionCardType = 'bank_withdraw' + nameForDetails = 'Bank Account' + isPeerActuallyUser = false + } break case 'CARD_SPEND': { // Merchant fields come from the M3 history fetcher's extraData @@ -462,6 +629,28 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact isPeerActuallyUser = false break } + // Unknown TRANSACTION_INTENT kind — log to Sentry so we + // catch BE-added kinds the FE doesn't yet handle. Render + // a defensive fallback so the row still appears. + if (typeof window !== 'undefined') { + // Lazy import to avoid bundling Sentry in non-browser + // contexts (test, SSR). Logged as a warning, not a + // hard error — the row still renders. + import('@sentry/nextjs') + .then((Sentry) => + Sentry.captureMessage( + `transactionTransformer: unhandled TRANSACTION_INTENT kind "${kind}"`, + { + level: 'warning', + tags: { feature: 'history', kind }, + extra: { entryUuid: entry.uuid, userRole: entry.userRole }, + } + ) + ) + .catch(() => { + // Sentry not available (test env) — no-op. + }) + } direction = 'send' transactionCardType = 'send' nameForDetails = entry.recipientAccount?.identifier || 'Transaction' @@ -484,6 +673,19 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact isPeerActuallyUser = false } + // Reaper-failed rows (orphaned PENDING intents the BE timed out — see + // peanut-api-ts/src/ledger/reaper.ts) get user-friendly copy. Without + // this branch the row renders with whatever userName the kind-switch + // landed on, which for a P2P_SEND that never confirmed is misleading + // ("Sent to alice" implies funds moved). The reaper sets + // metadata.failReason to '${kind}_timeout' before transitioning FAILED; + // the BE history fetcher surfaces it as `entry.extraData.failReason`. + const reaperFailReason = entry.extraData?.failReason as string | undefined + if (entry.status === 'FAILED' && reaperFailReason && reaperFailReason.endsWith('_timeout')) { + nameForDetails = REAPER_FAIL_COPY[reaperFailReason] ?? 'Transaction did not complete' + isPeerActuallyUser = false + } + // map the raw status string to the defined ui status types if ( entry.type === EHistoryEntryType.BRIDGE_OFFRAMP || @@ -728,14 +930,12 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact }, sourceView: 'history', points: entry.points, - bankAccountDetails: - entry.type === EHistoryEntryType.BRIDGE_OFFRAMP || - (entry.type === EHistoryEntryType.BANK_SEND_LINK_CLAIM && entry.userRole === EHistoryUserRole.RECIPIENT) - ? { - identifier: entry.recipientAccount.identifier, - type: entry.recipientAccount.type, - } - : undefined, + bankAccountDetails: shouldPlumbBankAccountDetails(entry) + ? { + identifier: entry.recipientAccount.identifier, + type: entry.recipientAccount.type, + } + : undefined, claimedAt: entry.claimedAt, createdAt: entry.createdAt, completedAt: entry.completedAt, diff --git a/src/components/TransactionDetails/useReceiptViewModel.ts b/src/components/TransactionDetails/useReceiptViewModel.ts index ee47769c0..479dd47a1 100644 --- a/src/components/TransactionDetails/useReceiptViewModel.ts +++ b/src/components/TransactionDetails/useReceiptViewModel.ts @@ -188,6 +188,9 @@ export function useReceiptViewModel( ), depositInstructions: !!( (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.BRIDGE_ONRAMP || + // Post-decomplexify: BRIDGE_ONRAMP arrives as TRANSACTION_INTENT/kind=ONRAMP. + (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.TRANSACTION_INTENT && + transaction.extraDataForDrawer?.kind === 'ONRAMP') || (isPendingBankRequest && transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER)) && transaction.status === 'pending' && @@ -205,7 +208,14 @@ export function useReceiptViewModel( attachment: !!(transaction.attachmentUrl && transaction.status !== 'cancelled'), mantecaDepositInfo: !isPublic && - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP && + (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP || + // Post-decomplexify: MANTECA_ONRAMP arrives as TRANSACTION_INTENT/kind=ONRAMP + // (provider differentiates Manteca vs Bridge — same kind, different provider). + // Without provider plumbed through to the FE, gate on kind alone — Bridge + // onramps with deposit instructions branch above; this path covers Manteca's + // ARS/BRL deposit info row. + (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.TRANSACTION_INTENT && + transaction.extraDataForDrawer?.kind === 'ONRAMP')) && transaction.status === 'pending', // Gate on whether CardPaymentRows would actually emit a sub-row. // Otherwise an "all-data-absent" card spend leaves the slot diff --git a/src/context/PeanutDebug.tsx b/src/context/PeanutDebug.tsx index 60124997b..2e0cb65b7 100644 --- a/src/context/PeanutDebug.tsx +++ b/src/context/PeanutDebug.tsx @@ -16,6 +16,7 @@ import { useEffect } from 'react' import { PEANUT_API_URL } from '@/constants/general.consts' import { debugLog } from '@/utils/debug-console' +import { logRunMode } from '@/utils/mode' export function PeanutDebug() { useEffect(() => { @@ -34,6 +35,11 @@ export function PeanutDebug() { return (window as any).__peanut_user_id ?? null } + // Captures the most recent BE response so callers can surface the + // structured `hint`/`suggestions` fields in helpful errors instead of + // generic "fetch failed" / "user not found". + let lastResponse: { status?: number; json?: any; networkError?: string } = {} + async function call(path: string, body?: object, method: 'GET' | 'POST' = 'POST') { const url = method === 'GET' && body @@ -41,16 +47,34 @@ export function PeanutDebug() { : `${PEANUT_API_URL}${path}` const start = performance.now() debugLog(`→ ${method} ${path}`, body ?? '') - const res = await fetch(url, { - method, - headers: { 'content-type': 'application/json', 'x-test-harness-secret': secret }, - body: method === 'POST' && body ? JSON.stringify(body) : undefined, - }) - const json = await res.json().catch(() => ({})) - const ms = Math.round(performance.now() - start) - const ok = res.ok && json?.ok !== false - debugLog(`${ok ? '✓' : '✗'} ${method} ${path} (${ms}ms)`, json) - return json + try { + const res = await fetch(url, { + method, + headers: { 'content-type': 'application/json', 'x-test-harness-secret': secret }, + body: method === 'POST' && body ? JSON.stringify(body) : undefined, + }) + const json = await res.json().catch(() => ({})) + const ms = Math.round(performance.now() - start) + const ok = res.ok && json?.ok !== false + debugLog(`${ok ? '✓' : '✗'} ${method} ${path} (${ms}ms)`, json) + lastResponse = { status: res.status, json } + return json + } catch (err) { + lastResponse = { networkError: (err as Error).message } + debugLog(`✗ ${method} ${path} — network error: ${(err as Error).message}`) + throw new Error( + `cheat call ${method} ${path} failed: ${(err as Error).message}.\n` + + `Hint: is the API running on ${PEANUT_API_URL}? Check 'qa status' or tail engineering/qa/logs/api.log.` + ) + } + } + + // Build a friendly multi-line error string. console.error renders + // multi-line strings as a stacked block, so the dev sees suggestions + // inline instead of having to expand a Promise rejection. + function friendlyError(headline: string, lines: string[]): Error { + const body = lines.filter(Boolean).join('\n ') + return new Error(body ? `${headline}\n ${body}` : headline) } const resolveUserId = async (userId?: string) => { @@ -65,7 +89,111 @@ export function PeanutDebug() { return uid ?? null } - const debugApi = { + // Default harness PK for the local-dev impersonate cheat. Wired in + // peanut_local_staging as hugostagqa's WALLET_SMART address override + // (SA: 0x478Eb47326...). Hardcoded here so `debug.impersonate('hugostagqa')` + // works in one shot — no need to pass `{ pk }` every time. Local-only; + // gated by HARNESS_ENABLED + the dev cheat route's requireTestMode. + // Sandbox harness keys are NOT secrets — Konrad's call. + const DEFAULT_HARNESS_PK = + '0x8501e6e37f45d268618debb9f0d95528ca90a2eadcb29ac2277c0284d0ec861b' + + const debugApi: any = { + // Local-dev impersonation cheat. Mints a JWT for the given userId + // OR username (case-insensitive lookup against /dev/cheats/whoami) + // and drops it into the jwt-token cookie + sets the harness ECDSA + // signer flags so transactions sign without a passkey. + // + // Usage: + // debug.impersonate('hugostagqa') // by username, default PK + // debug.impersonate('7077676f-2bba-...') // by userId UUID + // debug.impersonate('user', { pk: '0x..' }) // override PK + // debug.impersonate('user', { skipSigner: true }) // cookie only + async impersonate(userIdOrUsername: string, opts: { skipSigner?: boolean; pk?: string } = {}) { + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + let userId = userIdOrUsername + if (!uuidRe.test(userIdOrUsername)) { + const j = await call('/dev/cheats/userid-by-username', { username: userIdOrUsername }, 'GET') + if (!j?.userId) { + const sug: string[] = j?.suggestions ?? [] + const total: number | undefined = j?.totalUsers + throw friendlyError(`username '${userIdOrUsername}' not found in app.users`, [ + sug.length + ? `Did you mean: ${sug.map((s) => `'${s}'`).join(', ')}?` + : `No usernames start with '${userIdOrUsername.slice(0, 3)}'.`, + total != null ? `(${total} users in this DB)` : '', + 'Tip: list available users with debug.listUsers().', + ]) + } + userId = j.userId + } + const j = await call('/dev/cheats/mint-jwt', { userId }) + if (!j?.token) { + throw friendlyError('mint-jwt failed', [ + j?.hint ?? `BE returned: ${JSON.stringify(j)}`, + 'Common causes: API not on the right DB, JWT_SECRET unset, or harness secret mismatched.', + ]) + } + document.cookie = `jwt-token=${j.token}; path=/; max-age=2592000; SameSite=Lax` + if (!opts.skipSigner) { + // Resolution order: explicit opt > default. We deliberately + // do NOT consult localStorage here — a stale-bad PK from a + // prior session would otherwise win over the default and + // the kernel would silently fail to init (the symptom: a + // hugostagqa impersonate that "ran fine" but balance keeps + // loading and send-link insta-fails). `impersonate` should + // reset to known-good every time. Use `debug.harnessSigner(pk)` + // if you want to keep a custom PK across calls. + const pk = opts.pk ?? DEFAULT_HARNESS_PK + localStorage.setItem('__harness_skip_passkey', 'true') + localStorage.setItem('__harness_ecdsa_pk', pk) + localStorage.setItem('__harness_ecdsa_sponsored', 'true') + } + debugLog(`impersonating ${userId} — reloading /home`) + location.href = '/home' + }, + // Idempotent harness-signer setup without changing the JWT. + // Useful if `useZeroDev not ready` shows up on a tab that already + // has a JWT but no localStorage signer flags — just run + // `debug.harnessSigner()` and reload. + harnessSigner(pk?: string) { + if (pk && !/^0x[0-9a-fA-F]{64}$/.test(pk)) { + throw friendlyError('invalid PK format', [ + `Got: ${pk.slice(0, 12)}… (length=${pk.length})`, + 'Expected: 0x-prefixed 32-byte hex string (66 chars total).', + 'Tip: pass no argument to use the default harness PK.', + ]) + } + const key = pk ?? localStorage.getItem('__harness_ecdsa_pk') ?? DEFAULT_HARNESS_PK + localStorage.setItem('__harness_skip_passkey', 'true') + localStorage.setItem('__harness_ecdsa_pk', key) + localStorage.setItem('__harness_ecdsa_sponsored', 'true') + debugLog(`harness signer set (pk=${key.slice(0, 10)}…) — reload to take effect`) + location.reload() + }, + // Re-log the current run-mode (api / chain / signing) with + // big yellow text. Useful when you've been heads-down for an + // hour and forgot whether this tab is sandbox or staging. + mode() { + return logRunMode('debug.mode():') + }, + // Lists usernames from app.users so devs can find someone to + // impersonate without remembering names. Pass an optional prefix + // to narrow the result. + async listUsers(prefix: string = '', limit: number = 20) { + const j = await call('/dev/cheats/list-users', { prefix, limit }, 'GET') + if (!j?.users) { + throw friendlyError('listUsers failed', [ + `BE returned: ${JSON.stringify(j)}`, + 'Make sure the API is running with HARNESS_ENABLED=true and the harness secret matches.', + ]) + } + debugLog(`${j.users.length} users (of ${j.totalUsers ?? '?'} total):\n ` + j.users.join('\n ')) + return j.users as string[] + }, + logout() { + return debugApi.signOut() + }, // DB resets / session wipes async signOut() { // Clear every cookie + storage + IndexedDB (full reset of tab) @@ -335,6 +463,11 @@ export function PeanutDebug() { const lines = [ 'debug.fullSetup() one-click: KYC-all + fund + simulate-deposit + complete-pending', 'debug.autoComplete() complete every PROCESSING intent (Bridge ONRAMP/OFFRAMP)', + 'debug.impersonate(userIdOrUsername) mint JWT + harness signer for any user; reloads to /home', + 'debug.harnessSigner(pk?) re-arm the ECDSA signer without changing JWT; reloads', + 'debug.listUsers(prefix?, limit=20) list usernames available to impersonate (default 20)', + 'debug.mode() log api/chain/signing run-mode (yellow=sandbox, red=real money)', + 'debug.logout() alias for signOut()', 'debug.signOut() clear cookies + storage + reload', 'debug.whoami() KYC / wallet / provider ids', 'debug.fund(usdc="10") harness EOA → your SA (default $10)', diff --git a/src/utils/account-mask.utils.ts b/src/utils/account-mask.utils.ts new file mode 100644 index 000000000..569c2794f --- /dev/null +++ b/src/utils/account-mask.utils.ts @@ -0,0 +1,87 @@ +/** + * Per-rail bank account masking for receipt display. + * + * Receipts show transactions the user has already completed; the user has the + * full identifier in their saved-accounts list. The receipt only needs enough + * to remind them which account it was — last 4 digits suffice for numeric + * rails. Free-form/identifier rails (PIX, Manteca aliases) leave the value + * intact since masking would mangle the meaning. + * + * NOT used in deposit-instruction drawers — there the user needs to copy the + * full identifier to wire from their bank, so we render the unmasked value. + * + * Compare per-rail rules in `MASK_RULES` (constants below). Default for an + * unrecognised rail is `plain` — fail-open rather than mask wrong-shape input. + */ + +type MaskMode = + /** "**** **** **** 0217" — keep last 4, format in groups of 4. IBAN/CLABE/CBU/CVU. */ + | 'last-4' + /** Last 4 of account number; routing number stays plain. US ACH / GB sort code. */ + | 'last-4-account-only' + /** Truncate at 32 chars with ellipsis. PIX (email/phone/CPF/UUID — masking corrupts). */ + | 'truncate-32' + /** Show as-is. Manteca aliases — short user-chosen strings. */ + | 'plain' + +interface MaskRule { + mode: MaskMode +} + +/** + * Per-rail rules. Account types come from the BE's `Account.type` field + * (peanut-api-ts/prisma/schema.prisma `AccountType` enum). + */ +const MASK_RULES: Record = { + IBAN: { mode: 'last-4' }, + CLABE: { mode: 'last-4' }, + CBU: { mode: 'last-4' }, + CVU: { mode: 'last-4' }, + BANK_CBU: { mode: 'last-4' }, + BANK_CVU: { mode: 'last-4' }, + US: { mode: 'last-4-account-only' }, + GB: { mode: 'last-4-account-only' }, + PIX: { mode: 'truncate-32' }, + BANK_PIX: { mode: 'truncate-32' }, + MANTECA: { mode: 'plain' }, + MANTECA_ALIAS: { mode: 'plain' }, +} + +/** + * Mask a bank account identifier for receipt display per-rail. + * + * @param identifier the raw account string (IBAN, CBU, PIX key, etc.) + * @param accountType the rail (`IBAN`, `CLABE`, `PIX`, …) — falls back to plain if unknown + * @returns the masked display string. Empty/missing input returns ''. + */ +export function maskAccountIdentifier(identifier: string | null | undefined, accountType: string | null | undefined): string { + if (!identifier) return '' + const rail = (accountType ?? '').toUpperCase() + const rule = MASK_RULES[rail] ?? { mode: 'plain' as MaskMode } + + switch (rule.mode) { + case 'last-4': { + const cleaned = identifier.replace(/\s+/g, '') + if (cleaned.length <= 4) return cleaned + const last4 = cleaned.slice(-4) + // Format as **** **** **** 1234 (groups of 4). + return `**** **** **** ${last4}` + } + case 'last-4-account-only': { + // For US ACH the saved identifier shape is variable — sometimes the + // routing number is included, sometimes only the account number. + // The conservative move: show the last 4 digits of whatever we + // have. If a future BE shape exposes routingNumber separately, a + // richer rendering ("Bank · **** 6789") slots in here. + const cleaned = identifier.replace(/\s+/g, '') + if (cleaned.length <= 4) return cleaned + return `**** ${cleaned.slice(-4)}` + } + case 'truncate-32': { + if (identifier.length <= 32) return identifier + return identifier.slice(0, 29) + '…' + } + case 'plain': + return identifier + } +} diff --git a/src/utils/mode.ts b/src/utils/mode.ts new file mode 100644 index 000000000..01a65b173 --- /dev/null +++ b/src/utils/mode.ts @@ -0,0 +1,113 @@ +/** + * Run-mode detection for the FE. + * + * The FE doesn't have a single "mode" env var; the running configuration is + * derived from a constellation of NEXT_PUBLIC_* values that point at + * different APIs, chains, bundlers, etc. This module classifies them into a + * coherent label so devs can see at a glance what they're actually wired up + * to — surfaced in the dev banner + console log on every load. + * + * Classification (in priority order): + * - api = local | staging | prod | unknown + * - chain = arb-sepolia | arb-mainnet | unknown + * - signing = harness-ecdsa | passkey (build-time gate) + * - preset = derived label combining the above + */ + +import { PEANUT_API_URL } from '@/constants/general.consts' +import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' + +export type ApiTier = 'local' | 'staging' | 'prod' | 'unknown' +export type ChainTier = 'arb-sepolia' | 'arb-mainnet' | 'unknown' +export type SigningMode = 'harness-ecdsa' | 'passkey' + +export interface RunMode { + api: ApiTier + apiUrl: string + chain: ChainTier + chainId: number + signing: SigningMode + /** Combined human label, e.g. "sandbox · arb-sepolia · harness-ecdsa". */ + preset: string +} + +function classifyApi(url: string): ApiTier { + const u = url.toLowerCase() + if (u.includes('localhost') || u.includes('127.0.0.1') || u.startsWith('/')) return 'local' + if (u.includes('staging') || u.includes('-staging') || u.includes('peanut-api-ts-staging')) return 'staging' + if (u.includes('peanut-api-ts.onrender.com') || u.includes('api.peanut.me')) return 'prod' + return 'unknown' +} + +function classifyChain(chainId: number): ChainTier { + if (chainId === 421614) return 'arb-sepolia' + if (chainId === 42161) return 'arb-mainnet' + return 'unknown' +} + +function classifySigning(): SigningMode { + // Build-time gate. When set, the FE skips WebAuthn entirely. + if (process.env.NEXT_PUBLIC_HARNESS_SKIP_PASSKEY_CHECK === 'true') return 'harness-ecdsa' + return 'passkey' +} + +export function getRunMode(): RunMode { + const api = classifyApi(PEANUT_API_URL) + const chain = classifyChain(PEANUT_WALLET_CHAIN.id) + const signing = classifySigning() + + // Preset shortcut. The combinations we actually run: + // sandbox = local API + arb-sepolia + harness-ecdsa (fully local) + // staging-mirror = staging API + arb-sepolia + passkey (real staging DB; FE local) + // prod-real = prod API + arb-mainnet + passkey (real money — danger) + // custom = anything else + let preset = 'custom' + if (api === 'local' && chain === 'arb-sepolia' && signing === 'harness-ecdsa') preset = 'sandbox' + else if (api === 'staging' && chain === 'arb-sepolia') preset = 'staging-mirror' + else if (api === 'prod' && chain === 'arb-mainnet') preset = 'prod-real' + else preset = `custom (${api} · ${chain} · ${signing})` + + return { api, apiUrl: PEANUT_API_URL, chain, chainId: PEANUT_WALLET_CHAIN.id, signing, preset } +} + +/** + * Returns true if the current run-mode is "danger" — connected to real-money + * rails (prod API + mainnet). Used by UI elements that should warn or + * confirm before letting the user act. + */ +export function isRealMoneyMode(): boolean { + const mode = getRunMode() + return mode.api === 'prod' || mode.chain === 'arb-mainnet' +} + +/** + * Pretty-print the run-mode to the browser console with high-contrast styling. + * Used at app startup (Banner mount) and on demand via `debug.mode()`. Yellow + * background + black bold text + giant size — visually impossible to miss. + * + * Real-money modes get a red banner instead so the dev can never confuse + * sandbox for prod at a glance. + */ +export function logRunMode(prefix: string = ''): RunMode { + const m = getRunMode() + const realMoney = isRealMoneyMode() + + const headlineStyle = realMoney + ? 'background: #dc2626; color: #fff; font-size: 22px; font-weight: 900; padding: 10px 16px; border-radius: 4px; letter-spacing: 0.05em;' + : 'background: #facc15; color: #000; font-size: 22px; font-weight: 900; padding: 10px 16px; border-radius: 4px; letter-spacing: 0.05em;' + + const detailStyle = 'font-size: 13px; font-weight: 600; line-height: 1.6em;' + const tag = realMoney ? '⚠ REAL MONEY MODE' : '🟢 SANDBOX MODE' + + // eslint-disable-next-line no-console + console.log( + `${prefix ? prefix + ' ' : ''}%c${tag} · ${m.preset}%c\n` + + ` api = ${m.api} (${m.apiUrl})\n` + + ` chain = ${m.chain} (${m.chainId})\n` + + ` signing = ${m.signing}`, + headlineStyle, + detailStyle + ) + + return m +}