Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2a8e7bf
feat: implement LI.FI intents API integration and enhance composer ro…
Timidan May 23, 2026
e27f0e7
feat: register Mezo Testnet (31611) and Mainnet (31612) chains
Timidan May 24, 2026
1b2148a
feat: chain-aware EDB bridge routing
Timidan May 24, 2026
632f87c
feat: add Mezo Lens integration (Stack, Borrow, Save, Lock, Swap, Liq…
Timidan May 24, 2026
1bb6c4e
feat: chain-aware simulation trace and Blockscout source provenance
Timidan May 24, 2026
d5407dd
feat: bump SimulationHistory IDB schema v2 → v3
Timidan May 24, 2026
05a7981
chore: tighten LI.FI Earn types and remove unused branches
Timidan May 24, 2026
dac6ec8
feat: surface Mezo Lens in navigation, search, and docs
Timidan May 24, 2026
d685a4a
chore: bump edb submodule to feat/mezo
Timidan May 24, 2026
e7d9554
fix: drop unused router ETH-variant legs
Timidan May 24, 2026
db14305
fix: native asset label in simulation Summary header
Timidan May 24, 2026
baf8f87
feat: source Mezo token icons from CoinGecko CDN
Timidan May 24, 2026
13d1b45
feat: support veBTC and Mezo-bridged USDC/USDT in icons
Timidan May 24, 2026
fb57b9d
fix: surface live trove + veMEZO state, skip openTrove on resume
Timidan May 24, 2026
c0d4294
feat: clickable trove panel opens Manage Trove dialog
Timidan May 25, 2026
256e792
fix: encode repayMUSD + closeTrove for eth_simulateV1
Timidan May 25, 2026
e927004
fix: close-trove pulls debt minus gas comp, not full debt
Timidan May 25, 2026
71556f8
feat: manage-position dialogs for Lock, Savings, and Liquidity
Timidan May 25, 2026
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
16 changes: 16 additions & 0 deletions .codegraph/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed

# Database
*.db
*.db-wal
*.db-shm

# Cache
cache/

# Logs
*.log

# Hook markers
.dirty
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ VITE_WALLETCONNECT_PROJECT_ID=your-walletconnect-project-id

# EDB Bridge
# EDB_BRIDGE_URL — Full URL of the bridge server (e.g. https://your-droplet:5789)
# EDB_DEFAULT_BRIDGE_URL — Base/default bridge URL for non-Mezo chains (e.g. https://edb.hexkit.tech)
# EDB_MEZO_BRIDGE_URL — Mezo bridge URL (e.g. https://edb.hexkit.tech/mezo)
# EDB_API_KEY — Secret key that the Vercel proxy injects into bridge requests
# EDB_CORS_ALLOWED_ORIGINS — Comma-separated extra origins for the edb proxy (e.g. https://yourdomain.com)

Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Service account keys
gen-lang-client-*.json

# Logs
logs
*.log
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ A full yield management layer powered by the LI.FI Earn API:
- **Deposit / Withdraw Flows** -- Deposit into and withdraw from vaults directly through LI.FI's Composer API, which handles cross-chain swaps and bridging automatically.
- **Vault Simulator** -- Forecast projected returns for any vault over a configurable time horizon before committing capital.

#### Mezo Lens

DeFi on Mezo testnet (chain 31611): borrow MUSD against BTC, save it, lock MEZO for governance.

- **Six action tabs**: Stack (composite onboarding flow), Borrow (Liquity-style CDP), Swap (v2 placeholder), Save (sMUSD deposit), Liquidity (v2 placeholder), Lock (veMEZO governance).
- **Bundle simulation before sign**: every parameter change triggers an `eth_simulateV1` round-trip against Mezo's RPC. The whole multi-leg sequence (up to 5 writes + appended view calls) executes server-side and returns end-state balances, ICR, liquidation price, and decoded leg outcomes. State chains across calls.
- **Testnet gauge emissions** report `rewardRate=0` and are displayed as such.
- **Canonical-MUSD guard**: Mezo testnet has two MUSD ERC-20 deployments. The docs `0x118917a4…` is the one bound to BorrowerOperations; another `0x637e22A1…` exists separately. The sidebar warns if you hold balance on the wrong one.
- **Shared dev tooling**: once Mezo is in the chain registry, the simulator, decoder, ABI fetcher, and storage-layout reader work against Mezo contracts.

Demo path:

1. Visit https://faucet.test.mezo.org/ for 0.05 BTC + 100 MEZO testnet drip.
2. Open `/integrations/mezo` and connect; the page prompts for the chain switch.
3. Stack tab: tweak collateral / debt / save / lock sliders. The "Before → After" panel updates from simulated state on each change.
4. Build Stack executes all 5 legs sequentially with Blockscout tx links per leg.

`scripts/mezo-day-0-smoke.sh` runs the full write sequence (openTrove → MUSD.approve → sMUSD.deposit → MEZO.approve → VotingEscrow.createLock) against a throwaway wallet and emits testnet tx hashes for every leg.

Integrations:

- **MUSD**: open trove (mint canonical MUSD), `sMUSD.deposit` (savings vault), MUSD/BTC pool reads.
- **MEZO**: MEZO precompile reads, `VotingEscrow.createLock` to mint a veMEZO governance NFT.

#### Yield Concierge (AI-powered)

An AI assistant that translates natural language yield goals into actionable vault recommendations:
Expand Down
32 changes: 25 additions & 7 deletions api/edb-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { maybeInjectDefaultEtherscanKey } from "./edbShared.js";
import {
appendEdbBridgeSubPath,
extractChainIdFromRawJsonBody,
maybeInjectDefaultEtherscanKey,
resolveEdbBridgeUrl,
} from "./edbShared.js";

export const config = {
api: { bodyParser: false },
Expand Down Expand Up @@ -73,11 +78,14 @@ function getRawBody(req: VercelRequest): Promise<Buffer> {
export default async function handler(req: VercelRequest, res: VercelResponse) {
applyCors(req, res);

const bridgeUrl = process.env.EDB_BRIDGE_URL;
const configuredBridgeUrl =
process.env.EDB_BRIDGE_URL ||
process.env.EDB_DEFAULT_BRIDGE_URL ||
process.env.EDB_MEZO_BRIDGE_URL;
const apiKey = process.env.EDB_API_KEY;
const defaultEtherscanApiKey = process.env.ETHERSCAN_API_KEY;

if (!bridgeUrl) {
if (!configuredBridgeUrl) {
return res.status(503).json({ error: "bridge_not_configured" });
}
if (!apiKey) {
Expand Down Expand Up @@ -124,8 +132,6 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
}
}

const target = `${bridgeUrl.replace(/\/+$/, "")}/${subPath}`;

// Build upstream headers (explicit allowlist — no client headers leak through)
const upstreamHeaders: Record<string, string> = {
"X-API-Key": apiKey,
Expand All @@ -142,6 +148,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
req.method !== "GET" && req.method !== "HEAD"
? await getRawBody(req)
: undefined;
const queryChainId = Array.isArray(req.query?.chainId)
? req.query.chainId[0]
: typeof req.query?.chainId === "string"
? req.query.chainId
: undefined;
const parsedQueryChainId = queryChainId ? Number(queryChainId) : null;
const chainId = Number.isInteger(parsedQueryChainId)
? parsedQueryChainId
: extractChainIdFromRawJsonBody(rawBody, req.headers["content-type"]);
Comment on lines +156 to +159
const bridgeUrl = resolveEdbBridgeUrl(chainId, process.env, configuredBridgeUrl);
const target = appendEdbBridgeSubPath(bridgeUrl, subPath);
const body = maybeInjectDefaultEtherscanKey(
rawBody,
req.headers["content-type"],
Expand All @@ -152,14 +169,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
// Detect SSE path — use longer timeout, abort on client disconnect
const isSSE = subPath.match(/debug\/prepare\/[^/]+\/events$/);
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | null = null;

if (isSSE) {
// Abort upstream when client disconnects
req.on("close", () => controller.abort());
} else {
// Regular requests get a hard timeout
Comment on lines 174 to 178
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
req.on("close", () => clearTimeout(timer));
timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
}
Comment on lines 174 to 180

const upstream = await fetch(target, {
Expand All @@ -169,6 +186,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
signal: controller.signal,
redirect: "error", // never follow redirects — prevents key leaking to unexpected hosts
});
if (timer) clearTimeout(timer);

// SSE streaming response
const contentType = upstream.headers.get("content-type") || "";
Expand Down
125 changes: 125 additions & 0 deletions api/edbShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,143 @@ const BRIDGE_BOOTSTRAP_SUBPATHS = new Set([
"debug/start",
]);

const MEZO_CHAIN_IDS = new Set([31611, 31612]);

export interface EdbBridgeEnv {
EDB_BRIDGE_URL?: string;
EDB_DEFAULT_BRIDGE_URL?: string;
EDB_MEZO_BRIDGE_URL?: string;
}

function normalizeEnvValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

function stripTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

function withoutTrailingMezoPath(value: string): string {
try {
const url = new URL(value);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[parts.length - 1]?.toLowerCase() === "mezo") {
parts.pop();
url.pathname = parts.length > 0 ? `/${parts.join("/")}` : "/";
}
url.search = "";
url.hash = "";
return stripTrailingSlash(url.toString());
} catch {
return stripTrailingSlash(value.replace(/\/mezo\/?$/i, ""));
}
}

function withTrailingMezoPath(value: string): string {
try {
const url = new URL(value);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[parts.length - 1]?.toLowerCase() !== "mezo") {
parts.push("mezo");
url.pathname = `/${parts.join("/")}`;
}
url.search = "";
url.hash = "";
return stripTrailingSlash(url.toString());
} catch {
const stripped = stripTrailingSlash(value);
return /\/mezo$/i.test(stripped) ? stripped : `${stripped}/mezo`;
}
}

function isJsonContentType(contentType: string | string[] | undefined): boolean {
if (Array.isArray(contentType)) {
return contentType.some((value) => value.toLowerCase().includes("application/json"));
}
return typeof contentType === "string" && contentType.toLowerCase().includes("application/json");
}

function coerceChainId(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}
return null;
}

export function isMezoChainId(chainId: number | null | undefined): boolean {
return typeof chainId === "number" && MEZO_CHAIN_IDS.has(chainId);
}

export function extractChainIdFromPayload(payload: unknown): number | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}

const obj = payload as Record<string, unknown>;
const direct = coerceChainId(obj.chainId ?? obj.networkId);
if (direct !== null) return direct;

const chain = obj.chain;
if (chain && typeof chain === "object" && !Array.isArray(chain)) {
const fromChain = coerceChainId((chain as Record<string, unknown>).id);
if (fromChain !== null) return fromChain;
}

const network = obj.network;
if (network && typeof network === "object" && !Array.isArray(network)) {
const fromNetwork = coerceChainId(
(network as Record<string, unknown>).chainId ??
(network as Record<string, unknown>).id,
);
if (fromNetwork !== null) return fromNetwork;
}

return null;
}

export function extractChainIdFromRawJsonBody(
body: Buffer | undefined,
contentType: string | string[] | undefined,
): number | null {
if (!body || !isJsonContentType(contentType)) return null;
try {
return extractChainIdFromPayload(JSON.parse(body.toString("utf8")));
} catch {
return null;
}
}

export function resolveEdbBridgeUrl(
chainId: number | null | undefined,
env: EdbBridgeEnv,
fallbackBridgeUrl: string,
): string {
const configuredBridge =
normalizeEnvValue(env.EDB_BRIDGE_URL) || fallbackBridgeUrl;
const defaultBridge =
normalizeEnvValue(env.EDB_DEFAULT_BRIDGE_URL) ||
withoutTrailingMezoPath(configuredBridge);
const mezoBridge =
normalizeEnvValue(env.EDB_MEZO_BRIDGE_URL) ||
withTrailingMezoPath(configuredBridge);

return isMezoChainId(chainId) ? mezoBridge : defaultBridge;
}

export function appendEdbBridgeSubPath(
bridgeUrl: string,
subPath: string,
search = "",
): string {
const cleanBridgeUrl = stripTrailingSlash(bridgeUrl);
const cleanSubPath = subPath.replace(/^\/+/, "");
const path = cleanSubPath ? `/${cleanSubPath}` : "";
return `${cleanBridgeUrl}${path}${search}`;
}

export function maybeInjectDefaultEtherscanKey(
body: Buffer | undefined,
contentType: string | string[] | undefined,
Expand Down
Loading