Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,10 @@ debug-formatter.html
# Submodule / nested repos
edb

# Repo uses npm; prevent accidental pnpm installs at the root from being committed
# (confuses Vercel's package-manager auto-detection)
/pnpm-lock.yaml

# Misc
*.pdf
*test*
Expand Down
4 changes: 4 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
[submodule "edb"]
path = edb
url = git@github.com:Timidan/edb-extended.git
branch = toolkit
69 changes: 68 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,6 +66,47 @@ A full simulation analysis page with six tabs:

Browse past simulations stored in IndexedDB. Re-open any previous result for review or further debugging.

### Transaction Analysis (Tx-Captain)

A forensics sub-tool that turns a completed simulation into a verifiable, evidence-backed verdict. Two entry points:

- Click **Summarize** in the simulation results header (next to Share/Export).
- Open `/builder?mode=analysis` directly — the panel attaches to whichever simulation is currently pinned in the SimulationContext.

What it does:

- **Evidence extraction** -- Walks the V3 trace and emits a structured evidence packet (calls, storage reads/writes, events, transfers, path contracts).
- **Heuristic sieve** -- Flags patterns that routinely ship bugs: `revert_on_path`, `sload_after_sstore` (state contradicting writes), `zero_address_transfer`, `large_delta` (transfers at/over 10 ETH), and `accumulator` (three or more writes to the same contract slot).
- **LLM verdict** -- Sends the sanitized packet to the configured LLM (Gemini / Anthropic / OpenAI / custom, per the LLM config panel) and parses the response against a Zod schema: verdict, confidence, core contradiction, causal chain, gates, risk upper bound, missing evidence.
- **Deep Dive (optional)** -- When enabled, fetches verified Solidity source for each path contract (Sourcify / Etherscan / Blockscout) and falls back to Heimdall decompilation on unverified addresses. Sources are stripped of SPDX/pragma/import lines and vendored libraries (`@openzeppelin/…`, `@chainlink/…`, `node_modules/…`) before being sent to the LLM.

Privacy and export guarantees:

- Analyses are stored in a dedicated IndexedDB store (`web3-toolkit-tx-analysis`) separate from simulation history and are never uploaded.
- Sensitive keys (`rpcUrl`, `apiKey`, `privateKey`, `secret`, …) are stripped recursively before persistence.
- Prompts are SHA-256 hashed alongside the verdict so you can audit exactly what was sent.
- Each verdict can be exported as Markdown or JSON from the panel toolbar.

#### Encrypted Triage (Fhenix Wave 3)

Alongside the classical LLM path, Tx-Captain ships an **encrypted triage** path built on Fhenix CoFHE. The browser encrypts a 12-bit feature vector derived from the EvidencePacket and submits it to a live fhEVM classifier on Ethereum Sepolia. The contract classifies the vector under FHE, storing encrypted `(classBits, severity)` handles that only the submitter can decrypt via `decryptForView` + self-issued permit. A second contract — `RiskThrottle` — consumes the encrypted severity via `FHE.select` and updates per-user throttle status without any party decrypting on-chain.

Live deployments (Ethereum Sepolia, chainId 11155111):

| Contract | Address | Etherscan |
|---|---|---|
| `HackTriage` | `0xBe02be322c7733759ee068067BD620791e9e73D4` | [verified](https://sepolia.etherscan.io/address/0xBe02be322c7733759ee068067BD620791e9e73D4#code) |
| `RiskThrottle` | `0x1FbB01D8e3FE4E99D6742eeBf1A4e276050ecD05` | [verified](https://sepolia.etherscan.io/address/0x1FbB01D8e3FE4E99D6742eeBf1A4e276050ecD05#code) |

Reproduction:

1. Set `VITE_HACK_TRIAGE_ADDRESS` in `.env` (defaults to the live address above).
2. Connect a Sepolia-funded wallet via RainbowKit, run a simulation, and open the Analysis panel.
3. Flip the **Cleartext / Encrypted** toggle in `TxAnalysisPanel` to Encrypted, then click **Run Encrypted Triage**.
4. The hook walks `encrypting → writing → waiting-fhe → decrypting → ready`, revealing class labels and severity locally without ever decrypting on-chain.

Architecture, threat model, leakage table, and open questions live in [docs/superpowers/plans/fhenix-wave3-architecture.md](docs/superpowers/plans/fhenix-wave3-architecture.md). Solidity sources are in [fhe/contracts/](fhe/contracts/); client helpers in [src/utils/hack-analysis/triage/](src/utils/hack-analysis/triage/) and [src/components/tx-analysis/useHackTriage.ts](src/components/tx-analysis/useHackTriage.ts).

### Integrations

The `/integrations` route hosts protocol-specific modules that extend HexKit beyond debugging into active DeFi operations.
Expand DownExpand Up@@ -175,6 +216,31 @@ For the LI.FI Earn integration and AI concierge, set the following in `.env`:
| `GEMINI_FALLBACK_MODEL` | Fallback on 429/503 (default: `gemini-2.5-flash`) |
| `PROXY_SECRET` | Shared secret for API proxy authentication (production) |
| `ALLOWED_ORIGINS` | Comma-separated allowed CORS origins (production) |
| `VITE_HACK_TRIAGE_ADDRESS` | Ethereum Sepolia `HackTriage` contract (default: `0xBe02...73D4`) |

### Heimdall endpoints (bridge)

The bridge exposes three POST endpoints that shell out to the [heimdall-rs](https://github.com/Jon-Becker/heimdall-rs) CLI:

- `POST /heimdall/version` → `{ available: boolean, version?: string }`
- `POST /heimdall/decompile` → `{ source, abi, bytecodeHash, ... }`
- Body: `{ bytecode: "0x..." }` **or** `{ address, chainId }`
- `POST /heimdall/dump` → `{ slots: [...], address, chainId, blockNumber, ... }`
- Body: `{ address, chainId, blockNumber? | blockTag? }`

**Security:** Clients must not send `rpcUrl` — the bridge resolves it per-chain from a server-side allowlist (default: mainnet / optimism / base / arbitrum) and rejects any request that carries `rpcUrl` with HTTP 400. Override the allowlist with `HEIMDALL_RPC_BY_CHAIN` (see `.env.example`). The bridge also computes `bytecodeHash` itself from `eth_getCode` — the client never supplies it — to prevent cache-key poisoning.

Results are cached in-memory on the bridge (LRU + TTL). To install the binary: run `bash edb/scripts/install.sh` — it pulls heimdall via bifrost automatically unless `INSTALL_HEIMDALL=0`. Front end consumers use [`src/utils/heimdall/hooks.ts`](src/utils/heimdall/hooks.ts).

#### Unverified contract support (heuristic tier)

When Sourcify has no source for a contract, the Storage Layout Viewer falls back to Heimdall decompilation if the bridge has it installed. Results are shown with an amber **Heuristic** badge and a banner explaining the caveats:

- Slot labels derived from decompiled function signatures (ERC20-like, Ownable, Pausable) and Heimdall's `modifiers` hint per slot.
- Struct packing is **not** resolved — every populated slot is shown as a single 32-byte value.
- Mapping keys must be discovered manually (use the existing mapping probe).

To enable: install Heimdall on the bridge (see install step above). To disable globally, set `HEIMDALL_BIN_PATH` to a path that does not exist — the availability check will fall back to "unavailable" and the heuristic tier is skipped.

## Project Structure

Expand DownExpand Up@@ -229,7 +295,8 @@ src/
lib/ Shared libraries

api/
llm-recommend.ts Gemini LLM proxy with model fallback
llm-invoke.ts Provider-agnostic LLM proxy (allowlist-enforced BYOK transport)
llm-recommend.ts Gemini LLM proxy with model fallback (legacy; kept for rollback)
lifi-composer.ts LI.FI Composer quote/execute proxy
edb/ EDB simulation API routes

Expand Down
42 changes: 42 additions & 0 deletions api/_llm/allowlist.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
import type { LlmProvider } from "../../src/utils/llm/types";

const BASE_URLS: Record<Exclude<LlmProvider, "custom">, string> = {
anthropic: (process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com").replace(/\/$/, ""),
openai: (process.env.OPENAI_BASE_URL ?? "https://api.openai.com").replace(/\/$/, ""),
gemini: (process.env.GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com").replace(/\/$/, ""),
};

const ALLOWED_PATHS: Record<Exclude<LlmProvider, "custom">, RegExp[]> = {
anthropic: [/^\/v1\/messages$/],
openai: [/^\/v1\/chat\/completions$/, /^\/v1\/responses$/],
gemini: [
/^\/v1beta\/models\/[A-Za-z0-9._-]+:generateContent$/,
/^\/v1beta\/models\/[A-Za-z0-9._-]+:streamGenerateContent(\?alt=sse)?$/,
],
};

export function isAllowedProviderUrl(
provider: LlmProvider,
path: string,
): boolean {
if (provider === "custom") return false;
if (!(provider in BASE_URLS)) return false;
if (path.startsWith("http://") || path.startsWith("https://")) return false;
if (path.includes("..")) return false;
const rules = ALLOWED_PATHS[provider];
return rules.some((rx) => rx.test(path));
}

export function resolveProviderUrl(
provider: LlmProvider,
path: string,
): string {
if (!isAllowedProviderUrl(provider, path)) {
throw new Error(`Provider URL not allowed: ${provider}${path}`);
}
return `${BASE_URLS[provider as Exclude<LlmProvider, "custom">]}${path}`;
}

export function listAllowedProviders(): LlmProvider[] {
return Object.keys(BASE_URLS) as LlmProvider[];
}
71 changes: 71 additions & 0 deletions api/_llm/guardHeaders.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
import * as crypto from "crypto";

export interface GuardConfig {
allowedOrigins: string[];
proxySecret: string | undefined;
}

export interface GuardResult {
ok: boolean;
status?: number;
reason?: string;
}

function timingSafeEqualStr(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}

function isSameOriginOrLocalhost(origin: string, host: string | undefined): boolean {
if (origin.startsWith("http://localhost:") || origin === "http://localhost") return true;
if (origin.startsWith("http://127.0.0.1:") || origin === "http://127.0.0.1") return true;
if (!host) return false;
return origin === `https://${host}` || origin === `http://${host}`;
}

/**
* Fail-closed request guard. Allow order:
* 1. If proxySecret configured, require x-proxy-secret match (timing-safe).
* 2. Else if Origin absent, allow (same-origin server call / curl).
* 3. Else allow when origin is in allowedOrigins, is localhost/127.0.0.1,
* or equals same-host (http(s)://${host}).
* 4. Anything else: reject 403. Empty allowedOrigins + external Origin
* does NOT open the proxy — previously this was a fail-open bug.
*/
export function checkRequestGuards(
req: { headers: Record<string, string | string[] | undefined> },
cfg: GuardConfig,
): GuardResult {
const h = (name: string): string | undefined => {
const v = req.headers[name.toLowerCase()];
return Array.isArray(v) ? v[0] : v;
};

if (cfg.proxySecret) {
const sent = h("x-proxy-secret") ?? "";
return timingSafeEqualStr(sent, cfg.proxySecret)
? { ok: true }
: { ok: false, status: 403, reason: "bad_proxy_secret" };
}

const origin = h("origin");
const host = h("host");

if (!origin) return { ok: true };

if (cfg.allowedOrigins.includes(origin)) return { ok: true };
if (isSameOriginOrLocalhost(origin, host)) return { ok: true };

return { ok: false, status: 403, reason: "origin_not_allowed" };
}

export function readGuardConfigFromEnv(): GuardConfig {
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const proxySecret = process.env.PROXY_SECRET || undefined;
return { allowedOrigins, proxySecret };
}
16 changes: 12 additions & 4 deletions api/edb/[...path].ts → api/edb-proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { maybeInjectDefaultEtherscanKey } from "../edbShared.js";
import { maybeInjectDefaultEtherscanKey } from "./edbShared.js";

export const config = {
api: { bodyParser: false },
Expand DownExpand Up@@ -104,9 +104,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return res.status(405).json({ error: "method_not_allowed" });
}

// Extract sub-path from URL — more reliable than req.query.path across Vercel runtimes
const urlPath = (req.url || "").split("?")[0];
const subPath = urlPath.replace(/^\/api\/edb\/?/, "");
// Extract sub-path from the `path` query param populated by the Vercel
// rewrite rule in vercel.json. Vercel's file-based catch-all routing
// (`api/edb/[...path].ts`) does not reliably match multi-segment requests
// under `/api/edb/*` on this project, so we route via an explicit rewrite
// that mirrors the lifi-composer pattern.
const pathParam = req.query?.path;
const subPath = Array.isArray(pathParam)
? pathParam.join("/")
: typeof pathParam === "string"
? pathParam
: "";

// Validate each path segment
const parts = subPath ? subPath.split("/") : [];
Expand Down
Loading