From ecce71bf07dbe8488db89d1bd359b60c7b5a61ed Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Sun, 29 Mar 2026 18:22:16 -0300 Subject: [PATCH 1/7] FIX - caching --- .../usecases/get-protocol-info.usecase.ts | 48 ++++++++++++++----- backend/src/shared/cache.ts | 35 ++++++++++++++ .../src/shared/services/aerodrome.service.ts | 37 +++++--------- 3 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 backend/src/shared/cache.ts diff --git a/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts index 8ee16fe..acfa795 100644 --- a/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts @@ -3,14 +3,26 @@ import { getEnabledStakingPools } from "../config/staking-pools"; import { getContract } from "../../../providers/chain.provider"; import { GAUGE_ABI, POOL_ABI } from "../../../utils/abi"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { createCache, getCached, setCache } from "../../../shared/cache"; type DexScreenerMetrics = { feeAPR: string | null; tvlUsd: number | null; }; -/** Fetch fee-based APR and real TVL from DexScreener. */ +// Granular caches for APR/TVL and gauge data +const dexMetricsCache = createCache(); +const DEX_METRICS_TTL = 30_000; // 30s for APR + TVL + +const gaugeDataCache = createCache<{ rewardRate: bigint; totalStaked: bigint }>(); +const GAUGE_DATA_TTL = 60_000; // 60s for gauge rewards + +/** Fetch fee-based APR and real TVL from DexScreener (cached 30s). */ async function fetchDexScreenerMetrics(poolAddress: string, feeRate: number): Promise { + const cacheKey = `dex:${poolAddress.toLowerCase()}:${feeRate}`; + const cached = getCached(dexMetricsCache, cacheKey); + if (cached) return cached; + try { const res = await fetch(`https://api.dexscreener.com/latest/dex/pairs/base/${poolAddress}`, { signal: AbortSignal.timeout(8000), @@ -22,12 +34,16 @@ async function fetchDexScreenerMetrics(poolAddress: string, feeRate: number): Pr const vol24h = pair?.volume?.h24 ?? null; if (!tvlUsd || tvlUsd <= 0 || !vol24h || vol24h <= 0) { - return { feeAPR: null, tvlUsd }; + const result: DexScreenerMetrics = { feeAPR: null, tvlUsd }; + setCache(dexMetricsCache, cacheKey, result, DEX_METRICS_TTL); + return result; } const feeAPR = (vol24h * feeRate * 365) / tvlUsd * 100; console.log(`[APR-DEXSCREENER] vol24h=$${vol24h.toFixed(0)}, tvl=$${tvlUsd.toFixed(0)}, feeRate=${feeRate}, feeAPR=${feeAPR.toFixed(2)}%`); - return { feeAPR: `${feeAPR.toFixed(2)}%`, tvlUsd }; + const result: DexScreenerMetrics = { feeAPR: `${feeAPR.toFixed(2)}%`, tvlUsd }; + setCache(dexMetricsCache, cacheKey, result, DEX_METRICS_TTL); + return result; } catch (e) { console.error(`[APR-DEXSCREENER] Failed:`, e instanceof Error ? e.message : e); return { feeAPR: null, tvlUsd: null }; @@ -68,7 +84,7 @@ export interface GetProtocolInfoResponse { } let cache: { data: GetProtocolInfoResponse; expiresAt: number } | null = null; -const CACHE_TTL = 10 * 60 * 1000; // 10 minutes +const CACHE_TTL = 30_000; // 30s — individual caches (dex 30s, gauge 60s) handle per-resource freshness export async function executeGetProtocolInfo(): Promise { if (cache && Date.now() < cache.expiresAt) { @@ -88,15 +104,25 @@ export async function executeGetProtocolInfo(): Promise ); console.log(`[PROTOCOL-INFO] poolAddress=${poolAddress}, gaugeAddress=${gaugeAddress}`); - const gauge = getContract(gaugeAddress, GAUGE_ABI, "base"); const feeRate = pool.stable ? 0.0001 : 0.003; - // DexScreener is the critical path (APR + TVL). Gauge calls are best-effort — no retries. - const [dexMetrics, rewardRate, totalStaked] = await Promise.all([ - fetchDexScreenerMetrics(poolAddress, feeRate), - safeGaugeCall(gauge, "rewardRate"), - safeGaugeCall(gauge, "totalSupply"), - ]); + // Gauge data cached 60s; DexScreener cached 30s. + const gaugeCacheKey = `gauge:${gaugeAddress.toLowerCase()}`; + let gaugeData = getCached(gaugeDataCache, gaugeCacheKey); + const dexPromise = fetchDexScreenerMetrics(poolAddress, feeRate); + + if (!gaugeData) { + const gauge = getContract(gaugeAddress, GAUGE_ABI, "base"); + const [rewardRate, totalStaked] = await Promise.all([ + safeGaugeCall(gauge, "rewardRate"), + safeGaugeCall(gauge, "totalSupply"), + ]); + gaugeData = { rewardRate, totalStaked }; + setCache(gaugeDataCache, gaugeCacheKey, gaugeData, GAUGE_DATA_TTL); + } + + const dexMetrics = await dexPromise; + const { rewardRate, totalStaked } = gaugeData; let estimatedAPR = "0"; let aprSource = "unavailable"; diff --git a/backend/src/shared/cache.ts b/backend/src/shared/cache.ts new file mode 100644 index 0000000..4a7bedf --- /dev/null +++ b/backend/src/shared/cache.ts @@ -0,0 +1,35 @@ +/** + * Simple in-memory TTL cache backed by a Map. + * + * Usage: + * const myCache = createCache(); + * setCache(myCache, "key", value, 30_000); // 30s TTL + * const hit = getCached(myCache, "key"); // MyType | null + */ + +export interface CacheEntry { + value: T; + expiresAt: number; +} + +export type TTLCache = Map>; + +/** Create a new typed cache instance. */ +export function createCache(): TTLCache { + return new Map(); +} + +/** Retrieve a cached value. Returns `null` when missing or expired. */ +export function getCached(cache: TTLCache, key: string): T | null { + const entry = cache.get(key); + if (!entry || Date.now() >= entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.value; +} + +/** Store a value in the cache with a TTL in milliseconds. */ +export function setCache(cache: TTLCache, key: string, value: T, ttlMs: number): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); +} diff --git a/backend/src/shared/services/aerodrome.service.ts b/backend/src/shared/services/aerodrome.service.ts index 21d4d7d..c6dece4 100644 --- a/backend/src/shared/services/aerodrome.service.ts +++ b/backend/src/shared/services/aerodrome.service.ts @@ -2,6 +2,7 @@ import { ethers } from "ethers"; import { getContract } from "../../providers/chain.provider"; import { getProtocolConfig, getUserAdapterAddress } from "../../config/protocols"; import { AppError } from "../errorCodes"; +import { createCache, getCached, setCache, type TTLCache } from "../cache"; import { AERODROME_ROUTER_ABI, AERODROME_FACTORY_ABI, @@ -23,22 +24,9 @@ interface Route { } const BALANCE_CACHE_TTL_MS = 90_000; -const walletBalanceCache = new Map(); - -const poolInfoCache = new Map(); - -function getCached(cache: Map, key: string): unknown | null { - const entry = cache.get(key); - if (!entry || Date.now() >= entry.expiresAt) { - cache.delete(key); - return null; - } - return entry.value; -} - -function setCache(cache: Map, key: string, value: unknown, ttlMs: number): void { - cache.set(key, { value, expiresAt: Date.now() + ttlMs }); -} +const walletBalanceCache = createCache(); +const poolInfoCache = createCache(); +const gaugeRewardCache = createCache(); function resolveTokenAddress(address: string): string { return address === ETH_ADDRESS ? WETH : address; @@ -203,8 +191,14 @@ export class AerodromeService { } async getRewardRate(gaugeAddress: string): Promise { + const cacheKey = `rewardRate:${gaugeAddress.toLowerCase()}`; + const cached = getCached(gaugeRewardCache, cacheKey); + if (cached !== null) return cached; + const gauge = getContract(gaugeAddress, GAUGE_ABI, CHAIN); - return gauge.rewardRate(); + const rate: bigint = await gauge.rewardRate(); + setCache(gaugeRewardCache, cacheKey, rate, 60_000); // 60s TTL + return rate; } // ========== ALLOWANCE ========== @@ -248,17 +242,12 @@ export class AerodromeService { getWalletBalanceCached(userAddress: string, symbol: string): string | null { const key = `${userAddress.toLowerCase()}:${symbol.toUpperCase()}`; - const cached = walletBalanceCache.get(key); - if (!cached || Date.now() >= cached.expiresAt) { - walletBalanceCache.delete(key); - return null; - } - return cached.value; + return getCached(walletBalanceCache, key); } setWalletBalanceCached(userAddress: string, symbol: string, value: string): void { const key = `${userAddress.toLowerCase()}:${symbol.toUpperCase()}`; - walletBalanceCache.set(key, { value, expiresAt: Date.now() + BALANCE_CACHE_TTL_MS }); + setCache(walletBalanceCache, key, value, BALANCE_CACHE_TTL_MS); } } From 748db1db57d2bd4b5d372ea94ddc0c5b31eac946 Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Sun, 29 Mar 2026 18:32:28 -0300 Subject: [PATCH 2/7] protocol metadata --- .../src/shared/services/aerodrome.service.ts | 107 ++++++++++++++---- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/backend/src/shared/services/aerodrome.service.ts b/backend/src/shared/services/aerodrome.service.ts index c6dece4..b48b596 100644 --- a/backend/src/shared/services/aerodrome.service.ts +++ b/backend/src/shared/services/aerodrome.service.ts @@ -24,9 +24,20 @@ interface Route { } const BALANCE_CACHE_TTL_MS = 90_000; +const POOL_TTL_MS = 600_000; // 10min — pool addresses are immutable +const GAUGE_TTL_MS = 300_000; // 5min — gauge can be replaced by governance vote +const TOKEN_META_TTL_MS = 3_600_000; // 1h — symbol/decimals never change + const walletBalanceCache = createCache(); -const poolInfoCache = createCache(); -const gaugeRewardCache = createCache(); +const poolAddressCache = createCache(); +const gaugeAddressCache = createCache(); +const tokenMetaCache = createCache<{ symbol: string; decimals: number }>(); +const gaugeRewardCache = createCache(); + +// In-flight dedup maps (same pattern as adapterCache in protocols.ts) +const poolInFlight = new Map>(); +const gaugeInFlight = new Map>(); +const tokenMetaInFlight = new Map>(); function resolveTokenAddress(address: string): string { return address === ETH_ADDRESS ? WETH : address; @@ -98,18 +109,30 @@ export class AerodromeService { // ========== POOL ========== async getPoolAddress(tokenA: string, tokenB: string, stable: boolean): Promise { - // Pool addresses are immutable — cache for 10 minutes const a = resolveTokenAddress(tokenA).toLowerCase(); const b = resolveTokenAddress(tokenB).toLowerCase(); const cacheKey = `pool:${[a, b].sort().join(":")}:${stable}`; - const cached = getCached(poolInfoCache, cacheKey); - if (cached) return cached as string; - const config = getProtocolConfig("aerodrome"); - const factory = getContract(config.contracts.factory, AERODROME_FACTORY_ABI, CHAIN); - const result: string = await factory.getPool(a, b, stable); - setCache(poolInfoCache, cacheKey, result, 600_000); // 10min - return result; + const cached = getCached(poolAddressCache, cacheKey); + if (cached) return cached; + + const inFlight = poolInFlight.get(cacheKey); + if (inFlight) return inFlight; + + const request = (async () => { + const config = getProtocolConfig("aerodrome"); + const factory = getContract(config.contracts.factory, AERODROME_FACTORY_ABI, CHAIN); + const result: string = await factory.getPool(a, b, stable); + setCache(poolAddressCache, cacheKey, result, POOL_TTL_MS); + return result; + })(); + + poolInFlight.set(cacheKey, request); + try { + return await request; + } finally { + poolInFlight.delete(cacheKey); + } } async resolvePoolAndGauge(poolConfig: { @@ -145,6 +168,34 @@ export class AerodromeService { // ========== GAUGE ========== + async getTokenMetadata(tokenAddress: string): Promise<{ symbol: string; decimals: number }> { + const key = tokenAddress.toLowerCase(); + + const cached = getCached(tokenMetaCache, key); + if (cached) return cached; + + const inFlight = tokenMetaInFlight.get(key); + if (inFlight) return inFlight; + + const request = (async () => { + const contract = getContract(tokenAddress, ERC20_ABI, CHAIN); + const [symbol, decimals] = await Promise.all([ + contract.symbol() as Promise, + contract.decimals() as Promise, + ]); + const meta = { symbol, decimals: Number(decimals) }; + setCache(tokenMetaCache, key, meta, TOKEN_META_TTL_MS); + return meta; + })(); + + tokenMetaInFlight.set(key, request); + try { + return await request; + } finally { + tokenMetaInFlight.delete(key); + } + } + async getPoolInfo(poolAddress: string): Promise<{ address: string; token0: string; @@ -162,22 +213,40 @@ export class AerodromeService { pool.stable() as Promise, pool.getReserves() as Promise<[bigint, bigint, bigint]>, ]); - const t0 = getContract(token0, ERC20_ABI, CHAIN); - const t1 = getContract(token1, ERC20_ABI, CHAIN); - const [token0Symbol, token1Symbol] = await Promise.all([ - t0.symbol() as Promise, - t1.symbol() as Promise, + const [meta0, meta1] = await Promise.all([ + this.getTokenMetadata(token0), + this.getTokenMetadata(token1), ]); return { - address: poolAddress, token0, token1, token0Symbol, token1Symbol, + address: poolAddress, token0, token1, + token0Symbol: meta0.symbol, token1Symbol: meta1.symbol, stable, reserve0: reserves[0].toString(), reserve1: reserves[1].toString(), }; } async getGaugeForPool(poolAddress: string): Promise { - const config = getProtocolConfig("aerodrome"); - const voter = getContract(config.contracts.voter, VOTER_ABI, CHAIN); - return voter.gauges(poolAddress); + const cacheKey = `gauge:${poolAddress.toLowerCase()}`; + + const cached = getCached(gaugeAddressCache, cacheKey); + if (cached) return cached; + + const inFlight = gaugeInFlight.get(cacheKey); + if (inFlight) return inFlight; + + const request = (async () => { + const config = getProtocolConfig("aerodrome"); + const voter = getContract(config.contracts.voter, VOTER_ABI, CHAIN); + const result: string = await voter.gauges(poolAddress); + setCache(gaugeAddressCache, cacheKey, result, GAUGE_TTL_MS); + return result; + })(); + + gaugeInFlight.set(cacheKey, request); + try { + return await request; + } finally { + gaugeInFlight.delete(cacheKey); + } } async getStakedBalance(gaugeAddress: string, adapterAddress: string): Promise { From 7088638140d2160a8bb06e1692d80f10d667a753 Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Sun, 29 Mar 2026 19:23:44 -0300 Subject: [PATCH 3/7] add - fallback for data --- .../usecases/get-portfolio.usecase.ts | 28 +++ .../usecases/get-protocol-info.usecase.ts | 174 ++++++++++-------- backend/src/shared/cache.ts | 11 ++ 3 files changed, 134 insertions(+), 79 deletions(-) diff --git a/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts index a8eabde..1e0ca4b 100644 --- a/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts @@ -4,6 +4,7 @@ import { getContract } from "../../../providers/chain.provider"; import { ERC20_ABI, POOL_ABI } from "../../../utils/abi"; import { BASE_TOKENS } from "../../../config/protocols"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { createCache, getCached, setCache, getStale, type TTLCache } from "../../../shared/cache"; interface PortfolioAsset { poolId: string; @@ -20,8 +21,13 @@ export interface GetPortfolioResponse { totalPositions: number; assets: PortfolioAsset[]; walletBalances: Record; + stale?: boolean; + lastUpdated?: string; } +const portfolioCache = createCache(); +const PORTFOLIO_CACHE_TTL = 30_000; // 30s + async function resolvePoolAndGauge( pool: ReturnType[number] ): Promise<{ poolAddress: string; gaugeAddress: string } | null> { @@ -33,6 +39,27 @@ async function resolvePoolAndGauge( } export async function executeGetPortfolio(userAddress: string): Promise { + const cacheKey = userAddress.toLowerCase(); + + const cached = getCached(portfolioCache, cacheKey); + if (cached) return cached; + + try { + const data = await fetchPortfolioFresh(userAddress); + setCache(portfolioCache, cacheKey, data, PORTFOLIO_CACHE_TTL); + return data; + } catch (err) { + // On failure, return stale cache if available + const stale = getStale(portfolioCache, cacheKey); + if (stale) { + console.warn(`[PORTFOLIO] Fetch failed for ${userAddress}, returning stale data from ${stale.value.lastUpdated} —`, err instanceof Error ? err.message : err); + return { ...stale.value, stale: true }; + } + throw err; + } +} + +async function fetchPortfolioFresh(userAddress: string): Promise { const enabledPools = getEnabledStakingPools(); // Run wallet balances, adapter lookup, and pool resolution ALL in parallel @@ -142,5 +169,6 @@ export async function executeGetPortfolio(userAddress: string): Promise return cache.data; } - console.log("[PROTOCOL-INFO] Cache miss — fetching fresh data from on-chain..."); - const enabledPools = getEnabledStakingPools(); - console.log("[PROTOCOL-INFO] Enabled pools:", enabledPools.map(p => p.name).join(", ")); - - const poolResults = await Promise.all(enabledPools.map(async (pool): Promise => { - try { - console.log(`[PROTOCOL-INFO] Processing pool: ${pool.name}`); - const { poolAddress, gaugeAddress } = await aerodromeService.withRetry(() => - aerodromeService.resolvePoolAndGauge(pool) - ); - console.log(`[PROTOCOL-INFO] poolAddress=${poolAddress}, gaugeAddress=${gaugeAddress}`); - - const feeRate = pool.stable ? 0.0001 : 0.003; - - // Gauge data cached 60s; DexScreener cached 30s. - const gaugeCacheKey = `gauge:${gaugeAddress.toLowerCase()}`; - let gaugeData = getCached(gaugeDataCache, gaugeCacheKey); - const dexPromise = fetchDexScreenerMetrics(poolAddress, feeRate); - - if (!gaugeData) { - const gauge = getContract(gaugeAddress, GAUGE_ABI, "base"); - const [rewardRate, totalStaked] = await Promise.all([ - safeGaugeCall(gauge, "rewardRate"), - safeGaugeCall(gauge, "totalSupply"), - ]); - gaugeData = { rewardRate, totalStaked }; - setCache(gaugeDataCache, gaugeCacheKey, gaugeData, GAUGE_DATA_TTL); - } - - const dexMetrics = await dexPromise; - const { rewardRate, totalStaked } = gaugeData; - - let estimatedAPR = "0"; - let aprSource = "unavailable"; - let totalLiquidityUsd: string | null = null; - - if (dexMetrics.tvlUsd != null && Number.isFinite(dexMetrics.tvlUsd)) { - totalLiquidityUsd = dexMetrics.tvlUsd.toFixed(2); - } - - if (dexMetrics.feeAPR) { - estimatedAPR = dexMetrics.feeAPR.replace("%", ""); - aprSource = "DexScreener fee APR (24h volume × fee rate × 365 / TVL)"; - console.log(`[PROTOCOL-INFO] feeAPR=${estimatedAPR}% (DexScreener)`); + try { + console.log("[PROTOCOL-INFO] Cache miss — fetching fresh data from on-chain..."); + const enabledPools = getEnabledStakingPools(); + console.log("[PROTOCOL-INFO] Enabled pools:", enabledPools.map(p => p.name).join(", ")); + + const poolResults = await Promise.all(enabledPools.map(async (pool): Promise => { + try { + console.log(`[PROTOCOL-INFO] Processing pool: ${pool.name}`); + const { poolAddress, gaugeAddress } = await aerodromeService.withRetry(() => + aerodromeService.resolvePoolAndGauge(pool) + ); + console.log(`[PROTOCOL-INFO] poolAddress=${poolAddress}, gaugeAddress=${gaugeAddress}`); + + const feeRate = pool.stable ? 0.0001 : 0.003; + + // Gauge data cached 60s; DexScreener cached 30s. + const gaugeCacheKey = `gauge:${gaugeAddress.toLowerCase()}`; + let gaugeData = getCached(gaugeDataCache, gaugeCacheKey); + const dexPromise = fetchDexScreenerMetrics(poolAddress, feeRate); + + if (!gaugeData) { + const gauge = getContract(gaugeAddress, GAUGE_ABI, "base"); + const [rewardRate, totalStaked] = await Promise.all([ + safeGaugeCall(gauge, "rewardRate"), + safeGaugeCall(gauge, "totalSupply"), + ]); + gaugeData = { rewardRate, totalStaked }; + setCache(gaugeDataCache, gaugeCacheKey, gaugeData, GAUGE_DATA_TTL); + } + + const dexMetrics = await dexPromise; + const { rewardRate, totalStaked } = gaugeData; + + let estimatedAPR = "0"; + let aprSource = "unavailable"; + let totalLiquidityUsd: string | null = null; + + if (dexMetrics.tvlUsd != null && Number.isFinite(dexMetrics.tvlUsd)) { + totalLiquidityUsd = dexMetrics.tvlUsd.toFixed(2); + } + + if (dexMetrics.feeAPR) { + estimatedAPR = dexMetrics.feeAPR.replace("%", ""); + aprSource = "DexScreener fee APR (24h volume × fee rate × 365 / TVL)"; + console.log(`[PROTOCOL-INFO] feeAPR=${estimatedAPR}% (DexScreener)`); + } + + console.log(`[PROTOCOL-INFO] estimatedAPR=${estimatedAPR}%`); + + return { + poolId: pool.id, + poolName: pool.name, + poolAddress, + gaugeAddress, + stable: pool.stable, + rewardRatePerSecond: rewardRate.toString(), + totalStaked: totalStaked.toString(), + estimatedAPR: `${estimatedAPR}%`, + aprSource, + aprDisclaimer: "Fee APR estimate only. Does not include AERO gauge rewards. Past performance is not indicative of future results.", + totalLiquidityUsd, + }; + } catch (err) { + console.error(`[PROTOCOL-INFO] Pool ${pool.name} FAILED entirely:`, err instanceof Error ? err.message : err); + return null; } - - console.log(`[PROTOCOL-INFO] estimatedAPR=${estimatedAPR}%`); - - return { - poolId: pool.id, - poolName: pool.name, - poolAddress, - gaugeAddress, - stable: pool.stable, - rewardRatePerSecond: rewardRate.toString(), - totalStaked: totalStaked.toString(), - estimatedAPR: `${estimatedAPR}%`, - aprSource, - aprDisclaimer: "Fee APR estimate only. Does not include AERO gauge rewards. Past performance is not indicative of future results.", - totalLiquidityUsd, - }; - } catch (err) { - console.error(`[PROTOCOL-INFO] Pool ${pool.name} FAILED entirely:`, err instanceof Error ? err.message : err); - return null; + })); + + const pools = poolResults.filter((p): p is PoolInfo => p !== null); + + console.log(`[PROTOCOL-INFO] Done. ${pools.length} pools resolved. APRs: ${pools.map(p => `${p.poolName}=${p.estimatedAPR}`).join(", ")}`); + + const data: GetProtocolInfoResponse = { + protocol: "Aerodrome Finance", + chain: "Base (8453)", + pools, + updatedAt: new Date().toISOString(), + }; + + cache = { data, expiresAt: Date.now() + CACHE_TTL }; + return data; + } catch (err) { + // On total failure, return stale cache if available + if (cache) { + console.warn("[PROTOCOL-INFO] Fetch failed, returning stale cache from", cache.data.updatedAt, "—", err instanceof Error ? err.message : err); + return { ...cache.data, stale: true }; } - })); - - const pools = poolResults.filter((p): p is PoolInfo => p !== null); - - console.log(`[PROTOCOL-INFO] Done. ${pools.length} pools resolved. APRs: ${pools.map(p => `${p.poolName}=${p.estimatedAPR}`).join(", ")}`); - - const data: GetProtocolInfoResponse = { - protocol: "Aerodrome Finance", - chain: "Base (8453)", - pools, - updatedAt: new Date().toISOString(), - }; - - cache = { data, expiresAt: Date.now() + CACHE_TTL }; - return data; + throw err; + } } diff --git a/backend/src/shared/cache.ts b/backend/src/shared/cache.ts index 4a7bedf..02537a0 100644 --- a/backend/src/shared/cache.ts +++ b/backend/src/shared/cache.ts @@ -29,6 +29,17 @@ export function getCached(cache: TTLCache, key: string): T | null { return entry.value; } +/** + * Retrieve a cached value even if expired (stale fallback). + * Returns `null` only when the key was never set. + * Useful for returning last-known-good data on fetch failure. + */ +export function getStale(cache: TTLCache, key: string): { value: T; stale: boolean; expiresAt: number } | null { + const entry = cache.get(key); + if (!entry) return null; + return { value: entry.value, stale: Date.now() >= entry.expiresAt, expiresAt: entry.expiresAt }; +} + /** Store a value in the cache with a TTL in milliseconds. */ export function setCache(cache: TTLCache, key: string, value: T, ttlMs: number): void { cache.set(key, { value, expiresAt: Date.now() + ttlMs }); From f765dfc0a5238dc170f17f268358a5db3fc576ed Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Sun, 29 Mar 2026 23:55:24 -0300 Subject: [PATCH 4/7] ADD - logger --- backend/.env.example | 30 ++++- backend/src/config/chains.ts | 17 ++- backend/src/config/protocols.ts | 6 +- backend/src/index.ts | 11 +- backend/src/middleware/errorHandler.ts | 3 +- backend/src/middleware/execution-timeout.ts | 5 +- backend/src/middleware/serialize-by-user.ts | 3 +- backend/src/middleware/tracing.ts | 39 ++++++ .../usecases/prepare-swap.usecase.ts | 36 +----- .../usecases/get-portfolio.usecase.ts | 16 +-- .../usecases/get-position.usecase.ts | 5 +- .../usecases/get-protocol-info.usecase.ts | 27 +++-- .../usecases/get-staking-pools.usecase.ts | 6 +- .../prepare-enter-strategy.usecase.ts | 15 ++- backend/src/providers/chain.provider.ts | 25 ++-- backend/src/shared/aerodrome-add-liquidity.ts | 11 +- backend/src/shared/aerodrome-swap.ts | 5 +- backend/src/shared/logger.ts | 111 ++++++++++++++++++ 18 files changed, 263 insertions(+), 108 deletions(-) create mode 100644 backend/src/middleware/tracing.ts create mode 100644 backend/src/shared/logger.ts diff --git a/backend/.env.example b/backend/.env.example index 62a16b7..bc66048 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,14 +3,36 @@ PORT=3010 NODE_ENV=development # development | demo | production # ── Base Chain RPC ─────────────────────────────────────────────── +# Comma-separated, priority order. First = primary, rest = failover. +# The provider tries primary with 3.5s timeout, then races all fallbacks +# in parallel. Sick RPCs (2+ consecutive failures) are skipped for 30s. +# +# Recommended free RPCs (no API key required): +# https://base.llamarpc.com — LlamaNodes, generous limits +# https://mainnet.base.org — Coinbase official, moderate limits +# https://base.drpc.org — dRPC free tier +# https://base.meowrpc.com — MeowRPC free tier +# # 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 +# BASE_RPC_URL=https://mainnet.base.org +# +# Multiple RPCs with failover: +BASE_RPC_URLS=https://base.llamarpc.com,https://mainnet.base.org,https://base.drpc.org # ── Avalanche Chain RPC ────────────────────────────────────────── +# Same failover logic as Base. Comma-separated, priority order. +# +# Recommended free RPCs (no API key required): +# https://api.avax.network/ext/bc/C/rpc — Official, low rate limits +# https://avalanche.drpc.org — dRPC free tier +# https://avax.meowrpc.com — MeowRPC free tier +# https://rpc.ankr.com/avalanche — Ankr public +# +# Single RPC (backward-compatible): # 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 +# +# Multiple RPCs with failover: +AVAX_RPC_URLS=https://api.avax.network/ext/bc/C/rpc,https://avalanche.drpc.org,https://avax.meowrpc.com # ── Deployed Contract Addresses (Base Mainnet) ─────────────────── EXECUTOR_ADDRESS=0x82b000512A19f7B762A23033aEA5AE00aBD0D2bC diff --git a/backend/src/config/chains.ts b/backend/src/config/chains.ts index b460038..0e03f98 100644 --- a/backend/src/config/chains.ts +++ b/backend/src/config/chains.ts @@ -12,17 +12,22 @@ export interface ChainConfig { }; } -function parseRpcUrls(listEnv: string | undefined, singleEnv: string | undefined, defaultUrl: string): string[] { +function parseRpcUrls(listEnv: string | undefined, singleEnv: string | undefined, defaults: string[]): string[] { if (listEnv) { const urls = listEnv.split(",").map(u => u.trim()).filter(Boolean); if (urls.length > 0) return urls; } - return [singleEnv || defaultUrl]; + if (singleEnv) return [singleEnv]; + return defaults; } export function getChainConfig(chain: string): ChainConfig { if (chain === "base") { - const rpcUrls = parseRpcUrls(process.env.BASE_RPC_URLS, process.env.BASE_RPC_URL, "https://mainnet.base.org"); + const rpcUrls = parseRpcUrls(process.env.BASE_RPC_URLS, process.env.BASE_RPC_URL, [ + "https://base.llamarpc.com", + "https://mainnet.base.org", + "https://base.drpc.org", + ]); return { chainId: 8453, name: "Base", @@ -39,7 +44,11 @@ export function getChainConfig(chain: string): ChainConfig { } if (chain === "avalanche") { - const rpcUrls = parseRpcUrls(process.env.AVAX_RPC_URLS, process.env.AVAX_RPC_URL, "https://api.avax.network/ext/bc/C/rpc"); + const rpcUrls = parseRpcUrls(process.env.AVAX_RPC_URLS, process.env.AVAX_RPC_URL, [ + "https://api.avax.network/ext/bc/C/rpc", + "https://avalanche.drpc.org", + "https://avax.meowrpc.com", + ]); return { chainId: 43114, name: "Avalanche C-Chain", diff --git a/backend/src/config/protocols.ts b/backend/src/config/protocols.ts index 6dfa423..fac21c7 100644 --- a/backend/src/config/protocols.ts +++ b/backend/src/config/protocols.ts @@ -1,4 +1,5 @@ import { AppError } from "../shared/errorCodes"; +import { logger } from "../shared/logger"; export interface ProtocolConfig { protocolId: string; @@ -148,10 +149,7 @@ export async function getUserAdapterAddress(userAddress: string, protocolId: str adapterCache.set(cacheKey, { value: "", expiresAt: Date.now() + EMPTY_TTL_MS }); return ""; } - console.warn( - `[getUserAdapterAddress] failed:`, - err instanceof Error ? err.message : err - ); + logger.warn({ chain, protocol: protocolId, user: userAddress, error: err instanceof Error ? err.message : err }, "Adapter address lookup failed"); } adapterCache.set(cacheKey, { value: "", expiresAt: Date.now() + EMPTY_TTL_MS }); return ""; diff --git a/backend/src/index.ts b/backend/src/index.ts index 4b58ece..e2f04cd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,8 @@ import { avaxLiquidStakingRoutes } from "./modules/avax-liquid-staking/routes/ import { errorHandler } from "./middleware/errorHandler"; import { rateLimiter } from "./middleware/rateLimiter"; import { serializeByUser } from "./middleware/serialize-by-user"; +import { tracingMiddleware } from "./middleware/tracing"; +import { logger } from "./shared/logger"; const app = express(); const PORT = process.env.PORT || 3010; @@ -26,12 +28,7 @@ const allowedOrigins = process.env.ALLOWED_ORIGINS : ["http://localhost:3000", "http://localhost:3010", "http://localhost:7777"]; app.use(helmet()); - -// Request logger — logs every incoming request for local debugging -app.use((req, _res, next) => { - console.log(`[execution-layer] ← ${req.method} ${req.path}`); - next(); -}); +app.use(tracingMiddleware); app.use(cors({ origin: (origin, callback) => { @@ -69,7 +66,7 @@ app.get("/health", (_req, res) => { app.use(errorHandler); app.listen(PORT, () => { - console.log(`execution-service running on port ${PORT}`); + logger.info({ port: PORT }, `execution-service running on port ${PORT}`); }); export default app; diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 3d13eb4..7e8caf1 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction, RequestHandler } from "express"; import { AppError } from "../shared/errorCodes"; +import { logger } from "../shared/logger"; export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) { if (err instanceof AppError) { @@ -11,7 +12,7 @@ export function errorHandler(err: Error, _req: Request, res: Response, _next: Ne }); } - console.error("Unhandled error:", err); + logger.error({ error: err.message, stack: err.stack }, "Unhandled error"); return res.status(500).json({ error: { code: "INTERNAL_ERROR", diff --git a/backend/src/middleware/execution-timeout.ts b/backend/src/middleware/execution-timeout.ts index 655f050..b64b976 100644 --- a/backend/src/middleware/execution-timeout.ts +++ b/backend/src/middleware/execution-timeout.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import { logger } from "../shared/logger"; // ────────────────────────────────────────────────────────────────── // EXECUTION TIMEOUT MIDDLEWARE @@ -34,9 +35,7 @@ export function executionTimeout(ms: number = DEFAULT_TIMEOUT_MS) { // 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` - ); + logger.error({ method: req.method, path: req.path, timeoutMs: ms }, "Request exceeded timeout, sending 504"); res.status(504).json({ error: { code: "EXECUTION_TIMEOUT", diff --git a/backend/src/middleware/serialize-by-user.ts b/backend/src/middleware/serialize-by-user.ts index ccc9857..7676616 100644 --- a/backend/src/middleware/serialize-by-user.ts +++ b/backend/src/middleware/serialize-by-user.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import { logger } from "../shared/logger"; // ────────────────────────────────────────────────────────────────── // CONSTANTS @@ -83,7 +84,7 @@ export function serializeByUser(req: Request, res: Response, next: NextFunction) // 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})`); + logger.warn({ user: userKey.slice(0, 10), queueDepth: currentDepth }, "Rejecting request, queue full"); res.status(429).json({ error: { code: "QUEUE_FULL", diff --git a/backend/src/middleware/tracing.ts b/backend/src/middleware/tracing.ts new file mode 100644 index 0000000..069cb6d --- /dev/null +++ b/backend/src/middleware/tracing.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; +import { traceStore, logger } from "../shared/logger"; + +// Extend Express Request so any handler can read req.traceId +declare global { + namespace Express { + interface Request { + traceId: string; + } + } +} + +/** + * Injects a unique traceId per request and logs method + path. + * The traceId is propagated via AsyncLocalStorage so every logger + * call within the request automatically includes it. + */ +export function tracingMiddleware(req: Request, res: Response, next: NextFunction): void { + const traceId = (req.headers["x-trace-id"] as string) || randomUUID(); + req.traceId = traceId; + res.setHeader("x-trace-id", traceId); + + const start = Date.now(); + + res.on("finish", () => { + logger.info( + { + method: req.method, + path: req.path, + status: res.statusCode, + durationMs: Date.now() - start, + }, + `${req.method} ${req.path} ${res.statusCode}`, + ); + }); + + traceStore.run({ traceId }, () => next()); +} diff --git a/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts b/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts index d32aa42..9efb862 100644 --- a/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts +++ b/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts @@ -5,6 +5,7 @@ import { applySlippage, getDeadline, encodeProtocolId } from "../../../utils/enc import { BundleBuilder, TRADERJOE_SELECTORS } from "../../../shared/bundle-builder"; import { TransactionBundle } from "../../../types/transaction"; import { AppError } from "../../../shared/errorCodes"; +import { logger } from "../../../shared/logger"; export interface PrepareAvaxSwapRequest { userAddress: string; @@ -40,17 +41,11 @@ const WAVAX_UNWRAP_ABI = ["function withdraw(uint256 wad) external"]; export async function executePrepareAvaxSwap( req: PrepareAvaxSwapRequest ): Promise { - console.log("[avax-swap] prepare request:", JSON.stringify({ - userAddress: req.userAddress, - tokenIn: req.tokenIn, - tokenOut: req.tokenOut, - amountIn: req.amountIn, - slippageBps: req.slippageBps, - })); + logger.info({ chain: "avalanche", protocol: "traderjoe", user: req.userAddress, tokenIn: req.tokenIn, tokenOut: req.tokenOut, amountIn: req.amountIn, slippageBps: req.slippageBps }, "Prepare swap request"); const chain = getChainConfig("avalanche"); const executorAddr = chain.contracts.panoramaExecutor; - console.log("[avax-swap] executorAddr:", executorAddr); + logger.info({ chain: "avalanche", executor: executorAddr }, "Executor address resolved"); if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Avalanche"); const amountIn = BigInt(req.amountIn); @@ -97,17 +92,7 @@ export async function executePrepareAvaxSwap( const isAvaxOut = req.tokenOut.toLowerCase() === WAVAX.toLowerCase(); const swapType = isAvaxIn ? "avax-to-token" : isAvaxOut ? "token-to-avax" : "token-to-token"; - console.log("[avax-swap] quote:", { - path, - amountIn: amountIn.toString(), - amountOut: amountOut.toString(), - amountOutMin: amountOutMin.toString(), - swapType, - isAvaxIn, - isAvaxOut, - deadline, - WAVAX_const: WAVAX, - }); + logger.info({ chain: "avalanche", protocol: "traderjoe", path, amountIn: amountIn.toString(), amountOut: amountOut.toString(), amountOutMin: amountOutMin.toString(), swapType, isAvaxIn, isAvaxOut, deadline }, "Swap quote obtained"); const protocolId = encodeProtocolId("traderjoe"); const builder = new BundleBuilder(chain.chainId); @@ -138,14 +123,7 @@ export async function executePrepareAvaxSwap( [amountIn, amountOutMin, path, req.userAddress] ); - console.log("[avax-swap] adapterData:", { - selector: TRADERJOE_SELECTORS.SWAP_WITH_PATH, - protocolId, - transfers: transfers.map(t => ({ token: t.token, amount: t.amount.toString() })), - ethValue: ethValue.toString(), - executorAddr, - adapterData, - }); + logger.info({ chain: "avalanche", protocol: "traderjoe", selector: TRADERJOE_SELECTORS.SWAP_WITH_PATH, protocolId, transfers: transfers.map(t => ({ token: t.token, amount: t.amount.toString() })), ethValue: ethValue.toString() }, "Adapter data encoded"); builder.addExecute( protocolId, @@ -159,9 +137,7 @@ export async function executePrepareAvaxSwap( ); const bundle = builder.build(`Swap ${swapType} via TraderJoe on Avalanche`); - console.log("[avax-swap] bundle steps:", bundle.steps.map(s => ({ - to: s.to, value: s.value, dataLen: s.data.length, description: s.description, - }))); + logger.info({ chain: "avalanche", protocol: "traderjoe", steps: bundle.steps.map(s => ({ to: s.to, value: s.value, dataLen: s.data.length, description: s.description })) }, "Bundle built"); const priceImpact = amountIn > 0n ? (100 - (Number(amountOut) / Number(amountIn)) * 100).toFixed(4) diff --git a/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts index 1e0ca4b..b06b06a 100644 --- a/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-portfolio.usecase.ts @@ -5,6 +5,7 @@ import { ERC20_ABI, POOL_ABI } from "../../../utils/abi"; import { BASE_TOKENS } from "../../../config/protocols"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { createCache, getCached, setCache, getStale, type TTLCache } from "../../../shared/cache"; +import { logger } from "../../../shared/logger"; interface PortfolioAsset { poolId: string; @@ -52,7 +53,7 @@ export async function executeGetPortfolio(userAddress: string): Promise aerodromeService.getEarnedRewards(gaugeAddress, userAdapter), 2, 300).catch(() => 0n) : 0n; - console.log(`[PORTFOLIO] ${pool.name}: staked=${totalStaked}, earned=${totalEarned}`); + logger.info({ protocol: "aerodrome", pool: pool.name, staked: totalStaked.toString(), earned: totalEarned.toString() }, "Pool position fetched"); if (totalStaked > 0n) { const poolContract = getContract(poolAddress, POOL_ABI, "base"); diff --git a/backend/src/modules/liquid-staking/usecases/get-position.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-position.usecase.ts index 75fc6ce..16f5867 100644 --- a/backend/src/modules/liquid-staking/usecases/get-position.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-position.usecase.ts @@ -1,6 +1,7 @@ import { ethers } from "ethers"; import { getEnabledStakingPools } from "../config/staking-pools"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { logger } from "../../../shared/logger"; interface StakingPosition { poolId: string; @@ -49,7 +50,7 @@ export async function executeGetPosition( })), ]); - console.log(`[POSITIONS] user=${req.userAddress}, adapter=${userAdapter}, pools=${enabledPools.length}`); + logger.info({ protocol: "aerodrome", user: req.userAddress, adapter: userAdapter, pools: enabledPools.length }, "Position lookup started"); // Fetch staking positions sequentially within each pool to avoid RPC rate limiting. // All pools still run in parallel (outer Promise.all), but within each pool the three @@ -68,7 +69,7 @@ export async function executeGetPosition( .getTokenBalance(poolAddress, req.userAddress) .catch(() => 0n); - console.log(`[POSITIONS] ${pool.name}: staked=${stakedBalance}, earned=${earnedRewards}, walletLp=${walletLpBalance}`); + logger.info({ protocol: "aerodrome", pool: pool.name, staked: stakedBalance.toString(), earned: earnedRewards.toString(), walletLp: walletLpBalance.toString() }, "Position data fetched"); if (stakedBalance > 0n || earnedRewards > 0n || walletLpBalance > 0n) { return { diff --git a/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts index bc22f12..4c17aba 100644 --- a/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-protocol-info.usecase.ts @@ -4,6 +4,7 @@ import { getContract } from "../../../providers/chain.provider"; import { GAUGE_ABI, POOL_ABI } from "../../../utils/abi"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { createCache, getCached, getStale, setCache } from "../../../shared/cache"; +import { logger } from "../../../shared/logger"; type DexScreenerMetrics = { feeAPR: string | null; @@ -40,16 +41,16 @@ async function fetchDexScreenerMetrics(poolAddress: string, feeRate: number): Pr } const feeAPR = (vol24h * feeRate * 365) / tvlUsd * 100; - console.log(`[APR-DEXSCREENER] vol24h=$${vol24h.toFixed(0)}, tvl=$${tvlUsd.toFixed(0)}, feeRate=${feeRate}, feeAPR=${feeAPR.toFixed(2)}%`); + logger.info({ chain: "base", vol24h: vol24h.toFixed(0), tvl: tvlUsd.toFixed(0), feeRate, feeAPR: `${feeAPR.toFixed(2)}%` }, "DexScreener APR calculated"); const result: DexScreenerMetrics = { feeAPR: `${feeAPR.toFixed(2)}%`, tvlUsd }; setCache(dexMetricsCache, cacheKey, result, DEX_METRICS_TTL); return result; } catch (e) { - console.error(`[APR-DEXSCREENER] Failed:`, e instanceof Error ? e.message : e); + logger.error({ chain: "base", pool: poolAddress, error: e instanceof Error ? e.message : e }, "DexScreener fetch failed"); // Return stale value if available const stale = getStale(dexMetricsCache, cacheKey); if (stale) { - console.warn(`[APR-DEXSCREENER] Using stale cache for ${poolAddress}`); + logger.warn({ chain: "base", pool: poolAddress }, "Using stale DexScreener cache"); return stale.value; } return { feeAPR: null, tvlUsd: null }; @@ -95,22 +96,22 @@ const CACHE_TTL = 30_000; // 30s — individual caches (dex 30s, gauge 60s) hand export async function executeGetProtocolInfo(): Promise { if (cache && Date.now() < cache.expiresAt) { - console.log("[PROTOCOL-INFO] Returning cached data (expires in", Math.round((cache.expiresAt - Date.now()) / 1000), "s)"); + logger.info({ protocol: "aerodrome", expiresInSec: Math.round((cache.expiresAt - Date.now()) / 1000) }, "Returning cached protocol info"); return cache.data; } try { - console.log("[PROTOCOL-INFO] Cache miss — fetching fresh data from on-chain..."); + logger.info({ protocol: "aerodrome" }, "Cache miss, fetching fresh on-chain data"); const enabledPools = getEnabledStakingPools(); - console.log("[PROTOCOL-INFO] Enabled pools:", enabledPools.map(p => p.name).join(", ")); + logger.info({ protocol: "aerodrome", pools: enabledPools.map(p => p.name) }, "Enabled pools loaded"); const poolResults = await Promise.all(enabledPools.map(async (pool): Promise => { try { - console.log(`[PROTOCOL-INFO] Processing pool: ${pool.name}`); + logger.info({ protocol: "aerodrome", pool: pool.name }, "Processing pool"); const { poolAddress, gaugeAddress } = await aerodromeService.withRetry(() => aerodromeService.resolvePoolAndGauge(pool) ); - console.log(`[PROTOCOL-INFO] poolAddress=${poolAddress}, gaugeAddress=${gaugeAddress}`); + logger.info({ protocol: "aerodrome", pool: pool.name, poolAddress, gaugeAddress }, "Pool and gauge resolved"); const feeRate = pool.stable ? 0.0001 : 0.003; @@ -143,10 +144,10 @@ export async function executeGetProtocolInfo(): Promise if (dexMetrics.feeAPR) { estimatedAPR = dexMetrics.feeAPR.replace("%", ""); aprSource = "DexScreener fee APR (24h volume × fee rate × 365 / TVL)"; - console.log(`[PROTOCOL-INFO] feeAPR=${estimatedAPR}% (DexScreener)`); + logger.info({ protocol: "aerodrome", pool: pool.name, feeAPR: `${estimatedAPR}%` }, "Fee APR from DexScreener"); } - console.log(`[PROTOCOL-INFO] estimatedAPR=${estimatedAPR}%`); + logger.info({ protocol: "aerodrome", pool: pool.name, estimatedAPR: `${estimatedAPR}%` }, "Estimated APR computed"); return { poolId: pool.id, @@ -162,14 +163,14 @@ export async function executeGetProtocolInfo(): Promise totalLiquidityUsd, }; } catch (err) { - console.error(`[PROTOCOL-INFO] Pool ${pool.name} FAILED entirely:`, err instanceof Error ? err.message : err); + logger.error({ protocol: "aerodrome", pool: pool.name, error: err instanceof Error ? err.message : err }, "Pool resolution failed entirely"); return null; } })); const pools = poolResults.filter((p): p is PoolInfo => p !== null); - console.log(`[PROTOCOL-INFO] Done. ${pools.length} pools resolved. APRs: ${pools.map(p => `${p.poolName}=${p.estimatedAPR}`).join(", ")}`); + logger.info({ protocol: "aerodrome", poolCount: pools.length, aprs: pools.map(p => `${p.poolName}=${p.estimatedAPR}`) }, "Protocol info fetch complete"); const data: GetProtocolInfoResponse = { protocol: "Aerodrome Finance", @@ -183,7 +184,7 @@ export async function executeGetProtocolInfo(): Promise } catch (err) { // On total failure, return stale cache if available if (cache) { - console.warn("[PROTOCOL-INFO] Fetch failed, returning stale cache from", cache.data.updatedAt, "—", err instanceof Error ? err.message : err); + logger.warn({ protocol: "aerodrome", lastUpdated: cache.data.updatedAt, error: err instanceof Error ? err.message : err }, "Fetch failed, returning stale cache"); return { ...cache.data, stale: true }; } throw err; diff --git a/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts b/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts index 1021aec..85cab41 100644 --- a/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts +++ b/backend/src/modules/liquid-staking/usecases/get-staking-pools.usecase.ts @@ -3,6 +3,7 @@ import { getContract } from "../../../providers/chain.provider"; import { getProtocolConfig } from "../../../config/protocols"; import { GAUGE_ABI, VOTER_ABI } from "../../../utils/abi"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; +import { logger } from "../../../shared/logger"; interface StakingPoolInfo { id: string; @@ -69,10 +70,7 @@ export async function executeGetStakingPools(): Promise rewardRate: rewardRate.toString(), }; } catch (err) { - console.error( - `[STAKING/POOLS] Failed to resolve pool ${pool.name}:`, - err instanceof Error ? err.message : err - ); + logger.error({ protocol: "aerodrome", pool: pool.name, error: err instanceof Error ? err.message : err }, "Failed to resolve staking pool"); return null; } })); 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 9cd287c..7b99dfd 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 @@ -7,6 +7,7 @@ import { TransactionBundle } from "../../../types/transaction"; import { aerodromeService } from "../../../shared/services/aerodrome.service"; import { buildAerodromeAddLiquidityBundle } from "../../../shared/aerodrome-add-liquidity"; import { AppError } from "../../../shared/errorCodes"; +import { logger } from "../../../shared/logger"; export interface PrepareEnterStrategyRequest { userAddress: string; @@ -69,9 +70,7 @@ async function _executeEnterStrategyInner( const slippageBps = req.slippageBps ?? 100; const deadlineMinutes = req.deadlineMinutes ?? 20; - console.log(`[ENTER] user=${req.userAddress}, pool=${req.poolId}`); - console.log(`[ENTER] requested amountA=${amountADesired}, amountB=${amountBDesired}`); - console.log(`[ENTER] executor=${executorAddress}`); + logger.info({ protocol: "aerodrome", user: req.userAddress, pool: req.poolId, amountA: amountADesired.toString(), amountB: amountBDesired.toString(), executor: executorAddress }, "Enter strategy request"); // Cap amounts to user's actual on-chain balance to avoid TransferFromFailed. const [balA, balB] = await Promise.all([ @@ -89,8 +88,8 @@ async function _executeEnterStrategyInner( : Promise.resolve(amountBDesired), ]); - console.log(`[ENTER] ${poolConfig.tokenA.symbol} balance=${balA}, desired=${amountADesired}, capped=${amountADesired > balA} (+${Date.now() - t0}ms)`); - console.log(`[ENTER] ${poolConfig.tokenB.symbol} balance=${balB}, desired=${amountBDesired}, capped=${amountBDesired > balB}`); + logger.info({ protocol: "aerodrome", token: poolConfig.tokenA.symbol, balance: balA.toString(), desired: amountADesired.toString(), capped: amountADesired > balA, durationMs: Date.now() - t0 }, "Token A balance check"); + logger.info({ protocol: "aerodrome", token: poolConfig.tokenB.symbol, balance: balB.toString(), desired: amountBDesired.toString(), capped: amountBDesired > balB }, "Token B balance check"); if (amountADesired > balA) amountADesired = balA; if (amountBDesired > balB) amountBDesired = balB; @@ -99,7 +98,7 @@ async function _executeEnterStrategyInner( // Resolve pool and gauge addresses (hardcoded in config — instant) const { poolAddress, gaugeAddress } = await aerodromeService.resolvePoolAndGauge(poolConfig); - console.log(`[ENTER] resolvePoolAndGauge done (+${Date.now() - t0}ms) pool=${poolAddress}, gauge=${gaugeAddress}`); + logger.info({ protocol: "aerodrome", poolAddress, gaugeAddress, durationMs: Date.now() - t0 }, "Pool and gauge resolved"); // Query router for optimal amounts based on pool ratio. const { optimalA, optimalB, estimatedLiquidity } = await aerodromeService.quoteAddLiquidity( @@ -109,7 +108,7 @@ async function _executeEnterStrategyInner( amountADesired, amountBDesired ); - console.log(`[ENTER] quoteAddLiquidity done (+${Date.now() - t0}ms) optA=${optimalA}, optB=${optimalB}, liq=${estimatedLiquidity}`); + logger.info({ protocol: "aerodrome", optimalA: optimalA.toString(), optimalB: optimalB.toString(), estimatedLiquidity: estimatedLiquidity.toString(), durationMs: Date.now() - t0 }, "Liquidity quote obtained"); if (estimatedLiquidity === 0n) { throw new AppError( @@ -141,7 +140,7 @@ async function _executeEnterStrategyInner( chainId: chain.chainId, poolName: poolConfig.name, }); - console.log(`[ENTER] buildBundle done (+${Date.now() - t0}ms)`); + logger.info({ protocol: "aerodrome", durationMs: Date.now() - t0 }, "Bundle built"); return { bundle: builder.build(`Enter staking position: ${poolConfig.name}`), diff --git a/backend/src/providers/chain.provider.ts b/backend/src/providers/chain.provider.ts index a63213d..194b75a 100644 --- a/backend/src/providers/chain.provider.ts +++ b/backend/src/providers/chain.provider.ts @@ -1,5 +1,6 @@ import { ethers } from "ethers"; import { getChainConfig } from "../config/chains"; +import { logger } from "../shared/logger"; // ────────────────────────────────────────────────────────────────── // STATIC NETWORK DEFINITIONS @@ -121,7 +122,7 @@ function createProvider(chain: string): ethers.JsonRpcProvider { // Single RPC — no failover needed, return as-is if (allUrls.length <= 1) { - console.log(`[ChainProvider] ${chain}: 1 RPC endpoint (no failover)`); + logger.info({ chain, rpcEndpoint: allUrls[0] }, "Single RPC endpoint configured (no failover)"); return primary; } @@ -141,10 +142,14 @@ function createProvider(chain: string): ethers.JsonRpcProvider { return fallbackProviders; } - 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(", ")}` + logger.info( + { + chain, + totalEndpoints: allUrls.length, + primary: allUrls[0].replace(/^https?:\/\//, "").split("/")[0], + fallbacks: fallbackUrls.map(u => u.replace(/^https?:\/\//, "").split("/")[0]), + }, + "Multiple RPC endpoints configured with failover", ); // ── Override .send() with failover logic ── @@ -163,13 +168,13 @@ function createProvider(chain: string): ethers.JsonRpcProvider { return result; } catch (err) { markRpcFailed(primaryUrl); - console.warn( - `[ChainProvider] ${chain} primary RPC failed (${method}): ` + - `${err instanceof Error ? err.message : "unknown"}` + logger.warn( + { chain, method, error: err instanceof Error ? err.message : "unknown" }, + "Primary RPC failed, falling back", ); } } else { - console.warn(`[ChainProvider] ${chain} primary RPC is sick — skipping to fallbacks`); + logger.warn({ chain, method }, "Primary RPC is sick, skipping to fallbacks"); } // ── Step 2: Race all fallbacks in parallel ── @@ -191,7 +196,7 @@ function createProvider(chain: string): ethers.JsonRpcProvider { if (!resolved) { resolved = true; markRpcHealthy(fbUrl); - console.log(`[ChainProvider] ${chain} fallback #${idx + 1} succeeded (${method})`); + logger.info({ chain, method, fallbackIndex: idx + 1 }, "Fallback RPC succeeded"); resolve(result); } }, diff --git a/backend/src/shared/aerodrome-add-liquidity.ts b/backend/src/shared/aerodrome-add-liquidity.ts index 0166e7a..b49a64b 100644 --- a/backend/src/shared/aerodrome-add-liquidity.ts +++ b/backend/src/shared/aerodrome-add-liquidity.ts @@ -2,6 +2,7 @@ import { ethers } from "ethers"; import { aerodromeService } from "./services/aerodrome.service"; import { BundleBuilder, ADAPTER_SELECTORS } from "./bundle-builder"; import { encodeProtocolId, isNativeETH, applySlippage } from "../utils/encoding"; +import { logger } from "./logger"; export interface AerodromeAddLiquidityBundleParams { userAddress: string; @@ -43,29 +44,29 @@ export async function buildAerodromeAddLiquidityBundle( const protocolId = encodeProtocolId("aerodrome"); // Check allowances in parallel — each has the provider-level 3.5s timeout. - console.log(`[ADD_LIQ] checking 3 allowances in parallel...`); + logger.debug({ protocol: "aerodrome", pool: poolName }, "Checking 3 allowances in parallel"); const [allowanceA, allowanceB, lpAllowance] = await Promise.all([ !isNativeETH(tokenA.address) ? aerodromeService.checkAllowance(tokenA.address, userAddress, executorAddress, amountADesired) .catch((e) => { - console.error(`[ADD_LIQ] allowance check ${tokenA.symbol} FAILED (+${Date.now() - t0}ms):`, e instanceof Error ? e.message : e); + logger.error({ protocol: "aerodrome", token: tokenA.symbol, durationMs: Date.now() - t0, error: e instanceof Error ? e.message : e }, "Allowance check failed"); return { allowance: 0n, sufficient: false }; }) : Promise.resolve({ allowance: ethers.MaxUint256, sufficient: true }), !isNativeETH(tokenB.address) ? aerodromeService.checkAllowance(tokenB.address, userAddress, executorAddress, amountBDesired) .catch((e) => { - console.error(`[ADD_LIQ] allowance check ${tokenB.symbol} FAILED (+${Date.now() - t0}ms):`, e instanceof Error ? e.message : e); + logger.error({ protocol: "aerodrome", token: tokenB.symbol, durationMs: Date.now() - t0, error: e instanceof Error ? e.message : e }, "Allowance check failed"); return { allowance: 0n, sufficient: false }; }) : Promise.resolve({ allowance: ethers.MaxUint256, sufficient: true }), aerodromeService.checkAllowance(poolAddress, userAddress, executorAddress, estimatedLiquidity) .catch((e) => { - console.error(`[ADD_LIQ] LP allowance check FAILED (+${Date.now() - t0}ms):`, e instanceof Error ? e.message : e); + logger.error({ protocol: "aerodrome", token: "LP", durationMs: Date.now() - t0, error: e instanceof Error ? e.message : e }, "LP allowance check failed"); return { allowance: 0n, sufficient: false }; }), ]); - console.log(`[ADD_LIQ] allowances done (+${Date.now() - t0}ms) A=${allowanceA.sufficient} B=${allowanceB.sufficient} LP=${lpAllowance.sufficient}`); + logger.info({ protocol: "aerodrome", pool: poolName, durationMs: Date.now() - t0, tokenA: allowanceA.sufficient, tokenB: allowanceB.sufficient, lp: lpAllowance.sufficient }, "Allowances checked"); // Approve tokenA / tokenB if (!isNativeETH(tokenA.address)) { diff --git a/backend/src/shared/aerodrome-swap.ts b/backend/src/shared/aerodrome-swap.ts index 5cb1fe6..94a4620 100644 --- a/backend/src/shared/aerodrome-swap.ts +++ b/backend/src/shared/aerodrome-swap.ts @@ -3,6 +3,7 @@ import { aerodromeService } from "./services/aerodrome.service"; import { BundleBuilder, ADAPTER_SELECTORS } from "./bundle-builder"; import { encodeProtocolId, isNativeETH } from "../utils/encoding"; import { AppError } from "./errorCodes"; +import { logger } from "./logger"; export interface AerodromeSwapBundleParams { userAddress: string; @@ -44,7 +45,7 @@ export async function buildAerodromeSwapBundle( const allowance = allowanceResult.status === "fulfilled" ? allowanceResult.value.allowance : 0n; if (allowanceResult.status === "rejected") { - console.warn(`[aerodrome-swap] allowance read failed — assuming 0 (will add approve step): ${(allowanceResult.reason as Error)?.message}`); + logger.warn({ protocol: "aerodrome", token: tokenIn, error: (allowanceResult.reason as Error)?.message }, "Allowance read failed, assuming 0"); } if (balanceResult.status === "fulfilled") { @@ -53,7 +54,7 @@ export async function buildAerodromeSwapBundle( throw new AppError("INSUFFICIENT_BALANCE", `Have ${balance}, need ${amountIn}`); } } else { - console.warn(`[aerodrome-swap] balance read failed — skipping check, executor will revert if insufficient: ${(balanceResult.reason as Error)?.message}`); + logger.warn({ protocol: "aerodrome", token: tokenIn, error: (balanceResult.reason as Error)?.message }, "Balance read failed, skipping check"); } builder.addApproveIfNeeded(tokenIn, executorAddress, allowance, amountIn, "Approve token for swap"); diff --git a/backend/src/shared/logger.ts b/backend/src/shared/logger.ts new file mode 100644 index 0000000..eb0df9b --- /dev/null +++ b/backend/src/shared/logger.ts @@ -0,0 +1,111 @@ +/** + * Structured logger with per-request traceId support. + * + * - Production: JSON lines (one object per line, machine-parseable) + * - Development: human-readable colored output + * + * Usage: + * import { logger } from "../shared/logger"; + * logger.info({ chain: "base", protocol: "aerodrome" }, "Pool resolved"); + * logger.warn({ traceId: req.traceId, durationMs: 42 }, "Slow RPC"); + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +// ── Trace context ────────────────────────────────────────────────── +// AsyncLocalStorage carries the traceId through the entire request +// without having to pass it explicitly through every function call. + +export interface TraceContext { + traceId: string; +} + +export const traceStore = new AsyncLocalStorage(); + +/** Returns the current traceId or "no-trace" if outside a request. */ +export function getTraceId(): string { + return traceStore.getStore()?.traceId ?? "no-trace"; +} + +// ── Log levels ───────────────────────────────────────────────────── + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVEL_NUM: Record = { debug: 10, info: 20, warn: 30, error: 40 }; + +const ENV_LEVEL: LogLevel = + (process.env.LOG_LEVEL as LogLevel) ?? + (process.env.NODE_ENV === "production" ? "info" : "debug"); + +// ── Formatting ───────────────────────────────────────────────────── + +const IS_JSON = process.env.NODE_ENV === "production"; + +interface LogEntry { + level: LogLevel; + traceId: string; + msg: string; + ts: string; + [key: string]: unknown; +} + +function formatJson(entry: LogEntry): string { + return JSON.stringify(entry); +} + +const COLORS: Record = { + debug: "\x1b[90m", // gray + info: "\x1b[36m", // cyan + warn: "\x1b[33m", // yellow + error: "\x1b[31m", // red +}; +const RESET = "\x1b[0m"; + +function formatPretty(entry: LogEntry): string { + const { level, traceId, msg, ts, ...rest } = entry; + const color = COLORS[level]; + const tag = traceId !== "no-trace" ? ` [${traceId.slice(0, 8)}]` : ""; + const extra = Object.keys(rest).length > 0 + ? " " + Object.entries(rest).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ") + : ""; + return `${color}${level.toUpperCase().padEnd(5)}${RESET}${tag} ${msg}${extra}`; +} + +function write(level: LogLevel, fields: Record, msg: string): void { + if (LEVEL_NUM[level] < LEVEL_NUM[ENV_LEVEL]) return; + + const entry: LogEntry = { + level, + traceId: getTraceId(), + msg, + ts: new Date().toISOString(), + ...fields, + }; + + const line = IS_JSON ? formatJson(entry) : formatPretty(entry); + + if (level === "error") { + process.stderr.write(line + "\n"); + } else { + process.stdout.write(line + "\n"); + } +} + +// ── Public API ───────────────────────────────────────────────────── + +function createLogFn(level: LogLevel) { + return function log(fieldsOrMsg: Record | string, msg?: string): void { + if (typeof fieldsOrMsg === "string") { + write(level, {}, fieldsOrMsg); + } else { + write(level, fieldsOrMsg, msg ?? ""); + } + }; +} + +export const logger = { + debug: createLogFn("debug"), + info: createLogFn("info"), + warn: createLogFn("warn"), + error: createLogFn("error"), +}; From 25fe4e26021815d518dd51b07cfe5b612e3214c8 Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Mon, 30 Mar 2026 00:05:34 -0300 Subject: [PATCH 5/7] ADD - limiter --- backend/src/middleware/rateLimiter.ts | 98 +++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/backend/src/middleware/rateLimiter.ts b/backend/src/middleware/rateLimiter.ts index 8a0af04..cd57110 100644 --- a/backend/src/middleware/rateLimiter.ts +++ b/backend/src/middleware/rateLimiter.ts @@ -1,37 +1,107 @@ import { Request, Response, NextFunction } from "express"; import { AppError } from "../shared/errorCodes"; +import { logger } from "../shared/logger"; + +// ── Sliding-window counter ───────────────────────────────────────── interface RateLimitEntry { count: number; resetAt: number; } -const store = new Map(); +type LimitStore = Map; -const WINDOW_MS = 60_000; // 1 minute -const MAX_REQUESTS = 60; // 60 requests per minute +function check(store: LimitStore, key: string, windowMs: number, max: number): boolean { + const now = Date.now(); + const entry = store.get(key); -// Cleanup expired entries every 5 minutes -setInterval(() => { + if (!entry || now > entry.resetAt) { + store.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + + entry.count++; + return entry.count <= max; +} + +// ── Stores (one per tier) ────────────────────────────────────────── + +const ipStore = new Map(); +const userStore = new Map(); +const prepareStore = new Map(); + +// ── Limits ───────────────────────────────────────────────────────── + +const IP_WINDOW_MS = 60_000; // 1 min +const IP_MAX = 60; // 60 req/min per IP + +const USER_WINDOW_MS = 60_000; // 1 min +const USER_MAX = 30; // 30 req/min per wallet + +const PREPARE_WINDOW_MS = 10_000; // 10 s +const PREPARE_MAX = 10; // 10 prepare-* calls per 10s + +// ── Cleanup expired entries every 5 min ──────────────────────────── + +function sweep(store: LimitStore): void { const now = Date.now(); for (const [key, entry] of store) { if (now > entry.resetAt) store.delete(key); } +} + +setInterval(() => { + sweep(ipStore); + sweep(userStore); + sweep(prepareStore); }, 300_000); +// ── Resolve wallet address from request ──────────────────────────── + +function extractWallet(req: Request): string | null { + // Body fields used across modules + const addr = + req.body?.userAddress || + req.body?.address || + req.params?.userAddress || + null; + if (typeof addr === "string" && /^0x[a-fA-F0-9]{40}$/.test(addr)) { + return addr.toLowerCase(); + } + return null; +} + +// ── Is this a prepare-* endpoint? ────────────────────────────────── + +function isPrepareEndpoint(path: string): boolean { + return /\/prepare-/.test(path); +} + +// ── Middleware ────────────────────────────────────────────────────── + export function rateLimiter(req: Request, _res: Response, next: NextFunction) { - const key = req.ip ?? "unknown"; - const now = Date.now(); - const entry = store.get(key); + const ip = req.ip ?? "unknown"; - if (!entry || now > entry.resetAt) { - store.set(key, { count: 1, resetAt: now + WINDOW_MS }); - return next(); + // 1. Per-IP limit (all endpoints) + if (!check(ipStore, ip, IP_WINDOW_MS, IP_MAX)) { + logger.warn({ ip, tier: "ip", limit: IP_MAX }, "Rate limit exceeded"); + return next(new AppError("RATE_LIMIT_EXCEEDED", `IP rate limit exceeded (${IP_MAX} req/${IP_WINDOW_MS / 1000}s)`)); } - entry.count++; - if (entry.count > MAX_REQUESTS) { - return next(new AppError("RATE_LIMIT_EXCEEDED")); + // 2. Per-user limit (when wallet address is available) + const wallet = extractWallet(req); + if (wallet && !check(userStore, wallet, USER_WINDOW_MS, USER_MAX)) { + logger.warn({ wallet, tier: "user", limit: USER_MAX }, "Rate limit exceeded"); + return next(new AppError("RATE_LIMIT_EXCEEDED", `Wallet rate limit exceeded (${USER_MAX} req/${USER_WINDOW_MS / 1000}s)`)); + } + + // 3. Per-key burst limit on prepare-* endpoints + if (isPrepareEndpoint(req.path)) { + const prepareKey = wallet || ip; + if (!check(prepareStore, prepareKey, PREPARE_WINDOW_MS, PREPARE_MAX)) { + logger.warn({ key: prepareKey, tier: "prepare", limit: PREPARE_MAX }, "Rate limit exceeded"); + return next(new AppError("RATE_LIMIT_EXCEEDED", `Prepare endpoint rate limit exceeded (${PREPARE_MAX} req/${PREPARE_WINDOW_MS / 1000}s)`)); + } } next(); From 4622206eb4a444ee90b64ff1c6bb33f90a7fb280 Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Mon, 30 Mar 2026 11:39:03 -0300 Subject: [PATCH 6/7] ADD - test end to end --- CONTRACTS.md | 140 ++++ README.md | 169 ++++- backend/src/__tests__/e2e/demo-flow.test.ts | 649 ++++++++++++++++++ .../src/shared/services/aerodrome.service.ts | 5 +- 4 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 CONTRACTS.md create mode 100644 backend/src/__tests__/e2e/demo-flow.test.ts diff --git a/CONTRACTS.md b/CONTRACTS.md new file mode 100644 index 0000000..13f6e6e --- /dev/null +++ b/CONTRACTS.md @@ -0,0 +1,140 @@ +# CONTRACTS.md — Deployed Contract Addresses & Roles + +## Base Mainnet (Chain ID: 8453) + +### Panorama Infrastructure + +| Contract | Address | Role | +|---|---|---| +| PanoramaExecutorV2 | `0x7528861E7DD09dc9B1e5149542e897d984Ceda7f` | Single entry point — routes `execute()` calls to per-user BeaconProxy adapters | +| AerodromeAdapterV2 | `0x187e499afB2DE75836800ad19147e0cFcd2Dc715` | Beacon implementation for Aerodrome (swap, LP, stake/unstake, claim) | +| DCAVault | `0x155eC4256cC6f11f3d4C21Af28a2a1CC31f730d1` | Dollar-cost averaging vault (uses IPanoramaExecutor interface) | + +### Aerodrome Finance (DEX + Gauges) + +| Contract | Address | Role | +|---|---|---| +| Router | `0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43` | AMM router for swaps and liquidity | +| Factory | `0x420DD381b31aEf6683db6B902084cB0FFECe40Da` | Pool factory (immutable pool addresses) | +| Voter | `0x16613524e02ad97eDfeF371bC883F2F5d6C480A5` | Gauge registry — maps pool -> gauge | + +### Tokens + +| Token | Address | Decimals | +|---|---|---| +| WETH | `0x4200000000000000000000000000000000000006` | 18 | +| USDC | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | 6 | +| USDbC | `0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA` | 6 | +| AERO | `0x940181a94A35A4569E4529A3CDfB74e38FD98631` | 18 | +| cbBTC | `0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf` | 8 | +| wstETH | `0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452` | 18 | +| cbETH | `0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22` | 18 | +| DAI | `0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb` | 18 | + +--- + +## Avalanche C-Chain (Chain ID: 43114) + +### Panorama Infrastructure + +| Contract | Address | Role | +|---|---|---| +| PanoramaExecutorV2 | `0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12` | Single entry point — same pattern as Base | + +### Trader Joe V1 (DEX) + +| Contract | Address | Role | +|---|---|---| +| Router | `0x60aE616a2155Ee3d9A68541Ba4544862310933d4` | AMM router for swaps | + +Protocol ID: `keccak256("traderjoe")` + +### Benqi Finance (Lending) + +| Contract | Address | Role | +|---|---|---| +| Comptroller | `0x486Af39519B4Dc9a7fCcd318217352830E8AD9b4` | Lending market controller | +| qiAVAX | `0x5C0401e81Bc07Ca70fAD469b451682c0d747Ef1c` | AVAX lending market (qToken) | +| qiUSDC.e | `0xBEb5d47A3f720Ec0a390d04b4d41ED7d9688bC7F` | USDC.e lending market | +| qiUSDT | `0xc9e5999b8e75C3fEB117F6f73E664b9f3C8ca65C` | USDT lending market | +| qiETH | `0x334AD834Cd4481BB02d09615E7c11a00579A7909` | WETH.e lending market | + +Protocol ID: `keccak256("benqi")` + +### sAVAX (Liquid Staking) + +| Contract | Address | Role | +|---|---|---| +| StakedAvax (sAVAX) | `0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE` | AVAX liquid staking derivative | + +Protocol ID: `keccak256("savax")` + +### Tokens + +| Token | Address | Decimals | +|---|---|---| +| WAVAX | `0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7` | 18 | +| USDC | `0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E` | 6 | +| USDCe | `0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664` | 6 | +| USDT | `0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7` | 6 | +| USDTe | `0xc7198437980c041c805A1EDcbA50c1Ce5db95118` | 6 | +| WETHe | `0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB` | 18 | +| sAVAX | `0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE` | 18 | + +--- + +## Architecture + +### BeaconProxy Pattern (V2) + +``` +User -> PanoramaExecutorV2.execute(protocolId, action, transfers, deadline, data) + | + +-- looks up UpgradeableBeacon for protocolId + +-- creates or retrieves user's BeaconProxy + +-- pulls tokens from user to proxy (transfers[]) + +-- calls proxy.call(action ++ data) -- blind dispatch + | + +-- BeaconProxy delegates to Adapter implementation +``` + +- `beacon.upgradeTo(newImpl)` upgrades ALL users at once +- Adapters use `Initializable` + `__gap[50]` for storage stability +- Executor never contains action-specific logic + +### Protocol IDs + +Protocol IDs are `bytes32 = keccak256(protocolName)`. Backend uses `encodeProtocolId("name")` from `utils/encoding.ts`. + +| Protocol | Name String | Chain | +|---|---|---| +| Aerodrome | `"aerodrome"` | Base | +| Trader Joe | `"traderjoe"` | Avalanche | +| Benqi | `"benqi"` | Avalanche | +| sAVAX | `"savax"` | Avalanche | + +### Adapter Conventions + +All V2 adapters share: +- `initializeFull(address _executor, bytes calldata _initArgs) external initializer` +- `onlyExecutor` modifier (reverts with `OnlyExecutor()` custom error) +- `receive() external payable {}` +- `uint256[50] private __gap` for upgrade safety +- Custom errors (no `require` strings) + +Known differences (deployed, cannot change): +- **AerodromeAdapterV2**: uses `SafeTransferLib` + double `safeApprove(0); safeApprove(amt)` +- **Avax adapters**: use OpenZeppelin `SafeERC20` + `forceApprove()` +- **BenqiLendAdapter**: has parameterized error `BenqiError(uint256)` for Comptroller error codes + +### Environment Variables + +```bash +# Base +BASE_RPC_URLS=https://base.llamarpc.com,https://mainnet.base.org,https://base.drpc.org +EXECUTOR_ADDRESS=0x7528861E7DD09dc9B1e5149542e897d984Ceda7f + +# Avalanche +AVAX_RPC_URLS=https://api.avax.network/ext/bc/C/rpc,https://avalanche.drpc.org,https://avax.meowrpc.com +AVAX_EXECUTOR_ADDRESS=0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12 +``` diff --git a/README.md b/README.md index fbe69d6..b55cfa8 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ forge test -vv --no-match-path "test/fork/*" # Fork tests (requires RPC) BASE_RPC_URL=https://mainnet.base.org forge test --match-path "test/fork/*" -vvv -# Backend (Vitest) — 138 tests +# Backend (Vitest) — 187 tests cd backend && npm test ``` @@ -430,3 +430,170 @@ No changes needed to the executor or BundleBuilder core. | Testing | Foundry (Solidity), Vitest (TypeScript) | | Chains | Base (8453), Avalanche C-Chain (43114) | | Protocols | Aerodrome Finance, Trader Joe, Benqi Finance, BENQI sAVAX | + +--- + +## Backend Infrastructure + +### Caching (`shared/cache.ts`) + +TTL-based cache with stale fallback for graceful degradation: + +```typescript +const myCache = createCache(); +setCache(myCache, key, value, 30_000); // 30s TTL +const fresh = getCached(myCache, key); // null if expired +const stale = getStale(myCache, key); // { value, stale: true } if expired but exists +``` + +**Cache tiers across the backend:** + +| Data | TTL | Rationale | +|---|---|---| +| Pool addresses | 10 min | Immutable on-chain | +| Gauge addresses | 5 min | Can change via governance | +| Token metadata (symbol/decimals) | 1 hour | Never changes | +| Gauge reward rate | 60s | Updates per epoch | +| Portfolio per user | 30s | Balances change frequently | +| DexScreener metrics | 30s | External API | +| Wallet balances | 90s | Moderate refresh | + +All caches use **in-flight dedup** (`Map>`) to prevent thundering herd on concurrent requests for the same key. + +### RPC Provider Failover (`providers/chain.provider.ts`) + +Multiple free RPC endpoints per chain with automatic failover: + +``` +Primary RPC (3.5s timeout) + ↓ fail +Parallel race across fallback RPCs (3.5s each) + ↓ fail +Mark primary as "sick" (30s cooldown), retry next request on fallback +``` + +**Default RPCs:** +- **Base**: LlamaRPC, Base official, dRPC +- **Avalanche**: Avalanche official, dRPC, MeowRPC + +Configured via `BASE_RPC_URLS` / `AVAX_RPC_URLS` (comma-separated). Health tracking with 30s recovery window. + +### Structured Logging (`shared/logger.ts`) + +Zero-dependency structured logger with per-request trace IDs: + +```typescript +logger.info({ protocol: "aerodrome", pool: "WETH/USDC", durationMs: 45 }, "Quote obtained"); +// → {"level":"info","traceId":"abc-123","protocol":"aerodrome","pool":"WETH/USDC","durationMs":45,"msg":"Quote obtained","ts":"2026-03-30T..."} +``` + +- **`AsyncLocalStorage`** propagates `traceId` across async call chains +- **JSON** output in production, **colored text** in development +- **Tracing middleware** (`middleware/tracing.ts`) auto-generates UUID per request and logs on response finish + +### Rate Limiting (`middleware/rateLimiter.ts`) + +Three-tier sliding-window rate limiter: + +| Tier | Scope | Window | Max | +|---|---|---|---| +| IP | All endpoints | 60s | 60 req | +| Wallet | Per wallet address | 60s | 30 req | +| Prepare | `prepare-*` endpoints | 10s | 10 req | + +Cascading check: IP → Wallet → Prepare. Expired entries cleaned every 5 minutes. + +### Stale Fallback Pattern + +On data fetch failure, the backend returns the last known good value instead of erroring: + +``` +Fresh fetch succeeds → cache + return +Fresh fetch fails → check stale cache + ├── stale exists → return { ...data, stale: true } + └── no stale → throw error +``` + +Applied to: portfolio, protocol info, DexScreener metrics, wallet balances. + +### Error Codes (`shared/errorCodes.ts`) + +Standardized error responses via `AppError`: + +| Category | Codes | HTTP | +|---|---|---| +| Validation | `INVALID_ADDRESS`, `INVALID_AMOUNT`, `MISSING_FIELD`, `INVALID_SLIPPAGE` | 400 | +| Not Found | `POOL_NOT_FOUND`, `GAUGE_NOT_FOUND`, `ORDER_NOT_FOUND` | 404 | +| Client | `INSUFFICIENT_BALANCE`, `NO_LIQUIDITY`, `NO_LP_POSITION`, `NO_REWARDS` | 400 | +| Auth | `INVALID_SIGNATURE`, `AUTH_EXPIRED` | 401 | +| Rate Limit | `RATE_LIMIT_EXCEEDED` | 429 | +| Server | `RPC_ERROR`, `PROVIDER_ERROR`, `INTERNAL_ERROR` | 500/502 | + +### Cross-Chain Routing (Interface Only) + +Domain ports for future bridge integration: + +- **`domain/ports/RoutingPort.ts`** — aggregator: `getRoutes()`, `executeRoute()`, `getRouteStatus()` +- **`domain/ports/CrossChainMessagingPort.ts`** — per-protocol adapter (Wormhole, CCIP, LayerZero, LI.FI) +- **`types/cross-chain.ts`** — shared types: `CrossChainRoute`, `CrossChainFee`, `MessageStatus`, etc. + +No implementation yet — interfaces ready for LI.FI or equivalent. + +### Middleware Stack + +Request pipeline (in order): + +``` +tracing → CORS → rateLimiter → serializeByUser → validation → executionTimeout → handler → errorHandler +``` + +| Middleware | File | Purpose | +|---|---|---| +| `tracingMiddleware` | `middleware/tracing.ts` | UUID traceId per request | +| `rateLimiter` | `middleware/rateLimiter.ts` | 3-tier rate limiting | +| `serializeByUser` | `middleware/serialize-by-user.ts` | Queue concurrent requests per wallet | +| `validation` | `middleware/validation.ts` | Address, amount, tx hash, slippage checks | +| `executionTimeout` | `middleware/execution-timeout.ts` | 15s hard timeout per request | +| `errorHandler` | `middleware/errorHandler.ts` | AppError → structured JSON response | + +--- + +## Test Coverage + +```bash +cd backend && npm test +# 187 tests across 10 test suites +``` + +| Suite | Tests | Coverage | +|---|---|---| +| `e2e/demo-flow` | 49 | Full H5 demo flow (12 iterations), fallback messaging, bundle invariants | +| `integration/routes` | 16 | Swap + staking + claim + exit bundles via usecases | +| `modules/swap/get-quote` | 16 | Auto pool selection, slippage, exchange rate | +| `modules/swap/prepare-swap` | 11 | Approve logic, ETH handling, metadata | +| `modules/liquid-staking/prepare-enter` | 10 | Balance capping, liquidity quote, 5-step bundle | +| `modules/liquid-staking/prepare-exit` | 12 | Partial/full exit, unstake + removeLiquidity | +| `modules/liquid-staking/prepare-claim` | 8 | Reward check, single-step bundle | +| `shared/bundle-builder` | 25 | Selectors, approve logic, encode/decode | +| `shared/aerodrome-add-liquidity` | 11 | Allowance checks, slippage, stake amount | +| `shared/services/aerodrome.service` | 29 | Caching, in-flight dedup, retry, timeout | + +### E2E Demo Flow Test (`__tests__/e2e/demo-flow.test.ts`) + +Simulates the canonical H5 user journey **12 times** for determinism: + +1. Quote swap (WETH → USDC, auto pool selection) +2. Prepare swap bundle (approve + execute) +3. Check portfolio (empty) +4. Enter staking (addLiquidity + stake) +5. Check portfolio (has position) +6. Claim rewards +7. Exit position (unstake + removeLiquidity) +8. Check portfolio (empty again) + +Plus targeted tests for every common failure mode: +- **RPC timeout** → fallback to safe defaults (assume 0 allowance, skip balance check) +- **Insufficient balance** → `INSUFFICIENT_BALANCE` with have/need amounts +- **Pool not found** → `POOL_NOT_FOUND` with pool ID in message +- **No liquidity** → `NO_LIQUIDITY` for both auto-quote and enter +- **No position / No rewards** → `NO_LP_POSITION` / `NO_REWARDS` diff --git a/backend/src/__tests__/e2e/demo-flow.test.ts b/backend/src/__tests__/e2e/demo-flow.test.ts new file mode 100644 index 0000000..42ad154 --- /dev/null +++ b/backend/src/__tests__/e2e/demo-flow.test.ts @@ -0,0 +1,649 @@ +/** + * E2E Demo Flow — H5 canonical path + * + * Simulates the full user journey 10+ times: + * 1. Quote swap (WETH → USDC) + * 2. Prepare swap bundle + * 3. Check portfolio (empty) + * 4. Enter staking position (add liquidity + stake) + * 5. Check portfolio (has position) + * 6. Claim rewards + * 7. Exit position (unstake + remove liquidity) + * 8. Check portfolio (empty again) + * + * Tests cover: + * - Deterministic output across repeated runs + * - Fallback messaging for common failures (RPC timeout, insufficient balance, pool not found) + * - Bundle structure invariants + * - Flaky path detection via iteration + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ethers } from "ethers"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const EXECUTOR = "0x7528861E7DD09dc9B1e5149542e897d984Ceda7f"; +const USER = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const WETH = "0x4200000000000000000000000000000000000006"; +const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const AERO = "0x940181a94A35A4569E4529A3CDfB74e38FD98631"; +const POOL_ADDR = "0xcDAC0d6c6C59727a65F871236188350531885C43"; +const GAUGE = "0x519BBD1Dd8C6A94C46080E24f316c14Ee758C025"; +const ETH_ADDR = "0x0000000000000000000000000000000000000000"; +const ADAPTER = "0x000000000000000000000000000000000000dEaD"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../config/chains", () => ({ + getChainConfig: vi.fn(() => ({ + chainId: 8453, + name: "Base", + contracts: { panoramaExecutor: EXECUTOR }, + })), +})); + +const { mockBalanceOf, mockGetReserves, mockTotalSupply, mockToken0 } = vi.hoisted(() => ({ + mockBalanceOf: vi.fn().mockResolvedValue(BigInt("10000000000000000000")), + mockGetReserves: vi.fn().mockResolvedValue([1_000_000_000_000_000_000n, 3_000_000_000n, 0n]), + mockTotalSupply: vi.fn().mockResolvedValue(1_000_000_000n), + mockToken0: vi.fn().mockResolvedValue("0x4200000000000000000000000000000000000006"), +})); + +vi.mock("../../providers/chain.provider", () => ({ + getContract: vi.fn(() => ({ + balanceOf: mockBalanceOf, + getReserves: mockGetReserves, + totalSupply: mockTotalSupply, + token0: mockToken0, + })), +})); + +vi.mock("../../shared/services/aerodrome.service", () => ({ + aerodromeService: { + getQuote: vi.fn(), + checkAllowance: vi.fn(), + getTokenBalance: vi.fn(), + resolvePoolAndGauge: vi.fn(), + getUserAdapterAddress: vi.fn(), + getStakedBalance: vi.fn(), + getEarnedRewards: vi.fn(), + quoteAddLiquidity: vi.fn(), + getWalletBalanceCached: vi.fn().mockReturnValue(null), + setWalletBalanceCached: vi.fn(), + withRetry: vi.fn((fn: () => Promise) => fn()), + withTimeout: vi.fn((fn: () => Promise) => fn()), + }, +})); + +vi.mock("../../utils/tokenMath", async () => { + const actual = await vi.importActual("../../utils/tokenMath"); + return { + ...actual, + getTokenDecimals: vi.fn().mockResolvedValue(18), + }; +}); + +vi.mock("../../modules/liquid-staking/config/staking-pools", () => ({ + getStakingPoolById: vi.fn((id: string) => { + if (id === "weth-usdc-volatile") { + return { + id: "weth-usdc-volatile", + name: "WETH/USDC Volatile", + poolAddress: POOL_ADDR, + gaugeAddress: GAUGE, + stable: false, + tokenA: { symbol: "WETH", address: WETH, decimals: 18 }, + tokenB: { symbol: "USDC", address: USDC, decimals: 6 }, + rewardToken: { symbol: "AERO", address: AERO, decimals: 18 }, + enabled: true, + }; + } + return undefined; + }), + getEnabledStakingPools: vi.fn(() => [{ + id: "weth-usdc-volatile", + name: "WETH/USDC Volatile", + poolAddress: POOL_ADDR, + gaugeAddress: GAUGE, + stable: false, + tokenA: { symbol: "WETH", address: WETH, decimals: 18 }, + tokenB: { symbol: "USDC", address: USDC, decimals: 6 }, + rewardToken: { symbol: "AERO", address: AERO, decimals: 18 }, + enabled: true, + }]), +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── + +import { aerodromeService } from "../../shared/services/aerodrome.service"; +import { executeGetSwapQuote } from "../../modules/swap/usecases/get-quote.usecase"; +import { executePrepareSwapBundle } from "../../modules/swap/usecases/prepare-swap.usecase"; +import { executeEnterStrategy } from "../../modules/liquid-staking/usecases/prepare-enter-strategy.usecase"; +import { executeExitStrategy } from "../../modules/liquid-staking/usecases/prepare-exit-strategy.usecase"; +import { executeClaimRewards } from "../../modules/liquid-staking/usecases/prepare-claim-rewards.usecase"; +import { executeGetPortfolio } from "../../modules/liquid-staking/usecases/get-portfolio.usecase"; +import { ADAPTER_SELECTORS, PANORAMA_EXECUTOR_ABI_EXECUTE } from "../../shared/bundle-builder"; +import { AppError } from "../../shared/errorCodes"; + +const svc = vi.mocked(aerodromeService); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function decodeExecuteStep(data: string) { + const iface = new ethers.Interface(PANORAMA_EXECUTOR_ABI_EXECUTE); + return iface.decodeFunctionData("execute", data); +} + +function findStepBySelector(steps: Array<{ to: string; data: string }>, selector: string) { + return steps.find((s) => { + if (s.to.toLowerCase() !== EXECUTOR.toLowerCase()) return false; + try { + return decodeExecuteStep(s.data)[1] === selector; + } catch { + return false; + } + }); +} + +/** Reset all mocks to a clean happy-path state. */ +function resetToHappyPath() { + vi.clearAllMocks(); + + // Quote + svc.getQuote.mockResolvedValue({ amountOut: 3_000_000n, route: [] }); + + // Allowances: insufficient → forces approve steps + svc.checkAllowance.mockResolvedValue({ allowance: 0n, sufficient: false }); + + // Balances + svc.getTokenBalance.mockResolvedValue(10_000_000_000_000_000_000n); + mockBalanceOf.mockResolvedValue(BigInt("10000000000000000000")); + + // Pool / Gauge resolution + svc.resolvePoolAndGauge.mockResolvedValue({ poolAddress: POOL_ADDR, gaugeAddress: GAUGE }); + + // Adapter address + svc.getUserAdapterAddress.mockResolvedValue(ADAPTER); + + // Staking + svc.getStakedBalance.mockResolvedValue(500_000n); + svc.getEarnedRewards.mockResolvedValue(100_000_000_000_000_000n); // 0.1 AERO + + // Liquidity quote + svc.quoteAddLiquidity.mockResolvedValue({ + optimalA: 1_000_000_000_000_000_000n, + optimalB: 3_000_000n, + estimatedLiquidity: 1_000_000n, + }); + + // Wallet balance cache + svc.getWalletBalanceCached.mockReturnValue(null); + svc.setWalletBalanceCached.mockImplementation(() => {}); + + // Pass-through helpers + svc.withRetry.mockImplementation((fn: () => Promise) => fn()); + svc.withTimeout.mockImplementation((fn: () => Promise) => fn()); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FULL DEMO FLOW — 12 iterations for determinism +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("E2E Demo Flow — canonical path (H5)", () => { + const ITERATIONS = 12; + const results: Array<{ + quoteAmountOut: string; + swapSteps: number; + enterSteps: number; + portfolioPositions: number; + claimSteps: number; + exitSteps: number; + portfolioAfterPositions: number; + }> = []; + + beforeEach(resetToHappyPath); + + // Each iteration uses a unique user address to avoid portfolio cache collisions. + // The portfolio module caches per-address with 30s TTL — using the same address + // across iterations returns stale data from a previous run. + function userForIteration(i: number): string { + const hex = i.toString(16).padStart(40, "0"); + return `0x${hex}`; + } + + for (let i = 1; i <= ITERATIONS; i++) { + it(`iteration ${i}: full flow produces consistent output`, async () => { + resetToHappyPath(); + const user = userForIteration(i); + + // ── Step 1: Quote swap ────────────────────────────────────────── + const quote = await executeGetSwapQuote({ + tokenIn: WETH, + tokenOut: USDC, + amountIn: "1000000000000000000", // 1 WETH + stable: "auto", + }); + + expect(quote.amountOut).toBe("3000000"); + expect(quote.tokenIn).toBe(WETH); + expect(quote.tokenOut).toBe(USDC); + expect(typeof quote.exchangeRate).toBe("string"); + expect(Number(quote.amountOutMin)).toBeLessThanOrEqual(Number(quote.amountOut)); + + // ── Step 2: Prepare swap bundle ───────────────────────────────── + const swap = await executePrepareSwapBundle({ + userAddress: user, + tokenIn: WETH, + tokenOut: USDC, + amountIn: "1000000000000000000", + amountOutPrecomputed: quote.amountOut, + }); + + expect(swap.bundle.steps.length).toBeGreaterThanOrEqual(1); + expect(swap.bundle.steps.length).toBeLessThanOrEqual(2); + for (const step of swap.bundle.steps) { + expect(step.chainId).toBe(8453); + expect(step.data).toBeTruthy(); + } + const swapStep = findStepBySelector(swap.bundle.steps, ADAPTER_SELECTORS.SWAP); + expect(swapStep).toBeDefined(); + expect(swap.metadata.amountOut).toBe(quote.amountOut); + + // ── Step 3: Check portfolio (before staking) ──────────────────── + svc.getStakedBalance.mockResolvedValue(0n); + svc.getEarnedRewards.mockResolvedValue(0n); + + const portfolioBefore = await executeGetPortfolio(user); + expect(portfolioBefore.userAddress).toBe(user); + expect(portfolioBefore.totalPositions).toBe(0); + expect(portfolioBefore.assets).toHaveLength(0); + expect(portfolioBefore.walletBalances).toBeDefined(); + + // ── Step 4: Enter staking position ────────────────────────────── + resetToHappyPath(); + + const enter = await executeEnterStrategy({ + userAddress: user, + poolId: "weth-usdc-volatile", + amountA: "1000000000000000000", + amountB: "3000000", + }); + + expect(enter.bundle.steps.length).toBeGreaterThanOrEqual(3); + expect(enter.bundle.totalSteps).toBe(enter.bundle.steps.length); + expect(findStepBySelector(enter.bundle.steps, ADAPTER_SELECTORS.ADD_LIQUIDITY)).toBeDefined(); + expect(findStepBySelector(enter.bundle.steps, ADAPTER_SELECTORS.STAKE)).toBeDefined(); + expect(enter.metadata.poolAddress).toBe(POOL_ADDR); + expect(enter.metadata.gaugeAddress).toBe(GAUGE); + + // ── Step 5: Check portfolio (has position) ────────────────────── + // Use a different "user" address so portfolio cache doesn't return step 3 result + const userWithPosition = `0x${"A".repeat(38)}${i.toString(16).padStart(2, "0")}`; + svc.getStakedBalance.mockResolvedValue(500_000n); + svc.getEarnedRewards.mockResolvedValue(100_000_000_000_000_000n); + + const portfolioAfterEnter = await executeGetPortfolio(userWithPosition); + expect(portfolioAfterEnter.totalPositions).toBe(1); + expect(portfolioAfterEnter.assets[0].poolId).toBe("weth-usdc-volatile"); + expect(BigInt(portfolioAfterEnter.assets[0].lpStaked)).toBeGreaterThan(0n); + + // ── Step 6: Claim rewards ─────────────────────────────────────── + const claim = await executeClaimRewards({ + userAddress: user, + poolId: "weth-usdc-volatile", + }); + + expect(claim.bundle.steps).toHaveLength(1); + expect(findStepBySelector(claim.bundle.steps, ADAPTER_SELECTORS.CLAIM_REWARDS)).toBeDefined(); + expect(claim.metadata.earnedRewards).toBe("100000000000000000"); + expect(claim.metadata.rewardToken.symbol).toBe("AERO"); + + // ── Step 7: Exit position ─────────────────────────────────────── + const exit = await executeExitStrategy({ + userAddress: user, + poolId: "weth-usdc-volatile", + }); + + expect(exit.bundle.steps.length).toBeGreaterThanOrEqual(2); + expect(findStepBySelector(exit.bundle.steps, ADAPTER_SELECTORS.UNSTAKE)).toBeDefined(); + expect(findStepBySelector(exit.bundle.steps, ADAPTER_SELECTORS.REMOVE_LIQUIDITY)).toBeDefined(); + expect(exit.metadata.poolAddress).toBe(POOL_ADDR); + + // ── Step 8: Check portfolio (empty again) ─────────────────────── + const userAfterExit = `0x${"B".repeat(38)}${i.toString(16).padStart(2, "0")}`; + svc.getStakedBalance.mockResolvedValue(0n); + svc.getEarnedRewards.mockResolvedValue(0n); + + const portfolioAfterExit = await executeGetPortfolio(userAfterExit); + expect(portfolioAfterExit.totalPositions).toBe(0); + + // Record results for determinism check + results.push({ + quoteAmountOut: quote.amountOut, + swapSteps: swap.bundle.steps.length, + enterSteps: enter.bundle.steps.length, + portfolioPositions: portfolioAfterEnter.totalPositions, + claimSteps: claim.bundle.steps.length, + exitSteps: exit.bundle.steps.length, + portfolioAfterPositions: portfolioAfterExit.totalPositions, + }); + }); + } + + it("all iterations produce identical results (determinism check)", () => { + expect(results.length).toBe(ITERATIONS); + const reference = results[0]; + for (let i = 1; i < results.length; i++) { + expect(results[i]).toEqual(reference); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// FALLBACK MESSAGING — common failure modes +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("Fallback messaging — common failures", () => { + beforeEach(resetToHappyPath); + + // ── RPC timeout on quote ────────────────────────────────────────── + + describe("RPC timeout", () => { + it("quote: both pools fail → AppError RPC_ERROR with retry message", async () => { + svc.getQuote.mockRejectedValue(new Error("timeout")); + + const err = await executeGetSwapQuote({ + tokenIn: WETH, tokenOut: USDC, amountIn: "1000", stable: "auto", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("RPC_ERROR"); + expect(err.message).toMatch(/try again/i); + }); + + it("quote: volatile fails but stable succeeds → returns stable result", async () => { + svc.getQuote + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce({ amountOut: 2500n, route: [] }); + + const result = await executeGetSwapQuote({ + tokenIn: WETH, tokenOut: USDC, amountIn: "1000", stable: "auto", + }); + + expect(result.stable).toBe(true); + expect(result.amountOut).toBe("2500"); + }); + + it("swap: allowance timeout → assumes 0, adds approve step (safe fallback)", async () => { + svc.checkAllowance.mockRejectedValue(new Error("timeout")); + svc.getTokenBalance.mockResolvedValue(999999n); + + const { bundle } = await executePrepareSwapBundle({ + userAddress: USER, tokenIn: WETH, tokenOut: USDC, amountIn: "1000", + }); + + // Should still have approve step (assumed 0 allowance) + expect(bundle.steps).toHaveLength(2); + expect(bundle.steps[0].to.toLowerCase()).toBe(WETH.toLowerCase()); + }); + + it("swap: balance timeout → skips check, bundle still built", async () => { + svc.checkAllowance.mockResolvedValue({ allowance: 99999n, sufficient: true }); + svc.getTokenBalance.mockRejectedValue(new Error("timeout")); + + const { bundle } = await executePrepareSwapBundle({ + userAddress: USER, tokenIn: WETH, tokenOut: USDC, amountIn: "1000", + }); + + expect(bundle.steps.length).toBeGreaterThanOrEqual(1); + expect(findStepBySelector(bundle.steps, ADAPTER_SELECTORS.SWAP)).toBeDefined(); + }); + }); + + // ── Insufficient balance ────────────────────────────────────────── + + describe("Insufficient balance", () => { + it("swap: balance < amountIn → AppError INSUFFICIENT_BALANCE", async () => { + svc.getTokenBalance.mockResolvedValue(500n); // have 500, need 1000 + + const err = await executePrepareSwapBundle({ + userAddress: USER, tokenIn: WETH, tokenOut: USDC, amountIn: "1000", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("INSUFFICIENT_BALANCE"); + expect(err.message).toMatch(/500.*1000/); + }); + + it("enter: zero balance after cap → AppError INSUFFICIENT_BALANCE", async () => { + mockBalanceOf.mockResolvedValue(0n); + + const err = await executeEnterStrategy({ + userAddress: USER, poolId: "weth-usdc-volatile", + amountA: "1000000000000000000", amountB: "3000000", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("INSUFFICIENT_BALANCE"); + }); + }); + + // ── Pool not found ──────────────────────────────────────────────── + + describe("Pool not found", () => { + it("enter: unknown poolId → AppError POOL_NOT_FOUND", async () => { + const err = await executeEnterStrategy({ + userAddress: USER, poolId: "nonexistent-pool", + amountA: "1000", amountB: "1000", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("POOL_NOT_FOUND"); + expect(err.message).toMatch(/nonexistent-pool/); + }); + + it("exit: unknown poolId → AppError POOL_NOT_FOUND", async () => { + const err = await executeExitStrategy({ + userAddress: USER, poolId: "nonexistent-pool", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("POOL_NOT_FOUND"); + }); + + it("claim: unknown poolId → AppError POOL_NOT_FOUND", async () => { + const err = await executeClaimRewards({ + userAddress: USER, poolId: "nonexistent-pool", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("POOL_NOT_FOUND"); + }); + }); + + // ── No liquidity ────────────────────────────────────────────────── + + describe("No liquidity", () => { + it("quote auto: both pools return 0 → AppError NO_LIQUIDITY", async () => { + svc.getQuote.mockResolvedValue({ amountOut: 0n, route: [] }); + + const err = await executeGetSwapQuote({ + tokenIn: WETH, tokenOut: USDC, amountIn: "1000", stable: "auto", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("NO_LIQUIDITY"); + }); + + it("enter: estimatedLiquidity = 0 → AppError NO_LIQUIDITY", async () => { + svc.quoteAddLiquidity.mockResolvedValue({ + optimalA: 0n, optimalB: 0n, estimatedLiquidity: 0n, + }); + + const err = await executeEnterStrategy({ + userAddress: USER, poolId: "weth-usdc-volatile", + amountA: "1000", amountB: "1000", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("NO_LIQUIDITY"); + }); + }); + + // ── No LP position / No rewards ─────────────────────────────────── + + describe("No position / No rewards", () => { + it("exit: no staked + no wallet LP → AppError NO_LP_POSITION", async () => { + svc.getStakedBalance.mockResolvedValue(0n); + svc.getTokenBalance.mockResolvedValue(0n); + + const err = await executeExitStrategy({ + userAddress: USER, poolId: "weth-usdc-volatile", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("NO_LP_POSITION"); + }); + + it("claim: no rewards → AppError NO_REWARDS", async () => { + svc.getEarnedRewards.mockResolvedValue(0n); + + const err = await executeClaimRewards({ + userAddress: USER, poolId: "weth-usdc-volatile", + }).catch((e) => e); + + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe("NO_REWARDS"); + }); + }); + + // ── ETH native swap (no approve) ───────────────────────────────── + + describe("ETH native swap edge case", () => { + it("ETH → USDC: no approve, value set on execute step", async () => { + const { bundle } = await executePrepareSwapBundle({ + userAddress: USER, tokenIn: ETH_ADDR, tokenOut: USDC, + amountIn: "500000000000000000", + }); + + expect(bundle.steps).toHaveLength(1); + expect(bundle.steps[0].value).toBe("500000000000000000"); + expect(svc.checkAllowance).not.toHaveBeenCalled(); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// BUNDLE STRUCTURE INVARIANTS — cross-cutting checks +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("Bundle structure invariants (all operations)", () => { + beforeEach(resetToHappyPath); + + const operations = [ + { + name: "swap", + run: () => executePrepareSwapBundle({ + userAddress: USER, tokenIn: WETH, tokenOut: USDC, amountIn: "1000", + }).then((r) => r.bundle), + }, + { + name: "enter", + run: () => executeEnterStrategy({ + userAddress: USER, poolId: "weth-usdc-volatile", + amountA: "1000000000000000000", amountB: "3000000", + }).then((r) => r.bundle), + }, + { + name: "exit", + run: () => executeExitStrategy({ + userAddress: USER, poolId: "weth-usdc-volatile", + }).then((r) => r.bundle), + }, + { + name: "claim", + run: () => executeClaimRewards({ + userAddress: USER, poolId: "weth-usdc-volatile", + }).then((r) => r.bundle), + }, + ]; + + for (const op of operations) { + it(`${op.name}: all steps have chainId 8453`, async () => { + resetToHappyPath(); + const bundle = await op.run(); + for (const step of bundle.steps) { + expect(step.chainId).toBe(8453); + } + }); + + it(`${op.name}: totalSteps matches steps.length`, async () => { + resetToHappyPath(); + const bundle = await op.run(); + expect(bundle.totalSteps).toBe(bundle.steps.length); + }); + + it(`${op.name}: all steps have non-empty data`, async () => { + resetToHappyPath(); + const bundle = await op.run(); + for (const step of bundle.steps) { + expect(step.data.length).toBeGreaterThan(2); // at least "0x..." + } + }); + + it(`${op.name}: execute steps decode against ABI`, async () => { + resetToHappyPath(); + const bundle = await op.run(); + const iface = new ethers.Interface(PANORAMA_EXECUTOR_ABI_EXECUTE); + const execSteps = bundle.steps.filter( + (s) => s.to.toLowerCase() === EXECUTOR.toLowerCase() + ); + for (const step of execSteps) { + expect(() => iface.decodeFunctionData("execute", step.data)).not.toThrow(); + } + }); + + it(`${op.name}: summary is a non-empty string`, async () => { + resetToHappyPath(); + const bundle = await op.run(); + expect(typeof bundle.summary).toBe("string"); + expect(bundle.summary.length).toBeGreaterThan(0); + }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// PORTFOLIO STALE FALLBACK +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("Portfolio stale fallback", () => { + beforeEach(resetToHappyPath); + + it("returns fresh data with lastUpdated and correct shape", async () => { + // Use a unique address to avoid cache from other tests + const uniqueUser = "0x" + "C".repeat(40); + svc.getStakedBalance.mockResolvedValue(500_000n); + svc.getEarnedRewards.mockResolvedValue(100_000_000_000_000_000n); + + const fresh = await executeGetPortfolio(uniqueUser); + expect(fresh.stale).toBeUndefined(); + expect(fresh.lastUpdated).toBeDefined(); + expect(fresh.totalPositions).toBe(1); + expect(fresh.assets[0].poolId).toBe("weth-usdc-volatile"); + }); + + it("returns cached data on subsequent call within TTL", async () => { + const uniqueUser = "0x" + "D".repeat(40); + svc.getStakedBalance.mockResolvedValue(500_000n); + svc.getEarnedRewards.mockResolvedValue(100_000_000_000_000_000n); + + const first = await executeGetPortfolio(uniqueUser); + + // Change mocks — if cache works, second call returns same data + svc.getStakedBalance.mockResolvedValue(999_999n); + const second = await executeGetPortfolio(uniqueUser); + + expect(second.totalPositions).toBe(first.totalPositions); + expect(second.lastUpdated).toBe(first.lastUpdated); + }); +}); diff --git a/backend/src/shared/services/aerodrome.service.ts b/backend/src/shared/services/aerodrome.service.ts index b48b596..d341d8c 100644 --- a/backend/src/shared/services/aerodrome.service.ts +++ b/backend/src/shared/services/aerodrome.service.ts @@ -1,8 +1,9 @@ import { ethers } from "ethers"; import { getContract } from "../../providers/chain.provider"; import { getProtocolConfig, getUserAdapterAddress } from "../../config/protocols"; +import { encodeProtocolId } from "../../utils/encoding"; import { AppError } from "../errorCodes"; -import { createCache, getCached, setCache, type TTLCache } from "../cache"; +import { createCache, getCached, setCache } from "../cache"; import { AERODROME_ROUTER_ABI, AERODROME_FACTORY_ABI, @@ -44,7 +45,7 @@ function resolveTokenAddress(address: string): string { } export class AerodromeService { - readonly protocolId = ethers.keccak256(ethers.toUtf8Bytes("aerodrome")); + readonly protocolId = encodeProtocolId("aerodrome"); // Delegate to protocols.ts — already has caching + retry + in-flight dedup getUserAdapterAddress = getUserAdapterAddress; From 0f059abb9a7a743db55691f5ed941f61b683dc63 Mon Sep 17 00:00:00 2001 From: txrmarcos Date: Tue, 31 Mar 2026 19:38:44 -0300 Subject: [PATCH 7/7] FIX - callback --- .../usecases/prepare-borrow.usecase.ts | 2 +- .../usecases/prepare-redeem.usecase.ts | 2 +- .../usecases/prepare-repay.usecase.ts | 2 +- .../usecases/prepare-supply.usecase.ts | 2 +- .../usecases/prepare-redeem.usecase.ts | 2 +- .../prepare-request-unlock.usecase.ts | 2 +- .../usecases/prepare-stake.usecase.ts | 2 +- .../usecases/prepare-swap.usecase.ts | 2 +- .../usecases/prepare-claim-rewards.usecase.ts | 2 +- .../prepare-enter-strategy.usecase.ts | 2 +- .../usecases/prepare-exit-strategy.usecase.ts | 2 +- .../swap/usecases/prepare-swap.usecase.ts | 2 +- backend/src/shared/bundle-builder.ts | 49 +++++++++++++++++++ backend/src/types/transaction.ts | 1 + 14 files changed, 62 insertions(+), 12 deletions(-) diff --git a/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts b/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts index 55e2982..9e830c9 100644 --- a/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts +++ b/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts @@ -77,7 +77,7 @@ export async function executePrepareBorrow(req: PrepareBorrowRequest): Promise

({ to: s.to, value: s.value, dataLen: s.data.length, description: s.description })) }, "Bundle built"); const priceImpact = amountIn > 0n 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 66c92d8..09ff4e7 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 @@ -61,7 +61,7 @@ export async function executeClaimRewards( ); return { - bundle: builder.build(`Claim rewards from ${poolConfig.name}`), + bundle: await builder.buildWithGas(`Claim rewards from ${poolConfig.name}`, req.userAddress), metadata: { poolId: poolConfig.id, gaugeAddress, 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 7b99dfd..fc22340 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 @@ -143,7 +143,7 @@ async function _executeEnterStrategyInner( logger.info({ protocol: "aerodrome", durationMs: Date.now() - t0 }, "Bundle built"); return { - bundle: builder.build(`Enter staking position: ${poolConfig.name}`), + bundle: await builder.buildWithGas(`Enter staking position: ${poolConfig.name}`, req.userAddress), metadata: { poolId: poolConfig.id, poolAddress, 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 03e15bd..3614c90 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 @@ -144,7 +144,7 @@ export async function executeExitStrategy( ); return { - bundle: builder.build(`Exit staking position: ${poolConfig.name}`), + bundle: await builder.buildWithGas(`Exit staking position: ${poolConfig.name}`, req.userAddress), metadata: { poolId: poolConfig.id, poolAddress, diff --git a/backend/src/modules/swap/usecases/prepare-swap.usecase.ts b/backend/src/modules/swap/usecases/prepare-swap.usecase.ts index d344526..3473d9f 100644 --- a/backend/src/modules/swap/usecases/prepare-swap.usecase.ts +++ b/backend/src/modules/swap/usecases/prepare-swap.usecase.ts @@ -78,7 +78,7 @@ export async function executePrepareSwapBundle( : "0"; return { - bundle: builder.build(`Swap via Aerodrome (${stable ? "stable" : "volatile"} pool)`), + bundle: await builder.buildWithGas(`Swap via Aerodrome (${stable ? "stable" : "volatile"} pool)`, req.userAddress), metadata: { tokenIn: req.tokenIn, tokenOut: req.tokenOut, diff --git a/backend/src/shared/bundle-builder.ts b/backend/src/shared/bundle-builder.ts index 2476f2f..72f2e66 100644 --- a/backend/src/shared/bundle-builder.ts +++ b/backend/src/shared/bundle-builder.ts @@ -1,5 +1,7 @@ import { ethers } from "ethers"; import { PreparedTransaction, TransactionBundle } from "../types/transaction"; +import { getProvider } from "../providers/chain.provider"; +import { logger } from "./logger"; /** * Solidity function selectors for adapter actions. @@ -125,4 +127,51 @@ export class BundleBuilder { summary, }; } + + /** + * Builds the bundle and estimates gas for each step using the chain's RPC provider. + * This avoids MetaMask needing to call eth_estimateGas (which can be rate-limited). + * Adds a 30% buffer to the estimate to prevent out-of-gas failures. + */ + async buildWithGas(summary: string, fromAddress: string): Promise { + const chainName = CHAIN_ID_TO_NAME[this.chainId]; + if (!chainName) { + logger.warn({ chainId: this.chainId }, "Unknown chainId for gas estimation, returning without gas"); + return this.build(summary); + } + + const provider = getProvider(chainName); + const stepsWithGas = await Promise.all( + this.steps.map(async (step) => { + try { + const estimate = await provider.estimateGas({ + from: fromAddress, + to: step.to, + data: step.data, + value: BigInt(step.value || "0"), + }); + // 30% buffer + const buffered = (estimate * 130n) / 100n; + return { ...step, gas: `0x${buffered.toString(16)}` }; + } catch (err) { + logger.warn( + { step: step.description, error: err instanceof Error ? err.message : "unknown" }, + "Gas estimation failed for step, returning without gas" + ); + return step; + } + }) + ); + + return { + steps: stepsWithGas, + totalSteps: stepsWithGas.length, + summary, + }; + } } + +const CHAIN_ID_TO_NAME: Record = { + 8453: "base", + 43114: "avalanche", +}; diff --git a/backend/src/types/transaction.ts b/backend/src/types/transaction.ts index 1caa7d2..4ef0108 100644 --- a/backend/src/types/transaction.ts +++ b/backend/src/types/transaction.ts @@ -4,6 +4,7 @@ export interface PreparedTransaction { value: string; chainId: number; description?: string; + gas?: string; } export interface TransactionBundle {