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
+}