diff --git a/AGENTS.md b/AGENTS.md index 8124a19..3103000 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ ## Testing & Tooling - Contracts: run `forge build`, `forge test`, and `forge snapshot` inside `onchain/`. - Fuzzing: scripts under `onchain/analysis/` (e.g., `./analysis/run-fuzzing.sh [optimizer] debugCSV`) generate replayable scenarios. -- Integration: after the stack boots, inspect Anvil logs, hit `http://localhost:42069/graphql` for Ponder, and poll `http://127.0.0.1:43069/status` for txnBot health. +- Integration: after the stack boots, inspect Anvil logs, hit `http://localhost:8081/api/graphql` for Ponder, and poll `http://localhost:8081/api/txn/status` for txnBot health. - **E2E Tests**: Playwright-based full-stack tests in `tests/e2e/` verify complete user journeys (mint ETH → swap KRK → stake). Run with `npm run test:e2e` from repo root. Tests use mocked wallet provider with Anvil accounts and automatically start/stop the stack. See `INTEGRATION_TEST_STATUS.md` and `SWAP_VERIFICATION.md` for details. ## Version Validation System @@ -65,8 +65,8 @@ - `anvil --fork-url https://sepolia.base.org` - manual fork when diagnosing outside the helper script. - `cast call "slot0()"` - inspect pool state. - `PONDER_NETWORK=BASE_SEPOLIA_LOCAL_FORK npm run dev` (inside `services/ponder/`) - focused indexer debugging when the full stack is already running. -- `curl -X POST http://localhost:42069/graphql -d '{"query":"{ stats(id:\"0x01\"){kraikenTotalSupply}}"}'` -- `curl http://127.0.0.1:43069/status` +- `curl -X POST http://localhost:8081/api/graphql -d '{"query":"{ stats(id:\"0x01\"){kraikenTotalSupply}}"}'` +- `curl http://localhost:8081/api/txn/status` ## References - Deployment history: `onchain/deployments-local.json`, `onchain/broadcast/`. diff --git a/containers/Caddyfile b/containers/Caddyfile index 8216164..93fb5e4 100644 --- a/containers/Caddyfile +++ b/containers/Caddyfile @@ -2,18 +2,19 @@ route /app* { reverse_proxy webapp:5173 } - route /graphql* { + route /api/graphql* { + uri strip_prefix /api reverse_proxy ponder:42069 } route /health* { reverse_proxy ponder:42069 } - route /rpc/anvil* { - uri strip_prefix /rpc/anvil + route /api/rpc* { + uri strip_prefix /api/rpc reverse_proxy anvil:8545 } - route /txn* { - uri strip_prefix /txn + route /api/txn* { + uri strip_prefix /api/txn reverse_proxy txn-bot:43069 } reverse_proxy landing:5174 diff --git a/containers/bootstrap.sh b/containers/bootstrap.sh index b6f70a6..d41088f 100755 --- a/containers/bootstrap.sh +++ b/containers/bootstrap.sh @@ -218,13 +218,15 @@ EOPONDER } write_txn_bot_env() { + local provider_url=${TXNBOT_PROVIDER_URL:-$ANVIL_RPC} + local graphql_endpoint=${TXNBOT_GRAPHQL_ENDPOINT:-http://ponder:42069/graphql} cat >"$TXNBOT_ENV" <:80`, routing `/graphql`, `/health`, `/rpc/anvil`, and `/txn` to the internal services +- `caddy` – front door at `http://:80`, routing `/api/graphql`, `/health`, `/api/rpc`, and `/api/txn` to the internal services All containers mount the repository so code edits hot-reload exactly as the local script. Named volumes keep `node_modules` caches between restarts. @@ -26,9 +26,9 @@ podman-compose -f podman-compose.yml up ### Access Points (via Caddy) - Frontend: `http:///` -- GraphQL: `http:///graphql` -- RPC passthrough: `http:///rpc/anvil` -- Txn bot status: `http:///txn/status` +- GraphQL: `http:///api/graphql` +- RPC passthrough: `http:///api/rpc` +- Txn bot status: `http:///api/txn/status` ## Configuration Knobs Set environment variables before `podman-compose up`: diff --git a/kraiken-lib/package-lock.json b/kraiken-lib/package-lock.json index 9447b18..749c72a 100644 --- a/kraiken-lib/package-lock.json +++ b/kraiken-lib/package-lock.json @@ -219,7 +219,6 @@ "integrity": "sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.2", @@ -3107,7 +3106,6 @@ "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.13.0" } @@ -3192,7 +3190,6 @@ "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", @@ -3520,7 +3517,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3943,7 +3939,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -4780,7 +4775,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5521,7 +5515,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -5606,7 +5599,6 @@ "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", "devOptional": true, "license": "MIT", - "peer": true, "workspaces": [ "website" ], @@ -6266,7 +6258,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9249,7 +9240,6 @@ "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9572,7 +9562,6 @@ "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/kraiken-lib/yarn.lock b/kraiken-lib/yarn.lock index 94e9827..f0cfabc 100644 --- a/kraiken-lib/yarn.lock +++ b/kraiken-lib/yarn.lock @@ -2803,11 +2803,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" diff --git a/package-lock.json b/package-lock.json index 09ad2f8..0099335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "harb", + "name": "harb-wa-conf", "lockfileVersion": 3, "requires": true, "packages": { @@ -30,6 +30,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -47,6 +48,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -64,6 +66,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -81,6 +84,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -98,6 +102,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -115,6 +120,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -132,6 +138,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -149,6 +156,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -166,6 +174,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -183,6 +192,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -200,6 +210,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -217,6 +228,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -234,6 +246,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -251,6 +264,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -268,6 +282,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -285,6 +300,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -302,6 +318,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -319,6 +336,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -336,6 +354,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -353,6 +372,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -370,6 +390,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -387,6 +408,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -404,6 +426,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -421,6 +444,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -438,6 +462,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -455,6 +480,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1269,7 +1295,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.52.2", @@ -1283,7 +1310,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.52.2", @@ -1297,7 +1325,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.52.2", @@ -1311,7 +1340,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.52.2", @@ -1325,7 +1355,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.52.2", @@ -1339,7 +1370,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.52.2", @@ -1353,7 +1385,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.52.2", @@ -1367,7 +1400,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.52.2", @@ -1381,7 +1415,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.52.2", @@ -1395,7 +1430,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.52.2", @@ -1409,7 +1445,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.52.2", @@ -1423,7 +1460,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.52.2", @@ -1437,7 +1475,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.52.2", @@ -1451,7 +1490,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.52.2", @@ -1465,7 +1505,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.52.2", @@ -1479,7 +1520,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.52.2", @@ -1493,7 +1535,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.52.2", @@ -1507,7 +1550,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.52.2", @@ -1521,7 +1565,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.52.2", @@ -1535,7 +1580,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.52.2", @@ -1549,7 +1595,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.52.2", @@ -1563,7 +1610,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@skorotkiewicz/snowflake-id": { "version": "1.0.1", @@ -1858,7 +1906,8 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "2.0.0", @@ -2208,6 +2257,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2338,7 +2388,6 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -2412,6 +2461,7 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -2688,7 +2738,6 @@ "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -2713,7 +2762,6 @@ "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3085,6 +3133,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3184,7 +3233,8 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { "version": "4.0.3", @@ -3296,6 +3346,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3416,7 +3467,6 @@ "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3441,7 +3491,6 @@ "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -3555,6 +3604,7 @@ "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -3824,8 +3874,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -3874,6 +3923,7 @@ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -3917,13 +3967,6 @@ "node": ">= 0.6" } }, - "node_modules/undici-types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", - "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", - "dev": true, - "optional": true - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4005,6 +4048,7 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4085,6 +4129,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -4169,7 +4214,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/dev.sh b/scripts/dev.sh index 7eb0fd6..5152617 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -15,6 +15,11 @@ readonly POLL_INTERVAL=2 # Check health every N seconds PID_FILE=/tmp/kraiken-watcher.pid PROJECT_NAME=${COMPOSE_PROJECT_NAME:-$(basename "$PWD")} +container_name() { + local service="$1" + echo "${PROJECT_NAME}_${service}_1" +} + cleanup_existing() { # Kill any existing watch scripts pkill -f "watch-kraiken-lib.sh" 2>/dev/null || true @@ -24,11 +29,11 @@ cleanup_existing() { rm -f "$PID_FILE" # Kill zombie podman processes - pkill -9 -f "podman wait.*harb_" 2>/dev/null || true + pkill -9 -f "podman wait.*${PROJECT_NAME}_" 2>/dev/null || true # Remove any existing containers (suppress errors if they don't exist) echo " Cleaning up existing containers..." - podman ps -a --filter "name=harb_" --format "{{.Names}}" 2>/dev/null | \ + podman ps -a --filter "label=com.docker.compose.project=${PROJECT_NAME}" --format "{{.Names}}" 2>/dev/null | \ xargs -r podman rm -f 2>&1 | grep -v "Error.*no container" || true } @@ -94,32 +99,32 @@ start_stack() { echo " Starting anvil & postgres..." podman-compose up -d anvil postgres 2>&1 | grep -v "STEP\|Copying\|Writing\|Getting\|fetch\|Installing\|Executing" || true - wait_for_healthy harb_anvil_1 "$ANVIL_TIMEOUT" || exit 1 - wait_for_healthy harb_postgres_1 "$POSTGRES_TIMEOUT" || exit 1 + wait_for_healthy "$(container_name anvil)" "$ANVIL_TIMEOUT" || exit 1 + wait_for_healthy "$(container_name postgres)" "$POSTGRES_TIMEOUT" || exit 1 # Phase 2: Start bootstrap (depends on anvil & postgres healthy) echo " Starting bootstrap..." podman-compose up -d bootstrap >/dev/null 2>&1 - wait_for_exited harb_bootstrap_1 "$BOOTSTRAP_TIMEOUT" || exit 1 + wait_for_exited "$(container_name bootstrap)" "$BOOTSTRAP_TIMEOUT" || exit 1 # Phase 3: Start ponder (depends on bootstrap completed) echo " Starting ponder..." podman-compose up -d ponder >/dev/null 2>&1 - wait_for_healthy harb_ponder_1 "$PONDER_TIMEOUT" || exit 1 + wait_for_healthy "$(container_name ponder)" "$PONDER_TIMEOUT" || exit 1 # Phase 4: Start frontend services (depend on ponder healthy) echo " Starting webapp, landing, txn-bot..." podman-compose up -d webapp landing txn-bot >/dev/null 2>&1 - wait_for_healthy harb_webapp_1 "$WEBAPP_TIMEOUT" || exit 1 + wait_for_healthy "$(container_name webapp)" "$WEBAPP_TIMEOUT" || exit 1 # Phase 5: Start caddy (depends on frontend services) echo " Starting caddy..." podman-compose up -d caddy >/dev/null 2>&1 - wait_for_healthy harb_caddy_1 "$CADDY_TIMEOUT" || exit 1 + wait_for_healthy "$(container_name caddy)" "$CADDY_TIMEOUT" || exit 1 if [[ -z "${SKIP_WATCH:-}" ]]; then echo "Watching for kraiken-lib changes..." @@ -131,7 +136,8 @@ start_stack() { echo "" echo "[ok] Stack started in ${total_time}s" echo " Web App: http://localhost:8081/app/" - echo " GraphQL: http://localhost:8081/graphql" + echo " RPC Proxy: http://localhost:8081/api/rpc" + echo " GraphQL: http://localhost:8081/api/graphql" } stop_stack() { diff --git a/tests/setup/stack.ts b/tests/setup/stack.ts index fc2001a..e94cf60 100644 --- a/tests/setup/stack.ts +++ b/tests/setup/stack.ts @@ -3,9 +3,9 @@ import { formatHealthCheckError, } from './health-checks.js'; -const DEFAULT_RPC_URL = 'http://127.0.0.1:8545'; +const DEFAULT_RPC_URL = 'http://localhost:8081/api/rpc'; const DEFAULT_WEBAPP_URL = 'http://localhost:8081'; -const DEFAULT_GRAPHQL_URL = 'http://localhost:8081/graphql'; +const DEFAULT_GRAPHQL_URL = 'http://localhost:8081/api/graphql'; export interface StackConfig { rpcUrl: string; diff --git a/web-app/AGENTS.md b/web-app/AGENTS.md new file mode 100644 index 0000000..a249c61 --- /dev/null +++ b/web-app/AGENTS.md @@ -0,0 +1,278 @@ +# Web App - Agent Guide + +Vue 3 + TypeScript staking interface for KRAIKEN, enabling users to stake tokens, manage positions, and interact with Harberger-tax mechanics. + +## Technology Snapshot +- Vue 3 (Composition API) with TypeScript and Vite toolchain +- Wagmi/Viem for wallet connection and blockchain interaction +- Vue Router for navigation +- Axios for GraphQL queries to Ponder indexer +- Sass-based component styling + +## Architecture Overview + +### Chain Configuration Service + +**Location**: `src/services/chainConfig.ts` + +Centralizes all endpoint resolution for different blockchain networks. This service eliminates scattered configuration and provides a single source of truth for network-specific URLs. + +**Key Method**: +```typescript +chainConfigService.getEndpoint(chainId: number, type: 'graphql' | 'rpc' | 'txnBot'): string +``` + +**Supported Chains**: +- `31337` - Local Anvil fork (development) +- `84532` - Base Sepolia (testnet) +- `8453` - Base Mainnet (production) + +**Usage Pattern**: +```typescript +// Composables receive chainId and resolve endpoints internally +const endpoint = chainConfigService.getEndpoint(chainId, 'graphql'); +``` + +**Benefits**: +- Single source of truth for all endpoint configuration +- Easy to test composables with different chainIds +- No hidden global state dependencies +- Clear error messages when endpoints aren't configured + +### Wagmi Integration + +**Configuration**: `src/wagmi.ts` + +Wagmi manages wallet connection and tracks the user's active blockchain: +- Wallet providers: WalletConnect, Coinbase Wallet +- Supported chains: Local fork (31337), Base Sepolia (84532) +- State persistence via localStorage + +**Chain Determination Flow**: +1. Wagmi detects wallet's current chain via `watchChainId()` +2. `useWallet` exposes `account.chainId` from wagmi state +3. Components read `account.chainId` and pass to composables +4. Composables watch for chain changes independently and reload data +5. `ChainConfigService` maps chainId → endpoint URLs + +**Key Insight**: Wagmi is the source of truth for *which chain the wallet is on*, but composables don't import wallet state directly. They accept `chainId` as a parameter for better testability and explicit dependencies. + +## Key Composables + +### `useWallet()` +**Purpose**: Manages wallet connection, balance, and account state +**Exports**: `balance`, `account`, `loadBalance()` +**Watchers**: +- `watchAccount()` - Reloads balance when account/chain changes +- `watchChainId()` - Reloads balance on chain switch + +**Note**: Also exports `chainData` computed property for UI metadata (Uniswap links, chain names). This is separate from endpoint resolution, which goes through `ChainConfigService`. + +### `usePositions(chainId: number)` +**Purpose**: Loads and manages staking positions from Ponder GraphQL +**Parameters**: `chainId` - Which chain to query positions from +**Exports**: `activePositions`, `myActivePositions`, `myClosedPositions`, `tresholdValue`, `positionsError`, `loading` +**Key Features**: +- Watches contract events (`PositionCreated`, `PositionRemoved`) to auto-refresh +- Independent `watchChainId()` listener reloads on chain switch +- Exponential backoff retry on GraphQL failures (1.5s → 60s max) + +**Data Flow**: +``` +Component (chainId) → usePositions(chainId) + → resolveGraphqlEndpoint(chainId) + → chainConfigService.getEndpoint(chainId, 'graphql') + → axios.post(endpoint, query) +``` + +### `useStatCollection(chainId: number)` +**Purpose**: Loads protocol-wide statistics (total supply, outstanding stake, inflation, etc.) +**Parameters**: `chainId` - Which chain to query stats from +**Exports**: `kraikenTotalSupply`, `stakeTotalSupply`, `outstandingStake`, `profit7d`, `inflation7d`, `maxSlots`, `claimedSlots`, `statsError`, `loading` +**Retry Logic**: Same exponential backoff as `usePositions` + +### `useSnatchSelection(demo, taxRateIndex, chainId)` +**Purpose**: Calculates which positions can be "snatched" based on staking amount and tax rate +**Parameters**: +- `demo` - Include own positions in selection +- `taxRateIndex` - Maximum tax rate index to snatch +- `chainId` - Optional chain ID +**Dependencies**: `usePositions`, `useStatCollection`, `useStake` +**Exports**: `snatchablePositions`, `shortfallShares`, `floorTax`, `openPositionsAvailable` + +### `useStake()` +**Purpose**: Executes staking transactions (stake, snatch-and-stake) +**Contract Interaction**: Calls `Stake.sol` via wagmi/viem +**State Management**: Tracks transaction states (StakeAble, SignTransaction, Waiting) + +### `useAdjustTaxRate()` +**Purpose**: Provides tax rate options and handles tax rate adjustments +**Key Data**: `taxRates` array with pre-calculated yearly/daily rates + +### `useUnstake()` +**Purpose**: Handles position exit transactions +**Contract Interaction**: Calls `exitPosition()` on Stake contract + +## Key Components + +### `StakeView.vue` +**Route**: `/dashboard` +**Purpose**: Main staking dashboard showing chart, statistics, active positions +**Key Features**: +- Passes `initialChainId` to all composables for consistent data loading +- Displays chain support status +- Shows "Connect Wallet" prompt when disconnected + +### `StakeHolder.vue` +**Purpose**: Staking form with accessibility-focused UI +**Key Elements**: +- Token amount slider with ARIA labels +- Tax rate selector dropdown +- Real-time feedback (floor tax, positions buyout count) +- Action button with state-driven labels (Stake / Snatch and Stake / Sign Transaction / Waiting) + +**Accessibility**: +- Semantic HTML with proper ARIA attributes +- Screen reader announcements for dynamic content +- Keyboard navigation support + +**Test Hooks** (for Playwright): +- `page.getByRole('slider', { name: 'Token Amount' })` +- `page.getByLabel('Staking Amount')` +- `page.getByLabel('Tax')` + +### `ConnectWallet.vue` +**Purpose**: Wallet connection modal with connector selection +**Features**: +- Shows connected wallet with avatar (blockies) and address +- Token balance display ($KRK, Staked $KRK) +- Buy button with chain-specific Uniswap link (uses `chainData.uniswap`) + +### `ChartComplete.vue` +**Purpose**: Position visualization showing tax rates and snatchable positions +**Dependencies**: `usePositions`, `useStatCollection`, `useStake` + +### `CollapseActive.vue` +**Purpose**: Expandable position card with profit calculation and actions +**Actions**: Pay Tax, Adjust Tax Rate, Unstake + +## Shared Utilities + +### `src/utils/logger.ts` +Structured logging with namespaces (contract, info, error) + +### `src/utils/helper.ts` +Common helpers: +- `bigInt2Number(value, decimals)` - Convert Wei to human-readable +- `formatBigIntDivision(a, b)` - Safe BigInt division +- `compactNumber(n)` - Format large numbers (1.5M, 3.2K) + +### `src/config.ts` +Chain configuration data: +- `chainsData` - Array of chain metadata (id, graphql, contracts, etc.) +- `getChain(id)` - Lookup chain by ID +- `DEFAULT_CHAIN_ID` - Auto-detected based on environment/hostname + +## Contract Interfaces + +### `src/contracts/harb.ts` +**Contract**: Kraiken.sol +**Methods**: `getMinStake()`, `getAllowance()`, `getNonce()`, `approve()` +**Setup**: `setHarbContract()` updates contract address when chain changes + +### `src/contracts/stake.ts` +**Contract**: Stake.sol +**Methods**: `assetsToShares()`, `getTaxDue()`, `payTax(positionId)` +**Setup**: `setStakeContract()` updates contract address when chain changes + +## Retry Logic & Error Handling + +Both `usePositions` and `useStatCollection` implement **exponential backoff retry** for GraphQL failures: + +**Configuration**: +- Base delay: 1.5 seconds +- Max delay: 60 seconds +- Backoff multiplier: 2x (1.5s → 3s → 6s → 12s → 24s → 48s → 60s) + +**Why Retry**: +- Ponder indexer may not be ready during stack startup +- Temporary network issues shouldn't permanently break UI +- Chain switching endpoints may have brief response delays + +**Implementation** (duplicated in both files): +- `formatGraphqlError()` - Parse Axios errors into user-friendly messages +- `clearXxxRetryTimer()` - Cancel pending retry +- `scheduleXxxRetry()` - Queue retry with exponential backoff +- `resolveGraphqlEndpoint()` - Resolve chainId to GraphQL URL + +**Refactoring Opportunity**: ~41 lines of duplicate code could be extracted to `src/utils/graphqlRetry.ts` + +## Development Workflow + +### Running Locally +Boot the full stack with `./scripts/dev.sh start` (see root `CLAUDE.md` for details) + +### Targeted Development +- `npm run dev` - Vite dev server (assumes Ponder/Anvil already running) +- `npm run build` - Production build with type checking +- `npm run preview` - Preview production build +- `npm run test:e2e` - Playwright E2E tests (from repo root) + +### Live Reload +Use `./scripts/watch-kraiken-lib.sh` to rebuild `kraiken-lib` on file changes and auto-restart dependent containers + +## Testing + +### E2E Tests +**Location**: `tests/e2e/` (repo root) +**Framework**: Playwright +**Coverage**: Complete user journeys (mint ETH → swap KRK → stake) +**References**: See `INTEGRATION_TEST_STATUS.md` and `SWAP_VERIFICATION.md` + +**Test Strategy**: +- Use mocked wallet provider with Anvil accounts +- Tests automatically start/stop the full stack +- Rely on semantic HTML and ARIA attributes (not private selectors) + +### Manual Testing Checklist +1. Connect wallet (WalletConnect / Coinbase Wallet) +2. Switch chains and verify data reloads +3. Stake tokens with different tax rates +4. Snatch positions with higher tax rate +5. Adjust tax rate on existing position +6. Pay tax manually +7. Unstake position +8. Verify GraphQL error retry behavior (kill Ponder, observe retries) + +## Quality Guidelines + +- **Composables**: Accept `chainId` parameter instead of importing wallet state directly +- **Watchers**: Each composable maintains its own `watchChainId()` listener for independence +- **Error States**: Always expose `xxxError` and `loading` refs for UI feedback +- **Type Safety**: Use strongly typed interfaces for Position, StatsRecord, etc. +- **Accessibility**: Use semantic HTML, ARIA attributes, and keyboard navigation +- **Testability**: Design components to work with Playwright role/label selectors + +## Performance Tips + +- Lazy load routes with Vue Router dynamic imports +- Debounce `assetsToShares()` calls in StakeHolder (500ms) +- Use `computed()` for derived state to avoid recalculation +- Clear watchers in `onUnmounted()` to prevent memory leaks +- Cancel retry timers on unmount or manual actions + +## Common Pitfalls + +1. **Don't** import `chainData` from `useWallet` in composables - pass `chainId` explicitly +2. **Don't** forget to pass `chainId` to `loadPositions()` / `loadStats()` on manual refresh +3. **Don't** call composables conditionally - they must run on every component render +4. **Don't** forget to clear timers/watchers in `onUnmounted()` +5. **Do** reset retry delays on successful loads (`retryDelayMs.value = BASE_DELAY`) + +## Future Enhancements + +- Extract retry logic to shared utility (`src/utils/graphqlRetry.ts`) +- Remove dead code in `useChain.ts` (entire file is commented out) +- Clean up commented debug `console.log()` statements +- Implement three-way version validation (contract VERSION ↔ Ponder ↔ web-app) +- Add GraphQL query caching layer for better performance diff --git a/web-app/src/components/StakeHolder.vue b/web-app/src/components/StakeHolder.vue index 70d0906..4a3e55f 100644 --- a/web-app/src/components/StakeHolder.vue +++ b/web-app/src/components/StakeHolder.vue @@ -159,6 +159,7 @@ import { getMinStake } from '@/contracts/harb'; import { useWallet } from '@/composables/useWallet'; import { ref, onMounted, watch, computed, watchEffect, getCurrentInstance } from 'vue'; import { useStatCollection, loadStats } from '@/composables/useStatCollection'; +import { DEFAULT_CHAIN_ID } from '@/config'; const demo = sessionStorage.getItem('demo') === 'true'; @@ -168,8 +169,10 @@ const taxRateIndex = ref(defaultTaxRateIndex); const stake = useStake(); const _claim = useClaim(); const wallet = useWallet(); -const statCollection = useStatCollection(); -const { activePositions: _activePositions } = usePositions(); +const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID; +const statCollection = useStatCollection(initialChainId); +const { activePositions: _activePositions } = usePositions(initialChainId); +const currentChainId = computed(() => wallet.account.chainId ?? DEFAULT_CHAIN_ID); const instance = getCurrentInstance(); const uid = instance?.uid ?? Math.floor(Math.random() * 10000); @@ -330,7 +333,7 @@ const taxRateAnnouncement = computed(() => { return `Selected tax rate index ${selectedTaxOption.value.index}. You will pay ${formatNumber(selectedTaxOption.value.year, 2)} percent yearly.`; }); -const snatchSelection = useSnatchSelection(demo, taxRateIndex); +const snatchSelection = useSnatchSelection(demo, taxRateIndex, initialChainId); const floorTaxDisplay = computed(() => `${formatNumber(snatchSelection.floorTax.value ?? 0, 2)} %`); const floorTaxHelpText = 'Your tax needs to exceed this value to displace an existing position.'; @@ -487,8 +490,8 @@ async function stakeSnatch() { const snatchAblePositionsIds = snatchSelection.snatchablePositions.value.map((p: Position) => p.positionId); await stake.snatch(stake.stakingAmount, taxRateIndex.value, snatchAblePositionsIds); } - await loadPositions(); - await loadStats(); + await loadPositions(currentChainId.value); + await loadStats(currentChainId.value); } async function handleSubmit() { diff --git a/web-app/src/components/chart/ChartComplete.vue b/web-app/src/components/chart/ChartComplete.vue index 0fb206e..b98ebbd 100644 --- a/web-app/src/components/chart/ChartComplete.vue +++ b/web-app/src/components/chart/ChartComplete.vue @@ -10,9 +10,13 @@ import { useStatCollection } from '@/composables/useStatCollection'; import { useStake } from '@/composables/useStake'; import { usePositions, type Position } from '@/composables/usePositions'; import { useDark } from '@/composables/useDark'; +import { useWallet } from '@/composables/useWallet'; +import { DEFAULT_CHAIN_ID } from '@/config'; const { darkTheme } = useDark(); -const { activePositions } = usePositions(); +const wallet = useWallet(); +const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID; +const { activePositions } = usePositions(initialChainId); const ignoreOwner = ref(false); const taxRate = ref(1.0); @@ -26,7 +30,7 @@ const stakeAbleHarbAmount = computed(() => statCollection.kraikenTotalSupply / 5 const minStake = computed(() => stakeAbleHarbAmount.value / 600n); const stake = useStake(); -const statCollection = useStatCollection(); +const statCollection = useStatCollection(initialChainId); const snatchPositions = computed(() => { if ( bigInt2Number(statCollection.outstandingStake, 18) + stake.stakingAmountNumber <= diff --git a/web-app/src/components/collapse/CollapseActive.vue b/web-app/src/components/collapse/CollapseActive.vue index 3c1ea04..2a2fb4b 100644 --- a/web-app/src/components/collapse/CollapseActive.vue +++ b/web-app/src/components/collapse/CollapseActive.vue @@ -83,12 +83,17 @@ import { computed, ref, onMounted } from 'vue'; import { getTaxDue, payTax } from '@/contracts/stake'; import { type Position, loadPositions } from '@/composables/usePositions'; import { useStatCollection } from '@/composables/useStatCollection'; +import { useWallet } from '@/composables/useWallet'; +import { DEFAULT_CHAIN_ID } from '@/config'; import { formatUnits } from 'viem'; const unstake = useUnstake(); const adjustTaxRate = useAdjustTaxRate(); -const statCollection = useStatCollection(); +const wallet = useWallet(); +const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID; +const statCollection = useStatCollection(initialChainId); +const currentChainId = computed(() => wallet.account.chainId ?? DEFAULT_CHAIN_ID); const props = defineProps<{ taxRate: number; @@ -134,7 +139,7 @@ async function unstakePosition() { await unstake.exitPosition(props.id); loading.value = true; await new Promise(resolve => setTimeout(resolve, 5000)); - await loadPositions(); + await loadPositions(currentChainId.value); loading.value = false; } diff --git a/web-app/src/components/layouts/ConnectWallet.vue b/web-app/src/components/layouts/ConnectWallet.vue index 2aca9ea..ca8820e 100644 --- a/web-app/src/components/layouts/ConnectWallet.vue +++ b/web-app/src/components/layouts/ConnectWallet.vue @@ -16,7 +16,7 @@
@@ -64,8 +64,8 @@ import FOutput from '@/components/fcomponents/FOutput.vue'; // import { usePositions } from "@/composables/usePositions"; // import { useWallet } from "@/composables/useWallet"; import { usePositions } from '@/composables/usePositions'; -import { useWallet } from '@/composables/useWallet'; -import { useChain } from '@/composables/useChain'; +import { useWallet, chainData as walletChainData } from '@/composables/useWallet'; +import { DEFAULT_CHAIN_ID } from '@/config'; import { useAccount, useDisconnect, useConnect, useChainId, type CreateConnectorFn, type Connector } from '@wagmi/vue'; @@ -74,9 +74,10 @@ const { disconnect } = useDisconnect(); const { connectors, connect } = useConnect(); const router = useRouter(); const chainId = useChainId(); -const { myActivePositions } = usePositions(); +const initialChainId = chainId.value ?? DEFAULT_CHAIN_ID; +const { myActivePositions } = usePositions(initialChainId); const wallet = useWallet(); -const chain = useChain(); +const chain = walletChainData; function loadConnectorImage(connector: Connector) { if (connector.icon) { return connector.icon; diff --git a/web-app/src/composables/useChain.ts b/web-app/src/composables/useChain.ts deleted file mode 100644 index b02c449..0000000 --- a/web-app/src/composables/useChain.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ref, reactive, computed } from 'vue'; -// import { getChainId, watchChainId, getAccount, watchAccount } from "@wagmi/core"; -// import { config } from "@/wagmi"; -// import { setHarbContract } from "@/contracts/harb"; -// import { setStakeContract } from "@/contracts/stake"; -import { chainsData } from '@/config'; -// import logger from "@/utils/logger"; -const activeChain = ref(); - -export const chainData = computed(() => { - return chainsData.find(obj => obj.id === activeChain.value); -}); - -export function useChain() { - // if (!unwatch) { - // console.log("useChain function"); - // const chain = getChainId(config as Config) - // activeChain.value = chain - // unwatch = watchChainId(config as Config, { - // async onChange(chainId) { - // console.log("Chain changed", chainId); - // activeChain.value = chainId - // setHarbContract() - // setStakeContract() - // }, - // }); - // } - - // if (!unwatch) { - // console.log("useWallet function"); - - // unwatch = watchAccount(config as Config, { - // async onChange(data) { - // console.log("watchaccount-useChain", data); - - // if(!data.address) { - // } else if (activeChain.value !== data.chainId) { - // logger.info(`Chain changed!:`, data.chainId); - // } - // }, - // }); - // } - - return reactive({ chainData }); -} diff --git a/web-app/src/composables/usePositions.ts b/web-app/src/composables/usePositions.ts index 6fc1489..9a95323 100644 --- a/web-app/src/composables/usePositions.ts +++ b/web-app/src/composables/usePositions.ts @@ -9,17 +9,17 @@ import { HarbContract } from '@/contracts/harb'; import { bytesToUint256 } from 'kraiken-lib'; import { bigInt2Number } from '@/utils/helper'; import { getTaxRateIndexByDecimal } from '@/composables/useAdjustTaxRates'; -import { chainData } from '@/composables/useWallet'; import logger from '@/utils/logger'; +import { DEFAULT_CHAIN_ID } from '@/config'; +import { createRetryManager, formatGraphqlError, resolveGraphqlEndpoint } from '@/utils/graphqlRetry'; const rawActivePositions = ref>([]); const rawClosedPositoins = ref>([]); const loading = ref(false); const positionsError = ref(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 activeChainId = ref(DEFAULT_CHAIN_ID); + +const retryManager = createRetryManager(loadPositions, activeChainId); const activePositions = computed(() => { const account = getAccount(config as Config); @@ -108,9 +108,7 @@ const myActivePositions: ComputedRef = computed(() => const tresholdValue = computed(() => { // Compute average tax rate index instead of percentage to avoid floating-point issues - const validIndices = activePositions.value - .map(obj => obj.taxRateIndex) - .filter((idx): idx is number => typeof idx === 'number'); + const validIndices = activePositions.value.map(obj => obj.taxRateIndex).filter((idx): idx is number => typeof idx === 'number'); if (validIndices.length === 0) return 0; @@ -121,14 +119,11 @@ const tresholdValue = computed(() => { return Math.floor(avgIndex / 2); }); -export async function loadActivePositions(endpoint?: string) { - logger.info(`loadActivePositions for chain: ${chainData.value?.path}`); - const targetEndpoint = endpoint ?? chainData.value?.graphql?.trim(); - if (!targetEndpoint) { - throw new Error('GraphQL endpoint not configured for this chain.'); - } +export async function loadActivePositions(chainId: number, endpointOverride?: string) { + const targetEndpoint = resolveGraphqlEndpoint(chainId, endpointOverride); + logger.info(`loadActivePositions for chainId: ${chainId}`); - // console.log("chainData.value?.graphql", targetEndpoint); + // console.log("graphql endpoint", targetEndpoint); const res = await axios.post( targetEndpoint, @@ -173,12 +168,9 @@ function formatId(id: Hex) { return bigIntId; } -export async function loadMyClosedPositions(endpoint: string | undefined, account: GetAccountReturnType) { - logger.info(`loadMyClosedPositions for chain: ${chainData.value?.path}`); - const targetEndpoint = endpoint ?? chainData.value?.graphql?.trim(); - if (!targetEndpoint) { - throw new Error('GraphQL endpoint not configured for this chain.'); - } +export async function loadMyClosedPositions(chainId: number, endpointOverride: string | undefined, account: GetAccountReturnType) { + const targetEndpoint = resolveGraphqlEndpoint(chainId, endpointOverride); + logger.info(`loadMyClosedPositions for chainId: ${chainId}`); const res = await axios.post( targetEndpoint, { @@ -214,36 +206,41 @@ export async function loadMyClosedPositions(endpoint: string | undefined, accoun })) as Position[]; } -export async function loadPositions() { +export async function loadPositions(chainId?: number) { loading.value = true; - const endpoint = chainData.value?.graphql?.trim(); - if (!endpoint) { + const targetChainId = typeof chainId === 'number' ? chainId : (activeChainId.value ?? DEFAULT_CHAIN_ID); + activeChainId.value = targetChainId; + + let endpoint: string; + try { + endpoint = resolveGraphqlEndpoint(targetChainId); + } catch (error) { rawActivePositions.value = []; rawClosedPositoins.value = []; - positionsError.value = 'GraphQL endpoint not configured for this chain.'; - clearPositionsRetryTimer(); - positionsRetryDelayMs.value = POSITIONS_RETRY_BASE_DELAY; + positionsError.value = error instanceof Error ? error.message : 'GraphQL endpoint not configured for this chain.'; + retryManager.clear(); + retryManager.reset(); loading.value = false; return; } try { - rawActivePositions.value = await loadActivePositions(endpoint); + rawActivePositions.value = await loadActivePositions(targetChainId, endpoint); const account = getAccount(config as Config); if (account.address) { - rawClosedPositoins.value = await loadMyClosedPositions(endpoint, account); + rawClosedPositoins.value = await loadMyClosedPositions(targetChainId, endpoint, account); } else { rawClosedPositoins.value = []; } positionsError.value = null; - positionsRetryDelayMs.value = POSITIONS_RETRY_BASE_DELAY; - clearPositionsRetryTimer(); + retryManager.reset(); + retryManager.clear(); } catch (error) { rawActivePositions.value = []; rawClosedPositoins.value = []; positionsError.value = formatGraphqlError(error); - schedulePositionsRetry(); + retryManager.schedule(); } finally { loading.value = false; } @@ -253,48 +250,9 @@ let unwatch: WatchEventReturnType | null; let unwatchPositionRemovedEvent: WatchEventReturnType | null; let unwatchChainSwitch: WatchChainIdReturnType | null; let unwatchAccountChanged: WatchAccountReturnType | null; +export function usePositions(chainId: number = DEFAULT_CHAIN_ID) { + activeChainId.value = chainId; -function formatGraphqlError(error: unknown): string { - if (axios.isAxiosError(error)) { - const responseErrors = (error.response?.data as { errors?: unknown[] })?.errors; - if (Array.isArray(responseErrors) && responseErrors.length > 0) { - return responseErrors.map((err: unknown) => (err as { message?: string })?.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() { function watchEvent() { unwatch = watchContractEvent(config as Config, { address: HarbContract.contractAddress, @@ -325,7 +283,7 @@ export function usePositions() { //initial loading positions if (activePositions.value.length < 1 && loading.value === false) { - await loadPositions(); + await loadPositions(activeChainId.value); // await getMinStake(); } @@ -338,8 +296,10 @@ export function usePositions() { if (!unwatchChainSwitch) { unwatchChainSwitch = watchChainId(config as Config, { - async onChange(_chainId) { - await loadPositions(); + async onChange(nextChainId) { + const resolvedChainId = nextChainId ?? DEFAULT_CHAIN_ID; + activeChainId.value = resolvedChainId; + await loadPositions(resolvedChainId); }, }); } @@ -347,7 +307,7 @@ export function usePositions() { if (!unwatchAccountChanged) { unwatchAccountChanged = watchAccount(config as Config, { async onChange() { - await loadPositions(); + await loadPositions(activeChainId.value); }, }); } @@ -370,7 +330,7 @@ export function usePositions() { unwatchAccountChanged(); unwatchAccountChanged = null; } - clearPositionsRetryTimer(); + retryManager.clear(); }); function createRandomPosition(amount: number = 1) { diff --git a/web-app/src/composables/useSnatchSelection.ts b/web-app/src/composables/useSnatchSelection.ts index 1eef946..7411034 100644 --- a/web-app/src/composables/useSnatchSelection.ts +++ b/web-app/src/composables/useSnatchSelection.ts @@ -6,6 +6,7 @@ import { useStatCollection } from './useStatCollection'; import { useAdjustTaxRate } from './useAdjustTaxRates'; import { calculateSnatchShortfall } from 'kraiken-lib/staking'; import { selectSnatchPositions, minimumTaxRate, type SnatchablePosition } from 'kraiken-lib/snatch'; +import { DEFAULT_CHAIN_ID } from '@/config'; /** * Converts Kraiken token assets to shares using the same formula as Stake.sol: @@ -24,11 +25,12 @@ function assetsToSharesLocal(assets: bigint, kraikenTotalSupply: bigint, stakeTo return (assets * stakeTotalSupply) / kraikenTotalSupply; } -export function useSnatchSelection(demo = false, taxRateIndex?: Ref) { - const { activePositions } = usePositions(); - const stake = useStake(); +export function useSnatchSelection(demo = false, taxRateIndex?: Ref, chainId?: number) { const wallet = useWallet(); - const statCollection = useStatCollection(); + const resolvedChainId = chainId ?? wallet.account.chainId ?? DEFAULT_CHAIN_ID; + const { activePositions } = usePositions(resolvedChainId); + const stake = useStake(); + const statCollection = useStatCollection(resolvedChainId); const adjustTaxRate = useAdjustTaxRate(); const snatchablePositions = ref([]); @@ -78,7 +80,7 @@ export function useSnatchSelection(demo = false, taxRateIndex?: Ref) { const selectedTaxRateIndex = taxRateIndex?.value ?? stakeTaxRateIndex; const maxTaxRateDecimal = typeof selectedTaxRateIndex === 'number' && Number.isInteger(selectedTaxRateIndex) - ? adjustTaxRate.taxRates[selectedTaxRateIndex]?.decimal ?? Number.POSITIVE_INFINITY + ? (adjustTaxRate.taxRates[selectedTaxRateIndex]?.decimal ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY; const includeOwned = demo; const recipient = wallet.account.address ?? null; diff --git a/web-app/src/composables/useStatCollection.ts b/web-app/src/composables/useStatCollection.ts index 13c2d82..07e6074 100644 --- a/web-app/src/composables/useStatCollection.ts +++ b/web-app/src/composables/useStatCollection.ts @@ -1,17 +1,16 @@ import { ref, reactive, computed } from 'vue'; import axios from 'axios'; -import { chainData } from './useWallet'; import { watchChainId } from '@wagmi/core'; import type { Config } from '@wagmi/core'; import { config } from '@/wagmi'; import logger from '@/utils/logger'; import type { WatchBlocksReturnType } from 'viem'; import { bigInt2Number } from '@/utils/helper'; +import { DEFAULT_CHAIN_ID } from '@/config'; +import { createRetryManager, formatGraphqlError, resolveGraphqlEndpoint } from '@/utils/graphqlRetry'; 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 { burnNextHourProjected: string; @@ -33,52 +32,13 @@ const rawStatsCollections = ref>([]); const loading = ref(false); const initialized = ref(false); const statsError = ref(null); -const statsRetryDelayMs = ref(RETRY_BASE_DELAY_MS); -let statsRetryTimer: number | null = null; +const activeChainId = ref(DEFAULT_CHAIN_ID); -function formatGraphqlError(error: unknown): string { - if (axios.isAxiosError(error)) { - const responseErrors = (error.response?.data as { errors?: unknown[] })?.errors; - if (Array.isArray(responseErrors) && responseErrors.length > 0) { - return responseErrors.map((err: unknown) => (err as { message?: string })?.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 retryManager = createRetryManager(loadStats, activeChainId); -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}`); +export async function loadStatsCollection(chainId: number, endpointOverride?: string) { + const endpoint = resolveGraphqlEndpoint(chainId, endpointOverride); + logger.info(`loadStatsCollection for chainId: ${chainId}`); const res = await axios.post( endpoint, { @@ -223,30 +183,34 @@ const claimedSlots = computed(() => { } }); -export async function loadStats() { +export async function loadStats(chainId?: number) { loading.value = true; - const endpoint = chainData.value?.graphql?.trim(); - if (!endpoint) { + const targetChainId = typeof chainId === 'number' ? chainId : (activeChainId.value ?? DEFAULT_CHAIN_ID); + activeChainId.value = targetChainId; + + let endpoint: string; + try { + endpoint = resolveGraphqlEndpoint(targetChainId); + } catch (error) { rawStatsCollections.value = []; - statsError.value = 'GraphQL endpoint not configured for this chain.'; - clearStatsRetryTimer(); - statsRetryDelayMs.value = RETRY_BASE_DELAY_MS; + statsError.value = error instanceof Error ? error.message : 'GraphQL endpoint not configured for this chain.'; + retryManager.clear(); + retryManager.reset(); loading.value = false; initialized.value = true; return; } try { - rawStatsCollections.value = await loadStatsCollection(endpoint); + rawStatsCollections.value = await loadStatsCollection(targetChainId, endpoint); statsError.value = null; - statsRetryDelayMs.value = RETRY_BASE_DELAY_MS; - clearStatsRetryTimer(); + retryManager.reset(); + retryManager.clear(); } catch (error) { - // console.warn('[stats] loadStats() failed', error); rawStatsCollections.value = []; statsError.value = formatGraphqlError(error); - scheduleStatsRetry(); + retryManager.schedule(); } finally { loading.value = false; initialized.value = true; @@ -256,11 +220,13 @@ export async function loadStats() { import { onMounted, onUnmounted } from 'vue'; let unwatch: WatchBlocksReturnType | null = null; -export function useStatCollection() { +export function useStatCollection(chainId: number = DEFAULT_CHAIN_ID) { + activeChainId.value = chainId; + onMounted(async () => { //initial loading stats if (rawStatsCollections.value?.length === 0 && !loading.value) { - await loadStats(); + await loadStats(activeChainId.value); } }); if (!unwatch) { @@ -268,8 +234,10 @@ export function useStatCollection() { //chain Switch reload stats for other chain unwatch = watchChainId(config as Config, { - async onChange(_chainId) { - await loadStats(); + async onChange(nextChainId) { + const resolvedChainId = nextChainId ?? DEFAULT_CHAIN_ID; + activeChainId.value = resolvedChainId; + await loadStats(resolvedChainId); }, }); @@ -281,7 +249,7 @@ export function useStatCollection() { // }) } onUnmounted(() => { - clearStatsRetryTimer(); + retryManager.clear(); if (unwatch) { unwatch(); } diff --git a/web-app/src/composables/useWallet.ts b/web-app/src/composables/useWallet.ts index 73a826e..99f8645 100644 --- a/web-app/src/composables/useWallet.ts +++ b/web-app/src/composables/useWallet.ts @@ -1,12 +1,14 @@ import { ref, reactive, computed } from 'vue'; -import { getAccount, getBalance, watchAccount, watchChainId, type Config } from '@wagmi/core'; +import { getAccount, watchAccount, watchChainId, type Config } from '@wagmi/core'; import { type WatchAccountReturnType, type GetAccountReturnType, type GetBalanceReturnType } from '@wagmi/core'; -import { config } from '@/wagmi'; +import { config, KRAIKEN_LOCAL_CHAIN } from '@/wagmi'; import { getAllowance, HarbContract, getNonce } from '@/contracts/harb'; import logger from '@/utils/logger'; import { setHarbContract } from '@/contracts/harb'; import { setStakeContract } from '@/contracts/stake'; import { chainsData, DEFAULT_CHAIN_ID } from '@/config'; +import { createPublicClient, custom, formatUnits, type EIP1193Provider, type PublicClient, type Transport, type WalletClient } from 'viem'; +import { getWalletPublicClient, setWalletPublicClient } from '@/services/walletRpc'; const balance = ref({ value: 0n, @@ -25,24 +27,81 @@ export const chainData = computed(() => { return chainsData.find(obj => obj.id === selectedChainId.value); }); +const TOKEN_DECIMALS = 18; +const TOKEN_SYMBOL = 'KRK'; + +async function syncWalletPublicClient(data: GetAccountReturnType | undefined = account.value): Promise { + if (!data?.address || !data.connector) { + setWalletPublicClient(null); + return null; + } + try { + const connector = data.connector; + if (!connector || typeof connector.getProvider !== 'function') { + setWalletPublicClient(null); + return null; + } + + const connectorAny = connector as { + getProvider?: () => Promise; + getWalletClient?: (args: { chainId: number }) => Promise; + }; + const provider = connectorAny.getProvider ? await connectorAny.getProvider() : null; + const walletClient = connectorAny.getWalletClient + ? await connectorAny.getWalletClient({ chainId: data.chainId ?? selectedChainId.value }) + : null; + + if (!provider || typeof (provider as { request?: unknown }).request !== 'function') { + setWalletPublicClient(null); + return null; + } + + const chain = (walletClient as WalletClient | null)?.chain ?? KRAIKEN_LOCAL_CHAIN; + const transport = custom(provider as EIP1193Provider) as Transport; + const publicClient = createPublicClient({ + chain, + transport, + }) as PublicClient; + setWalletPublicClient(publicClient); + return publicClient; + } catch (_error) { + logger.contract('Failed to create wallet public client'); + setWalletPublicClient(null); + return null; + } +} + let unwatch: WatchAccountReturnType | null = null; let unwatchChain: WatchAccountReturnType | null = null; export function useWallet() { async function loadBalance() { logger.contract('loadBalance'); - if (account.value.address) { - // console.log("HarbContract",HarbContract ); - - balance.value = await getBalance(config as Config, { - address: account.value.address, - token: HarbContract.contractAddress, - }); - // console.log("balance.value", balance.value); - - return balance.value; - } else { + const userAddress = account.value.address; + if (!userAddress) { return 0n; } + let publicClient = getWalletPublicClient(); + if (!publicClient) { + publicClient = await syncWalletPublicClient(); + } + if (!publicClient) { + return 0n; + } + const value = (await publicClient.readContract({ + abi: HarbContract.abi, + address: HarbContract.contractAddress, + functionName: 'balanceOf', + args: [userAddress], + })) as bigint; + + balance.value = { + value, + decimals: TOKEN_DECIMALS, + symbol: TOKEN_SYMBOL, + formatted: formatUnits(value, TOKEN_DECIMALS), + } as GetBalanceReturnType; + + return value; } if (!unwatch) { @@ -54,6 +113,7 @@ export function useWallet() { if (!data.address) { logger.info(`disconnected`); + setWalletPublicClient(null); balance.value = { value: 0n, decimals: 0, @@ -63,6 +123,7 @@ export function useWallet() { } else if (account.value.address !== data.address || account.value.chainId !== data.chainId) { logger.info(`Account changed!:`, data.address); account.value = data; + await syncWalletPublicClient(data); await loadBalance(); await getAllowance(); // await loadPositions(); @@ -75,11 +136,16 @@ export function useWallet() { } //funzt nicht mehr-> library Änderung? + if (account.value.address) { + void syncWalletPublicClient(account.value); + } + if (!unwatchChain) { // console.log("unwatchChain"); unwatchChain = watchChainId(config as Config, { async onChange(_chainId) { + await syncWalletPublicClient(account.value); await loadBalance(); await getAllowance(); await getNonce(); diff --git a/web-app/src/config.ts b/web-app/src/config.ts index 6b68a3d..168a4ca 100644 --- a/web-app/src/config.ts +++ b/web-app/src/config.ts @@ -2,9 +2,9 @@ import deploymentsLocal from '../../onchain/deployments-local.json'; const env = import.meta.env; -const LOCAL_PONDER_URL = env.VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK ?? 'http://127.0.0.1:42069/graphql'; -const LOCAL_TXNBOT_URL = env.VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK ?? 'http://127.0.0.1:43069'; -const LOCAL_RPC_URL = env.VITE_LOCAL_RPC_URL ?? '/rpc/anvil'; +const LOCAL_PONDER_URL = env.VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK ?? '/api/graphql'; +const LOCAL_TXNBOT_URL = env.VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK ?? '/api/txn'; +const LOCAL_RPC_URL = env.VITE_LOCAL_RPC_URL ?? '/api/rpc'; interface DeploymentContracts { Kraiken?: string; diff --git a/web-app/src/contracts/harb.ts b/web-app/src/contracts/harb.ts index 5501f51..9da35f2 100644 --- a/web-app/src/contracts/harb.ts +++ b/web-app/src/contracts/harb.ts @@ -7,8 +7,7 @@ import { type Abi, type Address, type Hash } from 'viem'; import { StakeContract } from '@/contracts/stake'; import { getChain } from '@/config'; import logger from '@/utils/logger'; -// const chain1 = useChain(); -// console.log("chain1", chain1); +import { getWalletPublicClient } from '@/services/walletRpc'; interface Contract { abi: Abi; @@ -36,16 +35,9 @@ function getHarbJson() { } export function setHarbContract() { - // console.log("setHarbContract"); - HarbContract = getHarbJson(); } -// watch(chainData, async (newQuestion, oldQuestion) => { -// console.log("log harb update"); - -// }); - export async function getAllowance() { logger.contract('getAllowance'); @@ -53,12 +45,18 @@ export async function getAllowance() { if (!account.address) { return 0n; } - const result = await readContract(config as Config, { + + const publicClient = getWalletPublicClient(); + if (!publicClient) { + throw new Error('Wallet public client unavailable'); + } + + const result = (await publicClient.readContract({ abi: HarbContract.abi, address: HarbContract.contractAddress, functionName: 'allowance', args: [account.address, StakeContract.contractAddress], - }); + })) as bigint; allowance.value = result; return result; } @@ -66,6 +64,18 @@ export async function getAllowance() { export async function getMinStake() { logger.contract('getMinStake'); + const publicClient = getWalletPublicClient(); + if (publicClient) { + const result = (await publicClient.readContract({ + abi: HarbContract.abi, + address: HarbContract.contractAddress, + functionName: 'minStake', + args: [], + })) as bigint; + allowance.value = result; + return result; + } + const result: bigint = (await readContract(config as Config, { abi: HarbContract.abi, address: HarbContract.contractAddress, @@ -83,14 +93,19 @@ export async function getNonce() { if (!account.address) { return 0n; } - // console.log("HarbContract.contractAddress", HarbContract.contractAddress); + // console.log('HarbContract.contractAddress', HarbContract.contractAddress); - const result = await readContract(config as Config, { + const publicClient = getWalletPublicClient(); + if (!publicClient) { + throw new Error('Wallet public client unavailable'); + } + + const result = (await publicClient.readContract({ abi: HarbContract.abi, address: HarbContract.contractAddress, functionName: 'nonces', args: [account.address], - }); + })) as bigint; nonce.value = result; return result; @@ -99,15 +114,19 @@ export async function getNonce() { export async function getName() { logger.contract('getName'); - const result = await readContract(config as Config, { + const publicClient = getWalletPublicClient(); + if (!publicClient) { + throw new Error('Wallet public client unavailable'); + } + const result = (await publicClient.readContract({ abi: HarbContract.abi, address: HarbContract.contractAddress, functionName: 'name', args: [], - }); + })) as string; name.value = result; - return result as string; + return result; } export async function approve(amount: bigint): Promise { diff --git a/web-app/src/services/chainConfig.ts b/web-app/src/services/chainConfig.ts new file mode 100644 index 0000000..9766219 --- /dev/null +++ b/web-app/src/services/chainConfig.ts @@ -0,0 +1,38 @@ +import { getChain } from '@/config'; + +type EndpointType = 'graphql' | 'rpc' | 'txnBot'; + +class ChainConfigService { + getEndpoint(chainId: number, type: EndpointType): string { + const chain = getChain(chainId); + if (!chain) { + throw new Error(`Chain ${chainId} is not configured.`); + } + + switch (type) { + case 'graphql': + return this.ensureEndpoint(chain.graphql, chainId, 'GraphQL'); + case 'rpc': + return this.ensureEndpoint(chain.cheats?.rpc, chainId, 'RPC'); + case 'txnBot': + return this.ensureEndpoint(chain.cheats?.txnBot, chainId, 'txnBot'); + default: + // Exhaustiveness check + assertNever(type); + } + } + + private ensureEndpoint(value: string | null | undefined, chainId: number, label: string): string { + const normalized = value?.trim(); + if (!normalized) { + throw new Error(`${label} endpoint not configured for chain ${chainId}.`); + } + return normalized; + } +} + +function assertNever(_x: never): never { + throw new Error('Unsupported endpoint type'); +} + +export const chainConfigService = new ChainConfigService(); diff --git a/web-app/src/services/walletRpc.ts b/web-app/src/services/walletRpc.ts new file mode 100644 index 0000000..7b04827 --- /dev/null +++ b/web-app/src/services/walletRpc.ts @@ -0,0 +1,11 @@ +import type { PublicClient } from 'viem'; + +let currentPublicClient: PublicClient | null = null; + +export function setWalletPublicClient(client: PublicClient | null): void { + currentPublicClient = client; +} + +export function getWalletPublicClient(): PublicClient | null { + return currentPublicClient; +} diff --git a/web-app/src/utils/graphqlRetry.ts b/web-app/src/utils/graphqlRetry.ts new file mode 100644 index 0000000..780e196 --- /dev/null +++ b/web-app/src/utils/graphqlRetry.ts @@ -0,0 +1,107 @@ +import { ref, type Ref } from 'vue'; +import axios from 'axios'; +import { chainConfigService } from '@/services/chainConfig'; + +const RETRY_BASE_DELAY_MS = 1_500; +const RETRY_MAX_DELAY_MS = 60_000; + +export interface RetryManager { + schedule: () => void; + clear: () => void; + reset: () => void; + retryDelayMs: Ref; +} + +/** + * Creates a retry manager with exponential backoff for async operations. + * + * @param loadFn - The async function to retry (receives activeChainId as argument) + * @param activeChainId - Ref containing the current chain ID to pass to loadFn + * @param baseDelay - Initial retry delay in milliseconds (default: 1500ms) + * @param maxDelay - Maximum retry delay in milliseconds (default: 60000ms) + * @returns RetryManager with schedule, clear, and reset methods + * + * @example + * const retry = createRetryManager(loadPositions, activeChainId); + * try { + * await loadPositions(chainId); + * retry.reset(); // Reset on success + * } catch (error) { + * retry.schedule(); // Schedule retry with backoff + * } + */ +export function createRetryManager( + loadFn: (chainId: number) => Promise, + activeChainId: Ref, + baseDelay = RETRY_BASE_DELAY_MS, + maxDelay = RETRY_MAX_DELAY_MS +): RetryManager { + const retryDelayMs = ref(baseDelay); + let retryTimer: number | null = null; + + function clear() { + if (retryTimer !== null) { + clearTimeout(retryTimer); + retryTimer = null; + } + } + + function schedule() { + if (typeof window === 'undefined' || retryTimer !== null) { + return; + } + const delay = retryDelayMs.value; + retryTimer = window.setTimeout(async () => { + retryTimer = null; + await loadFn(activeChainId.value); + }, delay); + retryDelayMs.value = Math.min(retryDelayMs.value * 2, maxDelay); + } + + function reset() { + retryDelayMs.value = baseDelay; + } + + return { schedule, clear, reset, retryDelayMs }; +} + +/** + * Formats GraphQL/Axios errors into user-friendly messages. + * + * @param error - The error object from axios or other source + * @returns Formatted error message string + */ +export function formatGraphqlError(error: unknown): string { + if (axios.isAxiosError(error)) { + const responseErrors = (error.response?.data as { errors?: unknown[] })?.errors; + if (Array.isArray(responseErrors) && responseErrors.length > 0) { + return responseErrors.map((err: unknown) => (err as { message?: string })?.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'; +} + +/** + * Resolves a GraphQL endpoint URL for a given chain ID. + * + * @param chainId - The blockchain network ID + * @param endpointOverride - Optional endpoint URL to use instead of default + * @returns The GraphQL endpoint URL + * @throws Error if endpoint is not configured for the chain + */ +export function resolveGraphqlEndpoint(chainId: number, endpointOverride?: string): string { + const normalized = endpointOverride?.trim(); + if (normalized) { + return normalized; + } + return chainConfigService.getEndpoint(chainId, 'graphql'); +} diff --git a/web-app/src/views/StakeView.vue b/web-app/src/views/StakeView.vue index 7f9cade..1722eb3 100644 --- a/web-app/src/views/StakeView.vue +++ b/web-app/src/views/StakeView.vue @@ -81,10 +81,11 @@ const { status } = useAccount(); const showPanel = inject('showPanel'); import { InsertCommaNumber } from '@/utils/helper'; -const { myActivePositions, tresholdValue, activePositions } = usePositions(); - -const stats = useStatCollection(); const wallet = useWallet(); +const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID; +const { myActivePositions, tresholdValue, activePositions } = usePositions(initialChainId); + +const stats = useStatCollection(initialChainId); const chains = useChains(); function calculateAverageTaxRate(data: Array<{ taxRate: number | string }>): number { diff --git a/web-app/src/wagmi.ts b/web-app/src/wagmi.ts index 9b6cb5f..64c6f9d 100644 --- a/web-app/src/wagmi.ts +++ b/web-app/src/wagmi.ts @@ -3,9 +3,9 @@ import { baseSepolia } from '@wagmi/vue/chains'; 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 LOCAL_RPC_URL = import.meta.env.VITE_LOCAL_RPC_URL ?? '/api/rpc'; -const kraikenLocalFork = defineChain({ +export const KRAIKEN_LOCAL_CHAIN = defineChain({ id: 31337, name: 'Kraiken Local Fork', network: 'kraiken-local', @@ -21,7 +21,7 @@ const kraikenLocalFork = defineChain({ }); export const config = createConfig({ - chains: [kraikenLocalFork, baseSepolia], + chains: [KRAIKEN_LOCAL_CHAIN, baseSepolia], storage: createStorage({ storage: window.localStorage }), connectors: [ @@ -44,11 +44,11 @@ export const config = createConfig({ }), ], transports: { - [kraikenLocalFork.id]: http(LOCAL_RPC_URL), + [KRAIKEN_LOCAL_CHAIN.id]: http(LOCAL_RPC_URL), [baseSepolia.id]: http(), }, }); -if (typeof window !== 'undefined' && config.state.chainId !== kraikenLocalFork.id) { - config.setState(state => ({ ...state, chainId: kraikenLocalFork.id })); +if (typeof window !== 'undefined' && config.state.chainId !== KRAIKEN_LOCAL_CHAIN.id) { + config.setState(state => ({ ...state, chainId: KRAIKEN_LOCAL_CHAIN.id })); } diff --git a/web-app/vite.config.ts b/web-app/vite.config.ts index 55ddfb0..3236c4f 100644 --- a/web-app/vite.config.ts +++ b/web-app/vite.config.ts @@ -7,6 +7,8 @@ import vueDevTools from 'vite-plugin-vue-devtools' // https://vite.dev/config/ export default defineConfig(() => { const localRpcProxyTarget = process.env.VITE_LOCAL_RPC_PROXY_TARGET + const localGraphqlProxyTarget = process.env.VITE_LOCAL_GRAPHQL_PROXY_TARGET ?? 'http://127.0.0.1:42069' + const localTxnProxyTarget = process.env.VITE_LOCAL_TXN_PROXY_TARGET ?? 'http://127.0.0.1:43069' return { // base: "/HarbergPublic/", @@ -21,19 +23,47 @@ export default defineConfig(() => { }, }, 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, + proxy: + localRpcProxyTarget || localGraphqlProxyTarget || localTxnProxyTarget + ? { + ...(localRpcProxyTarget + ? { + '/api/rpc': { + target: localRpcProxyTarget, + changeOrigin: true, + secure: false, + rewrite: (path: string) => { + const rewritten = path.replace(/^\/api\/rpc/, '') + return rewritten.length === 0 ? '/' : rewritten + }, + }, + } + : {}), + ...(localGraphqlProxyTarget + ? { + '/api/graphql': { + target: localGraphqlProxyTarget, + changeOrigin: true, + secure: false, + rewrite: (path: string) => path.replace(/^\/api\/graphql/, '/graphql'), + }, + } + : {}), + ...(localTxnProxyTarget + ? { + '/api/txn': { + target: localTxnProxyTarget, + changeOrigin: true, + secure: false, + rewrite: (path: string) => { + const rewritten = path.replace(/^\/api\/txn/, '') + return rewritten.length === 0 ? '/' : rewritten + }, + }, + } + : {}), + } + : undefined, }, } })