Co-authored-by: openhands <openhands@all-hands.dev> Reviewed-on: https://codeberg.org/johba/harb/pulls/84
539 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|