Merge pull request 'better backend comms' (#17) from fix-web-app into master
Reviewed-on: https://codeberg.org/johba/harb/pulls/17
This commit is contained in:
commit
cbf5ce0733
17 changed files with 1054 additions and 134 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ onchain/node_modules/
|
||||||
ponder-repo
|
ponder-repo
|
||||||
tmp
|
tmp
|
||||||
foundry.lock
|
foundry.lock
|
||||||
|
services/ponder/.env.local
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
- `BASE_SEPOLIA`: Public Base Sepolia testnet
|
- `BASE_SEPOLIA`: Public Base Sepolia testnet
|
||||||
- `BASE`: Base mainnet
|
- `BASE`: Base mainnet
|
||||||
|
|
||||||
|
> **Local dev**: Always start the stack via `nohup ./scripts/local_env.sh start &` (never run services individually); stop with `./scripts/local_env.sh stop`.
|
||||||
|
|
||||||
## Execution Workflow
|
## Execution Workflow
|
||||||
1. **Contracts**
|
1. **Contracts**
|
||||||
- Build/test via `forge build` and `forge test` inside `onchain/`.
|
- Build/test via `forge build` and `forge test` inside `onchain/`.
|
||||||
|
|
@ -67,7 +69,7 @@
|
||||||
- `PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK npm run dev`
|
- `PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK npm run dev`
|
||||||
- `curl -X POST http://localhost:42069/graphql -d '{"query":"{ stats(id:\"0x01\"){kraikenTotalSupply}}"}'`
|
- `curl -X POST http://localhost:42069/graphql -d '{"query":"{ stats(id:\"0x01\"){kraikenTotalSupply}}"}'`
|
||||||
- `curl http://127.0.0.1:43069/status`
|
- `curl http://127.0.0.1:43069/status`
|
||||||
- `./scripts/local_env.sh start` boots Anvil+contracts+ponder+frontend+txnBot; stop with Ctrl+C or `./scripts/local_env.sh stop`.
|
- `nohup ./scripts/local_env.sh start &` boots Anvil+contracts+ponder+frontend+txnBot; stop with Ctrl+C or `./scripts/local_env.sh stop` when you want to tear it down.
|
||||||
|
|
||||||
## Refactor Backlog
|
## Refactor Backlog
|
||||||
- Replace the temporary `any` shims in `services/ponder/src/kraiken.ts` and `services/ponder/src/stake.ts` by importing the official 0.13 handler types instead of stubbing `ponder-env.d.ts`.
|
- Replace the temporary `any` shims in `services/ponder/src/kraiken.ts` and `services/ponder/src/stake.ts` by importing the official 0.13 handler types instead of stubbing `ponder-env.d.ts`.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ ANVIL_RPC="http://127.0.0.1:8545"
|
||||||
GRAPHQL_HEALTH="http://127.0.0.1:42069/health"
|
GRAPHQL_HEALTH="http://127.0.0.1:42069/health"
|
||||||
GRAPHQL_ENDPOINT="http://127.0.0.1:42069/graphql"
|
GRAPHQL_ENDPOINT="http://127.0.0.1:42069/graphql"
|
||||||
FRONTEND_URL="http://127.0.0.1:5173"
|
FRONTEND_URL="http://127.0.0.1:5173"
|
||||||
|
LOCAL_TXNBOT_URL="http://127.0.0.1:43069"
|
||||||
|
|
||||||
FOUNDRY_BIN=${FOUNDRY_BIN:-"$HOME/.foundry/bin"}
|
FOUNDRY_BIN=${FOUNDRY_BIN:-"$HOME/.foundry/bin"}
|
||||||
FORGE="$FOUNDRY_BIN/forge"
|
FORGE="$FOUNDRY_BIN/forge"
|
||||||
|
|
@ -172,8 +173,15 @@ ensure_dependencies() {
|
||||||
|
|
||||||
start_anvil() {
|
start_anvil() {
|
||||||
if [[ -f "$ANVIL_PID_FILE" ]]; then
|
if [[ -f "$ANVIL_PID_FILE" ]]; then
|
||||||
log "Anvil already running (pid $(cat "$ANVIL_PID_FILE"))"
|
local existing_pid
|
||||||
return
|
existing_pid="$(cat "$ANVIL_PID_FILE")"
|
||||||
|
if kill -0 "$existing_pid" 2>/dev/null; then
|
||||||
|
log "Anvil already running (pid $existing_pid)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Found stale Anvil pid file (pid $existing_pid); restarting Anvil"
|
||||||
|
rm -f "$ANVIL_PID_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Starting Anvil (forking $FORK_URL)"
|
log "Starting Anvil (forking $FORK_URL)"
|
||||||
|
|
@ -266,6 +274,16 @@ bootstrap_liquidity_manager() {
|
||||||
log "Calling recenter()"
|
log "Calling recenter()"
|
||||||
"$CAST" send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \
|
"$CAST" send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \
|
||||||
"$LIQUIDITY_MANAGER" "recenter()" >>"$SETUP_LOG" 2>&1
|
"$LIQUIDITY_MANAGER" "recenter()" >>"$SETUP_LOG" 2>&1
|
||||||
|
|
||||||
|
if [[ -n "$TXNBOT_ADDRESS" ]]; then
|
||||||
|
log "Transferring recenter access to txnBot $TXNBOT_ADDRESS"
|
||||||
|
"$CAST" rpc --rpc-url "$ANVIL_RPC" anvil_impersonateAccount "$FEE_DEST" >/dev/null
|
||||||
|
"$CAST" send --rpc-url "$ANVIL_RPC" --from "$FEE_DEST" --unlocked \
|
||||||
|
"$LIQUIDITY_MANAGER" "setRecenterAccess(address)" "$TXNBOT_ADDRESS" >>"$SETUP_LOG" 2>&1
|
||||||
|
"$CAST" rpc --rpc-url "$ANVIL_RPC" anvil_stopImpersonatingAccount "$FEE_DEST" >/dev/null
|
||||||
|
else
|
||||||
|
log "TXNBOT_ADDRESS not set; recenter access left with deployer"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare_application_state() {
|
prepare_application_state() {
|
||||||
|
|
@ -320,6 +338,14 @@ fund_txnbot_wallet() {
|
||||||
"$CAST" send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \
|
"$CAST" send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \
|
||||||
"$TXNBOT_ADDRESS" --value "$TXNBOT_FUND_VALUE" >>"$SETUP_LOG" 2>&1 || \
|
"$TXNBOT_ADDRESS" --value "$TXNBOT_FUND_VALUE" >>"$SETUP_LOG" 2>&1 || \
|
||||||
log "Funding txnBot wallet failed (see setup log)"
|
log "Funding txnBot wallet failed (see setup log)"
|
||||||
|
|
||||||
|
# Ensure local fork balance reflects the funding even if the upstream state is zero
|
||||||
|
local fund_value_wei
|
||||||
|
fund_value_wei="$("$CAST" --to-unit "$TXNBOT_FUND_VALUE" wei)"
|
||||||
|
local fund_value_hex
|
||||||
|
fund_value_hex="$("$CAST" --to-hex "$fund_value_wei")"
|
||||||
|
"$CAST" rpc --rpc-url "$ANVIL_RPC" anvil_setBalance "$TXNBOT_ADDRESS" "$fund_value_hex" \
|
||||||
|
>>"$SETUP_LOG" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
start_txnbot() {
|
start_txnbot() {
|
||||||
|
|
@ -355,13 +381,26 @@ start_ponder() {
|
||||||
|
|
||||||
start_frontend() {
|
start_frontend() {
|
||||||
if [[ -f "$WEBAPP_PID_FILE" ]]; then
|
if [[ -f "$WEBAPP_PID_FILE" ]]; then
|
||||||
log "Frontend already running (pid $(cat "$WEBAPP_PID_FILE"))"
|
log "Frontend already running (pid $(cat \"$WEBAPP_PID_FILE\"))"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
source_addresses || { log "Contract addresses not found"; exit 1; }
|
||||||
|
|
||||||
|
local vite_env=(
|
||||||
|
"VITE_DEFAULT_CHAIN_ID=31337"
|
||||||
|
"VITE_LOCAL_RPC_URL=/rpc/anvil"
|
||||||
|
"VITE_LOCAL_RPC_PROXY_TARGET=$ANVIL_RPC"
|
||||||
|
"VITE_KRAIKEN_ADDRESS=$KRAIKEN"
|
||||||
|
"VITE_STAKE_ADDRESS=$STAKE"
|
||||||
|
"VITE_SWAP_ROUTER=$SWAP_ROUTER"
|
||||||
|
"VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK=$GRAPHQL_ENDPOINT"
|
||||||
|
"VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK=$LOCAL_TXNBOT_URL"
|
||||||
|
)
|
||||||
|
|
||||||
log "Starting frontend (Vite dev server)"
|
log "Starting frontend (Vite dev server)"
|
||||||
pushd "$ROOT_DIR/web-app" >/dev/null
|
pushd "$ROOT_DIR/web-app" >/dev/null
|
||||||
npm run dev -- --host 0.0.0.0 --port 5173 >"$WEBAPP_LOG" 2>&1 &
|
env "${vite_env[@]}" npm run dev -- --host 0.0.0.0 --port 5173 >"$WEBAPP_LOG" 2>&1 &
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
echo $! >"$WEBAPP_PID_FILE"
|
echo $! >"$WEBAPP_PID_FILE"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# Auto-generated by local_env.sh
|
|
||||||
PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK
|
|
||||||
KRAIKEN_ADDRESS=0x56186c1e64ca8043def78d06aff222212ea5df71
|
|
||||||
STAKE_ADDRESS=0x056e4a859558a3975761abd7385506bc4d8a8e60
|
|
||||||
START_BLOCK=31443298
|
|
||||||
# Use PostgreSQL connection
|
|
||||||
DATABASE_URL=postgresql://ponder:ponder_local@localhost/ponder_local
|
|
||||||
DATABASE_SCHEMA=ponder_local_31443298
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { client, graphql } from "ponder";
|
||||||
import { db } from "ponder:api";
|
import { db } from "ponder:api";
|
||||||
import schema from "ponder:schema";
|
import schema from "ponder:schema";
|
||||||
import { Hono } from "hono";
|
|
||||||
import { client, graphql } from "ponder";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
const allowedOrigins = process.env.PONDER_CORS_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
app.use("/*", cors({
|
||||||
|
origin: allowedOrigins?.length ? allowedOrigins : "*",
|
||||||
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowHeaders: ["Content-Type", "Apollo-Require-Preflight"],
|
||||||
|
}));
|
||||||
|
|
||||||
// SQL endpoint
|
// SQL endpoint
|
||||||
app.use("/sql/*", client({ db, schema }));
|
app.use("/sql/*", client({ db, schema }));
|
||||||
|
|
||||||
|
|
@ -12,4 +21,4 @@ app.use("/sql/*", client({ db, schema }));
|
||||||
app.use("/graphql", graphql({ db, schema }));
|
app.use("/graphql", graphql({ db, schema }));
|
||||||
app.use("/", graphql({ db, schema }));
|
app.use("/", graphql({ db, schema }));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|
|
||||||
|
|
@ -99,22 +99,46 @@ export async function ensureStatsExists(context: any, timestamp?: bigint) {
|
||||||
let statsData = await context.db.find(stats, { id: STATS_ID });
|
let statsData = await context.db.find(stats, { id: STATS_ID });
|
||||||
if (!statsData) {
|
if (!statsData) {
|
||||||
const { client, contracts } = context;
|
const { client, contracts } = context;
|
||||||
|
const readWithFallback = async <T>(fn: () => Promise<T>, fallback: T, label: string): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[stats.ensureStatsExists] Falling back for ${label}`, error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [kraikenTotalSupply, stakeTotalSupply, outstandingStake] = await Promise.all([
|
const [kraikenTotalSupply, stakeTotalSupply, outstandingStake] = await Promise.all([
|
||||||
client.readContract({
|
readWithFallback(
|
||||||
abi: contracts.Kraiken.abi,
|
() =>
|
||||||
address: contracts.Kraiken.address,
|
client.readContract({
|
||||||
functionName: "totalSupply",
|
abi: contracts.Kraiken.abi,
|
||||||
}),
|
address: contracts.Kraiken.address,
|
||||||
client.readContract({
|
functionName: "totalSupply",
|
||||||
abi: contracts.Stake.abi,
|
}),
|
||||||
address: contracts.Stake.address,
|
0n,
|
||||||
functionName: "totalSupply",
|
"Kraiken.totalSupply",
|
||||||
}),
|
),
|
||||||
client.readContract({
|
readWithFallback(
|
||||||
abi: contracts.Stake.abi,
|
() =>
|
||||||
address: contracts.Stake.address,
|
client.readContract({
|
||||||
functionName: "outstandingStake",
|
abi: contracts.Stake.abi,
|
||||||
}),
|
address: contracts.Stake.address,
|
||||||
|
functionName: "totalSupply",
|
||||||
|
}),
|
||||||
|
0n,
|
||||||
|
"Stake.totalSupply",
|
||||||
|
),
|
||||||
|
readWithFallback(
|
||||||
|
() =>
|
||||||
|
client.readContract({
|
||||||
|
abi: contracts.Stake.abi,
|
||||||
|
address: contracts.Stake.address,
|
||||||
|
functionName: "outstandingStake",
|
||||||
|
}),
|
||||||
|
0n,
|
||||||
|
"Stake.outstandingStake",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
cachedStakeTotalSupply = stakeTotalSupply;
|
cachedStakeTotalSupply = stakeTotalSupply;
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ import { formatBigIntDivision, InsertCommaNumber, formatBigNumber, bigInt2Number
|
||||||
// import { bytesToUint256, uint256ToBytes } from "harb-lib";
|
// import { bytesToUint256, uint256ToBytes } from "harb-lib";
|
||||||
// import { getSnatchList } from "harb-lib/dist/";
|
// import { getSnatchList } from "harb-lib/dist/";
|
||||||
import { formatUnits } from "viem";
|
import { formatUnits } from "viem";
|
||||||
import { loadActivePositions, usePositions, type Position } from "@/composables/usePositions";
|
import { loadPositions, usePositions, type Position } from "@/composables/usePositions";
|
||||||
import {
|
import {
|
||||||
calculateSnatchShortfall,
|
calculateSnatchShortfall,
|
||||||
selectSnatchPositions,
|
selectSnatchPositions,
|
||||||
|
|
@ -238,7 +238,7 @@ async function stakeSnatch() {
|
||||||
}
|
}
|
||||||
stakeSnatchLoading.value = true;
|
stakeSnatchLoading.value = true;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||||
await loadActivePositions();
|
await loadPositions();
|
||||||
await loadStats();
|
await loadStats();
|
||||||
stakeSnatchLoading.value = false;
|
stakeSnatchLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {bigInt2Number, formatBigIntDivision} from "@/utils/helper";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useStatCollection } from "@/composables/useStatCollection";
|
import { useStatCollection } from "@/composables/useStatCollection";
|
||||||
import { useStake } from "@/composables/useStake";
|
import { useStake } from "@/composables/useStake";
|
||||||
import { loadActivePositions, usePositions, type Position } from "@/composables/usePositions";
|
import { usePositions, type Position } from "@/composables/usePositions";
|
||||||
import { useDark } from "@/composables/useDark";
|
import { useDark } from "@/composables/useDark";
|
||||||
const { darkTheme } = useDark();
|
const { darkTheme } = useDark();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ import logger from "@/utils/logger";
|
||||||
const rawActivePositions = ref<Array<Position>>([]);
|
const rawActivePositions = ref<Array<Position>>([]);
|
||||||
const rawClosedPositoins = ref<Array<Position>>([]);
|
const rawClosedPositoins = ref<Array<Position>>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const positionsError = ref<string | null>(null);
|
||||||
|
const POSITIONS_RETRY_BASE_DELAY = 1_500;
|
||||||
|
const POSITIONS_RETRY_MAX_DELAY = 60_000;
|
||||||
|
const positionsRetryDelayMs = ref(POSITIONS_RETRY_BASE_DELAY);
|
||||||
|
const GRAPHQL_TIMEOUT_MS = 15_000;
|
||||||
|
let positionsRetryTimer: number | null = null;
|
||||||
const chain = useChain();
|
const chain = useChain();
|
||||||
const activePositions = computed(() => {
|
const activePositions = computed(() => {
|
||||||
const account = getAccount(config as any);
|
const account = getAccount(config as any);
|
||||||
|
|
@ -106,14 +112,16 @@ const tresholdValue = computed(() => {
|
||||||
return avg / 2;
|
return avg / 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function loadActivePositions() {
|
export async function loadActivePositions(endpoint?: string) {
|
||||||
logger.info(`loadActivePositions for chain: ${chainData.value?.path}`);
|
logger.info(`loadActivePositions for chain: ${chainData.value?.path}`);
|
||||||
if (!chainData.value?.graphql) {
|
const targetEndpoint = endpoint ?? chainData.value?.graphql?.trim();
|
||||||
return [];
|
if (!targetEndpoint) {
|
||||||
|
throw new Error("GraphQL endpoint not configured for this chain.");
|
||||||
}
|
}
|
||||||
console.log("chainData.value?.graphql", chainData.value?.graphql);
|
|
||||||
|
|
||||||
const res = await axios.post(chainData.value?.graphql, {
|
console.log("chainData.value?.graphql", targetEndpoint);
|
||||||
|
|
||||||
|
const res = await axios.post(targetEndpoint, {
|
||||||
query: `query ActivePositions {
|
query: `query ActivePositions {
|
||||||
positionss(where: { status: "Active" }, orderBy: "taxRate", orderDirection: "asc", limit: 1000) {
|
positionss(where: { status: "Active" }, orderBy: "taxRate", orderDirection: "asc", limit: 1000) {
|
||||||
items {
|
items {
|
||||||
|
|
@ -132,7 +140,12 @@ export async function loadActivePositions() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
});
|
}, { timeout: GRAPHQL_TIMEOUT_MS });
|
||||||
|
|
||||||
|
const errors = res.data?.errors;
|
||||||
|
if (Array.isArray(errors) && errors.length > 0) {
|
||||||
|
throw new Error(errors.map((err: any) => err?.message ?? "GraphQL error").join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
const items = res.data?.data?.positionss?.items ?? [];
|
const items = res.data?.data?.positionss?.items ?? [];
|
||||||
return items.map((item: any) => ({
|
return items.map((item: any) => ({
|
||||||
|
|
@ -147,12 +160,13 @@ function formatId(id: Hex) {
|
||||||
return bigIntId;
|
return bigIntId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadMyClosedPositions(account: GetAccountReturnType) {
|
export async function loadMyClosedPositions(endpoint: string | undefined, account: GetAccountReturnType) {
|
||||||
logger.info(`loadMyClosedPositions for chain: ${chainData.value?.path}`);
|
logger.info(`loadMyClosedPositions for chain: ${chainData.value?.path}`);
|
||||||
if (!chainData.value?.graphql) {
|
const targetEndpoint = endpoint ?? chainData.value?.graphql?.trim();
|
||||||
return [];
|
if (!targetEndpoint) {
|
||||||
|
throw new Error("GraphQL endpoint not configured for this chain.");
|
||||||
}
|
}
|
||||||
const res = await axios.post(chainData.value?.graphql, {
|
const res = await axios.post(targetEndpoint, {
|
||||||
query: `query ClosedPositions {
|
query: `query ClosedPositions {
|
||||||
positionss(where: { status: "Closed", owner: "${account.address?.toLowerCase()}" }, limit: 1000) {
|
positionss(where: { status: "Closed", owner: "${account.address?.toLowerCase()}" }, limit: 1000) {
|
||||||
items {
|
items {
|
||||||
|
|
@ -171,10 +185,10 @@ export async function loadMyClosedPositions(account: GetAccountReturnType) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
});
|
}, { timeout: GRAPHQL_TIMEOUT_MS });
|
||||||
if (res.data.errors?.length > 0) {
|
const errors = res.data?.errors;
|
||||||
console.error("todo nur laden, wenn eingeloggt");
|
if (Array.isArray(errors) && errors.length > 0) {
|
||||||
return [];
|
throw new Error(errors.map((err: any) => err?.message ?? "GraphQL error").join(", "));
|
||||||
}
|
}
|
||||||
const items = res.data?.data?.positionss?.items ?? [];
|
const items = res.data?.data?.positionss?.items ?? [];
|
||||||
return items.map((item: any) => ({
|
return items.map((item: any) => ({
|
||||||
|
|
@ -186,20 +200,84 @@ export async function loadMyClosedPositions(account: GetAccountReturnType) {
|
||||||
export async function loadPositions() {
|
export async function loadPositions() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
rawActivePositions.value = await loadActivePositions();
|
const endpoint = chainData.value?.graphql?.trim();
|
||||||
//optimal wäre es: laden, wenn new Position meine Position geclosed hat
|
if (!endpoint) {
|
||||||
const account = getAccount(config as any);
|
rawActivePositions.value = [];
|
||||||
|
rawClosedPositoins.value = [];
|
||||||
if (account.address) {
|
positionsError.value = "GraphQL endpoint not configured for this chain.";
|
||||||
rawClosedPositoins.value = await loadMyClosedPositions(account);
|
clearPositionsRetryTimer();
|
||||||
|
positionsRetryDelayMs.value = POSITIONS_RETRY_BASE_DELAY;
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
rawActivePositions.value = await loadActivePositions(endpoint);
|
||||||
|
const account = getAccount(config as any);
|
||||||
|
if (account.address) {
|
||||||
|
rawClosedPositoins.value = await loadMyClosedPositions(endpoint, account);
|
||||||
|
} else {
|
||||||
|
rawClosedPositoins.value = [];
|
||||||
|
}
|
||||||
|
positionsError.value = null;
|
||||||
|
positionsRetryDelayMs.value = POSITIONS_RETRY_BASE_DELAY;
|
||||||
|
clearPositionsRetryTimer();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[positions] loadPositions() failed", error);
|
||||||
|
rawActivePositions.value = [];
|
||||||
|
rawClosedPositoins.value = [];
|
||||||
|
positionsError.value = formatGraphqlError(error);
|
||||||
|
schedulePositionsRetry();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let unwatch: WatchEventReturnType;
|
let unwatch: WatchEventReturnType | null;
|
||||||
let unwatchPositionRemovedEvent: WatchEventReturnType;
|
let unwatchPositionRemovedEvent: WatchEventReturnType | null;
|
||||||
let unwatchChainSwitch: WatchChainIdReturnType;
|
let unwatchChainSwitch: WatchChainIdReturnType | null;
|
||||||
let unwatchAccountChanged: WatchAccountReturnType;
|
let unwatchAccountChanged: WatchAccountReturnType | null;
|
||||||
|
|
||||||
|
function formatGraphqlError(error: unknown): string {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const responseErrors = (error.response?.data as any)?.errors;
|
||||||
|
if (Array.isArray(responseErrors) && responseErrors.length > 0) {
|
||||||
|
return responseErrors.map((err: any) => err?.message ?? "GraphQL error").join(", ");
|
||||||
|
}
|
||||||
|
if (error.response?.status) {
|
||||||
|
return `GraphQL request failed with status ${error.response.status}`;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Unknown GraphQL error";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPositionsRetryTimer() {
|
||||||
|
if (positionsRetryTimer !== null) {
|
||||||
|
clearTimeout(positionsRetryTimer);
|
||||||
|
positionsRetryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePositionsRetry() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (positionsRetryTimer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = positionsRetryDelayMs.value;
|
||||||
|
positionsRetryTimer = window.setTimeout(async () => {
|
||||||
|
positionsRetryTimer = null;
|
||||||
|
await loadPositions();
|
||||||
|
}, delay);
|
||||||
|
positionsRetryDelayMs.value = Math.min(positionsRetryDelayMs.value * 2, POSITIONS_RETRY_MAX_DELAY);
|
||||||
|
}
|
||||||
export function usePositions() {
|
export function usePositions() {
|
||||||
function watchEvent() {
|
function watchEvent() {
|
||||||
unwatch = watchContractEvent(config as any, {
|
unwatch = watchContractEvent(config as any, {
|
||||||
|
|
@ -231,7 +309,7 @@ export function usePositions() {
|
||||||
//initial loading positions
|
//initial loading positions
|
||||||
|
|
||||||
if (activePositions.value.length < 1 && loading.value === false) {
|
if (activePositions.value.length < 1 && loading.value === false) {
|
||||||
loadPositions();
|
await loadPositions();
|
||||||
// await getMinStake();
|
// await getMinStake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,12 +338,23 @@ export function usePositions() {
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// if (unwatch) {
|
if (unwatch) {
|
||||||
// unwatch();
|
unwatch();
|
||||||
// }
|
unwatch = null;
|
||||||
// if (unwatchPositionRemovedEvent) {
|
}
|
||||||
// unwatchPositionRemovedEvent();
|
if (unwatchPositionRemovedEvent) {
|
||||||
// }
|
unwatchPositionRemovedEvent();
|
||||||
|
unwatchPositionRemovedEvent = null;
|
||||||
|
}
|
||||||
|
if (unwatchChainSwitch) {
|
||||||
|
unwatchChainSwitch();
|
||||||
|
unwatchChainSwitch = null;
|
||||||
|
}
|
||||||
|
if (unwatchAccountChanged) {
|
||||||
|
unwatchAccountChanged();
|
||||||
|
unwatchAccountChanged = null;
|
||||||
|
}
|
||||||
|
clearPositionsRetryTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
function createRandomPosition(amount: number = 1) {
|
function createRandomPosition(amount: number = 1) {
|
||||||
|
|
@ -317,5 +406,7 @@ export function usePositions() {
|
||||||
watchEvent,
|
watchEvent,
|
||||||
watchPositionRemoved,
|
watchPositionRemoved,
|
||||||
createRandomPosition,
|
createRandomPosition,
|
||||||
|
positionsError,
|
||||||
|
loading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import type { WatchBlocksReturnType } from "viem";
|
||||||
import { bigInt2Number } from "@/utils/helper";
|
import { bigInt2Number } from "@/utils/helper";
|
||||||
const demo = sessionStorage.getItem("demo") === "true";
|
const demo = sessionStorage.getItem("demo") === "true";
|
||||||
|
|
||||||
|
const GRAPHQL_TIMEOUT_MS = 15_000;
|
||||||
|
const RETRY_BASE_DELAY_MS = 1_500;
|
||||||
|
const RETRY_MAX_DELAY_MS = 60_000;
|
||||||
|
|
||||||
interface StatsRecord {
|
interface StatsRecord {
|
||||||
burnNextHourProjected: string;
|
burnNextHourProjected: string;
|
||||||
burnedLastDay: string;
|
burnedLastDay: string;
|
||||||
|
|
@ -27,14 +31,54 @@ interface StatsRecord {
|
||||||
const rawStatsCollections = ref<Array<StatsRecord>>([]);
|
const rawStatsCollections = ref<Array<StatsRecord>>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const initialized = ref(false);
|
const initialized = ref(false);
|
||||||
|
const statsError = ref<string | null>(null);
|
||||||
|
const statsRetryDelayMs = ref(RETRY_BASE_DELAY_MS);
|
||||||
|
let statsRetryTimer: number | null = null;
|
||||||
|
|
||||||
export async function loadStatsCollection() {
|
function formatGraphqlError(error: unknown): string {
|
||||||
logger.info(`loadStatsCollection for chain: ${chainData.value?.path}`);
|
if (axios.isAxiosError(error)) {
|
||||||
if (!chainData.value?.graphql) {
|
const responseErrors = (error.response?.data as any)?.errors;
|
||||||
return [];
|
if (Array.isArray(responseErrors) && responseErrors.length > 0) {
|
||||||
|
return responseErrors.map((err: any) => err?.message ?? "GraphQL error").join(", ");
|
||||||
|
}
|
||||||
|
if (error.response?.status) {
|
||||||
|
return `GraphQL request failed with status ${error.response.status}`;
|
||||||
|
}
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Unknown GraphQL error";
|
||||||
|
}
|
||||||
|
|
||||||
const res = await axios.post(chainData.value?.graphql, {
|
function clearStatsRetryTimer() {
|
||||||
|
if (statsRetryTimer !== null) {
|
||||||
|
clearTimeout(statsRetryTimer);
|
||||||
|
statsRetryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStatsRetry() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statsRetryTimer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = statsRetryDelayMs.value;
|
||||||
|
statsRetryTimer = window.setTimeout(async () => {
|
||||||
|
statsRetryTimer = null;
|
||||||
|
await loadStats();
|
||||||
|
}, delay);
|
||||||
|
statsRetryDelayMs.value = Math.min(statsRetryDelayMs.value * 2, RETRY_MAX_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStatsCollection(endpoint: string) {
|
||||||
|
logger.info(`loadStatsCollection for chain: ${chainData.value?.path}`);
|
||||||
|
const res = await axios.post(endpoint, {
|
||||||
query: `query StatsQuery {
|
query: `query StatsQuery {
|
||||||
stats(id: "0x01") {
|
stats(id: "0x01") {
|
||||||
burnNextHourProjected
|
burnNextHourProjected
|
||||||
|
|
@ -52,11 +96,16 @@ export async function loadStatsCollection() {
|
||||||
totalMinted
|
totalMinted
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
});
|
}, { timeout: GRAPHQL_TIMEOUT_MS });
|
||||||
|
|
||||||
|
const errors = res.data?.errors;
|
||||||
|
if (Array.isArray(errors) && errors.length > 0) {
|
||||||
|
throw new Error(errors.map((err: any) => err?.message ?? "GraphQL error").join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
const stats = res.data?.data?.stats as StatsRecord | undefined;
|
const stats = res.data?.data?.stats as StatsRecord | undefined;
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
return [];
|
throw new Error("Stats entity not found in GraphQL response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ ...stats, kraikenTotalSupply: stats.kraikenTotalSupply }];
|
return [{ ...stats, kraikenTotalSupply: stats.kraikenTotalSupply }];
|
||||||
|
|
@ -178,10 +227,31 @@ const claimedSlots = computed(() => {
|
||||||
export async function loadStats() {
|
export async function loadStats() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
rawStatsCollections.value = await loadStatsCollection();
|
const endpoint = chainData.value?.graphql?.trim();
|
||||||
|
if (!endpoint) {
|
||||||
|
rawStatsCollections.value = [];
|
||||||
|
statsError.value = "GraphQL endpoint not configured for this chain.";
|
||||||
|
clearStatsRetryTimer();
|
||||||
|
statsRetryDelayMs.value = RETRY_BASE_DELAY_MS;
|
||||||
|
loading.value = false;
|
||||||
|
initialized.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = false;
|
try {
|
||||||
initialized.value = true;
|
rawStatsCollections.value = await loadStatsCollection(endpoint);
|
||||||
|
statsError.value = null;
|
||||||
|
statsRetryDelayMs.value = RETRY_BASE_DELAY_MS;
|
||||||
|
clearStatsRetryTimer();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[stats] loadStats() failed", error);
|
||||||
|
rawStatsCollections.value = [];
|
||||||
|
statsError.value = formatGraphqlError(error);
|
||||||
|
scheduleStatsRetry();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
initialized.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let unwatch: any = null;
|
let unwatch: any = null;
|
||||||
|
|
@ -212,6 +282,7 @@ export function useStatCollection() {
|
||||||
// })
|
// })
|
||||||
}
|
}
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
clearStatsRetryTimer();
|
||||||
unwatch();
|
unwatch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -227,5 +298,7 @@ export function useStatCollection() {
|
||||||
maxSlots,
|
maxSlots,
|
||||||
stakeableSupply,
|
stakeableSupply,
|
||||||
claimedSlots,
|
claimedSlots,
|
||||||
|
statsError,
|
||||||
|
loading,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ref, onMounted, onUnmounted, reactive, computed } from "vue";
|
import { ref, onMounted, onUnmounted, reactive, computed } from "vue";
|
||||||
import { type Ref } from "vue";
|
import { type Ref } from "vue";
|
||||||
import { getBalance, watchAccount, watchChainId } from "@wagmi/core";
|
import { getAccount, getBalance, watchAccount, watchChainId } from "@wagmi/core";
|
||||||
import { type WatchAccountReturnType, type GetAccountReturnType, type GetBalanceReturnType } from "@wagmi/core";
|
import { type WatchAccountReturnType, type GetAccountReturnType, type GetBalanceReturnType } from "@wagmi/core";
|
||||||
import { config } from "@/wagmi";
|
import { config } from "@/wagmi";
|
||||||
import { getAllowance, HarbContract, getNonce } from "@/contracts/harb";
|
import { getAllowance, HarbContract, getNonce } from "@/contracts/harb";
|
||||||
|
|
@ -15,18 +15,10 @@ const balance = ref<GetBalanceReturnType>({
|
||||||
symbol: "",
|
symbol: "",
|
||||||
formatted: ""
|
formatted: ""
|
||||||
});
|
});
|
||||||
const account = ref<GetAccountReturnType>({
|
const account = ref<GetAccountReturnType>(getAccount(config as any));
|
||||||
address: undefined,
|
if (!account.value.chainId) {
|
||||||
addresses: undefined,
|
(account.value as any).chainId = DEFAULT_CHAIN_ID;
|
||||||
chain: undefined,
|
}
|
||||||
chainId: undefined,
|
|
||||||
connector: undefined,
|
|
||||||
isConnected: false,
|
|
||||||
isConnecting: false,
|
|
||||||
isDisconnected: true,
|
|
||||||
isReconnecting: false,
|
|
||||||
status: "disconnected",
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedChainId = computed(() => account.value.chainId ?? DEFAULT_CHAIN_ID);
|
const selectedChainId = computed(() => account.value.chainId ?? DEFAULT_CHAIN_ID);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,55 @@
|
||||||
const LOCAL_PONDER_URL = "http://127.0.0.1:42069/graphql";
|
import deploymentsLocal from "../../onchain/deployments-local.json";
|
||||||
const LOCAL_TXNBOT_URL = "http://127.0.0.1:43069";
|
|
||||||
|
|
||||||
export const DEFAULT_CHAIN_ID = Number(import.meta.env.VITE_DEFAULT_CHAIN_ID ?? 84532);
|
const LOCAL_PONDER_URL = import.meta.env.VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK ?? "http://127.0.0.1:42069/graphql";
|
||||||
|
const LOCAL_TXNBOT_URL = import.meta.env.VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK ?? "http://127.0.0.1:43069";
|
||||||
|
const LOCAL_RPC_URL = import.meta.env.VITE_LOCAL_RPC_URL ?? "/rpc/anvil";
|
||||||
|
|
||||||
|
const localContracts = (deploymentsLocal as any)?.contracts ?? {};
|
||||||
|
const localInfra = (deploymentsLocal as any)?.infrastructure ?? {};
|
||||||
|
const LOCAL_KRAIKEN = (localContracts.Kraiken ?? "").trim();
|
||||||
|
const LOCAL_STAKE = (localContracts.Stake ?? "").trim();
|
||||||
|
const LOCAL_LM = (localContracts.LiquidityManager ?? "").trim();
|
||||||
|
const LOCAL_WETH = (localInfra.weth ?? "0x4200000000000000000000000000000000000006").trim();
|
||||||
|
const LOCAL_ROUTER = "0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4";
|
||||||
|
|
||||||
|
function detectDefaultChainId(): number {
|
||||||
|
const envValue = import.meta.env.VITE_DEFAULT_CHAIN_ID;
|
||||||
|
if (envValue) {
|
||||||
|
const parsed = Number(envValue);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const host = window.location.hostname.toLowerCase();
|
||||||
|
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
|
||||||
|
return 31337;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 84532;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CHAIN_ID = detectDefaultChainId();
|
||||||
|
|
||||||
export const chainsData = [
|
export const chainsData = [
|
||||||
|
{
|
||||||
|
// local base sepolia fork
|
||||||
|
id: 31337,
|
||||||
|
graphql: LOCAL_PONDER_URL,
|
||||||
|
path: "local",
|
||||||
|
stake: LOCAL_STAKE,
|
||||||
|
harb: LOCAL_KRAIKEN,
|
||||||
|
uniswap: "",
|
||||||
|
cheats: {
|
||||||
|
weth: LOCAL_WETH,
|
||||||
|
swapRouter: LOCAL_ROUTER,
|
||||||
|
liquidityManager: LOCAL_LM,
|
||||||
|
txnBot: LOCAL_TXNBOT_URL,
|
||||||
|
rpc: LOCAL_RPC_URL,
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// sepolia
|
// sepolia
|
||||||
id: 11155111,
|
id: 11155111,
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,66 @@
|
||||||
@click="addToken"
|
@click="addToken"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>{{ addingToken ? "Adding…" : "Add KRK token" }}</f-button>
|
>{{ addingToken ? "Adding…" : "Add KRK token" }}</f-button>
|
||||||
|
<f-button
|
||||||
|
:disabled="!canAddWeth || addingWeth"
|
||||||
|
@click="addWethToken"
|
||||||
|
variant="secondary"
|
||||||
|
>{{ addingWeth ? "Adding…" : "Add WETH token" }}</f-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</f-card>
|
</f-card>
|
||||||
|
|
||||||
|
<f-card 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>
|
||||||
|
<f-button
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="statsLoading"
|
||||||
|
@click="refreshLiquidityStats"
|
||||||
|
>{{ statsLoading ? "Refreshing…" : "Refresh stats" }}</f-button>
|
||||||
|
</div>
|
||||||
|
</f-card>
|
||||||
|
|
||||||
<div class="cheats-grid">
|
<div class="cheats-grid">
|
||||||
<f-card title="Mint ETH">
|
<f-card title="Mint ETH">
|
||||||
<div class="cheats-form">
|
<div class="cheats-form">
|
||||||
|
|
@ -65,7 +121,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import FButton from "@/components/fcomponents/FButton.vue";
|
import FButton from "@/components/fcomponents/FButton.vue";
|
||||||
import FCard from "@/components/fcomponents/FCard.vue";
|
import FCard from "@/components/fcomponents/FCard.vue";
|
||||||
import FInput from "@/components/fcomponents/FInput.vue";
|
import FInput from "@/components/fcomponents/FInput.vue";
|
||||||
|
|
@ -74,19 +130,27 @@ import { useToast } from "vue-toastification";
|
||||||
import { useAccount } from "@wagmi/vue";
|
import { useAccount } from "@wagmi/vue";
|
||||||
import { getChain, DEFAULT_CHAIN_ID } from "@/config";
|
import { getChain, DEFAULT_CHAIN_ID } from "@/config";
|
||||||
import { readContract, writeContract } from "@wagmi/core";
|
import { readContract, writeContract } from "@wagmi/core";
|
||||||
|
import HarbJson from "@/assets/contracts/harb.json";
|
||||||
import {
|
import {
|
||||||
|
createPublicClient,
|
||||||
erc20Abi,
|
erc20Abi,
|
||||||
|
formatEther,
|
||||||
getAddress,
|
getAddress,
|
||||||
|
http,
|
||||||
isAddress,
|
isAddress,
|
||||||
|
maxUint256,
|
||||||
parseEther,
|
parseEther,
|
||||||
toHex,
|
toHex,
|
||||||
type Address
|
zeroAddress,
|
||||||
|
type Address,
|
||||||
|
type Abi
|
||||||
} from "viem";
|
} from "viem";
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { address, chainId } = useAccount();
|
const { address, chainId } = useAccount();
|
||||||
|
|
||||||
const rpcUrl = ref("http://127.0.0.1:8545");
|
const rpcUrl = ref("");
|
||||||
|
let lastAutoRpcUrl = "";
|
||||||
|
|
||||||
const mintTarget = ref("");
|
const mintTarget = ref("");
|
||||||
const mintAmount = ref("10");
|
const mintAmount = ref("10");
|
||||||
|
|
@ -99,12 +163,32 @@ const forcing = ref(false);
|
||||||
|
|
||||||
const addingNetwork = ref(false);
|
const addingNetwork = ref(false);
|
||||||
const addingToken = ref(false);
|
const addingToken = ref(false);
|
||||||
|
const addingWeth = ref(false);
|
||||||
|
|
||||||
watch(address, (value) => {
|
interface PositionState {
|
||||||
if (value) {
|
liquidity: bigint;
|
||||||
mintTarget.value = value;
|
tickLower: number;
|
||||||
}
|
tickUpper: number;
|
||||||
}, { immediate: true });
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resolvedChainId = computed(() => chainId.value ?? DEFAULT_CHAIN_ID);
|
||||||
const chainConfig = computed(() => getChain(resolvedChainId.value));
|
const chainConfig = computed(() => getChain(resolvedChainId.value));
|
||||||
|
|
@ -113,6 +197,7 @@ const cheatConfig = computed(() => chainConfig.value?.cheats ?? null);
|
||||||
const canSwap = computed(() => Boolean(address.value && cheatConfig.value?.weth && cheatConfig.value?.swapRouter && chainConfig.value?.harb));
|
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 hasWalletProvider = computed(() => typeof window !== "undefined" && Boolean((window as any)?.ethereum?.request));
|
||||||
const canAddToken = computed(() => Boolean(hasWalletProvider.value && chainConfig.value?.harb));
|
const canAddToken = computed(() => Boolean(hasWalletProvider.value && chainConfig.value?.harb));
|
||||||
|
const canAddWeth = computed(() => Boolean(hasWalletProvider.value && cheatConfig.value?.weth));
|
||||||
const txnBotRecenterUrl = computed(() => {
|
const txnBotRecenterUrl = computed(() => {
|
||||||
const baseUrl = cheatConfig.value?.txnBot?.trim();
|
const baseUrl = cheatConfig.value?.txnBot?.trim();
|
||||||
if (!baseUrl) return null;
|
if (!baseUrl) return null;
|
||||||
|
|
@ -120,6 +205,38 @@ const txnBotRecenterUrl = computed(() => {
|
||||||
});
|
});
|
||||||
const canTriggerRecenter = computed(() => Boolean(txnBotRecenterUrl.value));
|
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 {
|
function resolveChainName(chainId: number): string {
|
||||||
switch (chainId) {
|
switch (chainId) {
|
||||||
case 31337:
|
case 31337:
|
||||||
|
|
@ -135,6 +252,17 @@ function resolveChainName(chainId: number): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const WETH_ABI = [
|
const WETH_ABI = [
|
||||||
{
|
{
|
||||||
inputs: [],
|
inputs: [],
|
||||||
|
|
@ -146,6 +274,13 @@ const WETH_ABI = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const SWAP_ROUTER_ABI = [
|
const SWAP_ROUTER_ABI = [
|
||||||
|
{
|
||||||
|
inputs: [],
|
||||||
|
name: "factory",
|
||||||
|
outputs: [{ internalType: "address", name: "", type: "address" }],
|
||||||
|
stateMutability: "view",
|
||||||
|
type: "function",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
|
|
@ -170,11 +305,361 @@ const SWAP_ROUTER_ABI = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const UNISWAP_FACTORY_ABI = [
|
||||||
|
{
|
||||||
|
inputs: [
|
||||||
|
{ internalType: "address", name: "tokenA", type: "address" },
|
||||||
|
{ internalType: "address", name: "tokenB", type: "address" },
|
||||||
|
{ internalType: "uint24", name: "fee", type: "uint24" },
|
||||||
|
],
|
||||||
|
name: "getPool",
|
||||||
|
outputs: [{ internalType: "address", name: "pool", type: "address" }],
|
||||||
|
stateMutability: "view",
|
||||||
|
type: "function",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNISWAP_POOL_ABI = [
|
||||||
|
{
|
||||||
|
inputs: [],
|
||||||
|
name: "slot0",
|
||||||
|
outputs: [
|
||||||
|
{ internalType: "uint160", name: "sqrtPriceX96", type: "uint160" },
|
||||||
|
{ internalType: "int24", name: "tick", type: "int24" },
|
||||||
|
{ internalType: "uint16", name: "observationIndex", type: "uint16" },
|
||||||
|
{ internalType: "uint16", name: "observationCardinality", type: "uint16" },
|
||||||
|
{ internalType: "uint16", name: "observationCardinalityNext", type: "uint16" },
|
||||||
|
{ internalType: "uint8", name: "feeProtocol", type: "uint8" },
|
||||||
|
{ internalType: "bool", name: "unlocked", type: "bool" },
|
||||||
|
],
|
||||||
|
stateMutability: "view",
|
||||||
|
type: "function",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [],
|
||||||
|
name: "liquidity",
|
||||||
|
outputs: [{ internalType: "uint128", name: "", type: "uint128" }],
|
||||||
|
stateMutability: "view",
|
||||||
|
type: "function",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 [Address, Address];
|
||||||
|
peripheryManager = managerFromContract;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cheats] Unable to read peripheryContracts", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideManager = (cheat as Record<string, unknown>)?.liquidityManager;
|
||||||
|
const managerAddressCandidate = typeof overrideManager === "string" && overrideManager.trim().length > 0
|
||||||
|
? overrideManager
|
||||||
|
: 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 factoryAddress = await client.readContract({
|
||||||
|
address: ensureAddress(cheat.swapRouter, "Uniswap router"),
|
||||||
|
abi: SWAP_ROUTER_ABI as Abi,
|
||||||
|
functionName: "factory",
|
||||||
|
}) as Address;
|
||||||
|
|
||||||
|
const poolAddressRaw = await client.readContract({
|
||||||
|
address: ensureAddress(factoryAddress, "Uniswap factory"),
|
||||||
|
abi: UNISWAP_FACTORY_ABI as Abi,
|
||||||
|
functionName: "getPool",
|
||||||
|
args: token0isWeth ? [wethAddress, harbAddress, POOL_FEE] : [harbAddress, wethAddress, POOL_FEE],
|
||||||
|
}) as Address;
|
||||||
|
|
||||||
|
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 as Abi,
|
||||||
|
functionName: "slot0",
|
||||||
|
}) as { tick: number } | readonly unknown[];
|
||||||
|
|
||||||
|
const currentTick = Array.isArray(slot0Response)
|
||||||
|
? Number(slot0Response[1])
|
||||||
|
: Number((slot0Response as { tick: number }).tick);
|
||||||
|
|
||||||
|
const [floorRaw, anchorRaw, discoveryRaw] = await Promise.all(
|
||||||
|
[0, 1, 2].map((stage) =>
|
||||||
|
client.readContract({
|
||||||
|
address: liquidityManagerAddress,
|
||||||
|
abi: LIQUIDITY_MANAGER_POSITIONS_ABI as Abi,
|
||||||
|
functionName: "positions",
|
||||||
|
args: [stage],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toPosition = (raw: any): PositionState => ({
|
||||||
|
liquidity: BigInt(raw?.liquidity ?? raw?.[0] ?? 0n),
|
||||||
|
tickLower: Number(raw?.tickLower ?? raw?.[1] ?? 0),
|
||||||
|
tickUpper: Number(raw?.tickUpper ?? raw?.[2] ?? 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
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: any) {
|
||||||
|
if (statsRequestId === requestId) {
|
||||||
|
liquidityStats.value = null;
|
||||||
|
const message = error?.shortMessage ?? error?.message ?? "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> {
|
async function rpcRequest<T>(method: string, params: unknown[]): Promise<T> {
|
||||||
if (!rpcUrl.value) {
|
if (!rpcUrl.value) {
|
||||||
throw new Error("RPC URL required");
|
throw new Error("RPC URL required");
|
||||||
}
|
}
|
||||||
const response = await fetch(rpcUrl.value, {
|
const response = await fetch(resolveRpcUrl(), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params }),
|
body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params }),
|
||||||
|
|
@ -266,17 +751,20 @@ async function addToken() {
|
||||||
throw new Error("KRAIKEN token address not configured for this chain");
|
throw new Error("KRAIKEN token address not configured for this chain");
|
||||||
}
|
}
|
||||||
const harbAddress = ensureAddress(chain.harb, "KRAIKEN token");
|
const harbAddress = ensureAddress(chain.harb, "KRAIKEN token");
|
||||||
|
const chainId = resolvedChainId.value;
|
||||||
const provider = (window as any).ethereum;
|
const provider = (window as any).ethereum;
|
||||||
const [symbol, decimals] = await Promise.all([
|
const [symbol, decimals] = await Promise.all([
|
||||||
readContract(config as any, {
|
readContract(config as any, {
|
||||||
abi: erc20Abi,
|
abi: erc20Abi,
|
||||||
address: harbAddress,
|
address: harbAddress,
|
||||||
functionName: "symbol",
|
functionName: "symbol",
|
||||||
|
chainId,
|
||||||
}) as Promise<string>,
|
}) as Promise<string>,
|
||||||
readContract(config as any, {
|
readContract(config as any, {
|
||||||
abi: erc20Abi,
|
abi: erc20Abi,
|
||||||
address: harbAddress,
|
address: harbAddress,
|
||||||
functionName: "decimals",
|
functionName: "decimals",
|
||||||
|
chainId,
|
||||||
}) as Promise<number>,
|
}) as Promise<number>,
|
||||||
]);
|
]);
|
||||||
await provider.request({
|
await provider.request({
|
||||||
|
|
@ -298,6 +786,50 @@ async function addToken() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = (window as any).ethereum;
|
||||||
|
const [symbol, decimals] = await Promise.all([
|
||||||
|
readContract(config as any, {
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: wethAddress,
|
||||||
|
functionName: "symbol",
|
||||||
|
chainId,
|
||||||
|
}) as Promise<string>,
|
||||||
|
readContract(config as any, {
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: wethAddress,
|
||||||
|
functionName: "decimals",
|
||||||
|
chainId,
|
||||||
|
}) as Promise<number>,
|
||||||
|
]);
|
||||||
|
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: any) {
|
||||||
|
toast.error(error?.message ?? "Failed to add token");
|
||||||
|
} finally {
|
||||||
|
addingWeth.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function buyKrk() {
|
async function buyKrk() {
|
||||||
if (!canSwap.value || swapping.value) return;
|
if (!canSwap.value || swapping.value) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -312,6 +844,7 @@ async function buyKrk() {
|
||||||
const router = ensureAddress(cheatConfig.value!.swapRouter, "Swap router");
|
const router = ensureAddress(cheatConfig.value!.swapRouter, "Swap router");
|
||||||
const harb = ensureAddress(chainConfig.value!.harb, "KRAIKEN token");
|
const harb = ensureAddress(chainConfig.value!.harb, "KRAIKEN token");
|
||||||
const caller = ensureAddress(address.value!, "Wallet address");
|
const caller = ensureAddress(address.value!, "Wallet address");
|
||||||
|
const chainId = resolvedChainId.value;
|
||||||
let amount: bigint;
|
let amount: bigint;
|
||||||
try {
|
try {
|
||||||
amount = parseEther(swapAmount.value || "0");
|
amount = parseEther(swapAmount.value || "0");
|
||||||
|
|
@ -321,18 +854,68 @@ async function buyKrk() {
|
||||||
if (amount <= 0n) {
|
if (amount <= 0n) {
|
||||||
throw new Error("Amount must be greater than zero");
|
throw new Error("Amount must be greater than zero");
|
||||||
}
|
}
|
||||||
await writeContract(config as any, {
|
const factoryAddress = await readContract(config as any, {
|
||||||
abi: WETH_ABI,
|
abi: SWAP_ROUTER_ABI,
|
||||||
address: weth,
|
address: router,
|
||||||
functionName: "deposit",
|
functionName: "factory",
|
||||||
value: amount,
|
chainId,
|
||||||
});
|
}) as Address;
|
||||||
await writeContract(config as any, {
|
const factory = ensureAddress(factoryAddress, "Uniswap factory");
|
||||||
|
const poolAddress = await readContract(config as any, {
|
||||||
|
abi: UNISWAP_FACTORY_ABI,
|
||||||
|
address: factory,
|
||||||
|
functionName: "getPool",
|
||||||
|
args: [weth, harb, 10_000],
|
||||||
|
chainId,
|
||||||
|
}) as Address;
|
||||||
|
if (!poolAddress || poolAddress === zeroAddress) {
|
||||||
|
throw new Error("No KRK/WETH pool found at 1% fee; deploy and recenter first");
|
||||||
|
}
|
||||||
|
const poolLiquidity = await readContract(config as any, {
|
||||||
|
abi: UNISWAP_POOL_ABI,
|
||||||
|
address: poolAddress,
|
||||||
|
functionName: "liquidity",
|
||||||
|
chainId,
|
||||||
|
}) as bigint;
|
||||||
|
if (poolLiquidity === 0n) {
|
||||||
|
throw new Error("KRK/WETH pool has zero liquidity; run recenter before swapping");
|
||||||
|
}
|
||||||
|
const wethBalance = await readContract(config as any, {
|
||||||
abi: erc20Abi,
|
abi: erc20Abi,
|
||||||
address: weth,
|
address: weth,
|
||||||
functionName: "approve",
|
functionName: "balanceOf",
|
||||||
args: [router, amount],
|
args: [caller],
|
||||||
});
|
chainId,
|
||||||
|
}) as bigint;
|
||||||
|
|
||||||
|
const wrapAmount = amount > wethBalance ? amount - wethBalance : 0n;
|
||||||
|
|
||||||
|
if (wrapAmount > 0n) {
|
||||||
|
await writeContract(config as any, {
|
||||||
|
abi: WETH_ABI,
|
||||||
|
address: weth,
|
||||||
|
functionName: "deposit",
|
||||||
|
value: wrapAmount,
|
||||||
|
chainId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const allowance = await readContract(config as any, {
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: weth,
|
||||||
|
functionName: "allowance",
|
||||||
|
args: [caller, router],
|
||||||
|
chainId,
|
||||||
|
}) as bigint;
|
||||||
|
|
||||||
|
if (allowance < amount) {
|
||||||
|
await writeContract(config as any, {
|
||||||
|
abi: erc20Abi,
|
||||||
|
address: weth,
|
||||||
|
functionName: "approve",
|
||||||
|
args: [router, maxUint256],
|
||||||
|
chainId,
|
||||||
|
});
|
||||||
|
}
|
||||||
await writeContract(config as any, {
|
await writeContract(config as any, {
|
||||||
abi: SWAP_ROUTER_ABI,
|
abi: SWAP_ROUTER_ABI,
|
||||||
address: router,
|
address: router,
|
||||||
|
|
@ -348,6 +931,7 @@ async function buyKrk() {
|
||||||
sqrtPriceLimitX96: 0n,
|
sqrtPriceLimitX96: 0n,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chainId,
|
||||||
});
|
});
|
||||||
toast.success("Swap submitted. Watch your wallet for KRK.");
|
toast.success("Swap submitted. Watch your wallet for KRK.");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -415,6 +999,23 @@ async function forceRecenter() {
|
||||||
flex-wrap: wrap
|
flex-wrap: wrap
|
||||||
gap: 12px
|
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
|
.cheats-hint
|
||||||
font-size: 12px
|
font-size: 12px
|
||||||
color: #A3A3A3
|
color: #A3A3A3
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {bigInt2Number, formatBigIntDivision} from "@/utils/helper";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useStatCollection } from "@/composables/useStatCollection";
|
import { useStatCollection } from "@/composables/useStatCollection";
|
||||||
import { useStake } from "@/composables/useStake";
|
import { useStake } from "@/composables/useStake";
|
||||||
import { loadActivePositions, usePositions, type Position } from "@/composables/usePositions";
|
import { usePositions, type Position } from "@/composables/usePositions";
|
||||||
import { useDark } from "@/composables/useDark";
|
import { useDark } from "@/composables/useDark";
|
||||||
const { darkTheme } = useDark();
|
const { darkTheme } = useDark();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
<chart-complete></chart-complete>
|
<chart-complete></chart-complete>
|
||||||
<div class="hold-stake-wrapper">
|
<div class="hold-stake-wrapper">
|
||||||
<f-card class="inner-border">
|
<f-card class="inner-border">
|
||||||
<template v-if="wallet.account.chainId && !chainsArray.includes(wallet.account.chainId)">
|
<template v-if="!isChainSupported">
|
||||||
Chain not supported
|
Chain not supported
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="status !== 'connected'">
|
<template v-else-if="status !== 'connected'">
|
||||||
<f-button @click="showPanel = true" size="large" block
|
<f-button @click="showPanel = true" size="large" block
|
||||||
>Connect Wallet</f-button
|
>Connect Wallet</f-button
|
||||||
|
|
@ -80,6 +80,7 @@ import { useWallet } from "@/composables/useWallet";
|
||||||
import FCard from "@/components/fcomponents/FCard.vue";
|
import FCard from "@/components/fcomponents/FCard.vue";
|
||||||
import IconInfo from "@/components/icons/IconInfo.vue";
|
import IconInfo from "@/components/icons/IconInfo.vue";
|
||||||
import FButton from "@/components/fcomponents/FButton.vue";
|
import FButton from "@/components/fcomponents/FButton.vue";
|
||||||
|
import { DEFAULT_CHAIN_ID } from "@/config";
|
||||||
|
|
||||||
// todo interface positions
|
// todo interface positions
|
||||||
import { usePositions } from "@/composables/usePositions";
|
import { usePositions } from "@/composables/usePositions";
|
||||||
|
|
@ -90,8 +91,8 @@ import { compactNumber, InsertCommaNumber } from "@/utils/helper";
|
||||||
const { myActivePositions, tresholdValue, activePositions } = usePositions();
|
const { myActivePositions, tresholdValue, activePositions } = usePositions();
|
||||||
|
|
||||||
const stats = useStatCollection();
|
const stats = useStatCollection();
|
||||||
const chains = useChains();
|
|
||||||
const wallet = useWallet();
|
const wallet = useWallet();
|
||||||
|
const chains = useChains();
|
||||||
|
|
||||||
function calculateAverageTaxRate(data: any): number {
|
function calculateAverageTaxRate(data: any): number {
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
|
@ -104,10 +105,10 @@ function calculateAverageTaxRate(data: any): number {
|
||||||
return averageTaxRate * 100;
|
return averageTaxRate * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chainsArray = computed(() => chains.value.map((chain) => chain.id));
|
|
||||||
|
|
||||||
|
|
||||||
const averageTaxRate = computed(() => calculateAverageTaxRate(activePositions.value));
|
const averageTaxRate = computed(() => calculateAverageTaxRate(activePositions.value));
|
||||||
|
const supportedChainIds = computed(() => chains.value.map((chain) => chain.id));
|
||||||
|
const currentChainId = computed(() => wallet.account.chainId ?? DEFAULT_CHAIN_ID);
|
||||||
|
const isChainSupported = computed(() => supportedChainIds.value.includes(currentChainId.value));
|
||||||
onMounted(async () => {});
|
onMounted(async () => {});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,54 @@
|
||||||
import { http, createConfig, createStorage } from "@wagmi/vue";
|
import { http, createConfig, createStorage } from "@wagmi/vue";
|
||||||
import { baseSepolia } from "@wagmi/vue/chains";
|
import { baseSepolia } from "@wagmi/vue/chains";
|
||||||
import { coinbaseWallet, walletConnect } from "@wagmi/vue/connectors";
|
import { coinbaseWallet, walletConnect } from "@wagmi/vue/connectors";
|
||||||
|
import { defineChain } from "viem";
|
||||||
|
|
||||||
|
const LOCAL_RPC_URL = import.meta.env.VITE_LOCAL_RPC_URL ?? "/rpc/anvil";
|
||||||
|
|
||||||
|
const kraikenLocalFork = defineChain({
|
||||||
|
id: 31337,
|
||||||
|
name: "Kraiken Local Fork",
|
||||||
|
network: "kraiken-local",
|
||||||
|
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||||
|
rpcUrls: {
|
||||||
|
default: { http: [LOCAL_RPC_URL] },
|
||||||
|
public: { http: [LOCAL_RPC_URL] },
|
||||||
|
},
|
||||||
|
blockExplorers: {
|
||||||
|
default: { name: "Local Explorer", url: "" },
|
||||||
|
},
|
||||||
|
testnet: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const config = createConfig({
|
export const config = createConfig({
|
||||||
chains: [baseSepolia],
|
chains: [kraikenLocalFork, baseSepolia],
|
||||||
storage: createStorage({ storage: window.localStorage }),
|
storage: createStorage({ storage: window.localStorage }),
|
||||||
|
|
||||||
connectors: [
|
connectors: [
|
||||||
walletConnect({
|
walletConnect({
|
||||||
projectId: "d8e5ecb0353c02e21d4c0867d4473ac5",
|
projectId: "d8e5ecb0353c02e21d4c0867d4473ac5",
|
||||||
metadata: {
|
metadata: {
|
||||||
name: "Harberg",
|
name: "Kraiken",
|
||||||
description: "Connect your wallet with Harberg",
|
description: "Connect your wallet with Kraiken",
|
||||||
url: "https://harberg.eth.limo",
|
url: "https://kraiken.eth.limo",
|
||||||
icons: [""],
|
icons: [""],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
coinbaseWallet({ appName: "Harberg", darkMode: true }),
|
coinbaseWallet({
|
||||||
|
appName: "Kraiken",
|
||||||
|
darkMode: true,
|
||||||
|
preference: {
|
||||||
|
options: "all",
|
||||||
|
telemetry: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
transports: {
|
transports: {
|
||||||
|
[kraikenLocalFork.id]: http(LOCAL_RPC_URL),
|
||||||
[baseSepolia.id]: http(),
|
[baseSepolia.id]: http(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && config.state.chainId !== kraikenLocalFork.id) {
|
||||||
|
config.setState((state) => ({ ...state, chainId: kraikenLocalFork.id }));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,35 @@ import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(() => {
|
||||||
|
const localRpcProxyTarget = process.env.VITE_LOCAL_RPC_PROXY_TARGET
|
||||||
|
|
||||||
|
return {
|
||||||
// base: "/HarbergPublic/",
|
// base: "/HarbergPublic/",
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
'kraiken-lib': fileURLToPath(new URL('../kraiken-lib/src', import.meta.url)),
|
'kraiken-lib': fileURLToPath(new URL('../kraiken-lib/src', import.meta.url)),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
|
proxy: localRpcProxyTarget
|
||||||
|
? {
|
||||||
|
'/rpc/anvil': {
|
||||||
|
target: localRpcProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => {
|
||||||
|
const rewritten = path.replace(/^\/rpc\/anvil/, '')
|
||||||
|
return rewritten.length === 0 ? '/' : rewritten
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue