harb/web-app/src/composables/usePositions.ts
johba 4277f19b68 feature/ci (#84)
Co-authored-by: openhands <openhands@all-hands.dev>
Reviewed-on: https://codeberg.org/johba/harb/pulls/84
2026-02-02 19:24:57 +01:00

539 lines
16 KiB
TypeScript

import { ref, computed, type ComputedRef, onMounted, onUnmounted } from 'vue';
import { config } from '@/wagmi';
import { type WatchEventReturnType } from 'viem';
import axios from 'axios';
import { getAccount, watchChainId, watchAccount, watchContractEvent, type Config } from '@wagmi/core';
import type { WatchChainIdReturnType, WatchAccountReturnType, GetAccountReturnType } from '@wagmi/core';
import { bigInt2Number } from '@/utils/helper';
import { getTaxRateIndexByDecimal } from '@/composables/useAdjustTaxRates';
import logger from '@/utils/logger';
import { DEFAULT_CHAIN_ID } from '@/config';
import { createRetryManager, formatGraphqlError, resolveGraphqlEndpoint } from '@/utils/graphqlRetry';
import { HarbContract } from '@/contracts/harb';
const rawActivePositions = ref<Array<Position>>([]);
const rawClosedPositoins = ref<Array<Position>>([]);
const loading = ref(false);
const positionsError = ref<string | null>(null);
const GRAPHQL_TIMEOUT_MS = 15_000;
const activeChainId = ref<number>(DEFAULT_CHAIN_ID);
const positionsUpdatedAt = ref<bigint>(0n);
const POLL_INTERVAL_MS = 10_000;
const retryManager = createRetryManager(loadPositions, activeChainId);
let positionsPollTimer: ReturnType<typeof setInterval> | null = null;
let pollInFlight = false;
let realtimeConsumerCount = 0;
let isPollingActive = false;
let isContractWatchActive = false;
let unwatchPositionCreated: WatchEventReturnType | null = null;
let unwatchPositionRemoved: WatchEventReturnType | null = null;
let unwatchChainSwitch: WatchChainIdReturnType | null = null;
let unwatchAccountChanged: WatchAccountReturnType | null = null;
let wagmiWatcherConsumers = 0;
const activePositions = computed(() => {
const account = getAccount(config as Config);
return rawActivePositions.value
.map(obj => {
const taxRateDecimal = Number(obj.taxRate);
const taxRateIndex =
Number.isFinite(taxRateDecimal) && !Number.isNaN(taxRateDecimal) ? getTaxRateIndexByDecimal(taxRateDecimal) : undefined;
return {
...obj,
positionId: BigInt(obj.id),
amount: bigInt2Number(obj.harbDeposit, 18),
taxRatePercentage: taxRateDecimal * 100,
taxRate: taxRateDecimal,
taxRateIndex,
iAmOwner: obj.owner?.toLowerCase() === account.address?.toLowerCase(),
totalSupplyEnd: obj.totalSupplyEnd ? BigInt(obj.totalSupplyEnd) : undefined,
totalSupplyInit: BigInt(obj.totalSupplyInit),
taxPaid: BigInt(obj.taxPaid),
share: Number(obj.share),
};
})
.sort((a, b) => {
// Sort by tax rate index instead of decimal to avoid floating-point issues
// Positions without an index are pushed to the end
if (typeof a.taxRateIndex !== 'number') return 1;
if (typeof b.taxRateIndex !== 'number') return -1;
return a.taxRateIndex - b.taxRateIndex;
});
});
export interface Position {
creationTime: Date;
id: string;
positionId: bigint;
owner: string;
lastTaxTime: Date;
taxPaid: bigint;
taxRate: number;
taxRateIndex?: number;
taxRatePercentage: number;
share: number;
status: string;
totalSupplyEnd?: bigint;
totalSupplyInit: bigint;
amount: number;
harbDeposit: bigint;
iAmOwner: boolean;
}
const myClosedPositions: ComputedRef<Position[]> = computed(() => {
const account = getAccount(config as Config);
return rawClosedPositoins.value.map(obj => {
// console.log("taxRatePosition", taxRatePosition);
// console.log("taxRates[taxRatePosition]", taxRates[taxRatePosition]);
const taxRateDecimal = Number(obj.taxRate);
const taxRateIndex =
Number.isFinite(taxRateDecimal) && !Number.isNaN(taxRateDecimal) ? getTaxRateIndexByDecimal(taxRateDecimal) : undefined;
return {
...obj,
positionId: BigInt(obj.id),
amount: obj.share * 1000000,
// amount: bigInt2Number(obj.harbDeposit, 18),
taxRatePercentage: taxRateDecimal * 100,
taxRate: taxRateDecimal,
taxRateIndex,
iAmOwner: obj.owner?.toLowerCase() === account.address?.toLowerCase(),
totalSupplyEnd: obj.totalSupplyEnd !== undefined ? BigInt(obj.totalSupplyEnd) : undefined,
totalSupplyInit: BigInt(obj.totalSupplyInit),
taxPaid: BigInt(obj.taxPaid),
share: Number(obj.share),
};
});
});
const myActivePositions: ComputedRef<Position[]> = computed(() =>
activePositions.value.filter((obj: Position) => {
return obj.iAmOwner;
})
);
const tresholdValue = computed(() => {
// Compute average tax rate index instead of percentage to avoid floating-point issues
const validIndices = activePositions.value.map(obj => obj.taxRateIndex).filter((idx): idx is number => typeof idx === 'number');
if (validIndices.length === 0) return 0;
const sum = validIndices.reduce((partialSum, idx) => partialSum + idx, 0);
const avgIndex = sum / validIndices.length;
// Return half the average index (rounded down)
return Math.floor(avgIndex / 2);
});
interface ActivePositionsResult {
positions: Position[];
updatedAt: bigint;
}
export async function loadActivePositions(chainId: number, endpointOverride?: string): Promise<ActivePositionsResult> {
const targetEndpoint = resolveGraphqlEndpoint(chainId, endpointOverride);
logger.info(`loadActivePositions for chainId: ${chainId}`);
// console.log("graphql endpoint", targetEndpoint);
const res = await axios.post(
targetEndpoint,
{
query: `query ActivePositions {
positionss(where: { status: "Active" }, orderBy: "taxRate", orderDirection: "asc", limit: 1000) {
items {
id
lastTaxTime
owner
payout
share
kraikenDeposit
snatched
status
taxPaid
taxRate
totalSupplyEnd
totalSupplyInit
}
}
stats(id: "0x01") {
positionsUpdatedAt
}
}`,
},
{ timeout: GRAPHQL_TIMEOUT_MS }
);
const errors = res.data?.errors;
if (Array.isArray(errors) && errors.length > 0) {
throw new Error(errors.map((err: unknown) => (err as { message?: string })?.message ?? 'GraphQL error').join(', '));
}
const items = res.data?.data?.positionss?.items ?? [];
const stats = res.data?.data?.stats;
const updatedAtRaw = stats?.positionsUpdatedAt;
const updatedAt = typeof updatedAtRaw === 'string' ? BigInt(updatedAtRaw) : 0n;
return {
positions: items.map((item: Record<string, unknown>) => ({
...item,
harbDeposit: item.kraikenDeposit ?? '0',
})) as Position[],
updatedAt,
};
}
// Position IDs are now directly converted to BigInt without transformation
// since GraphQL returns them as numeric strings
export async function loadMyClosedPositions(chainId: number, endpointOverride: string | undefined, account: GetAccountReturnType) {
const targetEndpoint = resolveGraphqlEndpoint(chainId, endpointOverride);
logger.info(`loadMyClosedPositions for chainId: ${chainId}`);
const res = await axios.post(
targetEndpoint,
{
query: `query ClosedPositions {
positionss(where: { status: "Closed", owner: "${account.address?.toLowerCase()}" }, limit: 1000) {
items {
id
lastTaxTime
owner
payout
share
kraikenDeposit
snatched
status
taxPaid
taxRate
totalSupplyEnd
totalSupplyInit
}
}
}`,
},
{ timeout: GRAPHQL_TIMEOUT_MS }
);
const errors = res.data?.errors;
if (Array.isArray(errors) && errors.length > 0) {
throw new Error(errors.map((err: unknown) => (err as { message?: string })?.message ?? 'GraphQL error').join(', '));
}
const items = res.data?.data?.positionss?.items ?? [];
return items.map((item: Record<string, unknown>) => ({
...item,
harbDeposit: item.kraikenDeposit ?? '0',
})) as Position[];
}
export async function fetchPositionsUpdatedAt(chainId: number, endpointOverride?: string): Promise<bigint> {
const targetEndpoint = resolveGraphqlEndpoint(chainId, endpointOverride);
logger.info(`fetchPositionsUpdatedAt for chainId: ${chainId}`);
const res = await axios.post(
targetEndpoint,
{
query: `query PositionsUpdatedAt {
stats(id: "0x01") {
positionsUpdatedAt
}
}`,
},
{ timeout: GRAPHQL_TIMEOUT_MS }
);
const errors = res.data?.errors;
if (Array.isArray(errors) && errors.length > 0) {
throw new Error(errors.map((err: unknown) => (err as { message?: string })?.message ?? 'GraphQL error').join(', '));
}
const updatedAtRaw = res.data?.data?.stats?.positionsUpdatedAt;
if (typeof updatedAtRaw !== 'string') {
throw new Error('positionsUpdatedAt missing from GraphQL response');
}
return BigInt(updatedAtRaw);
}
export async function loadPositions(chainId?: number) {
loading.value = true;
const targetChainId = typeof chainId === 'number' ? chainId : (activeChainId.value ?? DEFAULT_CHAIN_ID);
activeChainId.value = targetChainId;
let endpoint: string;
try {
endpoint = resolveGraphqlEndpoint(targetChainId);
} catch (error) {
rawActivePositions.value = [];
rawClosedPositoins.value = [];
positionsError.value = error instanceof Error ? error.message : 'GraphQL endpoint not configured for this chain.';
retryManager.clear();
retryManager.reset();
loading.value = false;
return;
}
try {
const { positions, updatedAt } = await loadActivePositions(targetChainId, endpoint);
rawActivePositions.value = positions;
positionsUpdatedAt.value = updatedAt;
const account = getAccount(config as Config);
if (account.address) {
rawClosedPositoins.value = await loadMyClosedPositions(targetChainId, endpoint, account);
} else {
rawClosedPositoins.value = [];
}
positionsError.value = null;
retryManager.reset();
retryManager.clear();
} catch (error) {
rawActivePositions.value = [];
rawClosedPositoins.value = [];
positionsError.value = formatGraphqlError(error);
retryManager.schedule();
positionsUpdatedAt.value = 0n;
} finally {
loading.value = false;
}
}
async function pollPositionsOnce() {
if (!isPollingActive || realtimeConsumerCount === 0 || pollInFlight || loading.value) {
return;
}
pollInFlight = true;
try {
const chainId = activeChainId.value ?? DEFAULT_CHAIN_ID;
let endpoint: string;
try {
endpoint = resolveGraphqlEndpoint(chainId);
} catch (error) {
logger.info('positions polling skipped: no GraphQL endpoint', error);
return;
}
const latestUpdatedAt = await fetchPositionsUpdatedAt(chainId, endpoint);
if (latestUpdatedAt > positionsUpdatedAt.value) {
await loadPositions(chainId);
}
} catch (error) {
logger.info('positions polling failed', error);
} finally {
pollInFlight = false;
}
}
function startPositionsPolling() {
if (isPollingActive || realtimeConsumerCount === 0) {
return;
}
positionsPollTimer = setInterval(() => {
void pollPositionsOnce();
}, POLL_INTERVAL_MS);
isPollingActive = true;
void pollPositionsOnce();
}
function stopPositionsPolling() {
if (!isPollingActive) {
return;
}
if (positionsPollTimer) {
clearInterval(positionsPollTimer);
positionsPollTimer = null;
}
isPollingActive = false;
}
function startContractEventWatchers() {
if (isContractWatchActive || realtimeConsumerCount === 0) {
return;
}
unwatchPositionCreated = watchContractEvent(config as Config, {
address: HarbContract.contractAddress,
abi: HarbContract.abi,
eventName: 'PositionCreated',
async onLogs() {
await loadPositions(activeChainId.value);
},
});
unwatchPositionRemoved = watchContractEvent(config as Config, {
address: HarbContract.contractAddress,
abi: HarbContract.abi,
eventName: 'PositionRemoved',
async onLogs() {
await loadPositions(activeChainId.value);
},
});
isContractWatchActive = true;
}
function stopContractEventWatchers() {
if (!isContractWatchActive) {
return;
}
if (unwatchPositionCreated) {
unwatchPositionCreated();
unwatchPositionCreated = null;
}
if (unwatchPositionRemoved) {
unwatchPositionRemoved();
unwatchPositionRemoved = null;
}
isContractWatchActive = false;
}
function syncRealtimeMode() {
if (realtimeConsumerCount === 0) {
stopContractEventWatchers();
stopPositionsPolling();
return;
}
const account = getAccount(config as Config);
const shouldUseContractEvents = Boolean(account.address);
if (shouldUseContractEvents) {
stopPositionsPolling();
startContractEventWatchers();
} else {
stopContractEventWatchers();
startPositionsPolling();
}
}
function registerRealtimeConsumer() {
realtimeConsumerCount += 1;
syncRealtimeMode();
}
function unregisterRealtimeConsumer() {
if (realtimeConsumerCount === 0) {
return;
}
realtimeConsumerCount -= 1;
if (realtimeConsumerCount === 0) {
stopContractEventWatchers();
stopPositionsPolling();
}
}
function ensureWagmiWatchers() {
wagmiWatcherConsumers += 1;
if (wagmiWatcherConsumers > 1) {
return;
}
if (!unwatchChainSwitch) {
unwatchChainSwitch = watchChainId(config as Config, {
async onChange(nextChainId) {
const resolvedChainId = nextChainId ?? DEFAULT_CHAIN_ID;
activeChainId.value = resolvedChainId;
await loadPositions(resolvedChainId);
syncRealtimeMode();
},
});
}
if (!unwatchAccountChanged) {
unwatchAccountChanged = watchAccount(config as Config, {
async onChange() {
await loadPositions(activeChainId.value);
syncRealtimeMode();
},
});
}
}
function teardownWagmiWatchers() {
wagmiWatcherConsumers = Math.max(0, wagmiWatcherConsumers - 1);
if (wagmiWatcherConsumers > 0) {
return;
}
if (unwatchChainSwitch) {
unwatchChainSwitch();
unwatchChainSwitch = null;
}
if (unwatchAccountChanged) {
unwatchAccountChanged();
unwatchAccountChanged = null;
}
}
export function usePositions(chainId: number = DEFAULT_CHAIN_ID) {
activeChainId.value = chainId;
onMounted(async () => {
ensureWagmiWatchers();
if (activePositions.value.length < 1 && loading.value === false) {
await loadPositions(activeChainId.value);
}
registerRealtimeConsumer();
});
onUnmounted(() => {
unregisterRealtimeConsumer();
teardownWagmiWatchers();
retryManager.clear();
});
function createRandomPosition(amount: number = 1) {
for (let index = 0; index < amount; index++) {
const newPosition: Position = {
creationTime: new Date(),
id: '123',
positionId: 123n,
owner: 'bla',
lastTaxTime: new Date(),
taxPaid: 100n,
taxRate: randomInRange(0.01, 1),
taxRateIndex: Math.floor(randomInRange(1, 30)),
taxRatePercentage: getRandomInt(1, 100),
share: getRandomInt(0.001, 0.09),
status: 'active',
totalSupplyEnd: undefined,
totalSupplyInit: 1000000000000n,
amount: 150,
harbDeposit: getRandomBigInt(1000, 5000),
iAmOwner: false,
};
rawActivePositions.value.push(newPosition);
}
}
function randomInRange(min: number, max: number) {
return Math.random() < 0.5 ? (1 - Math.random()) * (max - min) + min : Math.random() * (max - min) + min;
}
function getRandomInt(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
const randomNumber = Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive
return randomNumber;
}
function getRandomBigInt(min: number, max: number) {
const randomNumber = getRandomInt(min, max);
return BigInt(randomNumber) * 10n ** 18n;
}
return {
activePositions,
myActivePositions,
myClosedPositions,
tresholdValue,
createRandomPosition,
positionsError,
loading,
positionsUpdatedAt,
};
}