parent
396f2b5f90
commit
b3b7ab72f9
5 changed files with 381 additions and 2 deletions
|
|
@ -280,6 +280,28 @@ export const holders = onchainTable(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Transaction history for wallet dashboard
|
||||||
|
export const transactions = onchainTable(
|
||||||
|
'transactions',
|
||||||
|
t => ({
|
||||||
|
id: t.text().primaryKey(), // txHash-logIndex
|
||||||
|
holder: t.hex().notNull(),
|
||||||
|
type: t.text().notNull(), // "buy" | "sell" | "stake" | "unstake" | "snatch_in" | "snatch_out"
|
||||||
|
tokenAmount: t.bigint().notNull(),
|
||||||
|
ethAmount: t
|
||||||
|
.bigint()
|
||||||
|
.notNull()
|
||||||
|
.$default(() => 0n),
|
||||||
|
timestamp: t.bigint().notNull(),
|
||||||
|
blockNumber: t.integer().notNull(),
|
||||||
|
txHash: t.hex().notNull(),
|
||||||
|
}),
|
||||||
|
table => ({
|
||||||
|
holderIdx: index().on(table.holder),
|
||||||
|
timestampIdx: index().on(table.timestamp),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Helper constants
|
// Helper constants
|
||||||
export const STATS_ID = '0x01';
|
export const STATS_ID = '0x01';
|
||||||
export const SECONDS_IN_HOUR = 3600;
|
export const SECONDS_IN_HOUR = 3600;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ponder } from 'ponder:registry';
|
import { ponder } from 'ponder:registry';
|
||||||
import { getLogger } from './helpers/logger';
|
import { getLogger } from './helpers/logger';
|
||||||
import { stats, holders, STATS_ID } from 'ponder:schema';
|
import { stats, holders, transactions, STATS_ID } from 'ponder:schema';
|
||||||
import {
|
import {
|
||||||
ensureStatsExists,
|
ensureStatsExists,
|
||||||
parseRingBuffer,
|
parseRingBuffer,
|
||||||
|
|
@ -118,6 +118,29 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record buy/sell transactions
|
||||||
|
if (!isSelfTransfer && from !== ZERO_ADDRESS && to !== ZERO_ADDRESS) {
|
||||||
|
const isBuy = from.toLowerCase() === POOL_ADDRESS;
|
||||||
|
const isSell = to.toLowerCase() === POOL_ADDRESS;
|
||||||
|
|
||||||
|
if (isBuy || isSell) {
|
||||||
|
const currentPrice = statsData.currentPriceWei ?? 0n;
|
||||||
|
const ethEstimate = currentPrice > 0n ? (value * currentPrice) / 10n ** 18n : 0n;
|
||||||
|
const txId = `${event.transaction.hash}-${event.log.logIndex}`;
|
||||||
|
|
||||||
|
await context.db.insert(transactions).values({
|
||||||
|
id: txId,
|
||||||
|
holder: isBuy ? to : from,
|
||||||
|
type: isBuy ? 'buy' : 'sell',
|
||||||
|
tokenAmount: value,
|
||||||
|
ethAmount: ethEstimate,
|
||||||
|
timestamp: event.block.timestamp,
|
||||||
|
blockNumber: Number(event.block.number),
|
||||||
|
txHash: event.transaction.hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update holder count if changed (with underflow protection)
|
// Update holder count if changed (with underflow protection)
|
||||||
if (holderCountDelta !== 0) {
|
if (holderCountDelta !== 0) {
|
||||||
const newHolderCount = statsData.holderCount + holderCountDelta;
|
const newHolderCount = statsData.holderCount + holderCountDelta;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ponder } from 'ponder:registry';
|
import { ponder } from 'ponder:registry';
|
||||||
import { positions, stats, STATS_ID, TAX_RATES } from 'ponder:schema';
|
import { positions, transactions, stats, STATS_ID, TAX_RATES } from 'ponder:schema';
|
||||||
import {
|
import {
|
||||||
ensureStatsExists,
|
ensureStatsExists,
|
||||||
getStakeTotalSupply,
|
getStakeTotalSupply,
|
||||||
|
|
@ -55,6 +55,18 @@ ponder.on('Stake:PositionCreated', async ({ event, context }) => {
|
||||||
payout: ZERO,
|
payout: ZERO,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Record stake transaction
|
||||||
|
await context.db.insert(transactions).values({
|
||||||
|
id: `${event.transaction.hash}-${event.log.logIndex}`,
|
||||||
|
holder: event.args.owner as `0x${string}`,
|
||||||
|
type: 'stake',
|
||||||
|
tokenAmount: event.args.kraikenDeposit,
|
||||||
|
ethAmount: ZERO,
|
||||||
|
timestamp: event.block.timestamp,
|
||||||
|
blockNumber: Number(event.block.number),
|
||||||
|
txHash: event.transaction.hash,
|
||||||
|
});
|
||||||
|
|
||||||
await refreshOutstandingStake(context);
|
await refreshOutstandingStake(context);
|
||||||
await markPositionsUpdated(context, event.block.timestamp);
|
await markPositionsUpdated(context, event.block.timestamp);
|
||||||
});
|
});
|
||||||
|
|
@ -77,6 +89,18 @@ ponder.on('Stake:PositionRemoved', async ({ event, context }) => {
|
||||||
stakeDeposit: ZERO,
|
stakeDeposit: ZERO,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Record unstake transaction (could be voluntary unstake or snatch payout)
|
||||||
|
await context.db.insert(transactions).values({
|
||||||
|
id: `${event.transaction.hash}-${event.log.logIndex}`,
|
||||||
|
holder: position.owner,
|
||||||
|
type: 'unstake',
|
||||||
|
tokenAmount: event.args.kraikenPayout,
|
||||||
|
ethAmount: ZERO,
|
||||||
|
timestamp: event.block.timestamp,
|
||||||
|
blockNumber: Number(event.block.number),
|
||||||
|
txHash: event.transaction.hash,
|
||||||
|
});
|
||||||
|
|
||||||
await refreshOutstandingStake(context);
|
await refreshOutstandingStake(context);
|
||||||
await markPositionsUpdated(context, event.block.timestamp);
|
await markPositionsUpdated(context, event.block.timestamp);
|
||||||
|
|
||||||
|
|
|
||||||
306
web-app/src/components/TransactionHistory.vue
Normal file
306
web-app/src/components/TransactionHistory.vue
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
<template>
|
||||||
|
<div class="tx-history">
|
||||||
|
<h3 class="tx-history__title">
|
||||||
|
Transaction History
|
||||||
|
<span class="tx-history__count" v-if="transactions.length">{{ transactions.length }}</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="loading" class="tx-history__loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Loading transactions…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="transactions.length === 0" class="tx-history__empty">No transactions found for this address.</div>
|
||||||
|
|
||||||
|
<div v-else class="tx-history__table-wrapper">
|
||||||
|
<table class="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="text-right">Amount (KRK)</th>
|
||||||
|
<th class="text-right">Value (ETH)</th>
|
||||||
|
<th>Tx</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="tx in transactions" :key="tx.id" :class="txRowClass(tx.type)">
|
||||||
|
<td class="tx-date">{{ formatDate(tx.timestamp) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tx-type-badge" :class="txTypeClass(tx.type)">
|
||||||
|
{{ txTypeLabel(tx.type) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right mono">{{ formatKrk(tx.tokenAmount) }}</td>
|
||||||
|
<td class="text-right mono">{{ tx.ethAmount !== '0' ? formatEth(tx.ethAmount) : '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<a :href="explorerTxUrl(tx.txHash)" target="_blank" rel="noopener noreferrer" class="tx-link" :title="tx.txHash">
|
||||||
|
{{ shortHash(tx.txHash) }} ↗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue';
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: string;
|
||||||
|
holder: string;
|
||||||
|
type: string;
|
||||||
|
tokenAmount: string;
|
||||||
|
ethAmount: string;
|
||||||
|
timestamp: string;
|
||||||
|
blockNumber: number;
|
||||||
|
txHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
address: string;
|
||||||
|
graphqlUrl?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const transactions = ref<Transaction[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
const GRAPHQL_URL = props.graphqlUrl || '/api/graphql';
|
||||||
|
|
||||||
|
async function fetchTransactions(address: string) {
|
||||||
|
if (!address) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `{
|
||||||
|
transactionss(
|
||||||
|
where: { holder: "${address.toLowerCase()}" }
|
||||||
|
orderBy: "timestamp"
|
||||||
|
orderDirection: "desc"
|
||||||
|
limit: 50
|
||||||
|
) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
holder
|
||||||
|
type
|
||||||
|
tokenAmount
|
||||||
|
ethAmount
|
||||||
|
timestamp
|
||||||
|
blockNumber
|
||||||
|
txHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const res = await fetch(GRAPHQL_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
transactions.value = data?.data?.transactionss?.items ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to fetch transactions:', e);
|
||||||
|
transactions.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: string): string {
|
||||||
|
const ts = Number(timestamp) * 1000;
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return (
|
||||||
|
d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKrk(raw: string): string {
|
||||||
|
try {
|
||||||
|
const val = Number(BigInt(raw || '0')) / 1e18;
|
||||||
|
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||||
|
} catch {
|
||||||
|
return '0.00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEth(raw: string): string {
|
||||||
|
try {
|
||||||
|
const val = Number(BigInt(raw || '0')) / 1e18;
|
||||||
|
return val.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
|
||||||
|
} catch {
|
||||||
|
return '0.0000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortHash(hash: string): string {
|
||||||
|
if (!hash || hash.length < 12) return hash;
|
||||||
|
return `${hash.slice(0, 6)}…${hash.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function explorerTxUrl(hash: string): string {
|
||||||
|
// Base mainnet explorer; adjust for testnet if needed
|
||||||
|
return `https://basescan.org/tx/${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function txTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
buy: 'Buy',
|
||||||
|
sell: 'Sell',
|
||||||
|
stake: 'Stake',
|
||||||
|
unstake: 'Unstake',
|
||||||
|
snatch_in: 'Snatched In',
|
||||||
|
snatch_out: 'Snatched Out',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function txTypeClass(type: string): string {
|
||||||
|
if (['buy', 'unstake', 'snatch_in'].includes(type)) return 'tx-type--positive';
|
||||||
|
if (['sell', 'snatch_out'].includes(type)) return 'tx-type--negative';
|
||||||
|
return 'tx-type--neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function txRowClass(type: string): string {
|
||||||
|
if (['buy', 'unstake'].includes(type)) return 'tx-row--positive';
|
||||||
|
if (['sell', 'snatch_out'].includes(type)) return 'tx-row--negative';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchTransactions(props.address));
|
||||||
|
watch(
|
||||||
|
() => props.address,
|
||||||
|
addr => fetchTransactions(addr)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
.tx-history
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 16px
|
||||||
|
|
||||||
|
&__title
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 10px
|
||||||
|
font-size: 20px
|
||||||
|
color: #ffffff
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
&__count
|
||||||
|
background: rgba(117, 80, 174, 0.3)
|
||||||
|
color: #7550AE
|
||||||
|
border-radius: 99px
|
||||||
|
padding: 2px 10px
|
||||||
|
font-size: 14px
|
||||||
|
font-weight: 700
|
||||||
|
|
||||||
|
&__loading
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 12px
|
||||||
|
color: #9A9898
|
||||||
|
padding: 24px
|
||||||
|
|
||||||
|
&__empty
|
||||||
|
color: #9A9898
|
||||||
|
padding: 24px
|
||||||
|
text-align: center
|
||||||
|
background: #07111B
|
||||||
|
border-radius: 12px
|
||||||
|
border: 1px solid rgba(255,255,255,0.07)
|
||||||
|
|
||||||
|
&__table-wrapper
|
||||||
|
overflow-x: auto
|
||||||
|
border-radius: 12px
|
||||||
|
border: 1px solid rgba(255,255,255,0.08)
|
||||||
|
|
||||||
|
.tx-table
|
||||||
|
width: 100%
|
||||||
|
border-collapse: collapse
|
||||||
|
font-size: 14px
|
||||||
|
|
||||||
|
th
|
||||||
|
text-align: left
|
||||||
|
padding: 12px 16px
|
||||||
|
color: #9A9898
|
||||||
|
font-size: 11px
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 1px
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08)
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
|
td
|
||||||
|
padding: 12px 16px
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04)
|
||||||
|
color: #ffffff
|
||||||
|
|
||||||
|
.text-right
|
||||||
|
text-align: right
|
||||||
|
|
||||||
|
.mono
|
||||||
|
font-family: monospace
|
||||||
|
|
||||||
|
.tx-date
|
||||||
|
color: #9A9898
|
||||||
|
white-space: nowrap
|
||||||
|
font-size: 13px
|
||||||
|
|
||||||
|
.tx-type-badge
|
||||||
|
padding: 3px 10px
|
||||||
|
border-radius: 99px
|
||||||
|
font-size: 12px
|
||||||
|
font-weight: 600
|
||||||
|
text-transform: uppercase
|
||||||
|
letter-spacing: 0.5px
|
||||||
|
|
||||||
|
&.tx-type--positive
|
||||||
|
background: rgba(74, 222, 128, 0.12)
|
||||||
|
color: #4ADE80
|
||||||
|
|
||||||
|
&.tx-type--negative
|
||||||
|
background: rgba(248, 113, 113, 0.12)
|
||||||
|
color: #F87171
|
||||||
|
|
||||||
|
&.tx-type--neutral
|
||||||
|
background: rgba(117, 80, 174, 0.15)
|
||||||
|
color: #7550AE
|
||||||
|
|
||||||
|
.tx-row--positive td
|
||||||
|
background: rgba(74, 222, 128, 0.03)
|
||||||
|
|
||||||
|
.tx-row--negative td
|
||||||
|
background: rgba(248, 113, 113, 0.03)
|
||||||
|
|
||||||
|
.tx-link
|
||||||
|
color: #7550AE
|
||||||
|
text-decoration: none
|
||||||
|
font-family: monospace
|
||||||
|
font-size: 13px
|
||||||
|
white-space: nowrap
|
||||||
|
&:hover
|
||||||
|
text-decoration: underline
|
||||||
|
|
||||||
|
.spinner
|
||||||
|
width: 20px
|
||||||
|
height: 20px
|
||||||
|
border: 2px solid rgba(117, 80, 174, 0.3)
|
||||||
|
border-top-color: #7550AE
|
||||||
|
border-radius: 50%
|
||||||
|
animation: spin 0.8s linear infinite
|
||||||
|
|
||||||
|
@keyframes spin
|
||||||
|
to
|
||||||
|
transform: rotate(360deg)
|
||||||
|
</style>
|
||||||
|
|
@ -86,6 +86,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction History -->
|
||||||
|
<TransactionHistory :address="addressParam" />
|
||||||
|
|
||||||
<!-- Staking Positions -->
|
<!-- Staking Positions -->
|
||||||
<div class="positions-section">
|
<div class="positions-section">
|
||||||
<h3 class="positions-section__title">
|
<h3 class="positions-section__title">
|
||||||
|
|
@ -158,6 +161,7 @@
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRoute, RouterLink } from 'vue-router';
|
import { useRoute, RouterLink } from 'vue-router';
|
||||||
import { useWalletDashboard } from '@/composables/useWalletDashboard';
|
import { useWalletDashboard } from '@/composables/useWalletDashboard';
|
||||||
|
import TransactionHistory from '@/components/TransactionHistory.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const addressParam = computed(() => String(route.params.address ?? ''));
|
const addressParam = computed(() => String(route.params.address ?? ''));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue