Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions backend/src/config/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions backend/src/config/protocols.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppError } from "../shared/errorCodes";
import { logger } from "../shared/logger";

export interface ProtocolConfig {
protocolId: string;
Expand Down Expand Up @@ -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 "";
Expand Down
11 changes: 4 additions & 7 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
3 changes: 2 additions & 1 deletion backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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",
Expand Down
5 changes: 2 additions & 3 deletions backend/src/middleware/execution-timeout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "../shared/logger";

// ──────────────────────────────────────────────────────────────────
// EXECUTION TIMEOUT MIDDLEWARE
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion backend/src/middleware/serialize-by-user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "../shared/logger";

// ──────────────────────────────────────────────────────────────────
// CONSTANTS
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions backend/src/middleware/tracing.ts
Original file line number Diff line number Diff line change
@@ -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());
}
36 changes: 6 additions & 30 deletions backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,17 +41,11 @@ const WAVAX_UNWRAP_ABI = ["function withdraw(uint256 wad) external"];
export async function executePrepareAvaxSwap(
req: PrepareAvaxSwapRequest
): Promise<PrepareAvaxSwapResponse> {
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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";
import { logger } from "../../../shared/logger";

interface PortfolioAsset {
poolId: string;
Expand All @@ -20,8 +22,13 @@ export interface GetPortfolioResponse {
totalPositions: number;
assets: PortfolioAsset[];
walletBalances: Record<string, string>;
stale?: boolean;
lastUpdated?: string;
}

const portfolioCache = createCache<GetPortfolioResponse>();
const PORTFOLIO_CACHE_TTL = 30_000; // 30s

async function resolvePoolAndGauge(
pool: ReturnType<typeof getEnabledStakingPools>[number]
): Promise<{ poolAddress: string; gaugeAddress: string } | null> {
Expand All @@ -33,6 +40,27 @@ async function resolvePoolAndGauge(
}

export async function executeGetPortfolio(userAddress: string): Promise<GetPortfolioResponse> {
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) {
logger.warn({ protocol: "aerodrome", user: userAddress, lastUpdated: stale.value.lastUpdated, error: err instanceof Error ? err.message : err }, "Fetch failed, returning stale data");
return { ...stale.value, stale: true };
}
throw err;
}
}

async function fetchPortfolioFresh(userAddress: string): Promise<GetPortfolioResponse> {
const enabledPools = getEnabledStakingPools();

// Run wallet balances, adapter lookup, and pool resolution ALL in parallel
Expand All @@ -55,15 +83,10 @@ export async function executeGetPortfolio(userAddress: string): Promise<GetPortf
const cached = aerodromeService.getWalletBalanceCached(userAddress, symbol);
if (cached !== null) {
walletBalances[symbol] = cached;
console.warn(
`[PORTFOLIO] balance lookup failed for ${symbol} user=${userAddress}; using cached value=${cached}`
);
logger.warn({ protocol: "aerodrome", user: userAddress, token: symbol, cachedValue: cached }, "Balance lookup failed, using cached value");
return;
}
console.warn(
`[PORTFOLIO] balance lookup failed for ${symbol} user=${userAddress}:`,
err instanceof Error ? err.message : err
);
logger.warn({ protocol: "aerodrome", user: userAddress, token: symbol, error: err instanceof Error ? err.message : err }, "Balance lookup failed, defaulting to 0");
walletBalances[symbol] = "0";
}
});
Expand All @@ -82,7 +105,7 @@ export async function executeGetPortfolio(userAddress: string): Promise<GetPortf
Promise.all(poolDataPromises),
]);

console.log(`[PORTFOLIO] user=${userAddress}, adapter=${userAdapter}, resolvedPools=${poolResults.filter(Boolean).length}`);
logger.info({ protocol: "aerodrome", user: userAddress, adapter: userAdapter, resolvedPools: poolResults.filter(Boolean).length }, "Portfolio lookup started");

// Fetch staking positions for resolved pools (in parallel)
const assets: PortfolioAsset[] = [];
Expand All @@ -96,7 +119,7 @@ export async function executeGetPortfolio(userAddress: string): Promise<GetPortf
? await aerodromeService.withRetry(() => 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");
Expand Down Expand Up @@ -142,5 +165,6 @@ export async function executeGetPortfolio(userAddress: string): Promise<GetPortf
totalPositions: assets.length,
assets,
walletBalances,
lastUpdated: new Date().toISOString(),
};
}
Loading
Loading