harb/web-app/src/views/CheatsView.vue
2025-09-23 21:16:15 +02:00

429 lines
14 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>
<f-card>
<div class="cheats-form">
<f-input v-model="rpcUrl" label="RPC URL"></f-input>
<p class="cheats-hint">Defaults to the Anvil instance from <code>scripts/local_env.sh</code>.</p>
<div class="cheats-actions">
<f-button
:disabled="!hasWalletProvider || addingNetwork"
@click="addNetwork"
>{{ addingNetwork ? "Adding…" : "Add network to wallet" }}</f-button>
<f-button
:disabled="!canAddToken || addingToken"
@click="addToken"
variant="secondary"
>{{ addingToken ? "Adding…" : "Add KRK token" }}</f-button>
</div>
</div>
</f-card>
<div class="cheats-grid">
<f-card title="Mint ETH">
<div class="cheats-form">
<f-input v-model="mintTarget" label="Recipient"></f-input>
<f-input v-model="mintAmount" label="Additional ETH" type="number">
<template #details>Amount to add on top of the current balance.</template>
</f-input>
<f-button :disabled="minting" @click="mintEth">{{ minting ? "Minting" : "Mint" }}</f-button>
</div>
</f-card>
<f-card title="Buy KRK">
<div class="cheats-form">
<template v-if="!canSwap">
<p class="cheats-warning">Connect to the Base Sepolia fork to enable the quick swap helper.</p>
</template>
<template v-else>
<f-input v-model="swapAmount" label="ETH to spend" type="number"></f-input>
<f-button :disabled="swapping" @click="buyKrk">{{ swapping ? "Submitting" : "Buy" }}</f-button>
<p class="cheats-hint">Wraps ETH into WETH, approves the swap router, and trades into KRK.</p>
</template>
</div>
</f-card>
<f-card 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>
<f-button :disabled="forcing" @click="forceRecenter">{{ forcing ? "Calling" : "Call recenter" }}</f-button>
<p class="cheats-hint">Requests the local txnBot to execute <code>recenter()</code> with its operator key.</p>
</template>
</div>
</f-card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, 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 { useToast } from "vue-toastification";
import { useAccount } from "@wagmi/vue";
import { getChain, DEFAULT_CHAIN_ID } from "@/config";
import { readContract, writeContract } from "@wagmi/core";
import {
erc20Abi,
getAddress,
isAddress,
parseEther,
toHex,
type Address
} from "viem";
const toast = useToast();
const { address, chainId } = useAccount();
const rpcUrl = ref("http://127.0.0.1:8545");
const mintTarget = ref("");
const mintAmount = ref("10");
const minting = ref(false);
const swapAmount = ref("0.1");
const swapping = ref(false);
const forcing = ref(false);
const addingNetwork = ref(false);
const addingToken = ref(false);
watch(address, (value) => {
if (value) {
mintTarget.value = value;
}
}, { immediate: true });
const resolvedChainId = computed(() => chainId.value ?? DEFAULT_CHAIN_ID);
const chainConfig = computed(() => getChain(resolvedChainId.value));
const cheatConfig = computed(() => chainConfig.value?.cheats ?? null);
const canSwap = computed(() => Boolean(address.value && cheatConfig.value?.weth && cheatConfig.value?.swapRouter && chainConfig.value?.harb));
const hasWalletProvider = computed(() => typeof window !== "undefined" && Boolean((window as any)?.ethereum?.request));
const canAddToken = computed(() => Boolean(hasWalletProvider.value && chainConfig.value?.harb));
const txnBotRecenterUrl = computed(() => {
const baseUrl = cheatConfig.value?.txnBot?.trim();
if (!baseUrl) return null;
return `${baseUrl.replace(/\/$/, "")}/recenter`;
});
const canTriggerRecenter = computed(() => Boolean(txnBotRecenterUrl.value));
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}`;
}
}
const WETH_ABI = [
{
inputs: [],
name: "deposit",
outputs: [],
stateMutability: "payable",
type: "function",
},
];
const SWAP_ROUTER_ABI = [
{
inputs: [
{
components: [
{ internalType: "address", name: "tokenIn", type: "address" },
{ internalType: "address", name: "tokenOut", type: "address" },
{ internalType: "uint24", name: "fee", type: "uint24" },
{ internalType: "address", name: "recipient", type: "address" },
{ internalType: "uint256", name: "amountIn", type: "uint256" },
{ internalType: "uint256", name: "amountOutMinimum", type: "uint256" },
{ internalType: "uint160", name: "sqrtPriceLimitX96", type: "uint160" },
],
internalType: "struct ISwapRouter.ExactInputSingleParams",
name: "params",
type: "tuple",
},
],
name: "exactInputSingle",
outputs: [{ internalType: "uint256", name: "amountOut", type: "uint256" }],
stateMutability: "payable",
type: "function",
},
];
async function rpcRequest<T>(method: string, params: unknown[]): Promise<T> {
if (!rpcUrl.value) {
throw new Error("RPC URL required");
}
const response = await fetch(rpcUrl.value, {
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();
if (payload.error) {
throw new Error(payload.error.message ?? "RPC error");
}
return payload.result as T;
}
function ensureAddress(value: string, label: string): Address {
if (!value || !isAddress(value)) {
throw new Error(`${label} is not a valid address`);
}
return getAddress(value);
}
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: any) {
toast.error(error?.message ?? "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 = (window as any).ethereum;
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: [] as string[],
},
],
});
toast.success("Network request sent to your wallet");
} catch (error: any) {
toast.error(error?.message ?? "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 provider = (window as any).ethereum;
const [symbol, decimals] = await Promise.all([
readContract(config as any, {
abi: erc20Abi,
address: harbAddress,
functionName: "symbol",
}) as Promise<string>,
readContract(config as any, {
abi: erc20Abi,
address: harbAddress,
functionName: "decimals",
}) as Promise<number>,
]);
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: any) {
toast.error(error?.message ?? "Failed to add token");
} finally {
addingToken.value = false;
}
}
async function buyKrk() {
if (!canSwap.value || swapping.value) return;
try {
swapping.value = true;
if (!chainConfig.value?.harb) {
throw new Error("No Kraiken deployment configured for this chain");
}
if (!cheatConfig.value) {
throw new Error("Swap helpers are not configured for this chain");
}
const weth = ensureAddress(cheatConfig.value!.weth, "WETH");
const router = ensureAddress(cheatConfig.value!.swapRouter, "Swap router");
const harb = ensureAddress(chainConfig.value!.harb, "KRAIKEN token");
const caller = ensureAddress(address.value!, "Wallet address");
let amount: bigint;
try {
amount = parseEther(swapAmount.value || "0");
} catch {
throw new Error("Enter a valid ETH amount");
}
if (amount <= 0n) {
throw new Error("Amount must be greater than zero");
}
await writeContract(config as any, {
abi: WETH_ABI,
address: weth,
functionName: "deposit",
value: amount,
});
await writeContract(config as any, {
abi: erc20Abi,
address: weth,
functionName: "approve",
args: [router, amount],
});
await writeContract(config as any, {
abi: SWAP_ROUTER_ABI,
address: router,
functionName: "exactInputSingle",
args: [
{
tokenIn: weth,
tokenOut: harb,
fee: 10_000,
recipient: caller,
amountIn: amount,
amountOutMinimum: 0n,
sqrtPriceLimitX96: 0n,
},
],
});
toast.success("Swap submitted. Watch your wallet for KRK.");
} catch (error: any) {
toast.error(error?.shortMessage ?? error?.message ?? "Swap failed");
} finally {
swapping.value = false;
}
}
async function forceRecenter() {
if (forcing.value || !txnBotRecenterUrl.value) return;
try {
forcing.value = true;
const response = await fetch(txnBotRecenterUrl.value, { method: "POST" });
let payload: any = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const message = payload?.error ?? response.statusText ?? "recenter failed";
throw new Error(message);
}
const message = payload?.message ?? payload?.status ?? "TxnBot is recentering";
toast.success(message);
} catch (error: any) {
toast.error(error?.message ?? "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
.cheats-hint
font-size: 12px
color: #A3A3A3
.cheats-warning
color: #FFB347
font-size: 14px
code
font-family: "JetBrains Mono", monospace
</style>