Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions oltinpay/oltinpay-webapp/src/app/wallet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountType>('wallet');
Expand Down Expand Up @@ -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 (
<div className="p-4 space-y-4">
{/* Header */}
Expand Down Expand Up @@ -242,42 +263,46 @@ export default function WalletPage() {
{/* Recent Transactions */}
<div className="bg-card rounded-xl p-4 border border-border">
<h3 className="font-semibold mb-3">{t('recentTransactions')}</h3>
{!transactionsData?.length ? (
{!history.length ? (
<p className="text-text-muted text-sm text-center py-4">{t('noTransactions')}</p>
) : (
<div className="space-y-3">
{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 (
<a
key={tx.tx_hash}
// A collapsed BUY/SELL yields two rows sharing one tx_hash
// (a UZD leg + an OLTIN leg), so the hash alone is not unique.
// Compose with symbol/kind/amount for a stable per-row key.
key={`${tx.tx_hash}-${tx.symbol}-${tx.kind}-${tx.amount_wei}`}
href={tx.explorer_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
incoming ? 'bg-green/20' : 'bg-red/20'
positive ? 'bg-green/20' : negative ? 'bg-red/20' : 'bg-background'
}`}>
{incoming ? (
<ArrowDownLeft size={16} className="text-green" />
) : (
{negative ? (
<ArrowUpRight size={16} className="text-red" />
) : (
<ArrowDownLeft size={16} className={positive ? 'text-green' : 'text-text-muted'} />
)}
</div>
<div>
<div className="text-sm font-medium">
{incoming ? t('received') : t('sent')}
</div>
<div className="text-sm font-medium">{kindLabel(tx.kind)}</div>
<div className="text-xs text-text-muted">
{new Date(tx.indexed_at).toLocaleDateString()}
</div>
</div>
</div>
{tx.amount_wei && (
<div className={`font-semibold ${incoming ? 'text-green' : 'text-red'}`}>
{incoming ? '+' : '-'}{formatToken(tx.amount_wei)} OLTIN
<div className={`font-semibold ${
positive ? 'text-green' : negative ? 'text-red' : 'text-text-muted'
}`}>
{positive ? '+' : negative ? '-' : ''}{formatToken(tx.amount_wei)} {tx.symbol}
</div>
)}
</a>
Expand Down
87 changes: 87 additions & 0 deletions oltinpay/oltinpay-webapp/src/lib/history.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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');
});
});
83 changes: 83 additions & 0 deletions oltinpay/oltinpay-webapp/src/lib/history.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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,
}));
}
Loading