- landing/eslint.config.js: ban imports from web-app paths (rule 1),
direct RPC clients from viem/@wagmi/vue (rule 2), and axios (rule 4)
- web-app/eslint.config.js: ban string interpolation inside GraphQL
query/mutation property values (rule 3); fixes 4 pre-existing violations
in usePositionDashboard, usePositions, useSnatchNotifications,
useWalletDashboard by migrating to variables: {} pattern
- services/ponder/eslint.config.js: ban findMany() calls that lack a
limit parameter to prevent unbounded indexed-data growth (rule 5)
All error messages follow the [what is wrong][rule][how to fix][where to
read more] template so agents and humans fix on the first try.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
7.9 KiB
TypeScript
281 lines
7.9 KiB
TypeScript
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 { parseTokenAmount } from '@/utils/helper';
|
|
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 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($id: String!) {
|
|
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
|
|
}
|
|
}
|
|
}`,
|
|
variables: { id },
|
|
},
|
|
{ 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(() => parseTokenAmount(position.value?.kraikenDeposit ?? '0'));
|
|
const taxPaidKrk = computed(() => parseTokenAmount(position.value?.taxPaid ?? '0'));
|
|
|
|
const currentValueKrk = computed(() => {
|
|
if (!position.value || !stats.value) return 0;
|
|
const share = Number(position.value.share);
|
|
const outstanding = parseTokenAmount(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(() => parseTokenAmount(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,
|
|
};
|
|
}
|