diff --git a/oltinpay/oltinpay-webapp/src/app/wallet/page.tsx b/oltinpay/oltinpay-webapp/src/app/wallet/page.tsx index 46220d6..8f9c06e 100644 --- a/oltinpay/oltinpay-webapp/src/app/wallet/page.tsx +++ b/oltinpay/oltinpay-webapp/src/app/wallet/page.tsx @@ -10,13 +10,15 @@ import { useBalances } from '@/hooks/useBalances'; import { useTransactions } from '@/hooks/useTransactions'; import { api } from '@/lib/api'; import { formatToken } from '@/lib/format'; +import { prepareHistory } from '@/lib/history'; +import type { DisplayTx } from '@/lib/history'; import { useAppStore } from '@/stores/app'; type AccountType = 'wallet' | 'staking'; export default function WalletPage() { const { user: tgUser, hapticFeedback } = useTelegram(); - const { t } = useTranslation(); + const { t, language } = useTranslation(); const user = useAppStore((state) => state.user); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('wallet'); @@ -76,6 +78,25 @@ export default function WalletPage() { ] : [{ label: 'OLTIN', value: stakedOltin }]; + // On-chain history: correct token symbol + collapsed mint/burn double-records. + const L = (uz: string, ru: string, en: string) => + language === 'uz' ? uz : language === 'ru' ? ru : en; + const history = prepareHistory(transactionsData ?? []); + const kindLabel = (kind: DisplayTx['kind']): string => { + switch (kind) { + case 'received': + return t('received'); + case 'sent': + return t('sent'); + case 'minted': + return L('Berildi', 'Начислено', 'Credited'); + case 'burned': + return L('Yechildi', 'Списано', 'Debited'); + case 'self': + return L("O'ziga", 'Себе', 'Self'); + } + }; + return (
{/* Header */} @@ -242,15 +263,19 @@ export default function WalletPage() { {/* Recent Transactions */}

{t('recentTransactions')}

- {!transactionsData?.length ? ( + {!history.length ? (

{t('noTransactions')}

) : (
- {transactionsData.slice(0, 5).map((tx) => { - const incoming = tx.direction === 'in'; + {history.slice(0, 8).map((tx) => { + const positive = tx.kind === 'received' || tx.kind === 'minted'; + const negative = tx.kind === 'sent' || tx.kind === 'burned'; return (
- {incoming ? ( - - ) : ( + {negative ? ( + ) : ( + )}
-
- {incoming ? t('received') : t('sent')} -
+
{kindLabel(tx.kind)}
{new Date(tx.indexed_at).toLocaleDateString()}
{tx.amount_wei && ( -
- {incoming ? '+' : '-'}{formatToken(tx.amount_wei)} OLTIN +
+ {positive ? '+' : negative ? '-' : ''}{formatToken(tx.amount_wei)} {tx.symbol}
)}
diff --git a/oltinpay/oltinpay-webapp/src/lib/history.test.ts b/oltinpay/oltinpay-webapp/src/lib/history.test.ts new file mode 100644 index 0000000..6238445 --- /dev/null +++ b/oltinpay/oltinpay-webapp/src/lib/history.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; + +import { prepareHistory } from './history'; +import type { Transaction } from '@/types'; + +const ZERO = '0x0000000000000000000000000000000000000000'; +const USER = '0x4a75707d25679d7002d4467785360595eb7b8882'; +const OTHER = '0xf30b1acaa5365aa710f20aaeb0e2e8ccbbfcb35c'; + +function mk(over: Partial): Transaction { + return { + tx_hash: '0xtx', + event_type: 'oltin_transfer', + direction: 'out', + block_number: 1, + from_address: USER, + to_address: OTHER, + amount_wei: '1000000000000000000', + explorer_url: 'https://explorer/0xtx', + indexed_at: '2026-07-29T00:00:00Z', + ...over, + }; +} + +describe('prepareHistory', () => { + it('collapses a UZD mint (minted + transfer-from-0x0) into one minted row', () => { + const out = prepareHistory([ + mk({ tx_hash: '0xa', event_type: 'uzd_minted', direction: 'in', from_address: null, to_address: USER, amount_wei: '1000000000000000000000000' }), + mk({ tx_hash: '0xa', event_type: 'uzd_transfer', direction: 'in', from_address: ZERO, to_address: USER, amount_wei: '1000000000000000000000000' }), + ]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ symbol: 'UZD', kind: 'minted', amount_wei: '1000000000000000000000000' }); + }); + + it('collapses a UZD admin burn (burned + transfer-to-0x0) into one burned row', () => { + const out = prepareHistory([ + mk({ tx_hash: '0xb', event_type: 'uzd_admin_burned', direction: 'out', from_address: USER, to_address: null }), + mk({ tx_hash: '0xb', event_type: 'uzd_transfer', direction: 'out', from_address: USER, to_address: ZERO }), + ]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ symbol: 'UZD', kind: 'burned' }); + }); + + it('keeps a lone OLTIN burn (transfer-to-0x0, no BURNED event) — the sell leg', () => { + const out = prepareHistory([ + mk({ tx_hash: '0xc', event_type: 'oltin_transfer', direction: 'out', from_address: USER, to_address: ZERO, amount_wei: '100000000000000000' }), + ]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ symbol: 'OLTIN', kind: 'burned', amount_wei: '100000000000000000' }); + }); + + it('splits a BUY into two honest legs (minted OLTIN + sent UZD), dropping the 0x0 transfer', () => { + const out = prepareHistory([ + mk({ tx_hash: '0xd', event_type: 'oltin_minted', direction: 'in', from_address: null, to_address: USER, amount_wei: '634502341779686' }), + mk({ tx_hash: '0xd', event_type: 'oltin_transfer', direction: 'in', from_address: ZERO, to_address: USER, amount_wei: '634502341779686' }), + mk({ tx_hash: '0xd', event_type: 'uzd_transfer', direction: 'out', from_address: USER, to_address: OTHER, amount_wei: '100000000000000000000000' }), + ]); + expect(out).toHaveLength(2); + expect(out.map((t) => `${t.symbol}:${t.kind}`).sort()).toEqual(['OLTIN:minted', 'UZD:sent']); + }); + + it('labels a normal outgoing OLTIN transfer as sent and incoming as received', () => { + const [sent] = prepareHistory([mk({ event_type: 'oltin_transfer', direction: 'out', from_address: USER, to_address: OTHER })]); + expect(sent).toMatchObject({ symbol: 'OLTIN', kind: 'sent' }); + const [recv] = prepareHistory([mk({ event_type: 'oltin_transfer', direction: 'in', from_address: OTHER, to_address: USER })]); + expect(recv).toMatchObject({ kind: 'received' }); + }); + + it('marks a self-transfer as self (not sent)', () => { + const [row] = prepareHistory([mk({ event_type: 'oltin_transfer', direction: 'self', from_address: USER, to_address: USER })]); + expect(row.kind).toBe('self'); + }); + + it('skips reserve_answer (non-token event never pollutes the feed)', () => { + const out = prepareHistory([ + mk({ event_type: 'reserve_answer', from_address: null, to_address: null, amount_wei: null }), + mk({ event_type: 'oltin_transfer', direction: 'in', from_address: OTHER, to_address: USER }), + ]); + expect(out).toHaveLength(1); + expect(out[0].symbol).toBe('OLTIN'); + }); + + it('derives the symbol from the event_type prefix', () => { + const [uzd] = prepareHistory([mk({ event_type: 'uzd_transfer', direction: 'in', from_address: OTHER, to_address: USER })]); + expect(uzd.symbol).toBe('UZD'); + }); +}); diff --git a/oltinpay/oltinpay-webapp/src/lib/history.ts b/oltinpay/oltinpay-webapp/src/lib/history.ts new file mode 100644 index 0000000..0744547 --- /dev/null +++ b/oltinpay/oltinpay-webapp/src/lib/history.ts @@ -0,0 +1,83 @@ +import { zeroAddress } from 'viem'; + +import type { Transaction } from '@/types'; + +// A transaction prepared for the history list: the correct token symbol + a +// display kind, with mint/burn double-records collapsed. +export type HistoryKind = 'received' | 'sent' | 'minted' | 'burned' | 'self'; + +export interface DisplayTx { + tx_hash: string; + symbol: 'UZD' | 'OLTIN'; + kind: HistoryKind; + amount_wei: string | null; + explorer_url: string; + indexed_at: string; +} + +// Token symbol from the event_type prefix. null for non-token events +// (reserve_answer) — those never reach a user feed but are skipped defensively. +function symbolOf(eventType: string): 'UZD' | 'OLTIN' | null { + if (eventType.startsWith('uzd_')) return 'UZD'; + if (eventType.startsWith('oltin_')) return 'OLTIN'; + return null; +} + +const isZero = (addr: string | null): boolean => + (addr ?? '').toLowerCase() === zeroAddress; + +function kindOf(tx: Transaction): HistoryKind { + const isTransfer = tx.event_type.endsWith('_transfer'); + // A mint is a *_minted event OR a transfer FROM the zero address; a burn is a + // *_admin_burned event OR a transfer TO the zero address (OLTIN has no BURNER + // role, so its burn only ever surfaces as a transfer to 0x0). + if (tx.event_type.endsWith('_minted') || (isTransfer && isZero(tx.from_address))) { + return 'minted'; + } + if (tx.event_type.endsWith('_admin_burned') || (isTransfer && isZero(tx.to_address))) { + return 'burned'; + } + if (tx.direction === 'self') return 'self'; + return tx.direction === 'in' ? 'received' : 'sent'; +} + +/** + * Prepare the on-chain transaction feed for display. + * + * - Skips non-token events (reserve_answer). + * - Collapses the mint/burn double-record: a mint emits a `*_minted` event AND + * a `Transfer` from the zero address (same tx + token + amount); an admin burn + * emits `*_admin_burned` AND a `Transfer` to the zero address. Keep the + * minted/burned event, drop the paired zero-address transfer. A LONE + * zero-address transfer with no minted/burned event (an OLTIN sell/burn) is + * kept — dropping it would erase that leg. + * - Attaches the correct token symbol + a display kind. + */ +export function prepareHistory(txs: Transaction[]): DisplayTx[] { + const tokenTxs = txs.filter((tx) => symbolOf(tx.event_type) !== null); + + // Keys (tx_hash|symbol|amount) that have a minted/burned event — their paired + // zero-address transfer is redundant. Match by tx+token+amount, NOT address + // (a *_minted row has from_address=null, its paired transfer has from=0x0). + const collapsed = new Set(); + for (const tx of tokenTxs) { + if (tx.event_type.endsWith('_minted') || tx.event_type.endsWith('_admin_burned')) { + collapsed.add(`${tx.tx_hash}|${symbolOf(tx.event_type)}|${tx.amount_wei}`); + } + } + + const deduped = tokenTxs.filter((tx) => { + if (!tx.event_type.endsWith('_transfer')) return true; + if (!isZero(tx.from_address) && !isZero(tx.to_address)) return true; // normal transfer + return !collapsed.has(`${tx.tx_hash}|${symbolOf(tx.event_type)}|${tx.amount_wei}`); + }); + + return deduped.map((tx) => ({ + tx_hash: tx.tx_hash, + symbol: symbolOf(tx.event_type) as 'UZD' | 'OLTIN', + kind: kindOf(tx), + amount_wei: tx.amount_wei, + explorer_url: tx.explorer_url, + indexed_at: tx.indexed_at, + })); +}