From 0933c01d49ce50adcd0099d4b89ba0af81aa8aac Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:13:12 +0100 Subject: [PATCH 1/8] feat(H1): improve RPC failover with health tracking, timeout cleanup, and logging --- backend/src/providers/chain.provider.ts | 229 +++++++++++++++++++----- 1 file changed, 183 insertions(+), 46 deletions(-) diff --git a/backend/src/providers/chain.provider.ts b/backend/src/providers/chain.provider.ts index 04299cc..a63213d 100644 --- a/backend/src/providers/chain.provider.ts +++ b/backend/src/providers/chain.provider.ts @@ -1,92 +1,225 @@ import { ethers } from "ethers"; import { getChainConfig } from "../config/chains"; +// ────────────────────────────────────────────────────────────────── +// STATIC NETWORK DEFINITIONS +// Pre-built Network objects avoid an extra RPC call to eth_chainId +// on every new provider. ethers normally auto-detects the network, +// but that adds latency and can fail on flaky RPCs. +// ────────────────────────────────────────────────────────────────── const networks: Record = { base: ethers.Network.from(8453), avalanche: ethers.Network.from(43114), }; +// ────────────────────────────────────────────────────────────────── +// PROVIDER SINGLETON CACHE +// One provider per chain, created once and reused for all requests. +// This avoids reconnecting on every call and keeps connection pools alive. +// ────────────────────────────────────────────────────────────────── const providers: Record = {}; -/** - * Creates a provider with sequential failover across multiple RPC endpoints. - * Unlike ethers FallbackProvider (which requires quorum/consensus), - * this uses the primary RPC and only falls back on failure. - */ +// ────────────────────────────────────────────────────────────────── +// RPC HEALTH TRACKING +// Tracks consecutive failures per RPC URL. If primary has failed +// recently, we start with fallbacks immediately instead of wasting +// 3.5s on a known-bad endpoint. +// ────────────────────────────────────────────────────────────────── +interface RpcHealth { + consecutiveFailures: number; + lastFailureAt: number; // Date.now() timestamp +} + +const rpcHealthMap = new Map(); + +/** How long a "sick" RPC stays deprioritized before we retry it. */ +const HEALTH_RECOVERY_MS = 30_000; // 30 seconds + +function markRpcFailed(url: string): void { + const health = rpcHealthMap.get(url) ?? { consecutiveFailures: 0, lastFailureAt: 0 }; + health.consecutiveFailures++; + health.lastFailureAt = Date.now(); + rpcHealthMap.set(url, health); +} + +function markRpcHealthy(url: string): void { + rpcHealthMap.delete(url); // healthy = no entry +} + +/** Returns true if this RPC has been failing and hasn't recovered yet. */ +function isRpcSick(url: string): boolean { + const health = rpcHealthMap.get(url); + if (!health) return false; + // After HEALTH_RECOVERY_MS, give the RPC another chance + if (Date.now() - health.lastFailureAt > HEALTH_RECOVERY_MS) { + rpcHealthMap.delete(url); + return false; + } + return health.consecutiveFailures >= 2; +} + +// ────────────────────────────────────────────────────────────────── +// TIMEOUT UTILITY +// Wraps a promise with a deadline. Unlike a naive Promise.race with +// setTimeout, this version CLEARS the timer on success so we don't +// leak timers that keep Node.js alive unnecessarily. +// ────────────────────────────────────────────────────────────────── +const RPC_TIMEOUT_MS = 3_500; + +function withTimeout(promise: Promise, ms: number = RPC_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + reject(new Error("rpc_call_timeout")); + } + }, ms); + + promise.then( + (value) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(value); + } + }, + (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(err); + } + } + ); + }); +} + +// ────────────────────────────────────────────────────────────────── +// PROVIDER FACTORY +// Creates a JsonRpcProvider for a chain. If multiple RPC URLs are +// configured, wraps the provider's .send() method with: +// 1. Timeout (3.5s) on the primary RPC +// 2. Health check — skip primary if it's been failing +// 3. Parallel fallback race — all fallbacks fire at once, first wins +// 4. Health tracking — mark RPCs as healthy/sick after each call +// 5. Logging — which RPC served each request +// ────────────────────────────────────────────────────────────────── function createProvider(chain: string): ethers.JsonRpcProvider { const config = getChainConfig(chain); const network = networks[chain]; - // batchMaxCount: 1 — free RPCs reject large batches ("maximum 10 calls in 1 batch") + // batchMaxCount: 1 — free/public RPCs reject batched JSON-RPC calls + // ("maximum 10 calls in 1 batch" error). Disabling batching sends + // each call individually, which is slower but compatible with all RPCs. const opts = network ? { staticNetwork: network, batchMaxCount: 1 } : { batchMaxCount: 1 }; - const primary = new ethers.JsonRpcProvider(config.rpcUrls[0], network, opts); + const allUrls = config.rpcUrls; + const primary = new ethers.JsonRpcProvider(allUrls[0], network, opts); - if (config.rpcUrls.length <= 1) { + // Single RPC — no failover needed, return as-is + if (allUrls.length <= 1) { + console.log(`[ChainProvider] ${chain}: 1 RPC endpoint (no failover)`); return primary; } - // Build fallback providers (lazy — only created on first failure) - const fallbackUrls = config.rpcUrls.slice(1); + // ── Multiple RPCs: build failover wrapper ── + + // Fallback providers are created LAZILY on first failure. + // This avoids opening connections we may never need. + const fallbackUrls = allUrls.slice(1); let fallbackProviders: ethers.JsonRpcProvider[] | null = null; function getFallbacks(): ethers.JsonRpcProvider[] { if (!fallbackProviders) { fallbackProviders = fallbackUrls.map( - (url) => new ethers.JsonRpcProvider(url, network, opts) + (url: string) => new ethers.JsonRpcProvider(url, network, opts) ); } return fallbackProviders; } - console.log(`[ChainProvider] ${chain}: ${config.rpcUrls.length} RPC endpoints configured (failover enabled)`); + console.log( + `[ChainProvider] ${chain}: ${allUrls.length} RPC endpoints configured — ` + + `primary: ${allUrls[0].replace(/^https?:\/\//, "").split("/")[0]}, ` + + `fallbacks: ${fallbackUrls.map(u => u.replace(/^https?:\/\//, "").split("/")[0]).join(", ")}` + ); - // Per-RPC timeout — free RPCs often hang instead of failing cleanly. - // Without this, a single slow RPC blocks the entire failover chain. - const RPC_TIMEOUT_MS = 3500; + // ── Override .send() with failover logic ── + // Every JSON-RPC call (eth_call, eth_getBalance, etc.) goes through + // provider.send(). By wrapping it, we intercept at the lowest level. + const originalSend = primary.send.bind(primary); - function withTimeout(promise: Promise, ms: number): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("rpc_call_timeout")), ms) - ), - ]); - } + primary.send = async function failoverSend(method: string, params: unknown[]): Promise { + const primaryUrl = allUrls[0]; - // Wrap the primary provider's send method with timeout + parallel fallback. - // Strategy: fire primary first; on failure, race all fallbacks simultaneously. - const originalSend = primary.send.bind(primary); - primary.send = async function failoverSend(method: string, params: any[]): Promise { - try { - return await withTimeout(originalSend(method, params), RPC_TIMEOUT_MS); - } catch (primaryError) { - // Primary failed/timed out — race all fallbacks in parallel (fastest wins) - const fallbacks = getFallbacks(); - if (fallbacks.length === 0) throw primaryError; - - // Race all fallbacks — first to succeed wins. If all reject, throw original. + // ── Step 1: Try primary (unless it's been consistently failing) ── + if (!isRpcSick(primaryUrl)) { try { - return await new Promise((resolve, reject) => { - let remaining = fallbacks.length; - for (const fb of fallbacks) { - withTimeout(fb.send(method, params), RPC_TIMEOUT_MS).then( - resolve, - () => { if (--remaining === 0) reject(primaryError); } - ); - } - }); - } catch { - throw primaryError; + const result = await withTimeout(originalSend(method, params)); + markRpcHealthy(primaryUrl); + return result; + } catch (err) { + markRpcFailed(primaryUrl); + console.warn( + `[ChainProvider] ${chain} primary RPC failed (${method}): ` + + `${err instanceof Error ? err.message : "unknown"}` + ); } + } else { + console.warn(`[ChainProvider] ${chain} primary RPC is sick — skipping to fallbacks`); + } + + // ── Step 2: Race all fallbacks in parallel ── + // Fire all at once. First successful response wins. + // This is faster than trying sequentially (3.5s * N worst case). + const fallbacks = getFallbacks(); + if (fallbacks.length === 0) { + throw new Error(`[ChainProvider] ${chain}: all RPCs exhausted`); } + + return new Promise((resolve, reject) => { + let remaining = fallbacks.length; + let resolved = false; + + fallbacks.forEach((fb, idx) => { + const fbUrl = fallbackUrls[idx]; + withTimeout(fb.send(method, params)).then( + (result) => { + if (!resolved) { + resolved = true; + markRpcHealthy(fbUrl); + console.log(`[ChainProvider] ${chain} fallback #${idx + 1} succeeded (${method})`); + resolve(result); + } + }, + (err) => { + markRpcFailed(fbUrl); + remaining--; + if (remaining === 0 && !resolved) { + reject(new Error( + `[ChainProvider] ${chain}: all ${allUrls.length} RPCs failed for ${method}` + )); + } + } + ); + }); + }); }; return primary; } +// ────────────────────────────────────────────────────────────────── +// PUBLIC API +// ────────────────────────────────────────────────────────────────── + +/** + * Returns the singleton provider for a chain. + * First call creates and caches the provider; subsequent calls reuse it. + */ export function getProvider(chain: string): ethers.JsonRpcProvider { if (!providers[chain]) { providers[chain] = createProvider(chain); @@ -94,6 +227,10 @@ export function getProvider(chain: string): ethers.JsonRpcProvider { return providers[chain]; } +/** + * Creates a read-only Contract instance connected to the chain's provider. + * No signer attached — these contracts can only read, not send transactions. + */ export function getContract( address: string, abi: readonly string[], From 33b57925a3ebca5705632de087185d3c7d517e86 Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:56:42 +0100 Subject: [PATCH 2/8] fix: add missing test mocks for withTimeout, getReserves, token0 --- backend/src/__tests__/integration/routes.test.ts | 7 ++++++- .../liquid-staking/prepare-enter-strategy.test.ts | 1 + .../modules/liquid-staking/prepare-exit-strategy.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/src/__tests__/integration/routes.test.ts b/backend/src/__tests__/integration/routes.test.ts index 35405b4..38ffff2 100644 --- a/backend/src/__tests__/integration/routes.test.ts +++ b/backend/src/__tests__/integration/routes.test.ts @@ -66,7 +66,12 @@ vi.mock("../../modules/liquid-staking/config/staking-pools", () => ({ // Prevent real chain calls from getContract vi.mock("../../providers/chain.provider", () => ({ - getContract: vi.fn(() => ({ balanceOf: mockBalanceOf })), + getContract: vi.fn(() => ({ + balanceOf: mockBalanceOf, + getReserves: vi.fn().mockResolvedValue([1_000_000n, 2_000_000n, 0n]), + totalSupply: vi.fn().mockResolvedValue(10_000n), + token0: vi.fn().mockResolvedValue("0x4200000000000000000000000000000000000006"), + })), })); // ── Imports after mocks ─────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/modules/liquid-staking/prepare-enter-strategy.test.ts b/backend/src/__tests__/modules/liquid-staking/prepare-enter-strategy.test.ts index a3bcbeb..0fa678d 100644 --- a/backend/src/__tests__/modules/liquid-staking/prepare-enter-strategy.test.ts +++ b/backend/src/__tests__/modules/liquid-staking/prepare-enter-strategy.test.ts @@ -21,6 +21,7 @@ vi.mock("../../../shared/services/aerodrome.service", () => ({ checkAllowance: vi.fn(), quoteAddLiquidity: vi.fn(), withRetry: vi.fn((fn: () => Promise) => fn()), + withTimeout: vi.fn((fn: () => Promise) => fn()), }, })); diff --git a/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts b/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts index f5d2a4d..086ecc1 100644 --- a/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts +++ b/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts @@ -24,6 +24,15 @@ vi.mock("../../../shared/services/aerodrome.service", () => ({ }, })); +vi.mock("../../../providers/chain.provider", () => ({ + getContract: vi.fn(() => ({ + balanceOf: vi.fn().mockResolvedValue(10_000n), + getReserves: vi.fn().mockResolvedValue([1_000_000n, 2_000_000n, 0n]), + totalSupply: vi.fn().mockResolvedValue(10_000n), + token0: vi.fn().mockResolvedValue("0x4200000000000000000000000000000000000006"), + })), +})); + vi.mock("../../../modules/liquid-staking/config/staking-pools", () => ({ getStakingPoolById: vi.fn((id: string) => { if (id === "weth-usdc-volatile") { From b43533e32c40883df17757d609c79833601a68e4 Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:13:59 +0100 Subject: [PATCH 3/8] feat(H2): register serialize-by-user middleware with queue overflow protection --- backend/src/index.ts | 2 + backend/src/middleware/serialize-by-user.ts | 104 +++++++++++++++----- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 23d22c0..4b58ece 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ import { avaxLendingRoutes } from "./modules/avax-lending/routes/avax-le import { avaxLiquidStakingRoutes } from "./modules/avax-liquid-staking/routes/avax-liquid-staking.routes"; import { errorHandler } from "./middleware/errorHandler"; import { rateLimiter } from "./middleware/rateLimiter"; +import { serializeByUser } from "./middleware/serialize-by-user"; const app = express(); const PORT = process.env.PORT || 3010; @@ -42,6 +43,7 @@ app.use(cors({ })); app.use(express.json({ limit: "1mb" })); app.use(rateLimiter); +app.use(serializeByUser); // Swagger only in non-production environments if (process.env.NODE_ENV !== "production") { diff --git a/backend/src/middleware/serialize-by-user.ts b/backend/src/middleware/serialize-by-user.ts index f84a8ac..ccc9857 100644 --- a/backend/src/middleware/serialize-by-user.ts +++ b/backend/src/middleware/serialize-by-user.ts @@ -1,19 +1,35 @@ import { Request, Response, NextFunction } from "express"; +// ────────────────────────────────────────────────────────────────── +// CONSTANTS +// ────────────────────────────────────────────────────────────────── + +/** How long a queued request waits before being force-released (prevents deadlocks). */ const QUEUE_TIMEOUT_MS = 30_000; + +/** Max concurrent queued requests per user. Beyond this, new requests are rejected with 429. */ const MAX_QUEUE_SIZE = 10; -/** - * Per-user request serialization middleware. - * - * Ensures that concurrent requests from the same wallet address are processed - * sequentially, preventing RPC call bursts from a single user. - * - * User is identified by: req.verifiedAddress (set by auth middleware), - * req.body.userAddress, req.query.userAddress, or req.params.userAddress. - */ +// ────────────────────────────────────────────────────────────────── +// STATE +// ────────────────────────────────────────────────────────────────── + +/** Maps userKey → the tail of the serialization chain (a resolved promise = idle). */ const locks = new Map>(); +/** Tracks how many requests are currently queued per user. */ +const queueDepth = new Map(); + +// ────────────────────────────────────────────────────────────────── +// USER IDENTIFICATION +// Extracts the wallet address from the request to use as serialization key. +// Checks multiple sources in priority order: +// 1. req.verifiedAddress — set by auth middleware (most trusted) +// 2. req.body.userAddress — from POST body +// 3. req.query.userAddress — from query string +// 4. req.params.userAddress — from URL params +// Returns null if no user can be identified (health checks, public routes). +// ────────────────────────────────────────────────────────────────── function getUserKey(req: Request): string | null { const addr = (req as any).verifiedAddress || @@ -24,34 +40,65 @@ function getUserKey(req: Request): string | null { return addr ? String(addr).toLowerCase() : null; } +// ────────────────────────────────────────────────────────────────── +// MIDDLEWARE +// +// Ensures that concurrent state-changing requests from the same +// wallet address are processed one at a time. This prevents: +// - RPC call bursts that overwhelm free RPC endpoints +// - Race conditions in adapter state (e.g. nonce conflicts) +// - Double-execution of the same transaction bundle +// +// Requests are chained via Promises: each new request waits for +// the previous one from the same user to complete before proceeding. +// ────────────────────────────────────────────────────────────────── export function serializeByUser(req: Request, res: Response, next: NextFunction): void { - // Only serialize state-changing requests (POST/PUT/PATCH/DELETE). - // GET/HEAD are idempotent reads — they must NOT block writes. + // ── Bypass 1: Idempotent methods ── + // GET/HEAD/OPTIONS are read-only. They must NOT block writes, + // and writes must NOT block reads. Skip serialization entirely. if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") { next(); return; } - // Skip serialization for prepare-* routes — they are idempotent read-only - // queries that build unsigned tx bundles. The frontend fires multiple prewarm - // POSTs on amount change; serializing them causes the user's final request - // to queue behind all prewarms, compounding latency past the 30s timeout. + // ── Bypass 2: prepare-* routes ── + // These POSTs are idempotent read-only queries that build unsigned + // transaction bundles. The frontend fires multiple prewarm POSTs + // on amount change; serializing them causes the user's final request + // to queue behind all prewarms, compounding latency past 30s timeout. if (req.path.includes("/prepare-")) { next(); return; } + // ── No user identified → pass through ── const userKey = getUserKey(req); - - // No user identified — pass through (health checks, public routes) if (!userKey) { next(); return; } + // ── Queue overflow protection ── + // If a user already has MAX_QUEUE_SIZE requests queued, reject + // immediately instead of letting the queue grow unbounded. + const currentDepth = queueDepth.get(userKey) ?? 0; + if (currentDepth >= MAX_QUEUE_SIZE) { + console.warn(`[serialize] rejecting request for ${userKey.slice(0, 10)}… — queue full (${currentDepth})`); + res.status(429).json({ + error: { + code: "QUEUE_FULL", + message: "Too many pending requests. Please wait for current operations to complete.", + }, + }); + return; + } + + // ── Chain this request after the previous one ── const prev = locks.get(userKey) ?? Promise.resolve(); + queueDepth.set(userKey, currentDepth + 1); - // Create a deferred so we can control when this request's slot is released + // Create a deferred promise that we resolve when this request finishes. + // The NEXT request from this user will await this promise. let releaseLock: () => void; const currentLock = new Promise((resolve) => { releaseLock = resolve; @@ -59,16 +106,29 @@ export function serializeByUser(req: Request, res: Response, next: NextFunction) locks.set(userKey, currentLock); - // Timeout to prevent deadlocks from stuck requests + // ── Deadlock prevention ── + // If a request handler never sends a response (hangs), the timeout + // force-releases the lock so subsequent requests aren't stuck forever. const timeout = setTimeout(() => { releaseLock!(); }, QUEUE_TIMEOUT_MS); - // Release the lock when the response finishes (or closes prematurely) + // ── Release on response completion ── + // "finish" fires when the response is fully sent. + // "close" fires if the client disconnects prematurely. const release = () => { clearTimeout(timeout); releaseLock!(); - // Clean up if this is the last pending request for the user + + // Decrement queue depth + const depth = (queueDepth.get(userKey) ?? 1) - 1; + if (depth <= 0) { + queueDepth.delete(userKey); + } else { + queueDepth.set(userKey, depth); + } + + // Clean up lock map if this is the last pending request if (locks.get(userKey) === currentLock) { locks.delete(userKey); } @@ -77,6 +137,6 @@ export function serializeByUser(req: Request, res: Response, next: NextFunction) res.once("finish", release); res.once("close", release); - // Wait for previous request from same user to complete + // Wait for previous request from same user, then proceed prev.then(() => next()); } From 58208ee5b42d68968a4a00bf1e1570166ee031c4 Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:30:02 +0100 Subject: [PATCH 4/8] feat(H3): add execution timeout middleware to all prepare routes Adds executionTimeout() route-level middleware that enforces a 15s hard deadline on prepare/quote handlers. If the handler doesn't respond within the timeout, a 504 EXECUTION_TIMEOUT is sent. Applied to 17 endpoints across 6 route files: - swap: /quote, /prepare - staking: /prepare-enter, /prepare-exit, /prepare-claim - avax-swap: /quote, /prepare - avax-lending: /prepare-supply, /prepare-redeem, /prepare-borrow, /prepare-repay - avax-liquid-staking: /prepare-stake, /prepare-request-unlock, /prepare-redeem - dca: /prepare-create, /prepare-cancel Prevents silent hangs when RPCs are congested and never respond. --- backend/src/middleware/execution-timeout.ts | 71 +++++++++++++++++++ .../routes/avax-lending.routes.ts | 5 ++ .../routes/avax-liquid-staking.routes.ts | 4 ++ .../avax-swap/routes/avax-swap.routes.ts | 3 + backend/src/modules/dca/routes/dca.routes.ts | 3 + .../liquid-staking/routes/staking.routes.ts | 4 ++ .../src/modules/swap/routes/swap.routes.ts | 3 + 7 files changed, 93 insertions(+) create mode 100644 backend/src/middleware/execution-timeout.ts diff --git a/backend/src/middleware/execution-timeout.ts b/backend/src/middleware/execution-timeout.ts new file mode 100644 index 0000000..655f050 --- /dev/null +++ b/backend/src/middleware/execution-timeout.ts @@ -0,0 +1,71 @@ +import { Request, Response, NextFunction } from "express"; + +// ────────────────────────────────────────────────────────────────── +// EXECUTION TIMEOUT MIDDLEWARE +// +// Wraps any route handler with a hard deadline. If the handler doesn't +// send a response within the timeout, we send a 504 Gateway Timeout +// and prevent the handler from writing to the response afterwards. +// +// This prevents: +// - Silent hangs when RPCs are congested and never respond +// - Partial state from half-completed flows +// - Frontend retries stacking up behind a stuck request +// +// Usage in routes: +// router.post("/prepare", executionTimeout(15_000), asyncHandler(handler)) +// ────────────────────────────────────────────────────────────────── + +const DEFAULT_TIMEOUT_MS = 15_000; + +/** + * Returns a middleware that enforces a response deadline. + * + * @param ms Maximum time in milliseconds before 504 is sent. + * Defaults to 15 seconds. + */ +export function executionTimeout(ms: number = DEFAULT_TIMEOUT_MS) { + return (req: Request, res: Response, next: NextFunction): void => { + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + + // Only send 504 if the response hasn't started yet. + // If headers are already sent, we can't change the status code. + if (!res.headersSent) { + console.error( + `[timeout] ${req.method} ${req.path} exceeded ${ms}ms — sending 504` + ); + res.status(504).json({ + error: { + code: "EXECUTION_TIMEOUT", + message: `Request timed out after ${ms / 1000}s. RPC nodes may be congested — please try again.`, + }, + }); + } + }, ms); + + // Clear the timer when the response finishes normally + res.once("finish", () => clearTimeout(timer)); + res.once("close", () => clearTimeout(timer)); + + // Patch res.json and res.send to no-op after timeout. + // This prevents the late-arriving handler from crashing + // with "Cannot set headers after they are sent to the client". + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + + res.json = function guardedJson(body?: any) { + if (timedOut) return res; + return originalJson(body); + } as any; + + res.send = function guardedSend(body?: any) { + if (timedOut) return res; + return originalSend(body); + } as any; + + next(); + }; +} diff --git a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts index d9195a8..65a8daa 100644 --- a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts +++ b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { validateAddress, validateAmount, validateRequired } from "../../../middleware/validation"; +import { executionTimeout } from "../../../middleware/execution-timeout"; import * as ctrl from "../controllers/avax-lending.controller"; export const avaxLendingRoutes = Router(); @@ -33,6 +34,7 @@ avaxLendingRoutes.post( validateAddress("userAddress"), validateAddress("qTokenAddress"), validateAmount("amount"), + executionTimeout(), ctrl.prepareSupply ); @@ -49,6 +51,7 @@ avaxLendingRoutes.post( validateAddress("userAddress"), validateAddress("qTokenAddress"), validateAmount("amount"), + executionTimeout(), ctrl.prepareRedeem ); @@ -66,6 +69,7 @@ avaxLendingRoutes.post( validateAddress("userAddress"), validateAddress("qTokenAddress"), validateAmount("amount"), + executionTimeout(), ctrl.prepareBorrow ); @@ -82,5 +86,6 @@ avaxLendingRoutes.post( validateAddress("userAddress"), validateAddress("qTokenAddress"), validateAmount("amount"), + executionTimeout(), ctrl.prepareRepay ); diff --git a/backend/src/modules/avax-liquid-staking/routes/avax-liquid-staking.routes.ts b/backend/src/modules/avax-liquid-staking/routes/avax-liquid-staking.routes.ts index ceb6f7d..296a4f9 100644 --- a/backend/src/modules/avax-liquid-staking/routes/avax-liquid-staking.routes.ts +++ b/backend/src/modules/avax-liquid-staking/routes/avax-liquid-staking.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { validateAddress, validateAmount, validateRequired } from "../../../middleware/validation"; +import { executionTimeout } from "../../../middleware/execution-timeout"; import * as ctrl from "../controllers/avax-liquid-staking.controller"; export const avaxLiquidStakingRoutes = Router(); @@ -26,6 +27,7 @@ avaxLiquidStakingRoutes.post( validateRequired("userAddress", "amount"), validateAddress("userAddress"), validateAmount("amount"), + executionTimeout(), ctrl.prepareStake ); @@ -41,6 +43,7 @@ avaxLiquidStakingRoutes.post( validateRequired("userAddress", "sAvaxAmount"), validateAddress("userAddress"), validateAmount("sAvaxAmount"), + executionTimeout(), ctrl.prepareRequestUnlock ); @@ -55,5 +58,6 @@ avaxLiquidStakingRoutes.post( "/prepare-redeem", validateRequired("userAddress", "userUnlockIndex"), validateAddress("userAddress"), + executionTimeout(), ctrl.prepareRedeem ); diff --git a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts index 43a39bc..ee60153 100644 --- a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts +++ b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { validateAddress, validateAmount, validateSlippage, validateRequired } from "../../../middleware/validation"; +import { executionTimeout } from "../../../middleware/execution-timeout"; import * as ctrl from "../controllers/avax-swap.controller"; export const avaxSwapRoutes = Router(); @@ -23,6 +24,7 @@ avaxSwapRoutes.post( validateAddress("tokenOut"), validateAmount("amountIn"), validateSlippage(), + executionTimeout(), ctrl.getQuote ); @@ -41,5 +43,6 @@ avaxSwapRoutes.post( validateAddress("tokenOut"), validateAmount("amountIn"), validateSlippage(), + executionTimeout(), ctrl.prepareSwap ); diff --git a/backend/src/modules/dca/routes/dca.routes.ts b/backend/src/modules/dca/routes/dca.routes.ts index be9bd70..6a0db02 100644 --- a/backend/src/modules/dca/routes/dca.routes.ts +++ b/backend/src/modules/dca/routes/dca.routes.ts @@ -12,6 +12,7 @@ import { import { asyncHandler } from "../../../middleware/errorHandler"; import { validateAddress, validateRequired, validateTxHash } from "../../../middleware/validation"; import { requireWalletAuth } from "../../../middleware/auth"; +import { executionTimeout } from "../../../middleware/execution-timeout"; export const dcaRoutes = Router(); @@ -19,12 +20,14 @@ export const dcaRoutes = Router(); dcaRoutes.post("/prepare-create", validateRequired("userAddress", "tokenIn", "tokenOut", "amountPerSwap", "intervalSeconds", "depositAmount"), validateAddress("userAddress"), + executionTimeout(), asyncHandler(prepareCreateOrder) ); dcaRoutes.post("/prepare-cancel", validateRequired("userAddress", "orderId"), validateAddress("userAddress"), + executionTimeout(), asyncHandler(prepareCancelOrder) ); diff --git a/backend/src/modules/liquid-staking/routes/staking.routes.ts b/backend/src/modules/liquid-staking/routes/staking.routes.ts index 35fc6ba..db734b3 100644 --- a/backend/src/modules/liquid-staking/routes/staking.routes.ts +++ b/backend/src/modules/liquid-staking/routes/staking.routes.ts @@ -14,6 +14,7 @@ import { import { asyncHandler } from "../../../middleware/errorHandler"; import { validateAddress, validateAmount, validateRequired, validateSlippage, validateTxHash } from "../../../middleware/validation"; import { requireWalletAuth } from "../../../middleware/auth"; +import { executionTimeout } from "../../../middleware/execution-timeout"; export const stakingRoutes = Router(); @@ -42,6 +43,7 @@ stakingRoutes.post("/prepare-enter", validateAmount("amountA"), validateAmount("amountB"), validateSlippage(), + executionTimeout(), asyncHandler(prepareEnterStrategy) ); @@ -49,12 +51,14 @@ stakingRoutes.post("/prepare-exit", validateRequired("userAddress", "poolId"), validateAddress("userAddress"), validateSlippage(), + executionTimeout(), asyncHandler(prepareExitStrategy) ); stakingRoutes.post("/prepare-claim", validateRequired("userAddress", "poolId"), validateAddress("userAddress"), + executionTimeout(), asyncHandler(prepareClaimRewards) ); diff --git a/backend/src/modules/swap/routes/swap.routes.ts b/backend/src/modules/swap/routes/swap.routes.ts index 847c26c..7a08282 100644 --- a/backend/src/modules/swap/routes/swap.routes.ts +++ b/backend/src/modules/swap/routes/swap.routes.ts @@ -3,6 +3,7 @@ import { prepareSwap, getQuote, getSwapPairs, submitSwapTx, getSwapTxStatus, get import { asyncHandler } from "../../../middleware/errorHandler"; import { validateAddress, validateAmount, validateRequired, validateSlippage, validateTxHash } from "../../../middleware/validation"; import { requireWalletAuth } from "../../../middleware/auth"; +import { executionTimeout } from "../../../middleware/execution-timeout"; export const swapRoutes = Router(); @@ -10,6 +11,7 @@ export const swapRoutes = Router(); swapRoutes.post("/quote", validateRequired("tokenIn", "tokenOut", "amountIn"), validateSlippage(), + executionTimeout(), asyncHandler(getQuote) ); @@ -19,6 +21,7 @@ swapRoutes.post("/prepare", validateAddress("userAddress"), validateAmount("amountIn"), validateSlippage(), + executionTimeout(), asyncHandler(prepareSwap) ); From ddf8adf13b01db849fda753eded7593d23cc0358 Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:44:51 +0100 Subject: [PATCH 5/8] =?UTF-8?q?feat(H4):=20normalize=20error=20handling=20?= =?UTF-8?q?=E2=80=94=20convert=20raw=20Error=20to=20AppError=20across=20al?= =?UTF-8?q?l=20Base=20chain=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 new error codes to errorCodes.ts: - INSUFFICIENT_BALANCE (400), INSUFFICIENT_LP_BALANCE (400) - NO_LIQUIDITY (400), NO_LP_POSITION (400), NO_REWARDS (400) - EXECUTOR_NOT_CONFIGURED (500) - ORDER_UNAUTHORIZED (403), ORDER_INACTIVE (400) Converts ~20 raw throw new Error() to throw new AppError() in: - prepare-enter-strategy, prepare-exit-strategy, prepare-claim-rewards - get-quote, prepare-cancel-order, get-orders - aerodrome-swap, aerodrome.service, swap-provider, protocols Before: all Base chain errors returned generic 500 INTERNAL_ERROR. After: semantic HTTP status codes (400/403/404/502) with structured error bodies matching the Avalanche modules pattern. --- .../liquid-staking/prepare-exit-strategy.test.ts | 2 +- backend/src/config/protocols.ts | 4 +++- .../modules/dca/usecases/get-orders.usecase.ts | 3 ++- .../dca/usecases/prepare-cancel-order.usecase.ts | 5 +++-- .../usecases/prepare-claim-rewards.usecase.ts | 5 +++-- .../usecases/prepare-enter-strategy.usecase.ts | 12 +++++++----- .../usecases/prepare-exit-strategy.usecase.ts | 16 +++++++++------- .../modules/swap/usecases/get-quote.usecase.ts | 5 +++-- backend/src/shared/aerodrome-swap.ts | 3 ++- backend/src/shared/errorCodes.ts | 8 ++++++++ backend/src/shared/services/aerodrome.service.ts | 5 +++-- backend/src/usecases/swap-provider.usecase.ts | 5 +++-- 12 files changed, 47 insertions(+), 26 deletions(-) diff --git a/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts b/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts index 086ecc1..de0962e 100644 --- a/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts +++ b/backend/src/__tests__/modules/liquid-staking/prepare-exit-strategy.test.ts @@ -91,7 +91,7 @@ describe("executeExitStrategy", () => { mockWalletBal.mockResolvedValue(0n); await expect( executeExitStrategy({ userAddress: USER, poolId: "weth-usdc-volatile", amount: "1000" }) - ).rejects.toThrow("Insufficient LP balance"); + ).rejects.toThrow("Have total: 500"); }); it("full exit includes unstake + approve + removeLiquidity steps", async () => { diff --git a/backend/src/config/protocols.ts b/backend/src/config/protocols.ts index 4f3b4aa..6dfa423 100644 --- a/backend/src/config/protocols.ts +++ b/backend/src/config/protocols.ts @@ -1,3 +1,5 @@ +import { AppError } from "../shared/errorCodes"; + export interface ProtocolConfig { protocolId: string; name: string; @@ -74,7 +76,7 @@ export function registerProtocol(protocolId: string, config: ProtocolConfig): vo export function getProtocolConfig(protocolId: string): ProtocolConfig { const config = PROTOCOL_REGISTRY[protocolId]; - if (!config) throw new Error(`Unsupported protocol: ${protocolId}`); + if (!config) throw new AppError("UNSUPPORTED_OPERATION", `Unsupported protocol: ${protocolId}`); return config; } diff --git a/backend/src/modules/dca/usecases/get-orders.usecase.ts b/backend/src/modules/dca/usecases/get-orders.usecase.ts index c485e16..086f8e5 100644 --- a/backend/src/modules/dca/usecases/get-orders.usecase.ts +++ b/backend/src/modules/dca/usecases/get-orders.usecase.ts @@ -1,6 +1,7 @@ import { getChainConfig } from "../../../config/chains"; import { getContract } from "../../../providers/chain.provider"; import { DCA_VAULT_ABI } from "../../../utils/abi"; +import { AppError } from "../../../shared/errorCodes"; export interface DCAOrderInfo { orderId: number; @@ -71,7 +72,7 @@ export async function executeGetOrder(orderId: number): Promise { ]); if (order.owner === "0x0000000000000000000000000000000000000000") { - throw new Error(`Order ${orderId} not found`); + throw new AppError("ORDER_NOT_FOUND", `Order ${orderId} not found`); } return { diff --git a/backend/src/modules/dca/usecases/prepare-cancel-order.usecase.ts b/backend/src/modules/dca/usecases/prepare-cancel-order.usecase.ts index b96a120..63965a1 100644 --- a/backend/src/modules/dca/usecases/prepare-cancel-order.usecase.ts +++ b/backend/src/modules/dca/usecases/prepare-cancel-order.usecase.ts @@ -3,6 +3,7 @@ import { getChainConfig } from "../../../config/chains"; import { getContract } from "../../../providers/chain.provider"; import { DCA_VAULT_ABI } from "../../../utils/abi"; import { PreparedTransaction, TransactionBundle } from "../../../types/transaction"; +import { AppError } from "../../../shared/errorCodes"; export interface PrepareCancelOrderRequest { userAddress: string; @@ -25,10 +26,10 @@ export async function executePrepareCancel( const vault = getContract(dcaVaultAddress, DCA_VAULT_ABI, "base"); const order = await vault.orders(req.orderId); if (order.owner.toLowerCase() !== req.userAddress.toLowerCase()) { - throw new Error(`Order ${req.orderId} does not belong to ${req.userAddress}`); + throw new AppError("ORDER_UNAUTHORIZED", `Order ${req.orderId} does not belong to ${req.userAddress}`); } if (!order.active) { - throw new Error(`Order ${req.orderId} is already inactive`); + throw new AppError("ORDER_INACTIVE", `Order ${req.orderId} is already inactive`); } const steps: PreparedTransaction[] = []; diff --git a/backend/src/modules/liquid-staking/usecases/prepare-claim-rewards.usecase.ts b/backend/src/modules/liquid-staking/usecases/prepare-claim-rewards.usecase.ts index 60c9ac8..66c92d8 100644 --- a/backend/src/modules/liquid-staking/usecases/prepare-claim-rewards.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/prepare-claim-rewards.usecase.ts @@ -5,6 +5,7 @@ import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; import { TransactionBundle } from "../../../types/transaction"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { BundleBuilder, ADAPTER_SELECTORS } from "../../../shared/bundle-builder"; +import { AppError } from "../../../shared/errorCodes"; export interface PrepareClaimRewardsRequest { userAddress: string; @@ -26,7 +27,7 @@ export async function executeClaimRewards( ): Promise { const poolConfig = getStakingPoolById(req.poolId); if (!poolConfig) { - throw new Error(`Staking pool not found: ${req.poolId}`); + throw new AppError("POOL_NOT_FOUND", `Staking pool not found: ${req.poolId}`); } const chain = getChainConfig("base"); @@ -41,7 +42,7 @@ export async function executeClaimRewards( : 0n; if (earnedRewards === 0n) { - throw new Error(`No rewards to claim for ${poolConfig.name}`); + throw new AppError("NO_REWARDS", `No rewards to claim for ${poolConfig.name}`); } const protocolId = encodeProtocolId("aerodrome"); diff --git a/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts b/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts index 6fee146..9cd287c 100644 --- a/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/prepare-enter-strategy.usecase.ts @@ -6,6 +6,7 @@ import { getDeadline, isNativeETH, applySlippage } from "../../../utils/encoding import { TransactionBundle } from "../../../types/transaction"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { buildAerodromeAddLiquidityBundle } from "../../../shared/aerodrome-add-liquidity"; +import { AppError } from "../../../shared/errorCodes"; export interface PrepareEnterStrategyRequest { userAddress: string; @@ -54,13 +55,13 @@ async function _executeEnterStrategyInner( const t0 = Date.now(); const poolConfig = getStakingPoolById(req.poolId); if (!poolConfig) { - throw new Error(`Staking pool not found: ${req.poolId}`); + throw new AppError("POOL_NOT_FOUND", `Staking pool not found: ${req.poolId}`); } const chain = getChainConfig("base"); const executorAddress = chain.contracts.panoramaExecutor; if (!executorAddress) { - throw new Error("Executor contract not configured"); + throw new AppError("EXECUTOR_NOT_CONFIGURED"); } let amountADesired = BigInt(req.amountA); @@ -93,8 +94,8 @@ async function _executeEnterStrategyInner( if (amountADesired > balA) amountADesired = balA; if (amountBDesired > balB) amountBDesired = balB; - if (amountADesired === 0n) throw new Error(`Insufficient ${poolConfig.tokenA.symbol} balance to enter this position`); - if (amountBDesired === 0n) throw new Error(`Insufficient ${poolConfig.tokenB.symbol} balance to enter this position`); + if (amountADesired === 0n) throw new AppError("INSUFFICIENT_BALANCE", `Insufficient ${poolConfig.tokenA.symbol} balance to enter this position`); + if (amountBDesired === 0n) throw new AppError("INSUFFICIENT_BALANCE", `Insufficient ${poolConfig.tokenB.symbol} balance to enter this position`); // Resolve pool and gauge addresses (hardcoded in config — instant) const { poolAddress, gaugeAddress } = await aerodromeService.resolvePoolAndGauge(poolConfig); @@ -111,7 +112,8 @@ async function _executeEnterStrategyInner( console.log(`[ENTER] quoteAddLiquidity done (+${Date.now() - t0}ms) optA=${optimalA}, optB=${optimalB}, liq=${estimatedLiquidity}`); if (estimatedLiquidity === 0n) { - throw new Error( + throw new AppError( + "NO_LIQUIDITY", `Cannot add liquidity: the provided amounts are too small or too imbalanced. ` + `Try increasing both ${poolConfig.tokenA.symbol} and ${poolConfig.tokenB.symbol} amounts.` ); diff --git a/backend/src/modules/liquid-staking/usecases/prepare-exit-strategy.usecase.ts b/backend/src/modules/liquid-staking/usecases/prepare-exit-strategy.usecase.ts index 55f7186..03e15bd 100644 --- a/backend/src/modules/liquid-staking/usecases/prepare-exit-strategy.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/prepare-exit-strategy.usecase.ts @@ -7,6 +7,7 @@ import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { BundleBuilder, ADAPTER_SELECTORS } from "../../../shared/bundle-builder"; import { getContract } from "../../../providers/chain.provider"; import { ERC20_ABI, POOL_ABI } from "../../../utils/abi"; +import { AppError } from "../../../shared/errorCodes"; export interface PrepareExitStrategyRequest { userAddress: string; @@ -38,7 +39,7 @@ export async function executeExitStrategy( ): Promise { const poolConfig = getStakingPoolById(req.poolId); if (!poolConfig) { - throw new Error(`Staking pool not found: ${req.poolId}`); + throw new AppError("POOL_NOT_FOUND", `Staking pool not found: ${req.poolId}`); } const chain = getChainConfig("base"); @@ -61,11 +62,12 @@ export async function executeExitStrategy( const lpAmount = req.amount ? BigInt(req.amount) : totalAvailable; if (lpAmount === 0n) { - throw new Error(`No LP position found for ${poolConfig.name}`); + throw new AppError("NO_LP_POSITION", `No LP position found for ${poolConfig.name}`); } if (lpAmount > totalAvailable) { - throw new Error( - `Insufficient LP balance. Have total: ${totalAvailable.toString()} ` + + throw new AppError( + "INSUFFICIENT_LP_BALANCE", + `Have total: ${totalAvailable.toString()} ` + `(staked=${stakedBalance.toString()}, wallet=${walletLpBalance.toString()}), ` + `requested: ${lpAmount.toString()}` ); @@ -122,9 +124,9 @@ export async function executeExitStrategy( amountBMin = applySlippage(proportionalB, slippageBps); } } catch (e) { - throw new Error( - `Failed to fetch pool reserves for slippage calculation. Please try again. ` + - `(${e instanceof Error ? e.message : String(e)})` + throw new AppError( + "RPC_ERROR", + `Failed to fetch pool reserves for slippage calculation: ${e instanceof Error ? e.message : String(e)}` ); } diff --git a/backend/src/modules/swap/usecases/get-quote.usecase.ts b/backend/src/modules/swap/usecases/get-quote.usecase.ts index 0e5696c..45e9505 100644 --- a/backend/src/modules/swap/usecases/get-quote.usecase.ts +++ b/backend/src/modules/swap/usecases/get-quote.usecase.ts @@ -1,6 +1,7 @@ import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { applySlippage } from "../../../utils/encoding"; import { getTokenDecimals, formatExchangeRate } from "../../../utils/tokenMath"; +import { AppError } from "../../../shared/errorCodes"; export interface SwapQuoteRequest { tokenIn: string; @@ -42,9 +43,9 @@ export async function executeGetSwapQuote(req: SwapQuoteRequest): Promise Date: Sat, 28 Mar 2026 17:22:59 +0100 Subject: [PATCH 6/8] feat(H5): add demo environment configuration and update .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds config/demo.ts with: - Pinned contract addresses (Executor, Adapter, DCAVault) - Pinned Aerodrome protocol addresses (Router, Factory, Voter) - Canonical demo flow: ETH swap → Add Liquidity → Stake → Claim → Exit - Demo tokens and pool references for WETH/USDC volatile - isDemoMode() helper (NODE_ENV=demo or DEMO_MODE=true) - Wider timeouts (20s exec, 5s RPC) and slippage (2%) for live demos Updates .env.example with: - BASE_RPC_URLS / AVAX_RPC_URLS multi-RPC documentation - AVAX_EXECUTOR_ADDRESS placeholder - NODE_ENV options (development/demo/production) - DEMO_MODE flag - ALLOWED_ORIGINS documentation --- backend/.env.example | 24 ++++++- backend/src/config/demo.ts | 124 +++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 backend/src/config/demo.ts diff --git a/backend/.env.example b/backend/.env.example index 963dbfd..62a16b7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,10 +1,30 @@ # Server PORT=3010 +NODE_ENV=development # development | demo | production -# Base chain RPC +# ── Base Chain RPC ─────────────────────────────────────────────── +# Single RPC (backward-compatible): BASE_RPC_URL=https://mainnet.base.org +# Multiple RPCs with failover (comma-separated, priority order): +# BASE_RPC_URLS=https://your-alchemy-base.com,https://base.llamarpc.com,https://mainnet.base.org -# Deployed contract addresses (Base Mainnet) +# ── Avalanche Chain RPC ────────────────────────────────────────── +# AVAX_RPC_URL=https://api.avax.network/ext/bc/C/rpc +# AVAX_RPC_URLS=https://your-alchemy-avax.com,https://api.avax.network/ext/bc/C/rpc,https://avalanche.drpc.org + +# ── Deployed Contract Addresses (Base Mainnet) ─────────────────── EXECUTOR_ADDRESS=0x82b000512A19f7B762A23033aEA5AE00aBD0D2bC AERODROME_ADAPTER_ADDRESS=0x187e499afB2DE75836800ad19147e0cFcd2Dc715 DCA_VAULT_ADDRESS=0x155eC4256cC6f11f3d4C21Af28a2a1CC31f730d1 + +# ── Avalanche Contract Addresses ───────────────────────────────── +# AVAX_EXECUTOR_ADDRESS= + +# ── Demo Mode ──────────────────────────────────────────────────── +# Set NODE_ENV=demo or DEMO_MODE=true to use stable RPC endpoints +# and wider timeouts. Recommended for presentations. +# DEMO_MODE=true + +# ── CORS ───────────────────────────────────────────────────────── +# Comma-separated allowed origins (defaults to localhost:3000,3010,7777) +# ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7777 diff --git a/backend/src/config/demo.ts b/backend/src/config/demo.ts new file mode 100644 index 0000000..ef1f376 --- /dev/null +++ b/backend/src/config/demo.ts @@ -0,0 +1,124 @@ +// ────────────────────────────────────────────────────────────────── +// DEMO ENVIRONMENT CONFIGURATION +// +// Fixed configuration for demo/presentation environments. +// Ensures deterministic behavior by: +// - Using stable, paid RPC endpoints (not free/rate-limited ones) +// - Pinning contract addresses and flow parameters +// - Defining the canonical demo flow for presentations +// +// Usage: +// NODE_ENV=demo npm run dev +// +// The demo config is consumed by chains.ts when NODE_ENV === "demo". +// It can also be used by scripts/tests that need known-good parameters. +// ────────────────────────────────────────────────────────────────── + +export const DEMO_CONFIG = { + // ── Chain ────────────────────────────────────────────────────── + chain: "base" as const, + chainId: 8453, + + // ── RPC Endpoints (priority order) ───────────────────────────── + // In demo mode, prefer paid/stable endpoints to avoid rate limits + // and flaky responses during presentations. + // + // Override via BASE_RPC_URLS env var (comma-separated). + // These are the recommended defaults when no env var is set: + rpcUrls: [ + "https://mainnet.base.org", // Coinbase official — reliable but rate-limited + "https://base.llamarpc.com", // LlamaRPC — generous free tier + "https://base.drpc.org", // dRPC — backup + ], + + // ── Deployed Contracts ───────────────────────────────────────── + contracts: { + panoramaExecutor: "0x82b000512A19f7B762A23033aEA5AE00aBD0D2bC", + aerodromeAdapter: "0x187e499afB2DE75836800ad19147e0cFcd2Dc715", + dcaVault: "0x155eC4256cC6f11f3d4C21Af28a2a1CC31f730d1", + }, + + // ── Aerodrome Protocol ───────────────────────────────────────── + aerodrome: { + router: "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", + factory: "0x420DD381b31aEf6683db6B902084cB0FFECe40Da", + voter: "0x16613524e02ad97eDfeF371bC883F2F5d6C480A5", + }, + + // ── Canonical Demo Flow ──────────────────────────────────────── + // This is the sequence used during presentations. + // Each step maps to an API endpoint on the execution-layer. + // + // Flow: Swap ETH → USDC → Add Liquidity WETH/USDC → Stake LP + // + demoFlow: { + description: "Base chain → Aerodrome swap → Liquidity add → Gauge stake", + steps: [ + { + name: "Swap ETH → USDC", + endpoint: "POST /swap/prepare", + params: { + tokenIn: "0x0000000000000000000000000000000000000000", // Native ETH + tokenOut: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC + amountIn: "10000000000000000", // 0.01 ETH + }, + }, + { + name: "Add Liquidity WETH/USDC", + endpoint: "POST /staking/prepare-enter", + params: { + poolId: "weth-usdc-volatile", + amountA: "5000000000000000", // 0.005 WETH + amountB: "15000000", // 15 USDC (approx ratio) + }, + }, + { + name: "Check Position", + endpoint: "GET /staking/position/:userAddress", + }, + { + name: "Claim Rewards", + endpoint: "POST /staking/prepare-claim", + params: { + poolId: "weth-usdc-volatile", + }, + }, + { + name: "Exit Strategy", + endpoint: "POST /staking/prepare-exit", + params: { + poolId: "weth-usdc-volatile", + }, + }, + ], + }, + + // ── Demo Tokens ──────────────────────────────────────────────── + tokens: { + ETH: { address: "0x0000000000000000000000000000000000000000", decimals: 18 }, + WETH: { address: "0x4200000000000000000000000000000000000006", decimals: 18 }, + USDC: { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6 }, + AERO: { address: "0x940181a94A35A4569E4529A3CDfB74e38FD98631", decimals: 18 }, + }, + + // ── Demo Pool ────────────────────────────────────────────────── + pool: { + id: "weth-usdc-volatile", + poolAddress: "0xcDAC0d6c6C59727a65F871236188350531885C43", + gaugeAddress: "0x519BBD1Dd8C6A94C46080E24f316c14Ee758C025", + }, + + // ── Timeouts & Limits ────────────────────────────────────────── + // More generous timeouts for demo to avoid flaky failures. + executionTimeoutMs: 20_000, // 20s vs 15s default + rpcTimeoutMs: 5_000, // 5s vs 3.5s default + slippageBps: 200, // 2% — wider to avoid reverts during live demo +} as const; + +/** + * Returns true when the service is running in demo mode. + * Checks NODE_ENV and the DEMO_MODE flag (for docker-compose overrides). + */ +export function isDemoMode(): boolean { + return process.env.NODE_ENV === "demo" || process.env.DEMO_MODE === "true"; +} From d782fc892df9f72de607666752fe8e0447f928c1 Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:10:34 +0100 Subject: [PATCH 7/8] feat(H9+G9): define cross-chain messaging and routing port interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hexagonal architecture ports for future bridge integration: types/cross-chain.ts — Shared types: - MessagingProtocol (wormhole | ccip | layerzero | lifi) - MessageStatus (pending → confirming → relayed → executed | failed) - CrossChainRoute, CrossChainFee, ExecuteRouteResult, etc. domain/ports/CrossChainMessagingPort.ts — Single protocol adapter: - getRoutes(): find routes for a source→dest transfer - estimateFee(): lightweight fee estimation - executeRoute(): prepare unsigned transactions - getMessageStatus(): track in-flight messages domain/ports/RoutingPort.ts — Multi-protocol aggregator: - getRoutes(): queries all messaging adapters, returns best route - executeRoute(): delegates to the correct adapter - getRouteStatus(): track status Interface only — no implementation. Future adapters (Wormhole, CCIP, LayerZero, LI.FI) will implement CrossChainMessagingPort. --- .../domain/ports/CrossChainMessagingPort.ts | 93 ++++++++++++++++++ backend/src/domain/ports/RoutingPort.ts | 62 ++++++++++++ backend/src/types/cross-chain.ts | 98 +++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 backend/src/domain/ports/CrossChainMessagingPort.ts create mode 100644 backend/src/domain/ports/RoutingPort.ts create mode 100644 backend/src/types/cross-chain.ts diff --git a/backend/src/domain/ports/CrossChainMessagingPort.ts b/backend/src/domain/ports/CrossChainMessagingPort.ts new file mode 100644 index 0000000..2013131 --- /dev/null +++ b/backend/src/domain/ports/CrossChainMessagingPort.ts @@ -0,0 +1,93 @@ +// ────────────────────────────────────────────────────────────────── +// CROSS-CHAIN MESSAGING PORT +// +// Abstract interface for cross-chain message passing. +// Following the hexagonal architecture pattern from bridge-service's +// BridgeProviderPort — the domain defines WHAT it needs, and +// infrastructure adapters implement HOW. +// +// Future implementations: +// - WormholeAdapter (Wormhole NTT / Token Bridge) +// - CCIPAdapter (Chainlink CCIP) +// - LayerZeroAdapter (LayerZero V2) +// - LiFiAdapter (LI.FI aggregator — wraps multiple bridges) +// +// The Executor contract triggers cross-chain operations via a new +// adapter selector (CROSS_CHAIN_SEND). The backend prepares the +// transaction bundle with the appropriate messaging protocol's calldata. +// +// ──────────────────────────────────────────────────────────── +// INTEGRATION POINT WITH EXECUTOR +// +// The PanoramaExecutor will call: +// adapter.crossChainSend(destChainId, payload, refundAddress) +// +// The adapter encodes this into the messaging protocol's native +// send function (e.g. Wormhole's transferTokens, CCIP's ccipSend). +// ────────────────────────────────────────────────────────────────── + +import { + CrossChainRoute, + ExecuteRouteRequest, + ExecuteRouteResult, + MessageStatusResponse, + CrossChainFee, + MessagingProtocol, +} from "../../types/cross-chain"; + +export interface CrossChainMessagingPort { + /** Which messaging protocol this adapter implements. */ + readonly protocol: MessagingProtocol; + + /** Which chain pairs this adapter supports (sourceChainId → destChainId[]). */ + readonly supportedRoutes: ReadonlyMap; + + /** + * Returns available routes for a cross-chain transfer. + * + * @param sourceChainId Source chain (e.g. 8453 = Base) + * @param destChainId Destination chain (e.g. 43114 = Avalanche) + * @param token Token address on source chain + * @param amount Amount in wei (string) + * @returns Array of route options, sorted by estimated output (best first) + */ + getRoutes( + sourceChainId: number, + destChainId: number, + token: string, + amount: string, + ): Promise; + + /** + * Estimates the fee for a cross-chain transfer without committing. + * Lighter than getRoutes() — no route computation, just fee estimation. + * + * @param sourceChainId Source chain + * @param destChainId Destination chain + * @param token Token address on source chain + * @param amount Amount in wei (string) + */ + estimateFee( + sourceChainId: number, + destChainId: number, + token: string, + amount: string, + ): Promise; + + /** + * Prepares transaction(s) for a previously quoted route. + * Returns unsigned transactions for the user to sign. + * + * @param request Route ID from getRoutes() + user addresses + * @returns Transactions to sign + a messageId for tracking + */ + executeRoute(request: ExecuteRouteRequest): Promise; + + /** + * Checks the status of an in-flight cross-chain message. + * + * @param messageId Tracking ID from executeRoute() + * @returns Current status with source/dest tx hashes when available + */ + getMessageStatus(messageId: string): Promise; +} diff --git a/backend/src/domain/ports/RoutingPort.ts b/backend/src/domain/ports/RoutingPort.ts new file mode 100644 index 0000000..16372a6 --- /dev/null +++ b/backend/src/domain/ports/RoutingPort.ts @@ -0,0 +1,62 @@ +// ────────────────────────────────────────────────────────────────── +// ROUTING PORT +// +// Abstraction for cross-chain route aggregation. +// This is where LI.FI, Socket, or a custom routing engine will plug in. +// +// Differs from CrossChainMessagingPort: +// - MessagingPort = single protocol adapter (Wormhole, CCIP, etc.) +// - RoutingPort = aggregator that queries MULTIPLE messaging ports +// and returns the best route across all of them +// +// Pattern reference: bridge-service's BridgeProviderPort. +// ────────────────────────────────────────────────────────────────── + +import { CrossChainRoute, MessageStatusResponse } from "../../types/cross-chain"; + +export interface RoutingPort { + /** + * Queries all available messaging adapters and returns ranked routes. + * + * @param fromChainId Source chain ID + * @param toChainId Destination chain ID + * @param token Token address on source chain + * @param amount Amount in wei (string) + * @returns Routes from all protocols, sorted best-first + */ + getRoutes( + fromChainId: number, + toChainId: number, + token: string, + amount: string, + ): Promise; + + /** + * Executes a specific route by delegating to the appropriate + * CrossChainMessagingPort adapter. + * + * @param routeId Route ID from getRoutes() + * @param userAddress Sender's wallet address + * @returns Transactions to sign + tracking messageId + */ + executeRoute( + routeId: string, + userAddress: string, + ): Promise<{ + transactions: Array<{ + to: string; + data: string; + value: string; + chainId: number; + description: string; + }>; + messageId: string; + }>; + + /** + * Checks status of a cross-chain transfer. + * + * @param messageId Tracking ID from executeRoute + */ + getRouteStatus(messageId: string): Promise; +} diff --git a/backend/src/types/cross-chain.ts b/backend/src/types/cross-chain.ts new file mode 100644 index 0000000..6ab5741 --- /dev/null +++ b/backend/src/types/cross-chain.ts @@ -0,0 +1,98 @@ +// ────────────────────────────────────────────────────────────────── +// CROSS-CHAIN MESSAGING TYPES +// +// Shared types for cross-chain operations. Used by the +// CrossChainMessagingPort interface and its future implementations +// (Wormhole, CCIP, LayerZero, LI.FI). +// ────────────────────────────────────────────────────────────────── + +/** Supported cross-chain messaging protocols. */ +export type MessagingProtocol = "wormhole" | "ccip" | "layerzero" | "lifi"; + +/** Status of a cross-chain message in flight. */ +export type MessageStatus = + | "pending" // Submitted to source chain, not yet relayed + | "confirming" // Source chain confirmed, waiting for relay + | "relayed" // Message delivered to destination chain + | "executed" // Destination chain execution complete + | "failed" // Relay or execution failed + | "refunded"; // Failed and funds returned to sender + +/** Fee breakdown for a cross-chain operation. */ +export interface CrossChainFee { + /** Total fee in source chain native token (wei). */ + totalNative: string; + /** Protocol relay fee (wei). */ + relayFee: string; + /** Destination chain gas estimate (wei). */ + destinationGas: string; + /** Fee in USD (approximate, for display). */ + totalUsd?: string; +} + +/** Describes a cross-chain route option returned by getRoutes(). */ +export interface CrossChainRoute { + /** Unique identifier for this route (used in executeRoute). */ + routeId: string; + /** Which messaging protocol this route uses. */ + protocol: MessagingProtocol; + /** Source chain ID (e.g. 8453 for Base). */ + sourceChainId: number; + /** Destination chain ID (e.g. 43114 for Avalanche). */ + destChainId: number; + /** Token being sent. */ + sourceToken: string; + /** Token received on destination. */ + destToken: string; + /** Amount being sent (wei string). */ + amountIn: string; + /** Estimated amount received (wei string, after fees). */ + estimatedAmountOut: string; + /** Fee breakdown. */ + fee: CrossChainFee; + /** Estimated delivery time in seconds. */ + estimatedDurationSec: number; + /** Number of transaction steps the user must sign. */ + stepCount: number; +} + +/** Payload for executing a previously quoted route. */ +export interface ExecuteRouteRequest { + routeId: string; + userAddress: string; + /** Optional: override recipient on destination chain. */ + recipientAddress?: string; +} + +/** Result of executing a route — returns transaction(s) to sign. */ +export interface ExecuteRouteResult { + /** Source chain transaction(s) for the user to sign. */ + transactions: Array<{ + to: string; + data: string; + value: string; + chainId: number; + description: string; + }>; + /** Tracking ID for getMessageStatus(). */ + messageId: string; +} + +/** Status response for a cross-chain message in flight. */ +export interface MessageStatusResponse { + messageId: string; + status: MessageStatus; + protocol: MessagingProtocol; + sourceChainId: number; + destChainId: number; + /** Source chain transaction hash (if submitted). */ + sourceTxHash?: string; + /** Destination chain transaction hash (if executed). */ + destTxHash?: string; + /** Timestamp when message was sent. */ + sentAt?: number; + /** Timestamp when message was delivered/executed. */ + completedAt?: number; + /** Error message if status is "failed". */ + error?: string; +} From 95287fef259d2121ed419eb97446e28b30431963 Mon Sep 17 00:00:00 2001 From: Hugo Noyma <124173190+noymaxx@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:04:09 +0100 Subject: [PATCH 8/8] feat(H10+H11): add wallet separation model and deployment checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H10 — config/wallet-roles.ts: - Documents 4 wallet roles: USER, USER_ADAPTER, DCA_EXECUTOR, TREASURY - Defines per-transaction limits for DCA executor (0.5 ETH max single, 5 ETH session) - ExecutionAuditEntry interface for DCA action logging H11 — DEPLOY_CHECKLIST.md: - Pre-deploy: contract addresses, RPC endpoints, schema alignment, rate limits - Deploy steps: tag, build, health check, smoke test - Post-deploy: verification steps and rollback procedure --- backend/DEPLOY_CHECKLIST.md | 58 ++++++++++++++++++++ backend/src/config/wallet-roles.ts | 86 ++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 backend/DEPLOY_CHECKLIST.md create mode 100644 backend/src/config/wallet-roles.ts diff --git a/backend/DEPLOY_CHECKLIST.md b/backend/DEPLOY_CHECKLIST.md new file mode 100644 index 0000000..9ed07b9 --- /dev/null +++ b/backend/DEPLOY_CHECKLIST.md @@ -0,0 +1,58 @@ +# Deployment Checklist — Execution Layer + +Run through this checklist before every production deployment. + +## Pre-Deploy Verification + +### 1. Contract Addresses +- [ ] `EXECUTOR_ADDRESS` matches deployed PanoramaExecutor on Base Mainnet +- [ ] `DCA_VAULT_ADDRESS` matches deployed DCAVault on Base Mainnet +- [ ] `AVAX_EXECUTOR_ADDRESS` matches deployed executor on Avalanche (if applicable) +- [ ] Aerodrome protocol addresses in `config/protocols.ts` are correct (Router, Factory, Voter) + +### 2. RPC Endpoints +- [ ] `BASE_RPC_URLS` configured with at least 2 endpoints (primary + fallback) +- [ ] `AVAX_RPC_URLS` configured with at least 2 endpoints (if Avalanche is active) +- [ ] Primary RPC endpoint is a paid/stable provider (Alchemy, Infura, QuickNode) +- [ ] Tested: `curl -X POST -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'` returns a valid block number + +### 3. Schema Alignment +- [ ] Frontend (miniapp) is consuming the same response shapes as the backend produces +- [ ] Error codes in `shared/errorCodes.ts` are documented for frontend error mapping +- [ ] No breaking changes in `TransactionBundle` or `PreparedTransaction` types since last deploy + +### 4. Rate Limits & Middleware +- [ ] `rateLimiter.ts` settings are appropriate for production traffic +- [ ] `serialize-by-user.ts` queue limits match expected concurrency +- [ ] `execution-timeout.ts` default (15s) is appropriate for production RPCs +- [ ] CORS `ALLOWED_ORIGINS` includes all production frontend domains + +### 5. Build & Tests +- [ ] `npm run build` succeeds with zero TypeScript errors +- [ ] `npm test` passes all test suites +- [ ] No `.env` or credentials committed to the repository + +## Deploy Steps + +1. **Tag the release**: `git tag v && git push origin v` +2. **Build**: `npm run build` +3. **Verify health**: `curl https:///health` returns `{"status":"ok"}` +4. **Smoke test**: Run the canonical demo flow (see `config/demo.ts`) + - POST `/swap/prepare` (ETH → USDC) + - POST `/staking/prepare-enter` (WETH/USDC volatile) + - GET `/staking/position/:address` + +## Post-Deploy Verification + +- [ ] Health endpoint returns `{"status":"ok"}` +- [ ] Swagger docs accessible at `/docs` (non-production only) +- [ ] Logs show `execution-service running on port ` +- [ ] Logs show `[ChainProvider] base: N RPC endpoints configured` +- [ ] Test a swap quote: `POST /swap/quote` returns a valid `amountOut` + +## Rollback + +If issues are found post-deploy: +1. Revert to previous Docker image / git tag +2. Verify health endpoint +3. Document the issue for next deploy diff --git a/backend/src/config/wallet-roles.ts b/backend/src/config/wallet-roles.ts new file mode 100644 index 0000000..8c9ab77 --- /dev/null +++ b/backend/src/config/wallet-roles.ts @@ -0,0 +1,86 @@ +// ────────────────────────────────────────────────────────────────── +// WALLET SEPARATION MODEL +// +// Documents and enforces the distinct wallet roles in the system. +// Each role has different trust levels and transaction limits. +// +// Architecture: +// ┌──────────────┐ ┌───────────────────┐ ┌──────────────┐ +// │ User Wallet │────▶│ PanoramaExecutor │────▶│ UserAdapter │ +// │ (signs tx) │ │ (dispatcher) │ │ (per-user) │ +// └──────────────┘ └───────────────────┘ └──────────────┘ +// │ +// ┌────▼────┐ +// │ Protocol │ +// │ (Aero, │ +// │ Benqi) │ +// └─────────┘ +// +// NON-CUSTODIAL: The backend NEVER holds private keys. +// All transactions are prepared unsigned and signed by the user's wallet. +// ────────────────────────────────────────────────────────────────── + +/** Wallet roles in the PanoramaBlock execution system. */ +export enum WalletRole { + /** User's own wallet (MetaMask, ThirdWeb in-app). Signs all transactions. */ + USER = "USER", + + /** + * Per-user BeaconProxy clone created by PanoramaExecutor. + * Holds LP tokens, gauge stakes, and protocol positions on behalf of the user. + * Only the user (via Executor) can interact with their adapter. + */ + USER_ADAPTER = "USER_ADAPTER", + + /** + * DCA execution wallet (backend-controlled signer). + * Used ONLY for automated DCA swap execution via DCAVault. + * Has strict per-transaction and per-session limits. + */ + DCA_EXECUTOR = "DCA_EXECUTOR", + + /** + * Fee collection treasury (multisig or DAO-controlled). + * Receives protocol fees from adapter operations. + * NOT used in the current execution-layer — reserved for future use. + */ + TREASURY = "TREASURY", +} + +// ── Per-Transaction Limits ──────────────────────────────────────── +// These apply to the DCA_EXECUTOR role only. +// User-initiated transactions have no backend-enforced limit +// (the user controls their own wallet). + +export const EXECUTION_LIMITS = { + /** Max value per single DCA swap execution (in wei). ~0.5 ETH */ + MAX_SINGLE_TX_VALUE: BigInt("500000000000000000"), + + /** Max cumulative value per DCA session/epoch (in wei). ~5 ETH */ + MAX_SESSION_VALUE: BigInt("5000000000000000000"), + + /** Max number of DCA executions per session. */ + MAX_EXECUTIONS_PER_SESSION: 50, + + /** Session duration in milliseconds (1 hour). */ + SESSION_DURATION_MS: 60 * 60 * 1000, +} as const; + +// ── Audit Log Entry ─────────────────────────────────────────────── +// Every DCA execution action should be logged with this shape +// for post-hoc audit trail. + +export interface ExecutionAuditEntry { + timestamp: number; + walletRole: WalletRole; + action: string; + protocol: string; + chain: string; + userAddress: string; + adapterAddress?: string; + amountWei: string; + txHash?: string; + orderId?: number; + success: boolean; + error?: string; +}