From 5440d588e1a7b86aaef99b7a45b2dfdd7808748c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Apr 2026 17:51:23 +0100 Subject: [PATCH 1/2] refactor(observability): pipelineAlert() helper for FE Sentry signals Mirror the BE pipelineAlert helper. Migrates the transformer default-arm unknown-kind alert to the shared schema (component=pipeline, fixed category union, intentKind tag), so BE+FE pipeline events filter on the same dashboard query. Pairs with peanut-api-ts PR #679. --- .../transactionTransformer.ts | 45 +++++-------------- src/utils/pipelineAlerts.ts | 43 ++++++++++++++++++ 2 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 src/utils/pipelineAlerts.ts diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 5031ae7d0..3e6295323 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -14,6 +14,7 @@ import { type StatusPillType } from '../Global/StatusPill' import type { Address } from 'viem' import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/services.types' +import { pipelineAlert } from '@/utils/pipelineAlerts' /** * @fileoverview maps raw transaction history data from the api/hook to the format needed by ui components. @@ -35,10 +36,7 @@ import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/servic 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 - ) { + if (entry.type === EHistoryEntryType.BANK_SEND_LINK_CLAIM && entry.userRole === EHistoryUserRole.RECIPIENT) { return true } if (entry.type === EHistoryEntryType.TRANSACTION_INTENT) { @@ -467,8 +465,7 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? 'Recipient' fullName = entry.recipientAccount?.fullName ?? '' showFullName = entry.recipientAccount?.showFullName - isPeerActuallyUser = - !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser + isPeerActuallyUser = !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser break } if (entry.userRole === 'RECIPIENT') { @@ -514,9 +511,7 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact direction = 'receive' transactionCardType = 'receive' nameForDetails = - entry.senderAccount?.username || - entry.senderAccount?.identifier || - 'Received via Link' + entry.senderAccount?.username || entry.senderAccount?.identifier || 'Received via Link' fullName = entry.senderAccount?.fullName ?? '' showFullName = entry.senderAccount?.showFullName isPeerActuallyUser = true @@ -525,9 +520,7 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact direction = 'receive' transactionCardType = 'receive' nameForDetails = - entry.senderAccount?.username || - entry.senderAccount?.identifier || - 'Received via Link' + entry.senderAccount?.username || entry.senderAccount?.identifier || 'Received via Link' fullName = entry.senderAccount?.fullName ?? '' isPeerActuallyUser = false isLinkTx = true @@ -538,8 +531,7 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact if (entry.recipientAccount?.isUser) { direction = 'send' transactionCardType = 'send' - nameForDetails = - entry.recipientAccount?.username ?? entry.recipientAccount?.identifier + nameForDetails = entry.recipientAccount?.username ?? entry.recipientAccount?.identifier fullName = entry.recipientAccount?.fullName ?? '' showFullName = entry.recipientAccount?.showFullName isPeerActuallyUser = true @@ -632,25 +624,12 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact // 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. - }) - } + pipelineAlert( + 'unknown_transformer_kind', + `transactionTransformer: unhandled TRANSACTION_INTENT kind "${kind}"`, + { entryUuid: entry.uuid, kind, userRole: entry.userRole }, + 'warning' + ) direction = 'send' transactionCardType = 'send' nameForDetails = entry.recipientAccount?.identifier || 'Transaction' diff --git a/src/utils/pipelineAlerts.ts b/src/utils/pipelineAlerts.ts new file mode 100644 index 000000000..2127cb9fb --- /dev/null +++ b/src/utils/pipelineAlerts.ts @@ -0,0 +1,43 @@ +/** + * Single entry point for cross-cutting Sentry signals from the + * FE-side rendering pipeline (transformer, receipt drawer, etc). + * Mirrors peanut-api-ts/src/utils/pipelineAlerts.ts — same category + * union and tag schema so dashboards filter consistently across BE/FE. + * + * Sentry import is lazy: importing `@sentry/nextjs` at module scope adds + * weight to non-browser bundles (test runner, SSR). The dynamic import + * keeps the helper a no-op when Sentry isn't initialised. + */ + +export type PipelineAlertCategory = + | 'reaper_vs_completion' + | 'unknown_transformer_kind' + | 'reaper_validator_anomaly' + | 'orphaned_intent' + | 'projection_drift' + +export interface PipelineAlertExtra { + intentId?: string + kind?: string + userId?: string + source?: string + [k: string]: unknown +} + +export function pipelineAlert( + category: PipelineAlertCategory, + message: string, + extra: PipelineAlertExtra = {}, + level: 'warning' | 'error' = 'error' +): void { + if (typeof window === 'undefined') return + import('@sentry/nextjs') + .then((Sentry) => + Sentry.captureMessage(message, { + level, + tags: { component: 'pipeline', category, intentKind: (extra.kind as string | undefined) ?? 'n/a' }, + extra, + }) + ) + .catch(() => {}) +} From fd20e715f02d7023e82f64c6cc61ded1a5854f84 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Apr 2026 18:46:01 +0100 Subject: [PATCH 2/2] format: prettier-write inherited files for lint gate --- .../__tests__/transactionTransformer.test.ts | 188 ++++++++++++++---- src/context/PeanutDebug.tsx | 3 +- src/utils/account-mask.utils.ts | 5 +- 3 files changed, 158 insertions(+), 38 deletions(-) diff --git a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts index 7c3b1ff1a..7f6a0d9aa 100644 --- a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts +++ b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts @@ -1,10 +1,5 @@ import { mapTransactionDataForDrawer } from '../transactionTransformer' -import { - EHistoryEntryType, - EHistoryUserRole, - EHistoryStatus, - type HistoryEntry, -} from '@/utils/history.utils' +import { EHistoryEntryType, EHistoryUserRole, EHistoryStatus, type HistoryEntry } from '@/utils/history.utils' jest.mock('@/assets', () => ({})) jest.mock('@/assets/payment-apps', () => ({ MERCADO_PAGO: '', PIX: '', SIMPLEFI: '' })) @@ -80,88 +75,180 @@ 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 }, + 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 }), + 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 }, + 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 }), + 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 }, + 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 }), + 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 }, + 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 }), + 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 }), + 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 }), + 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 }), + 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 }, + 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 }), + 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 } }), + 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 }), + entry: baseEntry({ + type: EHistoryEntryType.PERK_REWARD, + userRole: EHistoryUserRole.RECIPIENT, + recipientAccount: aliceUser, + }), expect: { direction: 'receive', transactionCardType: 'receive', userName: 'Peanut Reward' }, }, @@ -227,11 +314,19 @@ const cases: TestCase[] = [ entry: baseEntry({ type: EHistoryEntryType.TRANSACTION_INTENT, userRole: EHistoryUserRole.RECIPIENT, - senderAccount: { identifier: '0xSomeone0000000000000000000000000000000000', type: 'WALLET_EXTERNAL', isUser: false }, + senderAccount: { + identifier: '0xSomeone0000000000000000000000000000000000', + type: 'WALLET_EXTERNAL', + isUser: false, + }, recipientAccount: aliceUser, extraData: { kind: 'CRYPTO_WITHDRAW' }, }), - expect: { direction: 'add', transactionCardType: 'add', userName: '0xSomeone0000000000000000000000000000000000' }, + expect: { + direction: 'add', + transactionCardType: 'add', + userName: '0xSomeone0000000000000000000000000000000000', + }, }, { name: 'TRANSACTION_INTENT × FIAT_OFFRAMP × SENDER → bank_withdraw', @@ -251,7 +346,12 @@ const cases: TestCase[] = [ recipientAccount: aliceUser, extraData: { kind: 'CARD_SPEND', merchantName: 'Acme Coffee', rainTransactionId: 'rain-123' }, }), - expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'Acme Coffee', cardPaymentDefined: true }, + expect: { + direction: 'qr_payment', + transactionCardType: 'pay', + userName: 'Acme Coffee', + cardPaymentDefined: true, + }, }, { name: 'TRANSACTION_INTENT × CARD_SPEND with no merchant → fallback "Card payment"', @@ -261,7 +361,12 @@ const cases: TestCase[] = [ recipientAccount: aliceUser, extraData: { kind: 'CARD_SPEND' }, }), - expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'Card payment', cardPaymentDefined: true }, + expect: { + direction: 'qr_payment', + transactionCardType: 'pay', + userName: 'Card payment', + cardPaymentDefined: true, + }, }, { name: 'TRANSACTION_INTENT × OTHER + parentRainTxId → card refund', @@ -271,7 +376,12 @@ const cases: TestCase[] = [ recipientAccount: aliceUser, extraData: { kind: 'OTHER', parentRainTxId: 'rain-456', merchantName: 'Acme Coffee' }, }), - expect: { direction: 'receive', transactionCardType: 'receive', userName: 'Refund from Acme Coffee', cardPaymentDefined: true }, + expect: { + direction: 'receive', + transactionCardType: 'receive', + userName: 'Refund from Acme Coffee', + cardPaymentDefined: true, + }, }, // ═════════════════════════════════════════════════════════════════════ @@ -324,7 +434,11 @@ const cases: TestCase[] = [ }, { name: '[PR-B] MANTECA_OFFRAMP plumbs bankAccountDetails (independent legacy bug)', - entry: baseEntry({ type: EHistoryEntryType.MANTECA_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), + entry: baseEntry({ + type: EHistoryEntryType.MANTECA_OFFRAMP, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + }), expect: { bankAccountDetailsDefined: true }, }, @@ -371,10 +485,14 @@ describe('mapTransactionDataForDrawer', () => { 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.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 diff --git a/src/context/PeanutDebug.tsx b/src/context/PeanutDebug.tsx index 2e0cb65b7..0e716e6f2 100644 --- a/src/context/PeanutDebug.tsx +++ b/src/context/PeanutDebug.tsx @@ -95,8 +95,7 @@ export function PeanutDebug() { // 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 DEFAULT_HARNESS_PK = '0x8501e6e37f45d268618debb9f0d95528ca90a2eadcb29ac2277c0284d0ec861b' const debugApi: any = { // Local-dev impersonation cheat. Mints a JWT for the given userId diff --git a/src/utils/account-mask.utils.ts b/src/utils/account-mask.utils.ts index 569c2794f..f10efac2e 100644 --- a/src/utils/account-mask.utils.ts +++ b/src/utils/account-mask.utils.ts @@ -54,7 +54,10 @@ const MASK_RULES: Record = { * @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 { +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 }