906 lines
29 KiB
Vue
906 lines
29 KiB
Vue
<template>
|
|
<div class="cheats-view">
|
|
<div class="cheats-header">
|
|
<h2>Cheat Console</h2>
|
|
<p class="cheats-caption">
|
|
Helpers for local forks while we wait on live liquidity. Requests hit the configured RPC directly, so keep this pointed at a trusted
|
|
node only.
|
|
</p>
|
|
</div>
|
|
|
|
<FCard>
|
|
<div class="cheats-form">
|
|
<FInput v-model="rpcUrl" label="RPC URL"></FInput>
|
|
<p class="cheats-hint">Defaults to the Anvil instance from <code>scripts/dev.sh</code>.</p>
|
|
<div class="cheats-actions">
|
|
<FButton :disabled="!hasWalletProvider || addingNetwork" @click="addNetwork">{{
|
|
addingNetwork ? 'Adding…' : 'Add network to wallet'
|
|
}}</FButton>
|
|
<FButton :disabled="!canAddToken || addingToken" @click="addToken" variant="secondary">{{
|
|
addingToken ? 'Adding…' : 'Add KRK token'
|
|
}}</FButton>
|
|
<FButton :disabled="!canAddWeth || addingWeth" @click="addWethToken" variant="secondary">{{
|
|
addingWeth ? 'Adding…' : 'Add WETH token'
|
|
}}</FButton>
|
|
</div>
|
|
</div>
|
|
</FCard>
|
|
|
|
<FCard title="Liquidity Snapshot">
|
|
<div class="cheats-form">
|
|
<template v-if="!cheatConfig">
|
|
<p class="cheats-warning">Cheat helpers are disabled for this network.</p>
|
|
</template>
|
|
<template v-else>
|
|
<template v-if="statsError">
|
|
<p class="cheats-warning">{{ statsError }}</p>
|
|
</template>
|
|
<template v-else-if="statsLoading">
|
|
<p class="cheats-hint">Loading liquidity metrics…</p>
|
|
</template>
|
|
<template v-else-if="liquidityStats">
|
|
<div class="liquidity-meta">
|
|
<span>Current tick: {{ liquidityStats.currentTick }}</span>
|
|
<span>Token0 is WETH: {{ liquidityStats.token0isWeth ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="liquidity-meta">
|
|
<span>Buy depth to discovery edge: {{ liquidityStats.buyDepthFormatted }} ETH</span>
|
|
</div>
|
|
<table class="liquidity-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Stage</th>
|
|
<th>Tick Lower</th>
|
|
<th>Tick Upper</th>
|
|
<th>Liquidity</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="position in liquidityStats.positions" :key="position.label">
|
|
<td>{{ position.label }}</td>
|
|
<td>{{ position.tickLower }}</td>
|
|
<td>{{ position.tickUpper }}</td>
|
|
<td>{{ position.liquidityFormatted }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
<template v-else>
|
|
<p class="cheats-hint">Liquidity stats unavailable for this network.</p>
|
|
</template>
|
|
</template>
|
|
<FButton variant="secondary" :disabled="statsLoading" @click="refreshLiquidityStats">{{
|
|
statsLoading ? 'Refreshing…' : 'Refresh stats'
|
|
}}</FButton>
|
|
</div>
|
|
</FCard>
|
|
|
|
<FCard title="Protocol Stats">
|
|
<div class="cheats-form">
|
|
<template v-if="!statCollection.initialized">
|
|
<p class="cheats-hint">Loading protocol stats…</p>
|
|
</template>
|
|
<template v-else-if="statCollection.statsError">
|
|
<p class="cheats-warning">{{ statCollection.statsError }}</p>
|
|
</template>
|
|
<template v-else>
|
|
<div class="liquidity-meta">
|
|
<span>Total Supply: {{ formatEth(statCollection.kraikenTotalSupply) }} KRK</span>
|
|
</div>
|
|
<div class="liquidity-meta">
|
|
<span>Total Staked: {{ formatEth(statCollection.outstandingStake) }} KRK</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</FCard>
|
|
|
|
<div class="cheats-grid">
|
|
<FCard title="Mint ETH">
|
|
<div class="cheats-form">
|
|
<FInput v-model="mintTarget" label="Recipient"></FInput>
|
|
<FInput v-model="mintAmount" label="Additional ETH" type="number">
|
|
<template #details>Amount to add on top of the current balance.</template>
|
|
</FInput>
|
|
<FButton :disabled="minting" @click="mintEth">{{ minting ? 'Minting…' : 'Mint' }}</FButton>
|
|
</div>
|
|
</FCard>
|
|
|
|
<FCard title="Force Recenter">
|
|
<div class="cheats-form">
|
|
<template v-if="!canTriggerRecenter">
|
|
<p class="cheats-warning">No txnBot recenter endpoint detected for this chain.</p>
|
|
</template>
|
|
<template v-else>
|
|
<FButton :disabled="forcing" @click="forceRecenter">{{ forcing ? 'Calling…' : 'Call recenter' }}</FButton>
|
|
<p class="cheats-hint">Requests the local txnBot to execute <code>recenter()</code> with its operator key.</p>
|
|
</template>
|
|
</div>
|
|
</FCard>
|
|
|
|
<FCard title="Block Explorer">
|
|
<div class="cheats-form">
|
|
<p class="cheats-hint">Browse transactions, blocks, and addresses on the local Anvil fork using Otterscan.</p>
|
|
<a href="http://localhost:5100" target="_blank" rel="noopener noreferrer" class="cheats-link">Open Otterscan</a>
|
|
</div>
|
|
</FCard>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import FButton from '@/components/fcomponents/FButton.vue';
|
|
import FCard from '@/components/fcomponents/FCard.vue';
|
|
import FInput from '@/components/fcomponents/FInput.vue';
|
|
import { config } from '@/wagmi';
|
|
import { useStatCollection } from '@/composables/useStatCollection';
|
|
import { useToast } from 'vue-toastification';
|
|
import { useAccount } from '@wagmi/vue';
|
|
import { getChain, DEFAULT_CHAIN_ID } from '@/config';
|
|
import { readContract, type Config as WagmiConfig } from '@wagmi/core';
|
|
import HarbJson from '@/assets/contracts/harb.json';
|
|
import {
|
|
createPublicClient,
|
|
erc20Abi,
|
|
formatEther,
|
|
http,
|
|
parseEther,
|
|
toHex,
|
|
zeroAddress,
|
|
type Address,
|
|
type Abi,
|
|
type EIP1193Provider,
|
|
} from 'viem';
|
|
import { SWAP_ROUTER_ABI, UNISWAP_FACTORY_ABI, UNISWAP_POOL_ABI } from '@/composables/useSwapKrk';
|
|
import { isRecord, coerceString, getErrorMessage, ensureAddress } from '@harb/utils';
|
|
|
|
const toast = useToast();
|
|
const statCollection = useStatCollection();
|
|
const { address, chainId } = useAccount();
|
|
const wagmiConfig: WagmiConfig = config;
|
|
|
|
const rpcUrl = ref('');
|
|
let lastAutoRpcUrl = '';
|
|
|
|
const mintTarget = ref('');
|
|
const mintAmount = ref('10');
|
|
const minting = ref(false);
|
|
|
|
const forcing = ref(false);
|
|
|
|
const addingNetwork = ref(false);
|
|
const addingToken = ref(false);
|
|
const addingWeth = ref(false);
|
|
|
|
interface PositionState {
|
|
liquidity: bigint;
|
|
tickLower: number;
|
|
tickUpper: number;
|
|
}
|
|
|
|
interface PositionView extends PositionState {
|
|
label: (typeof POSITION_STAGE_LABELS)[number];
|
|
liquidityFormatted: string;
|
|
}
|
|
|
|
interface LiquidityStats {
|
|
positions: PositionView[];
|
|
currentTick: number;
|
|
poolAddress: Address;
|
|
token0isWeth: boolean;
|
|
buyDepthWei: bigint;
|
|
buyDepthFormatted: string;
|
|
}
|
|
|
|
type PositionContractResponse =
|
|
| readonly [bigint, number, number]
|
|
| {
|
|
liquidity: bigint;
|
|
tickLower: number;
|
|
tickUpper: number;
|
|
};
|
|
|
|
function isStructuredPosition(raw: PositionContractResponse): raw is { liquidity: bigint; tickLower: number; tickUpper: number } {
|
|
return !Array.isArray(raw);
|
|
}
|
|
|
|
function toPosition(raw: PositionContractResponse): PositionState {
|
|
if (isStructuredPosition(raw)) {
|
|
return {
|
|
liquidity: BigInt(raw.liquidity),
|
|
tickLower: Number(raw.tickLower),
|
|
tickUpper: Number(raw.tickUpper),
|
|
};
|
|
}
|
|
|
|
const [liquidity, tickLower, tickUpper] = raw;
|
|
return {
|
|
liquidity: BigInt(liquidity),
|
|
tickLower: Number(tickLower),
|
|
tickUpper: Number(tickUpper),
|
|
};
|
|
}
|
|
|
|
const liquidityStats = ref<LiquidityStats | null>(null);
|
|
const statsLoading = ref(false);
|
|
const statsError = ref<string | null>(null);
|
|
let statsRequestId = 0;
|
|
|
|
const resolvedChainId = computed(() => chainId.value ?? DEFAULT_CHAIN_ID);
|
|
const chainConfig = computed(() => getChain(resolvedChainId.value));
|
|
|
|
const cheatConfig = computed(() => chainConfig.value?.cheats ?? null);
|
|
const hasWalletProvider = computed(() => typeof window !== 'undefined' && Boolean(window.ethereum?.request));
|
|
const canAddToken = computed(() => Boolean(hasWalletProvider.value && chainConfig.value?.harb));
|
|
const canAddWeth = computed(() => Boolean(hasWalletProvider.value && cheatConfig.value?.weth));
|
|
const txnBotRecenterUrl = computed(() => {
|
|
const baseUrl = cheatConfig.value?.txnBot?.trim();
|
|
if (!baseUrl) return null;
|
|
return `${baseUrl.replace(/\/$/, '')}/recenter`;
|
|
});
|
|
const canTriggerRecenter = computed(() => Boolean(txnBotRecenterUrl.value));
|
|
|
|
watch(
|
|
address,
|
|
value => {
|
|
if (value) {
|
|
mintTarget.value = value;
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => (cheatConfig.value?.rpc ?? '').trim(),
|
|
value => {
|
|
const trimmed = value;
|
|
if (!rpcUrl.value || rpcUrl.value === lastAutoRpcUrl) {
|
|
rpcUrl.value = trimmed;
|
|
}
|
|
lastAutoRpcUrl = trimmed;
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => [rpcUrl.value, resolvedChainId.value, chainConfig.value?.harb, cheatConfig.value?.weth, cheatConfig.value?.swapRouter],
|
|
() => {
|
|
void loadLiquidityStats();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
if (!liquidityStats.value && !statsLoading.value) {
|
|
void loadLiquidityStats();
|
|
}
|
|
});
|
|
|
|
function resolveChainName(chainId: number): string {
|
|
switch (chainId) {
|
|
case 31337:
|
|
return 'Local Anvil';
|
|
case 84532:
|
|
return 'Base Sepolia';
|
|
case 8453:
|
|
return 'Base';
|
|
case 11155111:
|
|
return 'Ethereum Sepolia';
|
|
default:
|
|
return `Chain ${chainId}`;
|
|
}
|
|
}
|
|
|
|
function resolveRpcUrl(): string {
|
|
const value = rpcUrl.value.trim();
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
if (value.startsWith('/') && typeof window !== 'undefined' && window.location?.origin) {
|
|
return `${window.location.origin}${value}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getEthereumProvider(): EIP1193Provider {
|
|
if (typeof window === 'undefined' || !window.ethereum) {
|
|
throw new Error('Wallet provider not available');
|
|
}
|
|
return window.ethereum;
|
|
}
|
|
|
|
function extractMessageFromPayload(payload: unknown, keys: string[], fallback: string): string {
|
|
if (!isRecord(payload)) {
|
|
return fallback;
|
|
}
|
|
for (const key of keys) {
|
|
const message = coerceString(payload[key]);
|
|
if (message) {
|
|
return message;
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const LIQUIDITY_MANAGER_POSITIONS_ABI = [
|
|
{
|
|
inputs: [{ internalType: 'uint8', name: '', type: 'uint8' }],
|
|
name: 'positions',
|
|
outputs: [
|
|
{ internalType: 'uint128', name: 'liquidity', type: 'uint128' },
|
|
{ internalType: 'int24', name: 'tickLower', type: 'int24' },
|
|
{ internalType: 'int24', name: 'tickUpper', type: 'int24' },
|
|
],
|
|
stateMutability: 'view',
|
|
type: 'function',
|
|
},
|
|
] as const satisfies Abi;
|
|
|
|
const POSITION_STAGE_LABELS = ['Floor', 'Anchor', 'Discovery'] as const;
|
|
const POOL_FEE = 10_000;
|
|
const MIN_TICK = -887272;
|
|
const MAX_TICK = 887272;
|
|
const Q96 = 2n ** 96n;
|
|
const MAX_UINT256_BIGINT = (1n << 256n) - 1n;
|
|
|
|
function formatBigInt(value: bigint): string {
|
|
const sign = value < 0n ? '-' : '';
|
|
const digits = (value < 0n ? -value : value).toString();
|
|
return sign + digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
}
|
|
|
|
function formatEth(value: bigint): string {
|
|
if (value === 0n) return '0';
|
|
const formatted = formatEther(value);
|
|
const [whole, frac = ''] = formatted.split('.');
|
|
const trimmedFrac = frac.replace(/0+$/, '').slice(0, 6);
|
|
return trimmedFrac ? `${whole}.${trimmedFrac}` : whole;
|
|
}
|
|
|
|
function getSqrtRatioAtTick(tick: number): bigint {
|
|
if (tick < MIN_TICK || tick > MAX_TICK) {
|
|
throw new Error('Tick out of range');
|
|
}
|
|
const absTick = BigInt(tick < 0 ? -tick : tick);
|
|
let ratio = (absTick & 0x1n) !== 0n ? 0xfffcb933bd6fad37aa2d162d1a594001n : 0x100000000000000000000000000000000n;
|
|
if ((absTick & 0x2n) !== 0n) ratio = (ratio * 0xfff97272373d413259a46990580e213an) >> 128n;
|
|
if ((absTick & 0x4n) !== 0n) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdccn) >> 128n;
|
|
if ((absTick & 0x8n) !== 0n) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0n) >> 128n;
|
|
if ((absTick & 0x10n) !== 0n) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644n) >> 128n;
|
|
if ((absTick & 0x20n) !== 0n) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0n) >> 128n;
|
|
if ((absTick & 0x40n) !== 0n) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861n) >> 128n;
|
|
if ((absTick & 0x80n) !== 0n) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053n) >> 128n;
|
|
if ((absTick & 0x100n) !== 0n) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4n) >> 128n;
|
|
if ((absTick & 0x200n) !== 0n) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54n) >> 128n;
|
|
if ((absTick & 0x400n) !== 0n) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3n) >> 128n;
|
|
if ((absTick & 0x800n) !== 0n) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9n) >> 128n;
|
|
if ((absTick & 0x1000n) !== 0n) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825n) >> 128n;
|
|
if ((absTick & 0x2000n) !== 0n) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5n) >> 128n;
|
|
if ((absTick & 0x4000n) !== 0n) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7n) >> 128n;
|
|
if ((absTick & 0x8000n) !== 0n) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6n) >> 128n;
|
|
if ((absTick & 0x10000n) !== 0n) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9n) >> 128n;
|
|
if ((absTick & 0x20000n) !== 0n) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604n) >> 128n;
|
|
if ((absTick & 0x40000n) !== 0n) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98n) >> 128n;
|
|
if ((absTick & 0x80000n) !== 0n) ratio = (ratio * 0x48a170391f7dc42444e8fa2n) >> 128n;
|
|
if (tick > 0) {
|
|
ratio = MAX_UINT256_BIGINT / ratio;
|
|
}
|
|
const result = ratio >> 32n;
|
|
const shouldRoundUp = (ratio & 0xffffffffn) !== 0n;
|
|
return shouldRoundUp ? result + 1n : result;
|
|
}
|
|
|
|
function getAmount0ForLiquidity(sqrtRatioAX96: bigint, sqrtRatioBX96: bigint, liquidity: bigint): bigint {
|
|
if (sqrtRatioAX96 > sqrtRatioBX96) {
|
|
[sqrtRatioAX96, sqrtRatioBX96] = [sqrtRatioBX96, sqrtRatioAX96];
|
|
}
|
|
if (liquidity === 0n || sqrtRatioAX96 === 0n) {
|
|
return 0n;
|
|
}
|
|
const numerator = liquidity << 96n;
|
|
const intermediate = (sqrtRatioBX96 - sqrtRatioAX96) * numerator;
|
|
return intermediate / sqrtRatioBX96 / sqrtRatioAX96;
|
|
}
|
|
|
|
function getAmount1ForLiquidity(sqrtRatioAX96: bigint, sqrtRatioBX96: bigint, liquidity: bigint): bigint {
|
|
if (sqrtRatioAX96 > sqrtRatioBX96) {
|
|
[sqrtRatioAX96, sqrtRatioBX96] = [sqrtRatioBX96, sqrtRatioAX96];
|
|
}
|
|
if (liquidity === 0n) {
|
|
return 0n;
|
|
}
|
|
return (liquidity * (sqrtRatioBX96 - sqrtRatioAX96)) / Q96;
|
|
}
|
|
|
|
function calculateEthToMoveBetweenTicks(fromTick: number, toTick: number, liquidity: bigint): bigint {
|
|
if (fromTick >= toTick || liquidity === 0n) {
|
|
return 0n;
|
|
}
|
|
const sqrtFrom = getSqrtRatioAtTick(fromTick);
|
|
const sqrtTo = getSqrtRatioAtTick(toTick);
|
|
return getAmount0ForLiquidity(sqrtFrom, sqrtTo, liquidity);
|
|
}
|
|
|
|
function calculateEthToMoveBetweenTicksDown(fromTick: number, toTick: number, liquidity: bigint): bigint {
|
|
if (fromTick <= toTick || liquidity === 0n) {
|
|
return 0n;
|
|
}
|
|
const sqrtFrom = getSqrtRatioAtTick(fromTick);
|
|
const sqrtTo = getSqrtRatioAtTick(toTick);
|
|
return getAmount1ForLiquidity(sqrtTo, sqrtFrom, liquidity);
|
|
}
|
|
|
|
function calculateBuyDepth(token0isWeth: boolean, currentTick: number, anchor: PositionState, discovery: PositionState): bigint {
|
|
if (anchor.liquidity === 0n && discovery.liquidity === 0n) {
|
|
return 0n;
|
|
}
|
|
|
|
if (token0isWeth) {
|
|
const targetTick = Math.max(discovery.tickUpper, anchor.tickUpper);
|
|
if (currentTick >= targetTick) {
|
|
return 0n;
|
|
}
|
|
|
|
let total = 0n;
|
|
if (currentTick >= anchor.tickLower && currentTick < anchor.tickUpper && anchor.liquidity > 0n) {
|
|
const anchorEnd = Math.min(targetTick, anchor.tickUpper);
|
|
total += calculateEthToMoveBetweenTicks(currentTick, anchorEnd, anchor.liquidity);
|
|
}
|
|
if (targetTick > anchor.tickUpper && discovery.liquidity > 0n) {
|
|
const discoveryStart = Math.max(currentTick, discovery.tickLower);
|
|
if (discoveryStart < discovery.tickUpper) {
|
|
total += calculateEthToMoveBetweenTicks(discoveryStart, targetTick, discovery.liquidity);
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
const targetTick = Math.min(discovery.tickLower, anchor.tickLower);
|
|
if (currentTick <= targetTick) {
|
|
return 0n;
|
|
}
|
|
|
|
let total = 0n;
|
|
if (currentTick <= anchor.tickUpper && currentTick > anchor.tickLower && anchor.liquidity > 0n) {
|
|
const anchorEnd = Math.max(targetTick, anchor.tickLower);
|
|
total += calculateEthToMoveBetweenTicksDown(currentTick, anchorEnd, anchor.liquidity);
|
|
}
|
|
if (targetTick < anchor.tickLower && discovery.liquidity > 0n) {
|
|
const discoveryStart = Math.min(currentTick, discovery.tickUpper);
|
|
if (discoveryStart > discovery.tickLower) {
|
|
total += calculateEthToMoveBetweenTicksDown(discoveryStart, targetTick, discovery.liquidity);
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
async function loadLiquidityStats({ notify = false }: { notify?: boolean } = {}) {
|
|
const requestId = ++statsRequestId;
|
|
const cheat = cheatConfig.value;
|
|
const chain = chainConfig.value;
|
|
|
|
if (!cheat || !chain || !cheat.weth || !chain.harb) {
|
|
liquidityStats.value = null;
|
|
statsError.value = null;
|
|
statsLoading.value = false;
|
|
if (notify) {
|
|
toast.error('Liquidity helpers not configured for this network');
|
|
}
|
|
return;
|
|
}
|
|
if (!rpcUrl.value) {
|
|
liquidityStats.value = null;
|
|
statsError.value = 'Enter an RPC URL to load liquidity stats';
|
|
statsLoading.value = false;
|
|
if (notify) {
|
|
toast.error(statsError.value);
|
|
}
|
|
return;
|
|
}
|
|
|
|
statsLoading.value = true;
|
|
statsError.value = null;
|
|
|
|
try {
|
|
const targetRpcUrl = resolveRpcUrl();
|
|
if (!targetRpcUrl) {
|
|
throw new Error('RPC URL is empty');
|
|
}
|
|
|
|
const client = createPublicClient({ transport: http(targetRpcUrl) });
|
|
const harbAddress = ensureAddress(chain.harb, 'KRAIKEN token');
|
|
|
|
let peripheryManager: Address | null = null;
|
|
try {
|
|
const [managerFromContract] = (await client.readContract({
|
|
address: harbAddress,
|
|
abi: HarbJson as Abi,
|
|
functionName: 'peripheryContracts',
|
|
})) as readonly [Address, Address];
|
|
peripheryManager = managerFromContract;
|
|
} catch {
|
|
peripheryManager = null;
|
|
}
|
|
|
|
const configuredManager =
|
|
typeof cheat.liquidityManager === 'string' && cheat.liquidityManager.trim().length > 0 ? cheat.liquidityManager.trim() : null;
|
|
|
|
const managerAddressCandidate = configuredManager ?? peripheryManager;
|
|
|
|
if (!managerAddressCandidate) {
|
|
throw new Error('LiquidityManager address not configured for this chain');
|
|
}
|
|
|
|
const liquidityManagerAddress = ensureAddress(managerAddressCandidate, 'LiquidityManager');
|
|
|
|
const wethAddress = ensureAddress(cheat.weth, 'WETH token');
|
|
const token0isWeth = BigInt(wethAddress) < BigInt(harbAddress);
|
|
|
|
const swapRouterAddress = ensureAddress(cheat.swapRouter, 'Uniswap router');
|
|
const factoryAddress = await client.readContract({
|
|
address: swapRouterAddress,
|
|
abi: SWAP_ROUTER_ABI,
|
|
functionName: 'factory',
|
|
});
|
|
|
|
const poolAddressRaw = await client.readContract({
|
|
address: ensureAddress(factoryAddress, 'Uniswap factory'),
|
|
abi: UNISWAP_FACTORY_ABI,
|
|
functionName: 'getPool',
|
|
args: token0isWeth ? [wethAddress, harbAddress, POOL_FEE] : [harbAddress, wethAddress, POOL_FEE],
|
|
});
|
|
|
|
if (!poolAddressRaw || poolAddressRaw === zeroAddress) {
|
|
throw new Error('KRK/WETH pool not found at 1% fee tier');
|
|
}
|
|
|
|
const poolAddress = ensureAddress(poolAddressRaw, 'Uniswap pool');
|
|
const slot0Response = await client.readContract({
|
|
address: poolAddress,
|
|
abi: UNISWAP_POOL_ABI,
|
|
functionName: 'slot0',
|
|
});
|
|
|
|
let currentTick: number | null = null;
|
|
if (isRecord(slot0Response) && typeof slot0Response.tick === 'number') {
|
|
currentTick = slot0Response.tick;
|
|
}
|
|
|
|
if (currentTick === null) {
|
|
throw new Error('Unable to determine current pool tick');
|
|
}
|
|
|
|
const [floorRaw, anchorRaw, discoveryRaw] = await Promise.all(
|
|
[0, 1, 2].map(
|
|
stage =>
|
|
client.readContract({
|
|
address: liquidityManagerAddress,
|
|
abi: LIQUIDITY_MANAGER_POSITIONS_ABI,
|
|
functionName: 'positions',
|
|
args: [stage],
|
|
}) as Promise<PositionContractResponse>
|
|
)
|
|
);
|
|
|
|
const floor = toPosition(floorRaw);
|
|
const anchor = toPosition(anchorRaw);
|
|
const discovery = toPosition(discoveryRaw);
|
|
|
|
const buyDepthWei = calculateBuyDepth(token0isWeth, currentTick, anchor, discovery);
|
|
|
|
if (statsRequestId === requestId) {
|
|
const positions: PositionView[] = [
|
|
{ label: POSITION_STAGE_LABELS[0], ...floor, liquidityFormatted: formatBigInt(floor.liquidity) },
|
|
{ label: POSITION_STAGE_LABELS[1], ...anchor, liquidityFormatted: formatBigInt(anchor.liquidity) },
|
|
{ label: POSITION_STAGE_LABELS[2], ...discovery, liquidityFormatted: formatBigInt(discovery.liquidity) },
|
|
];
|
|
|
|
liquidityStats.value = {
|
|
positions,
|
|
currentTick,
|
|
poolAddress,
|
|
token0isWeth,
|
|
buyDepthWei,
|
|
buyDepthFormatted: formatEth(buyDepthWei),
|
|
};
|
|
statsError.value = null;
|
|
}
|
|
} catch (error: unknown) {
|
|
if (statsRequestId === requestId) {
|
|
liquidityStats.value = null;
|
|
const message = getErrorMessage(error, 'Failed to load liquidity stats');
|
|
statsError.value = message;
|
|
if (notify) {
|
|
toast.error(message);
|
|
}
|
|
}
|
|
} finally {
|
|
if (statsRequestId === requestId) {
|
|
statsLoading.value = false;
|
|
if (!statsError.value && notify) {
|
|
toast.success('Liquidity stats refreshed');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshLiquidityStats() {
|
|
await loadLiquidityStats({ notify: true });
|
|
}
|
|
|
|
async function rpcRequest<T>(method: string, params: unknown[]): Promise<T> {
|
|
if (!rpcUrl.value) {
|
|
throw new Error('RPC URL required');
|
|
}
|
|
const response = await fetch(resolveRpcUrl(), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`RPC ${method} failed with status ${response.status}`);
|
|
}
|
|
const payload = (await response.json().catch(() => null)) as unknown;
|
|
if (!isRecord(payload)) {
|
|
throw new Error('RPC response malformed');
|
|
}
|
|
|
|
if ('error' in payload && payload.error !== undefined) {
|
|
const errorMessage = isRecord(payload.error)
|
|
? extractMessageFromPayload(payload.error, ['message'], `RPC ${method} failed`)
|
|
: `RPC ${method} failed`;
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
if ('result' in payload) {
|
|
return payload.result as T;
|
|
}
|
|
|
|
throw new Error('RPC response missing result field');
|
|
}
|
|
|
|
async function mintEth() {
|
|
if (minting.value) return;
|
|
try {
|
|
minting.value = true;
|
|
const target = ensureAddress(mintTarget.value, 'Recipient');
|
|
let amount: bigint;
|
|
try {
|
|
amount = parseEther(mintAmount.value || '0');
|
|
} catch {
|
|
throw new Error('Enter a valid ETH amount');
|
|
}
|
|
if (amount <= 0n) {
|
|
throw new Error('Amount must be greater than zero');
|
|
}
|
|
const current = await rpcRequest<string>('eth_getBalance', [target, 'latest']);
|
|
const nextBalance = BigInt(current ?? '0x0') + amount;
|
|
await rpcRequest('anvil_setBalance', [target, toHex(nextBalance)]);
|
|
toast.success(`Set balance to ${mintAmount.value} ETH higher for ${target}`);
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error, 'Mint failed'));
|
|
} finally {
|
|
minting.value = false;
|
|
}
|
|
}
|
|
|
|
async function addNetwork() {
|
|
if (addingNetwork.value || !hasWalletProvider.value) return;
|
|
try {
|
|
addingNetwork.value = true;
|
|
if (!rpcUrl.value) {
|
|
throw new Error('Enter an RPC URL to add the network');
|
|
}
|
|
const chain = chainConfig.value;
|
|
if (!chain) {
|
|
throw new Error('Select a supported network first');
|
|
}
|
|
const provider = getEthereumProvider();
|
|
await provider.request({
|
|
method: 'wallet_addEthereumChain',
|
|
params: [
|
|
{
|
|
chainId: toHex(BigInt(resolvedChainId.value)),
|
|
chainName: resolveChainName(resolvedChainId.value),
|
|
rpcUrls: [rpcUrl.value],
|
|
nativeCurrency: {
|
|
name: 'Ether',
|
|
symbol: 'ETH',
|
|
decimals: 18,
|
|
},
|
|
blockExplorerUrls: [],
|
|
},
|
|
],
|
|
});
|
|
toast.success('Network request sent to your wallet');
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error, 'Failed to add network'));
|
|
} finally {
|
|
addingNetwork.value = false;
|
|
}
|
|
}
|
|
|
|
async function addToken() {
|
|
if (addingToken.value || !canAddToken.value) return;
|
|
try {
|
|
addingToken.value = true;
|
|
const chain = chainConfig.value;
|
|
if (!chain?.harb) {
|
|
throw new Error('KRAIKEN token address not configured for this chain');
|
|
}
|
|
const harbAddress = ensureAddress(chain.harb, 'KRAIKEN token');
|
|
const chainId = resolvedChainId.value;
|
|
const provider = getEthereumProvider();
|
|
const [symbol, decimals] = await Promise.all([
|
|
readContract(wagmiConfig, {
|
|
abi: erc20Abi,
|
|
address: harbAddress,
|
|
functionName: 'symbol',
|
|
chainId,
|
|
}),
|
|
readContract(wagmiConfig, {
|
|
abi: erc20Abi,
|
|
address: harbAddress,
|
|
functionName: 'decimals',
|
|
chainId,
|
|
}),
|
|
]);
|
|
await provider.request({
|
|
method: 'wallet_watchAsset',
|
|
params: {
|
|
type: 'ERC20',
|
|
options: {
|
|
address: harbAddress,
|
|
symbol: symbol?.slice(0, 11) || 'HARB',
|
|
decimals: Number.isFinite(decimals) ? decimals : 18,
|
|
},
|
|
},
|
|
});
|
|
toast.success(`Token request sent (${symbol || 'HARB'})`);
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error, 'Failed to add token'));
|
|
} finally {
|
|
addingToken.value = false;
|
|
}
|
|
}
|
|
|
|
async function addWethToken() {
|
|
if (addingWeth.value || !canAddWeth.value) return;
|
|
try {
|
|
addingWeth.value = true;
|
|
const cheat = cheatConfig.value;
|
|
if (!cheat?.weth) {
|
|
throw new Error('WETH address not configured for this chain');
|
|
}
|
|
const wethAddress = ensureAddress(cheat.weth, 'WETH token');
|
|
const chainId = resolvedChainId.value;
|
|
const provider = getEthereumProvider();
|
|
const [symbol, decimals] = await Promise.all([
|
|
readContract(wagmiConfig, {
|
|
abi: erc20Abi,
|
|
address: wethAddress,
|
|
functionName: 'symbol',
|
|
chainId,
|
|
}),
|
|
readContract(wagmiConfig, {
|
|
abi: erc20Abi,
|
|
address: wethAddress,
|
|
functionName: 'decimals',
|
|
chainId,
|
|
}),
|
|
]);
|
|
await provider.request({
|
|
method: 'wallet_watchAsset',
|
|
params: {
|
|
type: 'ERC20',
|
|
options: {
|
|
address: wethAddress,
|
|
symbol: symbol?.slice(0, 11) || 'WETH',
|
|
decimals: Number.isFinite(decimals) ? decimals : 18,
|
|
},
|
|
},
|
|
});
|
|
toast.success(`Token request sent (${symbol || 'WETH'})`);
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error, 'Failed to add token'));
|
|
} finally {
|
|
addingWeth.value = false;
|
|
}
|
|
}
|
|
|
|
async function forceRecenter() {
|
|
if (forcing.value || !txnBotRecenterUrl.value) return;
|
|
try {
|
|
forcing.value = true;
|
|
const response = await fetch(txnBotRecenterUrl.value, { method: 'POST' });
|
|
const payload = (await response.json().catch(() => null)) as unknown;
|
|
const payloadRecord = isRecord(payload) ? payload : null;
|
|
|
|
if (!response.ok) {
|
|
const errorSource = payloadRecord?.error;
|
|
const message = isRecord(errorSource)
|
|
? extractMessageFromPayload(errorSource, ['message', 'error'], response.statusText || 'recenter failed')
|
|
: (coerceString(errorSource) ?? (response.statusText || 'recenter failed'));
|
|
throw new Error(message);
|
|
}
|
|
|
|
const successMessage = payloadRecord
|
|
? extractMessageFromPayload(payloadRecord, ['message', 'status'], 'TxnBot is recentering')
|
|
: 'TxnBot is recentering';
|
|
toast.success(successMessage);
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error, 'recenter failed'));
|
|
} finally {
|
|
forcing.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="sass">
|
|
.cheats-view
|
|
display: flex
|
|
flex-direction: column
|
|
gap: 24px
|
|
padding: 24px
|
|
@media (min-width: 992px)
|
|
padding: 48px
|
|
|
|
.cheats-header
|
|
display: flex
|
|
flex-direction: column
|
|
gap: 8px
|
|
|
|
.cheats-caption
|
|
color: #A3A3A3
|
|
max-width: 640px
|
|
|
|
.cheats-grid
|
|
display: grid
|
|
gap: 24px
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr))
|
|
|
|
.cheats-form
|
|
display: flex
|
|
flex-direction: column
|
|
gap: 16px
|
|
|
|
.cheats-actions
|
|
display: flex
|
|
flex-wrap: wrap
|
|
gap: 12px
|
|
|
|
.liquidity-meta
|
|
display: flex
|
|
flex-wrap: wrap
|
|
gap: 12px
|
|
font-size: 14px
|
|
|
|
.liquidity-table
|
|
width: 100%
|
|
border-collapse: collapse
|
|
font-size: 14px
|
|
|
|
.liquidity-table th,
|
|
.liquidity-table td
|
|
padding: 6px 8px
|
|
text-align: left
|
|
border-bottom: 1px solid #2a2a2a
|
|
|
|
.cheats-hint
|
|
font-size: 12px
|
|
color: #A3A3A3
|
|
|
|
.cheats-warning
|
|
color: #FFB347
|
|
font-size: 14px
|
|
|
|
.cheats-link
|
|
color: #60A5FA
|
|
text-decoration: none
|
|
font-weight: 500
|
|
&:hover
|
|
text-decoration: underline
|
|
|
|
code
|
|
font-family: "JetBrains Mono", monospace
|
|
</style>
|