Replace UBI with ETH reserve in ring buffer, fix Dockerfile HEALTHCHECK, enhance LiveStats (#154)
This commit is contained in:
parent
31063379a8
commit
76b2635e63
16 changed files with 2028 additions and 89 deletions
289
web-app/src/composables/usePositionDashboard.ts
Normal file
289
web-app/src/composables/usePositionDashboard.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const GRAPHQL_TIMEOUT_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface PositionRecord {
|
||||
id: string;
|
||||
owner: string;
|
||||
share: number;
|
||||
taxRate: number;
|
||||
taxRateIndex: number;
|
||||
kraikenDeposit: string;
|
||||
stakeDeposit: string;
|
||||
taxPaid: string;
|
||||
snatched: number;
|
||||
status: string;
|
||||
creationTime: string;
|
||||
lastTaxTime: string;
|
||||
closedAt: string | null;
|
||||
totalSupplyInit: string;
|
||||
totalSupplyEnd: string | null;
|
||||
payout: string | null;
|
||||
}
|
||||
|
||||
export interface PositionStats {
|
||||
stakeTotalSupply: string;
|
||||
outstandingStake: string;
|
||||
kraikenTotalSupply: string;
|
||||
lastEthReserve: string;
|
||||
}
|
||||
|
||||
export interface ActivePositionShort {
|
||||
id: string;
|
||||
taxRateIndex: number;
|
||||
kraikenDeposit: string;
|
||||
}
|
||||
|
||||
function formatTokenAmount(rawWei: string, decimals = 18): number {
|
||||
try {
|
||||
const big = BigInt(rawWei);
|
||||
const divisor = 10 ** decimals;
|
||||
return Number(big) / divisor;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(ts: string | null): string {
|
||||
if (!ts) return 'N/A';
|
||||
try {
|
||||
// ts may be seconds (unix) or ms
|
||||
const num = Number(ts);
|
||||
const ms = num > 1e12 ? num : num * 1000;
|
||||
return new Date(ms).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
function durationHuman(fromTs: string | null, toTs: string | null = null): string {
|
||||
if (!fromTs) return 'N/A';
|
||||
try {
|
||||
const from = Number(fromTs) > 1e12 ? Number(fromTs) : Number(fromTs) * 1000;
|
||||
const to = toTs ? (Number(toTs) > 1e12 ? Number(toTs) : Number(toTs) * 1000) : Date.now();
|
||||
const diffMs = to - from;
|
||||
if (diffMs < 0) return 'N/A';
|
||||
const totalSec = Math.floor(diffMs / 1000);
|
||||
const days = Math.floor(totalSec / 86400);
|
||||
const hours = Math.floor((totalSec % 86400) / 3600);
|
||||
const mins = Math.floor((totalSec % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (mins > 0 && days === 0) parts.push(`${mins}m`);
|
||||
return parts.length ? parts.join(' ') : '<1m';
|
||||
} catch {
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
export function usePositionDashboard(positionId: Ref<string>) {
|
||||
const position = ref<PositionRecord | null>(null);
|
||||
const stats = ref<PositionStats | null>(null);
|
||||
const allActivePositions = ref<ActivePositionShort[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function fetchData() {
|
||||
const id = positionId.value;
|
||||
if (!id) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
query: `query PositionDashboard {
|
||||
positions(id: "${id}") {
|
||||
id
|
||||
owner
|
||||
share
|
||||
taxRate
|
||||
taxRateIndex
|
||||
kraikenDeposit
|
||||
stakeDeposit
|
||||
taxPaid
|
||||
snatched
|
||||
status
|
||||
creationTime
|
||||
lastTaxTime
|
||||
closedAt
|
||||
totalSupplyInit
|
||||
totalSupplyEnd
|
||||
payout
|
||||
}
|
||||
statss(where: { id: "0x01" }) {
|
||||
items {
|
||||
stakeTotalSupply
|
||||
outstandingStake
|
||||
kraikenTotalSupply
|
||||
lastEthReserve
|
||||
}
|
||||
}
|
||||
positionss(where: { status: "Active" }, limit: 1000) {
|
||||
items {
|
||||
id
|
||||
taxRateIndex
|
||||
kraikenDeposit
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
const gqlErrors = res.data?.errors;
|
||||
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
|
||||
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
|
||||
}
|
||||
|
||||
position.value = res.data?.data?.positions ?? null;
|
||||
|
||||
const statsItems = res.data?.data?.statss?.items;
|
||||
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as PositionStats) : null;
|
||||
|
||||
const activeItems = res.data?.data?.positionss?.items;
|
||||
allActivePositions.value = Array.isArray(activeItems) ? (activeItems as ActivePositionShort[]) : [];
|
||||
|
||||
logger.info(`PositionDashboard loaded for #${id}`);
|
||||
} catch (err) {
|
||||
error.value = formatGraphqlError(err);
|
||||
logger.info('PositionDashboard fetch error', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived values
|
||||
const depositKrk = computed(() => formatTokenAmount(position.value?.kraikenDeposit ?? '0'));
|
||||
const taxPaidKrk = computed(() => formatTokenAmount(position.value?.taxPaid ?? '0'));
|
||||
|
||||
const currentValueKrk = computed(() => {
|
||||
if (!position.value || !stats.value) return 0;
|
||||
const share = Number(position.value.share);
|
||||
const outstanding = formatTokenAmount(stats.value.outstandingStake);
|
||||
return share * outstanding;
|
||||
});
|
||||
|
||||
const netReturnKrk = computed(() => {
|
||||
return currentValueKrk.value - depositKrk.value - taxPaidKrk.value;
|
||||
});
|
||||
|
||||
const taxRatePercent = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return Number(position.value.taxRate) * 100;
|
||||
});
|
||||
|
||||
const dailyTaxCost = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return (depositKrk.value * Number(position.value.taxRate)) / 365;
|
||||
});
|
||||
|
||||
const sharePercent = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return Number(position.value.share) * 100;
|
||||
});
|
||||
|
||||
const createdFormatted = computed(() => formatDate(position.value?.creationTime ?? null));
|
||||
const lastTaxFormatted = computed(() => formatDate(position.value?.lastTaxTime ?? null));
|
||||
const closedAtFormatted = computed(() => formatDate(position.value?.closedAt ?? null));
|
||||
|
||||
const timeHeld = computed(() => {
|
||||
if (!position.value) return 'N/A';
|
||||
return durationHuman(position.value.creationTime, position.value.closedAt ?? null);
|
||||
});
|
||||
|
||||
// Snatch risk: count active positions with lower taxRateIndex
|
||||
const snatchRisk = computed(() => {
|
||||
if (!position.value) return { count: 0, level: 'UNKNOWN', color: '#9A9898' };
|
||||
const myIndex = Number(position.value.taxRateIndex);
|
||||
const lower = allActivePositions.value.filter(p => Number(p.taxRateIndex) < myIndex).length;
|
||||
const total = allActivePositions.value.length;
|
||||
|
||||
let level: string;
|
||||
let color: string;
|
||||
if (total === 0) {
|
||||
level = 'LOW';
|
||||
color = '#4ADE80';
|
||||
} else {
|
||||
const ratio = lower / total;
|
||||
if (ratio < 0.33) {
|
||||
level = 'LOW';
|
||||
color = '#4ADE80';
|
||||
} else if (ratio < 0.67) {
|
||||
level = 'MEDIUM';
|
||||
color = '#FACC15';
|
||||
} else {
|
||||
level = 'HIGH';
|
||||
color = '#F87171';
|
||||
}
|
||||
}
|
||||
|
||||
return { count: lower, level, color };
|
||||
});
|
||||
|
||||
const payoutKrk = computed(() => formatTokenAmount(position.value?.payout ?? '0'));
|
||||
|
||||
const netPnlKrk = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return payoutKrk.value - depositKrk.value - taxPaidKrk.value;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
position,
|
||||
stats,
|
||||
allActivePositions,
|
||||
depositKrk,
|
||||
taxPaidKrk,
|
||||
currentValueKrk,
|
||||
netReturnKrk,
|
||||
taxRatePercent,
|
||||
dailyTaxCost,
|
||||
sharePercent,
|
||||
createdFormatted,
|
||||
lastTaxFormatted,
|
||||
closedAtFormatted,
|
||||
timeHeld,
|
||||
snatchRisk,
|
||||
payoutKrk,
|
||||
netPnlKrk,
|
||||
refresh: fetchData,
|
||||
};
|
||||
}
|
||||
189
web-app/src/composables/useWalletDashboard.ts
Normal file
189
web-app/src/composables/useWalletDashboard.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const GRAPHQL_TIMEOUT_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface WalletPosition {
|
||||
id: string;
|
||||
share: number;
|
||||
taxRate: number;
|
||||
taxRateIndex: number;
|
||||
kraikenDeposit: string;
|
||||
taxPaid: string;
|
||||
snatched: number;
|
||||
status: string;
|
||||
creationTime: string;
|
||||
lastTaxTime: string;
|
||||
payout: string | null;
|
||||
totalSupplyInit: string;
|
||||
totalSupplyEnd: string | null;
|
||||
}
|
||||
|
||||
export interface WalletStats {
|
||||
kraikenTotalSupply: string;
|
||||
lastEthReserve: string;
|
||||
floorPriceWei: string;
|
||||
currentPriceWei: string;
|
||||
ethReserveGrowthBps: number;
|
||||
recentersLastWeek: number;
|
||||
mintedLastWeek: string;
|
||||
burnedLastWeek: string;
|
||||
netSupplyChangeWeek: string;
|
||||
holderCount: number;
|
||||
}
|
||||
|
||||
function formatTokenAmount(rawWei: string, decimals = 18): number {
|
||||
try {
|
||||
const big = BigInt(rawWei);
|
||||
const divisor = 10 ** decimals;
|
||||
return Number(big) / divisor;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function useWalletDashboard(address: Ref<string>) {
|
||||
const holderBalance = ref<string>('0');
|
||||
const stats = ref<WalletStats | null>(null);
|
||||
const positions = ref<WalletPosition[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function fetchData() {
|
||||
const addr = address.value?.toLowerCase();
|
||||
if (!addr) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
query: `query WalletDashboard {
|
||||
holders(address: "${addr}") {
|
||||
address
|
||||
balance
|
||||
}
|
||||
statss(where: { id: "0x01" }) {
|
||||
items {
|
||||
kraikenTotalSupply
|
||||
lastEthReserve
|
||||
floorPriceWei
|
||||
currentPriceWei
|
||||
ethReserveGrowthBps
|
||||
recentersLastWeek
|
||||
mintedLastWeek
|
||||
burnedLastWeek
|
||||
netSupplyChangeWeek
|
||||
holderCount
|
||||
}
|
||||
}
|
||||
positionss(where: { owner: "${addr}" }, limit: 1000) {
|
||||
items {
|
||||
id
|
||||
share
|
||||
taxRate
|
||||
taxRateIndex
|
||||
kraikenDeposit
|
||||
taxPaid
|
||||
snatched
|
||||
status
|
||||
creationTime
|
||||
lastTaxTime
|
||||
payout
|
||||
totalSupplyInit
|
||||
totalSupplyEnd
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
const gqlErrors = res.data?.errors;
|
||||
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
|
||||
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
|
||||
}
|
||||
|
||||
const holder = res.data?.data?.holders;
|
||||
holderBalance.value = holder?.balance ?? '0';
|
||||
|
||||
const statsItems = res.data?.data?.statss?.items;
|
||||
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as WalletStats) : null;
|
||||
|
||||
const posItems = res.data?.data?.positionss?.items;
|
||||
positions.value = Array.isArray(posItems) ? (posItems as WalletPosition[]) : [];
|
||||
|
||||
logger.info(`WalletDashboard loaded for ${addr}`);
|
||||
} catch (err) {
|
||||
error.value = formatGraphqlError(err);
|
||||
logger.info('WalletDashboard fetch error', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived values
|
||||
const balanceKrk = computed(() => formatTokenAmount(holderBalance.value));
|
||||
|
||||
const ethBacking = computed(() => {
|
||||
if (!stats.value) return 0;
|
||||
const balance = balanceKrk.value;
|
||||
const reserve = formatTokenAmount(stats.value.lastEthReserve);
|
||||
const totalSupply = formatTokenAmount(stats.value.kraikenTotalSupply);
|
||||
if (totalSupply === 0) return 0;
|
||||
return balance * (reserve / totalSupply);
|
||||
});
|
||||
|
||||
const floorValue = computed(() => {
|
||||
if (!stats.value) return 0;
|
||||
const balance = balanceKrk.value;
|
||||
// floorPriceWei is price per token in wei → convert to ETH
|
||||
const floorPriceEth = formatTokenAmount(stats.value.floorPriceWei);
|
||||
return balance * floorPriceEth;
|
||||
});
|
||||
|
||||
const activePositions = computed(() => positions.value.filter(p => p.status === 'Active'));
|
||||
const closedPositions = computed(() => positions.value.filter(p => p.status === 'Closed'));
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
holderBalance,
|
||||
balanceKrk,
|
||||
ethBacking,
|
||||
floorValue,
|
||||
stats,
|
||||
positions,
|
||||
activePositions,
|
||||
closedPositions,
|
||||
refresh: fetchData,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue