harb/web-app/src/views/CheatsView.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>