diff --git a/.github/workflows/gas-regression.yml b/.github/workflows/gas-regression.yml index 398d974..ac45c23 100644 --- a/.github/workflows/gas-regression.yml +++ b/.github/workflows/gas-regression.yml @@ -10,11 +10,11 @@ # 6. Fails CI if any threshold in atupa.toml is exceeded # # REQUIRED SECRETS (in repo Settings → Secrets): -# ATUPA_RPC_URL — Optional: override Anvil with a real mainnet fork RPC +# ATUPA_RPC_URL — Optional: override Anvil with a real RPC endpoint # -# REQUIRED REPO VARIABLES: -# PROFILE_SCRIPT — relative path to a forge/cast script that outputs a TX hash -# to stdout (default: script/ProfileScript.s.sol) +# REPO VARIABLES (optional): +# PROFILE_SCRIPT — relative path to a forge/cast script (default: script/ProfileScript.s.sol) +# ATUPA_PROTOCOL — optional protocol adapter (aave | lido) # ───────────────────────────────────────────────────────────────────────────── name: ⛽ Atupa Gas Regression @@ -22,14 +22,19 @@ name: ⛽ Atupa Gas Regression on: pull_request: branches: [ main ] - # Allow manual trigger for debugging workflow_dispatch: inputs: base_tx: - description: 'Override base transaction hash' + description: 'Override base transaction hash / signature' required: false target_tx: - description: 'Override target transaction hash' + description: 'Override target transaction hash / signature' + required: false + protocol: + description: 'Optional protocol deep diff (aave | lido)' + required: false + vm: + description: 'Explicit VM runtime (evm | stylus | starknet | solana | stellar)' required: false permissions: @@ -38,7 +43,6 @@ permissions: env: CARGO_TERM_COLOR: always - # Use Anvil by default; override with a real RPC via secret for mainnet-fork RPC_URL: ${{ secrets.ATUPA_RPC_URL || 'http://127.0.0.1:8545' }} ANVIL_PORT: 8545 ANVIL_MNEMONIC: 'test test test test test test test test test test test junk' @@ -78,7 +82,6 @@ jobs: --steps-tracing \ --silent & echo "ANVIL_PID=$!" >> $GITHUB_ENV - # Wait for Anvil to be ready for i in $(seq 1 20); do cast block-number --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} &>/dev/null && break sleep 0.5 @@ -94,7 +97,6 @@ jobs: PROFILE_SCRIPT="${{ vars.PROFILE_SCRIPT || 'script/ProfileScript.s.sol' }}" if [ -f "$PROFILE_SCRIPT" ]; then - # Forge deployment path: run script, capture TX hash from stdout TX_HASH=$(forge script "$PROFILE_SCRIPT" \ --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} \ --private-key $PRIVATE_KEY \ @@ -102,7 +104,6 @@ jobs: --json 2>/dev/null \ | jq -r '.receipts[-1].transactionHash' 2>/dev/null) else - # Fallback: Use a simple cast send to a known deployed contract echo "⚠️ No PROFILE_SCRIPT found. Sending dummy ETH transfer for baseline." TX_HASH=$(cast send \ --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} \ @@ -141,7 +142,6 @@ jobs: with: key: atupa-ci-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 @@ -197,10 +197,9 @@ jobs: diff: name: 🏮 Atupa Gas Diff runs-on: ubuntu-latest - # Support manual override of hashes via workflow_dispatch needs: [ baseline, target ] permissions: - pull-requests: write # Required for posting PR comments + pull-requests: write steps: - name: Checkout PR branch @@ -214,7 +213,6 @@ jobs: with: key: atupa-ci-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - - name: Install Foundry (for Anvil) uses: foundry-rs/foundry-toolchain@v1 @@ -245,16 +243,19 @@ jobs: echo " Base: $BASE" echo " Target: $TARGET" - # Use the Atupa composite action (the action.yml we just created) + # Execute Atupa composite action - name: Run Atupa Gas Diff uses: ./ with: base_tx: ${{ steps.hashes.outputs.base }} target_tx: ${{ steps.hashes.outputs.target }} rpc_url: 'http://127.0.0.1:${{ env.ANVIL_PORT }}' + protocol: ${{ github.event.inputs.protocol || vars.ATUPA_PROTOCOL || '' }} + vm: ${{ github.event.inputs.vm || '' }} config: 'atupa.toml' post_comment: 'true' upload_svg: 'true' + upload_json: 'true' - name: Stop Anvil if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 825aa8c..cdf23d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,9 @@ # Atupa Release — Automated Binary Compilation & Publishing # # Triggered when a new version tag (v*) is pushed. -# Performs a cross-platform matrix build (Linux, macOS, Windows), -# bundles the Studio frontend into the binary, and creates a GitHub Release. +# Performs a cross-platform matrix build (Linux, macOS, Windows), +# bundles the Studio frontend into the binary, generates SHA-256 checksums, +# and creates a GitHub Release before publishing crates in order. # ───────────────────────────────────────────────────────────────────────────── name: 🚀 Release @@ -107,6 +108,7 @@ jobs: cd target/${{ matrix.target }}/release tar czf ../../../${{ matrix.asset_name }} atupa cd ../../../ + sha256sum ${{ matrix.asset_name }} > ${{ matrix.asset_name }}.sha256 - name: Package Binary (Windows) if: runner.os == 'Windows' @@ -115,12 +117,16 @@ jobs: cd target/${{ matrix.target }}/release Compress-Archive -Path atupa.exe -DestinationPath ../../../${{ matrix.asset_name }} cd ../../../ + $hash = (Get-FileHash -Path ${{ matrix.asset_name }} -Algorithm SHA256).Hash.ToLower() + "$hash ${{ matrix.asset_name }}" | Out-File -FilePath "${{ matrix.asset_name }}.sha256" -Encoding ascii # 🚀 Upload to the release created in Phase 1 - name: Upload Asset to Release uses: softprops/action-gh-release@v2 with: - files: ${{ matrix.asset_name }} + files: | + ${{ matrix.asset_name }} + ${{ matrix.asset_name }}.sha256 tag_name: ${{ github.ref_name }} # ── Phase 3: Publish to Crates.io ────────────────────────────────────────── @@ -159,4 +165,5 @@ jobs: echo "⚠️ CRATES_IO_TOKEN not found. Skipping publish step." exit 0 fi + chmod +x ./publish.sh ./publish.sh diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 97516d7..7851973 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,4 +1,4 @@ -name: Rust +name: Rust CI on: push: @@ -11,7 +11,7 @@ env: jobs: fmt: - name: Format Check + name: 🎨 Format Check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -19,39 +19,45 @@ jobs: with: components: rustfmt - uses: Swatinem/rust-cache@v2 - - name: Run fmt + - name: Check code formatting run: cargo fmt --all -- --check clippy: - name: Lint (Clippy) + name: 🔍 Lint (Clippy) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 with: - components: clippy - - uses: Swatinem/rust-cache@v2 - - name: Build Studio + node-version: 22 + - name: Build Studio Frontend run: | cd studio - npm install + npm ci || npm install npm run build + - uses: Swatinem/rust-cache@v2 - name: Run clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets test: - name: Test Suite + name: 🧪 Workspace Test Suite runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Build Studio + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: 'studio/package-lock.json' + - name: Build Studio Frontend run: | cd studio - npm install + npm ci || npm install npm run build - - name: Cargo Check + - uses: Swatinem/rust-cache@v2 + - name: Workspace Check run: cargo check --workspace - - name: Run tests + - name: Run all workspace tests run: cargo test --workspace diff --git a/.gitignore b/.gitignore index 4186ae9..d89f060 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ artifacts/ # Playground playground/out/ playground/cache/ + + +demo \ No newline at end of file diff --git a/ARBITRUM_UNIFIED_PROPOSAL.md b/ARBITRUM_UNIFIED_PROPOSAL.md deleted file mode 100644 index 2905568..0000000 --- a/ARBITRUM_UNIFIED_PROPOSAL.md +++ /dev/null @@ -1,48 +0,0 @@ -# Aave/Arbitrum Unified: The High-Fidelity Execution Layer - -**Project Name**: Atupa Unified Execution Suite -**Grant Requested**: $50,000 -**Category**: Infrastructure & Developer Tooling -**Target Ecosystem**: Arbitrum (One, Nova, Orbit) - ---- - -## 🏮 The Vision: Bridging the "WASM-EVM Gap" -The Arbitrum ecosystem is at a historical turning point with the launch of **Stylus**. While Stylus allows for massive compute efficiency in Rust/WASM, it introduces a "Visibility Black Hole" for the millions of existing Solidity developers. - -**Atupa** is the first unified execution profiler that bridges this gap. Instead of forcing developers to use fragmented tools for different parts of a transaction, Atupa provides a contiguous, high-fidelity view of the entire **Hybrid Execution Path**. - -## 🏗 Why Atupa vs. Niche Profilers? -Existing tools focus strictly on the WASM Native VM. Atupa focuses on the **Transaction Lifecycle**. - -- **Hybrid Interoperability**: Atupa correlates standard EVM traces with Stylus HostIO traces. For the first time, a developer can see exactly how a Solidity call-stack transitions into a WASM logic-gate and back. -- **Unified Metrics**: We normalize standard Gas and Stylus Ink into a single, comprehensive "Cost-of-Execution" report. -- **Protocol-Awareness**: With built-in adapters for **Aave**, **GHO**, and **Lido**, Atupa doesn't just show opcodes—it shows protocol health. - -## 🛠 Milestones (4 Months) - -### Milestone 1: The Nitro Unified Tracer ($15,000) - [✅ ALPHA DELIVERED] -- Develop the `atupa-nitro` adapter for stitching EVM and WASM traces. -- Implement the "Hybrid Flamegraph" engine. -- Deliverable: Alpha CLI tool (`atupa`) available in `bin/atupa`. - -### Milestone 2: Institutional Protocol Adapters ($20,000) - [✅ IN PROGRESS] -- Integrate deep decoding for Aave v3, GHO, and Lido on Arbitrum. -- Functional Adapters: `atupa-aave` and `atupa-lido` are now operational in the workspace. -- Implement "Liquidation-Efficiency" and "Oracle-Latency" tracking. -- Deliverable: Advanced profiling reports accessible via `atupa audit`. - -### Milestone 3: The Atupa Studio & CI Suite ($15,000) -- Launch the high-performance Web Viewer for interactive trace exploration. -- Implement GitHub Actions for "Hybrid Gas Regression" tracking. -- Deliverable: Full production suite and public documentation. - ---- - -## 👥 The Team: One Block -We are experts in high-performance Rust networking and Ethereum infrastructure. -- **Lead Developer**: Michael Dean Oyewole (`@dean8ix`) -- **Core Library**: [atupa-core (v0.1.0)](https://crates.io/crates/atupa-core) - ---- -🏮 *One Block: The Transparency Layer for Ethereum.* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f0e947..d43d622 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,32 +1,104 @@ -# Atupa Suit: System Architecture +# 🏮 Atupa System Architecture -The Atupa Suite is designed as a modular, high-performance infrastructure stack that provides transparency for the "Multi-VM" future of Ethereum. It separates the heavy lifting of raw trace parsing from the high-level business logic of protocol-specific auditing. +Atupa is a high-performance, modular infrastructure stack designed as a **Universal Multi-VM Execution Profiler**. This document details the technical design, data normalization strategies, and crate-level relationships that power the suite across diverse execution environments. + +--- + +## 🏛 Core Philosophy: The Unified Trace Model + +The central challenge Atupa solves is the fragmentation of execution data across different Virtual Machines (EVM, WASM, Cairo, SVM, Soroban). Each VM has its own native "gas" units, log structures, and call-stack representations. + +Atupa addresses this by normalizing all execution data into a **Unified Trace Step** (`TraceStep`): + +```rust +pub struct TraceStep { + pub pc: u64, // Program counter or instruction index + pub op: String, // Opcode, HostFn name, or Program Label + pub gas: u64, // Gas remaining + pub gas_cost: u64, // Normalized execution weight + pub depth: u16, // Call-stack depth + pub vm_kind: VmKind, // The source VM (Evm, Stylus, Solana, Starknet, Stellar) + pub stack: Option>, + pub memory: Option>, + pub error: Option, + pub reverted: bool, +} +``` + +By mapping heterogeneous units (Solana Compute Units, Soroban HostFn weights, Starknet Cairo steps) into this model, Atupa enables **cross-chain execution diffing** and **unified flamegraph visualization**. + +--- ## 🏗 System Components -### 1. Network Layer (The Sources) -Atupa connects to diverse execution environments: -- **Ethereum (L1)**: Standard EVM via `structLogs`. -- **Arbitrum (L2)**: Dual-VM (EVM + Stylus) via the Nitro `stylusTracer`. -- **Unichain (L2)**: Real-time "Flashblocks" (200ms pending state). - -### 2. Intelligence Layer (The Engine) -This is where raw hex data becomes human insight: -- **MixedTraceStitcher**: Correlates different trace formats (EVM, Stylus, Geth) into a unified timeline. -- **Protocol Adapters**: Specialized crates (`atupa-aave`, `atupa-lido`) that implement the `ProtocolAdapter` trait to extract domain-specific insights. -- **Symbol Resolver**: Uses DWARF symbols and Sourcify to map opcodes to source lines. - -### 3. Interface Layer (The UX) -How developers and auditors interact with the data: -- **`atupa`**: The primary Rust binary that orchestrates parsing, auditing, and visualization. -- **`atupa-sdk`**: A high-level library that bundles the core engine and all protocol adapters for third-party integrations. -- **Atupa Report**: Automated, professional audit summaries generated directly from the terminal. - -## 🏮 Data Formats -We use a unified **Atupa Profile JSON** that includes: -- `execution_steps`: Contiguous list of all VM instructions. -- `memory_deltas`: Mapping of memory growth and spikes. -- `protocol_context`: High-level labels and risk flags injected by Protocol Adapters (e.g., "Liquid Staking Share Rebase Detected"). +### 1. Multi-VM Adapters (`crates/atupa-*`) +Atupa connects to diverse execution environments via specialized clients: +- **`atupa-nitro`**: Handles Arbitrum's dual-VM state. Stitches Geth-style EVM traces with `stylusTracer` WASM HostIO logs (`msg_sender`, `storage_load_bytes32`, `native_keccak256`, etc.). +- **`atupa-starknet`**: Interacts with Starknet JSON-RPC (`starknet_traceTransaction`), flattening recursive Cairo function invocations and accounting for builtin weights (Pedersen, Range Check, Bitwise, Poseidon, ECDSA). +- **`atupa-solana`**: Implements a zero-allocation **Log Stitcher** state machine. Reconstructs nested instruction call trees from sequential `Program ... invoke` and `Program ... consumed/success` logs. +- **`atupa-stellar`**: Parses Soroban `diagnostic_events` to extract Host Function call trees and resource weights. +- **`atupa-aave`**: Semantic decoder for Aave v3 supply, borrow, flash loans, and GHO stablecoin liquidation audits. +- **`atupa-lido`**: Semantic decoder for Lido stETH staking, rebasing, and withdrawal queue lifecycle auditing. + +### 2. Aggregation & Normalization (`atupa-parser`) +Raw traces frequently contain hundreds of thousands of steps. The parser performs: +- **Calldata & Memory Decoding**: Extracts 4-byte selectors and memory offsets (`decoder.rs`). +- **Depth-Aware Normalization**: Groups sequential opcode steps while maintaining call-stack depth boundaries (`normalize.rs`). +- **Collapsed Stack Building**: Converts normalized steps into aggregated flamegraph stacks resolved against registered protocol adapters (`aggregator.rs`). + +### 3. Visual Rendering Engine (`atupa-output`) +Generates standalone, interactive SVG artifacts with zero runtime dependencies: +- **SVG Flamegraphs**: Hand-crafted SVG templates with dynamic color tokens differentiating between execution categories (Red for Storage Writes, Orange for Storage Reads, Teal for External Calls, Cyan for Solana, Purple for Starknet, Indigo for Soroban). +- **Differential Flamegraphs**: Dual-trace comparison SVG engine visualizing cost regressions in high-contrast red and optimizations in green. + +### 4. High-Level Engine & CLI (`atupa-sdk` & `bin/atupa`) +- **`atupa-sdk`**: Programmatic entry point providing `execute_profile` with heuristic and explicit VM routing. +- **`bin/atupa`**: Modular CLI organized into dedicated command runners (`profile`, `capture`, `audit`, `diff`, `studio`, `init`). + +### 5. Atupa Studio (`studio/`) +Local-first, high-performance web dashboard built with Vite + React 19 + TypeScript. +- Embedded directly into the `atupa` binary via `rust-embed` and served over a lightweight `axum` server. +- Supports drag-and-drop JSON report loading, hierarchical flamegraph zooming, category breakdowns, and paginated step-by-step trace inspection. + +--- + +## 📦 Monorepo Crate Hierarchy + +```mermaid +graph TD + CLI[bin/atupa] --> SDK[crates/atupa-sdk] + CLI --> Studio[studio/ - React SPA] + + SDK --> Core[crates/atupa-core] + SDK --> Nitro[crates/atupa-nitro] + SDK --> Solana[crates/atupa-solana] + SDK --> Starknet[crates/atupa-starknet] + SDK --> Stellar[crates/atupa-stellar] + SDK --> Adapters[crates/atupa-adapters] + + Adapters --> Aave[crates/atupa-aave] + Adapters --> Lido[crates/atupa-lido] + + Nitro --> Parser[crates/atupa-parser] + Solana --> Parser + Starknet --> Parser + Stellar --> Parser + + Parser --> Output[crates/atupa-output] + Output --> Core + + Nitro --> RPC[crates/atupa-rpc] +``` + +--- + +## 🏮 Data Lifecycle + +1. **Capture**: CLI or SDK dispatches to the corresponding client based on the transaction format and RPC URL. +2. **Normalize**: The chain adapter converts raw logs or trace structs into `Vec`. +3. **Stitch**: If the transaction crosses VM boundaries (e.g., Arbitrum), the Nitro stitcher synchronizes EVM and Stylus WASM windows. +4. **Aggregate**: The parser collapses steps into collapsed call-stacks resolved against the protocol `AdapterRegistry`. +5. **Render & Diff**: The output engine generates terminal summaries, JSON reports, SVG flamegraphs, or Markdown CI regression tables. --- -🏮 *One Block: The Transparency Layer for the Hybrid Future.* +🏮 *Atupa: Illuminating execution across the modular blockchain landscape.* diff --git a/Cargo.lock b/Cargo.lock index a5264b2..92971ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atupa" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "atupa-aave", @@ -156,6 +156,9 @@ dependencies = [ "atupa-parser", "atupa-rpc", "atupa-sdk", + "atupa-solana", + "atupa-starknet", + "atupa-stellar", "axum", "clap", "colored", @@ -174,59 +177,50 @@ dependencies = [ [[package]] name = "atupa-aave" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "atupa-adapters", "atupa-core", "log", "serde", - "serde_json", - "thiserror 2.0.18", ] [[package]] name = "atupa-adapters" -version = "0.1.0" +version = "0.2.0" dependencies = [ - "anyhow", "atupa-core", - "log", - "serde", - "serde_json", ] [[package]] name = "atupa-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", "dirs", "figment", + "log", "serde", "serde_json", - "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", ] [[package]] name = "atupa-lido" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "atupa-adapters", "atupa-core", - "log", "serde", - "serde_json", ] [[package]] name = "atupa-nitro" -version = "0.1.0" +version = "0.2.0" dependencies = [ - "anyhow", "atupa-core", "atupa-rpc", "log", @@ -239,35 +233,28 @@ dependencies = [ [[package]] name = "atupa-output" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "askama", "atupa-core", - "log", - "serde", - "serde_json", ] [[package]] name = "atupa-parser" -version = "0.1.0" +version = "0.2.0" dependencies = [ - "anyhow", "atupa-adapters", "atupa-core", "atupa-rpc", "env_logger", "log", - "serde", - "serde_json", ] [[package]] name = "atupa-rpc" -version = "0.1.0" +version = "0.2.0" dependencies = [ - "anyhow", "atupa-core", "dirs", "log", @@ -280,7 +267,7 @@ dependencies = [ [[package]] name = "atupa-sdk" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "atupa-aave", @@ -291,11 +278,51 @@ dependencies = [ "atupa-output", "atupa-parser", "atupa-rpc", + "atupa-solana", + "atupa-starknet", + "atupa-stellar", "indicatif", "log", "tokio", ] +[[package]] +name = "atupa-solana" +version = "0.2.0" +dependencies = [ + "atupa-core", + "atupa-rpc", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "atupa-starknet" +version = "0.2.0" +dependencies = [ + "atupa-core", + "atupa-rpc", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "atupa-stellar" +version = "0.2.0" +dependencies = [ + "atupa-core", + "atupa-rpc", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "autocfg" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 2446c85..6198381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,14 +10,16 @@ members = [ "crates/atupa-aave", "crates/atupa-nitro", "crates/atupa-lido", + "crates/atupa-starknet", + "crates/atupa-solana", + "crates/atupa-stellar", "bin/atupa", ] [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2024" -authors = ["Dean "] -description = "Atupa: High-Fidelity Ethereum Tracing & Visual Profiling Suite" +description = "Atupa: Universal Multi-VM Execution Profiler & Visual Analysis Suite" readme = "README.md" license = "MIT OR Apache-2.0" repository = "https://github.com/One-Block-Org/Atupa" @@ -56,13 +58,16 @@ figment = { version = "0.10.19", features = ["toml", "env"] } toml = "1.1.2" # Internal Workspace -atupa-sdk = { path = "crates/atupa-sdk", version = "0.1.1" } -atupa = { path = "bin/atupa", version = "0.1.1" } -atupa-rpc = { path = "crates/atupa-rpc", version = "0.1.1" } -atupa-parser = { path = "crates/atupa-parser", version = "0.1.1" } -atupa-core = { path = "crates/atupa-core", version = "0.1.1" } -atupa-adapters = { path = "crates/atupa-adapters", version = "0.1.1" } -atupa-output = { path = "crates/atupa-output", version = "0.1.1" } -atupa-aave = { path = "crates/atupa-aave", version = "0.1.1" } -atupa-nitro = { path = "crates/atupa-nitro", version = "0.1.1" } -atupa-lido = { path = "crates/atupa-lido", version = "0.1.1" } +atupa-sdk = { path = "crates/atupa-sdk", version = "0.2.0" } +atupa = { path = "bin/atupa", version = "0.2.0" } +atupa-rpc = { path = "crates/atupa-rpc", version = "0.2.0" } +atupa-parser = { path = "crates/atupa-parser", version = "0.2.0" } +atupa-core = { path = "crates/atupa-core", version = "0.2.0" } +atupa-adapters = { path = "crates/atupa-adapters", version = "0.2.0" } +atupa-output = { path = "crates/atupa-output", version = "0.2.0" } +atupa-aave = { path = "crates/atupa-aave", version = "0.2.0" } +atupa-nitro = { path = "crates/atupa-nitro", version = "0.2.0" } +atupa-lido = { path = "crates/atupa-lido", version = "0.2.0" } +atupa-starknet = { path = "crates/atupa-starknet", version = "0.2.0" } +atupa-solana = { path = "crates/atupa-solana", version = "0.2.0" } +atupa-stellar = { path = "crates/atupa-stellar", version = "0.2.0" } diff --git a/README.md b/README.md index a7e8785..103534f 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ Atupa Logo

-

Atupa

+

🏮 Atupa

- High-Fidelity Ethereum Tracing, Profiling & Visual Analysis Suite + Universal Multi-VM Execution Profiler & Visual Analysis Suite

@@ -17,18 +17,18 @@ --- -**Atupa** (meaning *Lantern/Lamp*) is a professional-grade EVM + Arbitrum Stylus execution profiler. It turns raw JSON-RPC `debug_traceTransaction` and `stylusTracer` logs into actionable visual insights — from gas flamegraphs to unified EVM/WASM execution dashboards. +**Atupa** is a professional-grade **Universal Multi-VM Execution Profiler**. It provides a unified observability and regression analysis layer across diverse blockchain Virtual Machines — including **EVM**, **Arbitrum Stylus (WASM)**, **Starknet (Cairo)**, **Solana (SVM)**, and **Stellar (Soroban)** — turning raw execution traces into actionable visual insights and CI-ready differential reports. ## ✨ Key Features -- **🔥 Unified EVM + Stylus Tracing**: Stitches data from both the EVM and Stylus WASM runtime into a single coherent execution timeline. -- **🏮 Atupa Studio**: A local-first web visualizer — drop a `report.json` to instantly render metric cards, HostIO hot paths, and a step-by-step trace inspector. -- **📊 HostIO Flamegraph**: Surfaces the most expensive Stylus Host I/O calls (`storage_flush_cache`, `native_keccak256`, etc.) ranked by gas-equivalent cost. -- **🚨 Crisp Revert Identification**: Instantly identifies failing sub-calls with high-contrast highlights. -- **🔍 Smart Contract Resolution**: Automatically resolves hex addresses to verified contract names via Etherscan V2. -- **🚀 Automated CI/CD Pipeline**: Built-in `atupa init` for zero-config gas regression gating in GitHub Actions. -- **💉 Protocol-Specific Deep Auditing**: Built-in deep traces for **Lido stETH** and **Aave v3**. -- **🛠 Modular Library Architecture**: Pure Rust workspace with specialized crates for adapters, RPC, parsing, and output. +- **🌐 Universal Multi-VM Profiling**: Unified tracing for EVM, Arbitrum Stylus (WASM), Starknet (Cairo), Solana (SVM), and Stellar (Soroban). +- **🔥 Dual-VM Stitching**: Seamlessly reconstructs execution timelines across VM boundaries (e.g. EVM calling Stylus WASM with HostIO breakdown). +- **📊 Protocol-Aware Gas Analysis**: Specialized cost mapping for non-EVM units, including Solana Compute Units (CU), Soroban HostFn weights, and Cairo execution steps. +- **🏮 Atupa Studio**: An embedded local-first web visualizer (`atupa studio`) — drop a `report.json` to instantly render cross-chain metric cards and interactive flamegraphs. +- **🔍 Smart Contract Resolution**: Automatically resolves addresses to verified contract names via Etherscan, Starkscan, and explorer resolvers. +- **🚀 Automated CI/CD Regression Gates**: Built-in zero-config gas regression gating for GitHub Actions with sticky PR commenting and SVG artifact generation. +- **🔬 Protocol-Specific Deep Auditing**: Built-in deep tracers for **Aave v3 / GHO** and **Lido stETH**. +- **🛠 Modular Architecture**: 13 pure Rust crates with zero-cost abstractions, strict type safety, and clean separation of concerns. ## 🚀 Quick Start @@ -40,10 +40,10 @@ cargo install atupa ### 🏮 One-Click Initialization -Bootstrap your project with Atupa profiling and automated CI regression in one command. +Bootstrap your project with Atupa profiling and automated CI regression in one command: ```bash -# Detects Foundry/Hardhat and sets up atupa.toml + GitHub Action +# Detects Foundry, Hardhat, or Stylus and generates atupa.toml + GitHub Action + profile script atupa init ``` @@ -53,68 +53,113 @@ atupa init # Capture an Arbitrum Stylus transaction (summary to terminal) atupa capture --tx 0x... --rpc https://arb-mainnet.g.alchemy.com/v2/KEY -# Export as JSON for Atupa Studio -atupa capture --tx 0x... --rpc https://... --output json --file report.json +# Capture a Solana transaction (SVM Compute Unit breakdown) +atupa capture --tx 5Z9... --rpc https://api.mainnet-beta.solana.com -# Deep protocol audit (Lido or Aave) -atupa audit --protocol lido --tx 0x... +# Capture a Starknet transaction (Cairo execution steps) +atupa capture --tx 0x... --rpc https://starknet-mainnet.public.blastapi.io -# Compare execution cost of two transactions -atupa diff --base 0x... --target 0x... -``` +# Capture a Stellar transaction (Soroban diagnostic events) +atupa capture --tx 0x... --rpc https://soroban-testnet.stellar.org + +# Explicitly specify VM runtime if ambiguous +atupa capture --tx 0x... --vm stylus --rpc https://arb-sepolia.g.alchemy.com/v2/KEY -## 🛡 Automated Gas Regression (CI) +# Export report as JSON and generate an SVG flamegraph simultaneously +atupa capture --tx 0x... --output json --file report.json --profile +``` -Atupa is designed to sit inside your CI/CD pipeline. Use `atupa init` to generate a `.github/workflows/atupa.yml` file that: -1. Runs your profile scripts on the base branch (baseline). -2. Runs your profile scripts on the pull request branch (target). -3. Compares results and fails the CI if gas regressions exceed your `atupa.toml` thresholds. +### Comparing Transactions (Differential Profiling) ```bash -atupa diff --base 0xBASE_TX --target 0xPR_TX --protocol lido +# Compare execution costs of two transactions +atupa diff --base 0xBASE_TX --target 0xTARGET_TX --rpc https://... + +# Enforce a CI regression threshold (fail if gas increases by > 2%) +atupa diff --base 0xBASE_TX --target 0xTARGET_TX --threshold 2.0 --markdown --svg + +# Run protocol deep diff (Aave v3 or Lido stETH) +atupa diff --base 0xBASE_TX --target 0xTARGET_TX --protocol aave ``` -### Run the Demo +### Generating an Interactive SVG Flamegraph + ```bash -atupa profile --demo +# Offline demo trace (no RPC required) +atupa profile --demo --out profile_demo.svg + +# Profile a live on-chain transaction +atupa profile --tx 0x... --rpc https://arb-mainnet.g.alchemy.com/v2/KEY ``` +--- + ## 🏮 Atupa Studio -Atupa Studio is a local-first web visualizer for your execution traces. +Atupa Studio is an embedded local web visualizer for interactive trace inspection and flamegraph exploration. ```bash -# Start the Studio dev server -cd studio && npm install && npm run dev +# Launch Studio and auto-open in browser +atupa studio + +# Or capture a trace and launch Studio with the report automatically loaded +atupa capture --tx 0x... --rpc https://... --studio ``` -Then open **http://localhost:5173**, generate a trace with `--output json --file report.json`, and drop the file into the Studio. The dashboard instantly renders: +--- -- **Execution Metrics** — EVM Gas, Stylus Ink, HostIO call counts, VM boundary crossings -- **HostIO Hot Paths** — Ranked table with inline distribution bars -- **Trace Inspector** — Paginated, filterable, searchable step-by-step execution viewer +## 🛡 Automated Gas Regression (GitHub Action) + +Integrate Atupa into your repository's CI pipeline with [`One-Block-Org/Atupa`](action.yml): + +```yaml +- name: Run Atupa Gas Regression Check + uses: One-Block-Org/Atupa@main + with: + base_tx: ${{ steps.baseline.outputs.tx_hash }} + target_tx: ${{ steps.target.outputs.tx_hash }} + rpc_url: ${{ secrets.ATUPA_RPC_URL }} + config: 'atupa.toml' + post_comment: 'true' + upload_svg: 'true' + upload_json: 'true' +``` + +--- -## 📦 Project Structure +## 📦 Monorepo Architecture -Atupa is built as a highly modular monorepo: +The workspace is organized into modular crates: | Crate / Directory | Description | |---|---| -| [`bin/atupa`](bin/atupa) | The primary command-line interface. | -| [`studio/`](studio) | Atupa Studio — Vite + React web visualizer. | -| [`crates/atupa-sdk`](crates/atupa-sdk) | Public-facing SDK for programmatic tracing. | -| [`crates/atupa-core`](crates/atupa-core) | Shared types and core configuration logic. | -| [`crates/atupa-parser`](crates/atupa-parser) | Aggregation engine that collapses EVM traces. | -| [`crates/atupa-nitro`](crates/atupa-nitro) | Arbitrum Nitro dual-VM stitcher (EVM + Stylus). | -| [`crates/atupa-rpc`](crates/atupa-rpc) | Async Ethereum JSON-RPC client & Etherscan resolver. | -| [`crates/atupa-lido`](crates/atupa-lido) | Specialized adapter for Lido stETH. | -| [`crates/atupa-aave`](crates/atupa-aave) | Specialized adapter for Aave v3 & GHO. | +| [`bin/atupa`](bin/atupa) | The primary command-line interface (`profile`, `capture`, `audit`, `diff`, `studio`, `init`). | +| [`studio/`](studio) | Atupa Studio — Vite + React 19 + TypeScript web visualizer. | +| [`crates/atupa-sdk`](crates/atupa-sdk) | High-level SDK facade for programmatic profiling and multi-VM routing. | +| [`crates/atupa-core`](crates/atupa-core) | Core data models, `TraceStep`, `GasCategory`, `VmKind`, and configuration types. | +| [`crates/atupa-adapters`](crates/atupa-adapters) | Common adapter registry and standard protocol traits (Uniswap v4, ERC-20). | +| [`crates/atupa-parser`](crates/atupa-parser) | Selector decoders, trace normalizers, and stack aggregators. | +| [`crates/atupa-output`](crates/atupa-output) | Standalone SVG flamegraph and visual diff flamegraph rendering engines. | +| [`crates/atupa-nitro`](crates/atupa-nitro) | Arbitrum Nitro dual-VM clock stitcher (EVM + Stylus WASM HostIOs). | +| [`crates/atupa-starknet`](crates/atupa-starknet) | Starknet Cairo execution flattener and builtin weight calculators. | +| [`crates/atupa-solana`](crates/atupa-solana) | Solana Sealevel VM (SVM) log-stitching state machine. | +| [`crates/atupa-stellar`](crates/atupa-stellar) | Stellar Soroban diagnostic event tracer and HostFn cost estimator. | +| [`crates/atupa-rpc`](crates/atupa-rpc) | Async multi-chain RPC client, raw trace types, and Etherscan resolver. | +| [`crates/atupa-aave`](crates/atupa-aave) | Specialized deep tracer for Aave v3 and GHO stablecoin operations. | +| [`crates/atupa-lido`](crates/atupa-lido) | Specialized deep tracer for Lido stETH staking operations. | + +--- ## 🤝 Contributing -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more details. +We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) and [Testing Guide](TESTING_GUIDE.md) for details. + +## 📖 Further Reading + +- [**The Atupa Vision**](docs/VISION.md) — Why universal multi-VM execution observability matters. +- [**System Architecture**](ARCHITECTURE.md) — Deep dive into normalization, dual-VM clock stitching, and pipeline design. +- [**Adapter Guide**](docs/ADAPTER_GUIDE.md) — Step-by-step guide to adding support for new Virtual Machines. ## 📄 License Atupa is dual-licensed under the [MIT License](LICENSE-MIT) and the [Apache License, Version 2.0](LICENSE-APACHE). -You may use this software under either license, at your option. diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1482933..1f377be 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1,107 +1,107 @@ -# Atupa Testing Guide +# 🧪 Atupa Testing Guide -This document provides a comprehensive, step-by-step framework for testing the entirety of the Atupa project. It covers automated workspace tests, local CLI verification, and end-to-end integration flows. +This document provides a comprehensive framework for testing the entire Atupa monorepo, covering automated Rust workspace tests, frontend Studio testing, local CLI verification, and CI regression checks. --- -## 1. Prerequisites & Environment Setup +## 1. Automated Workspace Testing (Rust & Frontend) -Most advanced features of Atupa (Trace Capturing, Protocol Audits, Differential Profiling) require live Ethereum/Arbitrum node data. Ensure your environment is configured before starting E2E testing. +### Rust Test Suite +The project leverages Cargo's test runner across all 13 crates: -### Environment Variables -For live network tests, expose the following environment variables: ```bash -export ATUPA_RPC_URL="https://arb-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY" -export ETHERSCAN_API_KEY="YOUR_ARBISCAN_KEY" -``` -*(If you do not have an API key, you can still verify functionality using the `--demo` mode or cargo unit testing).* +# 1. Format Check +cargo fmt --all -- --check ---- +# 2. Strict Linting +cargo clippy --workspace --all-targets -- -D warnings -## 2. Automated Workspace Testing (CI/CD Quality) +# 3. All Unit & Integration Tests +cargo test --workspace +``` -The project leverages Cargo's highly parallel test runner to ensure unit and integration validity across all workspace crates (`atupa-core`, `atupa-parser`, `atupa-lido`, `atupa-aave`, etc.). +### Studio Frontend Testing & Linting +Verify the embedded React 19 visualizer: -**Run the fundamental verification suite:** ```bash -# 1. Formatting -cargo fmt --all -- --check +cd studio -# 2. Linting -cargo clippy --workspace --all-targets --all-features -- -D warnings +# 1. Lint TypeScript and React Components +npm run lint -# 3. All Unit & Integration Tests -cargo test --workspace --all-features +# 2. Build Static Bundle for Binary Embedding +npm run build ``` -> **Success Criteria:** Zero warnings and passing results for all doc tests, unit tests, and integration assertions. --- -## 3. CLI Feature Testing (Local Verification) +## 2. CLI End-to-End Verification -To test the CLI application manually and visually, compile it locally and execute its main subcommands. E2E testing evaluates the terminal presentation, progress bar animations, rendering, and API communication. +Compile the CLI in release mode: -Compile first to ensure performance: ```bash cargo build --release -p atupa alias atupa="./target/release/atupa" ``` -### Flow 1: Offline / Demo Verification -Verify the CLI profiling outputs and visual styles without relying on the network. -```bash -# Run a demo profile -atupa profile --demo --tx 0x0 -``` -> **Expected Output:** You should see a visually distinct `eprintln!` profiling banner and a well-formatted statistical JSON structure showing unified traces. +### Flow 1: Offline / Demo Flamegraph +Verify SVG generation without network dependency: -### Flow 2: Live RPC Connection / Trace Capture -Test the `capture` command, evaluating both `stdout` output generation and diagnostic `stderr` headers. Replace `0x...` with a real target Arbitrum/Ethereum transaction. ```bash -atupa capture --tx 0x8a923... +atupa profile --demo --out profile_demo.svg ``` -> **Expected Output:** A cleanly rendered summary payload. (Verify that `atupa capture --tx ... > report.txt` results in the report being written cleanly to file without UI spinners overriding `stdout`). +> **Expected Output:** Profile banner, terminal confirmation, and a valid `profile_demo.svg` file created. -### Flow 3: Specific Protocol Audits -Test the semantic decoding layers. These verify that the abstract trace data successfully parses into protocol-specific models. +### Flow 2: Multi-VM Live Trace Captures +Test trace capture across supported VM runtimes: -**A. Test Aave v3 Decoding** ```bash -atupa audit --protocol aave --tx 0x93ab... -``` -> **Expected Output:** A generated diagnostic table identifying the Flash Loan execution, debt updates, user reserve states, and liquidation values. +# Arbitrum Nitro / EVM +atupa capture --tx 0x8a923... --rpc https://arb-mainnet.g.alchemy.com/v2/KEY -**B. Test Lido stETH Execution** -```bash -atupa audit --protocol lido --tx 0x1fca... +# Solana Sealevel VM (SVM) +atupa capture --tx 5Z9... --rpc https://api.mainnet-beta.solana.com + +# Starknet Cairo VM +atupa capture --tx 0x... --rpc https://starknet-mainnet.public.blastapi.io + +# Stellar Soroban WASM VM +atupa capture --tx 0x... --rpc https://soroban-testnet.stellar.org ``` -> **Expected Output:** Verification of the staking pipeline (Deposit Event → Oracle Refresh → Staking Limits → Treasury Accounting). -### Flow 4: Differential Profiling (Cost & Execution Delta) -Verify the `--diff` execution flow, calculating the divergence in bytecode pathways or gas consumption between two similar transactions. +### Flow 3: Protocol Deep Auditing +Verify specialized semantic decoding for DeFi protocols: ```bash -atupa diff 0xBASE_TX_HASH 0xTARGET_TX_HASH +# Aave v3 + GHO Stablecoin Audit +atupa audit --protocol aave --tx 0x... + +# Lido stETH Audit +atupa audit --protocol lido --tx 0x... ``` -> **Expected Output:** A dual-table view or structured report listing `-X%` or `+Y%` deltas for Execution Steps, Memory Allocation, EVM Gas, and System VM parameters. ---- +### Flow 4: Differential Profiling & CI Regression +Verify differential execution analysis: + +```bash +# Basic comparison +atupa diff --base 0xBASE_HASH --target 0xTARGET_HASH --rpc https://... -## 4. Specific Crate Example Evaluation +# Enforce regression threshold with Markdown report & SVG generation +atupa diff --base 0xBASE_HASH --target 0xTARGET_HASH --threshold 2.0 --markdown --svg +``` -To test individual programmatic SDK behaviors independent of the main CLI app, Atupa includes runnable examples inside specific crates. +### Flow 5: Local Studio Server +Launch the embedded web server and verify browser loading: -### Run the Trace Parser Example -This runs a simulated test of the underlying log unification parser: ```bash -RUST_LOG=info cargo run -p atupa-parser --example trace_analysis +atupa studio --port 5173 ``` -> **Expected Output:** The parsed log analysis will stream into the standard console output using `env_logger`. Ensure the output renders properly without utilizing `println!`. +> **Expected Output:** Local server starts on `http://localhost:5173` and automatically opens the visualizer in the default browser. --- -## 5. Security & Safety +## 3. Security & Code Safety Rules -Atupa follows strict safety constraints. As part of your testing loop: -- Ensure the **"No-`println!`" rule** is respected. The CLI `stdout` must remain untainted for data payload piping, utilizing `eprintln!` strictly for diagnostics. -- Ensure all dependencies remain synchronized avoiding version drift across workspaces. Run `cargo update` locally and re-run step 2 (Automated Workspace Testing) to check against dependency vulnerabilities. +- **Zero Clippy Warnings**: All commits must pass `cargo clippy --workspace --all-targets -- -D warnings`. +- **Clean stdout**: CLI diagnostic messages use `eprintln!`; structured JSON or metric payloads use `stdout` so output can be safely piped into files or downstream tools. diff --git a/action.yml b/action.yml index 59c5b6d..e8e6761 100644 --- a/action.yml +++ b/action.yml @@ -1,9 +1,9 @@ name: 'Atupa Gas Regression Check' description: | - Automated gas regression detection for Ethereum & Arbitrum Stylus transactions. - Runs atupa diff between two transactions, uploads an SVG flamegraph artifact, + Automated multi-VM gas regression detection for EVM, Arbitrum Stylus, Solana, Starknet, and Stellar. + Runs `atupa diff` between two transactions, uploads SVG flamegraph and JSON artifacts, and posts a sticky Markdown report to the Pull Request. -author: 'One Block Ltd' +author: 'One Block' branding: icon: 'zap' color: 'orange' @@ -12,10 +12,10 @@ branding: inputs: base_tx: - description: 'Baseline transaction hash (e.g. last commit on main)' + description: 'Baseline transaction hash or signature (e.g. last commit on main)' required: true target_tx: - description: 'Target transaction hash (e.g. current PR branch)' + description: 'Target transaction hash or signature (e.g. current PR branch)' required: true rpc_url: description: 'RPC endpoint for the target network' @@ -25,6 +25,10 @@ inputs: description: 'Optional: protocol-specific deep diff (aave | lido)' required: false default: '' + vm: + description: 'Explicit VM target runtime (evm | stylus | starknet | solana | stellar)' + required: false + default: '' threshold: description: 'Simple gas increase threshold % (overrides atupa.toml)' required: false @@ -65,17 +69,17 @@ runs: using: 'composite' steps: - # 1. Source-aware binary setup (stolen & improved from Stylus-Trace) - # - If we are INSIDE the Atupa repo, build from source and cache. - # - If we are in a CONSUMER repo, install from crates.io. + # 1. Source-aware binary setup + # - If inside the Atupa repository, build from source and cache. + # - If in a consumer repository, install from crates.io. - name: Setup Atupa Binary shell: bash run: | if [ -f "${{ github.workspace }}/bin/atupa/Cargo.toml" ]; then - echo "🏠 Running inside Atupa repo — building from source…" + echo "🏠 Running inside Atupa repository — building from source…" if [ -d "${{ github.workspace }}/studio" ]; then echo "🎨 Building Atupa Studio frontend…" - cd studio && npm install && npm run build && cd .. + cd "${{ github.workspace }}/studio" && npm install && npm run build && cd "${{ github.workspace }}" fi cargo build --release -p atupa echo "${{ github.workspace }}/target/release" >> $GITHUB_PATH @@ -96,6 +100,11 @@ runs: PROTO_FLAG="--protocol ${{ inputs.protocol }}" fi + VM_FLAG="" + if [ -n "${{ inputs.vm }}" ]; then + VM_FLAG="--vm ${{ inputs.vm }}" + fi + THRESHOLD_FLAG="" if [ -n "${{ inputs.threshold }}" ]; then THRESHOLD_FLAG="--threshold ${{ inputs.threshold }}" @@ -119,21 +128,22 @@ runs: --markdown \ --svg \ $PROTO_FLAG \ + $VM_FLAG \ $THRESHOLD_FLAG \ $CONFIG_FLAG \ $JSON_FLAG && REGRESSION=false || REGRESSION=true # Discover output paths - BASE_SHORT="${{ inputs.base_tx }}" - TARGET_SHORT="${{ inputs.target_tx }}" - MD_PATH="artifacts/diff/${BASE_SHORT:0:10}_vs_${TARGET_SHORT:0:10}.md" - SVG_PATH="artifacts/diff/${BASE_SHORT:0:10}_vs_${TARGET_SHORT:0:10}.svg" + BASE_CLEAN="${{ inputs.base_tx }}" + TARGET_CLEAN="${{ inputs.target_tx }}" + MD_PATH="artifacts/diff/${BASE_CLEAN:0:10}_vs_${TARGET_CLEAN:0:10}.md" + SVG_PATH="artifacts/diff/${BASE_CLEAN:0:10}_vs_${TARGET_CLEAN:0:10}.svg" echo "md_path=${MD_PATH}" >> $GITHUB_OUTPUT echo "svg_path=${SVG_PATH}" >> $GITHUB_OUTPUT echo "regression_detected=${REGRESSION}" >> $GITHUB_OUTPUT - # 3. Upload SVG flamegraph as a workflow artifact (link appears in PR checks tab) + # 3. Upload SVG flamegraph as a workflow artifact - name: Upload SVG Flamegraph if: inputs.upload_svg == 'true' && always() uses: actions/upload-artifact@v4 @@ -143,17 +153,17 @@ runs: if-no-files-found: warn retention-days: 30 - # 3.5 Upload JSON Reports for Atupa Studio - - name: Upload JSON Reports + # 3.5 Upload JSON and Diff Artifacts for Atupa Studio + - name: Upload Trace & Diff Artifacts if: inputs.upload_json == 'true' && always() uses: actions/upload-artifact@v4 with: - name: atupa-reports-${{ inputs.base_tx != '' && inputs.base_tx || 'base' }} - path: artifacts/reports/ + name: atupa-artifacts-${{ inputs.base_tx != '' && inputs.base_tx || 'base' }} + path: artifacts/ if-no-files-found: warn retention-days: 30 - # 4. Post sticky PR comment (updates in-place on every new push to PR) + # 4. Post sticky PR comment (updates in-place on every push to PR) - name: Post Sticky PR Comment if: inputs.post_comment == 'true' && github.event_name == 'pull_request' && always() continue-on-error: true @@ -162,12 +172,12 @@ runs: header: atupa-gas-diff path: ${{ steps.diff.outputs.md_path }} - # 5. Finally: fail the job if a regression was detected + # 5. Enforce regression gate - name: Enforce Regression Gate shell: bash run: | if [ "${{ steps.diff.outputs.regression_detected }}" = "true" ]; then - echo "❌ Atupa detected a gas regression. See the PR comment for details." + echo "❌ Atupa detected a gas regression exceeding configured limits. See the PR comment for details." exit 1 fi echo "✅ No gas regressions detected." diff --git a/artifacts/capture/test.json b/artifacts/capture/test.json deleted file mode 100644 index 94def93..0000000 --- a/artifacts/capture/test.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "version": "1.0.0", - "transaction_hash": "0xfd1fe741d0e7b2e5597880e77ca5c41936de7c54f07da0fe1caffddfbeb352d3", - "total_gas": 442732195, - "hostio_summary": { - "total_calls": 17, - "by_type": { - "storage_flush_cache": 1, - "storage_load": 3, - "read_args": 1, - "native_keccak256": 3, - "storage_cache": 3, - "other": 3, - "msg_reentrant": 1, - "write_result": 1, - "msg_value": 1 - }, - "total_hostio_gas": 442732195 - }, - "hot_paths": [ - { - "stack": "storage_flush_cache", - "gas": 400068073, - "percentage": 90.36344713986747, - "category": "StorageExpensive", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "storage_load_bytes32", - "gas": 42155440, - "percentage": 9.521656765892077, - "category": "StorageNormal", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "native_keccak256", - "gas": 365400, - "percentage": 0.08253296329624278, - "category": "Crypto", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "storage_cache_bytes32", - "gas": 55440, - "percentage": 0.012522242707016146, - "category": "StorageNormal", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "write_result", - "gas": 41162, - "percentage": 0.009297268295566354, - "category": "Memory", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "read_args", - "gas": 16440, - "percentage": 0.003713305737794831, - "category": "Memory", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "msg_value", - "gas": 13440, - "percentage": 0.003035695201700884, - "category": "System", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "msg_reentrant", - "gas": 8400, - "percentage": 0.0018973095010630524, - "category": "System", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "pay_for_memory_grow", - "gas": 8400, - "percentage": 0.0018973095010630524, - "category": "Memory", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "user_entrypoint", - "gas": 0, - "percentage": 0.0, - "category": "UserCode", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - }, - { - "stack": "user_returned", - "gas": 0, - "percentage": 0.0, - "category": "UserCode", - "source_hint": { - "file": "unknown", - "line": null, - "column": null, - "function": "0x0" - } - } - ], - "all_stacks": [ - { - "stack": "storage_flush_cache", - "weight": 400068073, - "last_pc": 0 - }, - { - "stack": "storage_load_bytes32", - "weight": 42155440, - "last_pc": 0 - }, - { - "stack": "native_keccak256", - "weight": 365400, - "last_pc": 0 - }, - { - "stack": "storage_cache_bytes32", - "weight": 55440, - "last_pc": 0 - }, - { - "stack": "write_result", - "weight": 41162, - "last_pc": 0 - }, - { - "stack": "read_args", - "weight": 16440, - "last_pc": 0 - }, - { - "stack": "msg_value", - "weight": 13440, - "last_pc": 0 - }, - { - "stack": "msg_reentrant", - "weight": 8400, - "last_pc": 0 - }, - { - "stack": "pay_for_memory_grow", - "weight": 8400, - "last_pc": 0 - }, - { - "stack": "user_entrypoint", - "weight": 0, - "last_pc": 0 - }, - { - "stack": "user_returned", - "weight": 0, - "last_pc": 0 - } - ], - "generated_at": "2026-04-15T16:15:00.860452520+00:00" -} \ No newline at end of file diff --git a/artifacts/capture/test.svg b/artifacts/capture/test.svg deleted file mode 100644 index 5bd0352..0000000 --- a/artifacts/capture/test.svg +++ /dev/null @@ -1 +0,0 @@ -Stylus Transaction Profileroot: 442732195 ink / 44273 gasrootstorage_flush_cache: 400068073 ink / 40006 gasstorage_flush_cachestorage_load_bytes32: 42155440 ink / 4215 gasstorage_load_...native_keccak256: 365400 ink / 36 gasLegend:Storage (Ex)StorageCryptoMemoryCall/MsgSystem \ No newline at end of file diff --git a/bin/atupa/Cargo.toml b/bin/atupa/Cargo.toml index 0a7da50..c1a4eee 100644 --- a/bin/atupa/Cargo.toml +++ b/bin/atupa/Cargo.toml @@ -2,9 +2,8 @@ name = "atupa" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } -description = "atupa — Unified EVM + Stylus Execution Profiler CLI" +description = "Atupa — Universal Multi-VM Execution Profiler" readme = { workspace = true } # Standalone binary target @@ -22,6 +21,9 @@ atupa-aave = { workspace = true } atupa-lido = { workspace = true } atupa-parser = { workspace = true } atupa-output = { workspace = true } +atupa-starknet= { workspace = true } +atupa-solana = { workspace = true } +atupa-stellar = { workspace = true } # CLI clap = { workspace = true } diff --git a/bin/atupa/dist/assets/index-BA5VvGth.js b/bin/atupa/dist/assets/index-BA5VvGth.js new file mode 100644 index 0000000..5e08d39 --- /dev/null +++ b/bin/atupa/dist/assets/index-BA5VvGth.js @@ -0,0 +1,9 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function ne(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function re(e,t){return ne(e.type,t,e.props)}function ie(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ae(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var w=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?ae(``+e.key):t.toString(36)}function se(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ce(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ce(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(w,`$&/`)+`/`),ce(o,r,i,``,function(e){return e})):o!=null&&(ie(o)&&(o=re(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(w,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,ee||(ee=!0,ie());else{var t=n(l);t!==null&&oe(x,t.startTime-e)}}var ee=!1,S=-1,C=5,te=-1;function ne(){return g?!0:!(e.unstable_now()-tet&&ne());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&oe(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ie():ee=!1}}}var ie;if(typeof y==`function`)ie=function(){y(re)};else if(typeof MessageChannel<`u`){var ae=new MessageChannel,w=ae.port2;ae.port1.onmessage=re,ie=function(){w.postMessage(null)}}else ie=function(){_(re,0)};function oe(t,n){S=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(S),S=-1):h=!0,oe(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,ie()))),r},e.unstable_shouldYield=ne,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1fe||(e.current=D[fe],D[fe]=null,fe--)}function k(e,t){fe++,D[fe]=e.current,e.current=t}var me=pe(null),he=pe(null),ge=pe(null),_e=pe(null);function ve(e,t){switch(k(ge,t),k(he,e),k(me,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}O(me),k(me,e)}function ye(){O(me),O(he),O(ge)}function A(e){e.memoizedState!==null&&k(_e,e);var t=me.current,n=Hd(t,e.type);t!==n&&(k(he,e),k(me,n))}function be(e){he.current===e&&(O(me),O(he)),_e.current===e&&(O(_e),Qf._currentValue=de)}var xe,Se;function Ce(e){if(xe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);xe=t&&t[1]||``,Se=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{we=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ce(n):``}function Ee(e,t){switch(e.tag){case 26:case 27:case 5:return Ce(e.type);case 16:return Ce(`Lazy`);case 13:return e.child!==t&&t!==null?Ce(`Suspense Fallback`):Ce(`Suspense`);case 19:return Ce(`SuspenseList`);case 0:case 15:return Te(e.type,!1);case 11:return Te(e.type.render,!1);case 1:return Te(e.type,!0);case 31:return Ce(`Activity`);default:return``}}function De(e){try{var t=``,n=null;do t+=Ee(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var Oe=Object.prototype.hasOwnProperty,ke=t.unstable_scheduleCallback,Ae=t.unstable_cancelCallback,je=t.unstable_shouldYield,Me=t.unstable_requestPaint,Ne=t.unstable_now,Pe=t.unstable_getCurrentPriorityLevel,Fe=t.unstable_ImmediatePriority,Ie=t.unstable_UserBlockingPriority,Le=t.unstable_NormalPriority,Re=t.unstable_LowPriority,ze=t.unstable_IdlePriority,Be=t.log,Ve=t.unstable_setDisableYieldValue,He=null,Ue=null;function We(e){if(typeof Be==`function`&&Ve(e),Ue&&typeof Ue.setStrictMode==`function`)try{Ue.setStrictMode(He,e)}catch{}}var Ge=Math.clz32?Math.clz32:Je,Ke=Math.log,qe=Math.LN2;function Je(e){return e>>>=0,e===0?32:31-(Ke(e)/qe|0)|0}var Ye=256,Xe=262144,Ze=4194304;function Qe(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function $e(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Qe(n))):i=Qe(o):i=Qe(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Qe(n))):i=Qe(o)):i=Qe(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function et(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function tt(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function nt(){var e=Ze;return Ze<<=1,!(Ze&62914560)&&(Ze=4194304),e}function rt(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function it(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function at(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),vn=!1;if(_n)try{var yn={};Object.defineProperty(yn,`passive`,{get:function(){vn=!0}}),window.addEventListener(`test`,yn,yn),window.removeEventListener(`test`,yn,yn)}catch{vn=!1}var bn=null,xn=null,Sn=null;function Cn(){if(Sn)return Sn;var e,t=xn,n=t.length,r,i=`value`in bn?bn.value:bn.textContent,a=i.length;for(e=0;e=tr),ir=` `,ar=!1;function or(e,t){switch(e){case`keyup`:return $n.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function sr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var cr=!1;function lr(e,t){switch(e){case`compositionend`:return sr(t);case`keypress`:return t.which===32?(ar=!0,ir):null;case`textInput`:return e=t.data,e===ir&&ar?null:e;default:return null}}function ur(e,t){if(cr)return e===`compositionend`||!er&&or(e,t)?(e=Cn(),Sn=xn=bn=null,cr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Mr(n)}}function Pr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Pr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Fr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Wt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Wt(e.document)}return t}function Ir(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Lr=_n&&`documentMode`in document&&11>=document.documentMode,Rr=null,zr=null,Br=null,Vr=!1;function Hr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Vr||Rr==null||Rr!==Wt(r)||(r=Rr,`selectionStart`in r&&Ir(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Br&&jr(Br,r)||(Br=r,r=Ed(zr,`onSelect`),0>=o,i-=o,Pi=1<<32-Ge(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),M&&Ii(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),M&&Ii(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return M&&Ii(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),M&&Ii(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ie&&Pa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Va(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=xi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=bi(o.type,o.key,o.props,null,e.mode,c),Va(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=wi(o,e.mode,c),c.return=e,e=c}return s(e);case ie:return o=Pa(o),b(e,r,o,c)}if(ue(o))return h(e,r,o,c);if(se(o)){if(l=se(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ba(o),c);if(o.$$typeof===S)return b(e,r,la(e,o),c);Ha(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=Si(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{za=0;var i=b(e,t,n,r);return Ra=null,i}catch(t){if(t===Oa||t===Aa)throw t;var a=gi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Wa=Ua(!0),Ga=Ua(!1),Ka=!1;function qa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ja(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ya(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Xa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,W&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=pi(e),fi(e,null,n),t}return li(e,r,t,n),pi(e)}function Za(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,st(e,n)}}function Qa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var $a=!1;function eo(){if($a){var e=ya;if(e!==null)throw e}}function to(e,t,n,r){$a=!1;var i=e.updateQueue;Ka=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(q&f)===f:(r&f)===f){f!==0&&f===va&&($a=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:Ka=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function no(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function ro(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=T.T,s={};T.T=s,Rs(e,!1,t,n);try{var c=i(),l=T.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ls(e,t,Sa(c,r),pu(e)):Ls(e,t,r,pu(e))}catch(n){Ls(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{E.p=a,o!==null&&s.types!==null&&(o.types=s.types),T.T=o}}function Ds(){}function Os(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=ks(e).queue;Es(e,a,t,de,n===null?Ds:function(){return As(e),n(r)})}function ks(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:de,baseState:de,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zo,lastRenderedState:de},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function As(e){var t=ks(e);t.next===null&&(t=e.alternate.memoizedState),Ls(e,t.next.queue,{},pu())}function js(){return ca(Qf)}function Ms(){return z().memoizedState}function Ns(){return z().memoizedState}function Ps(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Ya(n);var r=Xa(t,e,n);r!==null&&(hu(r,t,n),Za(r,t,n)),t={cache:ma()},e.payload=t;return}t=t.return}}function Fs(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},zs(e)?Bs(t,n):(n=ui(e,t,n,r),n!==null&&(hu(n,e,r),Vs(n,t,r)))}function Is(e,t,n){Ls(e,t,n,pu())}function Ls(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(zs(e))Bs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Ar(s,o))return li(e,t,i,0),G===null&&ci(),!1}catch{}if(n=ui(e,t,i,r),n!==null)return hu(n,e,r),Vs(n,t,r),!0}return!1}function Rs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},zs(e)){if(t)throw Error(i(479))}else t=ui(e,n,r,2),t!==null&&hu(t,e,2)}function zs(e){var t=e.alternate;return e===F||t!==null&&t===F}function Bs(e,t){bo=yo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Vs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,st(e,n)}}var Hs={readContext:ca,use:Lo,useCallback:R,useContext:R,useEffect:R,useImperativeHandle:R,useLayoutEffect:R,useInsertionEffect:R,useMemo:R,useReducer:R,useRef:R,useState:R,useDebugValue:R,useDeferredValue:R,useTransition:R,useSyncExternalStore:R,useId:R,useHostTransitionStatus:R,useFormState:R,useActionState:R,useOptimistic:R,useMemoCache:R,useCacheRefresh:R};Hs.useEffectEvent=R;var Us={readContext:ca,use:Lo,useCallback:function(e,t){return Po().memoizedState=[e,t===void 0?null:t],e},useContext:ca,useEffect:ps,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ds(4194308,4,ys.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ds(4194308,4,e,t)},useInsertionEffect:function(e,t){ds(4,2,e,t)},useMemo:function(e,t){var n=Po();t=t===void 0?null:t;var r=e();if(xo){We(!0);try{e()}finally{We(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Po();if(n!==void 0){var i=n(t);if(xo){We(!0);try{n(t)}finally{We(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Fs.bind(null,F,e),[r.memoizedState,e]},useRef:function(e){var t=Po();return e={current:e},t.memoizedState=e},useState:function(e){e=Yo(e);var t=e.queue,n=Is.bind(null,F,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:xs,useDeferredValue:function(e,t){return ws(Po(),e,t)},useTransition:function(){var e=Yo(!1);return e=Es.bind(null,F,e.queue,!0,!1),Po().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=F,a=Po();if(M){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),G===null)throw Error(i(349));q&127||Wo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ps(Ko.bind(null,r,o,e),[e]),r.flags|=2048,ls(9,{destroy:void 0},Go.bind(null,r,o,n,t),null),n},useId:function(){var e=Po(),t=G.identifierPrefix;if(M){var n=Fi,r=Pi;n=(r&~(1<<32-Ge(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=So++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[mt]=t,o[ht]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Ic(t)}}return V(t),Lc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Ic(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ge.current,Ji(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Vi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[mt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Gi(t,!0)}else e=Bd(e).createTextNode(r),e[mt]=t,t.stateNode=e}return V(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ji(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[mt]=t}else Yi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;V(t),e=!1}else n=Xi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(go(t),t):(go(t),null);if(t.flags&128)throw Error(i(558))}return V(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ji(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[mt]=t}else Yi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;V(t),a=!1}else a=Xi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(go(t),t):(go(t),null)}return go(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),zc(t,t.updateQueue),V(t),null);case 4:return ye(),e===null&&Sd(t.stateNode.containerInfo),V(t),null;case 10:return na(t.type),V(t),null;case 19:if(O(P),r=t.memoizedState,r===null)return V(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Bc(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=_o(e),o!==null){for(t.flags|=128,Bc(r,!1),e=o.updateQueue,t.updateQueue=e,zc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)yi(n,e),n=n.sibling;return k(P,P.current&1|2),M&&Ii(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ne()>nu&&(t.flags|=128,a=!0,Bc(r,!1),t.lanes=4194304)}else{if(!a)if(e=_o(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,zc(t,e),Bc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!M)return V(t),null}else 2*Ne()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,Bc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(V(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ne(),e.sibling=null,n=P.current,k(P,a?n&1|2:n&1),M&&Ii(t,r.treeForkCount),e);case 22:case 23:return go(t),co(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(V(t),t.subtreeFlags&6&&(t.flags|=8192)):V(t),n=t.updateQueue,n!==null&&zc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&O(wa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),na(N),V(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Hc(e,t){switch(zi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return na(N),ye(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return be(t),null;case 31:if(t.memoizedState!==null){if(go(t),t.alternate===null)throw Error(i(340));Yi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(go(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Yi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return O(P),null;case 4:return ye(),null;case 10:return na(t.type),null;case 22:case 23:return go(t),co(),e!==null&&O(wa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return na(N),null;case 25:return null;default:return null}}function Uc(e,t){switch(zi(t),t.tag){case 3:na(N),ye();break;case 26:case 27:case 5:be(t);break;case 4:ye();break;case 31:t.memoizedState!==null&&go(t);break;case 13:go(t);break;case 19:O(P);break;case 10:na(t.type);break;case 22:case 23:go(t),co(),e!==null&&O(wa);break;case 24:na(N)}}function Wc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Gc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function Kc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{ro(t,n)}catch(t){Z(e,e.return,t)}}}function qc(e,t,n){n.props=Xs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Jc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function Yc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Xc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Zc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ht]=t}catch(t){Z(e,e.return,t)}}function Qc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function $c(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Qc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function el(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=cn));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(el(e,t,n),e=e.sibling;e!==null;)el(e,t,n),e=e.sibling}function tl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(tl(e,t,n),e=e.sibling;e!==null;)tl(e,t,n),e=e.sibling}function nl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[mt]=e,t[ht]=n}catch(t){Z(e,e.return,t)}}var rl=!1,H=!1,il=!1,al=typeof WeakSet==`function`?WeakSet:Set,ol=null;function sl(e,t){if(e=e.containerInfo,Rd=sp,e=Fr(e),Ir(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,ol=t;ol!==null;)if(t=ol,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,ol=e;else for(;ol!==null;){switch(t=ol,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[mt]=e,Dt(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Nr(s,h),v=Nr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,T.T=null,n=lu,lu=null;var o=au,s=su;if(X=0,ou=au=null,su=0,W&6)throw Error(i(331));var c=W;if(W|=4,Il(o.current),Ol(o,o.current,s,n),W=c,id(0,!1),Ue&&typeof Ue.onPostCommitFiberRoot==`function`)try{Ue.onPostCommitFiberRoot(He,o)}catch{}return!0}finally{E.p=a,T.T=r,Vu(e,t)}}function Wu(e,t,n){t=Ei(n,t),t=nc(e.stateNode,t,2),e=Xa(e,t,2),e!==null&&(it(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=Ei(n,e),n=rc(2),r=Xa(t,n,2),r!==null&&(ic(n,r,t,e),it(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Bl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Wl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,G===e&&(q&n)===n&&(Y===4||Y===3&&(q&62914560)===q&&300>Ne()-eu?!(W&2)&&Su(e,0):Jl|=n,Xl===q&&(Xl=0)),rd(e)}function qu(e,t){t===0&&(t=nt()),e=di(e,t),e!==null&&(it(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return ke(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ge(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=q,a=$e(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||et(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Ne(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}X!==0&&X!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Kt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),Dt(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Kt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Kt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Kt(n.imageSizes)+`"]`)):i+=`[href="`+Kt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),Dt(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Kt(r)+`"][href="`+Kt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),Dt(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Et(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);Dt(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=Et(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Dt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=Et(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Dt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=ge.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=Et(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=Et(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=Et(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Kt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),Dt(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Kt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Kt(n.href)+`"]`);if(r)return t.instance=r,Dt(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Dt(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,Dt(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Dt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,Dt(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Dt(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Dt(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Dt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g();function y(e){return`type`in e&&e.type===`diff`}function b(e,t){return e.target_address&&t.resolved_names[e.target_address]?`${e.label} → ${t.resolved_names[e.target_address]}`:e.label}var x={StorageWrite:{label:`Storage Write`,color:`#ff2a4a`,icon:`💾`},StorageRead:{label:`Storage Read`,color:`#ff8c40`,icon:`📖`},Memory:{label:`Memory Ops`,color:`#a78bfa`,icon:`🧠`},Crypto:{label:`Crypto/Hashing`,color:`#60d9ff`,icon:`🔐`},Call:{label:`External Calls`,color:`#2fe4c4`,icon:`📡`},Execution:{label:`Core Execution`,color:`#ffb340`,icon:`⚙️`},Precompile:{label:`Precompiles`,color:`#9a9db5`,icon:`⚡`},Root:{label:`Root Frame`,color:`#f0f0f8`,icon:`🏁`},Other:{label:`Other`,color:`#555870`,icon:`❓`}};function ee(e){return e.steps.filter(e=>e.vm===`Evm`)}function S(e){return e.steps.filter(e=>e.vm===`Stylus`)}function C(e){return e.steps.filter(e=>e.vm===`Solana`)}function te(e){return e.steps.filter(e=>e.vm===`Starknet`)}function ne(e){return e.steps.filter(e=>e.vm===`Stellar`)}function re(e){let t=new Set(e.steps.map(e=>e.vm));return t.has(`Solana`)?`solana`:t.has(`Starknet`)?`starknet`:t.has(`Stellar`)?`stellar`:t.has(`Stylus`)?`stylus`:`evm`}function ie(e){switch(e){case`solana`:return{label:`Solana (SVM)`,icon:`☀️`,color:`#2fe4c4`};case`starknet`:return{label:`Starknet (Cairo)`,icon:`🐺`,color:`#a78bfa`};case`stellar`:return{label:`Stellar (Soroban)`,icon:`🚀`,color:`#60d9ff`};case`stylus`:return{label:`Arbitrum Stylus (Dual-VM)`,icon:`🌐`,color:`#ff8c40`};case`evm`:return{label:`EVM Mainnet`,icon:`⛽`,color:`#ff2a4a`}}}function ae(e){let t=new Map;for(let n of e.steps){if(n.vm!==`Stylus`)continue;let e=t.get(n.label)??{cost:0,ink:0,count:0};e.cost+=n.cost_equiv,e.ink+=n.cost_equiv*1e4,e.count+=1,t.set(n.label,e)}let n=e.total_stylus_gas_equiv||1,r=[];for(let[e,i]of t.entries())r.push({name:e,total_cost_equiv:i.cost,total_ink:Math.round(i.ink),call_count:i.count,pct:i.cost/n*100});return r.sort((e,t)=>t.total_cost_equiv-e.total_cost_equiv)}function w(e){return e.toLocaleString(`en-US`)}function oe(e){return e.toFixed(2)}function se(e){return e.length<12?e:`${e.slice(0,8)}…${e.slice(-6)}`}function ce(e){let t={id:`root`,name:e.tx_hash?`tx ${e.tx_hash.slice(0,8)}…`:`Transaction`,vm:`Evm`,value:0,selfCost:0,stepIndex:-1,depth:0,is_vm_boundary:!1,children:[]};if(e.steps.length===0)return t;let n=[{depth:0,node:t}];for(let t of e.steps){let e=Math.max(1,t.depth);for(;n.length>1&&n[n.length-1].depth>=e;)n.pop();let r=n[n.length-1].node,i=t.vm===`Evm`?t.gas_cost:t.cost_equiv,a={id:`step-${t.index}`,name:t.label,vm:t.vm,value:i,selfCost:i,stepIndex:t.index,depth:e,is_vm_boundary:t.is_vm_boundary,children:[]};r.children.push(a),n.push({depth:e,node:a})}return le(t),t}function le(e){if(e.children.length===0)return e.value=e.selfCost,e.value;let t=0;for(let n of e.children)t+=le(n);return e.value=e.selfCost+t,e.value}var ue=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),T=o(((e,t)=>{t.exports=ue()}))(),E=[{id:`stylus`,name:`Arbitrum Stylus (Dual-VM)`,icon:`🌐`,path:`/demos/stylus.json`,desc:`EVM + Stylus WASM HostIO execution`},{id:`solana`,name:`Solana (SVM)`,icon:`☀️`,path:`/demos/solana.json`,desc:`Raydium Swap Compute Units & SPL transfers`},{id:`starknet`,name:`Starknet (Cairo)`,icon:`🐺`,path:`/demos/starknet.json`,desc:`Cairo execution & ECDSA/Pedersen builtins`},{id:`stellar`,name:`Stellar (Soroban)`,icon:`🚀`,path:`/demos/stellar.json`,desc:`Diagnostic events & Soroban HostFn weights`},{id:`aave`,name:`Aave v3 / GHO Audit`,icon:`👻`,path:`/demos/aave.json`,desc:`Supply, flash loans & liquidation state`},{id:`lido`,name:`Lido stETH Audit`,icon:`💧`,path:`/demos/lido.json`,desc:`Staking pipeline, rebase oracle & withdrawals`},{id:`diff`,name:`Differential Diff (Uniswap v3 vs v4)`,icon:`⚖️`,path:`/demos/diff.json`,desc:`Gas delta & transient storage regression check`}];function de({onLoad:e}){let[t,n]=(0,_.useState)(!1),[r,i]=(0,_.useState)(null),a=(0,_.useCallback)(t=>{if(!t.name.endsWith(`.json`)){i(`Please drop a valid Atupa JSON trace file.`);return}let n=new FileReader;n.onload=t=>{try{let n=JSON.parse(t.target?.result),r=typeof n.tx_hash==`string`&&Array.isArray(n.steps),a=n.type===`diff`&&typeof n.base==`object`&&typeof n.target==`object`;if(!r&&!a){i(`File does not appear to be an Atupa trace report or comparison.`);return}i(null),e(n)}catch{i(`Failed to parse JSON — is this a valid Atupa trace?`)}},n.readAsText(t)},[e]),o=(0,_.useCallback)(t=>{i(null),fetch(t).then(e=>{if(!e.ok)throw Error(`Preset not found`);return e.json()}).then(t=>e(t)).catch(()=>i(`Failed to load demo preset.`))},[e]),s=(0,_.useCallback)(e=>{e.preventDefault(),n(!1);let t=e.dataTransfer.files[0];t&&a(t)},[a]),c=(0,_.useCallback)(e=>{let t=e.target.files?.[0];t&&a(t)},[a]);return(0,T.jsxs)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:24,maxWidth:900,margin:`0 auto`,width:`100%`},children:[(0,T.jsxs)(`div`,{id:`drop-zone`,className:`drop-zone${t?` dragging`:``}`,onDragOver:e=>{e.preventDefault(),n(!0)},onDragLeave:()=>n(!1),onDrop:s,children:[(0,T.jsx)(`div`,{className:`drop-icon`,children:`🏮`}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`div`,{className:`drop-title`,children:`Universal Multi-VM Trace Visualizer`}),(0,T.jsxs)(`div`,{className:`drop-subtitle`,style:{marginTop:8},children:[`Drop any `,(0,T.jsx)(`code`,{style:{color:`var(--color-amber)`,fontSize:12},children:`report.json`}),` from EVM, Arbitrum Stylus, Solana, Starknet, or Stellar.`]})]}),(0,T.jsx)(`div`,{style:{display:`flex`,gap:12,alignItems:`center`},children:(0,T.jsx)(`label`,{htmlFor:`file-input`,style:{cursor:`pointer`},children:(0,T.jsxs)(`span`,{className:`drop-cta`,role:`button`,"aria-label":`Choose file`,children:[(0,T.jsx)(`span`,{children:`📂`}),` Upload Local Report`]})})}),(0,T.jsx)(`input`,{id:`file-input`,type:`file`,accept:`.json`,onChange:c,style:{display:`none`}}),r&&(0,T.jsxs)(`div`,{style:{color:`var(--color-crimson)`,fontSize:12,background:`var(--color-crimson-glow)`,padding:`8px 14px`,borderRadius:6,border:`1px solid var(--color-border-accent)`},children:[`⚠ `,r]})]}),(0,T.jsxs)(`div`,{className:`glass-card`,style:{padding:`20px 24px`},children:[(0,T.jsxs)(`div`,{className:`section-header`,style:{marginBottom:16},children:[(0,T.jsx)(`span`,{className:`section-title`,children:`✨ Explore Preloaded Multi-VM & Protocol Traces`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(`div`,{style:{display:`grid`,gridTemplateColumns:`repeat(auto-fill, minmax(260px, 1fr))`,gap:12},children:E.map(e=>(0,T.jsxs)(`button`,{id:`btn-preset-${e.id}`,onClick:()=>o(e.path),style:{display:`flex`,alignItems:`flex-start`,gap:12,padding:`12px 14px`,background:`var(--color-bg-raised)`,border:`1px solid var(--color-border)`,borderRadius:8,color:`var(--color-text-primary)`,textAlign:`left`,cursor:`pointer`,transition:`all 150ms ease`},onMouseEnter:e=>{e.currentTarget.style.borderColor=`var(--color-border-accent)`,e.currentTarget.style.transform=`translateY(-2px)`,e.currentTarget.style.boxShadow=`0 4px 12px rgba(255, 42, 74, 0.15)`},onMouseLeave:e=>{e.currentTarget.style.borderColor=`var(--color-border)`,e.currentTarget.style.transform=`translateY(0)`,e.currentTarget.style.boxShadow=`none`},children:[(0,T.jsx)(`span`,{style:{fontSize:22,lineHeight:1},children:e.icon}),(0,T.jsxs)(`div`,{style:{flex:1},children:[(0,T.jsx)(`div`,{style:{fontWeight:600,fontSize:13,color:`var(--color-text-primary)`},children:e.name}),(0,T.jsx)(`div`,{style:{fontSize:11,color:`var(--color-text-muted)`,marginTop:2},children:e.desc})]})]},e.id))})]})]})}function D({label:e,value:t,sub:n,kind:r,icon:i}){return(0,T.jsxs)(`div`,{className:`metric-card ${r}`,role:`region`,"aria-label":e,children:[(0,T.jsxs)(`div`,{className:`metric-label`,children:[i&&(0,T.jsx)(`span`,{style:{marginRight:5},children:i}),e]}),(0,T.jsx)(`div`,{className:`metric-value ${r}`,children:t}),n&&(0,T.jsx)(`div`,{className:`metric-sub`,children:n})]})}function fe({rows:e}){if(e.length===0)return(0,T.jsx)(`div`,{style:{color:`var(--color-text-muted)`,fontSize:13},children:`No Stylus HostIO calls in this trace.`});let t=e[0]?.total_cost_equiv??1;return(0,T.jsxs)(`table`,{className:`hostio-table`,"aria-label":`Host IO aggregator`,children:[(0,T.jsx)(`thead`,{children:(0,T.jsxs)(`tr`,{children:[(0,T.jsx)(`th`,{children:`HostIO Name`}),(0,T.jsx)(`th`,{style:{textAlign:`right`},children:`Calls`}),(0,T.jsx)(`th`,{style:{textAlign:`right`},children:`Gas-equiv`}),(0,T.jsx)(`th`,{style:{textAlign:`right`},children:`%`}),(0,T.jsx)(`th`,{className:`flame-bar-cell`,children:`Distribution`})]})}),(0,T.jsx)(`tbody`,{children:e.map(e=>(0,T.jsxs)(`tr`,{children:[(0,T.jsx)(`td`,{className:`hostio-name`,children:e.name}),(0,T.jsx)(`td`,{className:`hostio-pct`,children:e.call_count}),(0,T.jsx)(`td`,{className:`hostio-gas`,children:oe(e.total_cost_equiv)}),(0,T.jsxs)(`td`,{className:`hostio-pct`,children:[e.pct.toFixed(1),`%`]}),(0,T.jsx)(`td`,{className:`flame-bar-cell`,children:(0,T.jsx)(`div`,{className:`flame-bar-track`,children:(0,T.jsx)(`div`,{className:`flame-bar-fill`,style:{width:`${e.total_cost_equiv/t*100}%`}})})})]},e.name))})]})}var pe=150;function O(e){return e.vm===`Solana`?e.cost_equiv>0?`${e.cost_equiv} CU`:``:e.vm===`Starknet`?e.cost_equiv>0?`${e.cost_equiv} steps`:``:e.vm===`Stellar`?e.cost_equiv>0?`${e.cost_equiv} units`:``:e.vm===`Evm`?e.gas_cost>0?`${e.gas_cost} gas`:``:e.vm===`Stylus`?e.cost_equiv>0?`${e.cost_equiv.toFixed(1)} gas-equiv`:``:e.cost_equiv>0?`${e.cost_equiv}`:``}function k(e){switch(e){case`Evm`:return{text:`EVM`,className:`evm`};case`Stylus`:return{text:`WASM`,className:`stylus`};case`Solana`:return{text:`SVM`,className:`solana`};case`Starknet`:return{text:`CAIRO`,className:`starknet`};case`Stellar`:return{text:`SOROBAN`,className:`stellar`}}}function me({step:e,report:t}){let n=Array.from({length:Math.max(0,e.depth-1)}).map((e,t)=>(0,T.jsx)(`span`,{className:`trace-depth-indent`},t)),r=O(e),i=b(e,t),a=e.target_address&&t.resolved_names[e.target_address],o=k(e.vm);return(0,T.jsxs)(`div`,{className:`trace-step${e.is_vm_boundary?` is-boundary`:``}`,role:`listitem`,title:a?`Target: ${e.target_address}`:e.is_vm_boundary?`Cross-VM / CPI Boundary Crossing`:void 0,children:[(0,T.jsxs)(`span`,{className:`trace-step-index`,children:[`#`,e.index]}),n,(0,T.jsx)(`span`,{className:`trace-step-badge ${o.className}`,children:o.text}),(0,T.jsx)(`span`,{className:`trace-step-label ${a?`resolved`:``}`,children:i}),r&&(0,T.jsx)(`span`,{className:`trace-step-cost`,children:r}),e.is_vm_boundary&&(0,T.jsx)(`span`,{style:{fontSize:10,color:`var(--color-violet)`,marginLeft:4},children:`⇌`})]})}function he({report:e}){let t=(0,_.useMemo)(()=>{let t=new Set;for(let n of e.steps)t.add(n.vm);return Array.from(t)},[e]),[n,r]=(0,_.useState)(`all`),[i,a]=(0,_.useState)(0),[o,s]=(0,_.useState)(``),c=(0,_.useMemo)(()=>e.steps.filter(t=>{if(n===`boundary`){if(!t.is_vm_boundary)return!1}else if(n!==`all`&&t.vm!==n)return!1;let r=b(t,e).toLowerCase();return!(o&&!r.includes(o.toLowerCase()))}),[e,n,o]),l=Math.ceil(c.length/pe),u=c.slice(i*pe,(i+1)*pe),d=e=>({padding:`4px 12px`,borderRadius:99,fontSize:11,fontWeight:600,cursor:`pointer`,border:`1px solid ${e?`var(--color-border-accent)`:`var(--color-border)`}`,background:e?`var(--color-crimson-glow)`:`transparent`,color:e?`var(--color-crimson)`:`var(--color-text-secondary)`,transition:`all 150ms`});return(0,T.jsxs)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:`var(--sp-3)`},children:[(0,T.jsxs)(`div`,{style:{display:`flex`,alignItems:`center`,gap:`var(--sp-3)`,flexWrap:`wrap`},children:[(0,T.jsxs)(`button`,{id:`filter-all`,style:d(n===`all`),onClick:()=>{r(`all`),a(0)},children:[`All Steps (`,e.steps.length,`)`]}),t.length>1&&t.map(e=>(0,T.jsxs)(`button`,{id:`filter-${e.toLowerCase()}`,style:d(n===e),onClick:()=>{r(e),a(0)},children:[k(e).text,` Only`]},e)),(0,T.jsxs)(`button`,{id:`filter-boundary`,style:d(n===`boundary`),onClick:()=>{r(`boundary`),a(0)},children:[`Boundaries / CPI (`,e.vm_boundary_count,`)`]}),(0,T.jsx)(`input`,{id:`trace-search`,type:`search`,placeholder:`Search opcode / instruction / HostIO…`,value:o,onChange:e=>{s(e.target.value),a(0)},style:{marginLeft:`auto`,padding:`5px 12px`,background:`var(--color-bg-raised)`,border:`1px solid var(--color-border)`,borderRadius:6,color:`var(--color-text-primary)`,fontSize:12,fontFamily:`var(--font-mono)`,outline:`none`,width:260}})]}),(0,T.jsx)(`div`,{className:`trace-list`,role:`list`,children:u.length===0?(0,T.jsx)(`div`,{style:{padding:`var(--sp-4)`,textAlign:`center`,color:`var(--color-text-muted)`,fontSize:13},children:`No steps matching filter or search.`}):u.map(t=>(0,T.jsx)(me,{step:t,report:e},t.index))}),l>1&&(0,T.jsxs)(`div`,{className:`trace-pagination`,children:[(0,T.jsx)(`button`,{id:`trace-prev`,disabled:i===0,onClick:()=>a(e=>Math.max(0,e-1)),style:{padding:`4px 10px`,background:`var(--color-bg-raised)`,border:`1px solid var(--color-border)`,borderRadius:4,color:`var(--color-text-secondary)`,cursor:i===0?`not-allowed`:`pointer`,opacity:i===0?.4:1},children:`← Prev`}),(0,T.jsxs)(`span`,{children:[`Page `,i+1,` of `,l,` (`,c.length,` total)`]}),(0,T.jsx)(`button`,{id:`trace-next`,disabled:i>=l-1,onClick:()=>a(e=>Math.min(l-1,e+1)),style:{padding:`4px 10px`,background:`var(--color-bg-raised)`,border:`1px solid var(--color-border)`,borderRadius:4,color:`var(--color-text-secondary)`,cursor:i>=l-1?`not-allowed`:`pointer`,opacity:i>=l-1?.4:1},children:`Next →`})]})]})}var ge=24,_e=6,ve=2,ye=6.5,A={evmFill:`#1e2a45`,evmStroke:`#2e4a7a`,evmText:`#93c5fd`,stylusFill:`#2a1922`,stylusStroke:`#7f1d2e`,stylusText:`#ff8fa3`,boundaryFill:`#1e1833`,boundaryStroke:`#6d28d9`,boundaryText:`#a78bfa`,highlightFill:`#7f1d2e`,rootFill:`#0d0f1a`,rootStroke:`#1e2435`,rootText:`#64748b`,starknetFill:`#1e1b4b`,starknetStroke:`#4338ca`,starknetText:`#a5b4fc`,solanaFill:`#064e3b`,solanaStroke:`#059669`,solanaText:`#6ee7b7`,stellarFill:`#172554`,stellarStroke:`#1e3a8a`,stellarText:`#93c5fd`,tooltipBg:`#181c2a`,tooltipBorder:`#2e3a5a`,tooltipText:`#e2e8f0`};function be({tip:e,rootValue:t}){let n=(e.node.value/t*100).toFixed(2),r=(e.node.selfCost/t*100).toFixed(2),i={Evm:`EVM`,Stylus:`WASM/Stylus`,Starknet:`Starknet Cairo`,Solana:`Solana SVM`,Stellar:`Stellar Soroban`}[e.node.vm]||e.node.vm,a={Evm:A.evmText,Stylus:A.stylusText,Starknet:A.starknetText,Solana:A.solanaText,Stellar:A.stellarText}[e.node.vm]||`#fff`;return(0,T.jsx)(`foreignObject`,{x:e.x+12,y:e.y-8,width:260,height:110,style:{pointerEvents:`none`,overflow:`visible`},children:(0,T.jsxs)(`div`,{style:{background:A.tooltipBg,border:`1px solid ${A.tooltipBorder}`,borderRadius:8,padding:`8px 12px`,fontFamily:`'JetBrains Mono', monospace`,fontSize:11,color:A.tooltipText,lineHeight:1.6,boxShadow:`0 4px 24px rgba(0,0,0,0.6)`,whiteSpace:`nowrap`},children:[(0,T.jsx)(`div`,{style:{fontWeight:700,fontSize:12,marginBottom:4,color:`#fff`},children:e.node.name}),(0,T.jsxs)(`div`,{style:{color:`#94a3b8`},children:[`VM: `,(0,T.jsx)(`span`,{style:{color:a},children:i})]}),(0,T.jsxs)(`div`,{style:{color:`#94a3b8`},children:[`Total: `,(0,T.jsxs)(`span`,{style:{color:`#e2e8f0`},children:[e.node.value.toLocaleString(`en-US`,{maximumFractionDigits:2}),` gas (`,n,`%)`]})]}),(0,T.jsxs)(`div`,{style:{color:`#94a3b8`},children:[`Self: `,(0,T.jsxs)(`span`,{style:{color:`#e2e8f0`},children:[e.node.selfCost.toLocaleString(`en-US`,{maximumFractionDigits:2}),` gas (`,r,`%)`]})]}),(0,T.jsxs)(`div`,{style:{color:`#94a3b8`},children:[`Depth: `,(0,T.jsx)(`span`,{style:{color:`#e2e8f0`},children:e.node.depth}),e.node.is_vm_boundary&&(0,T.jsx)(`span`,{style:{color:`#a78bfa`,marginLeft:8},children:`⇌ Boundary`})]})]})})}function xe(e,t,n,r,i){if(i.push({node:e,x:t,w:n-t,row:r}),e.children.length===0)return;let a=e.children.reduce((e,t)=>e+t.value,0);if(a===0)return;let o=t;for(let s of e.children){let e=(n-t)*s.value/a;xe(s,o,o+e,r+1,i),o+=e}}var Se=_.memo(function({lnode:e,svgWidth:t,zoomX:n,zoomW:r,highlight:i,onHover:a,onClick:o}){let{node:s,x:c,w:l,row:u}=e,d=(c-n)/r*t,f=l/r*t;if(f=2&&s.name.toLowerCase().includes(i.toLowerCase());_&&(m=A.highlightFill,h=`#ff2a4a`);let v=Math.max(0,Math.floor((f-_e*2)/ye)),y=s.name;return y.length>v&&(y=v>3?y.slice(0,v-1)+`…`:``),(0,T.jsxs)(`g`,{style:{cursor:u===0?`default`:`pointer`},onClick:()=>u>0&&o(s),onMouseMove:e=>a({x:e.nativeEvent.offsetX,y:p,node:s}),onMouseLeave:()=>a(null),children:[(0,T.jsx)(`rect`,{x:d+1,y:p+1,width:Math.max(0,f-2),height:ge-2,rx:3,fill:m,stroke:_?`#ff2a4a`:h,strokeWidth:_?1.5:.8,style:{transition:`fill 120ms ease`}}),y&&(0,T.jsx)(`text`,{x:d+_e,y:p+ge/2+4,fill:g,fontSize:11,fontFamily:`'JetBrains Mono', monospace`,style:{pointerEvents:`none`,userSelect:`none`},children:y})]})});function Ce({trail:e,onJump:t}){return(0,T.jsx)(`div`,{style:{display:`flex`,alignItems:`center`,gap:4,flexWrap:`wrap`,fontFamily:`'JetBrains Mono', monospace`,fontSize:11,color:`#64748b`,marginBottom:8,minHeight:20},children:e.map((n,r)=>(0,T.jsxs)(_.Fragment,{children:[r>0&&(0,T.jsx)(`span`,{style:{color:`#334155`},children:`›`}),(0,T.jsx)(`button`,{onClick:()=>t(r),style:{background:`none`,border:`none`,padding:`1px 4px`,borderRadius:4,cursor:r22?n.name.slice(0,20)+`…`:n.name})]},n.id))})}function we({root:e,search:t=``}){let n=(0,_.useRef)(null),[r,i]=(0,_.useState)(800),[a,o]=(0,_.useState)(null),[s,c]=(0,_.useState)(e),[l,u]=(0,_.useState)([e]);s!==e&&(c(e),u([e]));let d=l[l.length-1]??e;(0,_.useEffect)(()=>{if(!n.current)return;let e=new ResizeObserver(e=>{let t=e[0]?.contentRect.width;t&&i(t)});return e.observe(n.current),()=>e.disconnect()},[]);let f=(0,_.useMemo)(()=>{let t=[];return xe(e,0,1,0,t),t},[e]),{zoomX:p,zoomW:m}=(0,_.useMemo)(()=>{let e=f.find(e=>e.node.id===d.id);return e?{zoomX:e.x,zoomW:e.w}:{zoomX:0,zoomW:1}},[f,d]),h=(0,_.useMemo)(()=>f.filter(e=>e.w===0?!1:e.w/m*r>=ve),[f,m,r]),g=((0,_.useMemo)(()=>Math.max(...h.map(e=>e.row),0),[h])+1)*(ge+2)+8,v=(0,_.useCallback)(e=>{u(t=>[...t,e]),o(null)},[]),y=(0,_.useCallback)(e=>{u(t=>t.slice(0,e+1)),o(null)},[]),b=(0,_.useCallback)(e=>{o(e)},[]);return(0,T.jsxs)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:0},children:[(0,T.jsx)(Ce,{trail:l,onJump:y}),(0,T.jsxs)(`div`,{style:{display:`flex`,alignItems:`center`,gap:16,marginBottom:10,fontSize:10,fontFamily:`'JetBrains Mono', monospace`,color:`#64748b`},children:[[{color:A.evmStroke,label:`EVM`},{color:A.stylusStroke,label:`Stylus`},{color:A.starknetStroke,label:`Starknet`},{color:A.solanaStroke,label:`Solana`},{color:A.stellarStroke,label:`Stellar`},{color:`#6d28d9`,label:`VM Boundary`},{color:`#ff2a4a`,label:`Search match`}].map(({color:e,label:t})=>(0,T.jsxs)(`span`,{style:{display:`flex`,alignItems:`center`,gap:4},children:[(0,T.jsx)(`span`,{style:{width:10,height:10,borderRadius:2,background:e,display:`inline-block`}}),t]},t)),(0,T.jsxs)(`span`,{style:{marginLeft:`auto`,color:`#475569`},children:[f.length,` nodes · click to zoom`]})]}),(0,T.jsx)(`div`,{style:{border:`1px solid #1e2435`,borderRadius:8,overflow:`hidden`,background:`#07080d`},children:(0,T.jsxs)(`svg`,{ref:n,width:`100%`,height:g,style:{display:`block`},children:[h.map(e=>(0,T.jsx)(Se,{lnode:e,svgWidth:r,zoomX:p,zoomW:m,highlight:t,onHover:b,onClick:v},e.node.id)),a&&(0,T.jsx)(be,{tip:a,rootValue:e.value})]})}),l.length>1&&(0,T.jsx)(`div`,{style:{marginTop:8,fontSize:11,color:`#475569`,fontFamily:`'JetBrains Mono', monospace`,textAlign:`right`},children:(0,T.jsx)(`button`,{id:`flame-reset-zoom`,onClick:()=>u([e]),style:{background:`none`,border:`1px solid #1e2435`,borderRadius:4,color:`#64748b`,padding:`2px 8px`,fontSize:11,cursor:`pointer`,fontFamily:`inherit`},children:`↩ Reset zoom`})})]})}function Te({report:e}){let t=Object.entries(e.category_costs).filter(([,e])=>e>0).sort((e,t)=>t[1]-e[1]),n=e.total_unified_cost||1;return(0,T.jsxs)(`div`,{className:`category-breakdown`,children:[(0,T.jsx)(`div`,{className:`category-chart`,children:t.map(([e,t])=>{let r=t/n*100,i=x[e];return(0,T.jsx)(`div`,{className:`category-slice`,style:{width:`${r}%`,backgroundColor:i.color},title:`${i.label}: ${w(t)} gas (${r.toFixed(1)}%)`},e)})}),(0,T.jsx)(`div`,{className:`category-legend`,children:t.map(([e,t])=>{let r=t/n*100,i=x[e];return(0,T.jsxs)(`div`,{className:`legend-item`,children:[(0,T.jsx)(`span`,{className:`legend-dot`,style:{backgroundColor:i.color}}),(0,T.jsx)(`span`,{className:`legend-icon`,children:i.icon}),(0,T.jsx)(`span`,{className:`legend-label`,children:i.label}),(0,T.jsx)(`div`,{className:`legend-spacer`}),(0,T.jsxs)(`span`,{className:`legend-value`,children:[w(Math.round(t)),` gas`]}),(0,T.jsxs)(`span`,{className:`legend-pct`,children:[r.toFixed(1),`%`]})]},e)})})]})}function Ee({val:e,pct:t}){let n=e>0,r=n?`#ff4d4d`:`#4dff88`,i=n?`+`:``;return(0,T.jsxs)(`span`,{style:{color:r,fontWeight:`bold`,marginLeft:8},children:[i,w(Math.round(e)),` (`,i,t.toFixed(1),`%)`]})}function De({report:e}){let{base:t,target:n,metrics:r}=e;return(0,T.jsxs)(`div`,{className:`diff-overview`,children:[(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`📊 Comparison Summary`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsxs)(`div`,{className:`metric-grid`,children:[(0,T.jsx)(D,{kind:`evm`,icon:`⛽`,label:`On-Chain Gas`,value:w(r.target_total_gas),sub:(0,T.jsxs)(T.Fragment,{children:[`Baseline: `,w(r.base_total_gas),(0,T.jsx)(Ee,{val:r.gas_delta,pct:r.gas_pct})]})}),(0,T.jsx)(D,{kind:`stylus`,icon:`🦾`,label:`Execution Cost (Unified)`,value:oe(r.target_unified_cost),sub:(0,T.jsxs)(T.Fragment,{children:[`Baseline: `,oe(r.base_unified_cost),(0,T.jsx)(Ee,{val:r.unified_delta,pct:r.unified_pct})]})})]})]}),(0,T.jsxs)(`div`,{style:{display:`grid`,gridTemplateColumns:`1fr 1fr`,gap:20},children:[(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsxs)(`span`,{className:`section-title`,children:[`📐 Baseline ( `,t.tx_hash.slice(0,8),`… )`]}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(Te,{report:t})]}),(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsxs)(`span`,{className:`section-title`,children:[`🎯 Target ( `,n.tx_hash.slice(0,8),`… )`]}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(Te,{report:n})]})]})]})}function Oe(){let[e,t]=(0,_.useState)(null),[n,r]=(0,_.useState)(`overview`),[i,a]=(0,_.useState)(``),o=(0,_.useCallback)(e=>{t(e),r(`overview`)},[]);(0,_.useEffect)(()=>{new URLSearchParams(window.location.search).get(`auto`)===`true`&&fetch(`/auto-load.json`).then(e=>{if(!e.ok)throw Error(`Report not found`);return e.json()}).then(o).catch(e=>{console.warn(`Auto-load failed or no report found:`,e)})},[o]);let s=(0,_.useCallback)(()=>{t(null),r(`overview`),a(``)},[]),c=e?y(e)?e.target:e:null,l=c?re(c):`evm`,u=ie(l),d=c?ae(c):[],f=(0,_.useMemo)(()=>c?ce(c):null,[c]);return(0,T.jsxs)(`div`,{className:`app-shell`,children:[(0,T.jsxs)(`header`,{className:`app-topbar`,children:[(0,T.jsxs)(`a`,{className:`brand`,href:`#`,onClick:s,"aria-label":`Atupa Studio home`,children:[(0,T.jsx)(`span`,{className:`brand-icon`,children:`🏮`}),(0,T.jsx)(`span`,{className:`brand-name`,children:`Atupa`}),(0,T.jsx)(`span`,{className:`brand-tag`,children:`Studio`})]}),e&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`span`,{className:`live-badge`,style:{background:`color-mix(in srgb, ${u.color} 15%, transparent)`,borderColor:u.color,color:u.color},children:[(0,T.jsx)(`span`,{className:`live-dot`,style:{backgroundColor:u.color}}),u.icon,` `,u.label]}),y(e)&&(0,T.jsx)(`span`,{className:`live-badge`,style:{marginLeft:6},children:`⚖️ Diff Mode`}),(0,T.jsx)(`span`,{className:`topbar-tx`,title:y(e)?`${e.base.tx_hash} vs ${e.target.tx_hash}`:e.tx_hash,children:y(e)?`Execution Comparison`:e.tx_hash}),(0,T.jsx)(`button`,{id:`btn-reset`,onClick:s,style:{marginLeft:8,background:`none`,border:`1px solid var(--color-border)`,borderRadius:6,color:`var(--color-text-muted)`,padding:`4px 12px`,fontSize:11,cursor:`pointer`,fontFamily:`var(--font-ui)`},children:`✕ Clear`})]})]}),(0,T.jsxs)(`nav`,{className:`app-sidebar`,"aria-label":`Main navigation`,children:[(0,T.jsx)(`div`,{className:`sidebar-section-label`,children:`Views`}),[{id:`overview`,icon:`📊`,label:`Overview`},{id:`flame`,icon:`🔆`,label:`Visual Trace`},{id:`trace`,icon:`🧩`,label:`Trace Inspector`},...d.length>0?[{id:`hostio`,icon:`🔥`,label:`HostIO Hot Paths`}]:[]].map(t=>(0,T.jsxs)(`button`,{id:`nav-${t.id}`,className:`sidebar-nav-item${n===t.id&&e?` active`:``}`,onClick:()=>e&&r(t.id),disabled:!e,style:{opacity:e?1:.4},children:[(0,T.jsx)(`span`,{className:`nav-icon`,children:t.icon}),t.label]},t.id)),e&&!y(e)&&(0,T.jsxs)(`div`,{className:`sidebar-meta`,children:[(0,T.jsxs)(`div`,{children:[`tx: `,se(e.tx_hash)]}),(0,T.jsxs)(`div`,{children:[`total steps: `,e.steps.length.toLocaleString()]}),l===`solana`&&(0,T.jsxs)(`div`,{children:[`svm: `,C(e).length.toLocaleString()]}),l===`starknet`&&(0,T.jsxs)(`div`,{children:[`cairo: `,te(e).length.toLocaleString()]}),l===`stellar`&&(0,T.jsxs)(`div`,{children:[`soroban: `,ne(e).length.toLocaleString()]}),l===`stylus`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{children:[`evm: `,ee(e).length.toLocaleString()]}),(0,T.jsxs)(`div`,{children:[`wasm: `,S(e).length.toLocaleString()]})]}),l===`evm`&&(0,T.jsxs)(`div`,{children:[`evm: `,ee(e).length.toLocaleString()]})]}),e&&y(e)&&(0,T.jsxs)(`div`,{className:`sidebar-meta`,children:[(0,T.jsx)(`div`,{style:{color:`var(--color-text-primary)`,fontWeight:`bold`},children:`⚖️ DELTA`}),(0,T.jsxs)(`div`,{style:{color:e.metrics.gas_delta>0?`#ff4d4d`:`#4dff88`},children:[`Gas: `,e.metrics.gas_delta>0?`+`:``,w(e.metrics.gas_delta),` (`,e.metrics.gas_pct>0?`+`:``,e.metrics.gas_pct.toFixed(1),`%)`]})]})]}),(0,T.jsx)(`main`,{className:`app-main`,children:e?(0,T.jsxs)(T.Fragment,{children:[n===`overview`&&(0,T.jsxs)(T.Fragment,{children:[y(e)?(0,T.jsx)(De,{report:e}):(0,T.jsx)(T.Fragment,{children:(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`Cost Breakdown by Category`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(Te,{report:e})]})}),c&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsxs)(`span`,{className:`section-title`,children:[u.icon,` `,u.label,` Execution Metrics`]}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsxs)(`div`,{className:`metric-grid`,children:[l===`solana`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(D,{kind:`stylus`,icon:`☀️`,label:`Solana Compute Units`,value:w(c.total_unified_cost),sub:`consumed CU`}),(0,T.jsx)(D,{kind:`steps`,icon:`🧩`,label:`Instruction Steps`,value:w(c.steps.length),sub:`SVM instructions`}),(0,T.jsx)(D,{kind:`evm`,icon:`💾`,label:`State & Token Writes`,value:w(c.category_costs.StorageWrite||0),sub:`SPL balance & account updates`}),(0,T.jsx)(D,{kind:`stylus`,icon:`🔐`,label:`Crypto & Invariants`,value:w(c.category_costs.Crypto||0),sub:`AMM math & hashing`}),(0,T.jsx)(D,{kind:`boundary`,icon:`⇌`,label:`Cross-Program CPIs`,value:w(c.vm_boundary_count),sub:`nested program calls`})]}),l===`starknet`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(D,{kind:`stylus`,icon:`🐺`,label:`Cairo Execution Cost`,value:w(c.total_unified_cost),sub:`Cairo steps + builtins`}),(0,T.jsx)(D,{kind:`steps`,icon:`🧩`,label:`Function Invocations`,value:w(c.steps.length),sub:`Cairo call frames`}),(0,T.jsx)(D,{kind:`evm`,icon:`🔐`,label:`Crypto Builtins`,value:w(c.category_costs.Crypto||0),sub:`ECDSA & Pedersen`}),(0,T.jsx)(D,{kind:`stylus`,icon:`⚙️`,label:`Core Validation Steps`,value:w(c.category_costs.Execution||0),sub:`Range Check & VM steps`}),(0,T.jsx)(D,{kind:`boundary`,icon:`⇌`,label:`Contract Crossings`,value:w(c.vm_boundary_count),sub:`cross-contract invocations`})]}),l===`stellar`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(D,{kind:`stylus`,icon:`🚀`,label:`Soroban Resource Cost`,value:w(c.total_unified_cost),sub:`CPU & Memory units`}),(0,T.jsx)(D,{kind:`steps`,icon:`🧩`,label:`Diagnostic Events`,value:w(c.steps.length),sub:`Soroban events`}),(0,T.jsx)(D,{kind:`evm`,icon:`💾`,label:`State Ledger Updates`,value:w(c.category_costs.StorageWrite||0),sub:`put_contract_data`}),(0,T.jsx)(D,{kind:`stylus`,icon:`🔐`,label:`Crypto Hashing`,value:w(c.category_costs.Crypto||0),sub:`SHA256 & verification`}),(0,T.jsx)(D,{kind:`boundary`,icon:`⇌`,label:`HostFn Calls`,value:w(c.vm_boundary_count),sub:`Host Function invocations`})]}),l===`stylus`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(D,{kind:`evm`,icon:`⛽`,label:`EVM Trace Gas`,value:w(c.total_evm_gas),sub:`gas units`}),(0,T.jsx)(D,{kind:`stylus`,icon:`🦾`,label:`Stylus Ink`,value:w(c.total_stylus_ink),sub:`≈ ${oe(c.total_stylus_gas_equiv)} gas-equiv`}),(0,T.jsx)(D,{kind:`steps`,icon:`🧩`,label:`EVM Steps`,value:w(ee(c).length),sub:`struct log entries`}),(0,T.jsx)(D,{kind:`stylus`,icon:`📡`,label:`Stylus HostIOs`,value:w(S(c).length),sub:`WASM host calls`}),(0,T.jsx)(D,{kind:`boundary`,icon:`⇌`,label:`VM Boundaries`,value:w(c.vm_boundary_count),sub:`EVM ↔ WASM crossings`})]}),l===`evm`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(D,{kind:`evm`,icon:`⛽`,label:`On-Chain EVM Gas`,value:w(c.total_evm_gas),sub:`gas units`}),(0,T.jsx)(D,{kind:`steps`,icon:`🧩`,label:`Opcode Steps`,value:w(c.steps.length),sub:`struct log entries`}),(0,T.jsx)(D,{kind:`evm`,icon:`💾`,label:`Storage Writes`,value:w(c.category_costs.StorageWrite||0),sub:`SSTORE operations`}),(0,T.jsx)(D,{kind:`stylus`,icon:`📖`,label:`Storage Reads`,value:w(c.category_costs.StorageRead||0),sub:`SLOAD operations`}),(0,T.jsx)(D,{kind:`boundary`,icon:`📡`,label:`External Calls`,value:w(c.category_costs.Call||0),sub:`CALL / STATICCALL`})]})]})]}),d.length>0&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`🔥 Top Ink Consumers`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(fe,{rows:d.slice(0,6)})]})]}),n===`flame`&&f&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`🔆 Visual Trace`}),(0,T.jsx)(`div`,{className:`section-divider`}),(0,T.jsx)(`input`,{id:`flame-search`,type:`search`,placeholder:`Search node…`,value:i,onChange:e=>a(e.target.value),style:{padding:`4px 10px`,background:`var(--color-bg-raised)`,border:`1px solid var(--color-border)`,borderRadius:6,color:`var(--color-text-primary)`,fontSize:12}})]}),(0,T.jsx)(we,{root:f,search:i})]}),n===`trace`&&c&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`🧩 Trace Inspector`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(he,{report:c})]}),n===`hostio`&&d.length>0&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`🔥 Stylus HostIO Hot Paths`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsx)(fe,{rows:d})]})]}):(0,T.jsx)(de,{onLoad:o})})]})}(0,v.createRoot)(document.getElementById(`root`)).render((0,T.jsx)(_.StrictMode,{children:(0,T.jsx)(Oe,{})})); \ No newline at end of file diff --git a/bin/atupa/dist/assets/index-MuaPuQu_.css b/bin/atupa/dist/assets/index-MuaPuQu_.css new file mode 100644 index 0000000..bcd19a5 --- /dev/null +++ b/bin/atupa/dist/assets/index-MuaPuQu_.css @@ -0,0 +1 @@ +:root{--color-bg-void:#07080d;--color-bg-base:#0d0f1a;--color-bg-surface:#12151f;--color-bg-raised:#181c2a;--color-bg-glass:#181c2abf;--color-border:#ffffff12;--color-border-accent:#ff2a4a59;--color-crimson:#ff2a4a;--color-crimson-glow:#ff2a4a2e;--color-amber:#ffb340;--color-amber-glow:#ffb34026;--color-teal:#2fe4c4;--color-teal-glow:#2fe4c41f;--color-violet:#a78bfa;--color-violet-glow:#a78bfa1f;--color-text-primary:#f0f0f8;--color-text-secondary:#9a9db5;--color-text-muted:#555870;--color-text-evm:#60d9ff;--color-text-stylus:#ffb340;--color-text-boundary:#a78bfa;--badge-evm-bg:#60d9ff1f;--badge-evm-color:#60d9ff;--badge-stylus-bg:#ffb3401f;--badge-stylus-color:#ffb340;--font-ui:"Inter", system-ui, sans-serif;--font-brand:"Outfit", "Inter", sans-serif;--font-mono:"JetBrains Mono", "Fira Code", monospace;--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:20px;--sp-6:24px;--sp-8:32px;--sp-10:40px;--sp-12:48px;--radius-sm:6px;--radius-md:10px;--radius-lg:16px;--radius-xl:24px;--shadow-card:0 4px 24px #00000073;--shadow-glow-red:0 0 24px #ff2a4a38;--shadow-glow-amber:0 0 24px #ffb3402e;--t-fast:.15s cubic-bezier(.4, 0, .2, 1);--t-normal:.25s cubic-bezier(.4, 0, .2, 1);--t-slow:.4s cubic-bezier(.4, 0, .2, 1)}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;overflow:hidden}body{font-family:var(--font-ui);background:var(--color-bg-void);color:var(--color-text-primary);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-size:14px;line-height:1.6}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--color-border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--color-text-muted)}.app-shell{grid-template-rows:56px 1fr;grid-template-columns:280px 1fr;height:100vh;display:grid;overflow:hidden}.app-topbar{align-items:center;gap:var(--sp-4);padding:0 var(--sp-6);background:var(--color-bg-surface);border-bottom:1px solid var(--color-border);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);z-index:100;grid-column:1/-1;display:flex}.app-sidebar{background:var(--color-bg-surface);border-right:1px solid var(--color-border);gap:var(--sp-2);padding:var(--sp-4);flex-direction:column;display:flex;overflow-y:auto}.app-main{padding:var(--sp-6);gap:var(--sp-6);flex-direction:column;display:flex;overflow:auto}.brand{align-items:center;gap:var(--sp-3);text-decoration:none;display:flex}.brand-icon{font-size:22px;line-height:1}.brand-name{font-family:var(--font-brand);color:var(--color-text-primary);letter-spacing:.03em;font-size:17px;font-weight:700}.brand-tag{color:var(--color-crimson);background:var(--color-crimson-glow);border:1px solid var(--color-border-accent);letter-spacing:.06em;text-transform:uppercase;border-radius:99px;padding:2px 7px;font-size:11px;font-weight:500}.topbar-tx{font-family:var(--font-mono);color:var(--color-text-muted);text-overflow:ellipsis;white-space:nowrap;max-width:400px;margin-left:auto;font-size:11px;overflow:hidden}.glass-card{background:var(--color-bg-glass);border:1px solid var(--color-border);border-radius:var(--radius-lg);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);box-shadow:var(--shadow-card);padding:var(--sp-5);transition:border-color var(--t-normal), box-shadow var(--t-normal)}.glass-card:hover{border-color:#ffffff1f}.section-header{align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-4);display:flex}.section-title{font-family:var(--font-brand);letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-secondary);font-size:13px;font-weight:600}.section-divider{background:var(--color-border);flex:1;height:1px}.metric-grid{gap:var(--sp-4);grid-template-columns:repeat(auto-fill,minmax(190px,1fr));display:grid}.metric-card{background:var(--color-bg-raised);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--sp-4) var(--sp-5);transition:transform var(--t-normal), border-color var(--t-normal), box-shadow var(--t-normal);cursor:default;position:relative;overflow:hidden}.metric-card:before{content:"";opacity:0;transition:opacity var(--t-normal);position:absolute;inset:0}.metric-card:hover{transform:translateY(-2px)}.metric-card:hover:before{opacity:1}.metric-card.evm{border-top:2px solid var(--color-text-evm)}.metric-card.evm:before{background:linear-gradient(135deg,#60d9ff0f 0%,#0000 70%)}.metric-card.evm:hover{border-color:#60d9ff66;box-shadow:0 8px 32px #60d9ff1a}.metric-card.stylus{border-top:2px solid var(--color-amber)}.metric-card.stylus:before{background:linear-gradient(135deg, var(--color-amber-glow) 0%, transparent 70%)}.metric-card.stylus:hover{box-shadow:var(--shadow-glow-amber);border-color:#ffb34066}.metric-card.boundary{border-top:2px solid var(--color-violet)}.metric-card.boundary:before{background:linear-gradient(135deg, var(--color-violet-glow) 0%, transparent 70%)}.metric-card.boundary:hover{border-color:#a78bfa66;box-shadow:0 8px 32px #a78bfa1a}.metric-card.steps{border-top:2px solid var(--color-teal)}.metric-card.steps:before{background:linear-gradient(135deg, var(--color-teal-glow) 0%, transparent 70%)}.metric-card.steps:hover{border-color:#2fe4c466;box-shadow:0 8px 32px #2fe4c41a}.metric-label{letter-spacing:.06em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:var(--sp-2);font-size:11px;font-weight:500}.metric-value{font-family:var(--font-mono);color:var(--color-text-primary);font-size:22px;font-weight:500;line-height:1.2}.metric-value.evm{color:var(--color-text-evm)}.metric-value.stylus{color:var(--color-amber)}.metric-value.boundary{color:var(--color-violet)}.metric-value.steps{color:var(--color-teal)}.metric-sub{color:var(--color-text-muted);margin-top:var(--sp-1);font-size:11px;font-family:var(--font-mono)}.hostio-table{border-collapse:collapse;width:100%}.hostio-table th{text-align:left;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--color-border);font-size:11px;font-weight:500}.hostio-table td{padding:var(--sp-2) var(--sp-3);font-family:var(--font-mono);vertical-align:middle;border-bottom:1px solid #ffffff08;font-size:12px}.hostio-table tr:last-child td{border-bottom:none}.hostio-table tr:hover td{background:#ffffff06}.hostio-name{color:var(--color-amber)}.hostio-gas{color:var(--color-text-primary);text-align:right}.hostio-ink{color:var(--color-text-secondary);text-align:right}.hostio-pct{color:var(--color-text-muted);text-align:right}.flame-bar-cell{width:140px;padding-right:var(--sp-3)}.flame-bar-track{background:#ffb3401f;border-radius:3px;height:6px;overflow:hidden}.flame-bar-fill{background:linear-gradient(90deg, var(--color-amber), #ff8c40);height:100%;transition:width var(--t-slow);border-radius:3px}.trace-list{flex-direction:column;gap:2px;display:flex}.trace-step{align-items:center;gap:var(--sp-3);padding:5px var(--sp-3);border-radius:var(--radius-sm);cursor:pointer;transition:background var(--t-fast);display:flex;position:relative}.trace-step:hover{background:#ffffff0a}.trace-step.is-boundary{border-left:2px solid var(--color-violet);padding-left:calc(var(--sp-3) - 2px);background:#a78bfa0f}.trace-step.is-boundary:hover{background:#a78bfa1a}.trace-step-index{font-family:var(--font-mono);color:var(--color-text-muted);text-align:right;min-width:32px;font-size:10px}.trace-step-badge{font-family:var(--font-mono);text-align:center;border-radius:4px;min-width:52px;padding:2px 6px;font-size:10px;font-weight:500}.trace-step-badge.evm{background:var(--badge-evm-bg);color:var(--badge-evm-color)}.trace-step-badge.stylus{background:var(--badge-stylus-bg);color:var(--badge-stylus-color)}.trace-step-badge.solana{color:#2fe4c4;background:#2fe4c426}.trace-step-badge.starknet{color:#a78bfa;background:#a78bfa26}.trace-step-badge.stellar{color:#60d9ff;background:#60d9ff26}.trace-step-label{font-family:var(--font-mono);color:var(--color-text-primary);flex:1;font-size:12px}.trace-step-cost{font-family:var(--font-mono);color:var(--color-text-muted);text-align:right;font-size:11px}.trace-depth-indent{flex-shrink:0;width:12px;display:inline-block}.drop-zone{justify-content:center;align-items:center;gap:var(--sp-5);border:2px dashed var(--color-border);border-radius:var(--radius-xl);background:var(--color-bg-surface);min-height:380px;transition:border-color var(--t-normal), background var(--t-normal);cursor:pointer;padding:var(--sp-10);text-align:center;flex-direction:column;display:flex}.drop-zone.dragging{border-color:var(--color-crimson);background:var(--color-crimson-glow);box-shadow:var(--shadow-glow-red)}.drop-icon{font-size:52px;line-height:1;animation:3.5s ease-in-out infinite float}@keyframes float{0%,to{transform:translateY(0)}50%{transform:translateY(-8px)}}.drop-title{font-family:var(--font-brand);color:var(--color-text-primary);font-size:22px;font-weight:700;line-height:1.3}.drop-subtitle{color:var(--color-text-secondary);max-width:380px;font-size:13px;line-height:1.6}.drop-cta{align-items:center;gap:var(--sp-2);padding:10px var(--sp-6);background:var(--color-crimson);color:#fff;font-family:var(--font-ui);border-radius:var(--radius-md);cursor:pointer;transition:opacity var(--t-fast), transform var(--t-fast);border:none;font-size:13px;font-weight:600;display:inline-flex;box-shadow:0 4px 16px #ff2a4a59}.drop-cta:hover{opacity:.88;transform:translateY(-1px)}.drop-cta:active{transform:translateY(0)}.drop-hint{color:var(--color-text-muted);font-size:11px;font-family:var(--font-mono)}.sidebar-section-label{letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:var(--sp-2) var(--sp-2);margin-top:var(--sp-3);font-size:10px;font-weight:600}.sidebar-nav-item{align-items:center;gap:var(--sp-3);padding:8px var(--sp-3);border-radius:var(--radius-sm);color:var(--color-text-secondary);cursor:pointer;transition:background var(--t-fast), color var(--t-fast);text-align:left;background:0 0;border:none;width:100%;font-size:13px;font-weight:500;display:flex}.sidebar-nav-item:hover{color:var(--color-text-primary);background:#ffffff0d}.sidebar-nav-item.active{background:var(--color-crimson-glow);color:var(--color-crimson);border:1px solid var(--color-border-accent)}.sidebar-nav-item .nav-icon{min-width:18px;font-size:15px}.sidebar-meta{padding-top:var(--sp-4);border-top:1px solid var(--color-border);color:var(--color-text-muted);font-size:11px;font-family:var(--font-mono);margin-top:auto;line-height:1.8}@keyframes pulse-ring{0%{box-shadow:0 0 #ff2a4a80}70%{box-shadow:0 0 0 8px #0000}to{box-shadow:0 0 #0000}}.live-badge{letter-spacing:.06em;text-transform:uppercase;color:var(--color-crimson);background:var(--color-crimson-glow);border:1px solid var(--color-border-accent);border-radius:99px;align-items:center;gap:5px;padding:2px 8px;font-size:10px;font-weight:600;display:inline-flex}.live-dot{background:var(--color-crimson);border-radius:50%;width:6px;height:6px;animation:1.8s infinite pulse-ring}@media (width<=900px){.app-shell{grid-template-rows:56px auto 1fr;grid-template-columns:1fr}.app-sidebar{display:none}}.category-breakdown{gap:var(--sp-6);padding:var(--sp-2) 0;flex-direction:column;display:flex}.category-chart{background:var(--color-bg-void);border-radius:var(--radius-md);border:1px solid var(--color-border);height:32px;display:flex;overflow:hidden;box-shadow:inset 0 2px 4px #0003}.category-slice{height:100%;transition:width var(--t-slow), opacity var(--t-fast);cursor:help}.category-slice:hover{opacity:.85;filter:brightness(1.2)}.category-legend{gap:var(--sp-3) var(--sp-6);grid-template-columns:repeat(auto-fill,minmax(240px,1fr));display:grid}.legend-item{align-items:center;gap:var(--sp-2);font-family:var(--font-mono);padding:var(--sp-1) var(--sp-2);border-radius:var(--radius-sm);transition:background var(--t-fast);font-size:12px;display:flex}.legend-item:hover{background:#ffffff08}.legend-dot{border-radius:50%;flex-shrink:0;width:8px;height:8px}.legend-icon{margin-right:var(--sp-1);font-size:14px}.legend-label{color:var(--color-text-primary);white-space:nowrap}.legend-spacer{border-bottom:1px dotted var(--color-border);margin:0 var(--sp-2);opacity:.5;flex:1}.legend-value{color:var(--color-text-secondary);text-align:right}.legend-pct{color:var(--color-text-muted);text-align:right;min-width:45px}.trace-step-label.resolved{color:var(--color-crimson);text-shadow:0 0 8px #ff2a4a33;font-weight:600} diff --git a/bin/atupa/dist/auto-load.json b/bin/atupa/dist/auto-load.json new file mode 100644 index 0000000..b0ecc37 --- /dev/null +++ b/bin/atupa/dist/auto-load.json @@ -0,0 +1,137 @@ +{ + "tx_hash": "0x8a923fc41b0294e75618b76a0293847562019485726194857261948572619485", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "CALL (Entrypoint)", + "gas_cost": 21000, + "cost_equiv": 21000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" + }, + { + "index": 1, + "vm": "Evm", + "label": "PUSH4 0x38ed1739", + "gas_cost": 3, + "cost_equiv": 3, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Evm", + "label": "SLOAD", + "gas_cost": 2100, + "cost_equiv": 2100, + "depth": 1, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 3, + "vm": "Evm", + "label": "STATICCALL", + "gas_cost": 2600, + "cost_equiv": 2600, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + }, + { + "index": 4, + "vm": "Stylus", + "label": "stylus:msg_sender", + "gas_cost": 0, + "cost_equiv": 420.5, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 5, + "vm": "Stylus", + "label": "stylus:storage_load_bytes32", + "gas_cost": 0, + "cost_equiv": 1250.0, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 6, + "vm": "Stylus", + "label": "stylus:native_keccak256", + "gas_cost": 0, + "cost_equiv": 680.0, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 7, + "vm": "Stylus", + "label": "stylus:storage_flush_cache", + "gas_cost": 0, + "cost_equiv": 5400.0, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 8, + "vm": "Evm", + "label": "SSTORE", + "gas_cost": 20000, + "cost_equiv": 20000, + "depth": 1, + "is_vm_boundary": true, + "category": "StorageWrite" + }, + { + "index": 9, + "vm": "Evm", + "label": "LOG2", + "gas_cost": 1875, + "cost_equiv": 1875, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 10, + "vm": "Evm", + "label": "RETURN", + "gas_cost": 0, + "cost_equiv": 0, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + } + ], + "total_evm_gas": 47578, + "total_stylus_ink": 7750500, + "total_stylus_gas_equiv": 7750.5, + "total_unified_cost": 55328.5, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 25400, + "StorageRead": 3350, + "Call": 23600, + "Crypto": 680, + "Memory": 0, + "Execution": 2298.5, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e": "StylusVault", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "WETH9" + } +} \ No newline at end of file diff --git a/bin/atupa/dist/demos/aave.json b/bin/atupa/dist/demos/aave.json new file mode 100644 index 0000000..a12e4b6 --- /dev/null +++ b/bin/atupa/dist/demos/aave.json @@ -0,0 +1,91 @@ +{ + "tx_hash": "0x39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80746201948", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "AaveV3::Pool.supply(asset=USDC, amount=100,000)", + "gas_cost": 45000, + "cost_equiv": 45000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" + }, + { + "index": 1, + "vm": "Evm", + "label": "ReserveLogic::updateState (LiquidityIndex & VariableBorrowIndex)", + "gas_cost": 18200, + "cost_equiv": 18200, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 2, + "vm": "Evm", + "label": "ValidationLogic::validateSupply", + "gas_cost": 4600, + "cost_equiv": 4600, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 3, + "vm": "Evm", + "label": "aUSDC::mint (ScaledBalance Updated)", + "gas_cost": 22100, + "cost_equiv": 22100, + "depth": 2, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0x98C23E9d8f34FEFb1B72F6d102F7573986B0C043" + }, + { + "index": 4, + "vm": "Evm", + "label": "GHOFlashMinter::flashLoan(amount=500,000 GHO)", + "gas_cost": 38400, + "cost_equiv": 38400, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0x40be3B02376E62C2666323497d50B6e4bE13fDE8" + }, + { + "index": 5, + "vm": "Evm", + "label": "AaveOracle::getAssetPrice(USDC)", + "gas_cost": 2400, + "cost_equiv": 2400, + "depth": 3, + "is_vm_boundary": false, + "category": "StorageRead", + "target_address": "0x54586bE62E3c3580375aE3723C145253060Ca0C2" + } + ], + "total_evm_gas": 130700, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 130700, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 40300, + "StorageRead": 7000, + "Call": 83400, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2": "Aave v3 Pool (Ethereum)", + "0x98C23E9d8f34FEFb1B72F6d102F7573986B0C043": "aEthUSDC Token", + "0x40be3B02376E62C2666323497d50B6e4bE13fDE8": "GHO FlashMinter", + "0x54586bE62E3c3580375aE3723C145253060Ca0C2": "Aave Oracle" + } +} diff --git a/bin/atupa/dist/demos/diff.json b/bin/atupa/dist/demos/diff.json new file mode 100644 index 0000000..aef3d07 --- /dev/null +++ b/bin/atupa/dist/demos/diff.json @@ -0,0 +1,103 @@ +{ + "type": "diff", + "base": { + "tx_hash": "0xBASE923fc41b0294e75618b76a02938475620194857261948572619485726194", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "UniswapV3::exactInputSingle", + "gas_cost": 125000, + "cost_equiv": 125000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0xE592427A0AEce92De3Edee1F18E0157C05861564" + }, + { + "index": 1, + "vm": "Evm", + "label": "Pool::swap (Old Storage Layout)", + "gas_cost": 64000, + "cost_equiv": 64000, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + } + ], + "total_evm_gas": 189000, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 189000, + "vm_boundary_count": 0, + "category_costs": { + "StorageWrite": 64000, + "StorageRead": 0, + "Call": 125000, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0xE592427A0AEce92De3Edee1F18E0157C05861564": "Uniswap V3 SwapRouter" + } + }, + "target": { + "tx_hash": "0xTARGET3fc41b0294e75618b76a02938475620194857261948572619485726194", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "UniswapV4::swap (Hooks & Transient Storage)", + "gas_cost": 84000, + "cost_equiv": 84000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x498581fF718922c3f8e6A244956aF099B2652b2b" + }, + { + "index": 1, + "vm": "Evm", + "label": "TSTORE (Transient Storage Slot)", + "gas_cost": 100, + "cost_equiv": 100, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + } + ], + "total_evm_gas": 84100, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 84100, + "vm_boundary_count": 0, + "category_costs": { + "StorageWrite": 100, + "StorageRead": 0, + "Call": 84000, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x498581fF718922c3f8e6A244956aF099B2652b2b": "Uniswap V4 PoolManager" + } + }, + "metrics": { + "base_total_gas": 189000, + "target_total_gas": 84100, + "gas_delta": -104900, + "gas_pct": -55.5, + "base_unified_cost": 189000, + "target_unified_cost": 84100, + "unified_delta": -104900, + "unified_pct": -55.5 + } +} diff --git a/bin/atupa/dist/demos/lido.json b/bin/atupa/dist/demos/lido.json new file mode 100644 index 0000000..4695da8 --- /dev/null +++ b/bin/atupa/dist/demos/lido.json @@ -0,0 +1,82 @@ +{ + "tx_hash": "0x1fca898234be45a198c234509172462839401726485019284756102938475619", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "Lido::submit(referral=0x0) [Stake 32 ETH]", + "gas_cost": 52000, + "cost_equiv": 52000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + }, + { + "index": 1, + "vm": "Evm", + "label": "StakingRouter::getDepositLimit", + "gas_cost": 3100, + "cost_equiv": 3100, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead", + "target_address": "0xFdDf38947aFB06167083462ea50162A7733ba05c" + }, + { + "index": 2, + "vm": "Evm", + "label": "stETH::mintShares (Rebase Balance Calculated)", + "gas_cost": 28400, + "cost_equiv": 28400, + "depth": 2, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + }, + { + "index": 3, + "vm": "Evm", + "label": "LidoOracle::handleConsensusReport", + "gas_cost": 41200, + "cost_equiv": 41200, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0x442af784A788A5bd6F42A01Ebe1ED6480e6107b4" + }, + { + "index": 4, + "vm": "Evm", + "label": "WithdrawalQueueERC721::requestWithdrawalsWithPermit", + "gas_cost": 34600, + "cost_equiv": 34600, + "depth": 2, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1" + } + ], + "total_evm_gas": 159300, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 159300, + "vm_boundary_count": 3, + "category_costs": { + "StorageWrite": 63000, + "StorageRead": 3100, + "Call": 93200, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84": "Lido: stETH Token", + "0xFdDf38947aFB06167083462ea50162A7733ba05c": "Lido Staking Router", + "0x442af784A788A5bd6F42A01Ebe1ED6480e6107b4": "Lido Oracle", + "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1": "Lido: Withdrawal Queue NFT" + } +} diff --git a/bin/atupa/dist/demos/solana.json b/bin/atupa/dist/demos/solana.json new file mode 100644 index 0000000..af5dc98 --- /dev/null +++ b/bin/atupa/dist/demos/solana.json @@ -0,0 +1,128 @@ +{ + "tx_hash": "5Z9mJkQp7rN2vX8yW1cE4uT6hY3aB8dF5gH7jK9mN2vX", + "steps": [ + { + "index": 0, + "vm": "Solana", + "label": "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "gas_cost": 150, + "cost_equiv": 150, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 1, + "vm": "Solana", + "label": "SetComputeUnitLimit(200000)", + "gas_cost": 150, + "cost_equiv": 150, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Solana", + "label": "Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 (Raydium Swap) invoke [1]", + "gas_cost": 4500, + "cost_equiv": 4500, + "depth": 1, + "is_vm_boundary": true, + "category": "Call", + "target_address": "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" + }, + { + "index": 3, + "vm": "Solana", + "label": "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA (SPL Token) invoke [2]", + "gas_cost": 3200, + "cost_equiv": 3200, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "index": 4, + "vm": "Solana", + "label": "TransferChecked: 1,500.00 USDC", + "gas_cost": 4120, + "cost_equiv": 4120, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 5, + "vm": "Solana", + "label": "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "gas_cost": 500, + "cost_equiv": 500, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 6, + "vm": "Solana", + "label": "AMM Pool Invariant Calculation", + "gas_cost": 12850, + "cost_equiv": 12850, + "depth": 1, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 7, + "vm": "Solana", + "label": "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA (Mint Output) invoke [2]", + "gas_cost": 3100, + "cost_equiv": 3100, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "index": 8, + "vm": "Solana", + "label": "TransferChecked: 0.45 SOL", + "gas_cost": 4050, + "cost_equiv": 4050, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 9, + "vm": "Solana", + "label": "Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 success", + "gas_cost": 620, + "cost_equiv": 620, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + } + ], + "total_evm_gas": 0, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 33240, + "vm_boundary_count": 3, + "category_costs": { + "StorageWrite": 8170, + "StorageRead": 0, + "Call": 10800, + "Crypto": 12850, + "Memory": 0, + "Execution": 1420, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8": "Raydium Liquidity Pool v4", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA": "SPL Token Program" + } +} diff --git a/bin/atupa/dist/demos/starknet.json b/bin/atupa/dist/demos/starknet.json new file mode 100644 index 0000000..61ee8f1 --- /dev/null +++ b/bin/atupa/dist/demos/starknet.json @@ -0,0 +1,89 @@ +{ + "tx_hash": "0x04c8f429bc41b0294e75618b76a0293847562019485726194857261948572619", + "steps": [ + { + "index": 0, + "vm": "Starknet", + "label": "Account::execute [Cairo]", + "gas_cost": 4200, + "cost_equiv": 4200, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution", + "target_address": "0x0124367982f1b0294e75618b76a029384756201948572619485726194857261" + }, + { + "index": 1, + "vm": "Starknet", + "label": "builtin:ecdsa_signature_verification", + "gas_cost": 20480, + "cost_equiv": 20480, + "depth": 1, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 2, + "vm": "Starknet", + "label": "JediSwap::swap_exact_tokens_for_tokens", + "gas_cost": 8500, + "cost_equiv": 8500, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" + }, + { + "index": 3, + "vm": "Starknet", + "label": "builtin:pedersen_hash", + "gas_cost": 3200, + "cost_equiv": 3200, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 4, + "vm": "Starknet", + "label": "builtin:range_check", + "gas_cost": 1600, + "cost_equiv": 1600, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 5, + "vm": "Starknet", + "label": "ERC20::transfer (State Update)", + "gas_cost": 14200, + "cost_equiv": 14200, + "depth": 3, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8" + } + ], + "total_evm_gas": 0, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 52180, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 14200, + "StorageRead": 0, + "Call": 8500, + "Crypto": 23680, + "Memory": 0, + "Execution": 5800, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x0124367982f1b0294e75618b76a029384756201948572619485726194857261": "Braavos Smart Account", + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": "JediSwap Router", + "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8": "Starknet USDC ERC20" + } +} diff --git a/bin/atupa/dist/demos/stellar.json b/bin/atupa/dist/demos/stellar.json new file mode 100644 index 0000000..8f03034 --- /dev/null +++ b/bin/atupa/dist/demos/stellar.json @@ -0,0 +1,85 @@ +{ + "tx_hash": "c5949d28a49c4f1c998318182b8a74e534f3efd8544c45b85438efca88921a99", + "steps": [ + { + "index": 0, + "vm": "Stellar", + "label": "InvokeHostFunction: Soroban VM Init", + "gas_cost": 2500, + "cost_equiv": 2500, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 1, + "vm": "Stellar", + "label": "HostFn::get_ledger_sequence", + "gas_cost": 450, + "cost_equiv": 450, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Stellar", + "label": "HostFn::call (Soroban AMM Pool)", + "gas_cost": 5200, + "cost_equiv": 5200, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "CB4J4S547RGYV5T53F2H4W527G34LKVQ7M5RFTZ55YPQXQO6X7J34XYZ" + }, + { + "index": 3, + "vm": "Stellar", + "label": "HostFn::get_contract_data", + "gas_cost": 1850, + "cost_equiv": 1850, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 4, + "vm": "Stellar", + "label": "HostFn::compute_sha256", + "gas_cost": 3100, + "cost_equiv": 3100, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 5, + "vm": "Stellar", + "label": "HostFn::put_contract_data (Reserves Updated)", + "gas_cost": 16400, + "cost_equiv": 16400, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + } + ], + "total_evm_gas": 0, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 29500, + "vm_boundary_count": 1, + "category_costs": { + "StorageWrite": 16400, + "StorageRead": 1850, + "Call": 5200, + "Crypto": 3100, + "Memory": 0, + "Execution": 2950, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "CB4J4S547RGYV5T53F2H4W527G34LKVQ7M5RFTZ55YPQXQO6X7J34XYZ": "Soroswap AMM Pair" + } +} diff --git a/bin/atupa/dist/demos/stylus.json b/bin/atupa/dist/demos/stylus.json new file mode 100644 index 0000000..305239b --- /dev/null +++ b/bin/atupa/dist/demos/stylus.json @@ -0,0 +1,137 @@ +{ + "tx_hash": "0x8a923fc41b0294e75618b76a0293847562019485726194857261948572619485", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "CALL (Entrypoint)", + "gas_cost": 21000, + "cost_equiv": 21000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" + }, + { + "index": 1, + "vm": "Evm", + "label": "PUSH4 0x38ed1739", + "gas_cost": 3, + "cost_equiv": 3, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Evm", + "label": "SLOAD", + "gas_cost": 2100, + "cost_equiv": 2100, + "depth": 1, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 3, + "vm": "Evm", + "label": "STATICCALL", + "gas_cost": 2600, + "cost_equiv": 2600, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + }, + { + "index": 4, + "vm": "Stylus", + "label": "stylus:msg_sender", + "gas_cost": 0, + "cost_equiv": 420.5, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 5, + "vm": "Stylus", + "label": "stylus:storage_load_bytes32", + "gas_cost": 0, + "cost_equiv": 1250.0, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 6, + "vm": "Stylus", + "label": "stylus:native_keccak256", + "gas_cost": 0, + "cost_equiv": 680.0, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 7, + "vm": "Stylus", + "label": "stylus:storage_flush_cache", + "gas_cost": 0, + "cost_equiv": 5400.0, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 8, + "vm": "Evm", + "label": "SSTORE", + "gas_cost": 20000, + "cost_equiv": 20000, + "depth": 1, + "is_vm_boundary": true, + "category": "StorageWrite" + }, + { + "index": 9, + "vm": "Evm", + "label": "LOG2", + "gas_cost": 1875, + "cost_equiv": 1875, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 10, + "vm": "Evm", + "label": "RETURN", + "gas_cost": 0, + "cost_equiv": 0, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + } + ], + "total_evm_gas": 47578, + "total_stylus_ink": 7750500, + "total_stylus_gas_equiv": 7750.5, + "total_unified_cost": 55328.5, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 25400, + "StorageRead": 3350, + "Call": 23600, + "Crypto": 680, + "Memory": 0, + "Execution": 2298.5, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e": "ArbitrumStylusVault", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "WETH9" + } +} diff --git a/bin/atupa/dist/index.html b/bin/atupa/dist/index.html index e69de29..60d2e67 100644 --- a/bin/atupa/dist/index.html +++ b/bin/atupa/dist/index.html @@ -0,0 +1,21 @@ + + + + + + + + Atupa Studio + + + + + + + +

+ + diff --git a/bin/atupa/src/banner.rs b/bin/atupa/src/banner.rs new file mode 100644 index 0000000..18bfd78 --- /dev/null +++ b/bin/atupa/src/banner.rs @@ -0,0 +1,36 @@ +//! ASCII banner and terminal styling helpers for the Atupa CLI. + +use colored::*; + +/// Prints the Atupa terminal banner. +pub fn print_banner() { + eprintln!( + "{}", + "╔════════════════════════════════════════════╗".dimmed() + ); + eprintln!( + "{} {} {}", + "║".dimmed(), + " 🏮 ATUPA · Unified Execution Profiler ".bold(), + "║".dimmed() + ); + eprintln!( + "{}", + "╚════════════════════════════════════════════╝".dimmed() + ); + eprintln!(); +} + +/// Returns ANSI color escape code for HostIO call labels. +pub fn hostio_category_color(label: &str) -> &'static str { + match label { + "storage_flush_cache" | "storage_store_bytes32" => "\x1b[31;1m", + "storage_load_bytes32" | "storage_cache_bytes32" => "\x1b[33m", + "native_keccak256" => "\x1b[35m", + "read_args" | "write_result" | "pay_for_memory_grow" => "\x1b[32m", + "msg_sender" | "msg_value" | "msg_reentrant" | "emit_log" | "account_balance" + | "block_hash" => "\x1b[36m", + "call" | "static_call" | "delegate_call" | "create" => "\x1b[34m", + _ => "\x1b[90m", + } +} diff --git a/bin/atupa/src/cli.rs b/bin/atupa/src/cli.rs new file mode 100644 index 0000000..1e79c8b --- /dev/null +++ b/bin/atupa/src/cli.rs @@ -0,0 +1,310 @@ +//! CLI argument models and subcommands for the Atupa command-line interface. + +use clap::{Parser, Subcommand, ValueEnum}; + +/// Top-level CLI configuration. +#[derive(Parser, Debug)] +#[command( + name = "atupa", + bin_name = "atupa", + about = "🏮 Atupa — Universal Multi-VM Execution Profiler", + long_about = "\ +Inspect, profile, and audit transactions across Multi-VM\n\ +Part of the One Block infrastructure suite.\n\ +SOURCE: https://github.com/One-Block-Org/Atupa", + version +)] +pub struct Cli { + /// Arbitrum / Ethereum / Multi-VM RPC endpoint (or set ATUPA_RPC_URL) + #[arg(short, long, global = true, value_name = "URL")] + pub rpc: Option, + + #[command(subcommand)] + pub command: Commands, +} + +/// Available CLI subcommands. +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Generate a visual SVG flamegraph for any EVM/Stylus/Solana/Starknet/Stellar transaction + Profile { + /// Transaction hash (0x-prefixed or base58); omit when using --demo + #[arg(short, long, value_name = "TX_HASH", default_value = "")] + tx: String, + + /// Run an offline demo trace (no RPC required) + #[arg(long, default_value_t = false)] + demo: bool, + + /// Output path for the SVG (default: profile_.svg) + #[arg(short, long, value_name = "FILE")] + out: Option, + + /// Etherscan API key for contract name resolution + #[arg(long, value_name = "KEY")] + etherscan_key: Option, + + /// Explicitly select which VM runtime to use (default: auto-detect from RPC/tx) + #[arg(long, value_enum, value_name = "VM")] + vm: Option, + }, + + /// Capture a unified execution trace and export JSON/terminal metrics. + /// + /// Add --profile to also generate an SVG flamegraph from the same RPC call. + /// Add --studio to automatically launch Atupa Studio with the report loaded. + Capture { + /// Transaction hash to profile (0x-prefixed) + #[arg(short, long, value_name = "TX_HASH")] + tx: String, + + /// Output format for the JSON/summary report + #[arg(short, long, value_enum, default_value_t = OutputFormat::Summary)] + output: OutputFormat, + + /// Write report to a file instead of stdout + #[arg(short = 'f', long, value_name = "FILE")] + file: Option, + + /// Also generate an SVG flamegraph (reuses the same RPC trace) + #[arg(long, default_value_t = false)] + profile: bool, + + /// Etherscan API key for contract name resolution + #[arg(long, value_name = "KEY")] + etherscan_key: Option, + + /// Launch Atupa Studio after capture and open it in the browser + #[arg(long, default_value_t = false)] + studio: bool, + + /// Explicitly select which VM runtime to use (default: auto-detect from RPC URL) + #[arg(long, value_enum, value_name = "VM")] + vm: Option, + }, + + /// Protocol-aware execution auditing (Aave v3/GHO, Lido) + Audit { + /// Transaction hash to audit (0x-prefixed) + #[arg(short, long, value_name = "TX_HASH")] + tx: String, + + /// Protocol adapter to apply + #[arg(short, long, value_enum, default_value_t = Protocol::Aave)] + protocol: Protocol, + }, + + /// Compare the execution cost of two transactions + Diff { + /// Base transaction hash (0x-prefixed) + #[arg(short, long, value_name = "BASE_TX")] + base: String, + + /// Target transaction hash (0x-prefixed) + #[arg(short, long, value_name = "TARGET_TX")] + target: String, + + /// Simple mode override: Fail CI if gas increases by > X% + #[arg(long, value_name = "PERCENT")] + threshold: Option, + + /// Path to atupa.toml (defaults to looking in CWD) + #[arg(long, value_name = "FILE")] + config: Option, + + /// Generate artifacts/diff/report.md for GitHub PRs + #[arg(long, default_value_t = false)] + markdown: bool, + + /// Generate visual diff flamegraph in artifacts/diff/ + #[arg(long, default_value_t = false)] + svg: bool, + + /// Output format (summary | json | metric) + #[arg(short, long, value_enum, default_value_t = OutputFormat::Summary)] + output: OutputFormat, + + /// Optional: Run DeepTracer on both and diff heuristics + #[arg(short, long, value_enum)] + protocol: Option, + + /// Explicitly select which VM runtime to use (default: auto-detect from RPC URL) + #[arg(long, value_enum, value_name = "VM")] + vm: Option, + }, + + /// Launch Atupa Studio — the local web visualizer for trace reports + Studio { + /// Port for the dev server (default: 5173) + #[arg(short, long, default_value_t = 5173)] + port: u16, + + /// Path to the studio directory (overrides auto-detection) + #[arg(long, value_name = "DIR")] + dir: Option, + + /// Open the browser automatically after the server starts + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + open: bool, + }, + + /// Scaffold Atupa config, GitHub Actions workflow, and a profile script + /// + /// Run this once in a new repository to get started. + /// Detects Foundry, Hardhat, or Stylus projects automatically. + Init { + /// Overwrite existing files + #[arg(long, default_value_t = false)] + force: bool, + }, +} + +/// Supported report and output formats. +#[derive(Clone, Copy, ValueEnum, Debug, PartialEq, Eq)] +pub enum OutputFormat { + /// Human-readable terminal summary (default) + Summary, + /// Full step-by-step JSON — suitable for CI assertions and tooling + Json, + /// Emit only the numeric unified cost (gas-equiv) — ideal for scripting + Metric, +} + +/// Explicitly selects which VM runtime the profiler should use when +/// auto-detection (based on RPC URL or tx-hash format) is ambiguous. +#[derive(Clone, Copy, ValueEnum, Debug, PartialEq, Eq)] +pub enum VmTarget { + /// Standard EVM / Arbitrum Nitro (default) + Evm, + /// Arbitrum Stylus / WASM (EVM + HostIO stitching) + Stylus, + /// Starknet Cairo VM + Starknet, + /// Solana Sealevel VM + Solana, + /// Stellar Soroban WASM VM + Stellar, +} + +impl VmTarget { + /// Converts CLI target into the SDK's [`atupa::profile::VmHint`]. + pub fn to_sdk_hint(self) -> atupa::profile::VmHint { + match self { + Self::Evm => atupa::profile::VmHint::Evm, + Self::Stylus => atupa::profile::VmHint::Stylus, + Self::Starknet => atupa::profile::VmHint::Starknet, + Self::Solana => atupa::profile::VmHint::Solana, + Self::Stellar => atupa::profile::VmHint::Stellar, + } + } +} + +/// Target DeFi protocol for specialized deep tracing and invariant checking. +#[derive(Clone, Copy, ValueEnum, Debug, PartialEq, Eq)] +pub enum Protocol { + /// Aave v3 + GHO stablecoin protocol adapters + Aave, + /// Lido stETH execution resilience + Lido, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vm_target_to_sdk_hint_conversions() { + assert_eq!(VmTarget::Evm.to_sdk_hint(), atupa::profile::VmHint::Evm); + assert_eq!( + VmTarget::Stylus.to_sdk_hint(), + atupa::profile::VmHint::Stylus + ); + assert_eq!( + VmTarget::Starknet.to_sdk_hint(), + atupa::profile::VmHint::Starknet + ); + assert_eq!( + VmTarget::Solana.to_sdk_hint(), + atupa::profile::VmHint::Solana + ); + assert_eq!( + VmTarget::Stellar.to_sdk_hint(), + atupa::profile::VmHint::Stellar + ); + } + + #[test] + fn parses_profile_subcommand() { + let cli = Cli::try_parse_from(["atupa", "profile", "--demo", "--vm", "stylus"]).unwrap(); + match cli.command { + Commands::Profile { demo, vm, .. } => { + assert!(demo); + assert_eq!(vm, Some(VmTarget::Stylus)); + } + _ => panic!("Expected Profile command"), + } + } + + #[test] + fn parses_capture_subcommand_with_output_flags() { + let cli = Cli::try_parse_from([ + "atupa", + "capture", + "--tx", + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "--output", + "json", + "--profile", + ]) + .unwrap(); + + match cli.command { + Commands::Capture { + tx, + output, + profile, + .. + } => { + assert_eq!( + tx, + "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ); + assert_eq!(output, OutputFormat::Json); + assert!(profile); + } + _ => panic!("Expected Capture command"), + } + } + + #[test] + fn parses_diff_subcommand() { + let cli = Cli::try_parse_from([ + "atupa", + "diff", + "--base", + "0xaaaa", + "--target", + "0xbbbb", + "--threshold", + "5.5", + "--markdown", + ]) + .unwrap(); + + match cli.command { + Commands::Diff { + base, + target, + threshold, + markdown, + .. + } => { + assert_eq!(base, "0xaaaa"); + assert_eq!(target, "0xbbbb"); + assert_eq!(threshold, Some(5.5)); + assert!(markdown); + } + _ => panic!("Expected Diff command"), + } + } +} diff --git a/bin/atupa/src/commands/audit.rs b/bin/atupa/src/commands/audit.rs new file mode 100644 index 0000000..01f3491 --- /dev/null +++ b/bin/atupa/src/commands/audit.rs @@ -0,0 +1,239 @@ +//! Handler for `atupa audit` command (Aave v3/GHO, Lido stETH). + +use anyhow::{Context, Result}; +use colored::*; + +use atupa_aave::AaveDeepTracer; +use atupa_core::TraceStep; +use atupa_core::config::AtupaConfig; +use atupa_lido::LidoDeepTracer; +use atupa_nitro::{NitroClient, StitchedReport, VmKind}; +use atupa_rpc::EthClient; + +use crate::cli::Protocol; +use crate::utils::{bridge_raw_to_trace_step, divider, make_spinner, normalise_hash}; + +/// Executes the `audit` command against specialized protocol deep tracers. +pub async fn cmd_audit(config: &AtupaConfig, tx: &str, protocol: Protocol) -> Result<()> { + let tx = normalise_hash(tx); + let label = match protocol { + Protocol::Aave => "Aave v3 + GHO", + Protocol::Lido => "Lido stETH", + }; + + eprintln!( + "{} {} audit for {}", + "→".bold(), + label.yellow().bold(), + tx.cyan() + ); + eprintln!("{} {}\n", "→ Endpoint:".bold(), config.rpc_url.dimmed()); + + let eth_client = EthClient::new(config.rpc_url.clone()); + let client = NitroClient::new(config.rpc_url.clone()); + + // Fetch the top-level calldata selector (non-fatal) + let top_level_selector = eth_client + .get_transaction_input(&tx) + .await + .and_then(|input| EthClient::selector_from_input(&input)); + + let pb = make_spinner(&format!("Fetching trace for {label} audit…")); + + let report = client + .trace_transaction(&tx) + .await + .context("Failed to fetch trace — is the Arbitrum / EVM node reachable?")?; + + pb.finish_with_message(format!( + "{} Trace captured ({} unified steps).", + "✔".green().bold(), + report.steps.len() + )); + + match protocol { + Protocol::Aave => { + let pb2 = make_spinner("Applying Aave v3 + GHO protocol adapter…"); + + let trace_steps: Vec = report + .steps + .iter() + .filter(|s| s.vm == VmKind::Evm) + .filter_map(|s| s.evm.as_ref()) + .map(bridge_raw_to_trace_step) + .collect(); + + let tracer = AaveDeepTracer::new(); + let liq = tracer + .analyze_liquidation(&tx, &trace_steps) + .context("Aave adapter failed")?; + + pb2.finish_with_message(format!("{} Aave v3 adapter complete.", "✔".green().bold())); + eprintln!(); + print_aave_report(&liq, &report, top_level_selector.as_deref()); + } + Protocol::Lido => { + let pb2 = make_spinner("Applying Lido stETH protocol adapter…"); + + let trace_steps: Vec = report + .steps + .iter() + .filter(|s| s.vm == VmKind::Evm) + .filter_map(|s| s.evm.as_ref()) + .map(bridge_raw_to_trace_step) + .collect(); + + let tracer = LidoDeepTracer::new(); + let res = tracer + .analyze_staking(&tx, &trace_steps) + .context("Lido adapter failed")?; + + pb2.finish_with_message(format!( + "{} Lido stETH adapter complete.", + "✔".green().bold() + )); + eprintln!(); + print_lido_report(&res, &report, top_level_selector.as_deref()); + } + } + + Ok(()) +} + +fn print_aave_report( + aave: &atupa_aave::LiquidationReport, + nitro: &StitchedReport, + top_selector: Option<&str>, +) { + let div = divider(56); + println!("{}", " AAVE v3 PROTOCOL AUDIT".bold().underline()); + println!("{div}"); + + if let Some(sel) = top_selector { + let fn_name = atupa_aave::AaveV3Adapter::resolve_selector_label(sel) + .unwrap_or_else(|| format!("unknown ({sel})")); + println!( + " {:<34} {}", + "Top-Level Call:".bold(), + fn_name.yellow().bold() + ); + } + + let rows: &[(&str, String)] = &[ + ("Total Gas (Aave frame):", aave.total_gas.to_string()), + ("Liquidation Gas:", aave.liquidation_gas.to_string()), + ("Storage Reads (SLOAD):", aave.storage_reads.to_string()), + ("Storage Writes (SSTORE):", aave.storage_writes.to_string()), + ("External Calls:", aave.external_calls.to_string()), + ("Oracle Calls:", aave.oracle_calls.to_string()), + ( + "Cross-VM Calls (Stylus):", + nitro.vm_boundary_count.to_string(), + ), + ("Max Call Depth:", aave.max_depth.to_string()), + ]; + for (label, val) in rows { + println!(" {:<34} {}", label.bold(), val.cyan()); + } + println!("{div}"); + + if !aave.labeled_calls.is_empty() { + println!(" {}", "Protocol Calls Detected:".bold()); + for call in aave.labeled_calls.iter().take(10) { + println!( + " {} {} {}", + format!("[depth={:>2}]", call.depth).dimmed(), + call.label.yellow(), + format!("({} gas)", call.gas_cost).dimmed() + ); + } + println!("{div}"); + } + + println!( + " {:<34} {}", + "Reverted:".bold(), + if aave.reverted { + "YES".red().bold().to_string() + } else { + "NO".green().to_string() + } + ); + println!( + " {:<34} {:.4}", + "Liquidation Efficiency:".bold(), + aave.liquidation_efficiency + ); + println!("{div}"); +} + +fn print_lido_report( + lido: &atupa_lido::LidoReport, + nitro: &StitchedReport, + top_selector: Option<&str>, +) { + let div = divider(56); + println!("{}", " LIDO stETH PROTOCOL AUDIT".bold().underline()); + println!("{div}"); + + if let Some(sel) = top_selector { + let fn_name = atupa_lido::LidoAdapter::resolve_selector_label(sel) + .unwrap_or_else(|| format!("unknown fn ({sel})")); + println!( + " {:<34} {}", + "Top-Level Call:".bold(), + fn_name.yellow().bold() + ); + } + + let rows: &[(&str, String)] = &[ + ("Total Gas (Lido frame):", lido.total_gas.to_string()), + ("Storage Reads (SLOAD):", lido.storage_reads.to_string()), + ("Storage Writes (SSTORE):", lido.storage_writes.to_string()), + ("External Calls:", lido.external_calls.to_string()), + ("Shares Transfers:", lido.shares_transfers.to_string()), + ("Oracle Reports:", lido.oracle_reports.to_string()), + ("Withdrawal Requests:", lido.withdrawal_requests.to_string()), + ("Withdrawal Claims:", lido.withdrawal_claims.to_string()), + ("Wrapped Ops (wstETH):", lido.wrapped_ops.to_string()), + ( + "Cross-VM Calls (Stylus):", + nitro.vm_boundary_count.to_string(), + ), + ("Max Call Depth:", lido.max_depth.to_string()), + ]; + for (label, val) in rows { + println!(" {:<34} {}", label.bold(), val.cyan()); + } + println!("{div}"); + + if !lido.labeled_calls.is_empty() { + println!(" {}", "Protocol Calls Detected:".bold()); + for call in lido.labeled_calls.iter().take(10) { + println!( + " {} {} {}", + format!("[depth={:>2}]", call.depth).dimmed(), + call.label.yellow(), + format!("({} gas)", call.gas_cost).dimmed() + ); + } + if lido.labeled_calls.len() > 10 { + println!( + " ... and {} more", + (lido.labeled_calls.len() - 10).to_string().dimmed() + ); + } + println!("{div}"); + } + + println!( + " {:<34} {}", + "Reverted:".bold(), + if lido.reverted { + "YES".red().bold().to_string() + } else { + "NO".green().to_string() + } + ); + println!("{div}"); +} diff --git a/bin/atupa/src/commands/capture.rs b/bin/atupa/src/commands/capture.rs new file mode 100644 index 0000000..1e1c158 --- /dev/null +++ b/bin/atupa/src/commands/capture.rs @@ -0,0 +1,554 @@ +//! Handler for `atupa capture` command and multi-VM trace capture routines. + +use anyhow::{Context, Result}; +use colored::*; +use std::collections::{HashMap, HashSet}; + +use atupa_core::config::AtupaConfig; +use atupa_nitro::{NitroClient, StitchedReport, UnifiedStep, VmKind}; +use atupa_output::SvgGenerator; +use atupa_parser::Parser as TraceParser; +use atupa_parser::aggregator::Aggregator; +use atupa_rpc::EthClient; + +use crate::banner::hostio_category_color; +use crate::cli::{OutputFormat, VmTarget}; +use crate::utils::{ + divider, evm_count, get_network_name, make_spinner, normalise_hash, resolve_artifact_path, + trace_steps_to_report, +}; + +/// Executes the `capture` command across EVM / Arbitrum Stylus / Solana / Starknet / Stellar. +pub async fn cmd_capture( + config: &AtupaConfig, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, + vm: Option, +) -> Result> { + let tx = normalise_hash(tx); + eprintln!("{} {}", "→ Transaction:".bold(), tx.cyan()); + eprintln!("{} {}\n", "→ Endpoint: ".bold(), config.rpc_url.dimmed()); + + // Hint-or-heuristic routing + let use_starknet = matches!(vm, Some(VmTarget::Starknet)) + || (vm.is_none() && config.rpc_url.contains("starknet")); + let use_solana = !use_starknet + && (matches!(vm, Some(VmTarget::Solana)) + || (vm.is_none() && config.rpc_url.contains("solana"))); + let use_stellar = !use_starknet + && !use_solana + && (matches!(vm, Some(VmTarget::Stellar)) + || (vm.is_none() + && (config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban")))); + + let report_path = if use_starknet { + handle_starknet_capture(&config.rpc_url, &tx, format, file, generate_profile).await? + } else if use_solana { + handle_solana_capture(&config.rpc_url, &tx, format, file, generate_profile).await? + } else if use_stellar { + handle_stellar_capture(&config.rpc_url, &tx, format, file, generate_profile).await? + } else { + handle_nitro_capture(config, &tx, format, file, generate_profile).await? + }; + + Ok(Some(report_path)) +} + +// ─── Multi-VM Handlers ──────────────────────────────────────────────────────── + +async fn handle_starknet_capture( + rpc_url: &str, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = make_spinner("Detecting Starknet network and fetching execution trace…"); + let client = atupa_starknet::StarknetClient::new(rpc_url.to_string()); + let steps = client.profile_transaction(tx).await.context( + "Failed to fetch Starknet trace — ensure the RPC endpoint is valid and accessible.", + )?; + + pb.finish_with_message(format!( + "{} Captured Starknet trace ({} steps)", + "✔".green().bold(), + steps.len().to_string().cyan().bold() + )); + + let svg_path = if generate_profile { + Some(generate_and_save_svg(&steps, tx, &file)?) + } else { + None + }; + + let pb_render = make_spinner("Rendering report…"); + let report = trace_steps_to_report(tx, steps, VmKind::Starknet); + let json_for_disk = serde_json::to_string_pretty(&report)?; + let rendered = match format { + OutputFormat::Summary => format!( + "Starknet trace: {} steps · {:.2} gas-equiv", + report.steps.len(), + report.total_unified_cost + ), + OutputFormat::Json => json_for_disk.clone(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), + }; + pb_render.finish_with_message(format!("{} Report ready.", "✔".green().bold())); + + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) +} + +async fn handle_solana_capture( + rpc_url: &str, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = make_spinner("Detecting Solana network and fetching execution trace…"); + let client = atupa_solana::SolanaClient::new(rpc_url.to_string()); + let logs = client.get_transaction_logs(tx).await.context( + "Failed to fetch Solana logs — ensure the RPC endpoint is valid and accessible.", + )?; + + let steps = atupa_solana::SolanaLogStitcher::parse_logs(&logs); + + pb.finish_with_message(format!( + "{} Reconstructed Solana trace ({} steps)", + "✔".green().bold(), + steps.len().to_string().cyan().bold() + )); + + let svg_path = if generate_profile { + Some(generate_and_save_svg(&steps, tx, &file)?) + } else { + None + }; + + let pb_render = make_spinner("Rendering report…"); + let report = trace_steps_to_report(tx, steps, VmKind::Solana); + let json_for_disk = serde_json::to_string_pretty(&report)?; + let rendered = match format { + OutputFormat::Summary => format!( + "Solana trace: {} steps · {} compute units", + report.steps.len(), + report.total_evm_gas + ), + OutputFormat::Json => json_for_disk.clone(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), + }; + pb_render.finish_with_message(format!("{} Report ready.", "✔".green().bold())); + + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) +} + +async fn handle_stellar_capture( + rpc_url: &str, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = make_spinner("Detecting Stellar network and fetching diagnostic events…"); + let client = atupa_stellar::StellarClient::new(rpc_url.to_string()); + let steps = client + .get_transaction_trace(tx) + .await + .context("Failed to fetch Stellar diagnostic events — ensure the RPC endpoint supports Soroban traces.")?; + + pb.finish_with_message(format!( + "{} Reconstructed Soroban trace ({} steps)", + "✔".green().bold(), + steps.len().to_string().cyan().bold() + )); + + let svg_path = if generate_profile { + Some(generate_and_save_svg(&steps, tx, &file)?) + } else { + None + }; + + let pb_render = make_spinner("Rendering report…"); + let report = trace_steps_to_report(tx, steps, VmKind::Stellar); + let json_for_disk = serde_json::to_string_pretty(&report)?; + let rendered = match format { + OutputFormat::Summary => format!( + "Stellar/Soroban trace: {} host function calls · {} resource units", + report.steps.len(), + report.total_evm_gas + ), + OutputFormat::Json => json_for_disk.clone(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), + }; + pb_render.finish_with_message(format!("{} Report ready.", "✔".green().bold())); + + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) +} + +async fn handle_nitro_capture( + config: &AtupaConfig, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = make_spinner("Detecting network and fetching execution trace…"); + let client = NitroClient::new(config.rpc_url.clone()); + + let mut report = client + .trace_transaction(tx) + .await + .context("Failed to fetch trace — ensure the RPC endpoint is valid and accessible.")?; + + let network_name = get_network_name(report.chain_id); + pb.finish_with_message(format!( + "{} Captured trace from {} ({} EVM steps{} )", + "✔".green().bold(), + network_name.cyan().bold(), + evm_count(&report).to_string().green(), + if report.total_stylus_ink > 0 { + format!( + " + {} Stylus HostIOs", + report.stylus_steps().len().to_string().yellow() + ) + } else { + "".into() + } + )); + + // Fetch on-chain gasUsed from receipt (non-fatal) + let eth_client = EthClient::new(config.rpc_url.clone()); + report.on_chain_gas_used = eth_client.get_gas_used(tx).await; + + // Resolve contract names via Etherscan if configured + if let Some(key) = config.etherscan_key.clone() { + resolve_names_via_etherscan(&mut report, &key).await?; + } + + // Optional flamegraph SVG + let svg_path = if generate_profile { + let trace_steps: Vec = + report.steps.iter().map(|s| s.to_trace_step()).collect(); + Some(generate_and_save_svg(&trace_steps, tx, &file)?) + } else { + None + }; + + let (rendered, json_for_disk) = render_nitro_report(&report, &format)?; + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) +} + +async fn resolve_names_via_etherscan( + report: &mut StitchedReport, + etherscan_key: &str, +) -> Result<()> { + let pb_names = make_spinner("Resolving contract names via Etherscan…"); + let resolver = atupa_rpc::etherscan::EtherscanResolver::new( + Some(etherscan_key.to_string()), + report.chain_id, + ); + + let mut addresses = HashSet::new(); + for step in &report.steps { + if let Some(evm) = &step.evm + && (evm.op.contains("CALL") || evm.op.contains("CREATE")) + && let Some(stack) = &evm.stack + && stack.len() >= 2 + { + let hex_addr = &stack[stack.len() - 2]; + let clean_hex = hex_addr.trim_start_matches("0x"); + let padded = format!("{:0>40}", clean_hex); + let extracted = &padded[padded.len() - 40..]; + addresses.insert(format!("0x{extracted}")); + } + } + + for addr in addresses { + if let Some(name) = resolver.resolve_contract_name(&addr).await { + report.resolved_names.insert(addr, name); + } + } + pb_names.finish_with_message(format!( + "{} Resolved {} contract name(s) via Etherscan.", + "✔".green().bold(), + report.resolved_names.len().to_string().cyan().bold() + )); + Ok(()) +} + +// ─── Rendering Helpers ──────────────────────────────────────────────────────── + +fn render_nitro_report(report: &StitchedReport, format: &OutputFormat) -> Result<(String, String)> { + let pb_render = make_spinner("Rendering report…"); + let json_for_disk = serde_json::to_string_pretty(report)?; + + let rendered = match format { + OutputFormat::Summary => render_capture_summary(report), + OutputFormat::Json => json_for_disk.clone(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), + }; + pb_render.finish_with_message(format!("{} Report ready.", "✔".green().bold())); + Ok((rendered, json_for_disk)) +} + +fn render_capture_summary(report: &StitchedReport) -> String { + let div = divider(56); + let mut out = String::new(); + + out += &format!( + " {} ({})\n{}\n", + "UNIFIED EXECUTION SUMMARY".bold().underline(), + get_network_name(report.chain_id).cyan(), + div + ); + + out += &render_gas_totals(report); + out += &format!("{div}\n"); + out += &format!( + " {:<34} {}\n{}\n", + "TOTAL UNIFIED COST:".bold().cyan(), + format!("{:.2} gas", report.total_unified_cost) + .cyan() + .bold(), + div + ); + + out += &format!( + " {:<34} {}\n", + "EVM Steps:".bold(), + evm_count(report).to_string().green() + ); + + let stylus = report.stylus_steps(); + if !stylus.is_empty() { + out += &render_stylus_summary(report, &stylus, &div); + } + + out += &format!(" tx {}\n", report.tx_hash.dimmed()); + out +} + +fn render_gas_totals(report: &StitchedReport) -> String { + let mut out = String::new(); + if let Some(on_chain) = report.on_chain_gas_used { + let execution_gas = report.total_evm_gas; + let intrinsic_gas = on_chain.saturating_sub(execution_gas); + out += &format!( + " {:<34} {}\n", + "Total Gas Used (on-chain):".bold(), + on_chain.to_string().green().bold() + ); + out += &format!( + " {:<34} {}\n", + " ├─ Execution:".dimmed(), + execution_gas.to_string().green() + ); + out += &format!( + " {:<34} {}\n", + " └─ Intrinsic (base + calldata):".dimmed(), + intrinsic_gas.to_string().yellow() + ); + } else { + out += &format!( + " {:<34} {}\n", + "EVM Trace Gas (Total):".bold(), + report.total_evm_gas.to_string().green() + ); + } + + if report.total_stylus_ink > 0 { + out += &format!( + " {:<34} {}\n", + "Stylus Ink (raw):".bold(), + report.total_stylus_ink.to_string().yellow() + ); + out += &format!( + " {:<34} {}\n", + " → Gas-equivalent (÷10,000):".dimmed(), + format!("{:.2}", report.total_stylus_gas_equiv).yellow() + ); + } + + if report.vm_boundary_count > 0 { + out += &format!( + " {:<34} {}\n", + "VM Boundaries (EVM ↔ WASM):".bold(), + report.vm_boundary_count.to_string().magenta() + ); + } + out +} + +fn render_stylus_summary(report: &StitchedReport, stylus: &[&UnifiedStep], div: &str) -> String { + let mut out = String::new(); + let mut grouped: HashMap = HashMap::new(); + for step in stylus.iter() { + *grouped.entry(step.label.clone()).or_insert(0.0) += step.cost_equiv; + } + let mut aggregated: Vec<(String, f64)> = grouped.into_iter().collect(); + aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + let total_ink_gas: f64 = aggregated.iter().map(|(_, c)| c).sum(); + let unique_paths = aggregated.len(); + + out += &format!( + " {:<34} {}\n", + "Stylus HostIO Calls:".bold(), + stylus.len().to_string().yellow() + ); + out += &format!( + " {:<34} {}\n", + "Unique HostIO Paths:".bold(), + unique_paths.to_string().yellow() + ); + + if report.vm_boundary_count > 0 { + out += &format!(" {}\n", "EVM→WASM Boundary Details:".bold()); + for (i, step) in report.boundary_steps().iter().take(5).enumerate() { + out += &format!( + " {} {} at depth {}\n", + format!("[{}]", i + 1).cyan(), + step.label.bold(), + step.depth.to_string().dimmed() + ); + } + if report.vm_boundary_count > 5 { + out += &format!( + " … and {} more\n", + (report.vm_boundary_count - 5).to_string().dimmed() + ); + } + } + + out += &format!("{div}\n"); + out += &render_hot_paths(&aggregated, total_ink_gas); + out += &render_ascii_flamegraph(&aggregated, total_ink_gas, unique_paths); + out += &format!("{div}\n"); + out +} + +fn render_hot_paths(aggregated: &[(String, f64)], total_ink_gas: f64) -> String { + let wide_div = "━".repeat(72); + let reset = "\x1b[0m"; + let mut out = format!(" {}\n {wide_div}\n", "🔥 STYLUS HOT PATHS".bold()); + out += &format!( + " ┃ {:<42} ┃ {:>10} ┃ {:>14} ┃ {:>7} ┃\n", + "HostIO (Hottest First)", "GAS", "INK (raw)", "%" + ); + out += &format!(" {wide_div}\n"); + + for (label, cost_gas) in aggregated.iter().take(10) { + let cost_ink = (cost_gas * 10_000.0) as u64; + let pct = if total_ink_gas > 0.0 { + cost_gas / total_ink_gas * 100.0 + } else { + 0.0 + }; + let color = hostio_category_color(label); + let gas_str = format!("{:.0}", cost_gas); + out += &format!( + " ┃ {color}{:<42}{reset} ┃ {gas_str:>10} ┃ {cost_ink:>14} ┃ {pct:>6.1}% ┃\n", + label + ); + } + out += &format!(" {wide_div}\n"); + out +} + +fn render_ascii_flamegraph( + aggregated: &[(String, f64)], + total_ink_gas: f64, + unique_paths: usize, +) -> String { + let reset = "\x1b[0m"; + let mut out = format!("\n {}\n", "📊 SIMPLIFIED FLAMEGRAPH".bold()); + out += " root ██████████████████████████████████████████████████ 100%\n"; + + for (label, cost_gas) in aggregated.iter().take(5) { + let pct = if total_ink_gas > 0.0 { + cost_gas / total_ink_gas * 100.0 + } else { + 0.0 + }; + let bar_width = (pct / 2.0) as usize; + let bar = "█".repeat(bar_width); + let color = hostio_category_color(label); + out += &format!( + " └─ {color}{:<20}{reset} {color}{:<50}{reset} {:>5.1}%\n", + label, bar, pct + ); + } + if unique_paths > 10 { + out += &format!("\n ({} of {} unique paths shown)\n", 10, unique_paths); + } + out +} + +fn generate_and_save_svg( + steps: &[atupa_core::TraceStep], + tx: &str, + file_option: &Option, +) -> Result { + let pb_svg = make_spinner("Generating SVG flamegraph…"); + let normalized = TraceParser::normalize_raw(steps.to_vec()); + let registry = atupa::build_default_registry(); + let stacks = Aggregator::build_collapsed_stacks_with_registry(&normalized, ®istry); + let svg = + SvgGenerator::generate_flamegraph(&stacks).context("SVG flamegraph generation failed")?; + + let svg_suggestion = file_option.as_ref().map(|f| { + if f.ends_with(".json") { + f.trim_end_matches(".json").to_string() + ".svg" + } else { + f.to_string() + ".svg" + } + }); + let svg_out = resolve_artifact_path(svg_suggestion, "capture", tx, "svg"); + std::fs::write(&svg_out, svg).with_context(|| format!("Failed to write SVG to '{svg_out}'"))?; + + pb_svg.finish_with_message(format!( + "{} SVG saved → {}", + "✔".green().bold(), + svg_out.green().bold() + )); + Ok(svg_out) +} + +fn finalize_report( + rendered: &str, + format: &OutputFormat, + json_for_disk: &str, + file_option: Option, + tx: &str, + svg_path: Option, +) -> Result { + eprintln!(); + match format { + OutputFormat::Summary => println!("{rendered}"), + OutputFormat::Json => println!("{rendered}"), + OutputFormat::Metric => println!("{rendered}"), + } + eprintln!(); + + let report_path = resolve_artifact_path(file_option, "capture", tx, "json"); + std::fs::write(&report_path, json_for_disk) + .with_context(|| format!("Failed to write report to '{report_path}'"))?; + + eprintln!( + "{} Report saved to {}", + "✔".green().bold(), + report_path.cyan().bold() + ); + + if let Some(svg) = svg_path { + eprintln!( + "{} SVG profile saved to {}", + "✔".green().bold(), + svg.cyan().bold() + ); + } + + Ok(report_path) +} diff --git a/bin/atupa/src/commands/diff.rs b/bin/atupa/src/commands/diff.rs new file mode 100644 index 0000000..72122d0 --- /dev/null +++ b/bin/atupa/src/commands/diff.rs @@ -0,0 +1,855 @@ +//! Handler for `atupa diff` command across Multi-VM and protocol deep tracers. + +use anyhow::{Context, Result}; +use colored::*; + +use atupa_aave::AaveDeepTracer; +use atupa_core::config::AtupaConfig; +use atupa_core::{DiffRow, TraceStep}; +use atupa_lido::LidoDeepTracer; +use atupa_nitro::{NitroClient, StitchedReport}; +use atupa_parser::Parser as TraceParser; +use atupa_parser::aggregator::Aggregator; +use atupa_rpc::EthClient; + +use crate::cli::{OutputFormat, Protocol, VmTarget}; +use crate::thresholds::AtupaConfigToml; +use crate::utils::{evm_count, make_spinner, normalise_hash}; + +/// Executes the `diff` command, comparing base and target transactions. +#[allow(clippy::too_many_arguments)] +pub async fn cmd_diff( + config: &AtupaConfig, + base: &str, + target: &str, + threshold: Option, + diff_config: Option, + markdown: bool, + svg: bool, + output_format: OutputFormat, + protocol: Option, + vm: Option, +) -> Result<()> { + let base = normalise_hash(base); + let target = normalise_hash(target); + + eprintln!( + "{} {} {} {}", + "→ Base: ".bold(), + base.cyan(), + "Target:".bold(), + target.yellow() + ); + eprintln!("{} {}\n", "→ Endpoint:".bold(), config.rpc_url.dimmed()); + + let use_solana = + matches!(vm, Some(VmTarget::Solana)) || (vm.is_none() && config.rpc_url.contains("solana")); + let use_starknet = !use_solana + && (matches!(vm, Some(VmTarget::Starknet)) + || (vm.is_none() && config.rpc_url.contains("starknet"))); + let use_stellar = !use_solana + && !use_starknet + && (matches!(vm, Some(VmTarget::Stellar)) + || (vm.is_none() + && (config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban")))); + + if use_solana { + handle_solana_diff(&config.rpc_url, &base, &target, threshold, svg).await?; + } else if use_starknet { + handle_starknet_diff(&config.rpc_url, &base, &target, threshold, svg).await?; + } else if use_stellar { + handle_stellar_diff(&config.rpc_url, &base, &target, threshold, svg).await?; + } else { + handle_nitro_diff( + config, + &base, + &target, + threshold, + diff_config, + markdown, + svg, + output_format, + protocol, + ) + .await?; + } + + Ok(()) +} + +// ─── Multi-VM Diff Handlers ─────────────────────────────────────────────────── + +struct GenericDiffArgs<'a> { + network_name: &'a str, + unit_name: &'a str, + base_tx: &'a str, + target_tx: &'a str, + base_steps: Vec, + target_steps: Vec, + svg: bool, + threshold: Option, +} + +struct GenericDiffData { + base_cost: u64, + target_cost: u64, + cost_delta: f64, + cost_pct: f64, + base_count: usize, + target_count: usize, + count_delta: f64, + count_pct: f64, +} + +fn calculate_generic_diff_data(args: &GenericDiffArgs) -> GenericDiffData { + let base_cost = args.base_steps.iter().map(|s| s.gas_cost).sum::(); + let target_cost = args.target_steps.iter().map(|s| s.gas_cost).sum::(); + let cost_delta = target_cost as f64 - base_cost as f64; + let cost_pct = if base_cost > 0 { + cost_delta / base_cost as f64 * 100.0 + } else { + 0.0 + }; + + let base_count = args.base_steps.len(); + let target_count = args.target_steps.len(); + let count_delta = target_count as f64 - base_count as f64; + let count_pct = if base_count > 0 { + count_delta / base_count as f64 * 100.0 + } else { + 0.0 + }; + + GenericDiffData { + base_cost, + target_cost, + cost_delta, + cost_pct, + base_count, + target_count, + count_delta, + count_pct, + } +} + +fn print_generic_diff_summary(args: &GenericDiffArgs, data: &GenericDiffData) { + let div = "─".repeat(70).dimmed().to_string(); + + println!( + "{}", + format!(" {} EXECUTION DIFF", args.network_name) + .bold() + .underline() + ); + println!("{div}"); + println!( + " {:<25} {:<15} {:<15} {}", + "Metric".bold(), + "Base".bold(), + "Target".bold(), + "Delta".bold() + ); + println!("{div}"); + + let colorize_delta = |delta: f64, pct: f64| -> String { + let sign = if delta >= 0.0 { "+" } else { "" }; + if delta > 0.0 { + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .red() + .to_string() + } else if delta < 0.0 { + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .green() + .to_string() + } else { + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .dimmed() + .to_string() + } + }; + + println!( + " {:<25} {:<15} {:<15} {}", + format!("Total {}:", args.unit_name), + data.base_cost.to_string().cyan(), + data.target_cost.to_string().cyan(), + colorize_delta(data.cost_delta, data.cost_pct) + ); + + println!( + " {:<25} {:<15} {:<15} {}", + "Execution Steps:", + data.base_count.to_string().green(), + data.target_count.to_string().yellow(), + colorize_delta(data.count_delta, data.count_pct) + ); + println!("{div}\n"); +} + +fn evaluate_generic_thresholds(args: &GenericDiffArgs, data: &GenericDiffData) -> Vec { + let mut failures = Vec::new(); + if let Some(t) = args.threshold + && let Some(err) = crate::thresholds::DiffConfig::evaluate_simple_threshold( + args.unit_name, + data.cost_pct, + t, + ) + { + failures.push(err); + } + failures +} + +fn generate_generic_diff_svg(args: &GenericDiffArgs) -> Result<()> { + let pb_svg = make_spinner("Generating diff flamegraph…"); + let base_norm = TraceParser::normalize_raw(args.base_steps.clone()); + let target_norm = TraceParser::normalize_raw(args.target_steps.clone()); + let registry = atupa::build_default_registry(); + let base_stacks = Aggregator::build_collapsed_stacks_with_registry(&base_norm, ®istry); + let target_stacks = Aggregator::build_collapsed_stacks_with_registry(&target_norm, ®istry); + + let svg_out = atupa_output::generate_diff_flamegraph(&base_stacks, &target_stacks) + .context("SVG diff generation failed")?; + let out_path = format!( + "artifacts/diff/{}_vs_{}.svg", + &args.base_tx[..10.min(args.base_tx.len())], + &args.target_tx[..10.min(args.target_tx.len())] + ); + std::fs::create_dir_all("artifacts/diff").ok(); + std::fs::write(&out_path, svg_out).context("Failed to write diff SVG")?; + pb_svg.finish_with_message(format!( + "{} Diff SVG saved → {}", + "✔".green().bold(), + out_path.cyan() + )); + Ok(()) +} + +fn process_generic_diff(args: GenericDiffArgs) -> Result<()> { + let data = calculate_generic_diff_data(&args); + print_generic_diff_summary(&args, &data); + + let failures = evaluate_generic_thresholds(&args, &data); + + if args.svg { + generate_generic_diff_svg(&args)?; + } + + if !failures.is_empty() { + println!("\n {}", "❌ [FAILED] Regression detected:".red().bold()); + for f in &failures { + println!(" - {}", f.red()); + } + return Err(anyhow::anyhow!( + "{} regression thresholds exceeded", + args.network_name + )); + } else if args.threshold.is_some() { + println!( + "\n {} Execution cost within acceptable limits.", + "✅ [PASSED]".green().bold() + ); + } + + Ok(()) +} + +async fn handle_solana_diff( + rpc_url: &str, + base: &str, + target: &str, + threshold: Option, + svg: bool, +) -> Result<()> { + let solana_client = atupa_solana::SolanaClient::new(rpc_url.to_string()); + let pb = make_spinner("Fetching both Solana logs concurrently…"); + let (base_logs, target_logs) = tokio::try_join!( + solana_client.get_transaction_logs(base), + solana_client.get_transaction_logs(target), + ) + .context("Failed to fetch Solana logs")?; + pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); + eprintln!(); + + let base_steps = atupa_solana::SolanaLogStitcher::parse_logs(&base_logs); + let target_steps = atupa_solana::SolanaLogStitcher::parse_logs(&target_logs); + + process_generic_diff(GenericDiffArgs { + network_name: "Solana", + unit_name: "Compute Units", + base_tx: base, + target_tx: target, + base_steps, + target_steps, + svg, + threshold, + }) +} + +async fn handle_starknet_diff( + rpc_url: &str, + base: &str, + target: &str, + threshold: Option, + svg: bool, +) -> Result<()> { + let starknet_client = atupa_starknet::StarknetClient::new(rpc_url.to_string()); + let pb = make_spinner("Fetching both Starknet traces concurrently…"); + let (base_steps, target_steps) = tokio::try_join!( + starknet_client.profile_transaction(base), + starknet_client.profile_transaction(target), + ) + .context("Failed to fetch Starknet traces")?; + pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); + eprintln!(); + + process_generic_diff(GenericDiffArgs { + network_name: "Starknet Cairo", + unit_name: "Gas-Equivalent Steps", + base_tx: base, + target_tx: target, + base_steps, + target_steps, + svg, + threshold, + }) +} + +async fn handle_stellar_diff( + rpc_url: &str, + base: &str, + target: &str, + threshold: Option, + svg: bool, +) -> Result<()> { + let stellar_client = atupa_stellar::StellarClient::new(rpc_url.to_string()); + let pb = make_spinner("Fetching both Stellar diagnostic events concurrently…"); + let (base_steps, target_steps) = tokio::try_join!( + stellar_client.get_transaction_trace(base), + stellar_client.get_transaction_trace(target), + ) + .context("Failed to fetch Stellar traces")?; + pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); + eprintln!(); + + process_generic_diff(GenericDiffArgs { + network_name: "Stellar Soroban", + unit_name: "HostFn Weight", + base_tx: base, + target_tx: target, + base_steps, + target_steps, + svg, + threshold, + }) +} + +// ─── Nitro / EVM Diff Handlers ──────────────────────────────────────────────── + +struct NitroDiffData<'a> { + base_tx: &'a str, + target_tx: &'a str, + base_report: StitchedReport, + target_report: StitchedReport, + base_total_gas: u64, + target_total_gas: u64, + total_gas_delta: f64, + total_gas_pct: f64, + base_unified_cost: f64, + target_unified_cost: f64, + unified_delta: f64, + unified_pct: f64, + base_intrinsic: u64, + target_intrinsic: u64, + base_evm: usize, + tgt_evm: usize, + evm_delta: f64, + evm_pct: f64, + base_stylus: usize, + tgt_stylus: usize, + stylus_delta: f64, + stylus_pct: f64, +} + +#[allow(clippy::too_many_arguments)] +async fn handle_nitro_diff( + config: &AtupaConfig, + base: &str, + target: &str, + threshold: Option, + diff_config: Option, + markdown: bool, + svg: bool, + output_format: OutputFormat, + protocol: Option, +) -> Result<()> { + let client = NitroClient::new(config.rpc_url.clone()); + let eth_client = EthClient::new(config.rpc_url.clone()); + + let pb = make_spinner("Fetching both traces and receipts concurrently…"); + let (base_report, target_report) = tokio::try_join!( + client.trace_transaction(base), + client.trace_transaction(target), + ) + .context("Failed to fetch one or both traces")?; + + let (base_receipt_gas, target_receipt_gas) = tokio::join!( + eth_client.get_gas_used(base), + eth_client.get_gas_used(target), + ); + pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); + eprintln!(); + + let data = calculate_nitro_diff_data( + base, + target, + base_report, + target_report, + base_receipt_gas, + target_receipt_gas, + ); + + print_nitro_diff_summary(&data); + + let (proto_name, proto_rows) = if let Some(ref proto) = protocol { + handle_protocol_deep_diff(proto, base, target, &data.base_report, &data.target_report) + .await? + } else { + (String::new(), Vec::new()) + }; + + if markdown { + generate_diff_markdown(&data, &proto_name, &proto_rows)?; + } + + if svg { + generate_diff_svg(&data)?; + } + + let failures = evaluate_thresholds(&data, threshold, diff_config); + + if output_format == OutputFormat::Json { + let diff_report = serde_json::json!({ + "type": "diff", + "protocol": protocol.map(|p| format!("{:?}", p)), + "base": { "tx_hash": base, "report": data.base_report }, + "target": { "tx_hash": target, "report": data.target_report }, + "metrics": { + "base_total_gas": data.base_total_gas, + "target_total_gas": data.target_total_gas, + "gas_delta": data.total_gas_delta, + "gas_pct": data.total_gas_pct, + "base_unified_cost": data.base_unified_cost, + "target_unified_cost": data.target_unified_cost, + "unified_delta": data.unified_delta, + "unified_pct": data.unified_pct, + } + }); + println!("{}", serde_json::to_string_pretty(&diff_report)?); + } else { + if !failures.is_empty() { + println!("\n {}", "❌ [FAILED] Regression detected:".red().bold()); + for f in &failures { + println!(" - {}", f.red()); + } + } else if threshold.is_some() || AtupaConfigToml::auto_load().is_some() { + println!( + "\n {} Execution cost within acceptable limits.", + "✅ [PASSED]".green().bold() + ); + } + } + + if !failures.is_empty() { + return Err(anyhow::anyhow!("Gas regression thresholds exceeded")); + } + + Ok(()) +} + +fn calculate_nitro_diff_data<'a>( + base_tx: &'a str, + target_tx: &'a str, + base_report: StitchedReport, + target_report: StitchedReport, + base_receipt_gas: Option, + target_receipt_gas: Option, +) -> NitroDiffData<'a> { + let base_unified_cost = base_report.total_unified_cost; + let target_unified_cost = target_report.total_unified_cost; + let unified_delta = target_unified_cost - base_unified_cost; + let unified_pct = if base_unified_cost > 0.0 { + unified_delta / base_unified_cost * 100.0 + } else { + 0.0 + }; + + let base_total_gas = base_receipt_gas.unwrap_or(base_unified_cost as u64); + let target_total_gas = target_receipt_gas.unwrap_or(target_unified_cost as u64); + let total_gas_delta = target_total_gas as f64 - base_total_gas as f64; + let total_gas_pct = if base_total_gas > 0 { + total_gas_delta / base_total_gas as f64 * 100.0 + } else { + 0.0 + }; + + let base_intrinsic = base_total_gas.saturating_sub(base_unified_cost as u64); + let target_intrinsic = target_total_gas.saturating_sub(target_unified_cost as u64); + + let base_evm = evm_count(&base_report); + let tgt_evm = evm_count(&target_report); + let evm_delta = tgt_evm as f64 - base_evm as f64; + let evm_pct = if base_evm > 0 { + evm_delta / base_evm as f64 * 100.0 + } else { + 0.0 + }; + + let base_stylus = base_report.stylus_steps().len(); + let tgt_stylus = target_report.stylus_steps().len(); + let stylus_delta = tgt_stylus as f64 - base_stylus as f64; + let stylus_pct = if base_stylus > 0 { + stylus_delta / base_stylus as f64 * 100.0 + } else { + 0.0 + }; + + NitroDiffData { + base_tx, + target_tx, + base_report, + target_report, + base_total_gas, + target_total_gas, + total_gas_delta, + total_gas_pct, + base_unified_cost, + target_unified_cost, + unified_delta, + unified_pct, + base_intrinsic, + target_intrinsic, + base_evm, + tgt_evm, + evm_delta, + evm_pct, + base_stylus, + tgt_stylus, + stylus_delta, + stylus_pct, + } +} + +fn print_nitro_diff_summary(data: &NitroDiffData) { + let div = "─".repeat(70).dimmed().to_string(); + println!("{}", " EXECUTION DIFF".bold().underline()); + println!("{div}"); + println!( + " {:<25} {:<15} {:<15} {}", + "Metric".bold(), + "Base".bold(), + "Target".bold(), + "Delta".bold() + ); + println!("{div}"); + + let colorize_delta = |delta: f64, pct: f64| -> String { + let sign = if delta >= 0.0 { "+" } else { "" }; + if delta > 0.0 { + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .red() + .to_string() + } else if delta < 0.0 { + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .green() + .to_string() + } else { + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .dimmed() + .to_string() + } + }; + + println!( + " {:<25} {:<15} {:<15} {}", + "Total On-Chain Gas:", + data.base_total_gas.to_string().green(), + data.target_total_gas.to_string().yellow(), + colorize_delta(data.total_gas_delta, data.total_gas_pct) + ); + + println!( + " {:<25} {:<15} {:<15} {}", + "↳ Execution Gas (EVM):", + data.base_unified_cost.to_string().cyan(), + data.target_unified_cost.to_string().cyan(), + colorize_delta(data.unified_delta, data.unified_pct) + ); + + let intrinsic_delta = data.target_intrinsic as f64 - data.base_intrinsic as f64; + let intrinsic_pct = if data.base_intrinsic > 0 { + intrinsic_delta / data.base_intrinsic as f64 * 100.0 + } else { + 0.0 + }; + println!( + " {:<25} {:<15} {:<15} {}", + "↳ Intrinsic Gas:", + data.base_intrinsic.to_string().dimmed(), + data.target_intrinsic.to_string().dimmed(), + colorize_delta(intrinsic_delta, intrinsic_pct) + ); + + println!("{div}"); + + println!( + " {:<25} {:<15} {:<15} {}", + "EVM Steps:", + data.base_evm.to_string().green(), + data.tgt_evm.to_string().yellow(), + colorize_delta(data.evm_delta, data.evm_pct) + ); + + println!( + " {:<25} {:<15} {:<15} {}", + "Stylus Cross-VM Calls:", + data.base_stylus.to_string().green(), + data.tgt_stylus.to_string().yellow(), + colorize_delta(data.stylus_delta, data.stylus_pct) + ); + println!("{div}"); +} + +async fn handle_protocol_deep_diff( + proto: &Protocol, + base: &str, + target: &str, + base_report: &StitchedReport, + target_report: &StitchedReport, +) -> Result<(String, Vec)> { + let base_steps: Vec = base_report + .steps + .iter() + .map(|s| s.to_trace_step()) + .collect(); + let target_steps: Vec = target_report + .steps + .iter() + .map(|s| s.to_trace_step()) + .collect(); + + let report = match proto { + Protocol::Aave => { + AaveDeepTracer::new().diff_reports(base, &base_steps, target, &target_steps) + } + Protocol::Lido => { + LidoDeepTracer::new().diff_reports(base, &base_steps, target, &target_steps) + } + }; + + match report { + Ok(r) => { + let proto_div = "─".repeat(70).dimmed().to_string(); + println!( + "\n {} DEEP DIFF", + r.protocol.to_uppercase().bold().underline() + ); + println!("{proto_div}"); + println!( + " {:<28} {:<15} {:<15} {}", + "Metric".bold(), + "Base".bold(), + "Target".bold(), + "Delta".bold() + ); + println!("{proto_div}"); + + for row in &r.rows { + let sign = if row.delta >= 0.0 { "+" } else { "" }; + let delta_str = format!("{sign}{:.0} ({sign}{:.1}%)", row.delta, row.pct); + let delta_colored = if row.delta == 0.0 { + delta_str.dimmed().to_string() + } else if (row.delta > 0.0) == row.higher_is_worse { + delta_str.red().to_string() + } else { + delta_str.green().to_string() + }; + println!( + " {:<28} {:<15} {:<15} {}", + row.metric, + row.base.to_string().dimmed(), + row.target.to_string().dimmed(), + delta_colored + ); + } + println!("{proto_div}"); + Ok((r.protocol, r.rows)) + } + Err(e) => { + eprintln!(" ⚠ Protocol deep diff skipped: {e}"); + Ok((String::new(), Vec::new())) + } + } +} + +fn generate_diff_markdown( + data: &NitroDiffData, + proto_name: &str, + proto_rows: &[DiffRow], +) -> Result<()> { + let mut md = String::from("## 🏮 Atupa Gas Regression Report\n\n"); + md.push_str("| Metric | Base | Target | Delta |\n"); + md.push_str("|--------|------|--------|-------|\n"); + + md.push_str(&generate_summary_table_rows(data)); + md.push_str("\n*Profiled via Atupa Unified Tracer*\n"); + + if !proto_rows.is_empty() { + md.push_str(&format!("\n### 🔬 {proto_name} Protocol Deep Diff\n\n")); + md.push_str("| Metric | Base | Target | Delta |\n"); + md.push_str("|--------|------|--------|-------|\n"); + md.push_str(&generate_protocol_deep_diff_rows(proto_rows)); + } + + let out_path = format!( + "artifacts/diff/{}_vs_{}.md", + &data.base_tx[..10.min(data.base_tx.len())], + &data.target_tx[..10.min(data.target_tx.len())] + ); + std::fs::create_dir_all("artifacts/diff").ok(); + std::fs::write(&out_path, md).context("Failed to write markdown diff")?; + println!(" 📝 Markdown report written to {}", out_path.cyan()); + Ok(()) +} + +fn generate_summary_table_rows(data: &NitroDiffData) -> String { + let format_plain_delta = |delta: f64, pct: f64| -> String { + let sign = if delta >= 0.0 { "+" } else { "" }; + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + }; + + let mut rows = String::new(); + let entries = [ + ( + "Total Gas", + data.base_total_gas as f64, + data.target_total_gas as f64, + data.total_gas_delta, + data.total_gas_pct, + ), + ( + "Execution Gas", + data.base_unified_cost, + data.target_unified_cost, + data.unified_delta, + data.unified_pct, + ), + ( + "EVM Steps", + data.base_evm as f64, + data.tgt_evm as f64, + data.evm_delta, + data.evm_pct, + ), + ( + "Stylus Calls", + data.base_stylus as f64, + data.tgt_stylus as f64, + data.stylus_delta, + data.stylus_pct, + ), + ]; + + for (metric, base, target, delta, pct) in entries { + rows.push_str(&format!( + "| **{}** | {} | {} | {} |\n", + metric, + base, + target, + format_plain_delta(delta, pct) + )); + } + rows +} + +fn generate_protocol_deep_diff_rows(proto_rows: &[DiffRow]) -> String { + let mut rows = String::new(); + for row in proto_rows { + let sign = if row.delta >= 0.0 { "+" } else { "" }; + let emoji = if row.delta == 0.0 { + "" + } else if (row.delta > 0.0) == row.higher_is_worse { + "🔴 " + } else { + "🟢 " + }; + rows.push_str(&format!( + "| **{}** | {} | {} | {}{}{:.0} ({}{:.1}%) |\n", + row.metric, row.base, row.target, emoji, sign, row.delta, sign, row.pct + )); + } + rows +} + +fn generate_diff_svg(data: &NitroDiffData) -> Result<()> { + let base_steps: Vec = data + .base_report + .steps + .iter() + .map(|s| s.to_trace_step()) + .collect(); + let registry = atupa::build_default_registry(); + let base_stacks = Aggregator::build_collapsed_stacks_with_registry( + &TraceParser::normalize_raw(base_steps), + ®istry, + ); + + let target_steps: Vec = data + .target_report + .steps + .iter() + .map(|s| s.to_trace_step()) + .collect(); + let target_stacks = Aggregator::build_collapsed_stacks_with_registry( + &TraceParser::normalize_raw(target_steps), + ®istry, + ); + + let svg_content = atupa_output::generate_diff_flamegraph(&base_stacks, &target_stacks)?; + let out_path = format!( + "artifacts/diff/{}_vs_{}.svg", + &data.base_tx[..10.min(data.base_tx.len())], + &data.target_tx[..10.min(data.target_tx.len())] + ); + std::fs::create_dir_all("artifacts/diff").ok(); + std::fs::write(&out_path, svg_content).context("Failed to write diff flamegraph SVG")?; + println!(" 🔥 Visual diff flamegraph written to {}", out_path.cyan()); + Ok(()) +} + +fn evaluate_thresholds( + data: &NitroDiffData, + threshold: Option, + diff_config: Option, +) -> Vec { + let mut failures = Vec::new(); + let config_toml = AtupaConfigToml::resolve(diff_config.as_deref()); + + if let Some(t) = threshold { + if let Some(err) = + crate::thresholds::DiffConfig::evaluate_simple_threshold("Gas", data.total_gas_pct, t) + { + failures.push(err); + } + } else if let Some(ref cfg) = config_toml + && let Some(diff_cfg) = &cfg.diff + { + failures.extend(diff_cfg.evaluate_nitro( + data.total_gas_pct, + data.unified_pct, + data.evm_delta, + data.stylus_delta, + )); + } + failures +} diff --git a/bin/atupa/src/commands/mod.rs b/bin/atupa/src/commands/mod.rs new file mode 100644 index 0000000..b54cbbd --- /dev/null +++ b/bin/atupa/src/commands/mod.rs @@ -0,0 +1,13 @@ +//! Command dispatchers and implementations for the Atupa CLI. + +pub mod audit; +pub mod capture; +pub mod diff; +pub mod profile; +pub mod studio; + +pub use audit::cmd_audit; +pub use capture::cmd_capture; +pub use diff::cmd_diff; +pub use profile::cmd_profile; +pub use studio::cmd_studio; diff --git a/bin/atupa/src/commands/profile.rs b/bin/atupa/src/commands/profile.rs new file mode 100644 index 0000000..88175fa --- /dev/null +++ b/bin/atupa/src/commands/profile.rs @@ -0,0 +1,58 @@ +//! Handler for `atupa profile` command. + +use anyhow::{Context, Result}; +use colored::*; + +use crate::cli::VmTarget; +use crate::utils::{divider, resolve_artifact_path}; +use atupa_core::config::AtupaConfig; + +/// Executes the `profile` command, generating an SVG flamegraph. +pub async fn cmd_profile( + config: &AtupaConfig, + tx: &str, + demo: bool, + out: Option, + vm: Option, +) -> Result<()> { + if !demo && tx.is_empty() { + anyhow::bail!( + "You must provide --tx or run with --demo.\n\ + Example: atupa profile --demo" + ); + } + + let display = if demo { "demo" } else { tx }; + eprintln!("{} {}", "→ Profiling:".bold(), display.cyan()); + eprintln!("{} {}\n", "→ Endpoint: ".bold(), config.rpc_url.dimmed()); + + let vm_hint = vm.map(|v| v.to_sdk_hint()); + let svg_path = resolve_artifact_path(out, "profile", tx, "svg"); + + let (out_path, network) = atupa::execute_profile( + tx, + &config.rpc_url, + demo, + Some(svg_path), + config.etherscan_key.clone(), + vm_hint, + ) + .await + .context("Profile command failed")?; + + eprintln!(); + eprintln!( + " {} ({})", + "PROFILE COMPLETE".bold().underline(), + network.cyan() + ); + let div = divider(40); + eprintln!("{div}"); + eprintln!( + " {:<24} {}", + "SVG saved to:".bold(), + out_path.green().bold() + ); + eprintln!("{div}"); + Ok(()) +} diff --git a/bin/atupa/src/commands/studio.rs b/bin/atupa/src/commands/studio.rs new file mode 100644 index 0000000..7f6934e --- /dev/null +++ b/bin/atupa/src/commands/studio.rs @@ -0,0 +1,74 @@ +//! Handler for `atupa studio` command. + +use anyhow::{Context, Result}; +use colored::*; +use std::time::{Duration, Instant}; + +use crate::studio::StudioServer; +use atupa_core::config::AtupaConfig; + +/// Executes the `studio` command, launching the local embedded web UI. +pub async fn cmd_studio( + _config: &AtupaConfig, + port: u16, + launch_browser: bool, + report_path: Option, +) -> Result<()> { + // 1. Read report if provided + let report_content = if let Some(path) = report_path.as_ref() { + Some(std::fs::read_to_string(path).context("Failed to read report file for Studio")?) + } else { + None + }; + + // 2. Prepare the server + let server = StudioServer::new(report_content); + let mut url = format!("http://localhost:{port}/"); + if report_path.is_some() { + url += "?auto=true"; + } + + eprintln!("{} Launching Atupa Studio...", "→".bold().cyan()); + + // Spawn server in background + let server_handle = tokio::spawn(async move { + if let Err(e) = server.start(port).await { + eprintln!("\n{} Studio server error: {e}", "⚠".red().bold()); + } + }); + + // Wait for the port to become active + let addr = format!("127.0.0.1:{port}"); + let deadline = Instant::now() + Duration::from_secs(5); + while std::net::TcpStream::connect(&addr).is_err() { + if Instant::now() > deadline { + anyhow::bail!("Studio server failed to start on port {port} within 5s."); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + eprintln!( + "{} Studio ready at {}", + "✔".green().bold(), + url.cyan().bold() + ); + + // 3. Open browser + if launch_browser && let Err(e) = open::that(&url) { + eprintln!("{} Could not open browser: {e}", "⚠".yellow()); + } + + // 4. Footer info + if let Some(path) = report_path { + eprintln!( + "\n {} Report loaded: {}\n The Studio has automatically opened this report.", + "✔".green().bold(), + path.cyan().bold(), + ); + } + eprintln!("{}\n", " Press Ctrl+C to stop the Studio server.".dimmed()); + + // Keep the main task active while the server runs + let _ = server_handle.await; + Ok(()) +} diff --git a/bin/atupa/src/init/detector.rs b/bin/atupa/src/init/detector.rs new file mode 100644 index 0000000..a66444b --- /dev/null +++ b/bin/atupa/src/init/detector.rs @@ -0,0 +1,139 @@ +//! Project framework and DeFi protocol detection for scaffolding. + +use std::fs; +use std::path::Path; + +/// Detected smart contract development framework. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ProjectKind { + /// Foundry / Forge project (`foundry.toml` or `forge.toml`). + Foundry, + /// Hardhat JavaScript/TypeScript project (`hardhat.config.*`). + Hardhat, + /// Arbitrum Stylus Rust project (`Cargo.toml` without JS/Solidity frameworks). + StylusOnly, + /// Unknown or unsupported project structure. + Unknown, +} + +impl ProjectKind { + /// Returns a human-friendly string name of the project framework. + pub fn label(self) -> &'static str { + match self { + Self::Foundry => "Foundry", + Self::Hardhat => "Hardhat", + Self::StylusOnly => "Arbitrum Stylus (Rust-only)", + Self::Unknown => "Unknown", + } + } +} + +/// Detects the project kind in the current working directory. +pub fn detect_project() -> ProjectKind { + detect_project_at(Path::new(".")) +} + +/// Detects the project kind at a specified root path. +pub fn detect_project_at(root: &Path) -> ProjectKind { + if root.join("foundry.toml").exists() || root.join("forge.toml").exists() { + return ProjectKind::Foundry; + } + if root.join("hardhat.config.js").exists() + || root.join("hardhat.config.ts").exists() + || root.join("hardhat.config.mjs").exists() + { + return ProjectKind::Hardhat; + } + if root.join("Cargo.toml").exists() { + return ProjectKind::StylusOnly; + } + ProjectKind::Unknown +} + +/// Detects if the current directory is related to a supported protocol (e.g. Aave, Lido). +pub fn detect_protocol() -> Option { + detect_protocol_at(Path::new(".")) +} + +/// Detects protocol mentions at a specified root path. +pub fn detect_protocol_at(root: &Path) -> Option { + let keywords = [("lido", "lido"), ("aave", "aave"), ("gho", "aave")]; + let files = ["package.json", "foundry.toml", "Cargo.toml"]; + + for file in files { + if let Ok(content) = fs::read_to_string(root.join(file)) { + let content_lower = content.to_lowercase(); + for (kw, proto) in keywords { + if content_lower.contains(kw) { + return Some(proto.to_string()); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_kind_labels() { + assert_eq!(ProjectKind::Foundry.label(), "Foundry"); + assert_eq!(ProjectKind::Hardhat.label(), "Hardhat"); + assert_eq!( + ProjectKind::StylusOnly.label(), + "Arbitrum Stylus (Rust-only)" + ); + assert_eq!(ProjectKind::Unknown.label(), "Unknown"); + } + + #[test] + fn detect_project_at_mock_paths() { + let temp_dir = + std::env::temp_dir().join(format!("atupa_test_detect_{}", std::process::id())); + let _ = fs::create_dir_all(&temp_dir); + + // Initially unknown + assert_eq!(detect_project_at(&temp_dir), ProjectKind::Unknown); + + // Add foundry.toml -> Foundry + fs::write(temp_dir.join("foundry.toml"), "[profile.default]").unwrap(); + assert_eq!(detect_project_at(&temp_dir), ProjectKind::Foundry); + let _ = fs::remove_file(temp_dir.join("foundry.toml")); + + // Add hardhat.config.ts -> Hardhat + fs::write(temp_dir.join("hardhat.config.ts"), "export default {};").unwrap(); + assert_eq!(detect_project_at(&temp_dir), ProjectKind::Hardhat); + let _ = fs::remove_file(temp_dir.join("hardhat.config.ts")); + + // Add Cargo.toml -> StylusOnly + fs::write(temp_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); + assert_eq!(detect_project_at(&temp_dir), ProjectKind::StylusOnly); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn detect_protocol_at_finds_aave_and_lido() { + let temp_dir = + std::env::temp_dir().join(format!("atupa_test_proto_{}", std::process::id())); + let _ = fs::create_dir_all(&temp_dir); + + fs::write( + temp_dir.join("Cargo.toml"), + "[dependencies]\naave-v3-core = \"1.0\"", + ) + .unwrap(); + assert_eq!(detect_protocol_at(&temp_dir), Some("aave".to_string())); + + fs::write( + temp_dir.join("Cargo.toml"), + "[dependencies]\nlido-contracts = \"1.0\"", + ) + .unwrap(); + assert_eq!(detect_protocol_at(&temp_dir), Some("lido".to_string())); + + let _ = fs::remove_dir_all(&temp_dir); + } +} diff --git a/bin/atupa/src/init/mod.rs b/bin/atupa/src/init/mod.rs new file mode 100644 index 0000000..19ec654 --- /dev/null +++ b/bin/atupa/src/init/mod.rs @@ -0,0 +1,225 @@ +//! # `atupa init` +//! +//! Scaffolds all files required to integrate Atupa Gas Regression checking +//! into the current repository. Detects the project type (Foundry / Hardhat / +//! Stylus-only) and generates tailored configuration. + +use anyhow::{Context, Result}; +use colored::*; +use std::fs; +use std::path::Path; + +pub mod detector; +pub mod templates; + +pub use detector::{ProjectKind, detect_project, detect_protocol}; +pub use templates::*; + +/// Command-line arguments for the `init` subcommand. +pub struct InitArgs { + /// Overwrite existing scaffolding files if true. + pub force: bool, +} + +/// Executes repository initialization and scaffolding. +pub fn execute_init(args: InitArgs) -> Result<()> { + println!(); + println!("{}", "🏮 Atupa — Initializing project integration".bold()); + println!("{}", "─".repeat(55).dimmed()); + println!(); + + // ── Detect Project ──────────────────────────────────────────────────────── + let kind = detect_project(); + println!( + " {} {}", + "🔍 Detected project type:".bold(), + kind.label().cyan().bold() + ); + + // Attempt to detect protocol + let protocol = detect_protocol(); + if let Some(p) = &protocol { + println!( + " {} {}", + "💉 Detected protocol adapter:".bold(), + p.cyan().bold() + ); + } + println!(); + + let mut created: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + + // ── 1. atupa.toml ───────────────────────────────────────────────────────── + let toml_content = match kind { + ProjectKind::Foundry => ATUPA_TOML_FOUNDRY, + ProjectKind::Hardhat => ATUPA_TOML_HARDHAT, + ProjectKind::StylusOnly => ATUPA_TOML_STYLUS, + ProjectKind::Unknown => ATUPA_TOML_FOUNDRY, + }; + + scaffold_file( + "atupa.toml", + toml_content, + args.force, + &mut created, + &mut skipped, + )?; + + // ── 2. .github/workflows/atupa.yml ─────────────────────────────────────── + let workflow_dir = Path::new(".github/workflows"); + fs::create_dir_all(workflow_dir).context("Failed to create .github/workflows directory")?; + + scaffold_file( + ".github/workflows/atupa.yml", + WORKFLOW_YAML, + args.force, + &mut created, + &mut skipped, + )?; + + // ── 3. Profile Script (project-specific) ───────────────────────────────── + match kind { + ProjectKind::Foundry | ProjectKind::StylusOnly => { + fs::create_dir_all("script").context("Failed to create script/ directory")?; + scaffold_file( + "script/AtupaProfile.s.sol", + FORGE_PROFILE_SCRIPT, + args.force, + &mut created, + &mut skipped, + )?; + } + ProjectKind::Hardhat => { + fs::create_dir_all("scripts").context("Failed to create scripts/ directory")?; + scaffold_file( + "scripts/AtupaProfile.js", + HARDHAT_PROFILE_SCRIPT, + args.force, + &mut created, + &mut skipped, + )?; + } + ProjectKind::Unknown => { + fs::create_dir_all("script").ok(); + scaffold_file( + "script/AtupaProfile.s.sol", + FORGE_PROFILE_SCRIPT, + args.force, + &mut created, + &mut skipped, + )?; + } + } + + // ── Print Summary ───────────────────────────────────────────────────────── + println!(); + for path in &created { + println!(" {} {}", "✅ Created".green().bold(), path.cyan()); + } + for path in &skipped { + println!( + " {} {} {}", + "⚠️ Skipped".yellow(), + path.dimmed(), + "(already exists — use --force to overwrite)".dimmed() + ); + } + + println!(); + println!("{}", "─".repeat(55).dimmed()); + println!("{}", " 🚀 Next Steps".bold().underline()); + println!("{}", "─".repeat(55).dimmed()); + println!(); + + match kind { + ProjectKind::Foundry | ProjectKind::StylusOnly | ProjectKind::Unknown => { + println!( + " {} Edit {} to add your contract call.", + "1.".bold(), + "script/AtupaProfile.s.sol".cyan() + ); + } + ProjectKind::Hardhat => { + println!( + " {} Edit {} to add your contract call.", + "1.".bold(), + "scripts/AtupaProfile.js".cyan() + ); + } + } + + println!( + " {} Add {} to your GitHub Repository Secrets.", + "2.".bold(), + "ATUPA_RPC_URL".cyan() + ); + println!( + " {} Open a Pull Request — Atupa will automatically comment with a gas diff.", + "3.".bold() + ); + println!(); + println!( + " {} {}", + "Docs:".dimmed(), + "https://github.com/One-Block-Org/Atupa".dimmed() + ); + println!(); + + Ok(()) +} + +/// Helper function to write a scaffolded file or record it as skipped if already present. +pub fn scaffold_file( + path: &str, + content: &str, + force: bool, + created: &mut Vec, + skipped: &mut Vec, +) -> Result<()> { + if Path::new(path).exists() && !force { + skipped.push(path.to_string()); + return Ok(()); + } + fs::write(path, content).with_context(|| format!("Failed to write {path}"))?; + created.push(path.to_string()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaffold_file_creates_and_skips() { + let temp_dir = + std::env::temp_dir().join(format!("atupa_test_scaffold_{}", std::process::id())); + let _ = fs::create_dir_all(&temp_dir); + + let test_file = temp_dir.join("test_file.txt"); + let test_path = test_file.to_str().unwrap(); + + let mut created = Vec::new(); + let mut skipped = Vec::new(); + + // 1. Initial creation + scaffold_file(test_path, "hello world", false, &mut created, &mut skipped).unwrap(); + assert_eq!(created.len(), 1); + assert_eq!(skipped.len(), 0); + assert_eq!(fs::read_to_string(&test_file).unwrap(), "hello world"); + + // 2. Second time without force -> skipped + scaffold_file(test_path, "new content", false, &mut created, &mut skipped).unwrap(); + assert_eq!(created.len(), 1); + assert_eq!(skipped.len(), 1); + assert_eq!(fs::read_to_string(&test_file).unwrap(), "hello world"); + + // 3. Third time with force -> overwritten + scaffold_file(test_path, "new content", true, &mut created, &mut skipped).unwrap(); + assert_eq!(created.len(), 2); + assert_eq!(skipped.len(), 1); + assert_eq!(fs::read_to_string(&test_file).unwrap(), "new content"); + + let _ = fs::remove_dir_all(&temp_dir); + } +} diff --git a/bin/atupa/src/init.rs b/bin/atupa/src/init/templates.rs similarity index 51% rename from bin/atupa/src/init.rs rename to bin/atupa/src/init/templates.rs index 43780c1..16c7d92 100644 --- a/bin/atupa/src/init.rs +++ b/bin/atupa/src/init/templates.rs @@ -1,20 +1,7 @@ -//! # `atupa init` -//! -//! Scaffolds all files required to integrate Atupa Gas Regression checking -//! into the current repository. Detects the project type (Foundry / Hardhat / -//! Stylus-only) and generates tailored configuration. - -use anyhow::{Context, Result}; -use colored::*; -use std::fs; -use std::path::Path; - -// ─── Embedded Templates ─────────────────────────────────────────────────────── -// -// All templates are baked directly into the binary at compile time using -// include_str!. This makes `atupa` a true zero-dependency setup tool. +//! Embedded scaffolding templates for `atupa init`. -const ATUPA_TOML_FOUNDRY: &str = r#"# atupa.toml — Atupa Gas Regression Budget +/// `atupa.toml` configuration template for Foundry projects. +pub const ATUPA_TOML_FOUNDRY: &str = r#"# atupa.toml — Atupa Gas Regression Budget # Generated by `atupa init` (Foundry project detected) # https://github.com/One-Block-Org/Atupa @@ -32,7 +19,8 @@ max_evm_steps_increase = 100 max_stylus_calls_increase = 0 "#; -const ATUPA_TOML_STYLUS: &str = r#"# atupa.toml — Atupa Gas Regression Budget +/// `atupa.toml` configuration template for Arbitrum Stylus projects. +pub const ATUPA_TOML_STYLUS: &str = r#"# atupa.toml — Atupa Gas Regression Budget # Generated by `atupa init` (Arbitrum Stylus project detected) # https://github.com/One-Block-Org/Atupa @@ -50,7 +38,8 @@ max_evm_steps_increase = 50 max_stylus_calls_increase = 0 "#; -const ATUPA_TOML_HARDHAT: &str = r#"# atupa.toml — Atupa Gas Regression Budget +/// `atupa.toml` configuration template for Hardhat projects. +pub const ATUPA_TOML_HARDHAT: &str = r#"# atupa.toml — Atupa Gas Regression Budget # Generated by `atupa init` (Hardhat project detected) # https://github.com/One-Block-Org/Atupa @@ -68,7 +57,8 @@ max_evm_steps_increase = 500 max_stylus_calls_increase = 0 "#; -const WORKFLOW_YAML: &str = r#"# ───────────────────────────────────────────────────────────────────────────── +/// GitHub Actions CI workflow template. +pub const WORKFLOW_YAML: &str = r#"# ───────────────────────────────────────────────────────────────────────────── # Atupa Gas Regression — Auto-generated by `atupa init` # # On every PR against main: @@ -192,7 +182,8 @@ jobs: upload_json: 'true' "#; -const FORGE_PROFILE_SCRIPT: &str = r#"// SPDX-License-Identifier: MIT +/// Solidity script template for Foundry projects (`AtupaProfile.s.sol`). +pub const FORGE_PROFILE_SCRIPT: &str = r#"// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // AtupaProfile.s.sol — Auto-generated by `atupa init` @@ -228,7 +219,8 @@ contract AtupaProfile is Script { } "#; -const HARDHAT_PROFILE_SCRIPT: &str = r#"// AtupaProfile.js — Auto-generated by `atupa init` +/// JavaScript script template for Hardhat projects (`AtupaProfile.js`). +pub const HARDHAT_PROFILE_SCRIPT: &str = r#"// AtupaProfile.js — Auto-generated by `atupa init` // // Fill in the contract deployment and method call you want to benchmark. // Atupa will profile the last transaction emitted by this script. @@ -260,233 +252,24 @@ async function main() { main().catch((err) => { console.error(err); process.exit(1); }); "#; -// ─── Project Detection ──────────────────────────────────────────────────────── - -#[derive(Debug, PartialEq)] -pub enum ProjectKind { - Foundry, - Hardhat, - StylusOnly, - Unknown, -} - -impl ProjectKind { - pub fn label(&self) -> &'static str { - match self { - ProjectKind::Foundry => "Foundry", - ProjectKind::Hardhat => "Hardhat", - ProjectKind::StylusOnly => "Arbitrum Stylus (Rust-only)", - ProjectKind::Unknown => "Unknown", - } - } -} - -pub fn detect_project() -> ProjectKind { - if Path::new("foundry.toml").exists() || Path::new("forge.toml").exists() { - return ProjectKind::Foundry; - } - if Path::new("hardhat.config.js").exists() - || Path::new("hardhat.config.ts").exists() - || Path::new("hardhat.config.mjs").exists() - { - return ProjectKind::Hardhat; - } - // Stylus-only: Cargo.toml present but no JS/TS toolchain - if Path::new("Cargo.toml").exists() { - return ProjectKind::StylusOnly; - } - ProjectKind::Unknown -} - -pub fn detect_protocol() -> Option { - // Check for common protocol keywords in project files - let keywords = [("lido", "lido"), ("aave", "aave"), ("gho", "aave")]; - - // Check package.json or foundry.toml if they exist - let files = ["package.json", "foundry.toml", "Cargo.toml"]; - for file in files { - if let Ok(content) = fs::read_to_string(file) { - let content_lower = content.to_lowercase(); - for (kw, proto) in keywords { - if content_lower.contains(kw) { - return Some(proto.to_string()); - } - } - } - } - None -} - -// ─── Init Arguments ─────────────────────────────────────────────────────────── - -pub struct InitArgs { - pub force: bool, -} - -// ─── Public Entry Point ─────────────────────────────────────────────────────── - -pub fn execute_init(args: InitArgs) -> Result<()> { - println!(); - println!("{}", "🏮 Atupa — Initializing project integration".bold()); - println!("{}", "─".repeat(55).dimmed()); - println!(); - - // ── Detect Project ──────────────────────────────────────────────────────── - let kind = detect_project(); - println!( - " {} {}", - "🔍 Detected project type:".bold(), - kind.label().cyan().bold() - ); - - // Attempt to detect protocol - let protocol = detect_protocol(); - if let Some(p) = &protocol { - println!( - " {} {}", - "💉 Detected protocol adapter:".bold(), - p.cyan().bold() - ); - } - println!(); - - let mut created: Vec = Vec::new(); - let mut skipped: Vec = Vec::new(); - - // ── 1. atupa.toml ───────────────────────────────────────────────────────── - let toml_content = match kind { - ProjectKind::Foundry => ATUPA_TOML_FOUNDRY, - ProjectKind::Hardhat => ATUPA_TOML_HARDHAT, - ProjectKind::StylusOnly => ATUPA_TOML_STYLUS, - ProjectKind::Unknown => ATUPA_TOML_FOUNDRY, // sensible default - }; - - scaffold_file( - "atupa.toml", - toml_content, - args.force, - &mut created, - &mut skipped, - )?; - - // ── 2. .github/workflows/atupa.yml ─────────────────────────────────────── - let workflow_dir = Path::new(".github/workflows"); - fs::create_dir_all(workflow_dir).context("Failed to create .github/workflows directory")?; - - scaffold_file( - ".github/workflows/atupa.yml", - WORKFLOW_YAML, - args.force, - &mut created, - &mut skipped, - )?; - - // ── 3. Profile Script (project-specific) ───────────────────────────────── - match kind { - ProjectKind::Foundry | ProjectKind::StylusOnly => { - fs::create_dir_all("script").context("Failed to create script/ directory")?; - scaffold_file( - "script/AtupaProfile.s.sol", - FORGE_PROFILE_SCRIPT, - args.force, - &mut created, - &mut skipped, - )?; - } - ProjectKind::Hardhat => { - fs::create_dir_all("scripts").context("Failed to create scripts/ directory")?; - scaffold_file( - "scripts/AtupaProfile.js", - HARDHAT_PROFILE_SCRIPT, - args.force, - &mut created, - &mut skipped, - )?; - } - ProjectKind::Unknown => { - // Both — let the user decide - fs::create_dir_all("script").ok(); - scaffold_file( - "script/AtupaProfile.s.sol", - FORGE_PROFILE_SCRIPT, - args.force, - &mut created, - &mut skipped, - )?; - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn templates_are_non_empty() { + assert!(!ATUPA_TOML_FOUNDRY.is_empty()); + assert!(!ATUPA_TOML_STYLUS.is_empty()); + assert!(!ATUPA_TOML_HARDHAT.is_empty()); + assert!(!WORKFLOW_YAML.is_empty()); + assert!(!FORGE_PROFILE_SCRIPT.is_empty()); + assert!(!HARDHAT_PROFILE_SCRIPT.is_empty()); } - // ── Print Summary ───────────────────────────────────────────────────────── - println!(); - for path in &created { - println!(" {} {}", "✅ Created".green().bold(), path.cyan()); - } - for path in &skipped { - println!( - " {} {} {}", - "⚠️ Skipped".yellow(), - path.dimmed(), - "(already exists — use --force to overwrite)".dimmed() - ); - } - - println!(); - println!("{}", "─".repeat(55).dimmed()); - println!("{}", " 🚀 Next Steps".bold().underline()); - println!("{}", "─".repeat(55).dimmed()); - println!(); - - match kind { - ProjectKind::Foundry | ProjectKind::StylusOnly | ProjectKind::Unknown => { - println!( - " {} Edit {} to add your contract call.", - "1.".bold(), - "script/AtupaProfile.s.sol".cyan() - ); - } - ProjectKind::Hardhat => { - println!( - " {} Edit {} to add your contract call.", - "1.".bold(), - "scripts/AtupaProfile.js".cyan() - ); - } - } - - println!( - " {} Add {} to your GitHub Repository Secrets.", - "2.".bold(), - "ATUPA_RPC_URL".cyan() - ); - println!( - " {} Open a Pull Request — Atupa will automatically comment with a gas diff.", - "3.".bold() - ); - println!(); - println!( - " {} {}", - "Docs:".dimmed(), - "https://github.com/One-Block-Org/Atupa".dimmed() - ); - println!(); - - Ok(()) -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -fn scaffold_file( - path: &str, - content: &str, - force: bool, - created: &mut Vec, - skipped: &mut Vec, -) -> Result<()> { - if Path::new(path).exists() && !force { - skipped.push(path.to_string()); - return Ok(()); + #[test] + fn toml_templates_are_valid_toml() { + toml::from_str::(ATUPA_TOML_FOUNDRY).unwrap(); + toml::from_str::(ATUPA_TOML_STYLUS).unwrap(); + toml::from_str::(ATUPA_TOML_HARDHAT).unwrap(); } - fs::write(path, content).with_context(|| format!("Failed to write {path}"))?; - created.push(path.to_string()); - Ok(()) } diff --git a/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index 513ecda..d1ce5ea 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -1,205 +1,36 @@ //! # atupa CLI //! -//! Unified Ethereum + Arbitrum Stylus execution profiler. +//! Universal Multi-VM Execution Profiler (EVM, Arbitrum Nitro/Stylus, Solana, Starknet, Stellar). //! //! ## Usage //! //! ```text //! atupa profile --tx [--rpc ] [--out trace.svg] [--demo] //! atupa capture --tx [--rpc ] [--output summary|json|metric] [--file report.json] -//! [--profile] [--etherscan-key ] [--studio] +//! [--profile] [--etherscan-key ] [--studio] //! atupa audit --tx [--rpc ] [--protocol aave|lido] //! atupa diff --base --target [--rpc ] +//! atupa studio [--port 5173] [--dir ] +//! atupa init [--force] //! ``` -//! -//! ## Standalone Usage -//! Atupa is designed to be used as a standalone CLI tool. -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand, ValueEnum}; -use colored::*; -use indicatif::{ProgressBar, ProgressStyle}; -use std::time::Duration; +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; -use atupa_aave::AaveDeepTracer; -use atupa_core::TraceStep; use atupa_core::config::AtupaConfig; -use atupa_lido::LidoDeepTracer; -use atupa_nitro::{NitroClient, StitchedReport, VmKind}; -use atupa_output::SvgGenerator; -use atupa_parser::Parser as TraceParser; -use atupa_parser::aggregator::Aggregator; -use atupa_rpc::{EthClient, RawStructLog}; +mod banner; +mod cli; +mod commands; mod init; mod studio; mod thresholds; +mod utils; -use thresholds::AtupaConfigToml; -// ─── CLI Definition ──────────────────────────────────────────────────────────── - -#[derive(Parser)] -#[command( - name = "atupa", - bin_name = "atupa", - about = "🏮 Atupa — Unified Ethereum & Stylus Execution Profiler", - long_about = "\ -Inspect, profile, and audit transactions across the full Arbitrum Nitro\n\ -dual-VM stack (EVM + Stylus WASM). Part of the One Block infrastructure suite.\n\ -SOURCE: https://github.com/One-Block-Org/Atupa", - version -)] -struct Cli { - /// Arbitrum / Ethereum RPC endpoint (or set ATUPA_RPC_URL) - #[arg(short, long, global = true, value_name = "URL")] - rpc: Option, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Generate a visual SVG flamegraph for any EVM transaction - Profile { - /// Transaction hash (0x-prefixed); omit when using --demo - #[arg(short, long, value_name = "TX_HASH", default_value = "")] - tx: String, - - /// Run an offline demo trace (no RPC required) - #[arg(long, default_value_t = false)] - demo: bool, - - /// Output path for the SVG (default: profile_.svg) - #[arg(short, long, value_name = "FILE")] - out: Option, - - /// Etherscan API key for contract name resolution - #[arg(long, value_name = "KEY")] - etherscan_key: Option, - }, - - /// Capture a unified EVM + Stylus execution trace (Arbitrum Nitro). - /// - /// Add --profile to also generate an SVG flamegraph from the same RPC call. - /// Add --studio to automatically launch Atupa Studio with the report loaded. - Capture { - /// Transaction hash to profile (0x-prefixed) - #[arg(short, long, value_name = "TX_HASH")] - tx: String, - - /// Output format for the JSON/summary report - #[arg(short, long, value_enum, default_value_t = OutputFormat::Summary)] - output: OutputFormat, - - /// Write report to a file instead of stdout - #[arg(short = 'f', long, value_name = "FILE")] - file: Option, - - /// Also generate an SVG flamegraph (reuses the same RPC trace) - #[arg(long, default_value_t = false)] - profile: bool, - - /// Etherscan API key for contract name resolution - #[arg(long, value_name = "KEY")] - etherscan_key: Option, - - /// Launch Atupa Studio after capture and open it in the browser - #[arg(long, default_value_t = false)] - studio: bool, - }, - - /// Protocol-aware execution auditing (Aave v3/GHO, Lido) - Audit { - /// Transaction hash to audit (0x-prefixed) - #[arg(short, long, value_name = "TX_HASH")] - tx: String, - - /// Protocol adapter to apply - #[arg(short, long, value_enum, default_value_t = Protocol::Aave)] - protocol: Protocol, - }, - - /// Compare the execution cost of two transactions - Diff { - /// Base transaction hash (0x-prefixed) - #[arg(short, long, value_name = "BASE_TX")] - base: String, - - /// Target transaction hash (0x-prefixed) - #[arg(short, long, value_name = "TARGET_TX")] - target: String, - - /// Simple mode override: Fail CI if gas increases by > X% - #[arg(long, value_name = "PERCENT")] - threshold: Option, - - /// Path to atupa.toml (defaults to looking in CWD) - #[arg(long, value_name = "FILE")] - config: Option, - - /// Generate artifacts/diff/report.md for GitHub PRs - #[arg(long, default_value_t = false)] - markdown: bool, - - /// Generate visual diff flamegraph in artifacts/diff/ - #[arg(long, default_value_t = false)] - svg: bool, - - /// Output format (summary | json | markdown) - #[arg(short, long, value_enum, default_value_t = OutputFormat::Summary)] - output: OutputFormat, - - /// Optional: Run DeepTracer on both and diff heuristics - #[arg(short, long, value_enum)] - protocol: Option, - }, - - /// Launch Atupa Studio — the local web visualizer for trace reports - Studio { - /// Port for the dev server (default: 5173) - #[arg(short, long, default_value_t = 5173)] - port: u16, - - /// Path to the studio directory (overrides auto-detection) - #[arg(long, value_name = "DIR")] - dir: Option, - - /// Open the browser automatically after the server starts - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] - open: bool, - }, - - /// Scaffold Atupa config, GitHub Actions workflow, and a profile script - /// - /// Run this once in a new repository to get started. - /// Detects Foundry, Hardhat, or Stylus projects automatically. - Init { - /// Overwrite existing files - #[arg(long, default_value_t = false)] - force: bool, - }, -} - -#[derive(Clone, ValueEnum, Debug, PartialEq, Eq)] -enum OutputFormat { - /// Human-readable terminal summary (default) - Summary, - /// Full step-by-step JSON — suitable for CI assertions and tooling - Json, - /// Emit only the numeric unified cost (gas-equiv) — ideal for scripting - Metric, -} - -#[derive(Clone, ValueEnum, Debug)] -enum Protocol { - /// Aave v3 + GHO stablecoin protocol adapters - Aave, - /// Lido stETH execution resilience (Phase II roadmap) - Lido, -} - -// ─── Entry Point ────────────────────────────────────────────────────────────── +use banner::print_banner; +use cli::{Cli, Commands}; +use commands::{cmd_audit, cmd_capture, cmd_diff, cmd_profile, cmd_studio}; #[tokio::main] async fn main() -> Result<()> { @@ -225,11 +56,12 @@ async fn main() -> Result<()> { demo, out, etherscan_key, + vm, } => { if let Some(key) = etherscan_key { config.etherscan_key = Some(key); } - cmd_profile(&config, &tx, demo, out).await?; + cmd_profile(&config, &tx, demo, out, vm).await?; } Commands::Capture { tx, @@ -238,11 +70,12 @@ async fn main() -> Result<()> { profile, etherscan_key, studio, + vm, } => { if let Some(key) = etherscan_key { config.etherscan_key = Some(key); } - let report_path = cmd_capture(&config, &tx, output, file, profile).await?; + let report_path = cmd_capture(&config, &tx, output, file, profile, vm).await?; if studio { // Pass the generated report path to Studio for auto-load cmd_studio(&config, config.studio_port, true, report_path).await?; @@ -260,6 +93,7 @@ async fn main() -> Result<()> { svg, protocol, output, + vm, } => { cmd_diff( &config, @@ -271,12 +105,13 @@ async fn main() -> Result<()> { svg, output, protocol, + vm, ) .await?; } Commands::Studio { port, dir, open } => { if let Some(d) = dir { - config.studio_dir = Some(std::path::PathBuf::from(d)); + config.studio_dir = Some(PathBuf::from(d)); } config.studio_port = port; cmd_studio(&config, port, open, None).await?; @@ -288,1216 +123,3 @@ async fn main() -> Result<()> { Ok(()) } - -// ─── Profile Command ────────────────────────────────────────────────────────── - -async fn cmd_profile( - config: &AtupaConfig, - tx: &str, - demo: bool, - out: Option, -) -> Result<()> { - if !demo && tx.is_empty() { - anyhow::bail!( - "You must provide --tx or run with --demo.\n\ - Example: atupa profile --demo" - ); - } - - let display = if demo { "demo" } else { tx }; - eprintln!("{} {}", "→ Profiling:".bold(), display.cyan()); - eprintln!("{} {}\n", "→ Endpoint: ".bold(), config.rpc_url.dimmed()); - - // Route output through the standard artifacts directory (same as capture) - let svg_path = resolve_artifact_path(out, "profile", tx, "svg"); - - let (out_path, network) = atupa::execute_profile( - tx, - &config.rpc_url, - demo, - Some(svg_path), - config.etherscan_key.clone(), - ) - .await - .context("Profile command failed")?; - - eprintln!(); - eprintln!( - " {} ({})", - "PROFILE COMPLETE".bold().underline(), - network.cyan() - ); - let div = "─".repeat(40).dimmed().to_string(); - eprintln!("{div}"); - eprintln!( - " {:<24} {}", - "SVG saved to:".bold(), - out_path.green().bold() - ); - eprintln!("{div}"); - Ok(()) -} - -// ─── Capture Command ────────────────────────────────────────────────────────── - -async fn cmd_capture( - config: &AtupaConfig, - tx: &str, - format: OutputFormat, - file: Option, - generate_profile: bool, -) -> Result> { - let tx = normalise_hash(tx); - eprintln!("{} {}", "→ Transaction:".bold(), tx.cyan()); - eprintln!("{} {}\n", "→ Endpoint: ".bold(), config.rpc_url.dimmed()); - - // Phase 1: fetch ────────────────────────────────────────────────────────── - let pb = spinner("Detecting network and fetching execution trace…"); - let client = NitroClient::new(config.rpc_url.clone()); - - let mut report = client - .trace_transaction(&tx) - .await - .context("Failed to fetch trace — ensure the RPC endpoint is valid and accessible.")?; - - let network_name = get_network_name(report.chain_id); - pb.finish_with_message(format!( - "{} Captured trace from {} ({} EVM steps{} )", - "✔".green().bold(), - network_name.cyan().bold(), - evm_count(&report).to_string().green(), - if report.total_stylus_ink > 0 { - format!( - " + {} Stylus HostIOs", - report.stylus_steps().len().to_string().yellow() - ) - } else { - "".into() - } - )); - - // Phase 1b: fetch receipt for on-chain gasUsed (non-fatal) ────────────────── - let eth_client = EthClient::new(config.rpc_url.clone()); - report.on_chain_gas_used = eth_client.get_gas_used(&tx).await; - - // Phase 1.5: resolve contract names ───────────────────────────────────────── - if let Some(key) = config.etherscan_key.clone() { - let pb_names = spinner("Resolving contract names via Etherscan…"); - let resolver = atupa_rpc::etherscan::EtherscanResolver::new(Some(key), report.chain_id); - - let mut addresses = std::collections::HashSet::new(); - for step in &report.steps { - if let Some(evm) = &step.evm - && (evm.op.contains("CALL") || evm.op.contains("CREATE")) - && let Some(stack) = &evm.stack - && stack.len() >= 2 - { - let hex_addr = &stack[stack.len() - 2]; - let clean_hex = hex_addr.trim_start_matches("0x"); - let padded = format!("{:0>40}", clean_hex); - let extracted = &padded[padded.len() - 40..]; - addresses.insert(format!("0x{}", extracted)); - } - } - - for addr in addresses { - if let Some(name) = resolver.resolve_contract_name(&addr).await { - report.resolved_names.insert(addr, name); - } - } - pb_names.finish_with_message(format!( - "{} Resolved {} contract name(s) via Etherscan.", - "✔".green().bold(), - report.resolved_names.len().to_string().cyan().bold() - )); - } - - // Phase 2: optional Flamegraph SVG (built from already-fetched report — no second RPC call) ── - let mut svg_path: Option = None; - if generate_profile { - let pb_svg = spinner("Generating SVG flamegraph…"); - - // Convert report steps → collapsed stacks → SVG (zero extra RPC calls) - let trace_steps: Vec = - report.steps.iter().map(|s| s.to_trace_step()).collect(); - let normalized = TraceParser::normalize_raw(trace_steps); - let stacks = Aggregator::build_collapsed_stacks(&normalized); - let svg = SvgGenerator::generate_flamegraph(&stacks) - .context("SVG flamegraph generation failed")?; - - let svg_suggestion = file.as_ref().map(|f| { - if f.ends_with(".json") { - f.trim_end_matches(".json").to_string() + ".svg" - } else { - f.to_string() + ".svg" - } - }); - let svg_out = resolve_artifact_path(svg_suggestion, "capture", &tx, "svg"); - std::fs::write(&svg_out, svg) - .with_context(|| format!("Failed to write SVG to '{svg_out}'"))?; - - pb_svg.finish_with_message(format!( - "{} SVG saved → {}", - "✔".green().bold(), - svg_out.green().bold() - )); - svg_path = Some(svg_out); - } - - // Phase 3: render report ────────────────────────────────────────────────── - let pb2 = spinner("Rendering report…"); - let summary_text = render_capture_summary(&report); - - let rendered = match format { - OutputFormat::Summary => summary_text.clone(), - OutputFormat::Json => serde_json::to_string_pretty(&report)?, - OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), - }; - pb2.finish_with_message(format!("{} Report ready.", "✔".green().bold())); - - eprintln!(); - println!("{}", summary_text); - eprintln!(); - - // Phase 4: output ───────────────────────────────────────────────────────── - let report_path = resolve_artifact_path(file, "capture", &tx, "json"); - - std::fs::write(&report_path, &rendered) - .with_context(|| format!("Failed to write report to '{report_path}'"))?; - - eprintln!( - "{} Report saved to {}", - "✔".green().bold(), - report_path.cyan().bold() - ); - - if let Some(ref svg) = svg_path { - eprintln!( - "{} SVG profile saved to {}", - "✔".green().bold(), - svg.cyan().bold() - ); - } - - Ok(Some(report_path)) -} - -// ─── Audit Command ──────────────────────────────────────────────────────────── - -async fn cmd_audit(config: &AtupaConfig, tx: &str, protocol: Protocol) -> Result<()> { - let tx = normalise_hash(tx); - let label = match protocol { - Protocol::Aave => "Aave v3 + GHO", - Protocol::Lido => "Lido stETH", - }; - - eprintln!( - "{} {} audit for {}", - "→".bold(), - label.yellow().bold(), - tx.cyan() - ); - eprintln!("{} {}\n", "→ Endpoint:".bold(), config.rpc_url.dimmed()); - - let eth_client = EthClient::new(config.rpc_url.clone()); - let client = NitroClient::new(config.rpc_url.clone()); - - // Fetch the top-level calldata selector (non-fatal) — gives us the real function being called - let top_level_selector = eth_client - .get_transaction_input(&tx) - .await - .and_then(|input| EthClient::selector_from_input(&input)); - - let pb = spinner(&format!("Fetching trace for {label} audit…")); - - let report = client - .trace_transaction(&tx) - .await - .context("Failed to fetch trace — is the Arbitrum node running?")?; - - pb.finish_with_message(format!( - "{} Trace captured ({} unified steps).", - "✔".green().bold(), - report.steps.len() - )); - - match protocol { - Protocol::Aave => { - let pb2 = spinner("Applying Aave v3 + GHO protocol adapter…"); - - let trace_steps: Vec = report - .steps - .iter() - .filter(|s| s.vm == VmKind::Evm) - .filter_map(|s| s.evm.as_ref()) - .map(bridge_raw_to_trace_step) - .collect(); - - let tracer = AaveDeepTracer::new(); - let liq = tracer - .analyze_liquidation(&tx, &trace_steps) - .context("Aave adapter failed")?; - - pb2.finish_with_message(format!("{} Aave v3 adapter complete.", "✔".green().bold())); - eprintln!(); - print_aave_report(&liq, &report, top_level_selector.as_deref()); - } - Protocol::Lido => { - let pb2 = spinner("Applying Lido stETH protocol adapter…"); - - let trace_steps: Vec = report - .steps - .iter() - .filter(|s| s.vm == VmKind::Evm) - .filter_map(|s| s.evm.as_ref()) - .map(bridge_raw_to_trace_step) - .collect(); - - let tracer = LidoDeepTracer::new(); - let res = tracer - .analyze_staking(&tx, &trace_steps) - .context("Lido adapter failed")?; - - pb2.finish_with_message(format!( - "{} Lido stETH adapter complete.", - "✔".green().bold() - )); - eprintln!(); - print_lido_report(&res, &report, top_level_selector.as_deref()); - } - } - - Ok(()) -} - -// ─── Diff Command ───────────────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -#[allow(clippy::collapsible_if)] -async fn cmd_diff( - config: &AtupaConfig, - base: &str, - target: &str, - threshold: Option, - diff_config: Option, - markdown: bool, - svg: bool, - output_format: OutputFormat, - protocol: Option, -) -> Result<()> { - let base = normalise_hash(base); - let target = normalise_hash(target); - - eprintln!( - "{} {} {} {}", - "→ Base: ".bold(), - base.cyan(), - "Target:".bold(), - target.yellow() - ); - eprintln!("{} {}\n", "→ Endpoint:".bold(), config.rpc_url.dimmed()); - - let client = NitroClient::new(config.rpc_url.clone()); - let eth_client = EthClient::new(config.rpc_url.clone()); - - let pb = spinner("Fetching both traces and receipts concurrently…"); - - // Fetch traces - let (base_report, target_report) = tokio::try_join!( - client.trace_transaction(&base), - client.trace_transaction(&target), - ) - .context("Failed to fetch one or both traces")?; - - // Fetch receipts for actual gas used - let (base_receipt_gas, target_receipt_gas) = tokio::join!( - eth_client.get_gas_used(&base), - eth_client.get_gas_used(&target), - ); - - pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); - eprintln!(); - - // Cost deltas - let base_unified_cost = base_report.total_unified_cost; - let target_unified_cost = target_report.total_unified_cost; - let unified_delta = target_unified_cost - base_unified_cost; - let unified_pct = if base_unified_cost > 0.0 { - unified_delta / base_unified_cost * 100.0 - } else { - 0.0 - }; - - let base_total_gas = base_receipt_gas.unwrap_or(base_unified_cost as u64); - let target_total_gas = target_receipt_gas.unwrap_or(target_unified_cost as u64); - let total_gas_delta = target_total_gas as f64 - base_total_gas as f64; - let total_gas_pct = if base_total_gas > 0 { - total_gas_delta / base_total_gas as f64 * 100.0 - } else { - 0.0 - }; - - let base_intrinsic = base_total_gas.saturating_sub(base_unified_cost as u64); - let target_intrinsic = target_total_gas.saturating_sub(target_unified_cost as u64); - - let div = "─".repeat(70).dimmed().to_string(); - - println!("{}", " EXECUTION DIFF".bold().underline()); - println!("{div}"); - - // Print Table Header - println!( - " {:<25} {:<15} {:<15} {}", - "Metric".bold(), - "Base".bold(), - "Target".bold(), - "Delta".bold() - ); - println!("{div}"); - - let colorize_delta = |delta: f64, pct: f64| -> String { - let sign = if delta >= 0.0 { "+" } else { "" }; - if delta > 0.0 { - format!("{sign}{delta:.0} ({sign}{pct:.1}%)") - .red() - .to_string() - } else if delta < 0.0 { - format!("{sign}{delta:.0} ({sign}{pct:.1}%)") - .green() - .to_string() - } else { - format!("{sign}{delta:.0} ({sign}{pct:.1}%)") - .dimmed() - .to_string() - } - }; - - println!( - " {:<25} {:<15} {:<15} {}", - "Total On-Chain Gas:", - base_total_gas.to_string().green(), - target_total_gas.to_string().yellow(), - colorize_delta(total_gas_delta, total_gas_pct) - ); - - println!( - " {:<25} {:<15} {:<15} {}", - "↳ Execution Gas (EVM):", - base_unified_cost.to_string().cyan(), - target_unified_cost.to_string().cyan(), - colorize_delta(unified_delta, unified_pct) - ); - - let intrinsic_delta = target_intrinsic as f64 - base_intrinsic as f64; - let intrinsic_pct = if base_intrinsic > 0 { - intrinsic_delta / base_intrinsic as f64 * 100.0 - } else { - 0.0 - }; - println!( - " {:<25} {:<15} {:<15} {}", - "↳ Intrinsic Gas:", - base_intrinsic.to_string().dimmed(), - target_intrinsic.to_string().dimmed(), - colorize_delta(intrinsic_delta, intrinsic_pct) - ); - - println!("{div}"); - - // Step count comparison - let base_evm = evm_count(&base_report); - let tgt_evm = evm_count(&target_report); - let evm_delta = tgt_evm as f64 - base_evm as f64; - let evm_pct = if base_evm > 0 { - evm_delta / base_evm as f64 * 100.0 - } else { - 0.0 - }; - println!( - " {:<25} {:<15} {:<15} {}", - "EVM Steps:", - base_evm.to_string().green(), - tgt_evm.to_string().yellow(), - colorize_delta(evm_delta, evm_pct) - ); - - let base_stylus = base_report.stylus_steps().len(); - let tgt_stylus = target_report.stylus_steps().len(); - let stylus_delta = tgt_stylus as f64 - base_stylus as f64; - let stylus_pct = if base_stylus > 0 { - stylus_delta / base_stylus as f64 * 100.0 - } else { - 0.0 - }; - println!( - " {:<25} {:<15} {:<15} {}", - "Stylus Cross-VM Calls:", - base_stylus.to_string().green(), - tgt_stylus.to_string().yellow(), - colorize_delta(stylus_delta, stylus_pct) - ); - println!("{div}"); - - // ── Protocol Deep Diff (opt-in) ────────────────────────────────────────── - let mut proto_diff_rows: Vec = Vec::new(); - let mut proto_name = String::new(); - - if let Some(ref proto) = protocol { - let base_steps: Vec = base_report - .steps - .iter() - .map(|s| s.to_trace_step()) - .collect(); - let target_steps: Vec = target_report - .steps - .iter() - .map(|s| s.to_trace_step()) - .collect(); - - let proto_report = match proto { - Protocol::Aave => { - let tracer = AaveDeepTracer::new(); - tracer.diff_reports(&base, &base_steps, &target, &target_steps) - } - Protocol::Lido => { - let tracer = LidoDeepTracer::new(); - tracer.diff_reports(&base, &base_steps, &target, &target_steps) - } - }; - - match proto_report { - Ok(report) => { - proto_name = report.protocol.clone(); - let proto_div = "─".repeat(70).dimmed().to_string(); - println!( - "\n {} DEEP DIFF", - proto_name.to_uppercase().bold().underline() - ); - println!("{proto_div}"); - println!( - " {:<28} {:<15} {:<15} {}", - "Metric".bold(), - "Base".bold(), - "Target".bold(), - "Delta".bold() - ); - println!("{proto_div}"); - - for row in &report.rows { - let sign = if row.delta >= 0.0 { "+" } else { "" }; - let delta_str = format!("{sign}{:.0} ({sign}{:.1}%)", row.delta, row.pct); - let delta_colored = if row.delta == 0.0 { - delta_str.dimmed().to_string() - } else if (row.delta > 0.0) == row.higher_is_worse { - delta_str.red().to_string() // bad change - } else { - delta_str.green().to_string() // good change - }; - println!( - " {:<28} {:<15} {:<15} {}", - row.metric, - row.base.to_string().dimmed(), - row.target.to_string().dimmed(), - delta_colored - ); - proto_diff_rows.push(row.clone()); - } - println!("{proto_div}"); - } - Err(e) => { - eprintln!(" ⚠ Protocol deep diff skipped: {e}"); - } - } - } - - let format_plain_delta = |delta: f64, pct: f64| -> String { - let sign = if delta >= 0.0 { "+" } else { "" }; - format!("{sign}{delta:.0} ({sign}{pct:.1}%)") - }; - - if markdown { - let md = format!( - "## 🏮 Atupa Gas Regression Report\n\n\ - | Metric | Base | Target | Delta |\n\ - |--------|------|--------|-------|\n\ - | **Total Gas** | {} | {} | {} |\n\ - | **Execution Gas** | {} | {} | {} |\n\ - | **EVM Steps** | {} | {} | {} |\n\ - | **Stylus Calls** | {} | {} | {} |\n\n\ - *Profiled via Atupa Unified Tracer*\n", - base_total_gas, - target_total_gas, - format_plain_delta(total_gas_delta, total_gas_pct), - base_unified_cost, - target_unified_cost, - format_plain_delta(unified_delta, unified_pct), - base_evm, - tgt_evm, - format_plain_delta(evm_delta, evm_pct), - base_stylus, - tgt_stylus, - format_plain_delta(stylus_delta, stylus_pct) - ); - let out_path = format!("artifacts/diff/{}_vs_{}.md", &base[..10], &target[..10]); - std::fs::create_dir_all("artifacts/diff").ok(); - - // Append protocol deep diff to markdown if available - let proto_section = if !proto_diff_rows.is_empty() { - let mut section = format!("\n### 🔬 {} Protocol Deep Diff\n\n", proto_name); - section.push_str("| Metric | Base | Target | Delta |\n"); - section.push_str("|--------|------|--------|-------|\n"); - for row in &proto_diff_rows { - let sign = if row.delta >= 0.0 { "+" } else { "" }; - let emoji = if row.delta == 0.0 { - "" - } else if (row.delta > 0.0) == row.higher_is_worse { - "🔴 " - } else { - "🟢 " - }; - section.push_str(&format!( - "| **{}** | {} | {} | {}{}{:.0} ({}{:.1}%) |\n", - row.metric, row.base, row.target, emoji, sign, row.delta, sign, row.pct - )); - } - section - } else { - String::new() - }; - - std::fs::write(&out_path, md + &proto_section).context("Failed to write markdown diff")?; - println!(" 📝 Markdown report written to {}", out_path.cyan()); - } - - if svg { - let base_trace_steps: Vec = base_report - .steps - .iter() - .map(|s| s.to_trace_step()) - .collect(); - let base_normalized = TraceParser::normalize_raw(base_trace_steps); - let base_stacks = Aggregator::build_collapsed_stacks(&base_normalized); - - let target_trace_steps: Vec = target_report - .steps - .iter() - .map(|s| s.to_trace_step()) - .collect(); - let target_normalized = TraceParser::normalize_raw(target_trace_steps); - let target_stacks = Aggregator::build_collapsed_stacks(&target_normalized); - - let svg_content = atupa_output::generate_diff_flamegraph(&base_stacks, &target_stacks)?; - let svg_path = format!("artifacts/diff/{}_vs_{}.svg", &base[..10], &target[..10]); - std::fs::create_dir_all("artifacts/diff").ok(); - std::fs::write(&svg_path, svg_content).context("Failed to write diff flamegraph SVG")?; - println!(" 🔥 Visual diff flamegraph written to {}", svg_path.cyan()); - } - - // Threshold Engine Evaluation - let mut failures = Vec::new(); - - let config_toml = if let Some(path) = diff_config { - AtupaConfigToml::load(std::path::Path::new(&path)).ok() - } else { - AtupaConfigToml::auto_load() - }; - - if let Some(t) = threshold { - // Simple Mode override - if total_gas_pct > t { - failures.push(format!( - "Total Gas increased by {:.1}% (limit: {:.1}%)", - total_gas_pct, t - )); - } - } else if let Some(ref cfg) = config_toml { - // TOML Config evaluation - if let Some(diff_cfg) = &cfg.diff { - if let Some(max_total) = diff_cfg.max_total_gas_increase_percent { - if total_gas_pct > max_total { - failures.push(format!( - "Total Gas increased by {:.1}% (limit: {:.1}%)", - total_gas_pct, max_total - )); - } - } - if let Some(max_exec) = diff_cfg.max_execution_gas_increase_percent { - if unified_pct > max_exec { - failures.push(format!( - "Execution Gas increased by {:.1}% (limit: {:.1}%)", - unified_pct, max_exec - )); - } - } - if let Some(max_evm) = diff_cfg.max_evm_steps_increase { - if evm_delta > max_evm as f64 { - failures.push(format!( - "EVM Steps increased by {:.0} (limit: {})", - evm_delta, max_evm - )); - } - } - if let Some(max_stylus) = diff_cfg.max_stylus_calls_increase { - if stylus_delta > max_stylus as f64 { - failures.push(format!( - "Stylus Calls increased by {:.0} (limit: {})", - stylus_delta, max_stylus - )); - } - } - } - } - - // Final Output Handling - if output_format == OutputFormat::Json { - let diff_report = serde_json::json!({ - "type": "diff", - "protocol": protocol.map(|p| format!("{:?}", p)), - "base": { - "tx_hash": base, - "report": base_report, - }, - "target": { - "tx_hash": target, - "report": target_report, - }, - "metrics": { - "base_total_gas": base_total_gas, - "target_total_gas": target_total_gas, - "gas_delta": total_gas_delta, - "gas_pct": total_gas_pct, - "base_unified_cost": base_unified_cost, - "target_unified_cost": target_unified_cost, - "unified_delta": unified_delta, - "unified_pct": unified_pct, - } - }); - println!("{}", serde_json::to_string_pretty(&diff_report)?); - } else { - if !failures.is_empty() { - println!("\n {}", "❌ [FAILED] Regression detected:".red().bold()); - for f in failures.iter() { - println!(" - {}", f.red()); - } - } else if threshold.is_some() || config_toml.is_some() { - println!( - "\n {} Execution cost within acceptable limits.", - "✅ [PASSED]".green().bold() - ); - } - } - - if !failures.is_empty() { - return Err(anyhow::anyhow!("Gas regression thresholds exceeded")); - } - - Ok(()) -} - -// ─── Studio Command ─────────────────────────────────────────────────────────── - -async fn cmd_studio( - _config: &AtupaConfig, - port: u16, - launch_browser: bool, - report_path: Option, -) -> Result<()> { - // 1. Read report if provided - let report_content = if let Some(path) = report_path.as_ref() { - Some(std::fs::read_to_string(path).context("Failed to read report file for Studio")?) - } else { - None - }; - - // 2. Prepare the server - let server = studio::StudioServer::new(report_content); - let mut url = format!("http://localhost:{port}/"); - if report_path.is_some() { - url += "?auto=true"; - } - - eprintln!("{} Launching Atupa Studio...", "→".bold().cyan()); - - // Spawn server in background - let server_handle = tokio::spawn(async move { - if let Err(e) = server.start(port).await { - eprintln!("\n{} Studio server error: {e}", "⚠".red().bold()); - } - }); - - // Wait for the port to be active - let addr = format!("127.0.0.1:{port}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while std::net::TcpStream::connect(&addr).is_err() { - if std::time::Instant::now() > deadline { - anyhow::bail!("Studio server failed to start on port {port} within 5s."); - } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - - eprintln!( - "{} Studio ready at {}", - "✔".green().bold(), - url.cyan().bold() - ); - - // 3. Open browser - if launch_browser && let Err(e) = open::that(&url) { - eprintln!("{} Could not open browser: {e}", "⚠".yellow()); - } - - // 4. Footer info - if let Some(path) = report_path { - eprintln!( - "\n {} Report loaded: {}\n The Studio has automatically opened this report.", - "✔".green().bold(), - path.cyan().bold(), - ); - } - eprintln!("{}\n", " Press Ctrl+C to stop the Studio server.".dimmed()); - - // Keep the main thread alive while the server runs - let _ = server_handle.await; - Ok(()) -} - -// ─── Banner & Rendering ─────────────────────────────────────────────────────── - -fn print_banner() { - eprintln!( - "{}", - "╔════════════════════════════════════════════╗".dimmed() - ); - eprintln!( - "{} {} {}", - "║".dimmed(), - " 🏮 ATUPA · Unified Execution Profiler ".bold(), - "║".dimmed() - ); - eprintln!( - "{}", - "╚════════════════════════════════════════════╝".dimmed() - ); - eprintln!(); -} - -fn hostio_category_color(label: &str) -> &'static str { - match label { - "storage_flush_cache" | "storage_store_bytes32" => "\x1b[31;1m", - "storage_load_bytes32" | "storage_cache_bytes32" => "\x1b[33m", - "native_keccak256" => "\x1b[35m", - "read_args" | "write_result" | "pay_for_memory_grow" => "\x1b[32m", - "msg_sender" | "msg_value" | "msg_reentrant" | "emit_log" | "account_balance" - | "block_hash" => "\x1b[36m", - "call" | "static_call" | "delegate_call" | "create" => "\x1b[34m", - _ => "\x1b[90m", - } -} - -fn render_capture_summary(report: &StitchedReport) -> String { - const RESET: &str = "\x1b[0m"; - let div = "─".repeat(56).dimmed().to_string(); - let wide_div = "━".repeat(72); - let mut out = String::new(); - - out += &format!( - " {} ({})\n", - "UNIFIED EXECUTION SUMMARY".bold().underline(), - get_network_name(report.chain_id).cyan() - ); - out += &format!("{div}\n"); - - // ── Gas totals with Execution vs Intrinsic split ─────────────────────────────── - if let Some(on_chain) = report.on_chain_gas_used { - let execution_gas = report.total_evm_gas; - let intrinsic_gas = on_chain.saturating_sub(execution_gas); - out += &format!( - " {:<34} {}\n", - "Total Gas Used (on-chain):".bold(), - on_chain.to_string().green().bold() - ); - out += &format!( - " {:<34} {}\n", - " ├─ Execution:".dimmed(), - execution_gas.to_string().green() - ); - out += &format!( - " {:<34} {}\n", - " └─ Intrinsic (base + calldata):".dimmed(), - intrinsic_gas.to_string().yellow() - ); - } else { - out += &format!( - " {:<34} {}\n", - "EVM Trace Gas (Total):".bold(), - report.total_evm_gas.to_string().green() - ); - } - - if report.total_stylus_ink > 0 { - out += &format!( - " {:<34} {}\n", - "Stylus Ink (raw):".bold(), - report.total_stylus_ink.to_string().yellow() - ); - out += &format!( - " {:<34} {}\n", - " → Gas-equivalent (÷10,000):".dimmed(), - format!("{:.2}", report.total_stylus_gas_equiv).yellow() - ); - } - - if report.vm_boundary_count > 0 { - out += &format!( - " {:<34} {}\n", - "VM Boundaries (EVM ↔ WASM):".bold(), - report.vm_boundary_count.to_string().magenta() - ); - } - - out += &format!("{div}\n"); - out += &format!( - " {:<34} {}\n", - "TOTAL UNIFIED COST:".bold().cyan(), - format!("{:.2} gas", report.total_unified_cost) - .cyan() - .bold() - ); - out += &format!("{div}\n"); - - // EVM step count always shown - out += &format!( - " {:<34} {}\n", - "EVM Steps:".bold(), - evm_count(report).to_string().green() - ); - - // Stylus section — only when HostIO steps exist - let stylus = report.stylus_steps(); - if !stylus.is_empty() { - // Aggregate ink cost by label - let mut grouped: std::collections::HashMap = std::collections::HashMap::new(); - for step in stylus.iter() { - *grouped.entry(step.label.clone()).or_insert(0.0) += step.cost_equiv; - } - let mut aggregated: Vec<(String, f64)> = grouped.into_iter().collect(); - aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); - - let total_ink_gas: f64 = aggregated.iter().map(|(_, c)| c).sum(); - let unique_paths = aggregated.len(); - - out += &format!( - " {:<34} {}\n", - "Stylus HostIO Calls:".bold(), - stylus.len().to_string().yellow() - ); - out += &format!( - " {:<34} {}\n", - "Unique HostIO Paths:".bold(), - unique_paths.to_string().yellow() - ); - - if report.vm_boundary_count > 0 { - out += &format!(" {}\n", "EVM→WASM Boundary Details:".bold()); - for (i, step) in report.boundary_steps().iter().take(5).enumerate() { - out += &format!( - " {} {} at depth {}\n", - format!("[{}]", i + 1).cyan(), - step.label.bold(), - step.depth.to_string().dimmed() - ); - } - if report.vm_boundary_count > 5 { - out += &format!( - " … and {} more\n", - (report.vm_boundary_count - 5).to_string().dimmed() - ); - } - } - - out += &format!("{div}\n"); - - // ── Colour-coded hot-path table ──────────────────────────────────── - out += &format!(" {}\n", "🔥 STYLUS HOT PATHS".bold()); - out += &format!(" {wide_div}\n"); - out += &format!( - " ┃ {:<42} ┃ {:>10} ┃ {:>14} ┃ {:>7} ┃\n", - "HostIO (Hottest First)", "GAS", "INK (raw)", "%" - ); - out += &format!(" {wide_div}\n"); - for (label, cost_gas) in aggregated.iter().take(10) { - let cost_ink = (cost_gas * 10_000.0) as u64; - let pct = if total_ink_gas > 0.0 { - cost_gas / total_ink_gas * 100.0 - } else { - 0.0 - }; - let color = hostio_category_color(label); - let gas_str = format!("{:.0}", cost_gas); - out += &format!( - " ┃ {color}{:<42}{RESET} ┃ {gas_str:>10} ┃ {cost_ink:>14} ┃ {pct:>6.1}% ┃\n", - label, - ); - } - out += &format!(" {wide_div}\n"); - - // ── ASCII flamegraph ─────────────────────────────────────────────── - out += &format!("\n {}\n", "📊 SIMPLIFIED FLAMEGRAPH".bold()); - out += " root ██████████████████████████████████████████████████ 100%\n"; - for (label, cost_gas) in aggregated.iter().take(5) { - let pct = if total_ink_gas > 0.0 { - cost_gas / total_ink_gas * 100.0 - } else { - 0.0 - }; - let bar_width = (pct / 2.0) as usize; - let bar = "█".repeat(bar_width); - let color = hostio_category_color(label); - out += &format!( - " └─ {color}{:<20}{RESET} {color}{:<50}{RESET} {:>5.1}%\n", - label, bar, pct - ); - } - if unique_paths > 10 { - out += &format!("\n ({} of {} unique paths shown)\n", 10, unique_paths); - } - - out += &format!("{div}\n"); - } - - out += &format!(" tx {}\n", report.tx_hash.dimmed()); - out -} - -fn print_aave_report( - aave: &atupa_aave::LiquidationReport, - nitro: &StitchedReport, - top_selector: Option<&str>, -) { - let div = "─".repeat(56).dimmed().to_string(); - println!("{}", " AAVE v3 PROTOCOL AUDIT".bold().underline()); - println!("{div}"); - - // Show the actual top-level function called, resolved from calldata - if let Some(sel) = top_selector { - let fn_name = atupa_aave::AaveV3Adapter::resolve_selector_label(sel) - .unwrap_or_else(|| format!("unknown ({})", sel)); - println!( - " {:<34} {}", - "Top-Level Call:".bold(), - fn_name.yellow().bold() - ); - } - - let rows: &[(&str, String)] = &[ - ("Total Gas (Aave frame):", aave.total_gas.to_string()), - ("Liquidation Gas:", aave.liquidation_gas.to_string()), - ("Storage Reads (SLOAD):", aave.storage_reads.to_string()), - ("Storage Writes (SSTORE):", aave.storage_writes.to_string()), - ("External Calls:", aave.external_calls.to_string()), - ("Oracle Calls:", aave.oracle_calls.to_string()), - ( - "Cross-VM Calls (Stylus):", - nitro.vm_boundary_count.to_string(), - ), - ("Max Call Depth:", aave.max_depth.to_string()), - ]; - for (label, val) in rows { - println!(" {:<34} {}", label.bold(), val.cyan()); - } - println!("{div}"); - - if !aave.labeled_calls.is_empty() { - println!(" {}", "Protocol Calls Detected:".bold()); - for call in aave.labeled_calls.iter().take(10) { - println!( - " {} {} {}", - format!("[depth={:>2}]", call.depth).dimmed(), - call.label.yellow(), - format!("({} gas)", call.gas_cost).dimmed() - ); - } - println!("{div}"); - } - - println!( - " {:<34} {}", - "Reverted:".bold(), - if aave.reverted { - "YES".red().bold().to_string() - } else { - "NO".green().to_string() - } - ); - println!( - " {:<34} {:.4}", - "Liquidation Efficiency:".bold(), - aave.liquidation_efficiency - ); - println!("{div}"); -} - -fn print_lido_report( - lido: &atupa_lido::LidoReport, - nitro: &StitchedReport, - top_selector: Option<&str>, -) { - let div = "─".repeat(56).dimmed().to_string(); - println!("{}", " LIDO stETH PROTOCOL AUDIT".bold().underline()); - println!("{div}"); - - // Show the actual top-level function called, resolved from calldata - if let Some(sel) = top_selector { - let fn_name = atupa_lido::LidoAdapter::resolve_selector_label(sel) - .unwrap_or_else(|| format!("unknown fn ({})", sel)); - println!( - " {:<34} {}", - "Top-Level Call:".bold(), - fn_name.yellow().bold() - ); - } - - let rows: &[(&str, String)] = &[ - ("Total Gas (Lido frame):", lido.total_gas.to_string()), - ("Storage Reads (SLOAD):", lido.storage_reads.to_string()), - ("Storage Writes (SSTORE):", lido.storage_writes.to_string()), - ("External Calls:", lido.external_calls.to_string()), - ("Shares Transfers:", lido.shares_transfers.to_string()), - ("Oracle Reports:", lido.oracle_reports.to_string()), - ("Withdrawal Requests:", lido.withdrawal_requests.to_string()), - ("Withdrawal Claims:", lido.withdrawal_claims.to_string()), - ("Wrapped Ops (wstETH):", lido.wrapped_ops.to_string()), - ( - "Cross-VM Calls (Stylus):", - nitro.vm_boundary_count.to_string(), - ), - ("Max Call Depth:", lido.max_depth.to_string()), - ]; - for (label, val) in rows { - println!(" {:<34} {}", label.bold(), val.cyan()); - } - println!("{div}"); - - if !lido.labeled_calls.is_empty() { - println!(" {}", "Protocol Calls Detected:".bold()); - for call in lido.labeled_calls.iter().take(10) { - println!( - " {} {} {}", - format!("[depth={:>2}]", call.depth).dimmed(), - call.label.yellow(), - format!("({} gas)", call.gas_cost).dimmed() - ); - } - if lido.labeled_calls.len() > 10 { - println!( - " ... and {} more", - (lido.labeled_calls.len() - 10).to_string().dimmed() - ); - } - println!("{div}"); - } - - println!( - " {:<34} {}", - "Reverted:".bold(), - if lido.reverted { - "YES".red().bold().to_string() - } else { - "NO".green().to_string() - } - ); - println!("{div}"); -} - -// ─── Shared Utilities ───────────────────────────────────────────────────────── - -/// Normalise a transaction hash to lowercase 0x-prefixed form. -fn normalise_hash(tx: &str) -> String { - let t = tx.trim(); - if t.to_lowercase().starts_with("0x") { - t.to_lowercase() - } else { - format!("0x{}", t.to_lowercase()) - } -} - -fn evm_count(r: &StitchedReport) -> usize { - r.steps.iter().filter(|s| s.vm == VmKind::Evm).count() -} - -/// Bridge `RawStructLog` (atupa-rpc) → `TraceStep` (atupa-core) for adapters -/// that still operate on the lower-level type. -fn bridge_raw_to_trace_step(raw: &RawStructLog) -> TraceStep { - TraceStep { - pc: raw.pc, - op: raw.op.clone(), - gas: raw.gas, - gas_cost: raw.gas_cost, - depth: raw.depth, - stack: raw.stack.clone(), - memory: raw.memory.clone(), - error: raw.error.clone(), - reverted: raw.error.is_some(), - vm_kind: atupa_core::VmKind::Evm, - } -} - -fn spinner(msg: &str) -> ProgressBar { - let pb = ProgressBar::new_spinner(); - pb.set_style( - ProgressStyle::with_template("{spinner:.cyan} {msg}") - .unwrap() - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), - ); - pb.enable_steady_tick(Duration::from_millis(80)); - pb.set_message(msg.to_string()); - pb -} - -fn get_network_name(chain_id: u64) -> String { - match chain_id { - 1 => "Ethereum Mainnet".to_string(), - 11155111 => "Sepolia Testnet".to_string(), - 17000 => "Holesky Testnet".to_string(), - 42161 => "Arbitrum One".to_string(), - 42170 => "Arbitrum Nova".to_string(), - 421614 => "Arbitrum Sepolia".to_string(), - 8453 => "Base Mainnet".to_string(), - 84532 => "Base Sepolia".to_string(), - 10 => "Optimism".to_string(), - 11155420 => "Optimism Sepolia".to_string(), - 137 => "Polygon POS".to_string(), - 1337 | 31337 => "Local Devnet".to_string(), - 412346 => "Nitro Local Devnet".to_string(), - 0 => "Unknown Network".to_string(), - id => format!("Chain ID: {}", id), - } -} - -fn resolve_artifact_path(path: Option, category: &str, tx_hash: &str, ext: &str) -> String { - let filename = path.unwrap_or_else(|| { - let short = tx_hash - .trim_start_matches("0x") - .get(..10) - .unwrap_or(tx_hash); - match ext { - "json" => format!("report_{short}.json"), - "svg" => format!("profile_{short}.svg"), - _ => format!("artifact_{short}.{ext}"), - } - }); - - let pb = std::path::PathBuf::from(&filename); - // If it's a simple filename (no parent directory), move it to artifacts// - if pb - .parent() - .map(|p| p.as_os_str().is_empty()) - .unwrap_or(true) - { - let dir = format!("artifacts/{}", category); - let _ = std::fs::create_dir_all(&dir); - format!("{}/{}", dir, filename) - } else { - filename - } -} diff --git a/bin/atupa/src/thresholds.rs b/bin/atupa/src/thresholds.rs index 1c6d946..a90d48a 100644 --- a/bin/atupa/src/thresholds.rs +++ b/bin/atupa/src/thresholds.rs @@ -1,29 +1,51 @@ +//! TOML configuration parsing and CI threshold evaluation for Atupa. +//! +//! Evaluates gas regressions, execution budget breaches, and cross-VM boundary +//! increases against limits configured in `atupa.toml` or passed via CLI flags. + use anyhow::{Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::Path; -#[derive(Debug, Default, Deserialize)] +/// Top-level configuration representation matching `atupa.toml`. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] pub struct AtupaConfigToml { + /// RPC URL endpoint override. + pub rpc_url: Option, + /// Etherscan API key for contract name resolution. + pub etherscan_key: Option, + /// Default output directory for reports and SVG artifacts. + pub output_dir: Option, + /// Port for the embedded Studio visualizer dev server. + pub studio_port: Option, + /// CI gas regression and diff threshold configuration. pub diff: Option, } -#[derive(Debug, Default, Deserialize)] +/// Differential budget limits for CI/CD checks. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] pub struct DiffConfig { + /// Fail CI if total on-chain gas increases by more than this percentage. pub max_total_gas_increase_percent: Option, + /// Fail CI if execution gas (excluding intrinsic base/calldata cost) increases by > X%. pub max_execution_gas_increase_percent: Option, + /// Maximum additional EVM opcode steps allowed across a change. pub max_evm_steps_increase: Option, + /// Maximum additional Stylus cross-VM calls allowed (0 = disallow any new cross-VM calls). pub max_stylus_calls_increase: Option, } impl AtupaConfigToml { + /// Loads and parses configuration from a given file path. pub fn load(path: &Path) -> Result { let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file at {:?}", path))?; + .with_context(|| format!("Failed to read config file at {path:?}"))?; let config: Self = toml::from_str(&content) - .with_context(|| format!("Failed to parse TOML config from {:?}", path))?; + .with_context(|| format!("Failed to parse TOML config from {path:?}"))?; Ok(config) } + /// Attempts to auto-load `atupa.toml` from the current working directory. pub fn auto_load() -> Option { let path = Path::new("atupa.toml"); if path.exists() { @@ -32,4 +54,151 @@ impl AtupaConfigToml { None } } + + /// Resolves configuration from an explicit path or auto-detects `atupa.toml`. + pub fn resolve(custom_path: Option<&str>) -> Option { + if let Some(p) = custom_path { + Self::load(Path::new(p)).ok() + } else { + Self::auto_load() + } + } +} + +impl DiffConfig { + /// Evaluates EVM / Arbitrum Nitro diff metrics against configured thresholds. + pub fn evaluate_nitro( + &self, + total_gas_pct: f64, + unified_pct: f64, + evm_delta: f64, + stylus_delta: f64, + ) -> Vec { + let mut failures = Vec::new(); + + if let Some(max_total) = self.max_total_gas_increase_percent + && total_gas_pct > max_total + { + failures.push(format!( + "Total Gas increased by {total_gas_pct:.1}% (limit: {max_total:.1}%)" + )); + } + + if let Some(max_exec) = self.max_execution_gas_increase_percent + && unified_pct > max_exec + { + failures.push(format!( + "Execution Gas increased by {unified_pct:.1}% (limit: {max_exec:.1}%)" + )); + } + + if let Some(max_evm) = self.max_evm_steps_increase + && evm_delta > max_evm as f64 + { + failures.push(format!( + "EVM Steps increased by {evm_delta:.0} (limit: {max_evm})" + )); + } + + if let Some(max_stylus) = self.max_stylus_calls_increase + && stylus_delta > max_stylus as f64 + { + failures.push(format!( + "Stylus Calls increased by {stylus_delta:.0} (limit: {max_stylus})" + )); + } + + failures + } + + /// Evaluates a simple single percentage threshold against metric percentage change. + pub fn evaluate_simple_threshold( + unit_name: &str, + cost_pct: f64, + threshold_limit: f64, + ) -> Option { + if cost_pct > threshold_limit { + Some(format!( + "Total {unit_name} increased by {cost_pct:.1}% (limit: {threshold_limit:.1}%)" + )) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_full_atupa_toml() { + let toml_str = r#" + rpc_url = "http://localhost:8545" + etherscan_key = "secret_key" + output_dir = "artifacts/custom" + studio_port = 8080 + + [diff] + max_total_gas_increase_percent = 3.5 + max_execution_gas_increase_percent = 2.0 + max_evm_steps_increase = 75 + max_stylus_calls_increase = 1 + "#; + + let config: AtupaConfigToml = toml::from_str(toml_str).unwrap(); + assert_eq!(config.rpc_url.as_deref(), Some("http://localhost:8545")); + assert_eq!(config.etherscan_key.as_deref(), Some("secret_key")); + assert_eq!(config.output_dir.as_deref(), Some("artifacts/custom")); + assert_eq!(config.studio_port, Some(8080)); + + let diff = config.diff.unwrap(); + assert_eq!(diff.max_total_gas_increase_percent, Some(3.5)); + assert_eq!(diff.max_execution_gas_increase_percent, Some(2.0)); + assert_eq!(diff.max_evm_steps_increase, Some(75)); + assert_eq!(diff.max_stylus_calls_increase, Some(1)); + } + + #[test] + fn evaluates_nitro_thresholds_correctly() { + let diff = DiffConfig { + max_total_gas_increase_percent: Some(2.0), + max_execution_gas_increase_percent: Some(1.5), + max_evm_steps_increase: Some(50), + max_stylus_calls_increase: Some(0), + }; + + // Within limits -> no failures + let passes = diff.evaluate_nitro(1.8, 1.2, 40.0, 0.0); + assert!(passes.is_empty()); + + // Breaches total gas and evm steps + let failures = diff.evaluate_nitro(2.5, 1.2, 80.0, 0.0); + assert_eq!(failures.len(), 2); + assert!(failures[0].contains("Total Gas increased by 2.5%")); + assert!(failures[1].contains("EVM Steps increased by 80")); + + // Breaches stylus calls + let stylus_failure = diff.evaluate_nitro(0.0, 0.0, 0.0, 2.0); + assert_eq!(stylus_failure.len(), 1); + assert!(stylus_failure[0].contains("Stylus Calls increased by 2")); + } + + #[test] + fn evaluates_simple_threshold() { + let failure = DiffConfig::evaluate_simple_threshold("Compute Units", 10.5, 5.0); + assert_eq!( + failure, + Some("Total Compute Units increased by 10.5% (limit: 5.0%)".to_string()) + ); + + let pass = DiffConfig::evaluate_simple_threshold("Compute Units", 3.0, 5.0); + assert_eq!(pass, None); + } + + #[test] + fn load_non_existent_file_returns_error() { + let res = AtupaConfigToml::load(Path::new("/non/existent/atupa.toml")); + assert!(res.is_err()); + } } diff --git a/bin/atupa/src/utils.rs b/bin/atupa/src/utils.rs new file mode 100644 index 0000000..079bbea --- /dev/null +++ b/bin/atupa/src/utils.rs @@ -0,0 +1,263 @@ +//! Shared path resolution, normalization, and formatting utilities for CLI commands. + +use colored::*; +use indicatif::{ProgressBar, ProgressStyle}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use atupa_core::{GasCategory, TraceStep, VmKind as CoreVmKind}; +use atupa_nitro::{StitchedReport, UnifiedStep, VmKind as NitroVmKind}; +use atupa_rpc::RawStructLog; + +/// Returns a standardized artifact filepath, nesting into `artifacts//` +/// if no explicit parent directory was specified by the user. +pub fn resolve_artifact_path( + path: Option, + category: &str, + tx_hash: &str, + ext: &str, +) -> String { + let filename = path.unwrap_or_else(|| { + let short = tx_hash + .trim_start_matches("0x") + .get(..10) + .unwrap_or(tx_hash); + match ext { + "json" => format!("report_{short}.json"), + "svg" => format!("profile_{short}.svg"), + _ => format!("artifact_{short}.{ext}"), + } + }); + + let pb = PathBuf::from(&filename); + if pb + .parent() + .map(|p| p.as_os_str().is_empty()) + .unwrap_or(true) + { + let dir = format!("artifacts/{category}"); + let _ = std::fs::create_dir_all(&dir); + format!("{dir}/{filename}") + } else { + filename + } +} + +/// Normalise a transaction hash or signature. +/// EVM hashes get lowercased and `0x`-prefixed. +/// Solana signatures (Base58, >70 chars) are preserved exactly as provided. +pub fn normalise_hash(tx: &str) -> String { + let t = tx.trim(); + if t.len() > 70 { + return t.to_string(); + } + if t.to_lowercase().starts_with("0x") { + t.to_lowercase() + } else { + format!("0x{}", t.to_lowercase()) + } +} + +/// Counts the number of EVM steps in a stitched report. +pub fn evm_count(r: &StitchedReport) -> usize { + r.steps.iter().filter(|s| s.vm == NitroVmKind::Evm).count() +} + +/// Converts a flat `Vec` (from Starknet/Solana/Stellar adapters) +/// into a `StitchedReport` for studio visualizers and downstream tooling. +pub fn trace_steps_to_report( + tx: &str, + steps: Vec, + chain_vm: NitroVmKind, +) -> StitchedReport { + let mut total_gas: u64 = 0; + let mut category_costs: HashMap = HashMap::new(); + + let unified: Vec = steps + .into_iter() + .enumerate() + .map(|(i, s)| { + let cost = s.gas_cost as f64; + total_gas = total_gas.saturating_add(s.gas_cost); + let category = GasCategory::from_step(&s.op, &s.vm_kind); + *category_costs.entry(category.clone()).or_insert(0.0) += cost; + UnifiedStep { + index: i, + vm: chain_vm.clone(), + label: s.op, + gas_cost: s.gas_cost, + cost_equiv: cost, + depth: s.depth, + is_vm_boundary: false, + category, + target_address: None, + evm: None, + stylus: None, + } + }) + .collect(); + + StitchedReport { + tx_hash: tx.to_string(), + chain_id: 0, + steps: unified, + total_evm_gas: total_gas, + total_stylus_ink: 0, + vm_boundary_count: 0, + total_stylus_gas_equiv: 0.0, + total_unified_cost: total_gas as f64, + category_costs, + resolved_names: HashMap::new(), + on_chain_gas_used: None, + } +} + +/// Bridges a `RawStructLog` from RPC into a `TraceStep`. +pub fn bridge_raw_to_trace_step(raw: &RawStructLog) -> TraceStep { + TraceStep { + pc: raw.pc, + op: raw.op.clone(), + gas: raw.gas, + gas_cost: raw.gas_cost, + depth: raw.depth, + stack: raw.stack.clone(), + memory: raw.memory.clone(), + error: raw.error.clone(), + reverted: raw.error.is_some(), + vm_kind: CoreVmKind::Evm, + } +} + +/// Creates a stylized CLI terminal progress spinner. +pub fn make_spinner(msg: &str) -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + pb.set_message(msg.to_string()); + pb +} + +/// Returns a human-friendly name for standard chain IDs. +pub fn get_network_name(chain_id: u64) -> String { + match chain_id { + 1 => "Ethereum Mainnet".to_string(), + 11155111 => "Sepolia Testnet".to_string(), + 17000 => "Holesky Testnet".to_string(), + 42161 => "Arbitrum One".to_string(), + 42170 => "Arbitrum Nova".to_string(), + 421614 => "Arbitrum Sepolia".to_string(), + 8453 => "Base Mainnet".to_string(), + 84532 => "Base Sepolia".to_string(), + 10 => "Optimism".to_string(), + 11155420 => "Optimism Sepolia".to_string(), + 137 => "Polygon POS".to_string(), + 1337 | 31337 => "Local Devnet".to_string(), + 412346 => "Nitro Local Devnet".to_string(), + 0 => "Unknown Network".to_string(), + id => format!("Chain ID: {id}"), + } +} + +/// Formats a divider line of standard width. +pub fn divider(len: usize) -> String { + "─".repeat(len).dimmed().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalise_hash_handles_evm_and_solana() { + assert_eq!(normalise_hash("0xABCDEF123456"), "0xabcdef123456"); + assert_eq!(normalise_hash("ABCDEF123456"), "0xabcdef123456"); + // Solana signature: Base58 string > 70 chars + let solana_sig = + "5VERv8NMvzbJMEdV8xnrLkEaWRtSz9CosKDYj7WNXTip3MrTKEjWAFAwDxj61GbyGhBsp89uNpnv1Fs31"; + assert_eq!(normalise_hash(solana_sig), solana_sig); + } + + #[test] + fn network_name_mappings() { + assert_eq!(get_network_name(1), "Ethereum Mainnet"); + assert_eq!(get_network_name(42161), "Arbitrum One"); + assert_eq!(get_network_name(8453), "Base Mainnet"); + assert_eq!(get_network_name(999999), "Chain ID: 999999"); + } + + #[test] + fn resolve_artifact_path_nested_and_custom() { + let path = resolve_artifact_path(None, "capture", "0x1234567890abcdef", "json"); + assert!(path.contains("artifacts/capture/report_1234567890.json")); + + let custom = resolve_artifact_path( + Some("/tmp/custom_report.json".to_string()), + "capture", + "0x1234", + "json", + ); + assert_eq!(custom, "/tmp/custom_report.json"); + } + + #[test] + fn trace_steps_to_report_conversion() { + let steps = vec![ + TraceStep { + pc: 0, + op: "CALL".to_string(), + gas: 50000, + gas_cost: 2100, + depth: 1, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: CoreVmKind::Evm, + }, + TraceStep { + pc: 1, + op: "SLOAD".to_string(), + gas: 47900, + gas_cost: 2100, + depth: 1, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: CoreVmKind::Evm, + }, + ]; + + let report = trace_steps_to_report("0x1234", steps, NitroVmKind::Evm); + assert_eq!(report.steps.len(), 2); + assert_eq!(report.total_evm_gas, 4200); + assert_eq!(report.total_unified_cost, 4200.0); + } + + #[test] + fn bridge_raw_to_trace_step_conversion() { + let raw = RawStructLog { + pc: 42, + op: "SSTORE".to_string(), + gas: 100000, + gas_cost: 20000, + depth: 2, + stack: Some(vec!["0x1".to_string(), "0x2".to_string()]), + memory: None, + storage: None, + error: None, + }; + + let step = bridge_raw_to_trace_step(&raw); + assert_eq!(step.pc, 42); + assert_eq!(step.op, "SSTORE"); + assert_eq!(step.gas_cost, 20000); + assert_eq!(step.depth, 2); + assert!(!step.reverted); + } +} diff --git a/crates/atupa-aave/Cargo.toml b/crates/atupa-aave/Cargo.toml index 4d7875b..c8c85a6 100644 --- a/crates/atupa-aave/Cargo.toml +++ b/crates/atupa-aave/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-aave" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } readme = { workspace = true } @@ -16,7 +15,5 @@ categories = ["development-tools", "cryptography::cryptocurrencies"] atupa-core = { workspace = true } atupa-adapters = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } anyhow = { workspace = true } -thiserror = { workspace = true } log = { workspace = true } diff --git a/crates/atupa-aave/src/adapter.rs b/crates/atupa-aave/src/adapter.rs new file mode 100644 index 0000000..b5ce498 --- /dev/null +++ b/crates/atupa-aave/src/adapter.rs @@ -0,0 +1,113 @@ +//! [`AaveV3Adapter`] — [`ProtocolAdapter`] implementation for Aave v3 & GHO. + +use atupa_adapters::ProtocolAdapter; + +use crate::selectors::{resolve_address, resolve_selector}; + +/// Aave v3 + GHO protocol adapter — maps contract addresses and 4-byte +/// selectors to human-readable labels for flamegraph annotation and deep-trace +/// audits. +/// +/// Resolution priority (highest to lowest): +/// 1. GHO Facilitator address → `"Facilitator::*"` +/// 2. Aave Oracle address → `"Oracle::*"` +/// 3. Pool selector → `"AaveV3Pool::*"` +/// 4. GHO selector → `"GHO::*"` +#[derive(Default)] +pub struct AaveV3Adapter; + +impl ProtocolAdapter for AaveV3Adapter { + fn name(&self) -> &str { + "Aave v3 / GHO" + } + + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { + if let Some(addr) = address + && let Some(label) = resolve_address(addr) + { + return Some(label); + } + selector.and_then(resolve_selector) + } +} + +impl AaveV3Adapter { + /// Resolve a 4-byte selector string to a human-readable label without + /// requiring an adapter instance. + /// + /// Returns `None` if the selector is not found in either the Pool or GHO + /// selector tables. + pub fn resolve_selector_label(selector: &str) -> Option { + resolve_selector(selector) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_pool_selector() { + let adapter = AaveV3Adapter; + assert_eq!( + adapter.resolve_label(None, Some("0x00a718a9")), + Some("AaveV3Pool::liquidationCall".to_string()) + ); + } + + #[test] + fn resolves_gho_selector() { + let adapter = AaveV3Adapter; + assert_eq!( + adapter.resolve_label(None, Some("0x40c10f19")), + Some("GHO::mint".to_string()) + ); + } + + #[test] + fn resolves_facilitator_address() { + let adapter = AaveV3Adapter; + assert_eq!( + adapter.resolve_label(Some("0x5513224daaEABCa31af5280727878d52097afA05"), None), + Some("Facilitator::Direct Minter (Aave V3)".to_string()) + ); + } + + #[test] + fn resolves_oracle_address() { + let adapter = AaveV3Adapter; + assert_eq!( + adapter.resolve_label(Some("0x54586bE62E3c3580375aE3716C14bd2563060Ca0"), None), + Some("Oracle::Aave Price Oracle".to_string()) + ); + } + + #[test] + fn address_takes_priority_over_selector() { + // When both are provided, address resolution should win. + let adapter = AaveV3Adapter; + let label = adapter.resolve_label( + Some("0x5513224daaeabca31af5280727878d52097afa05"), + Some("0x00a718a9"), + ); + assert!(label.as_deref().unwrap_or("").starts_with("Facilitator::")); + } + + #[test] + fn returns_none_for_unknown_inputs() { + let adapter = AaveV3Adapter; + assert!(adapter.resolve_label(None, Some("0xdeadbeef")).is_none()); + assert!(adapter.resolve_label(None, None).is_none()); + } + + #[test] + fn static_resolve_selector_label() { + assert_eq!( + AaveV3Adapter::resolve_selector_label("0x9dc29fac"), + Some("GHO::burn".to_string()) + ); + assert!(AaveV3Adapter::resolve_selector_label("0xdeadbeef").is_none()); + } +} diff --git a/crates/atupa-aave/src/gho.rs b/crates/atupa-aave/src/gho.rs new file mode 100644 index 0000000..cc693e9 --- /dev/null +++ b/crates/atupa-aave/src/gho.rs @@ -0,0 +1,72 @@ +//! [`GhoSupplyMetrics`] and the per-label classification helper. + +use serde::{Deserialize, Serialize}; + +/// Aggregated GHO supply-level metrics extracted from trace steps. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct GhoSupplyMetrics { + /// Number of `mint` calls observed in the trace. + pub mint_count: u32, + /// Number of `burn` calls observed in the trace. + pub burn_count: u32, + /// Number of `updateFacilitatorBucketCapacity` calls (risk signal). + pub bucket_capacity_updates: u32, + /// Number of `distributeFeesToTreasury` calls. + pub fee_distributions: u32, +} + +/// Update [`GhoSupplyMetrics`] for a single recognized GHO label. +pub(crate) fn classify_gho_label(label: &str, metrics: &mut GhoSupplyMetrics) { + match label { + "GHO::mint" => metrics.mint_count += 1, + "GHO::burn" => metrics.burn_count += 1, + "GHO::updateFacilitatorBucketCapacity" => metrics.bucket_capacity_updates += 1, + "GHO::distributeFeesToTreasury" => metrics.fee_distributions += 1, + _ => {} + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_mint_increments_mint_count() { + let mut m = GhoSupplyMetrics::default(); + classify_gho_label("GHO::mint", &mut m); + classify_gho_label("GHO::mint", &mut m); + assert_eq!(m.mint_count, 2); + assert_eq!(m.burn_count, 0); + } + + #[test] + fn classify_burn_increments_burn_count() { + let mut m = GhoSupplyMetrics::default(); + classify_gho_label("GHO::burn", &mut m); + assert_eq!(m.burn_count, 1); + } + + #[test] + fn classify_bucket_capacity_update() { + let mut m = GhoSupplyMetrics::default(); + classify_gho_label("GHO::updateFacilitatorBucketCapacity", &mut m); + assert_eq!(m.bucket_capacity_updates, 1); + } + + #[test] + fn classify_fee_distribution() { + let mut m = GhoSupplyMetrics::default(); + classify_gho_label("GHO::distributeFeesToTreasury", &mut m); + assert_eq!(m.fee_distributions, 1); + } + + #[test] + fn classify_unknown_label_is_noop() { + let mut m = GhoSupplyMetrics::default(); + classify_gho_label("AaveV3Pool::supply", &mut m); + classify_gho_label("unknown", &mut m); + assert_eq!(m, GhoSupplyMetrics::default()); + } +} diff --git a/crates/atupa-aave/src/lib.rs b/crates/atupa-aave/src/lib.rs index 84719b0..4e3150f 100644 --- a/crates/atupa-aave/src/lib.rs +++ b/crates/atupa-aave/src/lib.rs @@ -1,477 +1,34 @@ //! # atupa-aave — DeepTracer //! //! Aave v3 & GHO protocol adapter for the Atupa EVM profiling engine. -//! Provides deep trace analysis for liquidation flows, supply/borrow -//! mechanics, and GHO stablecoin risk monitoring. - -use atupa_adapters::ProtocolAdapter; -use atupa_core::{DiffRow, ProtocolDiffReport, TraceStep}; -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Selector tables -// --------------------------------------------------------------------------- - -/// Known Aave v3 Pool function selectors → human-readable labels. -const POOL_SELECTORS: &[(&str, &str)] = &[ - ("0x617ba037", "supply"), - ("0x69328dec", "withdraw"), - ("0xa415bcad", "borrow"), - ("0x573ade81", "repay"), - ("0x563dd613", "repayWithPermit"), - ("0x2dad97d4", "repayWithATokens"), - ("0x00a718a9", "liquidationCall"), - ("0xab9c4b5d", "flashLoan"), - ("0x42b0b77c", "flashLoanSimple"), - ("0xe8eda9df", "deposit"), // v2 compat - ("0xa9059cbb", "transfer"), // ERC-20 — common inside traces - ("0x23b872dd", "transferFrom"), // ERC-20 - ("0x095ea7b3", "approve"), // ERC-20 - ("0x1e9a6950", "setUserUseReserveAsCollateral"), - ("0x02c205f0", "swapBorrowRateMode"), - ("0x1e9d0e2e", "claimRewards"), -]; - -/// Known GHO Facilitators (Ethereum Mainnet). -const GHO_FACILITATORS: &[(&str, &str)] = &[ - ( - "0x5513224daaEABCa31af5280727878d52097afA05", - "Direct Minter (Aave V3)", - ), - ( - "0xBc65ad17c5C0a2A4D159fa5a503f4992c7B545FE", - "Spark (Sky) Facilitator", - ), -]; - -/// Known Aave Oracles (Ethereum Mainnet). -const AAVE_ORACLES: &[(&str, &str)] = &[ - ( - "0x54586bE62E3c3580375aE3716C14bd2563060Ca0C2", - "Aave Price Oracle", - ), - ("0xD81E9938...?", "GHO Price Oracle"), -]; - -/// Known GHO-specific selectors. -const GHO_SELECTORS: &[(&str, &str)] = &[ - ("0x40c10f19", "mint"), - ("0x9dc29fac", "burn"), - ("0xd73dd623", "increaseAllowance"), - ("0x5d3a1f9b", "distributeFeesToTreasury"), - ("0x2e0f2625", "updateFacilitatorBucketCapacity"), - ("0xdb5a3c5e", "setVariableDebtToken"), -]; - -// --------------------------------------------------------------------------- -// Protocol Adapter implementation -// --------------------------------------------------------------------------- - -/// Enhanced Aave v3 protocol adapter — identifies Pool & GHO operations. -#[derive(Default)] -pub struct AaveV3Adapter; - -impl ProtocolAdapter for AaveV3Adapter { - fn name(&self) -> &str { - "Aave v3 / GHO" - } - - fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { - // Resolve facilitator names if address is provided - if let Some(addr) = address { - for &(known_addr, name) in GHO_FACILITATORS { - if addr.to_lowercase() == known_addr.to_lowercase() { - return Some(format!("Facilitator::{}", name)); - } - } - for &(known_addr, name) in AAVE_ORACLES { - if addr.to_lowercase() == known_addr.to_lowercase() { - return Some(format!("Oracle::{}", name)); - } - } - } - - let sel = selector?; - // Check Pool selectors first - for &(known_sel, label) in POOL_SELECTORS { - if sel == known_sel { - return Some(format!("AaveV3Pool::{label}")); - } - } - // Fall through to GHO selectors - for &(known_sel, label) in GHO_SELECTORS { - if sel == known_sel { - return Some(format!("GHO::{label}")); - } - } - None - } -} - -impl AaveV3Adapter { - /// Resolve a 4-byte selector string to a human-readable label (no instance needed). - pub fn resolve_selector_label(selector: &str) -> Option { - for &(known_sel, label) in POOL_SELECTORS { - if selector == known_sel { - return Some(format!("AaveV3Pool::{label}")); - } - } - for &(known_sel, label) in GHO_SELECTORS { - if selector == known_sel { - return Some(format!("GHO::{label}")); - } - } - None - } -} - -// --------------------------------------------------------------------------- -// Liquidation Report -// --------------------------------------------------------------------------- - -/// A human-readable breakdown of a single `liquidationCall` execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LiquidationReport { - /// Transaction hash being analyzed. - pub tx_hash: String, - /// Total gas consumed by the liquidation. - pub total_gas: u64, - /// Gas consumed by the `liquidationCall` itself. - pub liquidation_gas: u64, - /// Number of SLOAD opcodes (storage reads — proxy for oracle lookups). - pub storage_reads: u32, - /// Number of SSTORE opcodes (storage writes). - pub storage_writes: u32, - /// Number of cross-contract CALL opcodes. - pub external_calls: u32, - /// Whether the transaction reverted. - pub reverted: bool, - /// The deepest call depth reached. - pub max_depth: u16, - /// Liquidation Efficiency: (Gas Value / Debt Covered) -- lower is better. - /// (Note: Simplification for trace-only analysis). - pub liquidation_efficiency: f64, - /// Number of identified Oracle calls during the trace. - pub oracle_calls: u32, - /// Labeled call sequence extracted from the trace. - pub labeled_calls: Vec, -} - -impl LiquidationReport { - /// Returns a concise one-line summary for terminal output. - pub fn summary(&self) -> String { - format!( - "[LiquidationReport] tx={} gas={} reads={} writes={} calls={} reverted={}", - &self.tx_hash[..10], - self.total_gas, - self.storage_reads, - self.storage_writes, - self.external_calls, - self.reverted, - ) - } -} - -/// A single labeled call extracted during trace analysis. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LabeledCall { - pub depth: u16, - pub label: String, - pub gas_cost: u64, -} - -// --------------------------------------------------------------------------- -// GHO Supply Metrics -// --------------------------------------------------------------------------- - -/// Aggregated GHO supply-level metrics extracted from trace steps. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GhoSupplyMetrics { - /// Number of `mint` calls observed in the trace. - pub mint_count: u32, - /// Number of `burn` calls observed in the trace. - pub burn_count: u32, - /// Number of `updateFacilitatorBucketCapacity` calls (risk signal). - pub bucket_capacity_updates: u32, - /// Number of `distributeFeesToTreasury` calls. - pub fee_distributions: u32, -} - -// --------------------------------------------------------------------------- -// DeepTracer — Main entry point -// --------------------------------------------------------------------------- - -/// The main Aave DeepTracer entry point. Wraps the `AaveV3Adapter` and -/// provides higher-level analysis methods over raw Atupa `TraceStep` slices. -#[derive(Default)] -pub struct AaveDeepTracer { - adapter: AaveV3Adapter, -} - -impl AaveDeepTracer { - pub fn new() -> Self { - Self { - adapter: AaveV3Adapter, - } - } - - /// Analyzes a raw trace and produces a `LiquidationReport`. - pub fn analyze_liquidation( - &self, - tx_hash: &str, - steps: &[TraceStep], - ) -> anyhow::Result { - let mut storage_reads = 0u32; - let mut storage_writes = 0u32; - let mut external_calls = 0u32; - let mut oracle_calls = 0u32; - let mut max_depth = 0u16; - let mut total_gas = 0u64; - let mut liquidation_gas = 0u64; - let mut labeled_calls: Vec = Vec::new(); - let mut in_liquidation = false; - - for step in steps { - total_gas = total_gas.saturating_add(step.gas_cost); - max_depth = max_depth.max(step.depth); - - match step.op.as_str() { - "SLOAD" => storage_reads += 1, - "SSTORE" => storage_writes += 1, - "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" => { - external_calls += 1; - - // Attempt to resolve the selector and address - let selector = step - .stack - .as_ref() - .and_then(|s| s.last()) - .map(|s| s.as_str()); - - // Note: In a real trace, the address would be on the stack, - // this is a simplified simulation for the POC - let address = None; - - if let Some(label) = self.adapter.resolve_label(address, selector) { - if label.contains("liquidationCall") { - in_liquidation = true; - } - if label.contains("Oracle") { - oracle_calls += 1; - } - labeled_calls.push(LabeledCall { - depth: step.depth, - label, - gas_cost: step.gas_cost, - }); - } - } - _ => {} - } - - if in_liquidation { - liquidation_gas = liquidation_gas.saturating_add(step.gas_cost); - } - } - - let reverted = steps.last().is_some_and(|s| s.reverted); - - // Mock efficiency calculation (simplified for trace analysis) - let liquidation_efficiency = if liquidation_gas > 0 { - (liquidation_gas as f64) / 100_000.0 // Normalizing against a base gas cost - } else { - 0.0 - }; - - Ok(LiquidationReport { - tx_hash: tx_hash.to_string(), - total_gas, - liquidation_gas, - storage_reads, - storage_writes, - external_calls, - oracle_calls, - reverted, - max_depth, - liquidation_efficiency, - labeled_calls, - }) - } - - /// Scans a trace for GHO supply-level signals. - pub fn extract_gho_metrics(&self, steps: &[TraceStep]) -> GhoSupplyMetrics { - let mut metrics = GhoSupplyMetrics::default(); - - for step in steps { - if step.op != "CALL" && step.op != "STATICCALL" { - continue; - } - let selector = step - .stack - .as_ref() - .and_then(|s| s.last()) - .map(|s| s.as_str()); - - if let Some(label) = self.adapter.resolve_label(None, selector) { - match label.as_str() { - "GHO::mint" => metrics.mint_count += 1, - "GHO::burn" => metrics.burn_count += 1, - "GHO::updateFacilitatorBucketCapacity" => metrics.bucket_capacity_updates += 1, - "GHO::distributeFeesToTreasury" => metrics.fee_distributions += 1, - _ => {} - } - } - } - - metrics - } - - /// Compares two traces with full Aave protocol analysis and returns a - /// `ProtocolDiffReport` containing field-by-field deltas. - pub fn diff_reports( - &self, - base_hash: &str, - base_steps: &[TraceStep], - target_hash: &str, - target_steps: &[TraceStep], - ) -> anyhow::Result { - let base = self.analyze_liquidation(base_hash, base_steps)?; - let target = self.analyze_liquidation(target_hash, target_steps)?; - - let base_gho = self.extract_gho_metrics(base_steps); - let target_gho = self.extract_gho_metrics(target_steps); - - let rows = vec![ - DiffRow::new( - "Total Gas", - base.total_gas as f64, - target.total_gas as f64, - true, - ), - DiffRow::new( - "Liquidation Gas", - base.liquidation_gas as f64, - target.liquidation_gas as f64, - true, - ), - DiffRow::new( - "Storage Reads (SLOAD)", - base.storage_reads as f64, - target.storage_reads as f64, - true, - ), - DiffRow::new( - "Storage Writes (SSTORE)", - base.storage_writes as f64, - target.storage_writes as f64, - true, - ), - DiffRow::new( - "External Calls", - base.external_calls as f64, - target.external_calls as f64, - true, - ), - DiffRow::new( - "Oracle Calls", - base.oracle_calls as f64, - target.oracle_calls as f64, - true, - ), - DiffRow::new( - "Max Call Depth", - base.max_depth as f64, - target.max_depth as f64, - true, - ), - DiffRow::new( - "Liq. Efficiency", - base.liquidation_efficiency, - target.liquidation_efficiency, - true, - ), - DiffRow::new( - "GHO Mint Count", - base_gho.mint_count as f64, - target_gho.mint_count as f64, - false, - ), - DiffRow::new( - "GHO Burn Count", - base_gho.burn_count as f64, - target_gho.burn_count as f64, - false, - ), - ]; - - Ok(ProtocolDiffReport { - protocol: "Aave v3 / GHO".to_string(), - rows, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_call_step(op: &str, selector: &str, gas_cost: u64) -> TraceStep { - TraceStep { - op: op.to_string(), - gas: 1_000_000, - gas_cost, - depth: 1, - stack: Some(vec![selector.to_string()]), - ..Default::default() - } - } - - #[test] - fn adapter_resolves_liquidation_call() { - let adapter = AaveV3Adapter; - let label = adapter.resolve_label(None, Some("0x00a718a9")); - assert_eq!(label, Some("AaveV3Pool::liquidationCall".to_string())); - } - - #[test] - fn adapter_resolves_gho_mint() { - let adapter = AaveV3Adapter; - let label = adapter.resolve_label(None, Some("0x40c10f19")); - assert_eq!(label, Some("GHO::mint".to_string())); - } +//! +//! Provides deep trace analysis for liquidation flows, supply/borrow mechanics, +//! and GHO stablecoin risk monitoring. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`selectors`] | Selector/address tables and shared lookup helpers | +//! | [`adapter`] | [`AaveV3Adapter`] — [`ProtocolAdapter`] implementation | +//! | [`gho`] | [`GhoSupplyMetrics`] and GHO label classifier | +//! | [`report`] | [`LiquidationReport`], [`LabeledCall`] | +//! | [`tracer`] | [`AaveDeepTracer`] — main analysis entry point | +//! +//! ## Re-exports +//! +//! All public types are re-exported from the crate root so downstream crates +//! can use `atupa_aave::AaveDeepTracer` etc. without knowing the module layout. - #[test] - fn adapter_returns_none_for_unknown_selector() { - let adapter = AaveV3Adapter; - assert!(adapter.resolve_label(None, Some("0xdeadbeef")).is_none()); - } +pub mod adapter; +pub mod gho; +pub mod report; +pub mod selectors; +pub mod tracer; - #[test] - fn liquidation_report_detects_storage_ops() { - let tracer = AaveDeepTracer::new(); - let steps = vec![ - make_call_step("SLOAD", "", 800), - make_call_step("SLOAD", "", 800), - make_call_step("SSTORE", "", 20_000), - make_call_step("CALL", "0x00a718a9", 5_000), - ]; - let report = tracer.analyze_liquidation("0xdeadbeef", &steps).unwrap(); - assert_eq!(report.storage_reads, 2); - assert_eq!(report.storage_writes, 1); - assert_eq!(report.external_calls, 1); - assert!(!report.reverted); - } +// ── Flat re-exports ─────────────────────────────────────────────────────────── - #[test] - fn gho_metrics_extraction() { - let tracer = AaveDeepTracer::new(); - let steps = vec![ - make_call_step("CALL", "0x40c10f19", 5_000), // mint - make_call_step("CALL", "0x40c10f19", 5_000), // mint - make_call_step("CALL", "0x9dc29fac", 3_000), // burn - ]; - let metrics = tracer.extract_gho_metrics(&steps); - assert_eq!(metrics.mint_count, 2); - assert_eq!(metrics.burn_count, 1); - } -} +pub use adapter::AaveV3Adapter; +pub use gho::GhoSupplyMetrics; +pub use report::{LabeledCall, LiquidationReport}; +pub use tracer::AaveDeepTracer; diff --git a/crates/atupa-aave/src/report.rs b/crates/atupa-aave/src/report.rs new file mode 100644 index 0000000..d5d30df --- /dev/null +++ b/crates/atupa-aave/src/report.rs @@ -0,0 +1,228 @@ +//! [`LiquidationReport`], [`LabeledCall`], and the [`LiquidationAccumulator`] +//! that builds a report by processing trace steps one at a time. + +use atupa_adapters::ProtocolAdapter; +use atupa_core::TraceStep; +use serde::{Deserialize, Serialize}; + +use crate::adapter::AaveV3Adapter; +use crate::selectors::{LIQUIDATION_EFFICIENCY_BASE, is_call_opcode, selector_from_stack}; + +// ─── Report Structures ──────────────────────────────────────────────────────── + +/// A human-readable breakdown of a single `liquidationCall` execution. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LiquidationReport { + /// Transaction hash being analyzed. + pub tx_hash: String, + /// Total gas consumed across all steps. + pub total_gas: u64, + /// Gas consumed after the first `liquidationCall` opcode was seen. + /// + /// > **Approximation**: all gas from the first `liquidationCall` label + /// > onwards is attributed to the liquidation. A depth-tracking approach + /// > would be more precise. + pub liquidation_gas: u64, + /// Number of `SLOAD` opcodes (proxy for oracle / state lookups). + pub storage_reads: u32, + /// Number of `SSTORE` opcodes. + pub storage_writes: u32, + /// Number of cross-contract call opcodes. + pub external_calls: u32, + /// Whether the transaction reverted. + pub reverted: bool, + /// Maximum call-stack depth reached. + pub max_depth: u16, + /// Liquidation efficiency score: `liquidation_gas / LIQUIDATION_EFFICIENCY_BASE`. + /// + /// A lower value indicates a more gas-efficient liquidation. Only meaningful + /// when `liquidation_gas > 0`. + pub liquidation_efficiency: f64, + /// Number of oracle contract calls identified in the trace. + pub oracle_calls: u32, + /// Ordered sequence of labeled calls extracted from the trace. + pub labeled_calls: Vec, +} + +impl LiquidationReport { + /// Returns a concise one-line summary for terminal output. + pub fn summary(&self) -> String { + // Use .get(..10) to avoid panicking on short/synthetic hashes. + let short_hash = self.tx_hash.get(..10).unwrap_or(&self.tx_hash); + format!( + "[LiquidationReport] tx={} gas={} reads={} writes={} calls={} reverted={}", + short_hash, + self.total_gas, + self.storage_reads, + self.storage_writes, + self.external_calls, + self.reverted, + ) + } +} + +/// A single labeled call extracted during trace analysis. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LabeledCall { + pub depth: u16, + pub label: String, + pub gas_cost: u64, +} + +// ─── Accumulator ───────────────────────────────────────────────────────────── + +/// Internal mutable accumulator used by [`crate::tracer::AaveDeepTracer`] to +/// build a [`LiquidationReport`] by processing trace steps one at a time. +/// +/// Separating the accumulation state from the public API keeps +/// `analyze_liquidation` concise and the per-step logic independently testable. +#[derive(Default)] +pub(crate) struct LiquidationAccumulator { + storage_reads: u32, + storage_writes: u32, + external_calls: u32, + oracle_calls: u32, + max_depth: u16, + total_gas: u64, + liquidation_gas: u64, + in_liquidation: bool, + labeled_calls: Vec, +} + +impl LiquidationAccumulator { + /// Incorporate a single trace step into the running totals. + pub(crate) fn process_step(&mut self, step: &TraceStep, adapter: &AaveV3Adapter) { + self.total_gas = self.total_gas.saturating_add(step.gas_cost); + self.max_depth = self.max_depth.max(step.depth); + + match step.op.as_str() { + "SLOAD" => self.storage_reads += 1, + "SSTORE" => self.storage_writes += 1, + op if is_call_opcode(op) => self.process_call_step(step, adapter), + _ => {} + } + + if self.in_liquidation { + self.liquidation_gas = self.liquidation_gas.saturating_add(step.gas_cost); + } + } + + /// Process a call-opcode step: resolve its label and update relevant counters. + /// + /// # Note on address resolution + /// + /// In a real EVM trace the callee address sits on the stack at a + /// well-known offset, but extracting it reliably requires full stack + /// reconstruction which is beyond the current POC scope. We therefore pass + /// `None` for the address and rely solely on the selector. + fn process_call_step(&mut self, step: &TraceStep, adapter: &AaveV3Adapter) { + self.external_calls += 1; + + let selector = selector_from_stack(step); + let Some(label) = adapter.resolve_label(None, selector) else { + return; + }; + + if label.contains("liquidationCall") { + self.in_liquidation = true; + } + if label.contains("Oracle") { + self.oracle_calls += 1; + } + + self.labeled_calls.push(LabeledCall { + depth: step.depth, + label, + gas_cost: step.gas_cost, + }); + } + + /// Consume the accumulator and produce the final [`LiquidationReport`]. + pub(crate) fn into_report(self, tx_hash: &str, reverted: bool) -> LiquidationReport { + let liquidation_efficiency = if self.liquidation_gas > 0 { + self.liquidation_gas as f64 / LIQUIDATION_EFFICIENCY_BASE + } else { + 0.0 + }; + + LiquidationReport { + tx_hash: tx_hash.to_string(), + total_gas: self.total_gas, + liquidation_gas: self.liquidation_gas, + storage_reads: self.storage_reads, + storage_writes: self.storage_writes, + external_calls: self.external_calls, + oracle_calls: self.oracle_calls, + reverted, + max_depth: self.max_depth, + liquidation_efficiency, + labeled_calls: self.labeled_calls, + } + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use atupa_core::TraceStep; + + fn call_step(selector: &str, gas_cost: u64) -> TraceStep { + TraceStep { + op: "CALL".to_string(), + gas_cost, + depth: 1, + stack: Some(vec![selector.to_string()]), + ..Default::default() + } + } + + #[test] + fn accumulates_storage_reads_and_writes() { + let adapter = AaveV3Adapter; + let mut acc = LiquidationAccumulator::default(); + acc.process_step(&TraceStep::evm("SLOAD", 800), &adapter); + acc.process_step(&TraceStep::evm("SLOAD", 800), &adapter); + acc.process_step(&TraceStep::evm("SSTORE", 20_000), &adapter); + let report = acc.into_report("0xabc", false); + assert_eq!(report.storage_reads, 2); + assert_eq!(report.storage_writes, 1); + assert_eq!(report.total_gas, 21_600); + } + + #[test] + fn labels_liquidation_call_and_flips_in_liquidation() { + let adapter = AaveV3Adapter; + let mut acc = LiquidationAccumulator::default(); + acc.process_step(&call_step("0x00a718a9", 5_000), &adapter); // liquidationCall + let report = acc.into_report("0xabc", false); + assert_eq!(report.labeled_calls.len(), 1); + assert_eq!(report.labeled_calls[0].label, "AaveV3Pool::liquidationCall"); + // All gas after the liquidationCall step is attributed + assert!(report.liquidation_gas > 0); + } + + #[test] + fn efficiency_is_zero_without_liquidation_gas() { + let acc = LiquidationAccumulator::default(); + let report = acc.into_report("0xabc", false); + assert_eq!(report.liquidation_efficiency, 0.0); + } + + #[test] + fn summary_is_safe_on_short_hash() { + let acc = LiquidationAccumulator::default(); + let report = acc.into_report("0x1", false); + // Must not panic + let s = report.summary(); + assert!(s.contains("0x1")); + } + + #[test] + fn reverted_flag_propagated() { + let acc = LiquidationAccumulator::default(); + let report = acc.into_report("0xabc", true); + assert!(report.reverted); + } +} diff --git a/crates/atupa-aave/src/selectors.rs b/crates/atupa-aave/src/selectors.rs new file mode 100644 index 0000000..8b39e0f --- /dev/null +++ b/crates/atupa-aave/src/selectors.rs @@ -0,0 +1,199 @@ +//! Selector and address tables for Aave v3 & GHO, with shared lookup helpers. + +use atupa_core::TraceStep; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/// Gas cost baseline used to normalise the liquidation efficiency score. +pub(crate) const LIQUIDATION_EFFICIENCY_BASE: f64 = 100_000.0; + +/// Known Aave v3 Pool function selectors → human-readable labels. +pub(crate) const POOL_SELECTORS: &[(&str, &str)] = &[ + ("0x617ba037", "supply"), + ("0x69328dec", "withdraw"), + ("0xa415bcad", "borrow"), + ("0x573ade81", "repay"), + ("0x563dd613", "repayWithPermit"), + ("0x2dad97d4", "repayWithATokens"), + ("0x00a718a9", "liquidationCall"), + ("0xab9c4b5d", "flashLoan"), + ("0x42b0b77c", "flashLoanSimple"), + ("0xe8eda9df", "deposit"), // v2 compatibility alias + ("0xa9059cbb", "transfer"), // ERC-20 — common inside traces + ("0x23b872dd", "transferFrom"), // ERC-20 + ("0x095ea7b3", "approve"), // ERC-20 + ("0x1e9a6950", "setUserUseReserveAsCollateral"), + ("0x02c205f0", "swapBorrowRateMode"), + ("0x1e9d0e2e", "claimRewards"), +]; + +/// Known GHO-specific function selectors → human-readable labels. +pub(crate) const GHO_SELECTORS: &[(&str, &str)] = &[ + ("0x40c10f19", "mint"), + ("0x9dc29fac", "burn"), + ("0xd73dd623", "increaseAllowance"), + ("0x5d3a1f9b", "distributeFeesToTreasury"), + ("0x2e0f2625", "updateFacilitatorBucketCapacity"), + ("0xdb5a3c5e", "setVariableDebtToken"), +]; + +/// Known GHO Facilitator addresses (Ethereum Mainnet, stored lowercase). +pub(crate) const GHO_FACILITATORS: &[(&str, &str)] = &[ + ( + "0x5513224daaeabca31af5280727878d52097afa05", + "Direct Minter (Aave V3)", + ), + ( + "0xbc65ad17c5c0a2a4d159fa5a503f4992c7b545fe", + "Spark (Sky) Facilitator", + ), +]; + +/// Known Aave oracle addresses (Ethereum Mainnet, stored lowercase). +pub(crate) const AAVE_ORACLES: &[(&str, &str)] = &[ + ( + "0x54586be62e3c3580375ae3716c14bd2563060ca0", + "Aave Price Oracle", + ), + ( + "0x3f12643d3f6f874d39c2a4c9f2cd6f2dbac877f", + "GHO Price Oracle", + ), +]; + +// ─── Lookup helpers ─────────────────────────────────────────────────────────── + +/// Look up a contract address in the facilitator and oracle tables. +/// +/// The comparison is case-insensitive; all stored entries are already lowercase. +pub(crate) fn resolve_address(addr: &str) -> Option { + let lower = addr.to_lowercase(); + + for &(known, name) in GHO_FACILITATORS { + if lower == known { + return Some(format!("Facilitator::{name}")); + } + } + for &(known, name) in AAVE_ORACLES { + if lower == known { + return Some(format!("Oracle::{name}")); + } + } + None +} + +/// Look up a 4-byte selector in the Pool and GHO selector tables. +pub(crate) fn resolve_selector(selector: &str) -> Option { + for &(known, label) in POOL_SELECTORS { + if selector == known { + return Some(format!("AaveV3Pool::{label}")); + } + } + for &(known, label) in GHO_SELECTORS { + if selector == known { + return Some(format!("GHO::{label}")); + } + } + None +} + +/// Returns `true` for EVM opcodes that initiate a new call frame. +#[inline] +pub(crate) fn is_call_opcode(op: &str) -> bool { + matches!(op, "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE") +} + +/// Extract the top-of-stack value from a [`TraceStep`] as a selector string. +pub(crate) fn selector_from_stack(step: &TraceStep) -> Option<&str> { + step.stack.as_ref()?.last().map(String::as_str) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_selector_pool() { + assert_eq!( + resolve_selector("0x617ba037"), + Some("AaveV3Pool::supply".to_string()) + ); + } + + #[test] + fn resolve_selector_gho() { + assert_eq!( + resolve_selector("0x40c10f19"), + Some("GHO::mint".to_string()) + ); + } + + #[test] + fn resolve_selector_unknown_returns_none() { + assert!(resolve_selector("0xdeadbeef").is_none()); + } + + #[test] + fn resolve_address_facilitator_case_insensitive() { + let mixed = "0x5513224daaEABCa31af5280727878d52097afA05"; + assert_eq!( + resolve_address(mixed), + Some("Facilitator::Direct Minter (Aave V3)".to_string()) + ); + } + + #[test] + fn resolve_address_oracle() { + let addr = "0x54586bE62E3c3580375aE3716C14bd2563060Ca0"; + assert_eq!( + resolve_address(addr), + Some("Oracle::Aave Price Oracle".to_string()) + ); + } + + #[test] + fn resolve_address_unknown_returns_none() { + assert!(resolve_address("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef").is_none()); + } + + #[test] + fn is_call_opcode_detects_all_variants() { + for op in &["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE"] { + assert!(is_call_opcode(op), "{op} should be a call opcode"); + } + } + + #[test] + fn is_call_opcode_rejects_non_calls() { + for op in &["SLOAD", "SSTORE", "ADD", "CREATE", "JUMPDEST"] { + assert!(!is_call_opcode(op), "{op} should not be a call opcode"); + } + } + + #[test] + fn selector_from_stack_returns_last_element() { + let step = atupa_core::TraceStep { + op: "CALL".to_string(), + stack: Some(vec!["0xaaaa".to_string(), "0x617ba037".to_string()]), + ..Default::default() + }; + assert_eq!(selector_from_stack(&step), Some("0x617ba037")); + } + + #[test] + fn selector_from_stack_returns_none_for_empty_stack() { + let step = atupa_core::TraceStep { + stack: Some(vec![]), + ..Default::default() + }; + assert!(selector_from_stack(&step).is_none()); + } + + #[test] + fn selector_from_stack_returns_none_when_no_stack() { + let step = atupa_core::TraceStep::default(); + assert!(selector_from_stack(&step).is_none()); + } +} diff --git a/crates/atupa-aave/src/tracer.rs b/crates/atupa-aave/src/tracer.rs new file mode 100644 index 0000000..c6b56ca --- /dev/null +++ b/crates/atupa-aave/src/tracer.rs @@ -0,0 +1,263 @@ +//! [`AaveDeepTracer`] — main entry point for Aave v3 trace analysis. + +use atupa_adapters::ProtocolAdapter; +use atupa_core::{DiffRow, ProtocolDiffReport, TraceStep}; + +use crate::adapter::AaveV3Adapter; +use crate::gho::{GhoSupplyMetrics, classify_gho_label}; +use crate::report::{LiquidationAccumulator, LiquidationReport}; +use crate::selectors::is_call_opcode; +use crate::selectors::selector_from_stack; + +/// The main Aave DeepTracer — wraps [`AaveV3Adapter`] and provides higher-level +/// analysis methods over raw [`TraceStep`] slices. +#[derive(Default)] +pub struct AaveDeepTracer { + adapter: AaveV3Adapter, +} + +impl AaveDeepTracer { + pub fn new() -> Self { + Self::default() + } + + /// Analyze a raw trace and produce a [`LiquidationReport`]. + pub fn analyze_liquidation( + &self, + tx_hash: &str, + steps: &[TraceStep], + ) -> anyhow::Result { + let mut acc = LiquidationAccumulator::default(); + for step in steps { + acc.process_step(step, &self.adapter); + } + let reverted = steps.last().is_some_and(|s| s.reverted); + Ok(acc.into_report(tx_hash, reverted)) + } + + /// Scan a trace for GHO supply-level signals. + pub fn extract_gho_metrics(&self, steps: &[TraceStep]) -> GhoSupplyMetrics { + let mut metrics = GhoSupplyMetrics::default(); + for step in steps.iter().filter(|s| is_call_opcode(&s.op)) { + let selector = selector_from_stack(step); + if let Some(label) = self.adapter.resolve_label(None, selector) { + classify_gho_label(&label, &mut metrics); + } + } + metrics + } + + /// Compare two traces with full Aave protocol analysis and return a + /// [`ProtocolDiffReport`] with field-by-field deltas. + pub fn diff_reports( + &self, + base_hash: &str, + base_steps: &[TraceStep], + target_hash: &str, + target_steps: &[TraceStep], + ) -> anyhow::Result { + let base = self.analyze_liquidation(base_hash, base_steps)?; + let target = self.analyze_liquidation(target_hash, target_steps)?; + let base_gho = self.extract_gho_metrics(base_steps); + let target_gho = self.extract_gho_metrics(target_steps); + Ok(ProtocolDiffReport { + protocol: "Aave v3 / GHO".to_string(), + rows: build_diff_rows(&base, &target, &base_gho, &target_gho), + }) + } +} + +// ─── Private helper ─────────────────────────────────────────────────────────── + +/// Construct the ordered list of [`DiffRow`]s for a protocol diff report. +fn build_diff_rows( + base: &LiquidationReport, + target: &LiquidationReport, + base_gho: &GhoSupplyMetrics, + target_gho: &GhoSupplyMetrics, +) -> Vec { + vec![ + DiffRow::new( + "Total Gas", + base.total_gas as f64, + target.total_gas as f64, + true, + ), + DiffRow::new( + "Liquidation Gas", + base.liquidation_gas as f64, + target.liquidation_gas as f64, + true, + ), + DiffRow::new( + "Storage Reads (SLOAD)", + base.storage_reads as f64, + target.storage_reads as f64, + true, + ), + DiffRow::new( + "Storage Writes (SSTORE)", + base.storage_writes as f64, + target.storage_writes as f64, + true, + ), + DiffRow::new( + "External Calls", + base.external_calls as f64, + target.external_calls as f64, + true, + ), + DiffRow::new( + "Oracle Calls", + base.oracle_calls as f64, + target.oracle_calls as f64, + true, + ), + DiffRow::new( + "Max Call Depth", + base.max_depth as f64, + target.max_depth as f64, + true, + ), + DiffRow::new( + "Liq. Efficiency", + base.liquidation_efficiency, + target.liquidation_efficiency, + true, + ), + DiffRow::new( + "GHO Mint Count", + base_gho.mint_count as f64, + target_gho.mint_count as f64, + false, + ), + DiffRow::new( + "GHO Burn Count", + base_gho.burn_count as f64, + target_gho.burn_count as f64, + false, + ), + ] +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use atupa_core::TraceStep; + + fn call_step(selector: &str, gas_cost: u64) -> TraceStep { + TraceStep { + op: "CALL".to_string(), + gas_cost, + depth: 1, + stack: Some(vec![selector.to_string()]), + ..Default::default() + } + } + + // ── analyze_liquidation ─────────────────────────────────────────────────── + + #[test] + fn detects_storage_ops_and_external_call() { + let tracer = AaveDeepTracer::new(); + let steps = vec![ + TraceStep::evm("SLOAD", 800), + TraceStep::evm("SLOAD", 800), + TraceStep::evm("SSTORE", 20_000), + call_step("0x00a718a9", 5_000), // liquidationCall + ]; + let report = tracer.analyze_liquidation("0xdeadbeef", &steps).unwrap(); + assert_eq!(report.storage_reads, 2); + assert_eq!(report.storage_writes, 1); + assert_eq!(report.external_calls, 1); + assert_eq!(report.total_gas, 800 + 800 + 20_000 + 5_000); + assert!(!report.reverted); + } + + #[test] + fn counts_labeled_calls_in_order() { + let tracer = AaveDeepTracer::new(); + let steps = vec![ + call_step("0x00a718a9", 5_000), // liquidationCall + call_step("0x617ba037", 3_000), // supply + ]; + let report = tracer.analyze_liquidation("0xabc", &steps).unwrap(); + assert_eq!(report.labeled_calls.len(), 2); + assert_eq!(report.labeled_calls[0].label, "AaveV3Pool::liquidationCall"); + assert_eq!(report.labeled_calls[1].label, "AaveV3Pool::supply"); + } + + #[test] + fn reverted_trace_sets_flag() { + let tracer = AaveDeepTracer::new(); + let mut step = TraceStep::evm("REVERT", 0); + step.reverted = true; + let report = tracer.analyze_liquidation("0xabc", &[step]).unwrap(); + assert!(report.reverted); + } + + // ── extract_gho_metrics ─────────────────────────────────────────────────── + + #[test] + fn extracts_mint_and_burn_counts() { + let tracer = AaveDeepTracer::new(); + let steps = vec![ + call_step("0x40c10f19", 5_000), // mint + call_step("0x40c10f19", 5_000), // mint + call_step("0x9dc29fac", 3_000), // burn + ]; + let metrics = tracer.extract_gho_metrics(&steps); + assert_eq!(metrics.mint_count, 2); + assert_eq!(metrics.burn_count, 1); + } + + #[test] + fn ignores_non_call_opcodes_for_gho_metrics() { + let tracer = AaveDeepTracer::new(); + let steps = vec![TraceStep { + op: "SLOAD".to_string(), + stack: Some(vec!["0x40c10f19".to_string()]), + ..Default::default() + }]; + let metrics = tracer.extract_gho_metrics(&steps); + assert_eq!(metrics.mint_count, 0); + } + + // ── diff_reports ────────────────────────────────────────────────────────── + + #[test] + fn diff_produces_10_rows_with_correct_protocol_name() { + let tracer = AaveDeepTracer::new(); + let base = vec![ + TraceStep::evm("SLOAD", 800), + TraceStep::evm("SSTORE", 20_000), + ]; + let target = vec![TraceStep::evm("SLOAD", 800)]; + let report = tracer + .diff_reports("0xbase", &base, "0xtarget", &target) + .unwrap(); + assert_eq!(report.protocol, "Aave v3 / GHO"); + assert_eq!(report.rows.len(), 10); + } + + #[test] + fn diff_detects_storage_write_regression() { + let tracer = AaveDeepTracer::new(); + let base = vec![TraceStep::evm("SSTORE", 20_000)]; + let target = vec![ + TraceStep::evm("SSTORE", 20_000), + TraceStep::evm("SSTORE", 20_000), + ]; + let report = tracer + .diff_reports("0xbase", &base, "0xtarget", &target) + .unwrap(); + let write_row = report + .rows + .iter() + .find(|r| r.metric == "Storage Writes (SSTORE)") + .unwrap(); + assert!(write_row.is_regression()); + } +} diff --git a/crates/atupa-adapters/Cargo.toml b/crates/atupa-adapters/Cargo.toml index e68db07..7d28d2b 100644 --- a/crates/atupa-adapters/Cargo.toml +++ b/crates/atupa-adapters/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-adapters" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -14,7 +13,3 @@ categories = { workspace = true } [dependencies] atupa-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } -log = { workspace = true } diff --git a/crates/atupa-adapters/src/erc20.rs b/crates/atupa-adapters/src/erc20.rs new file mode 100644 index 0000000..0074bed --- /dev/null +++ b/crates/atupa-adapters/src/erc20.rs @@ -0,0 +1,104 @@ +//! Built-in [`Erc20Adapter`] for identifying standard ERC-20 / ERC-721 token calls. + +use crate::traits::ProtocolAdapter; + +/// Common ERC-20, ERC-721, and ERC-2612 4-byte function selectors. +pub const ERC20_SELECTORS: &[(&str, &str)] = &[ + ("0xa9059cbb", "transfer"), + ("0x23b872dd", "transferFrom"), + ("0x095ea7b3", "approve"), + ("0x70a08231", "balanceOf"), + ("0xdd62ed3e", "allowance"), + ("0x18160ddd", "totalSupply"), + ("0x313ce567", "decimals"), + ("0x06fdde03", "name"), + ("0x95d89b41", "symbol"), + ("0x40c10f19", "mint"), + ("0x42966c68", "burn"), + ("0xd505accf", "permit"), +]; + +/// Identifies standard ERC-20, ERC-721, and permit calls in EVM execution traces. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Erc20Adapter; + +impl Erc20Adapter { + /// Create a new [`Erc20Adapter`]. + pub fn new() -> Self { + Self + } + + /// Resolve a 4-byte selector string directly to an ERC-20 method name. + pub fn resolve_erc20_selector(selector: &str) -> Option<&'static str> { + let selector = selector.trim(); + for &(known_sel, label) in ERC20_SELECTORS { + if selector.eq_ignore_ascii_case(known_sel) { + return Some(label); + } + } + None + } +} + +impl ProtocolAdapter for Erc20Adapter { + fn name(&self) -> &str { + "ERC-20" + } + + fn resolve_label(&self, _address: Option<&str>, selector: Option<&str>) -> Option { + let sel = selector?; + let label = Self::resolve_erc20_selector(sel)?; + Some(format!("ERC20::{label}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapter_name() { + let adapter = Erc20Adapter::new(); + assert_eq!(adapter.name(), "ERC-20"); + } + + #[test] + fn resolves_erc20_selectors() { + let adapter = Erc20Adapter; + assert_eq!( + adapter.resolve_label(None, Some("0xa9059cbb")), + Some("ERC20::transfer".to_string()) + ); + assert_eq!( + adapter.resolve_label(None, Some("0x23b872dd")), + Some("ERC20::transferFrom".to_string()) + ); + assert_eq!( + adapter.resolve_label(None, Some("0x095ea7b3")), + Some("ERC20::approve".to_string()) + ); + assert_eq!( + adapter.resolve_label(None, Some("0x70a08231")), + Some("ERC20::balanceOf".to_string()) + ); + } + + #[test] + fn static_resolver_is_case_insensitive() { + assert_eq!( + Erc20Adapter::resolve_erc20_selector("0xA9059CBB"), + Some("transfer") + ); + assert_eq!( + Erc20Adapter::resolve_erc20_selector(" 0x095ea7b3 "), + Some("approve") + ); + } + + #[test] + fn returns_none_for_unknown_selector() { + let adapter = Erc20Adapter; + assert_eq!(adapter.resolve_label(None, Some("0xdeadbeef")), None); + assert_eq!(adapter.resolve_label(None, None), None); + } +} diff --git a/crates/atupa-adapters/src/lib.rs b/crates/atupa-adapters/src/lib.rs index 55ebec2..a2b7a12 100644 --- a/crates/atupa-adapters/src/lib.rs +++ b/crates/atupa-adapters/src/lib.rs @@ -1,162 +1,34 @@ -pub trait ProtocolAdapter { - /// The name of the protocol (e.g., "Uniswap v4"). - fn name(&self) -> &str; - - /// Resolves a combination of target address and function selector into a human-readable label. - fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option; -} - -/// Adapter specifically for identifying Uniswap v4 Hooks -pub struct UniswapV4Adapter; - -impl ProtocolAdapter for UniswapV4Adapter { - fn name(&self) -> &str { - "Uniswap v4" - } - - fn resolve_label(&self, _address: Option<&str>, selector: Option<&str>) -> Option { - let sel = selector?; - // Uniswap v4 Hook standard interface selectors - let label = match sel { - "0x18a9d381" => "beforeInitialize", - "0x999dea5d" => "afterInitialize", - "0x910746f2" => "beforeAddLiquidity", - "0xefd81287" => "afterAddLiquidity", - "0xd7386be3" => "beforeRemoveLiquidity", - "0x1efe5f9e" => "afterRemoveLiquidity", - "0xe82c3b75" => "beforeSwap", - "0x14d6eaec" => "afterSwap", - "0xa3d03227" => "beforeDonate", - "0x0df2d576" => "afterDonate", - _ => return None, - }; - - Some(format!("Uniswapv4: {}", label)) - } -} - -/// Adapter specifically for identifying Aave v3 Pool operations -pub struct AaveV3Adapter; - -impl ProtocolAdapter for AaveV3Adapter { - fn name(&self) -> &str { - "Aave v3" - } - - fn resolve_label(&self, _address: Option<&str>, selector: Option<&str>) -> Option { - let sel = selector?; - // Aave v3 Pool interface selectors - let label = match sel { - "0x617ba037" => "supply", - "0x69328dec" => "withdraw", - "0xa415bcad" => "borrow", - "0x573ade81" => "repay", - "0x00a718a9" => "liquidationCall", - "0xab9c4b5d" => "flashLoan", - "0x42b0b77c" => "flashLoanSimple", - _ => return None, - }; - - Some(format!("Aave: {}", label)) - } -} - -/// Adapter specifically for identifying Lido stETH operations -pub struct LidoAdapter; - -impl ProtocolAdapter for LidoAdapter { - fn name(&self) -> &str { - "Lido stETH" - } - - fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { - // Known Lido protocol contract addresses (Mainnet) - const LIDO_ADDRESSES: &[(&str, &str)] = &[ - ( - "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", - "stETH (Lido Core)", - ), - ( - "0x55032650b14df07b85bF18A3a3eC8E0Af2e028d5", - "NodeOperatorsRegistry", - ), - ("0x442af752419395f27ed54A848524a30028962bb2", "LidoOracle"), - ( - "0x889edC2Bf57978ed079b851D273218ee42a2b349", - "WithdrawalQueue", - ), - ("0x852f970761d74367f33B6C2e309a29D681E2F16a", "LegacyOracle"), - ("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", "wstETH"), - ]; - - if let Some(addr) = address { - for &(known_addr, name) in LIDO_ADDRESSES { - if addr.to_lowercase() == known_addr.to_lowercase() { - return Some(format!("Lido::{}", name)); - } - } - } - - let sel = selector?; - // Selectors for major Lido protocol operations - const LIDO_SELECTORS: &[(&str, &str)] = &[ - ("0xa1903eab", "submit"), - ("0xea598cb0", "requestWithdrawals"), - ("0x826a73d6", "requestWithdrawalsWithPermit"), - ("0xe35ea9a5", "claimWithdrawals"), - ("0x8b6ca260", "handleOracleReport"), - ("0x39ba163b", "transferShares"), - ("0x4dbcaef1", "transferSharesFrom"), - ("0xa9059cbb", "transfer"), - ("0x095ea7b3", "approve"), - ("0x0a19ea81", "wrap"), - ("0x1dfab2e1", "unwrap"), - ]; - - for &(known_sel, label) in LIDO_SELECTORS { - if sel.contains(known_sel) { - return Some(format!("stETH::{label}")); - } - } - None - } -} - -/// The registry holding all known protocol adapters. -pub struct AdapterRegistry { - adapters: Vec>, -} - -impl AdapterRegistry { - /// Initialize a new registry pre-loaded with all supported adapters. - pub fn new() -> Self { - let mut registry = Self { - adapters: Vec::new(), - }; - registry.register(Box::new(UniswapV4Adapter)); - registry.register(Box::new(AaveV3Adapter)); - registry.register(Box::new(LidoAdapter)); - registry - } - - /// Register a custom adapter - pub fn register(&mut self, adapter: Box) { - self.adapters.push(adapter); - } - - /// Iterates through adapters to find a descriptive label for the call. - pub fn resolve(&self, address: Option<&str>, selector: Option<&str>) -> Option { - for adapter in &self.adapters { - if let Some(label) = adapter.resolve_label(address, selector) { - return Some(label); - } - } - None - } -} - -impl Default for AdapterRegistry { - fn default() -> Self { - Self::new() - } -} +//! # atupa-adapters +//! +//! Protocol adapter framework and registry for the Atupa execution tracer. +//! +//! Protocol adapters resolve raw contract addresses and 4-byte EVM function +//! selectors into human-readable labels (e.g. `"Uniswap v4: beforeSwap"`, +//! `"ERC20::transfer"`, `"AaveV3Pool::liquidationCall"`). +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`traits`] | The core [`ProtocolAdapter`] trait and default helpers | +//! | [`registry`] | The dynamic [`AdapterRegistry`] for runtime adapter resolution | +//! | [`uniswap_v4`] | Built-in [`UniswapV4Adapter`] for Uniswap v4 hook interfaces | +//! | [`erc20`] | Built-in [`Erc20Adapter`] for standard token operations | +//! +//! ## Re-exports +//! +//! All public types are re-exported from the crate root so downstream crates +//! can use `atupa_adapters::ProtocolAdapter` and `atupa_adapters::AdapterRegistry` +//! directly. + +pub mod erc20; +pub mod registry; +pub mod traits; +pub mod uniswap_v4; + +// ── Flat re-exports ─────────────────────────────────────────────────────────── + +pub use erc20::Erc20Adapter; +pub use registry::AdapterRegistry; +pub use traits::ProtocolAdapter; +pub use uniswap_v4::UniswapV4Adapter; diff --git a/crates/atupa-adapters/src/registry.rs b/crates/atupa-adapters/src/registry.rs new file mode 100644 index 0000000..fe27f84 --- /dev/null +++ b/crates/atupa-adapters/src/registry.rs @@ -0,0 +1,263 @@ +//! Runtime registry for managing and querying active [`ProtocolAdapter`] instances. + +use crate::traits::ProtocolAdapter; +use crate::uniswap_v4::UniswapV4Adapter; + +/// A runtime registry of [`ProtocolAdapter`] instances. +/// +/// Adapters are checked sequentially in the order they were registered. The +/// first adapter to return a `Some(label)` for a given address and/or selector +/// provides the resolved label. +/// +/// # Examples +/// +/// ```rust +/// use atupa_adapters::{AdapterRegistry, UniswapV4Adapter, Erc20Adapter}; +/// +/// // Create a default registry (pre-loaded with UniswapV4Adapter) +/// let mut registry = AdapterRegistry::default(); +/// registry.register_typed(Erc20Adapter); +/// +/// // Resolve a Uniswap v4 Hook selector +/// let label = registry.resolve(None, Some("0xe82c3b75")); +/// assert_eq!(label, Some("Uniswapv4: beforeSwap".to_string())); +/// +/// // Resolve an ERC-20 transfer +/// let label = registry.resolve(None, Some("0xa9059cbb")); +/// assert_eq!(label, Some("ERC20::transfer".to_string())); +/// ``` +pub struct AdapterRegistry { + adapters: Vec>, +} + +impl Default for AdapterRegistry { + /// Pre-loads [`UniswapV4Adapter`]. Protocol-specific adapters (Aave, Lido) + /// must be registered separately to avoid pulling in their crates as + /// transitive dependencies. + fn default() -> Self { + let mut registry = Self::empty(); + registry.register_typed(UniswapV4Adapter); + registry + } +} + +impl AdapterRegistry { + /// Creates an empty registry with no adapters loaded. + pub fn empty() -> Self { + Self { + adapters: Vec::new(), + } + } + + /// Creates a new registry with default built-in adapters loaded. + /// + /// Alias for [`AdapterRegistry::default()`]. + pub fn new() -> Self { + Self::default() + } + + /// Register a boxed [`ProtocolAdapter`]. + pub fn register(&mut self, adapter: Box) { + self.adapters.push(adapter); + } + + /// Register a typed [`ProtocolAdapter`] without needing explicit `Box::new`. + pub fn register_typed(&mut self, adapter: T) { + self.adapters.push(Box::new(adapter)); + } + + /// Builder pattern: attach an adapter and return `self`. + pub fn with_adapter(mut self, adapter: T) -> Self { + self.register_typed(adapter); + self + } + + /// Builder pattern: attach a boxed adapter and return `self`. + pub fn with_boxed_adapter(mut self, adapter: Box) -> Self { + self.register(adapter); + self + } + + /// Walk every registered adapter and return the first label match found. + pub fn resolve(&self, address: Option<&str>, selector: Option<&str>) -> Option { + for adapter in &self.adapters { + if let Some(label) = adapter.resolve_label(address, selector) { + return Some(label); + } + } + None + } + + /// Resolve a label using only a 4-byte function selector. + pub fn resolve_selector(&self, selector: &str) -> Option { + self.resolve(None, Some(selector)) + } + + /// Resolve a label using only a contract address. + pub fn resolve_address(&self, address: &str) -> Option { + self.resolve(Some(address), None) + } + + /// Returns the number of currently registered adapters. + pub fn len(&self) -> usize { + self.adapters.len() + } + + /// Returns `true` if no adapters are registered. + pub fn is_empty(&self) -> bool { + self.adapters.is_empty() + } + + /// Clear all registered adapters from the registry. + pub fn clear(&mut self) { + self.adapters.clear(); + } + + /// Returns `true` if an adapter with the specified name is currently registered. + pub fn contains(&self, name: &str) -> bool { + self.adapters.iter().any(|a| a.name() == name) + } + + /// Returns an iterator over references to all registered adapters. + pub fn iter(&self) -> impl Iterator { + self.adapters.iter().map(|b| b.as_ref()) + } + + /// Returns the names of all currently registered adapters in order. + pub fn adapter_names(&self) -> Vec<&str> { + self.adapters.iter().map(|a| a.name()).collect() + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::erc20::Erc20Adapter; + + struct MockCustomAdapter; + + impl ProtocolAdapter for MockCustomAdapter { + fn name(&self) -> &str { + "CustomProtocol" + } + + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { + if let Some(addr) = address + && addr == "0x1234" + { + return Some("Custom::Target".to_string()); + } + if let Some(sel) = selector + && sel == "0x9999" + { + return Some("Custom::action".to_string()); + } + None + } + } + + #[test] + fn default_registry_contains_uniswap_v4() { + let registry = AdapterRegistry::default(); + assert_eq!(registry.len(), 1); + assert!(!registry.is_empty()); + assert!(registry.contains("Uniswap v4")); + assert_eq!(registry.adapter_names(), vec!["Uniswap v4"]); + } + + #[test] + fn empty_registry() { + let registry = AdapterRegistry::empty(); + assert_eq!(registry.len(), 0); + assert!(registry.is_empty()); + assert_eq!(registry.adapter_names().len(), 0); + assert_eq!(registry.resolve(None, Some("0x18a9d381")), None); + } + + #[test] + fn register_typed_and_boxed() { + let mut registry = AdapterRegistry::empty(); + registry.register_typed(UniswapV4Adapter); + registry.register(Box::new(MockCustomAdapter)); + + assert_eq!(registry.len(), 2); + assert_eq!( + registry.adapter_names(), + vec!["Uniswap v4", "CustomProtocol"] + ); + assert_eq!( + registry.resolve_selector("0x9999"), + Some("Custom::action".to_string()) + ); + assert_eq!( + registry.resolve_address("0x1234"), + Some("Custom::Target".to_string()) + ); + } + + #[test] + fn builder_pattern_chaining() { + let registry = AdapterRegistry::empty() + .with_adapter(UniswapV4Adapter) + .with_adapter(Erc20Adapter) + .with_boxed_adapter(Box::new(MockCustomAdapter)); + + assert_eq!(registry.len(), 3); + assert_eq!( + registry.adapter_names(), + vec!["Uniswap v4", "ERC-20", "CustomProtocol"] + ); + } + + #[test] + fn resolution_order_priority() { + struct OverrideAdapter; + impl ProtocolAdapter for OverrideAdapter { + fn name(&self) -> &str { + "Override" + } + fn resolve_label( + &self, + _address: Option<&str>, + selector: Option<&str>, + ) -> Option { + if selector == Some("0x18a9d381") { + Some("Overridden!".to_string()) + } else { + None + } + } + } + + // Register OverrideAdapter BEFORE UniswapV4Adapter + let mut registry = AdapterRegistry::empty(); + registry.register_typed(OverrideAdapter); + registry.register_typed(UniswapV4Adapter); + + assert_eq!( + registry.resolve_selector("0x18a9d381"), + Some("Overridden!".to_string()) + ); + } + + #[test] + fn clear_registry() { + let mut registry = AdapterRegistry::default(); + assert!(!registry.is_empty()); + registry.clear(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + } + + #[test] + fn iter_registered_adapters() { + let registry = AdapterRegistry::empty() + .with_adapter(UniswapV4Adapter) + .with_adapter(Erc20Adapter); + + let names: Vec<&str> = registry.iter().map(|a| a.name()).collect(); + assert_eq!(names, vec!["Uniswap v4", "ERC-20"]); + } +} diff --git a/crates/atupa-adapters/src/traits.rs b/crates/atupa-adapters/src/traits.rs new file mode 100644 index 0000000..e363b31 --- /dev/null +++ b/crates/atupa-adapters/src/traits.rs @@ -0,0 +1,100 @@ +//! The core [`ProtocolAdapter`] trait for translating low-level EVM execution +//! context (contract addresses and 4-byte function selectors) into +//! human-readable labels. + +/// The shared interface that every protocol adapter must implement. +/// +/// An adapter identifies whether an execution frame (defined by target address +/// and/or function selector) belongs to its protocol domain and returns a +/// structured, human-readable label (e.g. `"Uniswap v4: beforeSwap"` or +/// `"AaveV3Pool::liquidationCall"`). +pub trait ProtocolAdapter: Send + Sync { + /// The human-readable name of the protocol (e.g., `"Uniswap v4"`). + fn name(&self) -> &str; + + /// Resolves a combination of target address and function selector into a + /// human-readable label. + /// + /// Returns `None` if this adapter does not recognise the combination. + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option; + + /// Convenience helper to resolve a label using only a 4-byte function selector. + fn resolve_selector(&self, selector: &str) -> Option { + self.resolve_label(None, Some(selector)) + } + + /// Convenience helper to resolve a label using only a contract address. + fn resolve_address(&self, address: &str) -> Option { + self.resolve_label(Some(address), None) + } + + /// Returns `true` if this adapter recognises the given contract address. + fn matches_address(&self, address: &str) -> bool { + self.resolve_address(address).is_some() + } + + /// Returns `true` if this adapter recognises the given 4-byte selector. + fn matches_selector(&self, selector: &str) -> bool { + self.resolve_selector(selector).is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockAdapter; + + impl ProtocolAdapter for MockAdapter { + fn name(&self) -> &str { + "Mock Protocol" + } + + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { + if let Some(addr) = address + && addr.eq_ignore_ascii_case("0x1111111111111111111111111111111111111111") + { + return Some("Mock::Vault".to_string()); + } + if let Some(sel) = selector + && sel == "0x12345678" + { + return Some("Mock::deposit".to_string()); + } + None + } + } + + #[test] + fn trait_defaults_resolve_selector() { + let adapter = MockAdapter; + assert_eq!( + adapter.resolve_selector("0x12345678"), + Some("Mock::deposit".to_string()) + ); + assert_eq!(adapter.resolve_selector("0xdeadbeef"), None); + assert!(adapter.matches_selector("0x12345678")); + assert!(!adapter.matches_selector("0xdeadbeef")); + } + + #[test] + fn trait_defaults_resolve_address() { + let adapter = MockAdapter; + assert_eq!( + adapter.resolve_address("0x1111111111111111111111111111111111111111"), + Some("Mock::Vault".to_string()) + ); + assert_eq!( + adapter.resolve_address("0x2222222222222222222222222222222222222222"), + None + ); + assert!(adapter.matches_address("0x1111111111111111111111111111111111111111")); + assert!(!adapter.matches_address("0x2222222222222222222222222222222222222222")); + } + + #[test] + fn adapter_name() { + let adapter = MockAdapter; + assert_eq!(adapter.name(), "Mock Protocol"); + } +} diff --git a/crates/atupa-adapters/src/uniswap_v4.rs b/crates/atupa-adapters/src/uniswap_v4.rs new file mode 100644 index 0000000..2334079 --- /dev/null +++ b/crates/atupa-adapters/src/uniswap_v4.rs @@ -0,0 +1,103 @@ +//! Built-in [`UniswapV4Adapter`] for identifying Uniswap v4 Hook interface calls. + +use crate::traits::ProtocolAdapter; + +/// Known Uniswap v4 Hook standard interface 4-byte selectors. +pub const HOOK_SELECTORS: &[(&str, &str)] = &[ + ("0x18a9d381", "beforeInitialize"), + ("0x999dea5d", "afterInitialize"), + ("0x910746f2", "beforeAddLiquidity"), + ("0xefd81287", "afterAddLiquidity"), + ("0xd7386be3", "beforeRemoveLiquidity"), + ("0x1efe5f9e", "afterRemoveLiquidity"), + ("0xe82c3b75", "beforeSwap"), + ("0x14d6eaec", "afterSwap"), + ("0xa3d03227", "beforeDonate"), + ("0x0df2d576", "afterDonate"), +]; + +/// Identifies Uniswap v4 Hook interface calls by their 4-byte selectors. +/// +/// This adapter is included directly in `atupa-adapters` because Uniswap v4 hook +/// monitoring is part of the base profiler functionality without requiring a +/// separate heavy crate dependency. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct UniswapV4Adapter; + +impl UniswapV4Adapter { + /// Create a new [`UniswapV4Adapter`]. + pub fn new() -> Self { + Self + } + + /// Resolve a 4-byte selector string directly to a hook label name (e.g. `"beforeSwap"`). + pub fn resolve_hook_selector(selector: &str) -> Option<&'static str> { + let selector = selector.trim(); + for &(known_sel, label) in HOOK_SELECTORS { + if selector.eq_ignore_ascii_case(known_sel) { + return Some(label); + } + } + None + } +} + +impl ProtocolAdapter for UniswapV4Adapter { + fn name(&self) -> &str { + "Uniswap v4" + } + + fn resolve_label(&self, _address: Option<&str>, selector: Option<&str>) -> Option { + let sel = selector?; + let label = Self::resolve_hook_selector(sel)?; + Some(format!("Uniswapv4: {label}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapter_name() { + let adapter = UniswapV4Adapter::new(); + assert_eq!(adapter.name(), "Uniswap v4"); + } + + #[test] + fn resolves_all_known_hook_selectors() { + let adapter = UniswapV4Adapter; + for &(sel, expected_label) in HOOK_SELECTORS { + let resolved = adapter.resolve_label(None, Some(sel)); + assert_eq!( + resolved, + Some(format!("Uniswapv4: {expected_label}")), + "Failed to resolve hook selector {sel}" + ); + } + } + + #[test] + fn static_resolver_is_case_insensitive() { + assert_eq!( + UniswapV4Adapter::resolve_hook_selector("0x18A9D381"), + Some("beforeInitialize") + ); + assert_eq!( + UniswapV4Adapter::resolve_hook_selector("0x18a9d381"), + Some("beforeInitialize") + ); + assert_eq!( + UniswapV4Adapter::resolve_hook_selector(" 0xe82c3b75 "), + Some("beforeSwap") + ); + } + + #[test] + fn returns_none_for_unknown_selector() { + let adapter = UniswapV4Adapter; + assert_eq!(adapter.resolve_label(None, Some("0xdeadbeef")), None); + assert_eq!(adapter.resolve_label(None, None), None); + assert_eq!(UniswapV4Adapter::resolve_hook_selector("0xdeadbeef"), None); + } +} diff --git a/crates/atupa-core/Cargo.toml b/crates/atupa-core/Cargo.toml index 3600f14..f9baae3 100644 --- a/crates/atupa-core/Cargo.toml +++ b/crates/atupa-core/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-core" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -16,7 +15,8 @@ categories = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -thiserror = { workspace = true } +log = { workspace = true } + chrono = { workspace = true } figment = { workspace = true } toml = { workspace = true } diff --git a/crates/atupa-core/src/config.rs b/crates/atupa-core/src/config.rs index c220363..f6f44e3 100644 --- a/crates/atupa-core/src/config.rs +++ b/crates/atupa-core/src/config.rs @@ -1,3 +1,5 @@ +//! [`AtupaConfig`] — runtime configuration with multi-source merging and validation. + use figment::{ Figment, providers::{Env, Format, Serialized, Toml}, @@ -5,13 +7,27 @@ use figment::{ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Runtime configuration for the Atupa profiling engine. +/// +/// Configuration is loaded by merging multiple sources in the following priority +/// order (highest to lowest): +/// +/// 1. **CLI flags** — applied by the caller *after* [`AtupaConfig::load`] returns. +/// 2. **`ATUPA_*` environment variables** — e.g. `ATUPA_RPC_URL`, `ATUPA_ETHERSCAN_KEY`. +/// 3. **`atupa.toml`** — local project config in the current working directory. +/// 4. **`~/.atupa/config.toml`** — global user config. +/// 5. **Built-in defaults** — see [`AtupaConfig::default`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtupaConfig { + /// JSON-RPC endpoint URL for the target chain. pub rpc_url: String, + /// Optional Etherscan API key for contract name resolution. pub etherscan_key: Option, + /// Directory where profiling artifacts (SVGs, JSON reports) are written. pub output_dir: String, + /// Path to the Atupa Studio directory (overrides auto-detection when set). pub studio_dir: Option, - /// Port Atupa Studio's Vite dev-server will bind to (default: 5173). + /// TCP port Atupa Studio's embedded server will bind to. pub studio_port: u16, } @@ -28,56 +44,134 @@ impl Default for AtupaConfig { } impl AtupaConfig { - /// Load configuration by merging multiple sources. - /// Priority: CLI Flags (applied later) > Env Vars > atupa.toml > ~/.atupa/config.toml > Defaults + /// Load configuration by merging all available sources. + /// + /// Config parse errors are logged as warnings and fall back to defaults + /// rather than panicking, ensuring the CLI remains usable even with a + /// malformed config file. pub fn load() -> Self { + match Self::build_figment().extract::() { + Ok(config) => config, + Err(e) => { + log::warn!( + "Failed to parse Atupa configuration — falling back to defaults. \ + Check your atupa.toml or ~/.atupa/config.toml. Error: {e}" + ); + Self::default() + } + } + } + + /// Validate that this configuration is internally coherent. + /// + /// Returns an error describing the problem if any required field is invalid. + /// + /// # Errors + /// + /// - [`rpc_url`](AtupaConfig::rpc_url) is empty or whitespace-only. + pub fn validate(&self) -> anyhow::Result<()> { + if self.rpc_url.trim().is_empty() { + anyhow::bail!( + "rpc_url must not be empty. \ + Set it via the ATUPA_RPC_URL environment variable, atupa.toml, or the --rpc flag." + ); + } + Ok(()) + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + fn build_figment() -> Figment { let mut figment = Figment::from(Serialized::defaults(Self::default())); - // Global config - if let Some(mut home) = dirs::home_dir() { - home.push(".atupa"); - home.push("config.toml"); - figment = figment.merge(Toml::file(home)); + // 1. Global user config: ~/.atupa/config.toml + if let Some(home) = dirs::home_dir() { + figment = figment.merge(Toml::file(home.join(".atupa").join("config.toml"))); } - // Local config + // 2. Local project config: ./atupa.toml figment = figment.merge(Toml::file("atupa.toml")); - // Environment variables + // 3. Environment variable overrides figment = figment.merge(Env::prefixed("ATUPA_")); - figment.extract().unwrap_or_else(|_| Self::default()) + figment } } #[cfg(test)] mod tests { use super::*; - use std::env; + use std::sync::Mutex; + + /// Global mutex to serialise tests that mutate process environment variables. + /// + /// `std::env::set_var` / `remove_var` are inherently unsound in a + /// multi-threaded process (they race with reads from other threads). + /// Holding this lock ensures our env-mutating tests never overlap. + static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] - fn test_default_config() { - let config = AtupaConfig::default(); - assert_eq!(config.rpc_url, "http://localhost:8547"); - assert!(config.etherscan_key.is_none()); + fn default_values_are_sane() { + let cfg = AtupaConfig::default(); + assert_eq!(cfg.rpc_url, "http://localhost:8547"); + assert_eq!(cfg.studio_port, 5173); + assert_eq!(cfg.output_dir, "."); + assert!(cfg.etherscan_key.is_none()); + assert!(cfg.studio_dir.is_none()); } #[test] - fn test_env_override() { + fn validate_rejects_empty_rpc_url() { + let cfg = AtupaConfig { + rpc_url: String::new(), + ..Default::default() + }; + assert!( + cfg.validate().is_err(), + "empty rpc_url should fail validation" + ); + } + + #[test] + fn validate_rejects_whitespace_only_rpc_url() { + let cfg = AtupaConfig { + rpc_url: " ".to_string(), + ..Default::default() + }; + assert!( + cfg.validate().is_err(), + "whitespace-only rpc_url should fail validation" + ); + } + + #[test] + fn validate_accepts_default_config() { + assert!( + AtupaConfig::default().validate().is_ok(), + "default config should pass validation" + ); + } + + #[test] + fn env_vars_override_rpc_url_and_key() { + // Safety: ENV_LOCK ensures no other test mutates the environment concurrently. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { - env::set_var("ATUPA_RPC_URL", "http://test-rpc.local"); - env::set_var("ATUPA_ETHERSCAN_KEY", "test-key-123"); + std::env::set_var("ATUPA_RPC_URL", "http://test-rpc.local"); + std::env::set_var("ATUPA_ETHERSCAN_KEY", "test-key-123"); } - // Reloading should pick up env vars due to Env::prefixed("ATUPA_") - let config = AtupaConfig::load(); - - assert_eq!(config.rpc_url, "http://test-rpc.local"); - assert_eq!(config.etherscan_key, Some("test-key-123".to_string())); + let cfg = AtupaConfig::load(); + // Restore env state before any assertion can panic. unsafe { - env::remove_var("ATUPA_RPC_URL"); - env::remove_var("ATUPA_ETHERSCAN_KEY"); + std::env::remove_var("ATUPA_RPC_URL"); + std::env::remove_var("ATUPA_ETHERSCAN_KEY"); } + + assert_eq!(cfg.rpc_url, "http://test-rpc.local"); + assert_eq!(cfg.etherscan_key, Some("test-key-123".to_string())); } } diff --git a/crates/atupa-core/src/diff.rs b/crates/atupa-core/src/diff.rs new file mode 100644 index 0000000..12877d9 --- /dev/null +++ b/crates/atupa-core/src/diff.rs @@ -0,0 +1,185 @@ +//! Protocol diff structures: [`ProtocolDiffReport`] and [`DiffRow`]. + +use serde::{Deserialize, Serialize}; + +// ─── ProtocolDiffReport ─────────────────────────────────────────────────────── + +/// A field-by-field comparison report between two protocol executions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtocolDiffReport { + /// Human-readable name of the protocol being compared (e.g. `"Lido stETH"`). + pub protocol: String, + /// Ordered list of metric comparisons. + pub rows: Vec, +} + +impl ProtocolDiffReport { + /// Returns `true` if any row in this report represents a regression. + pub fn has_regressions(&self) -> bool { + self.rows.iter().any(DiffRow::is_regression) + } + + /// Returns an iterator over only the rows that are regressions. + pub fn regressions(&self) -> impl Iterator { + self.rows.iter().filter(|r| r.is_regression()) + } + + /// Returns an iterator over only the rows that are improvements. + pub fn improvements(&self) -> impl Iterator { + self.rows.iter().filter(|r| r.is_improvement()) + } +} + +// ─── DiffRow ────────────────────────────────────────────────────────────────── + +/// A single comparable metric between a base and target execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffRow { + /// Human-readable metric name (e.g. `"Total Gas"`). + pub metric: String, + /// Metric value for the base transaction. + pub base: f64, + /// Metric value for the target transaction. + pub target: f64, + /// Absolute difference: `target - base`. + pub delta: f64, + /// Percentage change relative to the base: `delta / base * 100`. + /// + /// Returns `0.0` when `base` is `0` to avoid division by zero. + pub pct: f64, + /// When `true`, an *increase* in this metric is a regression (e.g. gas cost, read count). + /// When `false`, a *decrease* in this metric is a regression. + pub higher_is_worse: bool, +} + +impl DiffRow { + /// Construct a new [`DiffRow`], automatically computing `delta` and `pct`. + /// + /// ``` + /// use atupa_core::DiffRow; + /// + /// let row = DiffRow::new("Total Gas", 1_000.0, 1_200.0, true); + /// assert_eq!(row.delta, 200.0); + /// assert_eq!(row.pct, 20.0); + /// assert!(row.is_regression()); + /// ``` + pub fn new(metric: &str, base: f64, target: f64, higher_is_worse: bool) -> Self { + let delta = target - base; + let pct = if base != 0.0 { + delta / base * 100.0 + } else { + 0.0 + }; + Self { + metric: metric.to_string(), + base, + target, + delta, + pct, + higher_is_worse, + } + } + + /// Returns `true` if this metric has regressed (moved in the undesired direction). + /// + /// - `higher_is_worse = true` → regression when `delta > 0` (cost increased). + /// - `higher_is_worse = false` → regression when `delta < 0` (a desirable metric decreased). + pub fn is_regression(&self) -> bool { + (self.higher_is_worse && self.delta > 0.0) || (!self.higher_is_worse && self.delta < 0.0) + } + + /// Returns `true` if this metric has improved relative to the baseline. + pub fn is_improvement(&self) -> bool { + (self.higher_is_worse && self.delta < 0.0) || (!self.higher_is_worse && self.delta > 0.0) + } + + /// Returns `true` if the metric value is unchanged between base and target. + pub fn is_neutral(&self) -> bool { + self.delta == 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── DiffRow ─────────────────────────────────────────────────────────────── + + #[test] + fn positive_delta_with_higher_is_worse_is_regression() { + let row = DiffRow::new("Total Gas", 100.0, 150.0, true); + assert_eq!(row.delta, 50.0); + assert_eq!(row.pct, 50.0); + assert!(row.is_regression()); + assert!(!row.is_improvement()); + assert!(!row.is_neutral()); + } + + #[test] + fn negative_delta_with_higher_is_worse_is_improvement() { + let row = DiffRow::new("Total Gas", 100.0, 80.0, true); + assert_eq!(row.delta, -20.0); + assert_eq!(row.pct, -20.0); + assert!(row.is_improvement()); + assert!(!row.is_regression()); + } + + #[test] + fn zero_base_pct_is_zero_not_nan() { + let row = DiffRow::new("New Metric", 0.0, 42.0, true); + assert_eq!(row.delta, 42.0); + assert_eq!( + row.pct, 0.0, + "pct must be 0 when base is 0 to avoid NaN/inf" + ); + } + + #[test] + fn unchanged_metric_is_neutral() { + let row = DiffRow::new("Steps", 50.0, 50.0, true); + assert!(row.is_neutral()); + assert!(!row.is_regression()); + assert!(!row.is_improvement()); + } + + #[test] + fn lower_is_better_regression_when_delta_negative() { + // E.g. "coverage %" where higher is better + let row = DiffRow::new("Coverage %", 80.0, 70.0, false); + assert!(row.is_regression()); + assert!(!row.is_improvement()); + } + + // ── ProtocolDiffReport ──────────────────────────────────────────────────── + + #[test] + fn report_detects_regressions() { + let rows = vec![ + DiffRow::new("Gas", 100.0, 120.0, true), // regression + DiffRow::new("Steps", 50.0, 50.0, true), // neutral + DiffRow::new("Reads", 10.0, 8.0, true), // improvement + ]; + let report = ProtocolDiffReport { + protocol: "Test".to_string(), + rows, + }; + assert!(report.has_regressions()); + assert_eq!(report.regressions().count(), 1); + assert_eq!(report.improvements().count(), 1); + } + + #[test] + fn report_with_no_regressions() { + let rows = vec![ + DiffRow::new("Gas", 100.0, 90.0, true), // improvement + DiffRow::new("Steps", 50.0, 50.0, true), // neutral + ]; + let report = ProtocolDiffReport { + protocol: "Test".to_string(), + rows, + }; + assert!(!report.has_regressions()); + assert_eq!(report.regressions().count(), 0); + assert_eq!(report.improvements().count(), 1); + } +} diff --git a/crates/atupa-core/src/gas.rs b/crates/atupa-core/src/gas.rs new file mode 100644 index 0000000..cf9a565 --- /dev/null +++ b/crates/atupa-core/src/gas.rs @@ -0,0 +1,364 @@ +//! [`GasCategory`] — logical cost-driver classification for execution steps. + +use crate::VmKind; +use serde::{Deserialize, Serialize}; + +/// Logical grouping of an execution step by its dominant cost driver. +/// +/// This categorisation is VM-agnostic — the same category names are used +/// whether the step came from EVM, Stylus, Starknet, Solana, or Stellar. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub enum GasCategory { + /// Persistent state writes (e.g. `SSTORE`, `storage_store`, `storage_write`). + StorageWrite, + /// Persistent state reads (e.g. `SLOAD`, `storage_load`, `storage_read`). + StorageRead, + /// Memory allocation and access (e.g. `MLOAD`, `MSTORE`, `memory_grow`). + Memory, + /// Cryptographic operations (e.g. `KECCAK256`, `pedersen`, `secp256k1`). + Crypto, + /// Cross-frame calls and contract deployments (e.g. `CALL`, `invoke_signed`). + Call, + /// Arithmetic, logic, stack management, and control-flow opcodes. + Execution, + /// Precompiled contract calls. + Precompile, + /// The root execution frame itself. + Root, + /// Any step that does not match a more specific category. + #[default] + Other, +} + +impl GasCategory { + /// Classify a single execution step given its opcode/label and the VM that produced it. + /// + /// Each VM has its own naming conventions, so classification is delegated + /// to a VM-specific function. For EVM, classification is exhaustive over + /// known opcodes; for all other VMs a keyword-matching strategy is used. + pub fn from_step(op: &str, vm: &VmKind) -> Self { + let op = op.trim(); + match vm { + VmKind::Evm => Self::from_evm(op), + VmKind::Stylus => Self::from_stylus(op), + VmKind::Starknet => Self::from_starknet(op), + VmKind::Solana => Self::from_solana(op), + VmKind::Stellar => Self::from_stellar(op), + } + } + + // ─── EVM (exhaustive over all known opcodes) ────────────────────────────── + + fn from_evm(op: &str) -> Self { + match op { + // Storage + "SSTORE" | "TSTORE" => Self::StorageWrite, + "SLOAD" | "TLOAD" => Self::StorageRead, + // Memory + "MLOAD" | "MSTORE" | "MSTORE8" | "MCOPY" | "MSIZE" => Self::Memory, + // Cryptography + "KECCAK256" | "SHA3" => Self::Crypto, + // Calls & deployment + "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" | "CREATE" | "CREATE2" + | "RETURN" | "REVERT" | "STOP" | "INVALID" | "SELFDESTRUCT" => Self::Call, + // Arithmetic, logic, stack & control flow + "ADD" | "SUB" | "MUL" | "DIV" | "SDIV" | "MOD" | "SMOD" | "ADDMOD" | "MULMOD" + | "EXP" | "SIGNEXTEND" | "LT" | "GT" | "SLT" | "SGT" | "EQ" | "ISZERO" | "AND" + | "OR" | "XOR" | "NOT" | "BYTE" | "SHL" | "SHR" | "SAR" | "POP" | "PUSH1" | "PUSH2" + | "PUSH3" | "PUSH4" | "PUSH5" | "PUSH6" | "PUSH7" | "PUSH8" | "PUSH9" | "PUSH10" + | "PUSH11" | "PUSH12" | "PUSH13" | "PUSH14" | "PUSH15" | "PUSH16" | "PUSH17" + | "PUSH18" | "PUSH19" | "PUSH20" | "PUSH21" | "PUSH22" | "PUSH23" | "PUSH24" + | "PUSH25" | "PUSH26" | "PUSH27" | "PUSH28" | "PUSH29" | "PUSH30" | "PUSH31" + | "PUSH32" | "DUP1" | "DUP2" | "DUP3" | "DUP4" | "DUP5" | "DUP6" | "DUP7" | "DUP8" + | "DUP9" | "DUP10" | "DUP11" | "DUP12" | "DUP13" | "DUP14" | "DUP15" | "DUP16" + | "SWAP1" | "SWAP2" | "SWAP3" | "SWAP4" | "SWAP5" | "SWAP6" | "SWAP7" | "SWAP8" + | "SWAP9" | "SWAP10" | "SWAP11" | "SWAP12" | "SWAP13" | "SWAP14" | "SWAP15" + | "SWAP16" | "JUMP" | "JUMPI" | "PC" | "GAS" | "JUMPDEST" => Self::Execution, + _ => Self::Other, + } + } + + // ─── Stylus WASM HostIO ─────────────────────────────────────────────────── + + fn from_stylus(hostio: &str) -> Self { + classify_by_keyword( + hostio, + &[ + (&["flush", "storage_store"], Self::StorageWrite), + (&["storage_load", "storage_cache"], Self::StorageRead), + (&["keccak", "sha2"], Self::Crypto), + (&["call", "create"], Self::Call), + (&["memory", "args", "return_data"], Self::Memory), + (&["msg", "block", "tx", "evm", "user"], Self::Execution), + ], + ) + } + + // ─── Starknet Cairo builtins & syscalls ─────────────────────────────────── + + fn from_starknet(op: &str) -> Self { + classify_by_keyword( + op, + &[ + (&["storage_write"], Self::StorageWrite), + (&["storage_read"], Self::StorageRead), + (&["keccak", "pedersen", "poseidon", "ec_op"], Self::Crypto), + (&["call", "deploy", "invoke"], Self::Call), + (&["range_check", "bitwise", "steps"], Self::Execution), + ], + ) + } + + // ─── Solana CPI & Compute Budget ────────────────────────────────────────── + + fn from_solana(op: &str) -> Self { + classify_by_keyword( + op, + &[ + (&["write", "store", "set_account"], Self::StorageWrite), + (&["read", "load", "get_account"], Self::StorageRead), + (&["hash", "keccak", "secp256k1", "ed25519"], Self::Crypto), + (&["invoke", "cpi", "call"], Self::Call), + ( + &["compute", "log", "instruction", "syscall"], + Self::Execution, + ), + ], + ) + } + + // ─── Stellar Soroban HostFn ─────────────────────────────────────────────── + + fn from_stellar(op: &str) -> Self { + classify_by_keyword( + op, + &[ + (&["put_contract_data", "write"], Self::StorageWrite), + (&["get_contract_data", "read"], Self::StorageRead), + (&["hash", "verify", "crypto", "recover"], Self::Crypto), + (&["call", "invoke", "create_contract"], Self::Call), + (&["value", "obj", "vec", "map", "bytes"], Self::Memory), + (&["log", "ledger", "meta"], Self::Execution), + ], + ) + } +} + +// ─── Private helpers ────────────────────────────────────────────────────────── + +/// Walk a prioritised list of `(keywords, category)` rules and return the first +/// [`GasCategory`] whose keyword appears (case-insensitively) within `op`. +/// +/// Falls through to [`GasCategory::Other`] if no rule matches. +fn classify_by_keyword(op: &str, rules: &[(&[&str], GasCategory)]) -> GasCategory { + let lower = op.to_lowercase(); + for (keywords, category) in rules { + if keywords.iter().any(|kw| lower.contains(kw)) { + return category.clone(); + } + } + GasCategory::Other +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── EVM ─────────────────────────────────────────────────────────────────── + + #[test] + fn evm_storage_ops() { + assert_eq!( + GasCategory::from_step("SSTORE", &VmKind::Evm), + GasCategory::StorageWrite + ); + assert_eq!( + GasCategory::from_step("TSTORE", &VmKind::Evm), + GasCategory::StorageWrite + ); + assert_eq!( + GasCategory::from_step("SLOAD", &VmKind::Evm), + GasCategory::StorageRead + ); + assert_eq!( + GasCategory::from_step("TLOAD", &VmKind::Evm), + GasCategory::StorageRead + ); + } + + #[test] + fn evm_memory_ops() { + assert_eq!( + GasCategory::from_step("MLOAD", &VmKind::Evm), + GasCategory::Memory + ); + assert_eq!( + GasCategory::from_step("MSTORE", &VmKind::Evm), + GasCategory::Memory + ); + assert_eq!( + GasCategory::from_step("MCOPY", &VmKind::Evm), + GasCategory::Memory + ); + } + + #[test] + fn evm_crypto_ops() { + assert_eq!( + GasCategory::from_step("KECCAK256", &VmKind::Evm), + GasCategory::Crypto + ); + assert_eq!( + GasCategory::from_step("SHA3", &VmKind::Evm), + GasCategory::Crypto + ); + } + + #[test] + fn evm_call_ops() { + assert_eq!( + GasCategory::from_step("CALL", &VmKind::Evm), + GasCategory::Call + ); + assert_eq!( + GasCategory::from_step("DELEGATECALL", &VmKind::Evm), + GasCategory::Call + ); + assert_eq!( + GasCategory::from_step("STATICCALL", &VmKind::Evm), + GasCategory::Call + ); + assert_eq!( + GasCategory::from_step("CREATE", &VmKind::Evm), + GasCategory::Call + ); + assert_eq!( + GasCategory::from_step("CREATE2", &VmKind::Evm), + GasCategory::Call + ); + } + + #[test] + fn evm_execution_ops() { + assert_eq!( + GasCategory::from_step("ADD", &VmKind::Evm), + GasCategory::Execution + ); + assert_eq!( + GasCategory::from_step("JUMPDEST", &VmKind::Evm), + GasCategory::Execution + ); + assert_eq!( + GasCategory::from_step("PUSH32", &VmKind::Evm), + GasCategory::Execution + ); + assert_eq!( + GasCategory::from_step("DUP1", &VmKind::Evm), + GasCategory::Execution + ); + assert_eq!( + GasCategory::from_step("SWAP16", &VmKind::Evm), + GasCategory::Execution + ); + } + + #[test] + fn evm_unknown_op_falls_to_other() { + assert_eq!( + GasCategory::from_step("CUSTOMOP", &VmKind::Evm), + GasCategory::Other + ); + assert_eq!(GasCategory::from_step("", &VmKind::Evm), GasCategory::Other); + } + + // ── Stylus ──────────────────────────────────────────────────────────────── + + #[test] + fn stylus_storage_ops() { + assert_eq!( + GasCategory::from_step("storage_store_bytes32", &VmKind::Stylus), + GasCategory::StorageWrite + ); + assert_eq!( + GasCategory::from_step("storage_load_bytes32", &VmKind::Stylus), + GasCategory::StorageRead + ); + assert_eq!( + GasCategory::from_step("flush_cache", &VmKind::Stylus), + GasCategory::StorageWrite + ); + } + + #[test] + fn stylus_call_ops() { + assert_eq!( + GasCategory::from_step("call_contract", &VmKind::Stylus), + GasCategory::Call + ); + } + + // ── Starknet ────────────────────────────────────────────────────────────── + + #[test] + fn starknet_crypto_and_storage() { + assert_eq!( + GasCategory::from_step("pedersen_hash", &VmKind::Starknet), + GasCategory::Crypto + ); + assert_eq!( + GasCategory::from_step("poseidon_hash_many", &VmKind::Starknet), + GasCategory::Crypto + ); + assert_eq!( + GasCategory::from_step("storage_write", &VmKind::Starknet), + GasCategory::StorageWrite + ); + assert_eq!( + GasCategory::from_step("storage_read", &VmKind::Starknet), + GasCategory::StorageRead + ); + } + + // ── Solana ──────────────────────────────────────────────────────────────── + + #[test] + fn solana_cpi_is_call() { + assert_eq!( + GasCategory::from_step("invoke_signed_cpi", &VmKind::Solana), + GasCategory::Call + ); + } + + #[test] + fn solana_secp256k1_is_crypto() { + assert_eq!( + GasCategory::from_step("secp256k1_recover", &VmKind::Solana), + GasCategory::Crypto + ); + } + + // ── Stellar ─────────────────────────────────────────────────────────────── + + #[test] + fn stellar_hostfn_storage() { + assert_eq!( + GasCategory::from_step("put_contract_data", &VmKind::Stellar), + GasCategory::StorageWrite + ); + assert_eq!( + GasCategory::from_step("get_contract_data", &VmKind::Stellar), + GasCategory::StorageRead + ); + } + + // ── Leading/trailing whitespace handling ────────────────────────────────── + + #[test] + fn whitespace_is_trimmed() { + assert_eq!( + GasCategory::from_step(" SSTORE ", &VmKind::Evm), + GasCategory::StorageWrite + ); + } +} diff --git a/crates/atupa-core/src/lib.rs b/crates/atupa-core/src/lib.rs index e07e8e7..dbfd8ce 100644 --- a/crates/atupa-core/src/lib.rs +++ b/crates/atupa-core/src/lib.rs @@ -1,206 +1,32 @@ -pub mod config; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Standard EVM Gas Categories for logical grouping of execution costs. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "PascalCase")] -pub enum GasCategory { - /// Opcodes like SSTORE - StorageWrite, - /// Opcodes like SLOAD - StorageRead, - /// Memory operations (MLOAD, MSTORE, etc.) - Memory, - /// Cryptographic operations (KECCAK256) - Crypto, - /// External calls (CALL, DELEGATECALL, etc.) - Call, - /// Logic and arithmetic - Execution, - /// Precompiled contract calls - Precompile, - /// Root execution frame - Root, - #[default] - Other, -} - -impl GasCategory { - pub fn from_step(op: &str, vm: VmKind) -> Self { - let op = op.trim(); - match vm { - VmKind::Evm => Self::from_evm(op), - VmKind::Stylus => Self::from_stylus(op), - } - } - - fn from_evm(op: &str) -> Self { - match op { - "SSTORE" | "TSTORE" => Self::StorageWrite, - "SLOAD" | "TLOAD" => Self::StorageRead, - "MLOAD" | "MSTORE" | "MSTORE8" | "MCOPY" | "MSIZE" => Self::Memory, - "KECCAK256" | "SHA3" => Self::Crypto, - "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" | "CREATE" | "CREATE2" - | "RETURN" | "REVERT" | "STOP" | "INVALID" | "SELFDESTRUCT" => Self::Call, - // Arithmetic, Logic, Stack, Flow - "ADD" | "SUB" | "MUL" | "DIV" | "SDIV" | "MOD" | "SMOD" | "ADDMOD" | "MULMOD" - | "EXP" | "SIGNEXTEND" | "LT" | "GT" | "SLT" | "SGT" | "EQ" | "ISZERO" | "AND" - | "OR" | "XOR" | "NOT" | "BYTE" | "SHL" | "SHR" | "SAR" | "POP" | "PUSH1" | "PUSH2" - | "PUSH3" | "PUSH4" | "PUSH5" | "PUSH6" | "PUSH7" | "PUSH8" | "PUSH9" | "PUSH10" - | "PUSH11" | "PUSH12" | "PUSH13" | "PUSH14" | "PUSH15" | "PUSH16" | "PUSH17" - | "PUSH18" | "PUSH19" | "PUSH20" | "PUSH21" | "PUSH22" | "PUSH23" | "PUSH24" - | "PUSH25" | "PUSH26" | "PUSH27" | "PUSH28" | "PUSH29" | "PUSH30" | "PUSH31" - | "PUSH32" | "DUP1" | "DUP2" | "DUP3" | "DUP4" | "DUP5" | "DUP6" | "DUP7" | "DUP8" - | "DUP9" | "DUP10" | "DUP11" | "DUP12" | "DUP13" | "DUP14" | "DUP15" | "DUP16" - | "SWAP1" | "SWAP2" | "SWAP3" | "SWAP4" | "SWAP5" | "SWAP6" | "SWAP7" | "SWAP8" - | "SWAP9" | "SWAP10" | "SWAP11" | "SWAP12" | "SWAP13" | "SWAP14" | "SWAP15" - | "SWAP16" | "JUMP" | "JUMPI" | "PC" | "GAS" | "JUMPDEST" => Self::Execution, - _ => Self::Other, - } - } - - fn from_stylus(hostio: &str) -> Self { - let n = hostio.to_lowercase(); - // Specific checks for flush (it's a write operation) - if n.contains("flush") || n.contains("storage_store") { - Self::StorageWrite - } else if n.contains("storage_load") || n.contains("storage_cache") { - Self::StorageRead - } else if n.contains("keccak") || n.contains("sha2") { - Self::Crypto - } else if n.contains("call") || n.contains("create") { - Self::Call - } else if n.contains("memory") || n.contains("args") || n.contains("return") { - Self::Memory - } else if n.contains("msg") - || n.contains("block") - || n.contains("tx") - || n.contains("evm") - || n.contains("user") - { - Self::Execution - } else { - Self::Other - } - } -} - -/// A single step in the EVM execution trace (equivalent to structLog). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TraceStep { - pub pc: u64, - pub op: String, - pub gas: u64, - pub gas_cost: u64, - pub depth: u16, - pub stack: Option>, - pub memory: Option>, - #[serde(default)] - pub error: Option, - #[serde(default)] - pub reverted: bool, - #[serde(default)] - pub vm_kind: VmKind, -} - -/// Which virtual machine produced these execution steps. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub enum VmKind { - #[default] - Evm, - Stylus, -} +//! # atupa-core +//! +//! Foundational types, configuration, and domain models shared across the +//! entire Atupa workspace. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`vm`] | [`VmKind`] — identifies the source Virtual Machine | +//! | [`gas`] | [`GasCategory`] — classifies execution steps by cost driver | +//! | [`types`] | [`TraceStep`], [`CollapsedStack`], [`HotPath`], [`Profile`], [`ProfileBuilder`] | +//! | [`diff`] | [`ProtocolDiffReport`], [`DiffRow`] — protocol-level regression comparison | +//! | [`config`] | [`AtupaConfig`] — multi-source configuration loading | +//! +//! ## Re-exports +//! +//! All public types are re-exported from the crate root so that downstream +//! crates can use `atupa_core::TraceStep` etc. without knowing the module layout. -/// A single collapsed stack entry for aggregation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CollapsedStack { - pub stack: String, - pub weight: u64, - pub last_pc: Option, - /// Maximum call depth seen for steps in this stack. - #[serde(default)] - pub depth: u16, - /// The VM that produced this collapsed stack. - #[serde(default)] - pub vm_kind: VmKind, - #[serde(default)] - pub target_address: Option, - #[serde(default)] - pub resolved_label: Option, - #[serde(default)] - pub reverted: bool, -} - -/// A collapsed execution path with aggregated gas costs. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HotPath { - pub stack: String, - pub gas: u64, - pub percentage: f64, - pub category: GasCategory, -} - -/// The final report generated by Atupa. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Profile { - pub version: String, - pub transaction_hash: String, - pub total_gas: u64, - pub categories: HashMap, - pub hot_paths: Vec, - pub generated_at: String, -} - -impl Profile { - pub fn new(tx_hash: String) -> Self { - Self { - version: env!("CARGO_PKG_VERSION").to_string(), - transaction_hash: tx_hash, - total_gas: 0, - categories: HashMap::new(), - hot_paths: Vec::new(), - generated_at: chrono::Utc::now().to_rfc3339(), - } - } -} - -// ─── Protocol Diff Structures ──────────────────────────────────────────────── - -/// A field-by-field delta between two protocol executions. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProtocolDiffReport { - pub protocol: String, - pub rows: Vec, -} +pub mod config; +pub mod diff; +pub mod gas; +pub mod types; +pub mod vm; -/// A single comparable metric row for protocol-level diffing. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiffRow { - pub metric: String, - pub base: f64, - pub target: f64, - pub delta: f64, - pub pct: f64, - /// true = a larger value is bad (gas, reads, calls), false = larger is better - pub higher_is_worse: bool, -} +// ── Flat re-exports ─────────────────────────────────────────────────────────── -impl DiffRow { - pub fn new(metric: &str, base: f64, target: f64, higher_is_worse: bool) -> Self { - let delta = target - base; - let pct = if base > 0.0 { - delta / base * 100.0 - } else { - 0.0 - }; - Self { - metric: metric.to_string(), - base, - target, - delta, - pct, - higher_is_worse, - } - } -} +pub use diff::{DiffRow, ProtocolDiffReport}; +pub use gas::GasCategory; +pub use types::{CollapsedStack, HotPath, Profile, ProfileBuilder, TraceStep}; +pub use vm::{ParseVmKindError, VmKind}; diff --git a/crates/atupa-core/src/types.rs b/crates/atupa-core/src/types.rs new file mode 100644 index 0000000..b9a3b93 --- /dev/null +++ b/crates/atupa-core/src/types.rs @@ -0,0 +1,269 @@ +//! Core execution trace types: [`TraceStep`], [`CollapsedStack`], [`HotPath`], +//! [`Profile`], and the [`ProfileBuilder`]. + +use crate::{GasCategory, VmKind}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// ─── TraceStep ──────────────────────────────────────────────────────────────── + +/// A single normalized execution step, produced by any supported VM. +/// +/// All VM-specific cost units (Compute Units, Cairo steps, Stylus Ink) are +/// mapped into [`gas_cost`](TraceStep::gas_cost) at the adapter layer so that +/// cross-chain comparison remains possible without further translation. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TraceStep { + /// Program counter or instruction index within the current call frame. + pub pc: u64, + /// Opcode name, HostFn label, or program identifier. + pub op: String, + /// Gas remaining at this step (EVM convention; may be `0` for non-EVM VMs). + pub gas: u64, + /// Normalized execution cost for this single step. + pub gas_cost: u64, + /// Call-stack depth at this step (`0` = root frame). + pub depth: u16, + /// EVM stack snapshot at this step, if available. + pub stack: Option>, + /// EVM memory snapshot at this step, if available. + pub memory: Option>, + /// Revert or error message emitted by this step, if any. + #[serde(default)] + pub error: Option, + /// Whether this step (or its enclosing call frame) was reverted. + #[serde(default)] + pub reverted: bool, + /// Which Virtual Machine produced this step. + #[serde(default)] + pub vm_kind: VmKind, +} + +impl TraceStep { + /// Convenience constructor for a minimal EVM step. + /// + /// All fields not specified default to their zero values. + /// Useful for building test fixtures without boilerplate. + /// + /// ``` + /// use atupa_core::{TraceStep, VmKind}; + /// + /// let step = TraceStep::evm("SSTORE", 5_000); + /// assert_eq!(step.vm_kind, VmKind::Evm); + /// assert_eq!(step.gas_cost, 5_000); + /// ``` + pub fn evm(op: impl Into, gas_cost: u64) -> Self { + Self { + op: op.into(), + gas_cost, + vm_kind: VmKind::Evm, + ..Default::default() + } + } + + /// Returns `true` if this step represents a cross-frame call boundary in the EVM. + /// + /// Useful for filtering steps that create a new call context (and thus a + /// new depth level) during aggregation. + pub fn is_call(&self) -> bool { + matches!( + self.op.as_str(), + "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" | "CREATE" | "CREATE2" + ) + } +} + +// ─── CollapsedStack ─────────────────────────────────────────────────────────── + +/// A depth-aggregated execution path with its total accumulated cost weight. +/// +/// Stack paths use a semi-colon-delimited format, e.g.: +/// `"CALL;SSTORE;KECCAK256"`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollapsedStack { + /// Semi-colon delimited opcode / label path. + pub stack: String, + /// Total normalized execution weight (gas / CU / Ink) for this path. + pub weight: u64, + /// Program counter of the last step folded into this entry. + pub last_pc: Option, + /// Maximum call depth seen across the steps in this path. + #[serde(default)] + pub depth: u16, + /// The VM that produced the steps in this path. + #[serde(default)] + pub vm_kind: VmKind, + /// Callee contract address extracted from the call boundary step, if any. + #[serde(default)] + pub target_address: Option, + /// Human-readable label resolved via a protocol adapter, if any. + #[serde(default)] + pub resolved_label: Option, + /// Whether the call frame represented by this stack was reverted. + #[serde(default)] + pub reverted: bool, +} + +// ─── HotPath ────────────────────────────────────────────────────────────────── + +/// An aggregated hot path — a collapsed stack ranked by its share of total gas. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HotPath { + /// Semi-colon delimited opcode / label path. + pub stack: String, + /// Total accumulated execution cost for this path. + pub gas: u64, + /// Share of the total transaction cost (0.0–100.0). + pub percentage: f64, + /// Dominant cost category for this path. + pub category: GasCategory, +} + +// ─── Profile ────────────────────────────────────────────────────────────────── + +/// The top-level profiling report emitted by the Atupa engine. +/// +/// Construct via [`Profile::new`] or, for deterministic testing, via +/// [`ProfileBuilder`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + /// Crate version that generated this report. + pub version: String, + /// Transaction hash (or trace identifier) that was profiled. + pub transaction_hash: String, + /// Total normalized execution cost across all steps. + pub total_gas: u64, + /// Per-[`GasCategory`] cost breakdown. + pub categories: HashMap, + /// Ranked list of execution hot paths. + pub hot_paths: Vec, + /// RFC-3339 timestamp at which this report was generated. + pub generated_at: String, +} + +impl Profile { + /// Creates a new, empty profile for the given transaction hash. + /// + /// `generated_at` is set to the current UTC time. Use [`ProfileBuilder`] + /// when you need a deterministic, injectable timestamp (e.g. in unit tests + /// or snapshot testing). + pub fn new(tx_hash: impl Into) -> Self { + ProfileBuilder::new(tx_hash).build() + } +} + +// ─── ProfileBuilder ─────────────────────────────────────────────────────────── + +/// A builder for [`Profile`] that allows injecting a custom `generated_at` +/// timestamp for deterministic unit testing. +/// +/// ``` +/// use atupa_core::ProfileBuilder; +/// +/// let profile = ProfileBuilder::new("0xdeadbeef") +/// .generated_at("2026-01-01T00:00:00Z") +/// .build(); +/// +/// assert_eq!(profile.generated_at, "2026-01-01T00:00:00Z"); +/// assert_eq!(profile.total_gas, 0); +/// ``` +pub struct ProfileBuilder { + tx_hash: String, + generated_at: Option, +} + +impl ProfileBuilder { + /// Start building a [`Profile`] for the given transaction hash. + pub fn new(tx_hash: impl Into) -> Self { + Self { + tx_hash: tx_hash.into(), + generated_at: None, + } + } + + /// Override the `generated_at` timestamp. + /// + /// If not called, defaults to [`chrono::Utc::now()`] at `.build()` time. + pub fn generated_at(mut self, ts: impl Into) -> Self { + self.generated_at = Some(ts.into()); + self + } + + /// Consume the builder and return the finished [`Profile`]. + pub fn build(self) -> Profile { + Profile { + version: env!("CARGO_PKG_VERSION").to_string(), + transaction_hash: self.tx_hash, + total_gas: 0, + categories: HashMap::new(), + hot_paths: Vec::new(), + generated_at: self + .generated_at + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── ProfileBuilder ──────────────────────────────────────────────────────── + + #[test] + fn profile_builder_deterministic_timestamp() { + let p = ProfileBuilder::new("0xabc") + .generated_at("2026-01-01T00:00:00Z") + .build(); + assert_eq!(p.transaction_hash, "0xabc"); + assert_eq!(p.generated_at, "2026-01-01T00:00:00Z"); + assert_eq!(p.total_gas, 0); + assert!(p.categories.is_empty()); + assert!(p.hot_paths.is_empty()); + } + + #[test] + fn profile_new_sets_version() { + let p = Profile::new("0xbeef"); + assert!( + !p.version.is_empty(), + "version should be set from CARGO_PKG_VERSION" + ); + } + + // ── TraceStep ───────────────────────────────────────────────────────────── + + #[test] + fn trace_step_evm_helper_sets_fields() { + let step = TraceStep::evm("SSTORE", 5_000); + assert_eq!(step.op, "SSTORE"); + assert_eq!(step.gas_cost, 5_000); + assert_eq!(step.vm_kind, VmKind::Evm); + assert!(!step.reverted); + assert_eq!(step.depth, 0); + } + + #[test] + fn trace_step_is_call_detects_call_opcodes() { + for op in &[ + "CALL", + "STATICCALL", + "DELEGATECALL", + "CALLCODE", + "CREATE", + "CREATE2", + ] { + assert!(TraceStep::evm(*op, 0).is_call(), "{op} should be a call"); + } + } + + #[test] + fn trace_step_is_call_rejects_non_calls() { + for op in &["SSTORE", "SLOAD", "ADD", "JUMPDEST"] { + assert!( + !TraceStep::evm(*op, 0).is_call(), + "{op} should not be a call" + ); + } + } +} diff --git a/crates/atupa-core/src/vm.rs b/crates/atupa-core/src/vm.rs new file mode 100644 index 0000000..1f1864f --- /dev/null +++ b/crates/atupa-core/src/vm.rs @@ -0,0 +1,121 @@ +//! [`VmKind`] — identifies which Virtual Machine produced a set of execution steps. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Identifies which Virtual Machine produced a set of execution trace steps. +/// +/// Marked `#[non_exhaustive]` so that downstream crates handle future VM +/// additions gracefully (via wildcard arms) rather than failing to compile. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, Hash)] +pub enum VmKind { + /// Standard Ethereum Virtual Machine (also used for Arbitrum EVM frames). + #[default] + Evm, + /// Arbitrum Stylus WASM Host I/O frames. + Stylus, + /// Starknet Cairo VM execution frames. + Starknet, + /// Solana Sealevel VM (SVM) / Cross-Program Invocation frames. + Solana, + /// Stellar Soroban Host Function frames. + Stellar, +} + +impl fmt::Display for VmKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VmKind::Evm => write!(f, "EVM"), + VmKind::Stylus => write!(f, "Stylus"), + VmKind::Starknet => write!(f, "Starknet"), + VmKind::Solana => write!(f, "Solana"), + VmKind::Stellar => write!(f, "Stellar"), + } + } +} + +// ── Parsing ─────────────────────────────────────────────────────────────────── + +/// Error returned when a string cannot be parsed into a [`VmKind`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseVmKindError(pub String); + +impl fmt::Display for ParseVmKindError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "unknown VM kind {:?} — expected one of: evm, stylus, starknet, solana, stellar", + self.0 + ) + } +} + +impl std::error::Error for ParseVmKindError {} + +impl TryFrom<&str> for VmKind { + type Error = ParseVmKindError; + + /// Parse a VM kind from a string (case-insensitive). + /// + /// `"soroban"` is accepted as an alias for [`VmKind::Stellar`]. + /// + /// ``` + /// use atupa_core::VmKind; + /// + /// assert_eq!(VmKind::try_from("evm").unwrap(), VmKind::Evm); + /// assert_eq!(VmKind::try_from("Solana").unwrap(), VmKind::Solana); + /// assert_eq!(VmKind::try_from("soroban").unwrap(), VmKind::Stellar); + /// assert!(VmKind::try_from("cosmos").is_err()); + /// ``` + fn try_from(s: &str) -> Result { + match s.to_lowercase().as_str() { + "evm" => Ok(VmKind::Evm), + "stylus" => Ok(VmKind::Stylus), + "starknet" => Ok(VmKind::Starknet), + "solana" => Ok(VmKind::Solana), + "stellar" | "soroban" => Ok(VmKind::Stellar), + other => Err(ParseVmKindError(other.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_formats_correctly() { + assert_eq!(VmKind::Evm.to_string(), "EVM"); + assert_eq!(VmKind::Stylus.to_string(), "Stylus"); + assert_eq!(VmKind::Starknet.to_string(), "Starknet"); + assert_eq!(VmKind::Solana.to_string(), "Solana"); + assert_eq!(VmKind::Stellar.to_string(), "Stellar"); + } + + #[test] + fn try_from_is_case_insensitive() { + assert_eq!(VmKind::try_from("evm").unwrap(), VmKind::Evm); + assert_eq!(VmKind::try_from("EVM").unwrap(), VmKind::Evm); + assert_eq!(VmKind::try_from("Stylus").unwrap(), VmKind::Stylus); + assert_eq!(VmKind::try_from("STARKNET").unwrap(), VmKind::Starknet); + assert_eq!(VmKind::try_from("Solana").unwrap(), VmKind::Solana); + } + + #[test] + fn soroban_alias_maps_to_stellar() { + assert_eq!(VmKind::try_from("soroban").unwrap(), VmKind::Stellar); + assert_eq!(VmKind::try_from("stellar").unwrap(), VmKind::Stellar); + } + + #[test] + fn unknown_vm_returns_error() { + let err = VmKind::try_from("cosmos").unwrap_err(); + assert!(err.to_string().contains("cosmos")); + } + + #[test] + fn empty_string_returns_error() { + assert!(VmKind::try_from("").is_err()); + } +} diff --git a/crates/atupa-lido/Cargo.toml b/crates/atupa-lido/Cargo.toml index 3a59d46..8bba2c5 100644 --- a/crates/atupa-lido/Cargo.toml +++ b/crates/atupa-lido/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-lido" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -16,6 +15,4 @@ categories = { workspace = true } atupa-core = { workspace = true } atupa-adapters = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } anyhow = { workspace = true } -log = { workspace = true } diff --git a/crates/atupa-lido/src/adapter.rs b/crates/atupa-lido/src/adapter.rs new file mode 100644 index 0000000..bb28d1f --- /dev/null +++ b/crates/atupa-lido/src/adapter.rs @@ -0,0 +1,95 @@ +//! [`LidoAdapter`] — [`ProtocolAdapter`] implementation for Lido stETH. + +use atupa_adapters::ProtocolAdapter; + +use crate::selectors::{resolve_address, resolve_selector}; + +/// Lido stETH protocol adapter — maps contract addresses and function selectors +/// to human-readable protocol labels. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct LidoAdapter; + +impl LidoAdapter { + /// Create a new [`LidoAdapter`]. + pub fn new() -> Self { + Self + } + + /// Resolve a 4-byte selector string to a human-readable label without + /// requiring an adapter instance. + pub fn resolve_selector_label(selector: &str) -> Option { + resolve_selector(selector) + } +} + +impl ProtocolAdapter for LidoAdapter { + fn name(&self) -> &str { + "Lido stETH" + } + + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { + if let Some(addr) = address + && let Some(label) = resolve_address(addr) + { + return Some(label); + } + selector.and_then(resolve_selector) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adapter_name() { + let adapter = LidoAdapter::new(); + assert_eq!(adapter.name(), "Lido stETH"); + } + + #[test] + fn resolves_submit_selector() { + let adapter = LidoAdapter; + assert_eq!( + adapter.resolve_label(None, Some("0xa1903eab")), + Some("stETH::submit".to_string()) + ); + } + + #[test] + fn resolves_contract_address() { + let adapter = LidoAdapter; + assert_eq!( + adapter.resolve_label(Some("0xae7ab96520de3a18e5e111b5eaab095312d7fe84"), None), + Some("Lido::stETH (Lido Core)".to_string()) + ); + } + + #[test] + fn address_takes_precedence_over_selector() { + let adapter = LidoAdapter; + assert_eq!( + adapter.resolve_label( + Some("0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0"), + Some("0xa1903eab") + ), + Some("Lido::wstETH".to_string()) + ); + } + + #[test] + fn static_resolver_helper() { + assert_eq!( + LidoAdapter::resolve_selector_label("0x8b6ca260"), + Some("stETH::handleOracleReport".to_string()) + ); + assert_eq!(LidoAdapter::resolve_selector_label("0xdeadbeef"), None); + } + + #[test] + fn returns_none_for_unknown_input() { + let adapter = LidoAdapter; + assert_eq!(adapter.resolve_label(None, Some("0xdeadbeef")), None); + assert_eq!(adapter.resolve_label(None, None), None); + } +} diff --git a/crates/atupa-lido/src/lib.rs b/crates/atupa-lido/src/lib.rs index 8bbf58a..6cea8fc 100644 --- a/crates/atupa-lido/src/lib.rs +++ b/crates/atupa-lido/src/lib.rs @@ -1,285 +1,33 @@ //! # atupa-lido — DeepTracer //! -//! Lido stETH protocol adapter for the Atupa EVM profiling engine. -//! Provides tracing capabilities for Liquid Staking Mechanics, -//! tracking gas usage across submitting ETH, sharing rebases, -//! and handling withdrawals. - -use atupa_adapters::ProtocolAdapter; -use atupa_core::{DiffRow, ProtocolDiffReport, TraceStep}; -use serde::{Deserialize, Serialize}; - -/// Selectors for major Lido protocol operations. -const LIDO_SELECTORS: &[(&str, &str)] = &[ - ("0xa1903eab", "submit"), // stETH.submit(address _referral) - ("0xea598cb0", "requestWithdrawals"), // Legacy request withdrawals - ("0x826a73d6", "requestWithdrawalsWithPermit"), - ("0xe35ea9a5", "claimWithdrawals"), - ("0x8b6ca260", "handleOracleReport"), // Rebase oracle consensus - ("0x39ba163b", "transferShares"), - ("0x4dbcaef1", "transferSharesFrom"), - ("0xa9059cbb", "transfer"), // ERC-20 generic - ("0x095ea7b3", "approve"), // ERC-20 generic - ("0x0a19ea81", "wrap"), // wstETH wrap - ("0x1dfab2e1", "unwrap"), // wstETH unwrap -]; - -/// Known Lido protocol contract addresses (Mainnet). -const LIDO_ADDRESSES: &[(&str, &str)] = &[ - ( - "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", - "stETH (Lido Core)", - ), - ( - "0x55032650b14df07b85bF18A3a3eC8E0Af2e028d5", - "NodeOperatorsRegistry", - ), - ("0x442af752419395f27ed54A848524a30028962bb2", "LidoOracle"), - ( - "0x889edC2Bf57978ed079b851D273218ee42a2b349", - "WithdrawalQueue", - ), - ("0x852f970761d74367f33B6C2e309a29D681E2F16a", "LegacyOracle"), - ("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", "wstETH"), -]; - -// --------------------------------------------------------------------------- -// Protocol Adapter Implementation -// --------------------------------------------------------------------------- - -#[derive(Default)] -pub struct LidoAdapter; - -impl ProtocolAdapter for LidoAdapter { - fn name(&self) -> &str { - "Lido stETH" - } - - fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { - if let Some(addr) = address { - for &(known_addr, name) in LIDO_ADDRESSES { - if addr.to_lowercase() == known_addr.to_lowercase() { - return Some(format!("Lido::{}", name)); - } - } - } - - let sel = selector?; - for &(known_sel, label) in LIDO_SELECTORS { - if sel.contains(known_sel.trim_start_matches("0x")) { - return Some(format!("stETH::{label}")); - } - } - None - } -} - -impl LidoAdapter { - /// Resolve a 4-byte selector string to a human-readable label (no instance needed). - pub fn resolve_selector_label(selector: &str) -> Option { - for &(known_sel, label) in LIDO_SELECTORS { - if selector.contains(known_sel.trim_start_matches("0x")) { - return Some(format!("stETH::{label}")); - } - } - None - } -} - -// --------------------------------------------------------------------------- -// Report Structures -// --------------------------------------------------------------------------- - -/// Detailed metrics for a Lido protocol interaction. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LidoReport { - pub tx_hash: String, - pub total_gas: u64, - pub storage_reads: u32, - pub storage_writes: u32, - pub external_calls: u32, - pub shares_transfers: u32, - pub oracle_reports: u32, - pub withdrawal_requests: u32, - pub withdrawal_claims: u32, - pub wrapped_ops: u32, - pub max_depth: u16, - pub reverted: bool, - pub labeled_calls: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LabeledCall { - pub depth: u16, - pub label: String, - pub gas_cost: u64, -} - -// --------------------------------------------------------------------------- -// Deep Tracer Implementation -// --------------------------------------------------------------------------- - -#[derive(Default)] -pub struct LidoDeepTracer { - adapter: LidoAdapter, -} - -impl LidoDeepTracer { - pub fn new() -> Self { - Self { - adapter: LidoAdapter, - } - } - - /// Analyze a sequence of trace steps for Lido-specific patterns. - pub fn analyze_staking( - &self, - tx_hash: &str, - steps: &[TraceStep], - ) -> anyhow::Result { - let mut total_gas = 0u64; - let mut storage_reads = 0u32; - let mut storage_writes = 0u32; - let mut external_calls = 0u32; - let mut shares_transfers = 0u32; - let mut oracle_reports = 0u32; - let mut withdrawal_requests = 0u32; - let mut withdrawal_claims = 0u32; - let mut wrapped_ops = 0u32; - let mut max_depth = 0u16; - let mut labeled_calls = Vec::new(); - - for step in steps { - total_gas = total_gas.saturating_add(step.gas_cost); - max_depth = max_depth.max(step.depth); - - match step.op.as_str() { - "SLOAD" => storage_reads += 1, - "SSTORE" => storage_writes += 1, - "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" => { - external_calls += 1; - - let selector = step - .stack - .as_ref() - .and_then(|s| s.last()) - .map(|s| s.as_str()); - - if let Some(label) = self.adapter.resolve_label(None, selector) { - if label.contains("transferShares") { - shares_transfers += 1; - } else if label.contains("handleOracleReport") { - oracle_reports += 1; - } else if label.contains("requestWithdrawals") { - withdrawal_requests += 1; - } else if label.contains("claimWithdrawals") { - withdrawal_claims += 1; - } else if label.contains("wrap") || label.contains("unwrap") { - wrapped_ops += 1; - } - - labeled_calls.push(LabeledCall { - depth: step.depth, - label, - gas_cost: step.gas_cost, - }); - } - } - _ => {} - } - } - - let reverted = steps.last().is_some_and(|s| s.reverted); - labeled_calls.dedup_by(|a, b| a.label == b.label && a.depth == b.depth); - - Ok(LidoReport { - tx_hash: tx_hash.to_string(), - total_gas, - storage_reads, - storage_writes, - external_calls, - shares_transfers, - oracle_reports, - withdrawal_requests, - withdrawal_claims, - wrapped_ops, - max_depth, - reverted, - labeled_calls, - }) - } +//! Lido stETH protocol adapter and deep trace analysis for the Atupa EVM +//! profiling engine. +//! +//! Tracks liquid staking mechanics across submitting ETH, rebase oracle reports, +//! shares transfers, and withdrawal queue lifecycle. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`selectors`] | Lido selectors, contract addresses, and lookup helpers | +//! | [`adapter`] | [`LidoAdapter`] implementing [`atupa_adapters::ProtocolAdapter`] | +//! | [`report`] | [`LidoReport`], [`LabeledCall`], and [`LidoAccumulator`](report::LidoAccumulator) | +//! | [`tracer`] | [`LidoDeepTracer`] analysis engine and diff reporting | +//! +//! ## Re-exports +//! +//! All public types are re-exported from the crate root so downstream crates +//! can use `atupa_lido::LidoAdapter`, `atupa_lido::LidoReport`, and +//! `atupa_lido::LidoDeepTracer` directly. - /// Perform a deep field-by-field diff between two Lido executions. - pub fn diff_reports( - &self, - base_tx: &str, - base_steps: &[TraceStep], - target_tx: &str, - target_steps: &[TraceStep], - ) -> anyhow::Result { - let base = self.analyze_staking(base_tx, base_steps)?; - let target = self.analyze_staking(target_tx, target_steps)?; +pub mod adapter; +pub mod report; +pub mod selectors; +pub mod tracer; - let rows = vec![ - DiffRow::new( - "Total Gas", - base.total_gas as f64, - target.total_gas as f64, - true, - ), - DiffRow::new( - "Storage Reads", - base.storage_reads as f64, - target.storage_reads as f64, - true, - ), - DiffRow::new( - "Storage Writes", - base.storage_writes as f64, - target.storage_writes as f64, - true, - ), - DiffRow::new( - "External Calls", - base.external_calls as f64, - target.external_calls as f64, - true, - ), - DiffRow::new( - "Shares Transfers", - base.shares_transfers as f64, - target.shares_transfers as f64, - true, - ), - DiffRow::new( - "Oracle Reports", - base.oracle_reports as f64, - target.oracle_reports as f64, - true, - ), - DiffRow::new( - "Withdrawal Requests", - base.withdrawal_requests as f64, - target.withdrawal_requests as f64, - true, - ), - DiffRow::new( - "Withdrawal Claims", - base.withdrawal_claims as f64, - target.withdrawal_claims as f64, - true, - ), - DiffRow::new( - "Wrapped Ops", - base.wrapped_ops as f64, - target.wrapped_ops as f64, - true, - ), - ]; +// ── Flat re-exports ─────────────────────────────────────────────────────────── - Ok(ProtocolDiffReport { - protocol: "Lido stETH".to_string(), - rows, - }) - } -} +pub use adapter::LidoAdapter; +pub use report::{LabeledCall, LidoReport}; +pub use tracer::LidoDeepTracer; diff --git a/crates/atupa-lido/src/report.rs b/crates/atupa-lido/src/report.rs new file mode 100644 index 0000000..a683217 --- /dev/null +++ b/crates/atupa-lido/src/report.rs @@ -0,0 +1,206 @@ +//! [`LidoReport`], [`LabeledCall`], and [`LidoAccumulator`] for Lido execution traces. + +use atupa_adapters::ProtocolAdapter; +use atupa_core::TraceStep; +use serde::{Deserialize, Serialize}; + +use crate::adapter::LidoAdapter; +use crate::selectors::{is_call_opcode, selector_from_stack}; + +/// Detailed metrics and audit signals extracted from a Lido protocol interaction. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LidoReport { + /// Transaction hash being analyzed. + pub tx_hash: String, + /// Total gas consumed across all steps. + pub total_gas: u64, + /// Number of `SLOAD` opcodes (state reads). + pub storage_reads: u32, + /// Number of `SSTORE` opcodes (state modifications). + pub storage_writes: u32, + /// Number of cross-contract call opcodes. + pub external_calls: u32, + /// Number of `transferShares` / `transferSharesFrom` calls observed. + pub shares_transfers: u32, + /// Number of oracle consensus reports (`handleOracleReport`). + pub oracle_reports: u32, + /// Number of withdrawal requests (`requestWithdrawals`). + pub withdrawal_requests: u32, + /// Number of withdrawal claims (`claimWithdrawals`). + pub withdrawal_claims: u32, + /// Number of wstETH `wrap` or `unwrap` operations. + pub wrapped_ops: u32, + /// Maximum call stack depth reached. + pub max_depth: u16, + /// Whether the transaction reverted. + pub reverted: bool, + /// Deduplicated list of labeled calls extracted from the trace. + pub labeled_calls: Vec, +} + +impl LidoReport { + /// Returns a concise one-line summary of the report. + pub fn summary(&self) -> String { + let short_hash = self.tx_hash.get(..10).unwrap_or(&self.tx_hash); + format!( + "[LidoReport] tx={} gas={} reads={} writes={} calls={} shares_tx={} oracle_rpt={} reverted={}", + short_hash, + self.total_gas, + self.storage_reads, + self.storage_writes, + self.external_calls, + self.shares_transfers, + self.oracle_reports, + self.reverted, + ) + } +} + +/// A single labeled call extracted during trace analysis. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LabeledCall { + /// Call depth at which this call occurred. + pub depth: u16, + /// Human-readable label for the call. + pub label: String, + /// Gas cost of this step. + pub gas_cost: u64, +} + +// ─── Internal Accumulator ───────────────────────────────────────────────────── + +/// Internal accumulator that folds trace steps into a [`LidoReport`]. +#[derive(Default)] +pub(crate) struct LidoAccumulator { + total_gas: u64, + storage_reads: u32, + storage_writes: u32, + external_calls: u32, + shares_transfers: u32, + oracle_reports: u32, + withdrawal_requests: u32, + withdrawal_claims: u32, + wrapped_ops: u32, + max_depth: u16, + labeled_calls: Vec, +} + +impl LidoAccumulator { + /// Incorporate a single trace step into the accumulator state. + pub(crate) fn process_step(&mut self, step: &TraceStep, adapter: &LidoAdapter) { + self.total_gas = self.total_gas.saturating_add(step.gas_cost); + self.max_depth = self.max_depth.max(step.depth); + + match step.op.as_str() { + "SLOAD" => self.storage_reads += 1, + "SSTORE" => self.storage_writes += 1, + op if is_call_opcode(op) => self.process_call_step(step, adapter), + _ => {} + } + } + + fn process_call_step(&mut self, step: &TraceStep, adapter: &LidoAdapter) { + self.external_calls += 1; + + let selector = selector_from_stack(step); + if let Some(label) = adapter.resolve_label(None, selector) { + if label.contains("transferShares") { + self.shares_transfers += 1; + } else if label.contains("handleOracleReport") { + self.oracle_reports += 1; + } else if label.contains("requestWithdrawals") { + self.withdrawal_requests += 1; + } else if label.contains("claimWithdrawals") { + self.withdrawal_claims += 1; + } else if label.contains("wrap") || label.contains("unwrap") { + self.wrapped_ops += 1; + } + + self.labeled_calls.push(LabeledCall { + depth: step.depth, + label, + gas_cost: step.gas_cost, + }); + } + } + + /// Produce the final [`LidoReport`]. + pub(crate) fn into_report(mut self, tx_hash: &str, reverted: bool) -> LidoReport { + self.labeled_calls + .dedup_by(|a, b| a.label == b.label && a.depth == b.depth); + + LidoReport { + tx_hash: tx_hash.to_string(), + total_gas: self.total_gas, + storage_reads: self.storage_reads, + storage_writes: self.storage_writes, + external_calls: self.external_calls, + shares_transfers: self.shares_transfers, + oracle_reports: self.oracle_reports, + withdrawal_requests: self.withdrawal_requests, + withdrawal_claims: self.withdrawal_claims, + wrapped_ops: self.wrapped_ops, + max_depth: self.max_depth, + reverted, + labeled_calls: self.labeled_calls, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn call_step(selector: &str, gas_cost: u64) -> TraceStep { + TraceStep { + op: "CALL".to_string(), + gas_cost, + depth: 1, + stack: Some(vec![selector.to_string()]), + ..Default::default() + } + } + + #[test] + fn accumulator_tracks_storage_and_calls() { + let adapter = LidoAdapter; + let mut acc = LidoAccumulator::default(); + acc.process_step(&TraceStep::evm("SLOAD", 800), &adapter); + acc.process_step(&TraceStep::evm("SSTORE", 20_000), &adapter); + acc.process_step(&call_step("0xa1903eab", 5_000), &adapter); // submit + + let report = acc.into_report("0x1234567890abcdef", false); + assert_eq!(report.storage_reads, 1); + assert_eq!(report.storage_writes, 1); + assert_eq!(report.external_calls, 1); + assert_eq!(report.total_gas, 25_800); + assert_eq!(report.labeled_calls.len(), 1); + assert_eq!(report.labeled_calls[0].label, "stETH::submit"); + } + + #[test] + fn summary_formatting_safe_on_short_hash() { + let acc = LidoAccumulator::default(); + let report = acc.into_report("0x1", false); + let summary = report.summary(); + assert!(summary.contains("0x1")); + } + + #[test] + fn tracks_specialized_lido_operations() { + let adapter = LidoAdapter; + let mut acc = LidoAccumulator::default(); + acc.process_step(&call_step("0x39ba163b", 1_000), &adapter); // transferShares + acc.process_step(&call_step("0x8b6ca260", 2_000), &adapter); // handleOracleReport + acc.process_step(&call_step("0xea598cb0", 3_000), &adapter); // requestWithdrawals + acc.process_step(&call_step("0xe35ea9a5", 4_000), &adapter); // claimWithdrawals + acc.process_step(&call_step("0x0a19ea81", 5_000), &adapter); // wrap + + let report = acc.into_report("0xabcdef", false); + assert_eq!(report.shares_transfers, 1); + assert_eq!(report.oracle_reports, 1); + assert_eq!(report.withdrawal_requests, 1); + assert_eq!(report.withdrawal_claims, 1); + assert_eq!(report.wrapped_ops, 1); + } +} diff --git a/crates/atupa-lido/src/selectors.rs b/crates/atupa-lido/src/selectors.rs new file mode 100644 index 0000000..fc22bbe --- /dev/null +++ b/crates/atupa-lido/src/selectors.rs @@ -0,0 +1,154 @@ +//! Selectors, contract addresses, and lookup helpers for Lido stETH protocol analysis. + +use atupa_core::TraceStep; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/// Selectors for major Lido stETH and wstETH protocol operations. +pub(crate) const LIDO_SELECTORS: &[(&str, &str)] = &[ + ("0xa1903eab", "submit"), // stETH.submit(address _referral) + ("0xea598cb0", "requestWithdrawals"), // Legacy request withdrawals + ("0x826a73d6", "requestWithdrawalsWithPermit"), + ("0xe35ea9a5", "claimWithdrawals"), + ("0x8b6ca260", "handleOracleReport"), // Rebase oracle consensus + ("0x39ba163b", "transferShares"), + ("0x4dbcaef1", "transferSharesFrom"), + ("0xa9059cbb", "transfer"), // ERC-20 generic + ("0x095ea7b3", "approve"), // ERC-20 generic + ("0x0a19ea81", "wrap"), // wstETH wrap + ("0x1dfab2e1", "unwrap"), // wstETH unwrap +]; + +/// Known Lido protocol contract addresses (Ethereum Mainnet, stored lowercase). +pub(crate) const LIDO_ADDRESSES: &[(&str, &str)] = &[ + ( + "0xae7ab96520de3a18e5e111b5eaab095312d7fe84", + "stETH (Lido Core)", + ), + ( + "0x55032650b14df07b85bf18a3a3ec8e0af2e028d5", + "NodeOperatorsRegistry", + ), + ("0x442af752419395f27ed54a848524a30028962bb2", "LidoOracle"), + ( + "0x889edc2bf57978ed079b851d273218ee42a2b349", + "WithdrawalQueue", + ), + ("0x852f970761d74367f33b6c2e309a29d681e2f16a", "LegacyOracle"), + ("0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "wstETH"), +]; + +// ─── Lookup helpers ─────────────────────────────────────────────────────────── + +/// Look up a contract address in the known Lido addresses table. +/// +/// Comparison is case-insensitive. +pub(crate) fn resolve_address(addr: &str) -> Option { + let lower = addr.to_lowercase(); + for &(known, name) in LIDO_ADDRESSES { + if lower == known { + return Some(format!("Lido::{name}")); + } + } + None +} + +/// Look up a 4-byte function selector in the Lido selectors table. +/// +/// Accepts selectors with or without a `0x` prefix and performs case-insensitive +/// matching. +pub(crate) fn resolve_selector(selector: &str) -> Option { + let clean_sel = selector.trim().trim_start_matches("0x").to_lowercase(); + for &(known_sel, label) in LIDO_SELECTORS { + let known_clean = known_sel.trim_start_matches("0x"); + if clean_sel == known_clean || clean_sel.starts_with(known_clean) { + return Some(format!("stETH::{label}")); + } + } + None +} + +/// Returns `true` for EVM opcodes that initiate a new call frame. +#[inline] +pub(crate) fn is_call_opcode(op: &str) -> bool { + matches!(op, "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE") +} + +/// Extract the top-of-stack value from a [`TraceStep`] as a selector string. +pub(crate) fn selector_from_stack(step: &TraceStep) -> Option<&str> { + step.stack.as_ref()?.last().map(String::as_str) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_selector_exact_and_prefixed() { + assert_eq!( + resolve_selector("0xa1903eab"), + Some("stETH::submit".to_string()) + ); + assert_eq!( + resolve_selector("a1903eab"), + Some("stETH::submit".to_string()) + ); + assert_eq!( + resolve_selector("0xA1903EAB"), + Some("stETH::submit".to_string()) + ); + assert_eq!( + resolve_selector("0x0a19ea81"), + Some("stETH::wrap".to_string()) + ); + assert_eq!( + resolve_selector("0x1dfab2e1"), + Some("stETH::unwrap".to_string()) + ); + } + + #[test] + fn resolve_selector_unknown_returns_none() { + assert!(resolve_selector("0xdeadbeef").is_none()); + assert!(resolve_selector("").is_none()); + } + + #[test] + fn resolve_address_case_insensitive() { + assert_eq!( + resolve_address("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"), + Some("Lido::stETH (Lido Core)".to_string()) + ); + assert_eq!( + resolve_address("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0"), + Some("Lido::wstETH".to_string()) + ); + } + + #[test] + fn resolve_address_unknown_returns_none() { + assert!(resolve_address("0x0000000000000000000000000000000000000000").is_none()); + } + + #[test] + fn is_call_opcode_detects_call_variants() { + for op in &["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE"] { + assert!(is_call_opcode(op)); + } + for op in &["SLOAD", "SSTORE", "REVERT", "JUMP"] { + assert!(!is_call_opcode(op)); + } + } + + #[test] + fn selector_from_stack_extracts_last_item() { + let step = TraceStep { + op: "CALL".to_string(), + stack: Some(vec!["0x1111".to_string(), "0xa1903eab".to_string()]), + ..Default::default() + }; + assert_eq!(selector_from_stack(&step), Some("0xa1903eab")); + } +} diff --git a/crates/atupa-lido/src/tracer.rs b/crates/atupa-lido/src/tracer.rs new file mode 100644 index 0000000..0e31ec3 --- /dev/null +++ b/crates/atupa-lido/src/tracer.rs @@ -0,0 +1,195 @@ +//! [`LidoDeepTracer`] — main entry point for Lido stETH trace analysis. + +use atupa_core::{DiffRow, ProtocolDiffReport, TraceStep}; + +use crate::adapter::LidoAdapter; +use crate::report::{LidoAccumulator, LidoReport}; + +/// High-level analysis engine for Lido stETH liquid staking traces. +#[derive(Debug, Default, Clone)] +pub struct LidoDeepTracer { + adapter: LidoAdapter, +} + +impl LidoDeepTracer { + /// Creates a new [`LidoDeepTracer`]. + pub fn new() -> Self { + Self { + adapter: LidoAdapter, + } + } + + /// Analyze a sequence of execution trace steps for Lido-specific patterns. + pub fn analyze_staking( + &self, + tx_hash: &str, + steps: &[TraceStep], + ) -> anyhow::Result { + let mut accumulator = LidoAccumulator::default(); + for step in steps { + accumulator.process_step(step, &self.adapter); + } + + let reverted = steps.last().is_some_and(|s| s.reverted); + Ok(accumulator.into_report(tx_hash, reverted)) + } + + /// Perform a deep field-by-field diff between two Lido executions. + pub fn diff_reports( + &self, + base_tx: &str, + base_steps: &[TraceStep], + target_tx: &str, + target_steps: &[TraceStep], + ) -> anyhow::Result { + let base = self.analyze_staking(base_tx, base_steps)?; + let target = self.analyze_staking(target_tx, target_steps)?; + + Ok(ProtocolDiffReport { + protocol: "Lido stETH".to_string(), + rows: build_diff_rows(&base, &target), + }) + } +} + +// ─── Private helpers ────────────────────────────────────────────────────────── + +/// Construct the ordered list of [`DiffRow`]s for a Lido protocol comparison. +fn build_diff_rows(base: &LidoReport, target: &LidoReport) -> Vec { + vec![ + DiffRow::new( + "Total Gas", + base.total_gas as f64, + target.total_gas as f64, + true, + ), + DiffRow::new( + "Storage Reads", + base.storage_reads as f64, + target.storage_reads as f64, + true, + ), + DiffRow::new( + "Storage Writes", + base.storage_writes as f64, + target.storage_writes as f64, + true, + ), + DiffRow::new( + "External Calls", + base.external_calls as f64, + target.external_calls as f64, + true, + ), + DiffRow::new( + "Shares Transfers", + base.shares_transfers as f64, + target.shares_transfers as f64, + true, + ), + DiffRow::new( + "Oracle Reports", + base.oracle_reports as f64, + target.oracle_reports as f64, + true, + ), + DiffRow::new( + "Withdrawal Requests", + base.withdrawal_requests as f64, + target.withdrawal_requests as f64, + true, + ), + DiffRow::new( + "Withdrawal Claims", + base.withdrawal_claims as f64, + target.withdrawal_claims as f64, + true, + ), + DiffRow::new( + "Wrapped Ops", + base.wrapped_ops as f64, + target.wrapped_ops as f64, + true, + ), + ] +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn call_step(selector: &str, gas_cost: u64) -> TraceStep { + TraceStep { + op: "CALL".to_string(), + gas_cost, + depth: 1, + stack: Some(vec![selector.to_string()]), + ..Default::default() + } + } + + #[test] + fn analyze_staking_produces_valid_report() { + let tracer = LidoDeepTracer::new(); + let steps = vec![ + TraceStep::evm("SLOAD", 800), + TraceStep::evm("SSTORE", 20_000), + call_step("0xa1903eab", 5_000), // submit + ]; + + let report = tracer.analyze_staking("0x123", &steps).unwrap(); + assert_eq!(report.tx_hash, "0x123"); + assert_eq!(report.total_gas, 25_800); + assert_eq!(report.storage_reads, 1); + assert_eq!(report.storage_writes, 1); + assert_eq!(report.external_calls, 1); + assert!(!report.reverted); + } + + #[test] + fn analyze_staking_propagates_revert() { + let tracer = LidoDeepTracer::new(); + let mut step = TraceStep::evm("REVERT", 0); + step.reverted = true; + let report = tracer.analyze_staking("0x123", &[step]).unwrap(); + assert!(report.reverted); + } + + #[test] + fn diff_reports_produces_nine_rows() { + let tracer = LidoDeepTracer::new(); + let base = vec![ + TraceStep::evm("SLOAD", 800), + TraceStep::evm("SSTORE", 20_000), + ]; + let target = vec![TraceStep::evm("SLOAD", 800)]; + + let report = tracer + .diff_reports("0xbase", &base, "0xtarget", &target) + .unwrap(); + assert_eq!(report.protocol, "Lido stETH"); + assert_eq!(report.rows.len(), 9); + } + + #[test] + fn diff_reports_identifies_regression() { + let tracer = LidoDeepTracer::new(); + let base = vec![TraceStep::evm("SSTORE", 20_000)]; + let target = vec![ + TraceStep::evm("SSTORE", 20_000), + TraceStep::evm("SSTORE", 20_000), + ]; + + let report = tracer + .diff_reports("0xbase", &base, "0xtarget", &target) + .unwrap(); + let write_row = report + .rows + .iter() + .find(|r| r.metric == "Storage Writes") + .unwrap(); + assert!(write_row.is_regression()); + } +} diff --git a/crates/atupa-nitro/Cargo.toml b/crates/atupa-nitro/Cargo.toml index 0baa17c..bff9507 100644 --- a/crates/atupa-nitro/Cargo.toml +++ b/crates/atupa-nitro/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-nitro" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } readme = { workspace = true } @@ -17,7 +16,6 @@ atupa-core = { workspace = true } atupa-rpc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -anyhow = { workspace = true } thiserror = { workspace = true } log = { workspace = true } reqwest = { workspace = true } diff --git a/crates/atupa-nitro/src/client.rs b/crates/atupa-nitro/src/client.rs new file mode 100644 index 0000000..0a2a600 --- /dev/null +++ b/crates/atupa-nitro/src/client.rs @@ -0,0 +1,151 @@ +//! JSON-RPC network client for Arbitrum Nitro & Stylus tracing. + +use atupa_rpc::{EthClient, RpcError}; +use serde_json::json; + +use crate::error::{NitroError, NitroResult}; +use crate::stitcher::MixedTraceStitcher; +use crate::types::{StitchedReport, StylusHostIO}; + +/// Arbitrum Nitro RPC client — fetches and stitches dual-VM traces concurrently. +pub struct NitroClient { + base_client: EthClient, + rpc_url: String, + client: reqwest::Client, +} + +impl NitroClient { + /// Creates a new [`NitroClient`] targeting the given JSON-RPC URL. + pub fn new(rpc_url: impl Into) -> Self { + let rpc_url = rpc_url.into(); + Self { + base_client: EthClient::new(rpc_url.clone()), + rpc_url, + client: reqwest::Client::new(), + } + } + + /// Fetches the Stylus HostIO trace for `tx_hash` using the `stylusTracer`. + pub async fn get_stylus_trace(&self, tx_hash: &str) -> NitroResult> { + let payload = json!({ + "jsonrpc": "2.0", + "method": "debug_traceTransaction", + "params": [tx_hash, { "tracer": "stylusTracer" }], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await? + .json::() + .await?; + + if let Some(error) = response.get("error") { + return Err(NitroError::Rpc(RpcError::Node( + error["message"] + .as_str() + .unwrap_or("Unknown RPC error") + .to_string(), + ))); + } + + let result = response.get("result").ok_or_else(|| { + NitroError::Stitch("Missing 'result' in stylusTracer response".into()) + })?; + + Ok(serde_json::from_value(result.clone())?) + } + + /// Fetches both EVM and Stylus traces **concurrently**, then stitches them + /// into a single [`StitchedReport`]. + /// + /// If the `stylusTracer` is unavailable (e.g. pure-EVM transaction on Arbitrum, + /// or an older node version), the error is downgraded to a warning and the report + /// will contain only EVM steps with `total_stylus_ink = 0`. + pub async fn trace_transaction(&self, tx_hash: &str) -> NitroResult { + let chain_id = self.base_client.get_chain_id().await.unwrap_or(0); + let is_nitro = is_nitro_chain(chain_id); + + log::info!( + "atupa-nitro: fetching trace for {} (chain_id: {}, nitro_aware: {})", + tx_hash, + chain_id, + is_nitro + ); + + let (evm_result, stylus_result) = if is_nitro { + tokio::join!( + self.base_client.get_transaction_trace(tx_hash), + self.get_stylus_trace(tx_hash), + ) + } else { + ( + self.base_client.get_transaction_trace(tx_hash).await, + Ok(Vec::new()), + ) + }; + + let evm_trace = evm_result?; + let stylus_trace = stylus_result.unwrap_or_else(|e| { + log::warn!( + "atupa-nitro: stylusTracer unavailable for {} ({}); falling back to pure-EVM.", + tx_hash, + e, + ); + Vec::new() + }); + + let report = + MixedTraceStitcher::stitch(tx_hash, chain_id, evm_trace.struct_logs, stylus_trace); + + log::info!( + "atupa-nitro: {} steps stitched | network: {} | EVM gas: {} | Stylus ink: {} ({:.2} gas-equiv) | boundaries: {}", + report.steps.len(), + chain_id, + report.total_evm_gas, + report.total_stylus_ink, + report.total_stylus_gas_equiv, + report.vm_boundary_count, + ); + + Ok(report) + } +} + +/// Identifies whether a given chain ID is known to support Nitro / Stylus tracing. +pub fn is_nitro_chain(chain_id: u64) -> bool { + match chain_id { + // Known Arbitrum / Nitro chains: One, Nova, Goerli, Sepolia, Stylus testnet, Orbit + 42161 | 42170 | 421611 | 421613 | 421614 | 23011913 => true, + // Local devnets often used for Nitro (nitro-testnode, anvil) + 1337 | 31337 => true, + // Known non-Nitro chains (Ethereum mainnet, Sepolia, Holesky, Base, Optimism, Polygon) + 1 | 11155111 | 17000 | 8453 | 84532 | 10 | 11155420 | 137 => false, + // Unknown: assume potentially Nitro-enabled + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_nitro_chains() { + assert!(is_nitro_chain(42161)); // Arbitrum One + assert!(is_nitro_chain(42170)); // Arbitrum Nova + assert!(is_nitro_chain(421614)); // Arbitrum Sepolia + assert!(is_nitro_chain(1337)); // Local devnet + } + + #[test] + fn detects_non_nitro_chains() { + assert!(!is_nitro_chain(1)); // Ethereum Mainnet + assert!(!is_nitro_chain(11155111)); // Ethereum Sepolia + assert!(!is_nitro_chain(8453)); // Base + assert!(!is_nitro_chain(10)); // Optimism + } +} diff --git a/crates/atupa-nitro/src/error.rs b/crates/atupa-nitro/src/error.rs new file mode 100644 index 0000000..b6009b5 --- /dev/null +++ b/crates/atupa-nitro/src/error.rs @@ -0,0 +1,45 @@ +//! Error types for Arbitrum Nitro and Stylus trace processing. + +use atupa_rpc::RpcError; +use thiserror::Error; + +/// Errors that can occur when querying, parsing, or stitching Arbitrum Nitro traces. +#[derive(Error, Debug)] +pub enum NitroError { + /// HTTP or connection failure when communicating with the node. + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// JSON-RPC node error (e.g. method not found, execution reverted). + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + + /// JSON serialization or deserialization failure. + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// Trace stitching or alignment inconsistency. + #[error("Stitching error: {0}")] + Stitch(String), +} + +/// Convenience result alias for operations returning [`NitroError`]. +pub type NitroResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stitch_error_display() { + let err = NitroError::Stitch("unaligned step".to_string()); + assert_eq!(err.to_string(), "Stitching error: unaligned step"); + } + + #[test] + fn rpc_error_conversion() { + let rpc_err = RpcError::Node("method not supported".to_string()); + let nitro_err: NitroError = rpc_err.into(); + assert!(nitro_err.to_string().contains("RPC error")); + } +} diff --git a/crates/atupa-nitro/src/lib.rs b/crates/atupa-nitro/src/lib.rs index 1f12c2a..c5e05d6 100644 --- a/crates/atupa-nitro/src/lib.rs +++ b/crates/atupa-nitro/src/lib.rs @@ -1,674 +1,38 @@ -use atupa_core::GasCategory; -use atupa_core::VmKind as CoreVmKind; -use atupa_rpc::{EthClient, RawStructLog, RpcError}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::collections::HashMap; -use thiserror::Error; - -// ─── Error Type ────────────────────────────────────────────────────────────── - -#[derive(Error, Debug)] -pub enum NitroError { - #[error("Network error: {0}")] - Network(#[from] reqwest::Error), - #[error("RPC error: {0}")] - Rpc(#[from] RpcError), - #[error("Serialization error: {0}")] - Serialization(#[from] serde_json::Error), - #[error("Stitching error: {0}")] - Stitch(String), -} - -// ─── Stylus Types ───────────────────────────────────────────────────────────── - -/// A single HostIO event emitted by Arbitrum's `stylusTracer`. -/// -/// HostIOs represent cross-VM system calls from WASM back into the Nitro host -/// (e.g. reading storage, emitting logs). Each event tracks its Ink budget. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StylusHostIO { - /// The HostIO function name (e.g. `storage_load_bytes32`, `user_entrypoint`). - pub name: String, - /// Hex-encoded input arguments. - pub args: String, - /// Hex-encoded output values. - pub outs: String, - /// Ink remaining at the START of this HostIO call. - pub start_ink: u64, - /// Ink remaining at the END of this HostIO call. - pub end_ink: u64, - /// Optional: the Stylus contract address that made this call. - #[serde(default)] - pub address: Option, -} - -impl StylusHostIO { - /// Net Ink consumed by this single HostIO event. - /// Ink is a monotonically-decreasing budget; this will always be >= 0. - pub fn ink_consumed(&self) -> u64 { - self.start_ink.saturating_sub(self.end_ink) - } - - /// Converts Ink consumed to an equivalent Gas unit. - /// - /// Arbitrum Nitro defines the canonical ratio: **1 Gas = 10,000 Ink**. - /// This allows unified cost reporting across both VMs. - pub fn ink_as_gas_equiv(&self) -> f64 { - self.ink_consumed() as f64 / 10_000.0 - } -} - -// ─── Unified Step ───────────────────────────────────────────────────────────── - -/// Identifies which virtual machine produced a step. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum VmKind { - Evm, - Stylus, -} - -impl From for CoreVmKind { - fn from(v: VmKind) -> Self { - match v { - VmKind::Evm => CoreVmKind::Evm, - VmKind::Stylus => CoreVmKind::Stylus, - } - } -} - -/// A single step in the merged, time-ordered execution timeline. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UnifiedStep { - /// Sequential index in the merged timeline. - pub index: usize, - /// The VM that produced this step. - pub vm: VmKind, - /// The primary opcode (EVM) or HostIO name (Stylus). - pub label: String, - /// Gas cost for EVM steps; 0 for Stylus steps. - pub gas_cost: u64, - /// Normalised cost-of-execution (Gas for EVM, Ink-as-Gas for Stylus). - pub cost_equiv: f64, - /// Call depth in the EVM frame at this point in execution. - pub depth: u16, - /// True when this is the EVM `CALL` opcode that dispatches into a WASM contract. - pub is_vm_boundary: bool, - /// The logical category of this execution step. - pub category: GasCategory, - /// Target address for CALL/CREATE operations. - pub target_address: Option, - /// Raw EVM structLog, present only for EVM steps. - pub evm: Option, - /// Raw Stylus HostIO, present only for Stylus steps. - pub stylus: Option, -} - -impl UnifiedStep { - /// Converts a unified step back to a core TraceStep, preserving VM identity and depth. - pub fn to_trace_step(&self) -> atupa_core::TraceStep { - if let Some(evm) = &self.evm { - let reverted = evm.error.is_some() || evm.op == "REVERT" || evm.op == "INVALID"; - atupa_core::TraceStep { - pc: evm.pc, - op: evm.op.clone(), - gas: evm.gas, - gas_cost: evm.gas_cost, - depth: evm.depth, - stack: evm.stack.clone(), - memory: evm.memory.clone(), - error: evm.error.clone(), - reverted, - vm_kind: atupa_core::VmKind::Evm, - } - } else if let Some(stylus) = &self.stylus { - atupa_core::TraceStep { - pc: 0, - op: stylus.name.clone(), - gas: 0, - gas_cost: self.cost_equiv.round() as u64, - depth: self.depth, - stack: None, - memory: None, - error: None, - reverted: false, - vm_kind: atupa_core::VmKind::Stylus, - } - } else { - // Fallback for label-only steps - atupa_core::TraceStep { - pc: 0, - op: self.label.clone(), - gas: 0, - gas_cost: 0, - depth: self.depth, - stack: None, - memory: None, - error: None, - reverted: false, - vm_kind: self.vm.clone().into(), - } - } - } -} - -// ─── Stitched Report ────────────────────────────────────────────────────────── - -/// The complete output of the stitching engine for a single transaction. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StitchedReport { - /// The transaction hash that was traced. - pub tx_hash: String, - /// The chain ID of the network being traced. - pub chain_id: u64, - /// Merged, time-ordered execution steps across both VMs. - pub steps: Vec, - /// Total EVM gas consumed across all steps. - pub total_evm_gas: u64, - /// Total Stylus Ink consumed (absolute Ink units). - pub total_stylus_ink: u64, - /// Number of EVM→WASM VM boundary crossings detected. - pub vm_boundary_count: usize, - /// Stylus Ink normalised to Gas-equivalent units. - pub total_stylus_gas_equiv: f64, - /// Combined cost: `total_evm_gas` + `total_stylus_gas_equiv`. - pub total_unified_cost: f64, - /// Aggregated costs by gas category. - pub category_costs: HashMap, - /// Address labels resolved via Etherscan (Address -> Contract Name). - pub resolved_names: HashMap, - /// Actual gas used on-chain from eth_getTransactionReceipt (includes intrinsic cost). - /// None if the receipt fetch was skipped or failed. - pub on_chain_gas_used: Option, -} - -impl StitchedReport { - /// Returns references to only the Stylus/WASM steps. - pub fn stylus_steps(&self) -> Vec<&UnifiedStep> { - self.steps - .iter() - .filter(|s| s.vm == VmKind::Stylus) - .collect() - } - - /// Returns references to the VM boundary (EVM→WASM crossing) steps. - pub fn boundary_steps(&self) -> Vec<&UnifiedStep> { - self.steps.iter().filter(|s| s.is_vm_boundary).collect() - } -} - -// ─── Stitcher Engine ────────────────────────────────────────────────────────── - -/// EVM opcodes that dispatch execution into a Stylus (WASM) contract. -/// These mark the EVM→WASM transition boundary. -const CALL_OPCODES: &[&str] = &["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE"]; - -/// The core engine for merging EVM and WASM execution paths into a unified timeline. -/// -/// ## Background: How Arbitrum Nitro executes hybrid transactions -/// -/// When an EVM contract calls a Stylus contract, Nitro's `debug_traceTransaction` -/// with the default tracer (`structLogger`) records the CALL opcode and then continues -/// as if execution returned immediately. The WASM portion is opaque to the EVM tracer. -/// -/// The `stylusTracer` records only the Stylus side: a sequence of `StylusHostIO` -/// events representing every cross-VM system call made by the WASM code. -/// -/// `MixedTraceStitcher` fuses these two independent traces into a single timeline -/// using the following heuristic: -/// -/// > **"Every CALL opcode in the EVM trace is a potential WASM entry point."** -/// -/// After each CALL, we drain the next batch of Stylus HostIOs and interleave them -/// into the unified timeline at the same call depth. This preserves temporal ordering -/// while clearly annotating which steps belong to which VM. -pub struct MixedTraceStitcher; - -impl MixedTraceStitcher { - /// Stitches EVM structLogs with Stylus HostIO events into a `StitchedReport`. - /// - /// ## Algorithm - /// 1. Stream EVM steps in program-counter order. - /// 2. On a `CALL`/`STATICCALL`/`DELEGATECALL` opcode, mark it as a VM boundary. - /// 3. Drain HostIOs from the Stylus stream, grouping them into the current boundary - /// frame. Stop when a `user_entrypoint` HostIO (signals a fresh Stylus invocation) - /// is encountered AND we have already ingested at least one HostIO in this window. - /// 4. Continue streaming EVM steps from the point immediately after the CALL. - /// 5. Drain any remaining Stylus steps (handles the case where the outer frame itself - /// is a Stylus contract — no preceding EVM CALL will exist). - /// 6. Aggregate totals and build the `StitchedReport`. - pub fn stitch( - tx_hash: impl Into, - chain_id: u64, - evm_logs: Vec, - stylus_logs: Vec, - ) -> StitchedReport { - let tx_hash = tx_hash.into(); - let mut steps: Vec = Vec::with_capacity(evm_logs.len() + stylus_logs.len()); - let mut stylus_iter = stylus_logs.into_iter().peekable(); - - let mut total_evm_gas: u64 = 0; - let mut total_stylus_ink: u64 = 0; - let mut vm_boundary_count: usize = 0; - let mut index: usize = 0; - - for log in evm_logs { - let is_boundary = CALL_OPCODES.contains(&log.op.as_str()); - let gas_cost = log.gas_cost; - let depth = log.depth; - - total_evm_gas = total_evm_gas.saturating_add(gas_cost); - - let category = GasCategory::from_step(&log.op, VmKind::Evm.into()); - - // Extract target address for CALL/CREATE - let mut target_address = None; - if (log.op.contains("CALL") || log.op.contains("CREATE")) - && let Some(stack) = &log.stack - && stack.len() >= 2 - { - let hex_addr = &stack[stack.len() - 2]; - let clean_hex = hex_addr.trim_start_matches("0x"); - let padded = format!("{:0>40}", clean_hex); - let extracted = &padded[padded.len() - 40..]; - target_address = Some(format!("0x{}", extracted.to_lowercase())); - } - - steps.push(UnifiedStep { - index, - vm: VmKind::Evm, - label: log.op.clone(), - gas_cost, - cost_equiv: gas_cost as f64, - depth, - is_vm_boundary: false, - category, - target_address, - evm: Some(log), - stylus: None, - }); - let call_step_index = index; - index += 1; - - if !is_boundary { - continue; - } - - // ── WASM Window ────────────────────────────────────────────────── - // Drain Stylus HostIOs that belong to this boundary frame. - let mut window_host_io_count: usize = 0; - - loop { - // Peek first — we may need to keep the next HostIO for the next window. - let should_break = match stylus_iter.peek() { - None => true, - Some(next) => { - // A second `user_entrypoint` signals a new Stylus invocation frame. - // Break so the NEXT CALL boundary picks it up. - next.name == "user_entrypoint" && window_host_io_count > 0 - } - }; - if should_break { - break; - } - - let host_io = stylus_iter.next().unwrap(); - let ink_used = host_io.ink_consumed(); - total_stylus_ink = total_stylus_ink.saturating_add(ink_used); - window_host_io_count += 1; - - let cost_equiv = host_io.ink_as_gas_equiv(); - let category = GasCategory::from_step(&host_io.name, VmKind::Stylus.into()); - steps.push(UnifiedStep { - index, - vm: VmKind::Stylus, - label: host_io.name.clone(), - gas_cost: 0, - cost_equiv, - depth: depth + 1, // Nest under the owning CALL frame. - is_vm_boundary: false, - category, - target_address: None, - evm: None, - stylus: Some(host_io), - }); - index += 1; - } - - if window_host_io_count > 0 { - vm_boundary_count += 1; - steps[call_step_index].is_vm_boundary = true; - // Boundaries are often categorized as 'Call', but the specific CALL that triggered - // it is already categorized above. - } - } - - // ── Trailing Stylus Steps ──────────────────────────────────────────── - // Drain any Stylus steps that had no matching EVM CALL preceding them. - // This handles transactions where the TOP-LEVEL entrypoint is itself Stylus. - for host_io in stylus_iter { - let ink_used = host_io.ink_consumed(); - total_stylus_ink = total_stylus_ink.saturating_add(ink_used); - - let cost_equiv = host_io.ink_as_gas_equiv(); - let category = GasCategory::from_step(&host_io.name, VmKind::Stylus.into()); - steps.push(UnifiedStep { - index, - vm: VmKind::Stylus, - label: host_io.name.clone(), - gas_cost: 0, - cost_equiv, - depth: 0, - is_vm_boundary: false, - category, - target_address: None, - evm: None, - stylus: Some(host_io), - }); - index += 1; - } - - let total_stylus_gas_equiv = total_stylus_ink as f64 / 10_000.0; - let total_unified_cost = total_evm_gas as f64 + total_stylus_gas_equiv; - - // Aggregate category costs - let mut category_costs = HashMap::new(); - for step in &steps { - *category_costs.entry(step.category.clone()).or_insert(0.0) += step.cost_equiv; - } - - StitchedReport { - tx_hash, - chain_id, - steps, - total_evm_gas, - total_stylus_ink, - vm_boundary_count, - total_stylus_gas_equiv, - total_unified_cost, - category_costs, - resolved_names: HashMap::new(), - on_chain_gas_used: None, - } - } -} - -// ─── Network Client ─────────────────────────────────────────────────────────── - -/// Arbitrum Nitro RPC client — fetches and stitches dual-VM traces concurrently. -pub struct NitroClient { - base_client: EthClient, - rpc_url: String, - client: reqwest::Client, -} - -impl NitroClient { - pub fn new(rpc_url: String) -> Self { - Self { - base_client: EthClient::new(rpc_url.clone()), - rpc_url, - client: reqwest::Client::new(), - } - } - - /// Fetches the Stylus HostIO trace for `tx_hash` using the `stylusTracer`. - pub async fn get_stylus_trace(&self, tx_hash: &str) -> Result, NitroError> { - let payload = json!({ - "jsonrpc": "2.0", - "method": "debug_traceTransaction", - "params": [tx_hash, { "tracer": "stylusTracer" }], - "id": 1 - }); - - let response = self - .client - .post(&self.rpc_url) - .json(&payload) - .send() - .await? - .json::() - .await?; - - if let Some(error) = response.get("error") { - return Err(NitroError::Rpc(RpcError::Node( - error["message"] - .as_str() - .unwrap_or("Unknown RPC error") - .to_string(), - ))); - } - - let result = response.get("result").ok_or_else(|| { - NitroError::Stitch("Missing 'result' in stylusTracer response".into()) - })?; - - Ok(serde_json::from_value(result.clone())?) - } - - /// Fetches both EVM and Stylus traces **concurrently**, then stitches them - /// into a single `StitchedReport`. - /// - /// If the `stylusTracer` is unavailable (e.g. pure-EVM transaction on Arbitrum, - /// or an older node version), the error is silently downgraded and the report - /// will contain only EVM steps with `total_stylus_ink = 0`. - pub async fn trace_transaction(&self, tx_hash: &str) -> Result { - let chain_id = self.base_client.get_chain_id().await.unwrap_or(0); - - let is_nitro = match chain_id { - // Known Arbitrum / Nitro chains - 42161 | 42170 | 421611 | 421613 | 421614 | 23011913 => true, - // Local devnets often used for Nitro - 1337 | 31337 => true, - // Known non-Nitro chains (skip tracer) - 1 | 11155111 | 17000 | 8453 | 84532 | 10 | 11155420 | 137 => false, - // Unknown – try it but don't fail hard - _ => true, - }; - - log::info!( - "atupa-nitro: fetching trace for {} (chain_id: {}, nitro_aware: {})", - tx_hash, - chain_id, - is_nitro - ); - - let (evm_result, stylus_result) = if is_nitro { - tokio::join!( - self.base_client.get_transaction_trace(tx_hash), - self.get_stylus_trace(tx_hash), - ) - } else { - ( - self.base_client.get_transaction_trace(tx_hash).await, - Ok(Vec::new()), - ) - }; - - let evm_trace = evm_result?; - let stylus_trace = stylus_result.unwrap_or_else(|e| { - log::warn!( - "atupa-nitro: stylusTracer unavailable for {} ({}); falling back to pure-EVM.", - tx_hash, - e, - ); - Vec::new() - }); - - let report = - MixedTraceStitcher::stitch(tx_hash, chain_id, evm_trace.struct_logs, stylus_trace); - - log::info!( - "atupa-nitro: {} steps stitched | network: {} | EVM gas: {} | Stylus ink: {} ({:.2} gas-equiv) | boundaries: {}", - report.steps.len(), - chain_id, - report.total_evm_gas, - report.total_stylus_ink, - report.total_stylus_gas_equiv, - report.vm_boundary_count, - ); - - Ok(report) - } -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn evm(op: &str, gas_cost: u64, depth: u16) -> RawStructLog { - RawStructLog { - pc: 0, - op: op.to_string(), - gas: 1_000_000, - gas_cost, - depth, - error: None, - stack: None, - memory: None, - storage: None, - } - } - - fn host_io(name: &str, start_ink: u64, end_ink: u64) -> StylusHostIO { - StylusHostIO { - name: name.to_string(), - args: String::new(), - outs: String::new(), - start_ink, - end_ink, - address: None, - } - } - - #[test] - fn pure_evm_produces_no_stylus_steps() { - let logs = vec![evm("PUSH1", 3, 1), evm("ADD", 3, 1), evm("RETURN", 0, 1)]; - let report = MixedTraceStitcher::stitch("0xabc", 1, logs, vec![]); - - assert_eq!(report.steps.len(), 3); - assert_eq!(report.vm_boundary_count, 0); - assert_eq!(report.total_evm_gas, 6); - assert_eq!(report.total_stylus_ink, 0); - assert!(report.stylus_steps().is_empty()); - } - - #[test] - fn hybrid_tx_stitches_wasm_window_after_call() { - let evm_logs = vec![ - evm("PUSH1", 3, 1), - evm("CALL", 100, 1), // ← VM boundary - evm("RETURN", 0, 1), - ]; - let stylus_logs = vec![ - host_io("user_entrypoint", 1_000_000, 900_000), // 100k ink - host_io("storage_load_bytes32", 900_000, 800_000), // 100k ink - ]; - - let report = MixedTraceStitcher::stitch("0xdef", 42161, evm_logs, stylus_logs); - - // 3 EVM + 2 Stylus = 5 total - assert_eq!(report.steps.len(), 5); - assert_eq!(report.vm_boundary_count, 1); - assert_eq!(report.total_evm_gas, 103); - assert_eq!(report.total_stylus_ink, 200_000); - // Unified cost: 103 gas + 200_000/10_000 gas-equiv = 103 + 20 = 123 - assert!((report.total_unified_cost - 123.0).abs() < f64::EPSILON); - } - - #[test] - fn multiple_call_boundaries_each_get_a_wasm_window() { - let evm_logs = vec![ - evm("CALL", 50, 1), // boundary 1 - evm("STATICCALL", 30, 1), // boundary 2 - ]; - let stylus_logs = vec![ - host_io("user_entrypoint", 500_000, 400_000), // window 1: 100k ink - host_io("user_entrypoint", 300_000, 200_000), // window 2: 100k ink - ]; - - let report = MixedTraceStitcher::stitch("0x111", 42161, evm_logs, stylus_logs); - - assert_eq!(report.vm_boundary_count, 2); - assert_eq!(report.stylus_steps().len(), 2); - // Each user_entrypoint should be in a separate window (depth preserved). - // First window entry is at index 1, second at index 3. - assert_eq!(report.steps[1].label, "user_entrypoint"); - assert_eq!(report.steps[1].category, GasCategory::Execution); - assert_eq!(report.steps[3].label, "user_entrypoint"); - assert_eq!(report.steps[3].category, GasCategory::Execution); - - // Category costs check - assert!(report.category_costs.get(&GasCategory::Call).unwrap() > &0.0); - assert!(report.category_costs.get(&GasCategory::Execution).unwrap() > &0.0); - } - - #[test] - fn top_level_stylus_tx_drains_trailing_host_ios() { - // No EVM CALL — the outer frame IS the Stylus contract. - let report = MixedTraceStitcher::stitch( - "0x999", - 42161, - vec![], - vec![host_io("user_entrypoint", 1_000_000, 900_000)], - ); - assert_eq!(report.stylus_steps().len(), 1); - assert_eq!(report.steps[0].depth, 0); // no EVM depth to inherit - } - - #[test] - fn ink_gas_equiv_ratio_is_correct() { - // 1 Gas = 10,000 Ink. - let h = host_io("test", 20_000, 10_000); - assert_eq!(h.ink_consumed(), 10_000); - assert!((h.ink_as_gas_equiv() - 1.0).abs() < f64::EPSILON); - } - - #[test] - fn boundary_steps_filter_returns_only_calls() { - // Without any HostIO steps, a CALL must NOT be marked as a VM boundary. - // (Pure-EVM transactions have no Stylus crossing — no false positives.) - let evm_logs = vec![evm("ADD", 3, 1), evm("CALL", 100, 1)]; - let report = MixedTraceStitcher::stitch("0xfff", 42161, evm_logs, vec![]); - assert_eq!( - report.boundary_steps().len(), - 0, - "CALL without Stylus steps should not be a boundary" - ); - - // With HostIO steps present, the CALL that precedes them IS a boundary. - let evm_logs2 = vec![evm("ADD", 3, 1), evm("CALL", 100, 1)]; - let stylus_steps = vec![host_io("user_entrypoint", 100_000, 90_000)]; - let report2 = MixedTraceStitcher::stitch("0xfff", 42161, evm_logs2, stylus_steps); - assert_eq!( - report2.boundary_steps().len(), - 1, - "CALL before Stylus steps should be a boundary" - ); - assert_eq!(report2.boundary_steps()[0].label, "CALL"); - } - - #[test] - fn target_address_is_extracted_from_evm_stack() { - // CALL stack: [gas, address, value, argsOffset, argsLength, retOffset, retLength] - // Address is at len-2. - let mut log = evm("CALL", 100, 1); - log.stack = Some(vec![ - "0x0".into(), // retLength - "0x0".into(), // retOffset - "0x4".into(), // argsLength - "0x20".into(), // argsOffset - "0x0".into(), // value - "0x00000000000000000000000071C7656EC7ab88b098defB751B7401B5f6d8976F".into(), // address - "0x1000".into(), // gas - ]); - - let report = MixedTraceStitcher::stitch("0xabc", 1, vec![log], vec![]); - assert_eq!( - report.steps[0].target_address.as_deref(), - Some("0x71c7656ec7ab88b098defb751b7401b5f6d8976f") - ); - } -} +//! # atupa-nitro +//! +//! Arbitrum Nitro and Stylus execution trace stitcher and RPC client. +//! +//! Arbitrum Nitro executes dual-VM transactions where standard EVM contracts +//! interoperate seamlessly with WebAssembly (WASM) Stylus programs. This crate +//! provides: +//! +//! 1. [`MixedTraceStitcher`] — fuses asynchronous `structLogger` (EVM) and +//! `stylusTracer` (WASM) streams into a single time-ordered [`StitchedReport`]. +//! 2. [`NitroClient`] — concurrent dual-tracer RPC client with automatic Nitro +//! chain detection and fallback handling. +//! 3. Cost normalisation between Stylus **Ink** and EVM **Gas** (`1 Gas = 10,000 Ink`). +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`error`] | [`NitroError`] and [`NitroResult`](error::NitroResult) | +//! | [`types`] | [`StylusHostIO`], [`VmKind`], [`UnifiedStep`], and [`StitchedReport`] | +//! | [`stitcher`] | [`MixedTraceStitcher`] and [`CALL_OPCODES`](stitcher::CALL_OPCODES) | +//! | [`client`] | [`NitroClient`] and [`is_nitro_chain`](client::is_nitro_chain) | +//! +//! ## Re-exports +//! +//! All primary types are re-exported at the crate root for downstream convenience. + +pub mod client; +pub mod error; +pub mod stitcher; +pub mod types; + +// ── Flat re-exports ─────────────────────────────────────────────────────────── + +pub use client::{NitroClient, is_nitro_chain}; +pub use error::{NitroError, NitroResult}; +pub use stitcher::{CALL_OPCODES, MixedTraceStitcher}; +pub use types::{StitchedReport, StylusHostIO, UnifiedStep, VmKind}; diff --git a/crates/atupa-nitro/src/stitcher.rs b/crates/atupa-nitro/src/stitcher.rs new file mode 100644 index 0000000..2e8f0ed --- /dev/null +++ b/crates/atupa-nitro/src/stitcher.rs @@ -0,0 +1,377 @@ +//! Dual-VM execution timeline stitching engine for Arbitrum Nitro & Stylus traces. + +use atupa_core::GasCategory; +use atupa_core::VmKind as CoreVmKind; +use atupa_rpc::RawStructLog; +use std::collections::HashMap; + +use crate::types::{StitchedReport, StylusHostIO, UnifiedStep, VmKind}; + +/// EVM opcodes that dispatch execution into a Stylus (WASM) contract. +/// These mark the EVM→WASM transition boundary. +pub const CALL_OPCODES: &[&str] = &["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE"]; + +/// The core engine for merging EVM and WASM execution paths into a unified timeline. +/// +/// ## Background: How Arbitrum Nitro executes hybrid transactions +/// +/// When an EVM contract calls a Stylus contract, Nitro's `debug_traceTransaction` +/// with the default tracer (`structLogger`) records the `CALL` opcode and then continues +/// as if execution returned immediately. The WASM portion is opaque to the EVM tracer. +/// +/// The `stylusTracer` records only the Stylus side: a sequence of `StylusHostIO` +/// events representing every cross-VM system call made by the WASM code. +/// +/// `MixedTraceStitcher` fuses these two independent traces into a single timeline +/// using the following heuristic: +/// +/// > **"Every CALL opcode in the EVM trace is a potential WASM entry point."** +/// +/// After each `CALL`, we drain the next batch of Stylus HostIOs and interleave them +/// into the unified timeline at the nested call depth (`depth + 1`). This preserves +/// temporal ordering while clearly annotating which steps belong to which VM. +pub struct MixedTraceStitcher; + +impl MixedTraceStitcher { + /// Stitches EVM structLogs with Stylus HostIO events into a [`StitchedReport`]. + /// + /// ## Algorithm + /// 1. Stream EVM steps in program-counter order. + /// 2. On a `CALL`/`STATICCALL`/`DELEGATECALL`/`CALLCODE` opcode, mark it as a VM boundary. + /// 3. Drain HostIOs from the Stylus stream, grouping them into the current boundary + /// frame. Stop when a `user_entrypoint` HostIO (signals a fresh Stylus invocation) + /// is encountered AND we have already ingested at least one HostIO in this window. + /// 4. Continue streaming EVM steps from the point immediately after the `CALL`. + /// 5. Drain any remaining Stylus steps (handles the case where the outer frame itself + /// is a Stylus contract — no preceding EVM CALL will exist). + /// 6. Aggregate totals and build the finished [`StitchedReport`]. + pub fn stitch( + tx_hash: impl Into, + chain_id: u64, + evm_logs: Vec, + stylus_logs: Vec, + ) -> StitchedReport { + let tx_hash = tx_hash.into(); + let mut steps: Vec = Vec::with_capacity(evm_logs.len() + stylus_logs.len()); + let mut stylus_iter = stylus_logs.into_iter().peekable(); + + let mut total_evm_gas: u64 = 0; + let mut total_stylus_ink: u64 = 0; + let mut vm_boundary_count: usize = 0; + let mut index: usize = 0; + + for log in evm_logs { + let is_boundary = CALL_OPCODES.contains(&log.op.as_str()); + let gas_cost = log.gas_cost; + let depth = log.depth; + + total_evm_gas = total_evm_gas.saturating_add(gas_cost); + let category = GasCategory::from_step(&log.op, &CoreVmKind::Evm); + let target_address = extract_target_address(&log); + + steps.push(UnifiedStep { + index, + vm: VmKind::Evm, + label: log.op.clone(), + gas_cost, + cost_equiv: gas_cost as f64, + depth, + is_vm_boundary: false, + category, + target_address, + evm: Some(log), + stylus: None, + }); + let call_step_index = index; + index += 1; + + if !is_boundary { + continue; + } + + // ── WASM Window ────────────────────────────────────────────────── + // Drain Stylus HostIOs that belong to this boundary frame. + let window_ink = drain_wasm_window(&mut stylus_iter, &mut steps, &mut index, depth + 1); + + if window_ink > 0 { + total_stylus_ink = total_stylus_ink.saturating_add(window_ink); + vm_boundary_count += 1; + steps[call_step_index].is_vm_boundary = true; + } + } + + // ── Trailing Stylus Steps ──────────────────────────────────────────── + // Drain any Stylus steps that had no matching EVM CALL preceding them. + // Handles transactions where the TOP-LEVEL entrypoint is itself Stylus. + let trailing_ink = drain_trailing_stylus_steps(&mut stylus_iter, &mut steps, &mut index); + total_stylus_ink = total_stylus_ink.saturating_add(trailing_ink); + + let total_stylus_gas_equiv = total_stylus_ink as f64 / 10_000.0; + let total_unified_cost = total_evm_gas as f64 + total_stylus_gas_equiv; + let category_costs = aggregate_category_costs(&steps); + + StitchedReport { + tx_hash, + chain_id, + steps, + total_evm_gas, + total_stylus_ink, + vm_boundary_count, + total_stylus_gas_equiv, + total_unified_cost, + category_costs, + resolved_names: HashMap::new(), + on_chain_gas_used: None, + } + } +} + +// ─── Private helpers ────────────────────────────────────────────────────────── + +/// Extract target contract address from stack for CALL/CREATE operations. +pub(crate) fn extract_target_address(log: &RawStructLog) -> Option { + if (log.op.contains("CALL") || log.op.contains("CREATE")) + && let Some(stack) = &log.stack + && stack.len() >= 2 + { + let hex_addr = &stack[stack.len() - 2]; + let clean_hex = hex_addr.trim_start_matches("0x"); + let padded = format!("{:0>40}", clean_hex); + let extracted = &padded[padded.len() - 40..]; + Some(format!("0x{}", extracted.to_lowercase())) + } else { + None + } +} + +/// Drain Stylus HostIO events belonging to a specific EVM call boundary window. +fn drain_wasm_window( + stylus_iter: &mut std::iter::Peekable, + steps: &mut Vec, + index: &mut usize, + depth: u16, +) -> u64 +where + I: Iterator, +{ + let mut window_ink: u64 = 0; + let mut window_count: usize = 0; + + loop { + let should_break = match stylus_iter.peek() { + None => true, + Some(next) => next.name == "user_entrypoint" && window_count > 0, + }; + if should_break { + break; + } + + let host_io = stylus_iter.next().unwrap(); + let ink_used = host_io.ink_consumed(); + window_ink = window_ink.saturating_add(ink_used); + window_count += 1; + + let cost_equiv = host_io.ink_as_gas_equiv(); + let category = GasCategory::from_step(&host_io.name, &CoreVmKind::Stylus); + steps.push(UnifiedStep { + index: *index, + vm: VmKind::Stylus, + label: host_io.name.clone(), + gas_cost: 0, + cost_equiv, + depth, + is_vm_boundary: false, + category, + target_address: None, + evm: None, + stylus: Some(host_io), + }); + *index += 1; + } + + window_ink +} + +/// Drain any remaining Stylus HostIO events at depth 0. +fn drain_trailing_stylus_steps( + stylus_iter: &mut std::iter::Peekable, + steps: &mut Vec, + index: &mut usize, +) -> u64 +where + I: Iterator, +{ + let mut trailing_ink: u64 = 0; + for host_io in stylus_iter.by_ref() { + let ink_used = host_io.ink_consumed(); + trailing_ink = trailing_ink.saturating_add(ink_used); + + let cost_equiv = host_io.ink_as_gas_equiv(); + let category = GasCategory::from_step(&host_io.name, &CoreVmKind::Stylus); + steps.push(UnifiedStep { + index: *index, + vm: VmKind::Stylus, + label: host_io.name.clone(), + gas_cost: 0, + cost_equiv, + depth: 0, + is_vm_boundary: false, + category, + target_address: None, + evm: None, + stylus: Some(host_io), + }); + *index += 1; + } + trailing_ink +} + +/// Aggregate step costs by their GasCategory. +fn aggregate_category_costs(steps: &[UnifiedStep]) -> HashMap { + let mut category_costs = HashMap::new(); + for step in steps { + *category_costs.entry(step.category.clone()).or_insert(0.0) += step.cost_equiv; + } + category_costs +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn evm(op: &str, gas_cost: u64, depth: u16) -> RawStructLog { + RawStructLog { + pc: 0, + op: op.to_string(), + gas: 1_000_000, + gas_cost, + depth, + error: None, + stack: None, + memory: None, + storage: None, + } + } + + fn host_io(name: &str, start_ink: u64, end_ink: u64) -> StylusHostIO { + StylusHostIO { + name: name.to_string(), + args: String::new(), + outs: String::new(), + start_ink, + end_ink, + address: None, + } + } + + #[test] + fn pure_evm_produces_no_stylus_steps() { + let logs = vec![evm("PUSH1", 3, 1), evm("ADD", 3, 1), evm("RETURN", 0, 1)]; + let report = MixedTraceStitcher::stitch("0xabc", 1, logs, vec![]); + + assert_eq!(report.steps.len(), 3); + assert_eq!(report.vm_boundary_count, 0); + assert_eq!(report.total_evm_gas, 6); + assert_eq!(report.total_stylus_ink, 0); + assert!(report.stylus_steps().is_empty()); + } + + #[test] + fn hybrid_tx_stitches_wasm_window_after_call() { + let evm_logs = vec![ + evm("PUSH1", 3, 1), + evm("CALL", 100, 1), // ← VM boundary + evm("RETURN", 0, 1), + ]; + let stylus_logs = vec![ + host_io("user_entrypoint", 1_000_000, 900_000), // 100k ink + host_io("storage_load_bytes32", 900_000, 800_000), // 100k ink + ]; + + let report = MixedTraceStitcher::stitch("0xdef", 42161, evm_logs, stylus_logs); + + assert_eq!(report.steps.len(), 5); + assert_eq!(report.vm_boundary_count, 1); + assert_eq!(report.total_evm_gas, 103); + assert_eq!(report.total_stylus_ink, 200_000); + assert!((report.total_unified_cost - 123.0).abs() < f64::EPSILON); + } + + #[test] + fn multiple_call_boundaries_each_get_a_wasm_window() { + let evm_logs = vec![ + evm("CALL", 50, 1), // boundary 1 + evm("STATICCALL", 30, 1), // boundary 2 + ]; + let stylus_logs = vec![ + host_io("user_entrypoint", 500_000, 400_000), // window 1: 100k ink + host_io("user_entrypoint", 300_000, 200_000), // window 2: 100k ink + ]; + + let report = MixedTraceStitcher::stitch("0x111", 42161, evm_logs, stylus_logs); + + assert_eq!(report.vm_boundary_count, 2); + assert_eq!(report.stylus_steps().len(), 2); + assert_eq!(report.steps[1].label, "user_entrypoint"); + assert_eq!(report.steps[1].category, GasCategory::Execution); + assert_eq!(report.steps[3].label, "user_entrypoint"); + assert_eq!(report.steps[3].category, GasCategory::Execution); + assert!(report.category_costs.get(&GasCategory::Call).unwrap() > &0.0); + assert!(report.category_costs.get(&GasCategory::Execution).unwrap() > &0.0); + } + + #[test] + fn top_level_stylus_tx_drains_trailing_host_ios() { + let report = MixedTraceStitcher::stitch( + "0x999", + 42161, + vec![], + vec![host_io("user_entrypoint", 1_000_000, 900_000)], + ); + assert_eq!(report.stylus_steps().len(), 1); + assert_eq!(report.steps[0].depth, 0); + } + + #[test] + fn boundary_steps_filter_returns_only_calls() { + let evm_logs = vec![evm("ADD", 3, 1), evm("CALL", 100, 1)]; + let report = MixedTraceStitcher::stitch("0xfff", 42161, evm_logs, vec![]); + assert_eq!( + report.boundary_steps().len(), + 0, + "CALL without Stylus steps should not be a boundary" + ); + + let evm_logs2 = vec![evm("ADD", 3, 1), evm("CALL", 100, 1)]; + let stylus_steps = vec![host_io("user_entrypoint", 100_000, 90_000)]; + let report2 = MixedTraceStitcher::stitch("0xfff", 42161, evm_logs2, stylus_steps); + assert_eq!( + report2.boundary_steps().len(), + 1, + "CALL before Stylus steps should be a boundary" + ); + assert_eq!(report2.boundary_steps()[0].label, "CALL"); + } + + #[test] + fn target_address_is_extracted_from_evm_stack() { + let mut log = evm("CALL", 100, 1); + log.stack = Some(vec![ + "0x0".into(), + "0x0".into(), + "0x4".into(), + "0x20".into(), + "0x0".into(), + "0x00000000000000000000000071C7656EC7ab88b098defB751B7401B5f6d8976F".into(), + "0x1000".into(), + ]); + + let report = MixedTraceStitcher::stitch("0xabc", 1, vec![log], vec![]); + assert_eq!( + report.steps[0].target_address.as_deref(), + Some("0x71c7656ec7ab88b098defb751b7401b5f6d8976f") + ); + } +} diff --git a/crates/atupa-nitro/src/types.rs b/crates/atupa-nitro/src/types.rs new file mode 100644 index 0000000..ad18bbf --- /dev/null +++ b/crates/atupa-nitro/src/types.rs @@ -0,0 +1,358 @@ +//! Domain types for Arbitrum Nitro EVM and Stylus WASM trace modeling. + +use atupa_core::GasCategory; +use atupa_core::VmKind as CoreVmKind; +use atupa_rpc::RawStructLog; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +// ─── Stylus HostIO ──────────────────────────────────────────────────────────── + +/// A single HostIO event emitted by Arbitrum's `stylusTracer`. +/// +/// HostIOs represent cross-VM system calls from WASM back into the Nitro host +/// (e.g. reading storage, emitting logs). Each event tracks its Ink budget. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StylusHostIO { + /// The HostIO function name (e.g. `storage_load_bytes32`, `user_entrypoint`). + pub name: String, + /// Hex-encoded input arguments. + pub args: String, + /// Hex-encoded output values. + pub outs: String, + /// Ink remaining at the START of this HostIO call. + pub start_ink: u64, + /// Ink remaining at the END of this HostIO call. + pub end_ink: u64, + /// Optional: the Stylus contract address that made this call. + #[serde(default)] + pub address: Option, +} + +impl StylusHostIO { + /// Net Ink consumed by this single HostIO event. + /// + /// Ink is a monotonically-decreasing budget; this will always be `>= 0`. + #[inline] + pub fn ink_consumed(&self) -> u64 { + self.start_ink.saturating_sub(self.end_ink) + } + + /// Converts Ink consumed to an equivalent Gas unit. + /// + /// Arbitrum Nitro defines the canonical ratio: **1 Gas = 10,000 Ink**. + /// This allows unified cost reporting across both VMs. + #[inline] + pub fn ink_as_gas_equiv(&self) -> f64 { + self.ink_consumed() as f64 / 10_000.0 + } +} + +// ─── VM Kind ────────────────────────────────────────────────────────────────── + +/// Identifies which virtual machine produced a trace step in the unified timeline. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, Hash)] +pub enum VmKind { + /// Standard EVM execution step. + #[default] + Evm, + /// Arbitrum Stylus WASM HostIO step. + Stylus, + /// Starknet Cairo VM step. + Starknet, + /// Solana Sealevel VM step. + Solana, + /// Stellar Soroban HostFn step. + Stellar, +} + +impl fmt::Display for VmKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VmKind::Evm => write!(f, "EVM"), + VmKind::Stylus => write!(f, "Stylus"), + VmKind::Starknet => write!(f, "Starknet"), + VmKind::Solana => write!(f, "Solana"), + VmKind::Stellar => write!(f, "Stellar"), + } + } +} + +impl From for CoreVmKind { + fn from(v: VmKind) -> Self { + match v { + VmKind::Evm => CoreVmKind::Evm, + VmKind::Stylus => CoreVmKind::Stylus, + VmKind::Starknet => CoreVmKind::Starknet, + VmKind::Solana => CoreVmKind::Solana, + VmKind::Stellar => CoreVmKind::Stellar, + } + } +} + +// ─── Unified Step ───────────────────────────────────────────────────────────── + +/// A single step in the merged, time-ordered execution timeline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnifiedStep { + /// Sequential index in the merged timeline. + pub index: usize, + /// The VM that produced this step. + pub vm: VmKind, + /// The primary opcode (EVM) or HostIO name (Stylus). + pub label: String, + /// Gas cost for EVM steps; 0 for Stylus steps. + pub gas_cost: u64, + /// Normalised cost-of-execution (Gas for EVM, Ink-as-Gas for Stylus). + pub cost_equiv: f64, + /// Call depth in the EVM frame at this point in execution. + pub depth: u16, + /// True when this is the EVM `CALL` opcode that dispatches into a WASM contract. + pub is_vm_boundary: bool, + /// The logical category of this execution step. + pub category: GasCategory, + /// Target address for CALL/CREATE operations. + pub target_address: Option, + /// Raw EVM structLog, present only for EVM steps. + pub evm: Option, + /// Raw Stylus HostIO, present only for Stylus steps. + pub stylus: Option, +} + +impl UnifiedStep { + /// Returns `true` if this step originated from the EVM. + pub fn is_evm(&self) -> bool { + self.vm == VmKind::Evm + } + + /// Returns `true` if this step originated from Stylus WASM. + pub fn is_stylus(&self) -> bool { + self.vm == VmKind::Stylus + } + + /// Converts a unified step back to a core [`atupa_core::TraceStep`], preserving + /// VM identity, depth, and normalized costs. + pub fn to_trace_step(&self) -> atupa_core::TraceStep { + if let Some(evm) = &self.evm { + let reverted = evm.error.is_some() || evm.op == "REVERT" || evm.op == "INVALID"; + atupa_core::TraceStep { + pc: evm.pc, + op: evm.op.clone(), + gas: evm.gas, + gas_cost: evm.gas_cost, + depth: evm.depth, + stack: evm.stack.clone(), + memory: evm.memory.clone(), + error: evm.error.clone(), + reverted, + vm_kind: atupa_core::VmKind::Evm, + } + } else if let Some(stylus) = &self.stylus { + atupa_core::TraceStep { + pc: 0, + op: stylus.name.clone(), + gas: 0, + gas_cost: self.cost_equiv.round() as u64, + depth: self.depth, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: atupa_core::VmKind::Stylus, + } + } else { + // Fallback for synthetic/label-only steps + atupa_core::TraceStep { + pc: 0, + op: self.label.clone(), + gas: 0, + gas_cost: self.gas_cost, + depth: self.depth, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: self.vm.clone().into(), + } + } + } +} + +// ─── Stitched Report ────────────────────────────────────────────────────────── + +/// The complete output of the stitching engine for a single transaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StitchedReport { + /// The transaction hash that was traced. + pub tx_hash: String, + /// The chain ID of the network being traced. + pub chain_id: u64, + /// Merged, time-ordered execution steps across both VMs. + pub steps: Vec, + /// Total EVM gas consumed across all steps. + pub total_evm_gas: u64, + /// Total Stylus Ink consumed (absolute Ink units). + pub total_stylus_ink: u64, + /// Number of EVM→WASM VM boundary crossings detected. + pub vm_boundary_count: usize, + /// Stylus Ink normalised to Gas-equivalent units. + pub total_stylus_gas_equiv: f64, + /// Combined cost: `total_evm_gas` + `total_stylus_gas_equiv`. + pub total_unified_cost: f64, + /// Aggregated costs by gas category. + pub category_costs: HashMap, + /// Address labels resolved via contract registry or Etherscan. + pub resolved_names: HashMap, + /// Actual gas used on-chain from receipt (if available). + pub on_chain_gas_used: Option, +} + +impl StitchedReport { + /// Returns references to only the Stylus/WASM steps. + pub fn stylus_steps(&self) -> Vec<&UnifiedStep> { + self.steps + .iter() + .filter(|s| s.vm == VmKind::Stylus) + .collect() + } + + /// Returns references to only the EVM steps. + pub fn evm_steps(&self) -> Vec<&UnifiedStep> { + self.steps.iter().filter(|s| s.vm == VmKind::Evm).collect() + } + + /// Returns references to the VM boundary (EVM→WASM crossing) steps. + pub fn boundary_steps(&self) -> Vec<&UnifiedStep> { + self.steps.iter().filter(|s| s.is_vm_boundary).collect() + } + + /// Returns a one-line summary string of this report. + pub fn summary(&self) -> String { + let short_hash = self.tx_hash.get(..10).unwrap_or(&self.tx_hash); + format!( + "[NitroReport] tx={} steps={} evm_gas={} stylus_ink={} ({:.1} gas-equiv) boundaries={}", + short_hash, + self.steps.len(), + self.total_evm_gas, + self.total_stylus_ink, + self.total_stylus_gas_equiv, + self.vm_boundary_count, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stylus_host_io_cost_calculations() { + let io = StylusHostIO { + name: "storage_load_bytes32".to_string(), + args: "".to_string(), + outs: "".to_string(), + start_ink: 1_000_000, + end_ink: 900_000, + address: None, + }; + assert_eq!(io.ink_consumed(), 100_000); + assert!((io.ink_as_gas_equiv() - 10.0).abs() < f64::EPSILON); + } + + #[test] + fn vm_kind_display_and_conversion() { + assert_eq!(VmKind::Evm.to_string(), "EVM"); + assert_eq!(VmKind::Stylus.to_string(), "Stylus"); + assert_eq!(VmKind::Starknet.to_string(), "Starknet"); + assert_eq!(VmKind::Solana.to_string(), "Solana"); + assert_eq!(VmKind::Stellar.to_string(), "Stellar"); + + let core_vm: CoreVmKind = VmKind::Stylus.into(); + assert_eq!(core_vm, CoreVmKind::Stylus); + } + + #[test] + fn unified_step_to_trace_step_evm() { + let step = UnifiedStep { + index: 0, + vm: VmKind::Evm, + label: "SSTORE".to_string(), + gas_cost: 20_000, + cost_equiv: 20_000.0, + depth: 1, + is_vm_boundary: false, + category: GasCategory::StorageWrite, + target_address: None, + evm: Some(RawStructLog { + pc: 10, + op: "SSTORE".to_string(), + gas: 500_000, + gas_cost: 20_000, + depth: 1, + error: None, + stack: None, + memory: None, + storage: None, + }), + stylus: None, + }; + + assert!(step.is_evm()); + assert!(!step.is_stylus()); + let trace_step = step.to_trace_step(); + assert_eq!(trace_step.op, "SSTORE"); + assert_eq!(trace_step.gas_cost, 20_000); + assert_eq!(trace_step.vm_kind, CoreVmKind::Evm); + } + + #[test] + fn unified_step_to_trace_step_stylus() { + let step = UnifiedStep { + index: 1, + vm: VmKind::Stylus, + label: "storage_load_bytes32".to_string(), + gas_cost: 0, + cost_equiv: 10.0, + depth: 2, + is_vm_boundary: false, + category: GasCategory::StorageRead, + target_address: None, + evm: None, + stylus: Some(StylusHostIO { + name: "storage_load_bytes32".to_string(), + args: "".to_string(), + outs: "".to_string(), + start_ink: 100_000, + end_ink: 0, + address: None, + }), + }; + + assert!(step.is_stylus()); + let trace_step = step.to_trace_step(); + assert_eq!(trace_step.op, "storage_load_bytes32"); + assert_eq!(trace_step.gas_cost, 10); + assert_eq!(trace_step.vm_kind, CoreVmKind::Stylus); + } + + #[test] + fn report_summary_safe_on_short_hash() { + let report = StitchedReport { + tx_hash: "0x12".to_string(), + chain_id: 42161, + steps: Vec::new(), + total_evm_gas: 0, + total_stylus_ink: 0, + vm_boundary_count: 0, + total_stylus_gas_equiv: 0.0, + total_unified_cost: 0.0, + category_costs: HashMap::new(), + resolved_names: HashMap::new(), + on_chain_gas_used: None, + }; + let summary = report.summary(); + assert!(summary.contains("0x12")); + } +} diff --git a/crates/atupa-output/Cargo.toml b/crates/atupa-output/Cargo.toml index b1d8e6e..5906cc5 100644 --- a/crates/atupa-output/Cargo.toml +++ b/crates/atupa-output/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-output" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -14,9 +13,5 @@ categories = { workspace = true } [dependencies] atupa-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } anyhow = { workspace = true } askama = { workspace = true } -log = { workspace = true } -# SVG generation logic will be here diff --git a/crates/atupa-output/src/common.rs b/crates/atupa-output/src/common.rs new file mode 100644 index 0000000..39feda6 --- /dev/null +++ b/crates/atupa-output/src/common.rs @@ -0,0 +1,95 @@ +//! Shared layout constants and rendering helpers for SVG flamegraph generation. + +/// Standard total canvas width for generated SVGs in pixels. +pub const SVG_WIDTH: f64 = 1000.0; + +/// Left and right padding inside the SVG canvas. +pub const PADDING_LEFT: f64 = 10.0; + +/// Usable chart width for rendering stack bars. +pub const CHART_WIDTH: f64 = SVG_WIDTH - PADDING_LEFT * 2.0; + +/// Height of a single stack bar in pixels. +pub const BAR_HEIGHT: f64 = 26.0; + +/// Vertical gap between adjacent depth lanes in pixels. +pub const BAR_GAP: f64 = 4.0; + +/// Top header space for legend and title in standard flamegraphs. +pub const HEADER_HEIGHT: f64 = 36.0; + +/// Top header space in diff flamegraphs. +pub const DIFF_HEADER_HEIGHT: f64 = 60.0; + +/// Height reserved for the EVM/WASM divider row. +pub const SEPARATOR_HEIGHT: f64 = 28.0; + +/// Minimum pixel width required to render a bar (prevents 0-width visual artifacts). +pub const MIN_BAR_PX: f64 = 2.0; + +/// Approximate horizontal character width for font rendering calculations (Inter @ 11px ≈ 7.0px). +const CHAR_WIDTH_PX: f64 = 7.0; + +/// Renders a fallback SVG canvas displaying an informational message. +pub fn render_empty_svg(message: &str) -> String { + format!( + r##"{}"##, + message + ) +} + +/// Truncates a text label with an ellipsis (`…`) so that it fits comfortably +/// inside a bar of width `bar_width` pixels. +pub fn truncate_label(text: &str, bar_width: f64) -> String { + let max_chars = ((bar_width - 8.0) / CHAR_WIDTH_PX) as usize; + if max_chars < 3 { + return String::new(); + } + if text.len() <= max_chars { + text.to_string() + } else { + format!("{}…", &text[..max_chars.saturating_sub(1)]) + } +} + +/// Extracts the leaf opcode or label from a semi-colon delimited stack path. +/// +/// E.g. `"CALL;SSTORE;KECCAK256"` -> `"KECCAK256"`. +#[inline] +pub fn stack_leaf(stack: &str) -> &str { + stack.split(';').next_back().unwrap_or(stack) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stack_leaf_extraction() { + assert_eq!(stack_leaf("CALL;SSTORE;KECCAK256"), "KECCAK256"); + assert_eq!(stack_leaf("SINGLE_OP"), "SINGLE_OP"); + assert_eq!(stack_leaf(""), ""); + } + + #[test] + fn truncate_label_behavior() { + // Wide bar: no truncation + assert_eq!(truncate_label("submit", 100.0), "submit"); + + // Medium bar: truncates with ellipsis + let truncated = truncate_label("very_long_function_name_that_overflows", 70.0); + assert!(truncated.ends_with('…')); + assert!(truncated.len() < "very_long_function_name_that_overflows".len()); + + // Narrow bar (< 3 chars): returns empty string + assert_eq!(truncate_label("submit", 15.0), ""); + } + + #[test] + fn render_empty_svg_contains_message() { + let svg = render_empty_svg("No execution data found."); + assert!(svg.contains("No execution data found.")); + assert!(svg.starts_with("")); + } +} diff --git a/crates/atupa-output/src/diff.rs b/crates/atupa-output/src/diff.rs index cfc2740..6df965a 100644 --- a/crates/atupa-output/src/diff.rs +++ b/crates/atupa-output/src/diff.rs @@ -1,6 +1,14 @@ +//! Visual differential flamegraph generation between baseline and target execution traces. + use atupa_core::{CollapsedStack, VmKind}; use std::collections::HashMap; +use crate::common::{ + BAR_GAP, BAR_HEIGHT, CHART_WIDTH, DIFF_HEADER_HEIGHT, MIN_BAR_PX, PADDING_LEFT, + SEPARATOR_HEIGHT, SVG_WIDTH, render_empty_svg, stack_leaf, truncate_label, +}; + +/// Internal merged entry representing a unique stack path across baseline and target. struct DiffEntry { stack: String, depth: u16, @@ -12,78 +20,28 @@ struct DiffEntry { reverted: bool, } +/// Generates a visual differential flamegraph comparing a baseline trace against a target trace. +/// +/// Green bars represent performance improvements (reduced gas / removed paths), +/// red bars represent regressions (increased gas / new paths), and gray bars represent stable paths. pub fn generate_diff_flamegraph( baseline_stacks: &[CollapsedStack], target_stacks: &[CollapsedStack], ) -> anyhow::Result { - // 1. Merge Stacks by exact path string - let mut merged: HashMap = HashMap::new(); - - for s in baseline_stacks { - merged.insert( - s.stack.clone(), - DiffEntry { - stack: s.stack.clone(), - depth: s.depth, - vm_kind: s.vm_kind.clone(), - baseline_weight: s.weight, - target_weight: 0, - resolved_label: s.resolved_label.clone(), - target_address: s.target_address.clone(), - reverted: s.reverted, - }, - ); - } - - for s in target_stacks { - if let Some(entry) = merged.get_mut(&s.stack) { - entry.target_weight += s.weight; - } else { - merged.insert( - s.stack.clone(), - DiffEntry { - stack: s.stack.clone(), - depth: s.depth, - vm_kind: s.vm_kind.clone(), - baseline_weight: 0, - target_weight: s.weight, - resolved_label: s.resolved_label.clone(), - target_address: s.target_address.clone(), - reverted: s.reverted, - }, - ); - } - } - + let merged = merge_diff_entries(baseline_stacks, target_stacks); let entries: Vec<&DiffEntry> = merged.values().collect(); + if entries.is_empty() || entries .iter() .all(|e| e.baseline_weight == 0 && e.target_weight == 0) { - return Ok( - "\ - \ - No execution data found for diff.\ - " - .to_string(), - ); + return Ok(render_empty_svg("No execution data found for diff.")); } - const SVG_W: f64 = 1000.0; - const PAD_L: f64 = 10.0; - const CHART_W: f64 = SVG_W - PAD_L * 2.0; - const BAR_H: f64 = 26.0; - const GAP: f64 = 4.0; - const HEADER_H: f64 = 60.0; - const SEPARATOR_H: f64 = 28.0; - const MIN_BAR_PX: f64 = 2.0; - let evm_entries: Vec<&&DiffEntry> = entries .iter() - .filter(|e| e.vm_kind == VmKind::Evm) + .filter(|e| e.vm_kind != VmKind::Stylus) .collect(); let mut wasm_entries: Vec<&&DiffEntry> = entries .iter() @@ -95,16 +53,13 @@ pub fn generate_diff_flamegraph( depths.sort_unstable(); depths.dedup(); - let mut svg = String::new(); - // We will build the SVG body first to know the total height let mut body = String::new(); - let mut current_y = HEADER_H; + let mut current_y = DIFF_HEADER_HEIGHT; - // ── EVM lanes ───────────────────────────────────────────────────────────── + // ── EVM / Standard Depth Lanes ──────────────────────────────────────────── for depth in &depths { let mut lane_entries: Vec<&&&DiffEntry> = evm_entries.iter().filter(|e| e.depth == *depth).collect(); - // Sort by stack string to maintain deterministic left-to-right ordering lane_entries.sort_by(|a, b| a.stack.cmp(&b.stack)); let lane_weight: u64 = lane_entries @@ -115,35 +70,35 @@ pub fn generate_diff_flamegraph( continue; } - let mut bar_x = PAD_L; + let mut bar_x = PADDING_LEFT; for entry in &lane_entries { let node_weight = std::cmp::max(entry.baseline_weight, entry.target_weight); if node_weight == 0 { continue; } - let bar_w = (node_weight as f64 / lane_weight as f64) * CHART_W; + let bar_w = (node_weight as f64 / lane_weight as f64) * CHART_WIDTH; if bar_w < MIN_BAR_PX { continue; } - render_diff_bar(&mut body, entry, bar_x, current_y, bar_w - 1.0, BAR_H); + render_diff_bar(&mut body, entry, bar_x, current_y, bar_w - 1.0, BAR_HEIGHT); bar_x += bar_w; } - current_y += BAR_H + GAP; + current_y += BAR_HEIGHT + BAR_GAP; } - // ── WASM lanes ──────────────────────────────────────────────────────────── + // ── Stylus WASM Lanes ───────────────────────────────────────────────────── if has_wasm { - current_y += SEPARATOR_H; + current_y += SEPARATOR_HEIGHT; // Draw separator body.push_str(&format!( r##""##, - PAD_L, current_y - 14.0, SVG_W - PAD_L, current_y - 14.0 + PADDING_LEFT, current_y - 14.0, SVG_WIDTH - PADDING_LEFT, current_y - 14.0 )); body.push_str(&format!( r##"STYLUS HOST I/O"##, - SVG_W / 2.0, current_y - 10.0 + SVG_WIDTH / 2.0, current_y - 10.0 )); let global_wasm_weight: u64 = wasm_entries @@ -152,33 +107,33 @@ pub fn generate_diff_flamegraph( .sum(); wasm_entries.sort_by(|a, b| a.stack.cmp(&b.stack)); - let mut bar_x = PAD_L; + let mut bar_x = PADDING_LEFT; for entry in &wasm_entries { let node_weight = std::cmp::max(entry.baseline_weight, entry.target_weight); if node_weight == 0 { continue; } let bar_w = if global_wasm_weight > 0 { - (node_weight as f64 / global_wasm_weight as f64) * CHART_W + (node_weight as f64 / global_wasm_weight as f64) * CHART_WIDTH } else { - CHART_W / wasm_entries.len() as f64 + CHART_WIDTH / wasm_entries.len() as f64 }; if bar_w < MIN_BAR_PX { continue; } - render_diff_bar(&mut body, entry, bar_x, current_y, bar_w - 1.0, BAR_H); + render_diff_bar(&mut body, entry, bar_x, current_y, bar_w - 1.0, BAR_HEIGHT); bar_x += bar_w; } - current_y += BAR_H + GAP; + current_y += BAR_HEIGHT + BAR_GAP; } let total_height = current_y + 60.0; + let mut svg = String::new(); - // Build final SVG svg.push_str(&format!( r##""##, - SVG_W, total_height, SVG_W, total_height + SVG_WIDTH, total_height, SVG_WIDTH, total_height )); svg.push_str( @@ -210,7 +165,7 @@ pub fn generate_diff_flamegraph( // Title svg.push_str(&format!( r##"Atupa Visual Diff Flamegraph"##, - SVG_W / 2.0 + SVG_WIDTH / 2.0 )); // Legend @@ -221,6 +176,53 @@ pub fn generate_diff_flamegraph( Ok(svg) } +// ─── Private Helpers ────────────────────────────────────────────────────────── + +fn merge_diff_entries( + baseline_stacks: &[CollapsedStack], + target_stacks: &[CollapsedStack], +) -> HashMap { + let mut merged: HashMap = HashMap::new(); + + for s in baseline_stacks { + merged.insert( + s.stack.clone(), + DiffEntry { + stack: s.stack.clone(), + depth: s.depth, + vm_kind: s.vm_kind.clone(), + baseline_weight: s.weight, + target_weight: 0, + resolved_label: s.resolved_label.clone(), + target_address: s.target_address.clone(), + reverted: s.reverted, + }, + ); + } + + for s in target_stacks { + if let Some(entry) = merged.get_mut(&s.stack) { + entry.target_weight += s.weight; + } else { + merged.insert( + s.stack.clone(), + DiffEntry { + stack: s.stack.clone(), + depth: s.depth, + vm_kind: s.vm_kind.clone(), + baseline_weight: 0, + target_weight: s.weight, + resolved_label: s.resolved_label.clone(), + target_address: s.target_address.clone(), + reverted: s.reverted, + }, + ); + } + } + + merged +} + fn render_diff_bar(out: &mut String, entry: &DiffEntry, x: f64, y: f64, w: f64, h: f64) { let baseline = entry.baseline_weight; let target = entry.target_weight; @@ -234,13 +236,7 @@ fn render_diff_bar(out: &mut String, entry: &DiffEntry, x: f64, y: f64, w: f64, )); out.push_str(&format!(r##"{}"##, tooltip)); - let display_name = get_truncated_name( - &entry.stack, - &entry.resolved_label, - &entry.target_address, - w, - target, - ); + let display_name = get_diff_display_name(entry, w); if !display_name.is_empty() { out.push_str(&format!( r##"{}"##, @@ -251,7 +247,7 @@ fn render_diff_bar(out: &mut String, entry: &DiffEntry, x: f64, y: f64, w: f64, } } -fn get_diff_class(baseline: u64, target: u64) -> &'static str { +pub(crate) fn get_diff_class(baseline: u64, target: u64) -> &'static str { if baseline == 0 && target == 0 { return "box-stable"; } @@ -276,56 +272,38 @@ fn get_diff_class(baseline: u64, target: u64) -> &'static str { fn format_diff_tooltip(entry: &DiffEntry) -> String { let baseline = entry.baseline_weight; let target = entry.target_weight; - let leaf = entry.stack.split(';').next_back().unwrap_or(&entry.stack); + let leaf = stack_leaf(&entry.stack); let prefix = if entry.reverted { "REVERTED — " } else { "" }; - let vm = if entry.vm_kind == VmKind::Evm { - "EVM" - } else { - "Stylus" + let vm = match entry.vm_kind { + VmKind::Stylus => "Stylus", + _ => "EVM", }; if baseline == 0 { - return format!("{}{} [{}] | NEW: {} gas", prefix, leaf, vm, target); + return format!("{prefix}{leaf} [{vm}] | NEW: {target} gas"); } if target == 0 { - return format!("{}{} [{}] | REMOVED: {} gas", prefix, leaf, vm, baseline); + return format!("{prefix}{leaf} [{vm}] | REMOVED: {baseline} gas"); } let diff = target as i64 - baseline as i64; let percent = (diff as f64 / baseline as f64) * 100.0; - format!( - "{}{} [{}] | {} -> {} gas ({:+.2}%)", - prefix, leaf, vm, baseline, target, percent - ) + format!("{prefix}{leaf} [{vm}] | {baseline} -> {target} gas ({percent:+.2}%)") } -fn get_truncated_name( - stack: &str, - resolved: &Option, - addr: &Option, - w: f64, - weight: u64, -) -> String { - let leaf = stack.split(';').next_back().unwrap_or(stack); - let base = if let Some(r) = resolved { +fn get_diff_display_name(entry: &DiffEntry, w: f64) -> String { + let leaf = stack_leaf(&entry.stack); + let base = if let Some(r) = &entry.resolved_label { r.clone() - } else if let Some(a) = addr { - format!("{} [{}]", leaf, a) + } else if let Some(a) = &entry.target_address { + format!("{leaf} [{a}]") } else { - format!("{} ({} gas)", leaf, weight) + format!("{leaf} ({} gas)", entry.target_weight) }; - let max_chars = ((w - 12.0) / 7.0) as usize; - if max_chars < 3 { - return String::new(); - } - if base.len() <= max_chars { - base - } else { - format!("{}…", &base[..max_chars.saturating_sub(1)]) - } + truncate_label(&base, w) } fn render_diff_legend(out: &mut String, y: f64) { @@ -353,3 +331,46 @@ fn render_diff_legend(out: &mut String, y: f64) { )); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_stack(op: &str, weight: u64, depth: u16) -> CollapsedStack { + CollapsedStack { + stack: op.to_string(), + weight, + last_pc: Some(0), + depth, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: None, + reverted: false, + } + } + + #[test] + fn empty_diff_returns_placeholder() { + let svg = generate_diff_flamegraph(&[], &[]).unwrap(); + assert!(svg.contains("No execution data found for diff.")); + } + + #[test] + fn diff_class_determination() { + assert_eq!(get_diff_class(100, 150), "box-regress"); + assert_eq!(get_diff_class(100, 50), "box-improve"); + assert_eq!(get_diff_class(100, 100), "box-stable"); + assert_eq!(get_diff_class(0, 100), "box-regress"); + assert_eq!(get_diff_class(100, 0), "box-improve"); + } + + #[test] + fn diff_flamegraph_renders_valid_svg() { + let base = vec![make_stack("SSTORE", 20_000, 1)]; + let target = vec![make_stack("SSTORE", 15_000, 1)]; + + let svg = generate_diff_flamegraph(&base, &target).unwrap(); + assert!(svg.contains("Atupa Visual Diff Flamegraph")); + assert!(svg.contains("box-improve")); + } +} diff --git a/crates/atupa-output/src/flamegraph.rs b/crates/atupa-output/src/flamegraph.rs new file mode 100644 index 0000000..8d154d7 --- /dev/null +++ b/crates/atupa-output/src/flamegraph.rs @@ -0,0 +1,346 @@ +//! Depth-lane SVG flamegraph generation for single-transaction executions. + +use askama::Template; +use atupa_core::{CollapsedStack, VmKind}; + +use crate::common::{ + BAR_GAP, BAR_HEIGHT, CHART_WIDTH, HEADER_HEIGHT, MIN_BAR_PX, PADDING_LEFT, SEPARATOR_HEIGHT, + SVG_WIDTH, render_empty_svg, stack_leaf, truncate_label, +}; + +// ─── Template Types ─────────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "flamegraph.svg")] +struct FlamegraphTemplate { + stacks: Vec, + width: u32, + height: u32, + has_wasm: bool, + has_starknet: bool, + has_solana: bool, + has_stellar: bool, +} + +struct StackEntry { + x: f64, + y: f64, + bar_width: f64, + label: String, + tooltip: String, + class: String, + /// True for the very first Stylus/WASM bar — renderer draws a separator above it. + is_wasm_section_start: bool, + /// y-coordinate of the separator line (meaningful when `is_wasm_section_start` is true). + separator_y: f64, +} + +// ─── Renderer ──────────────────────────────────────────────────────────────── + +/// Generates visual SVG flamegraphs from aggregated execution stacks. +pub struct SvgGenerator; + +impl SvgGenerator { + /// Generates a depth-lane, multi-VM SVG flamegraph. + /// + /// ## Layout Rules + /// - EVM (and non-WASM) stacks are arranged in horizontal swim lanes by call depth. + /// Deeper calls are placed in lower lanes so visual nesting matches the call hierarchy. + /// - Within each depth lane, bars are laid out left-to-right proportional to their weight. + /// - Stylus/WASM HostIO steps render below a separator in a dedicated amber lane. + /// - Reverted stacks use a distinct red gradient. + pub fn generate_flamegraph(stacks: &[CollapsedStack]) -> anyhow::Result { + if stacks.is_empty() || stacks.iter().all(|s| s.weight == 0) { + return Ok(render_empty_svg("No execution data found.")); + } + + let evm_stacks: Vec<&CollapsedStack> = stacks + .iter() + .filter(|s| s.vm_kind != VmKind::Stylus) + .collect(); + let wasm_stacks: Vec<&CollapsedStack> = stacks + .iter() + .filter(|s| s.vm_kind == VmKind::Stylus) + .collect(); + let has_wasm = !wasm_stacks.is_empty(); + + let global_evm_weight: u64 = evm_stacks.iter().map(|s| s.weight).sum(); + let global_wasm_weight: u64 = wasm_stacks.iter().map(|s| s.weight).sum(); + + let mut entries: Vec = Vec::new(); + let mut current_y = HEADER_HEIGHT; + + // 1. Layout standard depth lanes (EVM / non-Stylus) + layout_depth_lanes(&evm_stacks, global_evm_weight, &mut entries, &mut current_y); + + // 2. Layout Stylus/WASM section if present + if has_wasm { + layout_wasm_section( + &wasm_stacks, + global_wasm_weight, + &mut entries, + &mut current_y, + ); + } + + let has_starknet = evm_stacks.iter().any(|s| s.vm_kind == VmKind::Starknet); + let has_solana = evm_stacks.iter().any(|s| s.vm_kind == VmKind::Solana); + let has_stellar = evm_stacks.iter().any(|s| s.vm_kind == VmKind::Stellar); + + let height = (current_y + 16.0) as u32; + let template = FlamegraphTemplate { + stacks: entries, + width: SVG_WIDTH as u32, + height, + has_wasm, + has_starknet, + has_solana, + has_stellar, + }; + + Ok(template.render()?) + } +} + +// ─── Private Layout Helpers ─────────────────────────────────────────────────── + +fn layout_depth_lanes( + evm_stacks: &[&CollapsedStack], + global_weight: u64, + entries: &mut Vec, + current_y: &mut f64, +) { + let mut depths: Vec = evm_stacks.iter().map(|s| s.depth).collect(); + depths.sort_unstable(); + depths.dedup(); + + for depth in &depths { + let lane_stacks: Vec<&&CollapsedStack> = + evm_stacks.iter().filter(|s| s.depth == *depth).collect(); + let lane_weight: u64 = lane_stacks.iter().map(|s| s.weight).sum(); + if lane_weight == 0 { + continue; + } + + let mut bar_x = PADDING_LEFT; + for stack in &lane_stacks { + if stack.weight == 0 { + continue; + } + let bar_w = (stack.weight as f64 / lane_weight as f64) * CHART_WIDTH; + if bar_w < MIN_BAR_PX { + continue; + } + + let class = get_stack_css_class(stack); + let label = make_bar_label(stack, bar_w); + let tooltip = build_tooltip(stack, global_weight); + + entries.push(StackEntry { + x: bar_x, + y: *current_y, + bar_width: bar_w - 1.0, // 1px breathing gap between siblings + label, + tooltip, + class, + is_wasm_section_start: false, + separator_y: 0.0, + }); + bar_x += bar_w; + } + + *current_y += BAR_HEIGHT + BAR_GAP; + } +} + +fn layout_wasm_section( + wasm_stacks: &[&CollapsedStack], + global_weight: u64, + entries: &mut Vec, + current_y: &mut f64, +) { + *current_y += SEPARATOR_HEIGHT; + let mut bar_x = PADDING_LEFT; + + for stack in wasm_stacks { + if stack.weight == 0 { + continue; + } + let bar_w = if global_weight > 0 { + (stack.weight as f64 / global_weight as f64) * CHART_WIDTH + } else { + CHART_WIDTH / wasm_stacks.len() as f64 + }; + if bar_w < MIN_BAR_PX { + continue; + } + + let label = make_bar_label(stack, bar_w); + let pct = if global_weight > 0 { + stack.weight as f64 / global_weight as f64 * 100.0 + } else { + 0.0 + }; + let tooltip = format!( + "{} | Stylus HostIO | {:.2} gas-equiv ({:.1}%)", + stack_leaf(&stack.stack), + stack.weight as f64, + pct + ); + + let is_first_wasm = entries.iter().all(|e| e.class != "box-wasm"); + entries.push(StackEntry { + x: bar_x, + y: *current_y, + bar_width: bar_w - 1.0, + label, + tooltip, + class: "box-wasm".to_string(), + is_wasm_section_start: is_first_wasm, + separator_y: *current_y - 18.0, + }); + bar_x += bar_w; + } + + *current_y += BAR_HEIGHT + BAR_GAP; +} + +fn get_stack_css_class(stack: &CollapsedStack) -> String { + if stack.reverted { + "box-revert".to_string() + } else { + match stack.vm_kind { + VmKind::Starknet => "box-starknet".to_string(), + VmKind::Solana => "box-solana".to_string(), + VmKind::Stellar => "box-stellar".to_string(), + _ => "box-evm".to_string(), + } + } +} + +fn make_bar_label(stack: &CollapsedStack, bar_w: f64) -> String { + let base = if let Some(r) = &stack.resolved_label { + r.clone() + } else if let Some(addr) = &stack.target_address { + format!("{} [{}]", stack_leaf(&stack.stack), addr) + } else { + format!("{} ({} gas)", stack_leaf(&stack.stack), stack.weight) + }; + + truncate_label(&base, bar_w) +} + +fn build_tooltip(stack: &CollapsedStack, global_weight: u64) -> String { + let pct = if global_weight > 0 { + stack.weight as f64 / global_weight as f64 * 100.0 + } else { + 0.0 + }; + + let leaf = stack_leaf(&stack.stack); + if stack.reverted { + format!( + "REVERTED — {} | depth {} | {} gas ({:.1}%)", + leaf, stack.depth, stack.weight, pct + ) + } else { + format!( + "{} | depth {} | {} gas ({:.1}%)", + leaf, stack.depth, stack.weight, pct + ) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn evm_stack() -> CollapsedStack { + CollapsedStack { + stack: "CALL".to_string(), + weight: 21_000, + last_pc: Some(0), + depth: 1, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: None, + reverted: false, + } + } + + fn stack_with_vm(vm_kind: VmKind) -> CollapsedStack { + CollapsedStack { + stack: "OP".to_string(), + weight: 1_000, + last_pc: Some(0), + depth: 1, + vm_kind, + target_address: None, + resolved_label: None, + reverted: false, + } + } + + #[test] + fn empty_stacks_returns_placeholder() { + let svg = SvgGenerator::generate_flamegraph(&[]).unwrap(); + assert!(svg.contains("No execution data found.")); + } + + #[test] + fn zero_weight_stacks_returns_placeholder() { + let mut stack = evm_stack(); + stack.weight = 0; + let svg = SvgGenerator::generate_flamegraph(&[stack]).unwrap(); + assert!(svg.contains("No execution data found.")); + } + + #[test] + fn legend_pure_evm_shows_revert_not_solana() { + let stacks = vec![evm_stack()]; + let svg = SvgGenerator::generate_flamegraph(&stacks).expect("SVG generated"); + + assert!(svg.contains(r#"class="box-evm""#)); + assert!(!svg.contains(r#"class="box-solana""#)); + assert!(svg.contains(r#"class="box-revert""#)); + } + + #[test] + fn legend_starknet_trace() { + let stacks = vec![stack_with_vm(VmKind::Starknet)]; + let svg = SvgGenerator::generate_flamegraph(&stacks).expect("SVG generated"); + + assert!(svg.contains(r#"class="box-starknet""#)); + assert!(!svg.contains(r#"class="box-solana""#)); + assert!(!svg.contains(r#"class="box-stellar""#)); + } + + #[test] + fn legend_solana_trace() { + let stacks = vec![stack_with_vm(VmKind::Solana)]; + let svg = SvgGenerator::generate_flamegraph(&stacks).expect("SVG generated"); + + assert!(svg.contains(r#"class="box-solana""#)); + assert!(!svg.contains(r#"class="box-starknet""#)); + } + + #[test] + fn legend_stellar_trace() { + let stacks = vec![stack_with_vm(VmKind::Stellar)]; + let svg = SvgGenerator::generate_flamegraph(&stacks).expect("SVG generated"); + + assert!(svg.contains(r#"class="box-stellar""#)); + assert!(!svg.contains(r#"class="box-solana""#)); + } + + #[test] + fn legend_stylus_trace() { + let stacks = vec![stack_with_vm(VmKind::Stylus)]; + let svg = SvgGenerator::generate_flamegraph(&stacks).expect("SVG generated"); + + assert!(svg.contains(r#"class="box-wasm""#)); + assert!(!svg.contains(r#"class="box-solana""#)); + } +} diff --git a/crates/atupa-output/src/lib.rs b/crates/atupa-output/src/lib.rs index c2fcec9..982d187 100644 --- a/crates/atupa-output/src/lib.rs +++ b/crates/atupa-output/src/lib.rs @@ -1,240 +1,29 @@ -use askama::Template; -use atupa_core::{CollapsedStack, VmKind}; - +//! # atupa-output +//! +//! Visual SVG flamegraph rendering engine for single and differential EVM/Stylus traces. +//! +//! Provides two primary generators: +//! 1. [`SvgGenerator`] — renders depth-lane, multi-VM single-transaction flamegraphs. +//! 2. [`generate_diff_flamegraph`] — renders visual differential flamegraphs highlighting +//! regressions, improvements, and changes between two executions. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`common`] | Layout constants, label truncation, and SVG placeholders | +//! | [`flamegraph`] | [`SvgGenerator`] for single execution traces | +//! | [`diff`] | [`generate_diff_flamegraph`] for differential trace analysis | +//! +//! ## Re-exports +//! +//! Primary entry points are re-exported at the crate root. + +pub mod common; pub mod diff; -pub use diff::generate_diff_flamegraph; - -// ─── Template types ────────────────────────────────────────────────────────── - -#[derive(Template)] -#[template(path = "flamegraph.svg")] -struct FlamegraphTemplate { - stacks: Vec, - width: u32, - height: u32, - has_wasm: bool, -} - -struct StackEntry { - x: f64, - y: f64, - bar_width: f64, - label: String, - tooltip: String, - class: String, - /// True for the very first Stylus/WASM bar — renderer draws separator above it. - is_wasm_section_start: bool, - /// y-coordinate of the separator line (only meaningful when is_wasm_section_start) - separator_y: f64, -} - -// ─── Renderer ──────────────────────────────────────────────────────────────── - -pub struct SvgGenerator; - -impl SvgGenerator { - /// Generates a depth-lane, dual-VM SVG flamegraph. - /// - /// Layout rules: - /// - EVM stacks are arranged in horizontal swim lanes by call depth. - /// Deeper calls are placed in lower lanes so the visual nesting matches - /// the actual call hierarchy. - /// - Within each depth lane the bars are laid out left-to-right proportional - /// to their gas weight. - /// - Stylus/WASM HostIO steps render below a separator in a dedicated amber lane. - /// - Reverted stacks use a red gradient. - pub fn generate_flamegraph(stacks: &[CollapsedStack]) -> anyhow::Result { - if stacks.is_empty() || stacks.iter().all(|s| s.weight == 0) { - return Ok( - "\ - \ - No execution data found.\ - " - .to_string(), - ); - } - - const SVG_W: f64 = 1000.0; - const PAD_L: f64 = 10.0; - const CHART_W: f64 = SVG_W - PAD_L * 2.0; - const BAR_H: f64 = 26.0; - const GAP: f64 = 4.0; - const HEADER_H: f64 = 36.0; // row for legend + title - const SEPARATOR_H: f64 = 28.0; // height of the EVM/WASM divider row - const MIN_BAR_PX: f64 = 2.0; - - let evm_stacks: Vec<&CollapsedStack> = - stacks.iter().filter(|s| s.vm_kind == VmKind::Evm).collect(); - let wasm_stacks: Vec<&CollapsedStack> = stacks - .iter() - .filter(|s| s.vm_kind == VmKind::Stylus) - .collect(); - let has_wasm = !wasm_stacks.is_empty(); - - // Gather unique depths for EVM stacks, sorted ascending (depth 1 on top). - let mut depths: Vec = evm_stacks.iter().map(|s| s.depth).collect(); - depths.sort_unstable(); - depths.dedup(); - - // Total EVM weight is per depth-lane (each lane fills CHART_W independently) - // but we need the global total for the tooltip percentage. - let global_evm_weight: u64 = evm_stacks.iter().map(|s| s.weight).sum(); - let global_wasm_weight: u64 = wasm_stacks.iter().map(|s| s.weight).sum(); - - let mut entries: Vec = Vec::new(); - let mut current_y = HEADER_H; - - // ── EVM depth lanes ─────────────────────────────────────────────────── - for depth in &depths { - let lane_stacks: Vec<&&CollapsedStack> = - evm_stacks.iter().filter(|s| s.depth == *depth).collect(); - let lane_weight: u64 = lane_stacks.iter().map(|s| s.weight).sum(); - if lane_weight == 0 { - continue; - } +pub mod flamegraph; - let mut bar_x = PAD_L; - for stack in &lane_stacks { - if stack.weight == 0 { - continue; - } - let bar_w = (stack.weight as f64 / lane_weight as f64) * CHART_W; - if bar_w < MIN_BAR_PX { - continue; - } +// ── Flat re-exports ─────────────────────────────────────────────────────────── - let class = if stack.reverted { - "box-revert" - } else { - "box-evm" - }; - let label = Self::make_label(stack, bar_w); - let pct = if global_evm_weight > 0 { - stack.weight as f64 / global_evm_weight as f64 * 100.0 - } else { - 0.0 - }; - let tooltip = if stack.reverted { - format!( - "REVERTED — {} | depth {} | {} gas ({:.1}%)", - Self::stack_leaf(stack), - stack.depth, - stack.weight, - pct - ) - } else { - format!( - "{} | depth {} | {} gas ({:.1}%)", - Self::stack_leaf(stack), - stack.depth, - stack.weight, - pct - ) - }; - - entries.push(StackEntry { - x: bar_x, - y: current_y, - bar_width: bar_w - 1.0, // 1px breathing gap between siblings - label, - tooltip, - class: class.to_string(), - is_wasm_section_start: false, - separator_y: 0.0, - }); - bar_x += bar_w; - } - - current_y += BAR_H + GAP; - } - - // ── WASM section ───────────────────────────────────────────────────── - if has_wasm { - // Spacer / label row — rendered via template has_wasm flag not via entries - current_y += SEPARATOR_H; - - let mut bar_x = PAD_L; - for stack in &wasm_stacks { - if stack.weight == 0 { - continue; - } - let bar_w = if global_wasm_weight > 0 { - (stack.weight as f64 / global_wasm_weight as f64) * CHART_W - } else { - CHART_W / wasm_stacks.len() as f64 - }; - if bar_w < MIN_BAR_PX { - continue; - } - - let label = Self::make_label(stack, bar_w); - let pct = if global_wasm_weight > 0 { - stack.weight as f64 / global_wasm_weight as f64 * 100.0 - } else { - 0.0 - }; - let tooltip = format!( - "{} | Stylus HostIO | {:.2} gas-equiv ({:.1}%)", - Self::stack_leaf(stack), - stack.weight as f64, - pct - ); - - let is_first_wasm = entries.iter().all(|e| e.class != "box-wasm"); - entries.push(StackEntry { - x: bar_x, - y: current_y, - bar_width: bar_w - 1.0, - label, - tooltip, - class: "box-wasm".to_string(), - is_wasm_section_start: is_first_wasm, - separator_y: current_y - 18.0, - }); - bar_x += bar_w; - } - - current_y += BAR_H + GAP; - } - - let height = (current_y + 16.0) as u32; - let template = FlamegraphTemplate { - stacks: entries, - width: SVG_W as u32, - height, - has_wasm, - }; - Ok(template.render()?) - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - /// Label shown inside the bar. Uses resolved_label if present, otherwise - /// builds "LEAF (N gas)". Truncates to fit the available pixel width. - fn make_label(stack: &CollapsedStack, bar_w: f64) -> String { - let base = if let Some(r) = &stack.resolved_label { - r.clone() - } else if let Some(addr) = &stack.target_address { - format!("{} [{}]", Self::stack_leaf(stack), addr) - } else { - format!("{} ({} gas)", Self::stack_leaf(stack), stack.weight) - }; - - // Approximate character fit: Inter/mono ≈ 7px per char at 12px - let max_chars = ((bar_w - 8.0) / 7.0) as usize; - if max_chars < 3 { - return String::new(); - } - if base.len() <= max_chars { - base - } else { - format!("{}…", &base[..max_chars.saturating_sub(1)]) - } - } - - fn stack_leaf(stack: &CollapsedStack) -> &str { - stack.stack.split(';').next_back().unwrap_or(&stack.stack) - } -} +pub use diff::generate_diff_flamegraph; +pub use flamegraph::SvgGenerator; diff --git a/crates/atupa-output/templates/flamegraph.svg b/crates/atupa-output/templates/flamegraph.svg index 7401091..b5b285e 100644 --- a/crates/atupa-output/templates/flamegraph.svg +++ b/crates/atupa-output/templates/flamegraph.svg @@ -15,6 +15,21 @@ + + + + + + + + + + + + + + + @@ -32,9 +50,27 @@ {% if has_wasm %} Stylus / WASM - {% endif %} Reverted + {% else if has_starknet %} + + Starknet Cairo + + Reverted + {% else if has_solana %} + + Solana Program + + Reverted + {% else if has_stellar %} + + Stellar Soroban + + Reverted + {% else %} + + Reverted + {% endif %} {% for entry in stacks %} diff --git a/crates/atupa-parser/Cargo.toml b/crates/atupa-parser/Cargo.toml index 6c998ae..2347157 100644 --- a/crates/atupa-parser/Cargo.toml +++ b/crates/atupa-parser/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-parser" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -16,9 +15,6 @@ categories = { workspace = true } atupa-core = { workspace = true } atupa-rpc = { workspace = true } atupa-adapters = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } log = { workspace = true } [dev-dependencies] diff --git a/crates/atupa-parser/src/aggregator.rs b/crates/atupa-parser/src/aggregator.rs index dfa5b24..ab779cf 100644 --- a/crates/atupa-parser/src/aggregator.rs +++ b/crates/atupa-parser/src/aggregator.rs @@ -1,163 +1,74 @@ +//! Execution trace aggregator — collapses sequential trace steps into weighted call stacks. + +use atupa_adapters::AdapterRegistry; use atupa_core::{CollapsedStack, TraceStep, VmKind}; use log::debug; use std::collections::HashMap; +use crate::decoder::{extract_memory_selector, extract_target_address}; + +/// Aggregates linear execution steps into weighted, collapsed call-stack paths for flamegraph visualization. pub struct Aggregator; impl Aggregator { - /// Build collapsed stacks from a sequence of raw trace steps (structLogs style). - /// - /// # Algorithm - /// 1. Walk through execution steps - /// 2. Track call stack depth - /// 3. Build stack strings for each gas-consuming operation - /// 4. Aggregate by unique stack (sum gas weights) - /// - /// Processes a stream of `TraceStep` and aggregates them into collapsed call-stacks for visualization. - #[allow(clippy::collapsible_if)] + /// Build collapsed stacks from a sequence of raw trace steps using the default [`AdapterRegistry`]. pub fn build_collapsed_stacks(steps: &[TraceStep]) -> Vec { + let registry = AdapterRegistry::new(); + Self::build_collapsed_stacks_with_registry(steps, ®istry) + } + + /// Build collapsed stacks from a sequence of trace steps using a custom [`AdapterRegistry`]. + pub fn build_collapsed_stacks_with_registry( + steps: &[TraceStep], + registry: &AdapterRegistry, + ) -> Vec { debug!( "Building collapsed stacks from {} execution steps", steps.len() ); - struct AggregatedData { - total_gas: u64, - _last_pc: u64, - max_depth: u16, - target_address: Option, - resolved_label: Option, - reverted: bool, - vm_kind: VmKind, - } - - let registry = atupa_adapters::AdapterRegistry::new(); - - // Map to aggregate stacks: stack_string -> AggregatedData let mut stack_map: HashMap = HashMap::new(); - - // Current call stack let mut call_stack: Vec = Vec::new(); for step in steps { - let operation = step.op.clone(); + let operation = &step.op; let current_depth = step.depth as usize; - // If depth decreased, we returned from function calls + // 1. Maintain call stack depth if current_depth < call_stack.len() { call_stack.truncate(current_depth); } - - // If depth increased, we entered a new call while call_stack.len() < current_depth { call_stack.push("CALL".to_string()); } - // Extract Target Address & Parse Function Selector if this is a Call opcode - let mut target_address = None; - let mut resolved_label = None; - - if operation == "CALL" - || operation == "STATICCALL" - || operation == "DELEGATECALL" - || operation == "CALLCODE" - { - if let Some(stack) = &step.stack { - if stack.len() >= 2 { - // Extract target address (second item from top) - let hex_addr = &stack[stack.len() - 2]; - let clean_hex = hex_addr.trim_start_matches("0x"); - let padded = format!("{:0>40}", clean_hex); - let extracted = &padded[padded.len() - 40..]; - target_address = Some(format!("0x{}", extracted)); - } - - // Attempt to extract the 4-byte selector from Memory using Offset & Length - let mut args_offset_idx = None; - let mut args_length_idx = None; - - if operation == "CALL" || operation == "CALLCODE" { - if stack.len() >= 5 { - args_offset_idx = Some(stack.len() - 4); - args_length_idx = Some(stack.len() - 5); - } - } else if (operation == "DELEGATECALL" || operation == "STATICCALL") - && stack.len() >= 4 - { - args_offset_idx = Some(stack.len() - 3); - args_length_idx = Some(stack.len() - 4); - } - - if let (Some(off_idx), Some(len_idx)) = (args_offset_idx, args_length_idx) { - let offset_str = stack[off_idx].trim_start_matches("0x"); - let len_str = stack[len_idx].trim_start_matches("0x"); - - if let (Ok(offset), Ok(length)) = ( - usize::from_str_radix(offset_str, 16), - usize::from_str_radix(len_str, 16), - ) { - if length >= 4 { - if let Some(mem) = &step.memory { - let word_idx = offset / 32; - let byte_offset = offset % 32; - let hex_offset = byte_offset * 2; // Each byte is 2 hex chars - - if let Some(word) = mem.get(word_idx) { - let clean_word = word.trim_start_matches("0x"); - let selector_opt = if clean_word.len() >= hex_offset + 8 { - let selector = &clean_word[hex_offset..hex_offset + 8]; - Some(format!("0x{}", selector)) - } else if word_idx + 1 < mem.len() { - // The 4-byte selector spans across two memory boundary words - let p1 = &clean_word[hex_offset..]; - let needed = 8 - p1.len(); - let next_word = - mem[word_idx + 1].trim_start_matches("0x"); - if next_word.len() >= needed { - let p2 = &next_word[..needed]; - Some(format!("0x{}{}", p1, p2)) - } else { - None - } - } else { - None - }; - - // Try resolving the label - if let Some(sel) = selector_opt { - resolved_label = registry - .resolve(target_address.as_deref(), Some(&sel)); - } - } - } - } - } - } - } - } + // 2. Decode target address & function selector if this is a call opcode + let (target_address, resolved_label) = decode_call_context(step, registry); - // Build the full stack string with current operation + // 3. Build stack path string let stack_str = if call_stack.is_empty() { operation.clone() } else { format!("{};{}", call_stack.join(";"), operation) }; - // Accumulate gas cost and flags - let entry = stack_map.entry(stack_str).or_insert(AggregatedData { - total_gas: 0, - _last_pc: step.pc, - max_depth: step.depth, - target_address: None, - resolved_label: None, - reverted: false, - vm_kind: step.vm_kind.clone(), - }); - entry.total_gas += step.gas_cost; - entry._last_pc = step.pc; - if step.depth > entry.max_depth { - entry.max_depth = step.depth; - } + // 4. Accumulate into map + let entry = stack_map + .entry(stack_str) + .or_insert_with(|| AggregatedData { + total_gas: 0, + last_pc: step.pc, + max_depth: step.depth, + target_address: None, + resolved_label: None, + reverted: false, + vm_kind: step.vm_kind.clone(), + }); + + entry.total_gas = entry.total_gas.saturating_add(step.gas_cost); + entry.last_pc = step.pc; + entry.max_depth = entry.max_depth.max(step.depth); + if target_address.is_some() { entry.target_address = target_address; } @@ -167,7 +78,6 @@ impl Aggregator { if step.reverted { entry.reverted = true; } - // Leaf VM kind wins for the stack entry.vm_kind = step.vm_kind.clone(); } @@ -176,7 +86,7 @@ impl Aggregator { .map(|(stack, data)| CollapsedStack { stack, weight: data.total_gas, - last_pc: Some(data._last_pc), + last_pc: Some(data.last_pc), depth: data.max_depth, vm_kind: data.vm_kind, target_address: data.target_address, @@ -192,15 +102,56 @@ impl Aggregator { } } +// ─── Private Helpers ────────────────────────────────────────────────────────── + +struct AggregatedData { + total_gas: u64, + last_pc: u64, + max_depth: u16, + target_address: Option, + resolved_label: Option, + reverted: bool, + vm_kind: VmKind, +} + +fn is_call_op(op: &str) -> bool { + matches!(op, "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE") +} + +fn decode_call_context( + step: &TraceStep, + registry: &AdapterRegistry, +) -> (Option, Option) { + if !is_call_op(&step.op) { + return (None, None); + } + + let Some(stack) = &step.stack else { + return (None, None); + }; + + let target_address = extract_target_address(stack); + let mut resolved_label = None; + + if let Some(mem) = &step.memory + && let Some(selector) = extract_memory_selector(&step.op, stack, mem) + { + resolved_label = registry.resolve(target_address.as_deref(), Some(&selector)); + } + + (target_address, resolved_label) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + #[cfg(test)] mod tests { use super::*; - use atupa_core::TraceStep; + use atupa_adapters::ProtocolAdapter; #[test] - fn test_aggregator_collapses_simple_call() { + fn collapses_simple_call_hierarchy() { let steps = vec![ - // Root context opcodes (Depth 1) TraceStep { op: "PUSH1".into(), gas: 100, @@ -215,7 +166,6 @@ mod tests { depth: 1, ..Default::default() }, - // Sub-context opcodes (Depth 2) TraceStep { op: "SSTORE".into(), gas: 50, @@ -230,7 +180,6 @@ mod tests { depth: 2, ..Default::default() }, - // Back to root (Depth 1) TraceStep { pc: 2, op: "STOP".into(), @@ -241,7 +190,7 @@ mod tests { ]; let stacks = Aggregator::build_collapsed_stacks(&steps); - assert!(!stacks.is_empty(), "Stacks should not be empty"); + assert!(!stacks.is_empty()); let sstore_stack = stacks .iter() .find(|s| s.stack == "CALL;CALL;SSTORE") @@ -250,7 +199,7 @@ mod tests { } #[test] - fn test_aggregator_recursive_calls() { + fn handles_recursive_call_depths() { let steps = vec![ TraceStep { op: "CALL".into(), @@ -296,7 +245,7 @@ mod tests { } #[test] - fn test_aggregator_revert_propagation() { + fn propagates_revert_status() { let steps = vec![ TraceStep { op: "CALL".into(), @@ -325,30 +274,20 @@ mod tests { } #[test] - fn test_aggregator_memory_selector_extraction() { - // Stack for CALL: - // gas, address, value, argsOffset, argsLength, retOffset, retLength - // Top of stack is at the end. - // We want argsOffset to be "0x20" (32 bytes), argsLength to be "0x04" (4 bytes) - // stack[len-4] = argsOffset - // stack[len-5] = argsLength - + fn extracts_memory_selector_and_resolves_label() { let stack = vec![ - "0x0".to_string(), // retLength - "0x0".to_string(), // retOffset - "0x4".to_string(), // argsLength - "0x20".to_string(), // argsOffset (byte 32) - "0x0".to_string(), // value - "0x0000000000000000000000001111111111111111111111111111111111111111".to_string(), // target address - "0x1000".to_string(), // gas + "0x0".to_string(), + "0x0".to_string(), + "0x4".to_string(), // argsLength = 4 + "0x20".to_string(), // argsOffset = 32 + "0x0".to_string(), + "0x0000000000000000000000001111111111111111111111111111111111111111".to_string(), + "0x1000".to_string(), ]; - // Memory array (32-byte chunks as 64-char hex strings) - // We set argsOffset = 32, so it looks in mem[1]. - // "beforeInitialize" selector is 0x18a9d381. We'll pad the rest with zeroes. let memory = vec![ - "0000000000000000000000000000000000000000000000000000000000000000".to_string(), // word 0 - "18a9d38100000000000000000000000000000000000000000000000000000000".to_string(), // word 1 + "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + "18a9d38100000000000000000000000000000000000000000000000000000000".to_string(), ]; let steps = vec![ @@ -376,13 +315,10 @@ mod tests { .find(|s| s.stack == "CALL;CALL") .expect("Should find CALL"); - // Ensure that the target address was resolved successfully assert_eq!( call_stack.target_address.as_deref(), Some("0x1111111111111111111111111111111111111111") ); - - // Ensure that the specific Uniswap v4 Hook was decoded assert_eq!( call_stack.resolved_label.as_deref(), Some("Uniswapv4: beforeInitialize") @@ -390,23 +326,37 @@ mod tests { } #[test] - fn test_aggregator_memory_selector_aave() { + fn custom_registry_resolution() { + struct MockCustomAdapter; + impl ProtocolAdapter for MockCustomAdapter { + fn name(&self) -> &str { + "CustomProtocol" + } + fn resolve_label( + &self, + _address: Option<&str>, + selector: Option<&str>, + ) -> Option { + if selector == Some("0xab9c4b5d") { + Some("Custom::flashLoan".to_string()) + } else { + None + } + } + } + let stack = vec![ - "0x0".to_string(), // retLength - "0x0".to_string(), // retOffset - "0x4".to_string(), // argsLength - "0x0".to_string(), // argsOffset (byte 0) - "0x0".to_string(), // value - "0x0000000000000000000000002222222222222222222222222222222222222222".to_string(), // target address - "0x1000".to_string(), // gas + "0x0".to_string(), + "0x0".to_string(), + "0x4".to_string(), + "0x0".to_string(), + "0x0".to_string(), + "0x0000000000000000000000002222222222222222222222222222222222222222".to_string(), + "0x1000".to_string(), ]; - // Memory array (32-byte chunks as 64-char hex strings) - // We set argsOffset = 0, so it looks in mem[0]. - // "flashLoan" selector is 0xab9c4b5d. We'll pad the rest with zeroes. - let memory = vec![ - "ab9c4b5d00000000000000000000000000000000000000000000000000000000".to_string(), // word 0 - ]; + let memory = + vec!["ab9c4b5d00000000000000000000000000000000000000000000000000000000".to_string()]; let steps = vec![TraceStep { op: "CALL".into(), @@ -418,7 +368,10 @@ mod tests { ..Default::default() }]; - let stacks = Aggregator::build_collapsed_stacks(&steps); + let mut registry = AdapterRegistry::empty(); + registry.register_typed(MockCustomAdapter); + + let stacks = Aggregator::build_collapsed_stacks_with_registry(&steps, ®istry); let call_stack = stacks .iter() .find(|s| s.stack == "CALL;CALL") @@ -426,7 +379,7 @@ mod tests { assert_eq!( call_stack.resolved_label.as_deref(), - Some("Aave: flashLoan") + Some("Custom::flashLoan") ); } } diff --git a/crates/atupa-parser/src/decoder.rs b/crates/atupa-parser/src/decoder.rs new file mode 100644 index 0000000..24f6de9 --- /dev/null +++ b/crates/atupa-parser/src/decoder.rs @@ -0,0 +1,165 @@ +//! Low-level EVM stack and memory decoders for call target addresses and function selectors. + +/// Extracts the target contract address from an EVM call/create stack. +/// +/// In standard EVM `CALL`, `STATICCALL`, `DELEGATECALL`, and `CALLCODE` opcodes, +/// the target address is the second item from the top of the stack (`stack[len - 2]`). +pub fn extract_target_address(stack: &[String]) -> Option { + if stack.len() < 2 { + return None; + } + let hex_addr = &stack[stack.len() - 2]; + let clean_hex = hex_addr.trim_start_matches("0x"); + let padded = format!("{:0>40}", clean_hex); + let extracted = &padded[padded.len().saturating_sub(40)..]; + Some(format!("0x{extracted}")) +} + +/// Attempts to extract the 4-byte function selector from EVM memory based on +/// call opcode argument offsets and lengths on the stack. +/// +/// Handles both single 32-byte word extraction and selectors spanning across +/// adjacent 32-byte memory word boundaries. +pub fn extract_memory_selector(op: &str, stack: &[String], memory: &[String]) -> Option { + let (args_offset_idx, args_length_idx) = match op { + "CALL" | "CALLCODE" if stack.len() >= 5 => (stack.len() - 4, stack.len() - 5), + "DELEGATECALL" | "STATICCALL" if stack.len() >= 4 => (stack.len() - 3, stack.len() - 4), + _ => return None, + }; + + let offset_str = stack[args_offset_idx].trim_start_matches("0x"); + let len_str = stack[args_length_idx].trim_start_matches("0x"); + + let offset = usize::from_str_radix(offset_str, 16).ok()?; + let length = usize::from_str_radix(len_str, 16).ok()?; + + if length < 4 { + return None; + } + + let word_idx = offset / 32; + let byte_offset = offset % 32; + let hex_offset = byte_offset * 2; // Each byte is 2 hex characters + + let word = memory.get(word_idx)?; + let clean_word = word.trim_start_matches("0x"); + + if clean_word.len() >= hex_offset + 8 { + let selector = &clean_word[hex_offset..hex_offset + 8]; + Some(format!("0x{selector}")) + } else if word_idx + 1 < memory.len() { + // The 4-byte selector spans across two memory boundary words + let p1 = &clean_word[hex_offset..]; + let needed = 8 - p1.len(); + let next_word = memory[word_idx + 1].trim_start_matches("0x"); + if next_word.len() >= needed { + let p2 = &next_word[..needed]; + Some(format!("0x{p1}{p2}")) + } else { + None + } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_target_address_from_call_stack() { + let stack = vec![ + "0x0".to_string(), // retLength + "0x0".to_string(), // retOffset + "0x4".to_string(), // argsLength + "0x20".to_string(), // argsOffset + "0x0".to_string(), // value + "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), // target address + "0x1000".to_string(), // gas + ]; + let addr = extract_target_address(&stack); + assert_eq!( + addr, + Some("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()) + ); + } + + #[test] + fn extract_target_address_returns_none_on_small_stack() { + assert_eq!(extract_target_address(&[]), None); + assert_eq!(extract_target_address(&["0x1".to_string()]), None); + } + + #[test] + fn extracts_memory_selector_from_first_word() { + let stack = vec![ + "0x0".to_string(), + "0x0".to_string(), + "0x4".to_string(), // length = 4 bytes + "0x0".to_string(), // offset = 0 bytes + "0x0".to_string(), + "0x1111".to_string(), + "0x1000".to_string(), + ]; + let memory = + vec!["a9059cbb00000000000000000000000000000000000000000000000000000000".to_string()]; + + let sel = extract_memory_selector("CALL", &stack, &memory); + assert_eq!(sel, Some("0xa9059cbb".to_string())); + } + + #[test] + fn extracts_memory_selector_with_offset() { + let stack = vec![ + "0x0".to_string(), + "0x0".to_string(), + "0x4".to_string(), // length = 4 bytes + "0x20".to_string(), // offset = 32 bytes (word 1) + "0x0".to_string(), + "0x1111".to_string(), + "0x1000".to_string(), + ]; + let memory = vec![ + "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + "617ba03700000000000000000000000000000000000000000000000000000000".to_string(), + ]; + + let sel = extract_memory_selector("CALL", &stack, &memory); + assert_eq!(sel, Some("0x617ba037".to_string())); + } + + #[test] + fn extracts_memory_selector_staticcall() { + let stack = vec![ + "0x0".to_string(), + "0x0".to_string(), + "0x4".to_string(), // length = 4 bytes (len - 4) + "0x0".to_string(), // offset = 0 bytes (len - 3) + "0x1111".to_string(), + "0x1000".to_string(), + ]; + let memory = + vec!["70a0823100000000000000000000000000000000000000000000000000000000".to_string()]; + + let sel = extract_memory_selector("STATICCALL", &stack, &memory); + assert_eq!(sel, Some("0x70a08231".to_string())); + } + + #[test] + fn returns_none_when_length_less_than_4() { + let stack = vec![ + "0x0".to_string(), + "0x0".to_string(), + "0x3".to_string(), // length < 4 + "0x0".to_string(), + "0x0".to_string(), + "0x1111".to_string(), + "0x1000".to_string(), + ]; + let memory = + vec!["a9059cbb00000000000000000000000000000000000000000000000000000000".to_string()]; + + assert_eq!(extract_memory_selector("CALL", &stack, &memory), None); + } +} diff --git a/crates/atupa-parser/src/lib.rs b/crates/atupa-parser/src/lib.rs index a931b3e..61c75ab 100644 --- a/crates/atupa-parser/src/lib.rs +++ b/crates/atupa-parser/src/lib.rs @@ -1,45 +1,29 @@ -pub mod aggregator; - -use atupa_core::{TraceStep, VmKind}; -use atupa_rpc::RawStructLog; +//! # atupa-parser +//! +//! Trace normalization, address/selector decoding, and stack aggregation engine. +//! +//! Converts raw RPC debug execution traces into normalized [`atupa_core::TraceStep`]s, +//! decodes call arguments/selectors from EVM memory, and aggregates linear execution steps +//! into hierarchical [`atupa_core::CollapsedStack`] profiles for flamegraph visualization. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`normalize`] | [`Parser`] for converting RPC `structLog`s into [`atupa_core::TraceStep`]s | +//! | [`decoder`] | Memory selector extraction & target address decoders | +//! | [`aggregator`] | [`Aggregator`] for collapsing linear steps into tree call-stacks | +//! +//! ## Re-exports +//! +//! Primary types are re-exported at the crate root. -pub struct Parser; +pub mod aggregator; +pub mod decoder; +pub mod normalize; -impl Parser { - /// Normalizes a raw Anvil/Geth structLog into our universal TraceStep schema. - pub fn normalize(raw_logs: Vec) -> Vec { - raw_logs - .into_iter() - .map(|log| { - let reverted = log.error.is_some() || log.op == "REVERT" || log.op == "INVALID"; - TraceStep { - pc: log.pc, - op: log.op, - gas: log.gas, - gas_cost: log.gas_cost, - depth: log.depth, - stack: log.stack, - memory: log.memory, - error: log.error, - reverted, - vm_kind: VmKind::Evm, - } - }) - .collect() - } +// ── Flat re-exports ─────────────────────────────────────────────────────────── - /// Pass-through for steps that are already normalized (e.g. the unified - /// `UnifiedStep` timeline from `atupa-nitro`). Applies the same revert - /// detection logic so the Aggregator sees consistent flags. - pub fn normalize_raw(steps: Vec) -> Vec { - steps - .into_iter() - .map(|mut step| { - if step.error.is_some() || step.op == "REVERT" || step.op == "INVALID" { - step.reverted = true; - } - step - }) - .collect() - } -} +pub use aggregator::Aggregator; +pub use decoder::{extract_memory_selector, extract_target_address}; +pub use normalize::Parser; diff --git a/crates/atupa-parser/src/normalize.rs b/crates/atupa-parser/src/normalize.rs new file mode 100644 index 0000000..1b77db6 --- /dev/null +++ b/crates/atupa-parser/src/normalize.rs @@ -0,0 +1,107 @@ +//! Trace step normalization from raw RPC structLogs to universal [`TraceStep`] schema. + +use atupa_core::{TraceStep, VmKind}; +use atupa_rpc::RawStructLog; + +/// Normalizes raw execution trace logs into the universal [`TraceStep`] representation. +pub struct Parser; + +impl Parser { + /// Normalizes a raw Anvil/Geth `structLog` list into the universal [`TraceStep`] schema. + pub fn normalize(raw_logs: Vec) -> Vec { + raw_logs + .into_iter() + .map(|log| { + let reverted = log.error.is_some() || log.op == "REVERT" || log.op == "INVALID"; + TraceStep { + pc: log.pc, + op: log.op, + gas: log.gas, + gas_cost: log.gas_cost, + depth: log.depth, + stack: log.stack, + memory: log.memory, + error: log.error, + reverted, + vm_kind: VmKind::Evm, + } + }) + .collect() + } + + /// Pass-through normalization for steps that are already in `TraceStep` format + /// (e.g. from `atupa-nitro`, `atupa-solana`, etc.). + /// + /// Ensures consistent error/revert flag propagation. + pub fn normalize_raw(steps: Vec) -> Vec { + steps + .into_iter() + .map(|mut step| { + if step.error.is_some() || step.op == "REVERT" || step.op == "INVALID" { + step.reverted = true; + } + step + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_raw_struct_log() { + let raw = vec![ + RawStructLog { + pc: 0, + op: "PUSH1".to_string(), + gas: 100, + gas_cost: 3, + depth: 1, + error: None, + stack: None, + memory: None, + storage: None, + }, + RawStructLog { + pc: 2, + op: "REVERT".to_string(), + gas: 97, + gas_cost: 0, + depth: 1, + error: Some("execution reverted".to_string()), + stack: None, + memory: None, + storage: None, + }, + ]; + + let normalized = Parser::normalize(raw); + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].op, "PUSH1"); + assert!(!normalized[0].reverted); + assert_eq!(normalized[1].op, "REVERT"); + assert!(normalized[1].reverted); + } + + #[test] + fn normalize_raw_marks_revert_flag() { + let steps = vec![ + TraceStep { + op: "ADD".to_string(), + reverted: false, + ..Default::default() + }, + TraceStep { + op: "INVALID".to_string(), + reverted: false, + ..Default::default() + }, + ]; + + let normalized = Parser::normalize_raw(steps); + assert!(!normalized[0].reverted); + assert!(normalized[1].reverted); + } +} diff --git a/crates/atupa-rpc/Cargo.toml b/crates/atupa-rpc/Cargo.toml index 35b56ae..e58ee04 100644 --- a/crates/atupa-rpc/Cargo.toml +++ b/crates/atupa-rpc/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-rpc" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -18,7 +17,6 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } -anyhow = { workspace = true } thiserror = { workspace = true } log = { workspace = true } dirs = { workspace = true } diff --git a/crates/atupa-rpc/src/client.rs b/crates/atupa-rpc/src/client.rs new file mode 100644 index 0000000..12c8dac --- /dev/null +++ b/crates/atupa-rpc/src/client.rs @@ -0,0 +1,179 @@ +//! JSON-RPC client implementation for interacting with Ethereum / EVM nodes. + +use reqwest::Client; +use serde_json::json; + +use crate::error::{RpcError, RpcResult}; +use crate::types::{RpcResponse, TraceResult}; + +/// Lightweight HTTP JSON-RPC client for EVM node querying and debug trace retrieval. +pub struct EthClient { + rpc_url: String, + client: Client, +} + +impl EthClient { + /// Creates a new [`EthClient`] connected to the given JSON-RPC URL. + pub fn new(rpc_url: impl Into) -> Self { + Self { + rpc_url: rpc_url.into(), + client: Client::new(), + } + } + + /// Returns the target RPC URL. + pub fn rpc_url(&self) -> &str { + &self.rpc_url + } + + /// Fetch a raw `debug_traceTransaction` structLog response from the node. + pub async fn get_transaction_trace(&self, tx_hash: &str) -> RpcResult { + let params = json!([ + tx_hash, + { + "enableMemory": false, + "disableStack": false, + "disableStorage": true + } + ]); + + let payload = json!({ + "jsonrpc": "2.0", + "method": "debug_traceTransaction", + "params": params, + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await?; + + let rpc_res: RpcResponse = response.json().await?; + + if let Some(err) = rpc_res.error { + return Err(RpcError::Node(err.message)); + } + + rpc_res + .result + .ok_or_else(|| RpcError::Node("Missing result in RPC response".to_string())) + } + + /// Fetch the chain ID from the node (`eth_chainId`). + pub async fn get_chain_id(&self) -> RpcResult { + let payload = json!({ + "jsonrpc": "2.0", + "method": "eth_chainId", + "params": [], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await?; + + let rpc_res: serde_json::Value = response.json().await?; + + if let Some(err) = rpc_res.get("error") { + return Err(RpcError::Node( + err["message"].as_str().unwrap_or("Unknown").to_string(), + )); + } + + let result = rpc_res["result"] + .as_str() + .ok_or_else(|| RpcError::Node("Missing result in eth_chainId response".to_string()))?; + + u64::from_str_radix(result.trim_start_matches("0x"), 16) + .map_err(|e| RpcError::Node(format!("Invalid chainId hex: {e}"))) + } + + /// Fetch the actual on-chain `gasUsed` from `eth_getTransactionReceipt`. + /// + /// Returns `None` if the receipt is unavailable or the call fails (non-fatal). + pub async fn get_gas_used(&self, tx_hash: &str) -> Option { + let payload = json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionReceipt", + "params": [tx_hash], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await + .ok()?; + + let rpc_res: serde_json::Value = response.json().await.ok()?; + let gas_hex = rpc_res["result"]["gasUsed"].as_str()?; + u64::from_str_radix(gas_hex.trim_start_matches("0x"), 16).ok() + } + + /// Fetch the raw `input` (calldata) of a transaction via `eth_getTransactionByHash`. + /// + /// Returns `None` if the transaction is not found or the call fails (non-fatal). + pub async fn get_transaction_input(&self, tx_hash: &str) -> Option { + let payload = json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionByHash", + "params": [tx_hash], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await + .ok()?; + + let rpc_res: serde_json::Value = response.json().await.ok()?; + rpc_res["result"]["input"].as_str().map(|s| s.to_string()) + } + + /// Extract the 4-byte function selector from raw transaction calldata. + /// + /// Returns a lowercase hex string like `"0xa9059cbb"`, or `None` if calldata is too short. + pub fn selector_from_input(input: &str) -> Option { + let stripped = input.trim_start_matches("0x"); + if stripped.len() < 8 { + return None; + } + Some(format!("0x{}", &stripped[..8].to_lowercase())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selector_from_input_extraction() { + assert_eq!( + EthClient::selector_from_input("0xa9059cbb0000000000000000000000001234"), + Some("0xa9059cbb".to_string()) + ); + assert_eq!( + EthClient::selector_from_input("A9059CBB0000000000000000000000001234"), + Some("0xa9059cbb".to_string()) + ); + assert_eq!(EthClient::selector_from_input("0x123"), None); + assert_eq!(EthClient::selector_from_input(""), None); + } + + #[test] + fn client_constructor_and_getter() { + let client = EthClient::new("http://localhost:8545"); + assert_eq!(client.rpc_url(), "http://localhost:8545"); + } +} diff --git a/crates/atupa-rpc/src/error.rs b/crates/atupa-rpc/src/error.rs new file mode 100644 index 0000000..8b5314a --- /dev/null +++ b/crates/atupa-rpc/src/error.rs @@ -0,0 +1,36 @@ +//! Error types for JSON-RPC communication and node responses. + +use thiserror::Error; + +/// Errors that can occur when executing JSON-RPC calls against an EVM/L2 node. +#[derive(Error, Debug)] +pub enum RpcError { + /// HTTP or network layer failure. + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// JSON serialization or deserialization failure. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Node returned a JSON-RPC error response. + #[error("RPC error: {0}")] + Node(String), +} + +/// Convenience result alias for operations returning [`RpcError`]. +pub type RpcResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rpc_error_node_display() { + let err = RpcError::Node("method debug_traceTransaction not found".to_string()); + assert_eq!( + err.to_string(), + "RPC error: method debug_traceTransaction not found" + ); + } +} diff --git a/crates/atupa-rpc/src/etherscan.rs b/crates/atupa-rpc/src/etherscan.rs index 739eada..02e1463 100644 --- a/crates/atupa-rpc/src/etherscan.rs +++ b/crates/atupa-rpc/src/etherscan.rs @@ -1,3 +1,5 @@ +//! Etherscan API client and persistent disk cache for contract name resolution. + use reqwest::{Client, Url}; use serde::Deserialize; use std::collections::HashMap; @@ -24,44 +26,47 @@ fn cache_path() -> Option { dirs::home_dir().map(|h| h.join(".atupa").join("etherscan_cache.json")) } -/// Loads the serialized cache from disk. Returns an empty map if the file -/// doesn't exist or cannot be parsed (non-fatal — we'll just fetch from the API). -fn load_cache() -> HashMap { +/// Asynchronously loads the serialized cache from disk. +/// Returns an empty map if the file doesn't exist or cannot be parsed (non-fatal). +async fn load_cache_async() -> HashMap { let Some(path) = cache_path() else { return HashMap::new(); }; - match std::fs::read_to_string(&path) { + match tokio::fs::read_to_string(&path).await { Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), Err(_) => HashMap::new(), } } -/// Flushes the full in-memory cache to disk atomically. +/// Spawns a background task to flush the cache to disk. +/// Takes an owned snapshot of the cache so the caller can drop the Mutex lock immediately. /// Errors are logged but never propagated — a cache write failure is non-fatal. -fn flush_cache(cache: &HashMap) { - let Some(path) = cache_path() else { return }; - - // Ensure the parent directory exists - if let Some(parent) = path.parent() - && let Err(e) = std::fs::create_dir_all(parent) - { - log::warn!("⚠️ Could not create cache dir {:?}: {}", parent, e); - return; - } +fn spawn_flush_cache(snapshot: HashMap) { + tokio::spawn(async move { + let Some(path) = cache_path() else { return }; - match serde_json::to_string_pretty(cache) { - Ok(json) => { - if let Err(e) = std::fs::write(&path, json) { - log::warn!("⚠️ Could not write Etherscan cache to {:?}: {}", path, e); - } + if let Some(parent) = path.parent() + && let Err(e) = tokio::fs::create_dir_all(parent).await + { + log::warn!("Could not create cache directory {:?}: {}", parent, e); + return; } - Err(e) => { - log::warn!("⚠️ Could not serialize Etherscan cache: {}", e); + + match serde_json::to_string_pretty(&snapshot) { + Ok(json) => { + if let Err(e) = tokio::fs::write(&path, json).await { + log::warn!("Could not write Etherscan cache to {:?}: {}", path, e); + } + } + Err(e) => { + log::warn!("Could not serialize Etherscan cache: {}", e); + } } - } + }); } /// A lightweight client to resolve EVM addresses into Human-Readable Contract Names. +/// /// Resolves are cached in memory during execution and persisted to /// `~/.atupa/etherscan_cache.json` across sessions. #[derive(Clone)] @@ -79,30 +84,38 @@ impl Default for EtherscanResolver { } impl EtherscanResolver { + /// Creates a new [`EtherscanResolver`] with optional API key and target chain ID. pub fn new(api_key: Option, chain_id: u64) -> Self { - let disk_cache = load_cache(); - let cache_size = disk_cache.len(); - if cache_size > 0 { - log::info!( - "📦 Loaded {} Etherscan contract name(s) from disk cache", - cache_size - ); - } + let cache = Arc::new(Mutex::new(HashMap::new())); + let cache_clone = cache.clone(); + + tokio::spawn(async move { + let disk_cache = load_cache_async().await; + let cache_size = disk_cache.len(); + let mut lock = cache_clone.lock().await; + *lock = disk_cache; + if cache_size > 0 { + log::info!( + "Loaded {} Etherscan contract name(s) from disk cache", + cache_size + ); + } + }); Self { client: Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap_or_default(), - cache: Arc::new(Mutex::new(disk_cache)), + cache, api_key, chain_id, } } /// Resolves an address to its verified Contract Name via Etherscan. + /// /// Results are cached in memory and on disk to avoid redundant API calls. - #[allow(clippy::collapsible_if)] pub async fn resolve_contract_name(&self, address: &str) -> Option { if address.len() < 40 { return None; @@ -112,7 +125,7 @@ impl EtherscanResolver { { let cache_lock = self.cache.lock().await; if let Some(name) = cache_lock.get(address) { - log::debug!("📦 Cache hit: {} -> {}", address, name); + log::debug!("Cache hit: {} -> {}", address, name); return Some(name.clone()); } } @@ -123,40 +136,63 @@ impl EtherscanResolver { self.chain_id, address ); if let Some(key) = &self.api_key { - url_str.push_str(&format!("&apikey={}", key)); + url_str.push_str(&format!("&apikey={key}")); } let Ok(url) = Url::parse(&url_str) else { return None; }; - if let Ok(resp) = self.client.get(url).send().await { - if let Ok(api_res) = resp.json::().await { - match (api_res.status.as_str(), api_res.result.first()) { - ("1", Some(item)) if !item.contract_name.is_empty() => { - let name = item.contract_name.clone(); - log::info!("✅ Etherscan resolved {} -> {}", address, name); - - // Update in-memory cache then flush to disk - let mut cache_lock = self.cache.lock().await; - cache_lock.insert(address.to_string(), name.clone()); - flush_cache(&cache_lock); - - return Some(name); - } - _ => { - log::debug!( - "❌ Etherscan hit but no name for {}: {:?}", - address, - api_res - ); - } + if let Ok(resp) = self.client.get(url).send().await + && let Ok(api_res) = resp.json::().await + { + match (api_res.status.as_str(), api_res.result.first()) { + ("1", Some(item)) if !item.contract_name.is_empty() => { + let name = item.contract_name.clone(); + log::info!("Etherscan resolved {} -> {}", address, name); + + let mut cache_lock = self.cache.lock().await; + cache_lock.insert(address.to_string(), name.clone()); + let snapshot = cache_lock.clone(); + drop(cache_lock); + spawn_flush_cache(snapshot); + + return Some(name); + } + _ => { + log::debug!("Etherscan hit but no name for {}: {:?}", address, api_res); } - } else { - log::debug!("❌ Etherscan JSON parse failed for {}", address); } } None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn cache_hit_returns_cached_name_immediately() { + let resolver = EtherscanResolver::new(None, 1); + { + let mut lock = resolver.cache.lock().await; + lock.insert( + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), + "FiatTokenV2".to_string(), + ); + } + + let name = resolver + .resolve_contract_name("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + .await; + assert_eq!(name, Some("FiatTokenV2".to_string())); + } + + #[tokio::test] + async fn short_address_returns_none() { + let resolver = EtherscanResolver::new(None, 1); + assert_eq!(resolver.resolve_contract_name("0x123").await, None); + } +} diff --git a/crates/atupa-rpc/src/lib.rs b/crates/atupa-rpc/src/lib.rs index c316692..c1b3fce 100644 --- a/crates/atupa-rpc/src/lib.rs +++ b/crates/atupa-rpc/src/lib.rs @@ -1,191 +1,34 @@ +//! # atupa-rpc +//! +//! JSON-RPC client and Etherscan metadata resolver for the Atupa execution tracer. +//! +//! Provides: +//! - [`EthClient`] — HTTP client for `debug_traceTransaction`, `eth_chainId`, +//! `eth_getTransactionReceipt`, and `eth_getTransactionByHash`. +//! - [`EtherscanResolver`] — Verified contract name resolution with persistent disk caching. +//! - [`RawStructLog`] & [`TraceResult`] — Universal debug trace payload models. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`error`] | [`RpcError`] and [`RpcResult`](error::RpcResult) | +//! | [`types`] | [`RawStructLog`] and [`TraceResult`] | +//! | [`client`] | [`EthClient`] JSON-RPC client | +//! | [`etherscan`] | [`EtherscanResolver`] contract metadata resolver | +//! +//! ## Re-exports +//! +//! Primary types are re-exported at the crate root. + +pub mod client; +pub mod error; pub mod etherscan; +pub mod types; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use thiserror::Error; +// ── Flat re-exports ─────────────────────────────────────────────────────────── -#[derive(Error, Debug)] -pub enum RpcError { - #[error("Network error: {0}")] - Network(#[from] reqwest::Error), - #[error("RPC error: {0}")] - Node(String), -} - -/// Raw structLog from the EVM tracer -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RawStructLog { - pub pc: u64, - pub op: String, - pub gas: u64, - pub gas_cost: u64, - pub depth: u16, - pub error: Option, - pub stack: Option>, - pub memory: Option>, - pub storage: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TraceResult { - pub gas: u64, - pub return_value: String, - pub struct_logs: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RpcResponse { - jsonrpc: String, - id: u64, - result: Option, - error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct RpcErrorBody { - code: i64, - message: String, -} - -pub struct EthClient { - rpc_url: String, - client: Client, -} - -impl EthClient { - pub fn new(rpc_url: String) -> Self { - Self { - rpc_url, - client: Client::new(), - } - } - - /// Fetch a raw debug_traceTransaction structLog response from the node - pub async fn get_transaction_trace(&self, tx_hash: &str) -> Result { - // debug_traceTransaction parameters - let params = json!([ - tx_hash, - { - "enableMemory": false, - "disableStack": false, - "disableStorage": true - } - ]); - - let payload = json!({ - "jsonrpc": "2.0", - "method": "debug_traceTransaction", - "params": params, - "id": 1 - }); - - let response = self - .client - .post(&self.rpc_url) - .json(&payload) - .send() - .await?; - - let rpc_res: RpcResponse = response.json().await?; - - if let Some(err) = rpc_res.error { - return Err(RpcError::Node(err.message)); - } - - rpc_res - .result - .ok_or_else(|| RpcError::Node("Missing result in RPC response".to_string())) - } - - /// Fetch the chain ID from the node - pub async fn get_chain_id(&self) -> Result { - let payload = json!({ - "jsonrpc": "2.0", - "method": "eth_chainId", - "params": [], - "id": 1 - }); - - let response = self - .client - .post(&self.rpc_url) - .json(&payload) - .send() - .await?; - - let rpc_res: serde_json::Value = response.json().await?; - - if let Some(err) = rpc_res.get("error") { - return Err(RpcError::Node( - err["message"].as_str().unwrap_or("Unknown").to_string(), - )); - } - - let result = rpc_res["result"] - .as_str() - .ok_or_else(|| RpcError::Node("Missing result in eth_chainId response".to_string()))?; - - u64::from_str_radix(result.trim_start_matches("0x"), 16) - .map_err(|e| RpcError::Node(format!("Invalid chainId hex: {}", e))) - } - - /// Fetch the actual on-chain gasUsed from eth_getTransactionReceipt. - /// Returns None (non-fatal) if the receipt is unavailable or the call fails. - pub async fn get_gas_used(&self, tx_hash: &str) -> Option { - let payload = json!({ - "jsonrpc": "2.0", - "method": "eth_getTransactionReceipt", - "params": [tx_hash], - "id": 1 - }); - - let response = self - .client - .post(&self.rpc_url) - .json(&payload) - .send() - .await - .ok()?; - - let rpc_res: serde_json::Value = response.json().await.ok()?; - - let gas_hex = rpc_res["result"]["gasUsed"].as_str()?; - u64::from_str_radix(gas_hex.trim_start_matches("0x"), 16).ok() - } - - /// Fetch the raw `input` (calldata) of a transaction via eth_getTransactionByHash. - /// Returns the hex-encoded input string (e.g. "0xa9059cbb000..."). - /// Returns None (non-fatal) if the call fails. - pub async fn get_transaction_input(&self, tx_hash: &str) -> Option { - let payload = json!({ - "jsonrpc": "2.0", - "method": "eth_getTransactionByHash", - "params": [tx_hash], - "id": 1 - }); - - let response = self - .client - .post(&self.rpc_url) - .json(&payload) - .send() - .await - .ok()?; - - let rpc_res: serde_json::Value = response.json().await.ok()?; - rpc_res["result"]["input"].as_str().map(|s| s.to_string()) - } - - /// Extract the 4-byte function selector from raw calldata. - /// Returns a lowercase hex string like "0xa9059cbb", or None if calldata is too short. - pub fn selector_from_input(input: &str) -> Option { - let stripped = input.trim_start_matches("0x"); - if stripped.len() < 8 { - return None; - } - Some(format!("0x{}", &stripped[..8].to_lowercase())) - } -} +pub use client::EthClient; +pub use error::{RpcError, RpcResult}; +pub use etherscan::EtherscanResolver; +pub use types::{RawStructLog, TraceResult}; diff --git a/crates/atupa-rpc/src/types.rs b/crates/atupa-rpc/src/types.rs new file mode 100644 index 0000000..e188285 --- /dev/null +++ b/crates/atupa-rpc/src/types.rs @@ -0,0 +1,129 @@ +//! Data models for EVM debug tracer JSON-RPC payloads. + +use serde::{Deserialize, Serialize}; + +/// Raw execution step (`structLog`) emitted by Geth / Anvil / Nitro debug tracers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct RawStructLog { + /// Program counter. + pub pc: u64, + /// Opcode mnemonic (e.g. `PUSH1`, `CALL`, `SSTORE`). + pub op: String, + /// Cumulative remaining gas before executing this opcode. + pub gas: u64, + /// Gas consumed by this specific execution step. + pub gas_cost: u64, + /// Call frame depth. + pub depth: u16, + /// Optional execution error (e.g. `execution reverted`, `out of gas`). + pub error: Option, + /// EVM stack contents prior to opcode execution. + pub stack: Option>, + /// EVM memory words (32-byte chunks as 64-character hex strings). + pub memory: Option>, + /// EVM storage slots. + pub storage: Option, +} + +impl RawStructLog { + /// Returns `true` if this step resulted in an error or was a reverting opcode. + pub fn is_reverted(&self) -> bool { + self.error.is_some() || self.op == "REVERT" || self.op == "INVALID" + } + + /// Returns `true` if this step represents a cross-contract call. + pub fn is_call(&self) -> bool { + matches!( + self.op.as_str(), + "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" + ) + } +} + +/// The top-level result payload returned by `debug_traceTransaction`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct TraceResult { + /// Total gas used reported by the tracer. + pub gas: u64, + /// Hex-encoded return value of the top-level call. + pub return_value: String, + /// Sequential list of all execution step logs. + pub struct_logs: Vec, +} + +// ─── Internal JSON-RPC Envelope ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RpcResponse { + pub jsonrpc: String, + pub id: u64, + pub result: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RpcErrorBody { + pub code: i64, + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_raw_struct_log() { + let json_data = r#"{ + "pc": 12, + "op": "CALL", + "gas": 950000, + "gasCost": 2600, + "depth": 1, + "error": null, + "stack": ["0x1000", "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"], + "memory": ["0000000000000000000000000000000000000000000000000000000000000000"], + "storage": null + }"#; + + let log: RawStructLog = serde_json::from_str(json_data).unwrap(); + assert_eq!(log.pc, 12); + assert_eq!(log.op, "CALL"); + assert_eq!(log.gas_cost, 2600); + assert_eq!(log.depth, 1); + assert!(log.is_call()); + assert!(!log.is_reverted()); + assert_eq!(log.stack.as_ref().unwrap().len(), 2); + } + + #[test] + fn detects_revert_status() { + let log = RawStructLog { + op: "REVERT".to_string(), + ..Default::default() + }; + assert!(log.is_reverted()); + + let log_err = RawStructLog { + op: "PUSH1".to_string(), + error: Some("out of gas".to_string()), + ..Default::default() + }; + assert!(log_err.is_reverted()); + } + + #[test] + fn deserializes_trace_result() { + let json_data = r#"{ + "gas": 21000, + "returnValue": "0x", + "structLogs": [] + }"#; + + let result: TraceResult = serde_json::from_str(json_data).unwrap(); + assert_eq!(result.gas, 21000); + assert_eq!(result.return_value, "0x"); + assert!(result.struct_logs.is_empty()); + } +} diff --git a/crates/atupa-sdk/Cargo.toml b/crates/atupa-sdk/Cargo.toml index 0c1a76e..8fd19a5 100644 --- a/crates/atupa-sdk/Cargo.toml +++ b/crates/atupa-sdk/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa-sdk" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } repository = { workspace = true } description = { workspace = true } @@ -22,6 +21,9 @@ atupa-output = { workspace = true } atupa-aave = { workspace = true } atupa-lido = { workspace = true } atupa-nitro = { workspace = true } +atupa-starknet = { workspace = true } +atupa-solana = { workspace = true } +atupa-stellar = { workspace = true } # Runtime deps needed by the high-level profile module tokio = { workspace = true } diff --git a/crates/atupa-sdk/src/lib.rs b/crates/atupa-sdk/src/lib.rs index 0df588a..acea4ac 100644 --- a/crates/atupa-sdk/src/lib.rs +++ b/crates/atupa-sdk/src/lib.rs @@ -1,6 +1,6 @@ //! # Atupa //! -//! **Unified Ethereum Execution Profiler** — the top-level façade crate for the +//! **Unified Multi-VM Blockchain Execution Profiler** — the top-level façade crate for the //! Atupa SDK. This crate re-exports every layer of the suite so that external //! integrators only need to depend on a single crate: //! @@ -13,279 +13,65 @@ //! //! ```text //! atupa (this façade) -//! ├── atupa-core → Types: TraceStep, CollapsedStack, GasCategory +//! ├── atupa-core → Types: TraceStep, CollapsedStack, GasCategory, VmKind //! ├── atupa-rpc → JSON-RPC client (EthClient, EtherscanResolver) -//! ├── atupa-parser → StructLog → TraceStep normalization -//! ├── atupa-adapters → ProtocolAdapter trait -//! ├── atupa-output → SvgGenerator flamegraphs -//! ├── atupa-aave → AaveDeepTracer, GHO metrics -//! └── atupa-lido → LidoDeepTracer, stETH / wstETH tracing +//! ├── atupa-parser → TraceStep normalization and Call Stack Aggregation +//! ├── atupa-adapters → ProtocolAdapter trait (Uniswap v4, ERC-20, etc.) +//! ├── atupa-output → SvgGenerator & differential flamegraph renderers +//! ├── atupa-aave → AaveDeepTracer, GHO supply metrics +//! ├── atupa-lido → LidoDeepTracer, stETH / wstETH tracing +//! ├── atupa-nitro → Mixed EVM + Arbitrum Stylus WASM dual-tracing +//! ├── atupa-starknet → Starknet Cairo VM trace flattening +//! ├── atupa-solana → Solana Sealevel instruction log stitcher +//! └── atupa-stellar → Stellar Soroban WASM diagnostic event parser //! ``` +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`registry`] | [`build_default_registry`] pre-configured with all protocol adapters | +//! | [`profile`] | [`execute_profile`] and [`VmHint`] high-level execution engine | + +pub mod profile; +pub mod registry; // ─── Public re-exports ──────────────────────────────────────────────────────── /// Core types shared across the entire Atupa suite. pub use atupa_core as core; -/// JSON-RPC transport layer: EthClient, EtherscanResolver, RawStructLog. +/// JSON-RPC transport layer: `EthClient`, `EtherscanResolver`, `RawStructLog`. pub use atupa_rpc as rpc; -/// Trace normalization and aggregation. +/// Trace normalization and stack aggregation engine. pub use atupa_parser as parser; -/// ProtocolAdapter trait for pluggable DeFi recognizers. +/// `ProtocolAdapter` trait and `AdapterRegistry` for pluggable protocol recognizers. pub use atupa_adapters as adapters; -/// SVG flamegraph renderer. +/// SVG flamegraph and diff visualization renderer. pub use atupa_output as output; -/// Aave v3 + GHO protocol adapter. +/// Aave v3 + GHO protocol tracer. pub use atupa_aave as aave; -/// Lido stETH protocol adapter. +/// Lido stETH protocol tracer. pub use atupa_lido as lido; -// ─── High-level API ─────────────────────────────────────────────────────────── - -pub use profile::execute_profile; - -/// High-level profile execution logic, usable independently from the CLI. -pub mod profile { - use anyhow::Result; - use atupa_core::{CollapsedStack, VmKind}; - use atupa_nitro::{NitroClient, VmKind as NitroVmKind}; - use atupa_output::SvgGenerator; - use atupa_parser::{Parser as AtupaParser, aggregator::Aggregator}; - use atupa_rpc::etherscan::EtherscanResolver; - use indicatif::{ProgressBar, ProgressStyle}; - use std::{fs, time::Duration}; - - /// Fetch (or generate a demo), aggregate, and render an SVG flamegraph for - /// the given transaction hash. - /// - /// This is the same logic that `atupa profile` runs — exposed here so it can - /// be called programmatically by other tools or tests. - pub async fn execute_profile( - tx: &str, - rpc: &str, - is_demo: bool, - out: Option, - etherscan_key: Option, - ) -> Result<(String, String)> { - let pb = make_spinner(); - - // 1. Fetch ───────────────────────────────────────────────────────────── - let (mut stacks, network_name) = if is_demo { - pb.set_message("Generating offline demo trace…"); - (demo_stacks(), "Demo".to_string()) - } else { - pb.set_message("Detecting network and fetching execution trace…"); - let client = NitroClient::new(rpc.to_string()); - let report = - tokio::time::timeout(Duration::from_secs(30), client.trace_transaction(tx)) - .await - .map_err(|_| { - anyhow::anyhow!("RPC timed out after 30s — is the node reachable at {rpc}?") - })? - .map_err(|e| anyhow::anyhow!("RPC error: {e}"))?; - - let network = get_network_name(report.chain_id); - let evm_count = report - .steps - .iter() - .filter(|s| s.vm == NitroVmKind::Evm) - .count(); - let wasm_count = report - .steps - .iter() - .filter(|s| s.vm == NitroVmKind::Stylus) - .count(); - pb.set_message(format!( - "Processing {evm_count} EVM + {wasm_count} Stylus steps from {network}…" - )); - - // ── Unified single-pass aggregation ──────────────────────────────── - // Convert the interleaved UnifiedStep timeline into core TraceSteps. - // Stylus steps already carry depth = (parent CALL depth + 1) and a - // gas_cost equal to ink / 10_000, so the Aggregator nests them under - // their EVM CALL frames without any special-casing. - let unified_steps: Vec = - report.steps.iter().map(|s| s.to_trace_step()).collect(); - - let normalized = AtupaParser::normalize_raw(unified_steps); - let mut combined = Aggregator::build_collapsed_stacks(&normalized); - - // Etherscan resolution — only meaningful for EVM steps with an address. - pb.set_message("Resolving contract names via Etherscan…"); - let resolver = EtherscanResolver::new(etherscan_key, report.chain_id); - for stack in &mut combined { - if stack.vm_kind == VmKind::Evm - && let Some(addr) = &stack.target_address - && let Some(name) = resolver.resolve_contract_name(addr).await - { - stack.target_address = Some(name); - } - } - - (combined, network) - }; - - // Sort EVM stacks descending by weight; Stylus stacks come after - let evm_end = stacks.partition_point(|s| s.vm_kind == VmKind::Evm); - stacks[..evm_end].sort_by_key(|b| std::cmp::Reverse(b.weight)); - - // 2. Render + save ───────────────────────────────────────────────────── - pb.set_message("Generating SVG flamegraph…"); - let svg = SvgGenerator::generate_flamegraph(&stacks)?; - let out_path = out.unwrap_or_else(|| { - if is_demo { - "profile_demo.svg".to_string() - } else { - // Shorten to first 10 hex chars after 0x - let short = tx.trim_start_matches("0x").get(..10).unwrap_or(tx); - format!("profile_{short}.svg") - } - }); - fs::write(&out_path, svg)?; +/// Arbitrum Nitro & Stylus WASM dual-tracing client. +pub use atupa_nitro as nitro; - pb.finish_with_message(format!("✔ Profile saved → {out_path}")); - Ok((out_path, network_name)) - } +/// Starknet (Cairo VM) protocol tracer. +pub use atupa_starknet as starknet; - // ── Helpers ─────────────────────────────────────────────────────────────── +/// Solana (Sealevel VM) protocol tracer. +pub use atupa_solana as solana; - fn make_spinner() -> ProgressBar { - let pb = ProgressBar::new_spinner(); - pb.set_style( - ProgressStyle::with_template("{spinner:.cyan} {msg}") - .unwrap() - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), - ); - pb.enable_steady_tick(Duration::from_millis(80)); - pb - } +/// Stellar (Soroban WASM VM) protocol tracer. +pub use atupa_stellar as stellar; - fn get_network_name(chain_id: u64) -> String { - match chain_id { - 1 => "Ethereum Mainnet".to_string(), - 11155111 => "Sepolia Testnet".to_string(), - 17000 => "Holesky Testnet".to_string(), - 42161 => "Arbitrum One".to_string(), - 42170 => "Arbitrum Nova".to_string(), - 421614 => "Arbitrum Sepolia".to_string(), - 8453 => "Base Mainnet".to_string(), - 84532 => "Base Sepolia".to_string(), - 10 => "Optimism".to_string(), - 11155420 => "Optimism Sepolia".to_string(), - 137 => "Polygon POS".to_string(), - 1337 | 31337 => "Local Devnet".to_string(), - 412346 => "Nitro Local Devnet".to_string(), - 0 => "Unknown Network".to_string(), - id => format!("Chain ID: {id}"), - } - } +// ─── High-level API Re-exports ──────────────────────────────────────────────── - /// A rich offline demo trace showcasing nested calls, reverts, and simulated Stylus steps. - fn demo_stacks() -> Vec { - vec![ - // ── Root frame ops (depth 1) ──────────────────────────────────── - CollapsedStack { - stack: "CALL".to_string(), - weight: 21_000, - last_pc: Some(0), - depth: 1, - vm_kind: VmKind::Evm, - target_address: None, - resolved_label: Some("Root CALL (21,000 gas)".to_string()), - reverted: false, - }, - CollapsedStack { - stack: "CALL;SLOAD".to_string(), - weight: 2_100, - last_pc: Some(10), - depth: 2, - vm_kind: VmKind::Evm, - target_address: None, - resolved_label: Some("Storage Read (2,100 gas)".to_string()), - reverted: false, - }, - CollapsedStack { - stack: "CALL;SSTORE".to_string(), - weight: 20_000, - last_pc: Some(14), - depth: 2, - vm_kind: VmKind::Evm, - target_address: None, - resolved_label: Some("Storage Write (20,000 gas)".to_string()), - reverted: false, - }, - // ── Nested sub-call (depth 2 → 3) ────────────────────────────── - CollapsedStack { - stack: "CALL;CALL;KECCAK256".to_string(), - weight: 30, - last_pc: Some(20), - depth: 3, - vm_kind: VmKind::Evm, - target_address: Some("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()), - resolved_label: Some("USDC: KECCAK256 (30 gas)".to_string()), - reverted: false, - }, - CollapsedStack { - stack: "CALL;CALL;SLOAD".to_string(), - weight: 2_100, - last_pc: Some(24), - depth: 3, - vm_kind: VmKind::Evm, - target_address: None, - resolved_label: Some("Nested SLOAD (2,100 gas)".to_string()), - reverted: false, - }, - // ── Reverted sub-call (depth 2) ───────────────────────────────── - CollapsedStack { - stack: "CALL;REVERT".to_string(), - weight: 5_000, - last_pc: Some(40), - depth: 2, - vm_kind: VmKind::Evm, - target_address: None, - resolved_label: Some("REVERTED sub-call (5,000 gas)".to_string()), - reverted: true, - }, - // ── Simulated Stylus WASM steps ───────────────────────────────── - CollapsedStack { - stack: "storage_load_bytes32".to_string(), - weight: 421, - last_pc: None, - depth: 1, - vm_kind: VmKind::Stylus, - target_address: None, - resolved_label: Some( - "storage_load_bytes32 (4,215 ink → 0.42 gas-equiv)".to_string(), - ), - reverted: false, - }, - CollapsedStack { - stack: "storage_flush_cache".to_string(), - weight: 4_001, - last_pc: None, - depth: 1, - vm_kind: VmKind::Stylus, - target_address: None, - resolved_label: Some( - "storage_flush_cache (40,010 ink → 4.00 gas-equiv)".to_string(), - ), - reverted: false, - }, - CollapsedStack { - stack: "native_keccak256".to_string(), - weight: 4, - last_pc: None, - depth: 1, - vm_kind: VmKind::Stylus, - target_address: None, - resolved_label: Some("native_keccak256 (36 ink → 0.004 gas-equiv)".to_string()), - reverted: false, - }, - ] - } -} +pub use profile::{VmHint, execute_profile}; +pub use registry::build_default_registry; diff --git a/crates/atupa-sdk/src/profile.rs b/crates/atupa-sdk/src/profile.rs new file mode 100644 index 0000000..b1670b6 --- /dev/null +++ b/crates/atupa-sdk/src/profile.rs @@ -0,0 +1,408 @@ +//! High-level profile execution engine, usable programmatically or via CLI. + +use anyhow::Result; +use atupa_core::{CollapsedStack, VmKind}; +use atupa_nitro::{NitroClient, VmKind as NitroVmKind}; +use atupa_output::SvgGenerator; +use atupa_parser::{Parser as AtupaParser, aggregator::Aggregator}; +use atupa_rpc::etherscan::EtherscanResolver; +use atupa_solana::{SolanaClient, SolanaLogStitcher}; +use atupa_starknet::StarknetClient; +use atupa_stellar::StellarClient; +use indicatif::{ProgressBar, ProgressStyle}; +use std::{fs, time::Duration}; + +/// Controls which VM runtime `execute_profile` targets. +/// +/// If omitted (`None`), heuristic auto-detection determines the target runtime +/// from the RPC endpoint URL and transaction hash structure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VmHint { + /// Standard EVM / Arbitrum Nitro (EVM + optional Stylus stitching) + Evm, + /// Force Arbitrum Stylus trace + Stylus, + /// Starknet Cairo VM + Starknet, + /// Solana Sealevel VM + Solana, + /// Stellar Soroban WASM VM + Stellar, +} + +/// Detects which VM to use given an optional explicit hint, the RPC URL, and the transaction hash. +pub fn detect_vm(vm_hint: Option<&VmHint>, rpc: &str, tx: &str) -> VmHint { + if let Some(hint) = vm_hint { + return hint.clone(); + } + + if rpc.contains("starknet") || tx.len() > 66 { + VmHint::Starknet + } else if rpc.contains("solana") || tx.len() == 44 { + VmHint::Solana + } else if rpc.contains("stellar") || rpc.contains("soroban") || tx.len() == 64 { + VmHint::Stellar + } else { + VmHint::Evm + } +} + +/// Fetch (or generate a demo), aggregate, and render an SVG flamegraph for +/// the given transaction hash. +pub async fn execute_profile( + tx: &str, + rpc: &str, + is_demo: bool, + out: Option, + etherscan_key: Option, + vm_hint: Option, +) -> Result<(String, String)> { + let pb = make_spinner(); + + // 1. Fetch & Aggregate Stacks ────────────────────────────────────────────── + let (mut stacks, network_name) = if is_demo { + pb.set_message("Generating offline demo trace…"); + (demo_stacks(), "Demo".to_string()) + } else { + pb.set_message("Detecting network and fetching execution trace…"); + let target_vm = detect_vm(vm_hint.as_ref(), rpc, tx); + + match target_vm { + VmHint::Starknet => fetch_starknet_stacks(tx, rpc, &pb).await?, + VmHint::Solana => fetch_solana_stacks(tx, rpc, &pb).await?, + VmHint::Stellar => fetch_stellar_stacks(tx, rpc, &pb).await?, + VmHint::Evm | VmHint::Stylus => { + fetch_evm_nitro_stacks(tx, rpc, etherscan_key, &pb).await? + } + } + }; + + // Sort EVM stacks descending by weight; Stylus stacks come after + let evm_end = stacks.partition_point(|s| s.vm_kind == VmKind::Evm); + stacks[..evm_end].sort_by_key(|b| std::cmp::Reverse(b.weight)); + + // 2. Render & Output ─────────────────────────────────────────────────────── + pb.set_message("Generating SVG flamegraph…"); + let out_path = render_and_save_flamegraph(&stacks, tx, is_demo, out)?; + + pb.finish_with_message(format!("✔ Profile saved → {out_path}")); + Ok((out_path, network_name)) +} + +// ─── VM Fetch Handlers ──────────────────────────────────────────────────────── + +async fn fetch_starknet_stacks( + tx: &str, + rpc: &str, + pb: &ProgressBar, +) -> Result<(Vec, String)> { + pb.set_message("Starknet node detected. Fetching Cairo VM trace…"); + let client = StarknetClient::new(rpc.to_string()); + let steps = client + .profile_transaction(tx) + .await + .map_err(|e| anyhow::anyhow!("Starknet RPC error: {e}"))?; + + let normalized = AtupaParser::normalize_raw(steps); + let combined = Aggregator::build_collapsed_stacks(&normalized); + Ok((combined, "Starknet".to_string())) +} + +async fn fetch_solana_stacks( + tx: &str, + rpc: &str, + pb: &ProgressBar, +) -> Result<(Vec, String)> { + pb.set_message("Solana node detected. Reconstructing Sealevel VM trace…"); + let client = SolanaClient::new(rpc.to_string()); + let logs = client + .get_transaction_logs(tx) + .await + .map_err(|e| anyhow::anyhow!("Solana RPC error: {e}"))?; + + let steps = SolanaLogStitcher::parse_logs(&logs); + let normalized = AtupaParser::normalize_raw(steps); + let combined = Aggregator::build_collapsed_stacks(&normalized); + Ok((combined, "Solana".to_string())) +} + +async fn fetch_stellar_stacks( + tx: &str, + rpc: &str, + pb: &ProgressBar, +) -> Result<(Vec, String)> { + pb.set_message("Stellar node detected. Fetching Soroban diagnostic trace…"); + let client = StellarClient::new(rpc.to_string()); + let steps = client + .get_transaction_trace(tx) + .await + .map_err(|e| anyhow::anyhow!("Stellar RPC error: {e}"))?; + + let normalized = AtupaParser::normalize_raw(steps); + let combined = Aggregator::build_collapsed_stacks(&normalized); + Ok((combined, "Stellar".to_string())) +} + +async fn fetch_evm_nitro_stacks( + tx: &str, + rpc: &str, + etherscan_key: Option, + pb: &ProgressBar, +) -> Result<(Vec, String)> { + let client = NitroClient::new(rpc.to_string()); + let report = tokio::time::timeout(Duration::from_secs(30), client.trace_transaction(tx)) + .await + .map_err(|_| anyhow::anyhow!("RPC timed out after 30s — is the node reachable at {rpc}?"))? + .map_err(|e| anyhow::anyhow!("RPC error: {e}"))?; + + let network = get_network_name(report.chain_id); + let evm_count = report + .steps + .iter() + .filter(|s| s.vm == NitroVmKind::Evm) + .count(); + let wasm_count = report + .steps + .iter() + .filter(|s| s.vm == NitroVmKind::Stylus) + .count(); + pb.set_message(format!( + "Processing {evm_count} EVM + {wasm_count} Stylus steps from {network}…" + )); + + let unified_steps: Vec = + report.steps.iter().map(|s| s.to_trace_step()).collect(); + + let normalized = AtupaParser::normalize_raw(unified_steps); + let registry = crate::build_default_registry(); + let mut combined = Aggregator::build_collapsed_stacks_with_registry(&normalized, ®istry); + + // Etherscan resolution — only meaningful for EVM steps with an address. + pb.set_message("Resolving contract names via Etherscan…"); + let resolver = EtherscanResolver::new(etherscan_key, report.chain_id); + for stack in &mut combined { + if stack.vm_kind == VmKind::Evm + && let Some(addr) = &stack.target_address + && let Some(name) = resolver.resolve_contract_name(addr).await + { + stack.target_address = Some(name); + } + } + + Ok((combined, network)) +} + +fn render_and_save_flamegraph( + stacks: &[CollapsedStack], + tx: &str, + is_demo: bool, + out: Option, +) -> Result { + let svg = SvgGenerator::generate_flamegraph(stacks)?; + let out_path = out.unwrap_or_else(|| { + if is_demo { + "profile_demo.svg".to_string() + } else { + let short = tx.trim_start_matches("0x").get(..10).unwrap_or(tx); + format!("profile_{short}.svg") + } + }); + fs::write(&out_path, svg)?; + Ok(out_path) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +fn make_spinner() -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + pb +} + +/// Returns a human-friendly name for standard chain IDs. +pub fn get_network_name(chain_id: u64) -> String { + match chain_id { + 1 => "Ethereum Mainnet".to_string(), + 11155111 => "Sepolia Testnet".to_string(), + 17000 => "Holesky Testnet".to_string(), + 42161 => "Arbitrum One".to_string(), + 42170 => "Arbitrum Nova".to_string(), + 421614 => "Arbitrum Sepolia".to_string(), + 8453 => "Base Mainnet".to_string(), + 84532 => "Base Sepolia".to_string(), + 10 => "Optimism".to_string(), + 11155420 => "Optimism Sepolia".to_string(), + 137 => "Polygon POS".to_string(), + 1337 | 31337 => "Local Devnet".to_string(), + 412346 => "Nitro Local Devnet".to_string(), + 0 => "Unknown Network".to_string(), + id => format!("Chain ID: {id}"), + } +} + +/// A rich offline demo trace showcasing nested calls, storage operations, reverts, and simulated Stylus steps. +pub fn demo_stacks() -> Vec { + vec![ + // ── Root frame ops (depth 1) ──────────────────────────────────── + CollapsedStack { + stack: "CALL".to_string(), + weight: 21_000, + last_pc: Some(0), + depth: 1, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Root CALL (21,000 gas)".to_string()), + reverted: false, + }, + CollapsedStack { + stack: "CALL;SLOAD".to_string(), + weight: 2_100, + last_pc: Some(10), + depth: 2, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Storage Read (2,100 gas)".to_string()), + reverted: false, + }, + CollapsedStack { + stack: "CALL;SSTORE".to_string(), + weight: 20_000, + last_pc: Some(14), + depth: 2, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Storage Write (20,000 gas)".to_string()), + reverted: false, + }, + // ── Nested sub-call (depth 2 → 3) ────────────────────────────── + CollapsedStack { + stack: "CALL;CALL;KECCAK256".to_string(), + weight: 30, + last_pc: Some(20), + depth: 3, + vm_kind: VmKind::Evm, + target_address: Some("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()), + resolved_label: Some("USDC: KECCAK256 (30 gas)".to_string()), + reverted: false, + }, + CollapsedStack { + stack: "CALL;CALL;SLOAD".to_string(), + weight: 2_100, + last_pc: Some(24), + depth: 3, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Nested SLOAD (2,100 gas)".to_string()), + reverted: false, + }, + // ── Reverted sub-call (depth 2) ───────────────────────────────── + CollapsedStack { + stack: "CALL;REVERT".to_string(), + weight: 5_000, + last_pc: Some(40), + depth: 2, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("REVERTED sub-call (5,000 gas)".to_string()), + reverted: true, + }, + // ── Simulated Stylus WASM steps ───────────────────────────────── + CollapsedStack { + stack: "storage_load_bytes32".to_string(), + weight: 421, + last_pc: None, + depth: 1, + vm_kind: VmKind::Stylus, + target_address: None, + resolved_label: Some("storage_load_bytes32 (4,215 ink → 0.42 gas-equiv)".to_string()), + reverted: false, + }, + CollapsedStack { + stack: "storage_flush_cache".to_string(), + weight: 4_001, + last_pc: None, + depth: 1, + vm_kind: VmKind::Stylus, + target_address: None, + resolved_label: Some("storage_flush_cache (40,010 ink → 4.00 gas-equiv)".to_string()), + reverted: false, + }, + CollapsedStack { + stack: "native_keccak256".to_string(), + weight: 4, + last_pc: None, + depth: 1, + vm_kind: VmKind::Stylus, + target_address: None, + resolved_label: Some("native_keccak256 (36 ink → 0.004 gas-equiv)".to_string()), + reverted: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_explicit_vm_hint() { + assert_eq!( + detect_vm(Some(&VmHint::Starknet), "http://localhost:8545", "0x1234"), + VmHint::Starknet + ); + assert_eq!( + detect_vm(Some(&VmHint::Solana), "http://localhost:8545", "0x1234"), + VmHint::Solana + ); + } + + #[test] + fn detects_vm_heuristics() { + // Starknet heuristic via URL + assert_eq!( + detect_vm( + None, + "https://starknet-mainnet.public.blastapi.io", + "0x1234" + ), + VmHint::Starknet + ); + // Solana heuristic via 44-char signature + let solana_sig = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; + assert_eq!( + detect_vm(None, "https://api.mainnet-beta.solana.com", solana_sig), + VmHint::Solana + ); + // Stellar heuristic via 64-char hash + let stellar_hash = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + assert_eq!( + detect_vm( + None, + "https://soroban-rpc.mainnet.stellar.org", + stellar_hash + ), + VmHint::Stellar + ); + } + + #[test] + fn network_names_mapping() { + assert_eq!(get_network_name(1), "Ethereum Mainnet"); + assert_eq!(get_network_name(42161), "Arbitrum One"); + assert_eq!(get_network_name(8453), "Base Mainnet"); + assert_eq!(get_network_name(99999), "Chain ID: 99999"); + } + + #[test] + fn demo_stacks_has_evm_and_stylus_items() { + let stacks = demo_stacks(); + assert!(!stacks.is_empty()); + assert!(stacks.iter().any(|s| s.vm_kind == VmKind::Evm)); + assert!(stacks.iter().any(|s| s.vm_kind == VmKind::Stylus)); + } +} diff --git a/crates/atupa-sdk/src/registry.rs b/crates/atupa-sdk/src/registry.rs new file mode 100644 index 0000000..9ec846d --- /dev/null +++ b/crates/atupa-sdk/src/registry.rs @@ -0,0 +1,28 @@ +//! Default protocol adapter registry for the Atupa SDK. + +use atupa_adapters::{AdapterRegistry, Erc20Adapter}; + +/// Builds the default adapter registry for the Atupa SDK, pre-loaded with +/// all supported protocol adapters (Uniswap v4, ERC-20, Aave v3 / GHO, Lido stETH). +pub fn build_default_registry() -> AdapterRegistry { + let mut registry = AdapterRegistry::new(); + registry.register_typed(Erc20Adapter); + registry.register_typed(atupa_aave::AaveV3Adapter); + registry.register_typed(atupa_lido::LidoAdapter); + registry +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_registry_contains_core_adapters() { + let registry = build_default_registry(); + assert!(!registry.is_empty()); + assert!(registry.contains("Aave v3 / GHO")); + assert!(registry.contains("Lido stETH")); + assert!(registry.contains("Uniswap v4")); + assert!(registry.contains("ERC-20")); + } +} diff --git a/crates/atupa-solana/Cargo.toml b/crates/atupa-solana/Cargo.toml new file mode 100644 index 0000000..97657c1 --- /dev/null +++ b/crates/atupa-solana/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "atupa-solana" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +description = "Solana Sealevel VM log-to-trace adapter for the Atupa engine" +keywords = ["solana", "sealevel", "tracing", "logs"] +categories = ["development-tools", "cryptography::cryptocurrencies"] + +[dependencies] +atupa-core = { workspace = true } +atupa-rpc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +reqwest = { workspace = true } +regex = "1.11.1" diff --git a/crates/atupa-solana/src/client.rs b/crates/atupa-solana/src/client.rs new file mode 100644 index 0000000..151d325 --- /dev/null +++ b/crates/atupa-solana/src/client.rs @@ -0,0 +1,107 @@ +//! JSON-RPC client for interacting with Solana validator RPC nodes. + +use atupa_rpc::RpcError; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::error::{SolanaError, SolanaResult}; + +/// JSON-RPC client for querying Solana transaction metadata and program execution logs. +pub struct SolanaClient { + rpc_url: String, + client: Client, +} + +impl SolanaClient { + /// Creates a new [`SolanaClient`] pointing to the specified Solana RPC endpoint. + pub fn new(rpc_url: impl Into) -> Self { + Self { + rpc_url: rpc_url.into(), + client: Client::new(), + } + } + + /// Returns the target RPC URL. + pub fn rpc_url(&self) -> &str { + &self.rpc_url + } + + /// Retrieves the program log messages for a confirmed transaction signature via `getTransaction`. + pub async fn get_transaction_logs(&self, tx_sig: &str) -> SolanaResult> { + let payload = json!({ + "jsonrpc": "2.0", + "method": "getTransaction", + "params": [tx_sig, { "encoding": "json", "maxSupportedTransactionVersion": 0 }], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await? + .json::() + .await?; + + if let Some(error) = response.get("error") { + return Err(SolanaError::Rpc(RpcError::Node( + error["message"] + .as_str() + .unwrap_or("Unknown RPC error") + .to_string(), + ))); + } + + let result: SolanaTransactionResponse = serde_json::from_value(response["result"].clone()) + .map_err(|e| SolanaError::Parse(e.to_string()))?; + + result + .meta + .and_then(|m| m.log_messages) + .ok_or_else(|| SolanaError::Parse("No log messages found in transaction".into())) + } +} + +// ─── Response Data Models ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SolanaTransactionResponse { + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SolanaMeta { + #[serde(rename = "logMessages")] + pub log_messages: Option>, + pub fee: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_constructor_and_getter() { + let client = SolanaClient::new("https://api.mainnet-beta.solana.com"); + assert_eq!(client.rpc_url(), "https://api.mainnet-beta.solana.com"); + } + + #[test] + fn deserializes_solana_response() { + let json_str = r#"{ + "meta": { + "logMessages": ["Program 1111 invoke [1]"], + "fee": 5000 + } + }"#; + + let res: SolanaTransactionResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(res.meta.as_ref().unwrap().fee, 5000); + assert_eq!( + res.meta.as_ref().unwrap().log_messages, + Some(vec!["Program 1111 invoke [1]".to_string()]) + ); + } +} diff --git a/crates/atupa-solana/src/error.rs b/crates/atupa-solana/src/error.rs new file mode 100644 index 0000000..05eda28 --- /dev/null +++ b/crates/atupa-solana/src/error.rs @@ -0,0 +1,44 @@ +//! Error types for Solana RPC and log parsing operations. + +use atupa_rpc::RpcError; +use thiserror::Error; + +/// Errors that can occur during Solana RPC calls or log reconstruction. +#[derive(Error, Debug)] +pub enum SolanaError { + /// HTTP or network layer failure. + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// Solana JSON-RPC error. + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + + /// Parsing or structure error in transaction log response. + #[error("Parsing error: {0}")] + Parse(String), + + /// Request timed out. + #[error("Timeout error: {0}")] + Timeout(String), +} + +/// Convenience result alias for operations returning [`SolanaError`]. +pub type SolanaResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_formatting() { + let err = SolanaError::Parse("missing logMessages".to_string()); + assert_eq!(err.to_string(), "Parsing error: missing logMessages"); + + let err_timeout = SolanaError::Timeout("node did not respond in 30s".to_string()); + assert_eq!( + err_timeout.to_string(), + "Timeout error: node did not respond in 30s" + ); + } +} diff --git a/crates/atupa-solana/src/lib.rs b/crates/atupa-solana/src/lib.rs new file mode 100644 index 0000000..aa6e438 --- /dev/null +++ b/crates/atupa-solana/src/lib.rs @@ -0,0 +1,30 @@ +//! # atupa-solana +//! +//! Solana Sealevel VM program log parser and trace reconstructor for the Atupa engine. +//! +//! Solana does not expose opcode-level step traces by default. This crate reconstructs +//! execution trees and computes exclusive compute unit (CU) costs per frame by parsing +//! standard Solana `Program ... invoke`, `consumed ... compute units`, and `success/failed` +//! transaction log messages. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`error`] | [`SolanaError`] and [`SolanaResult`](error::SolanaResult) | +//! | [`client`] | [`SolanaClient`] for querying validator RPCs via `getTransaction` | +//! | [`parser`] | [`SolanaLogStitcher`] for reconstructing [`atupa_core::TraceStep`] trees | +//! +//! ## Re-exports +//! +//! Primary types are re-exported at the crate root. + +pub mod client; +pub mod error; +pub mod parser; + +// ── Flat re-exports ─────────────────────────────────────────────────────────── + +pub use client::{SolanaClient, SolanaMeta, SolanaTransactionResponse}; +pub use error::{SolanaError, SolanaResult}; +pub use parser::SolanaLogStitcher; diff --git a/crates/atupa-solana/src/parser.rs b/crates/atupa-solana/src/parser.rs new file mode 100644 index 0000000..cd400bb --- /dev/null +++ b/crates/atupa-solana/src/parser.rs @@ -0,0 +1,190 @@ +//! Reconstructs hierarchical execution traces from raw Solana program log events. + +use atupa_core::{TraceStep, VmKind}; +use regex::Regex; +use std::sync::OnceLock; + +static INVOKE_REGEX: OnceLock = OnceLock::new(); +static CONSUMED_REGEX: OnceLock = OnceLock::new(); +static RETURN_REGEX: OnceLock = OnceLock::new(); + +fn get_invoke_regex() -> &'static Regex { + INVOKE_REGEX.get_or_init(|| { + Regex::new(r"Program (?P[1-9A-HJ-NP-Za-km-z]{32,44}) invoke \[(?P\d+)\]") + .expect("Valid invoke regex") + }) +} + +fn get_consumed_regex() -> &'static Regex { + CONSUMED_REGEX.get_or_init(|| { + Regex::new(r"Program (?P[1-9A-HJ-NP-Za-km-z]{32,44}) consumed (?P\d+) of (?P\d+) compute units") + .expect("Valid consumed regex") + }) +} + +fn get_return_regex() -> &'static Regex { + RETURN_REGEX.get_or_init(|| { + Regex::new(r"Program (?P[1-9A-HJ-NP-Za-km-z]{32,44}) (?Psuccess|failed)") + .expect("Valid return regex") + }) +} + +/// Reconstructs linear/hierarchical [`TraceStep`] execution traces from raw Solana log strings. +pub struct SolanaLogStitcher; + +impl SolanaLogStitcher { + /// Reconstructs a trace timeline from raw Solana log strings. + pub fn parse_logs(logs: &[String]) -> Vec { + let mut steps = Vec::new(); + let mut active_frames: Vec = Vec::new(); + + let invoke_re = get_invoke_regex(); + let consumed_re = get_consumed_regex(); + let return_re = get_return_regex(); + + for log in logs { + if let Some(caps) = invoke_re.captures(log) { + handle_invoke(&caps, &mut steps, &mut active_frames); + } else if let Some(caps) = consumed_re.captures(log) { + handle_consumed(&caps, &mut active_frames); + } else if let Some(caps) = return_re.captures(log) { + handle_return(&caps, &mut steps, &mut active_frames); + } + } + + steps + } +} + +// ─── Internal Parsing Helpers ───────────────────────────────────────────────── + +struct ActiveFrame { + addr: String, + start_idx: usize, + total_cu: u64, + children_cu: u64, +} + +fn handle_invoke( + caps: ®ex::Captures<'_>, + steps: &mut Vec, + active_frames: &mut Vec, +) { + let addr = caps["addr"].to_string(); + let depth: u16 = caps["depth"].parse().unwrap_or(1); + + let short_addr = if addr.len() > 8 { &addr[0..8] } else { &addr }; + + steps.push(TraceStep { + pc: 0, + op: format!("INVOKE:{short_addr}"), + gas: 0, + gas_cost: 0, // Computed when the frame returns + depth, + stack: Some(vec![addr.clone()]), + memory: None, + error: None, + reverted: false, + vm_kind: VmKind::Solana, + }); + + active_frames.push(ActiveFrame { + addr, + start_idx: steps.len() - 1, + total_cu: 0, + children_cu: 0, + }); +} + +fn handle_consumed(caps: ®ex::Captures<'_>, active_frames: &mut [ActiveFrame]) { + let addr = &caps["addr"]; + let cu: u64 = caps["cu"].parse().unwrap_or(0); + + // Match the consumed log to the topmost active frame for this address + if let Some(frame) = active_frames.iter_mut().rev().find(|f| f.addr == addr) { + frame.total_cu = cu; + } +} + +fn handle_return( + caps: ®ex::Captures<'_>, + steps: &mut [TraceStep], + active_frames: &mut Vec, +) { + let addr = &caps["addr"]; + let status = &caps["status"]; + + // Pop frames until we find the matching address (handles intermediate uncaught failures) + while let Some(frame) = active_frames.pop() { + let is_match = frame.addr == addr; + + let exclusive_cu = frame.total_cu.saturating_sub(frame.children_cu); + if let Some(step) = steps.get_mut(frame.start_idx) { + step.gas_cost = exclusive_cu; + if is_match && status == "failed" { + step.reverted = true; + } + } + + if let Some(parent) = active_frames.last_mut() { + parent.children_cu = parent.children_cu.saturating_add(frame.total_cu); + } + + if is_match { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_nested_program_invocations() { + let logs = vec![ + "Program 11111111111111111111111111111111 invoke [1]".to_string(), + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]".to_string(), + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4000 of 195000 compute units".to_string(), + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success".to_string(), + "Program 11111111111111111111111111111111 consumed 5000 of 200000 compute units".to_string(), + "Program 11111111111111111111111111111111 success".to_string(), + ]; + + let steps = SolanaLogStitcher::parse_logs(&logs); + assert_eq!(steps.len(), 2); + + // Step 0 is the parent + assert_eq!(steps[0].op, "INVOKE:11111111"); + assert_eq!(steps[0].depth, 1); + assert_eq!(steps[0].gas_cost, 1000); // 5000 total - 4000 children + assert!(!steps[0].reverted); + + // Step 1 is the child + assert_eq!(steps[1].op, "INVOKE:Tokenkeg"); + assert_eq!(steps[1].depth, 2); + assert_eq!(steps[1].gas_cost, 4000); // 4000 total - 0 children + assert!(!steps[1].reverted); + } + + #[test] + fn parses_failed_invocation() { + let logs = vec![ + "Program 11111111111111111111111111111111 invoke [1]".to_string(), + "Program 11111111111111111111111111111111 consumed 2500 of 200000 compute units" + .to_string(), + "Program 11111111111111111111111111111111 failed".to_string(), + ]; + + let steps = SolanaLogStitcher::parse_logs(&logs); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].gas_cost, 2500); + assert!(steps[0].reverted); + } + + #[test] + fn empty_logs_returns_empty_steps() { + let steps = SolanaLogStitcher::parse_logs(&[]); + assert!(steps.is_empty()); + } +} diff --git a/crates/atupa-starknet/Cargo.toml b/crates/atupa-starknet/Cargo.toml new file mode 100644 index 0000000..76e6c86 --- /dev/null +++ b/crates/atupa-starknet/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "atupa-starknet" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +description = "Starknet (Cairo VM) tracing adapter for the Atupa engine" +keywords = ["starknet", "cairo", "tracing", "profiler"] +categories = ["development-tools", "cryptography::cryptocurrencies"] + +[dependencies] +atupa-core = { workspace = true } +atupa-rpc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +reqwest = { workspace = true } diff --git a/crates/atupa-starknet/src/client.rs b/crates/atupa-starknet/src/client.rs new file mode 100644 index 0000000..3761aeb --- /dev/null +++ b/crates/atupa-starknet/src/client.rs @@ -0,0 +1,93 @@ +//! JSON-RPC client for querying Starknet node execution traces. + +use atupa_core::TraceStep; +use atupa_rpc::RpcError; +use reqwest::Client; +use serde_json::json; + +use crate::error::{StarknetError, StarknetResult}; +use crate::flattener::{flatten_invocation, flatten_trace}; +use crate::types::{FunctionInvocation, StarknetTransactionTrace}; + +/// JSON-RPC client for querying Starknet execution traces and Cairo resource counters. +pub struct StarknetClient { + rpc_url: String, + client: Client, +} + +impl StarknetClient { + /// Creates a new [`StarknetClient`] pointing to the specified Starknet RPC endpoint. + pub fn new(rpc_url: impl Into) -> Self { + Self { + rpc_url: rpc_url.into(), + client: Client::new(), + } + } + + /// Returns the target RPC URL. + pub fn rpc_url(&self) -> &str { + &self.rpc_url + } + + /// Retrieves the execution trace of a transaction via `starknet_traceTransaction`. + pub async fn get_transaction_trace( + &self, + tx_hash: &str, + ) -> StarknetResult { + let payload = json!({ + "jsonrpc": "2.0", + "method": "starknet_traceTransaction", + "params": [tx_hash], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await? + .json::() + .await?; + + if let Some(error) = response.get("error") { + return Err(StarknetError::Rpc(RpcError::Node( + error["message"] + .as_str() + .unwrap_or("Unknown RPC error") + .to_string(), + ))); + } + + let result = response.get("result").ok_or_else(|| { + StarknetError::Process("Missing 'result' in starknet_traceTransaction response".into()) + })?; + + Ok(serde_json::from_value(result.clone())?) + } + + /// Recursively flattens a Starknet function invocation into [`TraceStep`]s. + pub fn flatten_trace(&self, invocation: &FunctionInvocation, depth: u16) -> Vec { + flatten_invocation(invocation, depth) + } + + /// Profiles a transaction by fetching its trace and flattening all execution phases into [`TraceStep`]s. + pub async fn profile_transaction(&self, tx_hash: &str) -> StarknetResult> { + let trace = self.get_transaction_trace(tx_hash).await?; + Ok(flatten_trace(&trace)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_constructor_and_getter() { + let client = StarknetClient::new("https://starknet-mainnet.public.blastapi.io"); + assert_eq!( + client.rpc_url(), + "https://starknet-mainnet.public.blastapi.io" + ); + } +} diff --git a/crates/atupa-starknet/src/error.rs b/crates/atupa-starknet/src/error.rs new file mode 100644 index 0000000..5955355 --- /dev/null +++ b/crates/atupa-starknet/src/error.rs @@ -0,0 +1,38 @@ +//! Error types for Starknet RPC and trace processing. + +use atupa_rpc::RpcError; +use thiserror::Error; + +/// Errors that can occur during Starknet RPC communication or trace extraction. +#[derive(Error, Debug)] +pub enum StarknetError { + /// HTTP or network layer failure. + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// Starknet JSON-RPC node error. + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + + /// JSON serialization or deserialization failure. + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// Trace processing or normalization error. + #[error("Processing error: {0}")] + Process(String), +} + +/// Convenience result alias for operations returning [`StarknetError`]. +pub type StarknetResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_formatting() { + let err = StarknetError::Process("missing result field".to_string()); + assert_eq!(err.to_string(), "Processing error: missing result field"); + } +} diff --git a/crates/atupa-starknet/src/flattener.rs b/crates/atupa-starknet/src/flattener.rs new file mode 100644 index 0000000..755e9e4 --- /dev/null +++ b/crates/atupa-starknet/src/flattener.rs @@ -0,0 +1,186 @@ +//! Flattens hierarchical Starknet call trees into linear [`TraceStep`] execution timelines. + +use crate::types::{ExecutionResources, FunctionInvocation, StarknetTransactionTrace}; +use atupa_core::{TraceStep, VmKind}; + +// ─── Builtin Gas Weights ────────────────────────────────────────────────────── + +/// Relative gas-equivalent weight per Pedersen hash invocation. +pub const PEDERSEN_WEIGHT: u64 = 32; + +/// Relative gas-equivalent weight per Range Check operation. +pub const RANGE_CHECK_WEIGHT: u64 = 16; + +/// Relative gas-equivalent weight per Bitwise builtin operation. +pub const BITWISE_WEIGHT: u64 = 64; + +/// Relative gas-equivalent weight per Poseidon hash invocation. +pub const POSEIDON_WEIGHT: u64 = 32; + +/// Relative gas-equivalent weight per Elliptic Curve operation. +pub const EC_OP_WEIGHT: u64 = 1024; + +/// Relative gas-equivalent weight per ECDSA signature verification. +pub const ECDSA_WEIGHT: u64 = 2048; + +/// Recursively flattens a [`FunctionInvocation`] and its nested sub-calls into [`TraceStep`]s. +pub fn flatten_invocation(invocation: &FunctionInvocation, depth: u16) -> Vec { + let mut steps = Vec::new(); + + let selector_label = if invocation.entry_point_selector.len() > 12 { + &invocation.entry_point_selector[0..12] + } else { + &invocation.entry_point_selector + }; + + // Root step for this call frame + steps.push(TraceStep { + pc: 0, + op: format!("CALL:{selector_label}"), + gas: 0, + gas_cost: invocation.execution_resources.steps, // Base Cairo step count + depth, + stack: Some(vec![invocation.contract_address.clone()]), + memory: None, + error: None, + reverted: false, + vm_kind: VmKind::Starknet, + }); + + // Virtual steps for builtins + append_builtin_steps(&invocation.execution_resources, depth + 1, &mut steps); + + // Recursively process nested child calls + for sub_call in &invocation.calls { + steps.extend(flatten_invocation(sub_call, depth + 1)); + } + + steps +} + +/// Flattens all top-level phases (validate, execute, fee transfer) of a [`StarknetTransactionTrace`]. +pub fn flatten_trace(trace: &StarknetTransactionTrace) -> Vec { + let mut all_steps = Vec::new(); + + if let Some(invoke) = &trace.validate_invocation { + all_steps.extend(flatten_invocation(invoke, 1)); + } + if let Some(invoke) = &trace.execute_invocation { + all_steps.extend(flatten_invocation(invoke, 1)); + } + if let Some(invoke) = &trace.fee_transfer_invocation { + all_steps.extend(flatten_invocation(invoke, 1)); + } + + all_steps +} + +fn append_builtin_steps(resources: &ExecutionResources, depth: u16, steps: &mut Vec) { + let mut add_builtin = |op: &str, count: u64, weight: u64| { + if count > 0 { + steps.push(TraceStep { + pc: 0, + op: op.to_string(), + gas: 0, + gas_cost: count.saturating_mul(weight), + depth, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: VmKind::Starknet, + }); + } + }; + + add_builtin("PEDERSEN", resources.pedersen_builtin, PEDERSEN_WEIGHT); + add_builtin( + "RANGE_CHECK", + resources.range_check_builtin, + RANGE_CHECK_WEIGHT, + ); + add_builtin("BITWISE", resources.bitwise_builtin, BITWISE_WEIGHT); + add_builtin("POSEIDON", resources.poseidon_builtin, POSEIDON_WEIGHT); + add_builtin("EC_OP", resources.ec_op_builtin, EC_OP_WEIGHT); + add_builtin("ECDSA", resources.ecdsa_builtin, ECDSA_WEIGHT); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flattens_recursive_invocation_with_builtins() { + let invocation = FunctionInvocation { + contract_address: "0x1".to_string(), + entry_point_selector: "0xabcdef123456789".to_string(), + calldata: vec![], + execution_resources: ExecutionResources { + steps: 100, + pedersen_builtin: 1, + range_check_builtin: 2, + ..Default::default() + }, + calls: vec![FunctionInvocation { + contract_address: "0x2".to_string(), + entry_point_selector: "0xdeadbeef".to_string(), + calldata: vec![], + execution_resources: ExecutionResources { + steps: 50, + ..Default::default() + }, + calls: vec![], + }], + }; + + let steps = flatten_invocation(&invocation, 1); + + // 1 (root) + 1 (pedersen) + 1 (range_check) + 1 (sub-call) = 4 steps + assert_eq!(steps.len(), 4); + assert_eq!(steps[0].op, "CALL:0xabcdef1234"); + assert_eq!(steps[0].depth, 1); + assert_eq!(steps[0].gas_cost, 100); + + assert_eq!(steps[1].op, "PEDERSEN"); + assert_eq!(steps[1].depth, 2); + assert_eq!(steps[1].gas_cost, 32); + + assert_eq!(steps[2].op, "RANGE_CHECK"); + assert_eq!(steps[2].depth, 2); + assert_eq!(steps[2].gas_cost, 32); // 2 * 16 + + assert_eq!(steps[3].op, "CALL:0xdeadbeef"); + assert_eq!(steps[3].depth, 2); + assert_eq!(steps[3].gas_cost, 50); + } + + #[test] + fn flattens_full_trace_phases() { + let trace = StarknetTransactionTrace { + validate_invocation: Some(FunctionInvocation { + contract_address: "0xAccount".to_string(), + entry_point_selector: "0xvalidate".to_string(), + execution_resources: ExecutionResources { + steps: 40, + ..Default::default() + }, + ..Default::default() + }), + execute_invocation: Some(FunctionInvocation { + contract_address: "0xDapp".to_string(), + entry_point_selector: "0xexecute".to_string(), + execution_resources: ExecutionResources { + steps: 200, + ..Default::default() + }, + ..Default::default() + }), + fee_transfer_invocation: None, + }; + + let steps = flatten_trace(&trace); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].op, "CALL:0xvalidate"); + assert_eq!(steps[1].op, "CALL:0xexecute"); + } +} diff --git a/crates/atupa-starknet/src/lib.rs b/crates/atupa-starknet/src/lib.rs new file mode 100644 index 0000000..1e053fb --- /dev/null +++ b/crates/atupa-starknet/src/lib.rs @@ -0,0 +1,33 @@ +//! # atupa-starknet +//! +//! Starknet (Cairo VM) execution trace adapter and flattener for the Atupa engine. +//! +//! Queries `starknet_traceTransaction` to retrieve hierarchical execution traces, +//! decomposes Cairo execution resources (steps, Pedersen, Range Check, Bitwise, Poseidon, +//! EC OP, and ECDSA builtins), and flattens recursive call frames into linear +//! [`atupa_core::TraceStep`] timelines for unified flamegraph profiling. +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`error`] | [`StarknetError`] and [`StarknetResult`](error::StarknetResult) | +//! | [`types`] | [`ExecutionResources`], [`FunctionInvocation`], [`StarknetTransactionTrace`] | +//! | [`flattener`] | [`flatten_invocation`] and [`flatten_trace`] recursive tree traversers | +//! | [`client`] | [`StarknetClient`] JSON-RPC client | +//! +//! ## Re-exports +//! +//! Primary types are re-exported at the crate root. + +pub mod client; +pub mod error; +pub mod flattener; +pub mod types; + +// ── Flat re-exports ─────────────────────────────────────────────────────────── + +pub use client::StarknetClient; +pub use error::{StarknetError, StarknetResult}; +pub use flattener::{flatten_invocation, flatten_trace}; +pub use types::{ExecutionResources, FunctionInvocation, StarknetTransactionTrace}; diff --git a/crates/atupa-starknet/src/types.rs b/crates/atupa-starknet/src/types.rs new file mode 100644 index 0000000..cc24ff4 --- /dev/null +++ b/crates/atupa-starknet/src/types.rs @@ -0,0 +1,105 @@ +//! Data models for Starknet (Cairo VM) transaction execution traces and resource counters. + +use serde::{Deserialize, Serialize}; + +/// Cairo VM execution resources consumed by a single Starknet call frame. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ExecutionResources { + /// Number of Cairo steps executed. + pub steps: u64, + /// Number of Pedersen hash builtin invocations. + #[serde(default)] + pub pedersen_builtin: u64, + /// Number of Range Check builtin invocations. + #[serde(default)] + pub range_check_builtin: u64, + /// Number of Bitwise builtin invocations. + #[serde(default)] + pub bitwise_builtin: u64, + /// Number of Poseidon hash builtin invocations. + #[serde(default)] + pub poseidon_builtin: u64, + /// Number of Elliptic Curve operations builtin invocations. + #[serde(default)] + pub ec_op_builtin: u64, + /// Number of ECDSA signature verification builtin invocations. + #[serde(default)] + pub ecdsa_builtin: u64, +} + +impl ExecutionResources { + /// Returns `true` if any builtins were utilized in this call frame. + pub fn has_builtins(&self) -> bool { + self.pedersen_builtin > 0 + || self.range_check_builtin > 0 + || self.bitwise_builtin > 0 + || self.poseidon_builtin > 0 + || self.ec_op_builtin > 0 + || self.ecdsa_builtin > 0 + } +} + +/// A recursive function call frame in a Starknet transaction trace. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct FunctionInvocation { + /// Target contract address in hex string format. + pub contract_address: String, + /// 4-byte / felt entry point selector. + pub entry_point_selector: String, + /// Raw calldata felts. + #[serde(default)] + pub calldata: Vec, + /// Execution resources consumed directly by this frame. + #[serde(default)] + pub execution_resources: ExecutionResources, + /// Nested child function calls invoked by this frame. + #[serde(default)] + pub calls: Vec, +} + +/// Top-level transaction trace payload returned by `starknet_traceTransaction`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct StarknetTransactionTrace { + /// Account contract validation phase invocation. + pub validate_invocation: Option, + /// Main execution phase invocation. + pub execute_invocation: Option, + /// Fee transfer phase invocation. + pub fee_transfer_invocation: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execution_resources_builtin_check() { + let empty = ExecutionResources::default(); + assert!(!empty.has_builtins()); + + let with_poseidon = ExecutionResources { + poseidon_builtin: 5, + ..Default::default() + }; + assert!(with_poseidon.has_builtins()); + } + + #[test] + fn deserializes_function_invocation() { + let json_str = r#"{ + "contract_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "entry_point_selector": "0x0361458367e696363fbcc70777d07ebbd23fef3c80269b0083d675d7b050f679", + "calldata": [], + "execution_resources": { + "steps": 120, + "pedersen_builtin": 2 + }, + "calls": [] + }"#; + + let inv: FunctionInvocation = serde_json::from_str(json_str).unwrap(); + assert_eq!(inv.execution_resources.steps, 120); + assert_eq!(inv.execution_resources.pedersen_builtin, 2); + assert!(inv.execution_resources.has_builtins()); + } +} diff --git a/crates/atupa-stellar/Cargo.toml b/crates/atupa-stellar/Cargo.toml new file mode 100644 index 0000000..a485345 --- /dev/null +++ b/crates/atupa-stellar/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "atupa-stellar" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +description = "Stellar Soroban (WASM) tracing adapter for the Atupa engine" +keywords = ["stellar", "soroban", "wasm", "tracing"] +categories = ["development-tools", "cryptography::cryptocurrencies"] + +[dependencies] +atupa-core = { workspace = true } +atupa-rpc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +reqwest = { workspace = true } diff --git a/crates/atupa-stellar/src/client.rs b/crates/atupa-stellar/src/client.rs new file mode 100644 index 0000000..7f1962f --- /dev/null +++ b/crates/atupa-stellar/src/client.rs @@ -0,0 +1,76 @@ +//! JSON-RPC client for querying Stellar / Soroban node diagnostic events. + +use atupa_core::TraceStep; +use atupa_rpc::RpcError; +use reqwest::Client; +use serde_json::json; + +use crate::error::{StellarError, StellarResult}; +use crate::parser::StellarTraceParser; +use crate::types::StellarTransactionResponse; + +/// JSON-RPC client for querying Stellar diagnostic transaction logs. +pub struct StellarClient { + rpc_url: String, + client: Client, +} + +impl StellarClient { + /// Creates a new [`StellarClient`] pointing to the specified Stellar/Soroban RPC endpoint. + pub fn new(rpc_url: impl Into) -> Self { + Self { + rpc_url: rpc_url.into(), + client: Client::new(), + } + } + + /// Returns the target RPC URL. + pub fn rpc_url(&self) -> &str { + &self.rpc_url + } + + /// Retrieves diagnostic events for a confirmed transaction and reconstructs [`TraceStep`]s. + pub async fn get_transaction_trace(&self, tx_hash: &str) -> StellarResult> { + let payload = json!({ + "jsonrpc": "2.0", + "method": "getTransaction", + "params": [tx_hash], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await? + .json::() + .await?; + + if let Some(error) = response.get("error") { + return Err(StellarError::Rpc(RpcError::Node( + error["message"] + .as_str() + .unwrap_or("Unknown RPC error") + .to_string(), + ))); + } + + let result: StellarTransactionResponse = serde_json::from_value(response["result"].clone()) + .map_err(|e| StellarError::Parse(e.to_string()))?; + + let events = result.diagnostic_events.unwrap_or_default(); + Ok(StellarTraceParser::parse_diagnostic_events(&events)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_constructor_and_getter() { + let client = StellarClient::new("https://soroban-rpc.mainnet.stellar.org"); + assert_eq!(client.rpc_url(), "https://soroban-rpc.mainnet.stellar.org"); + } +} diff --git a/crates/atupa-stellar/src/error.rs b/crates/atupa-stellar/src/error.rs new file mode 100644 index 0000000..4f1ae0a --- /dev/null +++ b/crates/atupa-stellar/src/error.rs @@ -0,0 +1,38 @@ +//! Error types for Stellar/Soroban RPC and event parsing. + +use atupa_rpc::RpcError; +use thiserror::Error; + +/// Errors that can occur during Stellar/Soroban RPC queries or diagnostic event extraction. +#[derive(Error, Debug)] +pub enum StellarError { + /// HTTP or network layer failure. + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// Stellar JSON-RPC node error. + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + + /// Parsing or structure error in transaction diagnostic events. + #[error("Parsing error: {0}")] + Parse(String), + + /// Request timed out. + #[error("Timeout error: {0}")] + Timeout(String), +} + +/// Convenience result alias for operations returning [`StellarError`]. +pub type StellarResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_display_formatting() { + let err = StellarError::Parse("malformed event payload".to_string()); + assert_eq!(err.to_string(), "Parsing error: malformed event payload"); + } +} diff --git a/crates/atupa-stellar/src/lib.rs b/crates/atupa-stellar/src/lib.rs new file mode 100644 index 0000000..3cf23b9 --- /dev/null +++ b/crates/atupa-stellar/src/lib.rs @@ -0,0 +1,32 @@ +//! # atupa-stellar +//! +//! Stellar Soroban (WASM) execution event parser and diagnostic trace adapter for the Atupa engine. +//! +//! Reconstructs hierarchical execution call frames from Soroban diagnostic events emitted +//! during smart contract execution and assigns gas costs according to Soroban host function +//! resource models (contract data storage, cryptography/hashing, sub-invocations). +//! +//! ## Modules +//! +//! | Module | Contents | +//! |---|---| +//! | [`error`] | [`StellarError`] and [`StellarResult`](error::StellarResult) | +//! | [`types`] | [`SorobanDiagnosticEvent`], [`StellarTransactionResponse`] | +//! | [`parser`] | [`StellarTraceParser`] diagnostic event reconstructor | +//! | [`client`] | [`StellarClient`] JSON-RPC client | +//! +//! ## Re-exports +//! +//! Primary types are re-exported at the crate root. + +pub mod client; +pub mod error; +pub mod parser; +pub mod types; + +// ── Flat re-exports ─────────────────────────────────────────────────────────── + +pub use client::StellarClient; +pub use error::{StellarError, StellarResult}; +pub use parser::StellarTraceParser; +pub use types::{SorobanDiagnosticEvent, StellarTransactionResponse}; diff --git a/crates/atupa-stellar/src/parser.rs b/crates/atupa-stellar/src/parser.rs new file mode 100644 index 0000000..4ba675d --- /dev/null +++ b/crates/atupa-stellar/src/parser.rs @@ -0,0 +1,131 @@ +//! Parser for mapping Stellar / Soroban diagnostic event streams to [`TraceStep`]s. + +use crate::types::SorobanDiagnosticEvent; +use atupa_core::{TraceStep, VmKind}; + +/// Estimated gas costs for common Soroban host functions. +pub const COST_PUT_CONTRACT_DATA: u64 = 5_000; +pub const COST_GET_CONTRACT_DATA: u64 = 2_100; +pub const COST_CRYPTO_HASH: u64 = 3_000; +pub const COST_INVOKE_CONTRACT: u64 = 1_500; +pub const COST_GENERIC_HOST_FN: u64 = 100; + +/// Reconstructs hierarchical execution traces from Soroban diagnostic events. +pub struct StellarTraceParser; + +impl StellarTraceParser { + /// Maps Stellar diagnostic events to Atupa [`TraceStep`]s. + pub fn parse_diagnostic_events(events: &[SorobanDiagnosticEvent]) -> Vec { + let mut steps = Vec::new(); + let mut depth: u16 = 1; + + for event in events { + if event.event_type != "diagnostic" { + continue; + } + + let event_action = event.topics.first().map(|s| s.as_str()).unwrap_or(""); + let fn_name = event.topics.get(1).map(|s| s.as_str()).unwrap_or("unknown"); + + // Handle depth adjustments for returning contract calls + if event_action.contains("return") { + depth = depth.saturating_sub(1); + continue; + } + + let gas_cost = estimate_host_fn_gas_cost(fn_name); + + steps.push(TraceStep { + pc: 0, + op: fn_name.to_string(), + gas: 0, + gas_cost, + depth, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: VmKind::Stellar, + }); + + // If this was an invocation call, nested events happen at deeper frame level + if fn_name.contains("invoke_contract") && event_action.contains("call") { + depth = depth.saturating_add(1); + } + } + + steps + } +} + +/// Estimates the gas-equivalent cost for a given Soroban host function name. +pub fn estimate_host_fn_gas_cost(fn_name: &str) -> u64 { + if fn_name.contains("put_contract_data") { + COST_PUT_CONTRACT_DATA + } else if fn_name.contains("get_contract_data") { + COST_GET_CONTRACT_DATA + } else if fn_name.contains("crypto") || fn_name.contains("hash") { + COST_CRYPTO_HASH + } else if fn_name.contains("invoke") { + COST_INVOKE_CONTRACT + } else { + COST_GENERIC_HOST_FN + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_nested_events_with_depth_tracking() { + let events = vec![ + SorobanDiagnosticEvent { + event_type: "diagnostic".into(), + topics: vec!["fn_call".into(), "invoke_contract".into()], + value: "AAAAA...".into(), + }, + SorobanDiagnosticEvent { + event_type: "diagnostic".into(), + topics: vec!["fn_call".into(), "put_contract_data".into()], + value: "AAAAA...".into(), + }, + SorobanDiagnosticEvent { + event_type: "diagnostic".into(), + topics: vec!["fn_return".into(), "invoke_contract".into()], + value: "AAAAA...".into(), + }, + ]; + + let steps = StellarTraceParser::parse_diagnostic_events(&events); + assert_eq!(steps.len(), 2); + + assert_eq!(steps[0].op, "invoke_contract"); + assert_eq!(steps[0].depth, 1); + assert_eq!(steps[0].gas_cost, COST_INVOKE_CONTRACT); + + assert_eq!(steps[1].op, "put_contract_data"); + assert_eq!(steps[1].depth, 2); // Depth increased after invoke_contract + assert_eq!(steps[1].gas_cost, COST_PUT_CONTRACT_DATA); + } + + #[test] + fn skips_non_diagnostic_events() { + let events = vec![SorobanDiagnosticEvent { + event_type: "contract".into(), + topics: vec!["transfer".into()], + value: "123".into(), + }]; + + let steps = StellarTraceParser::parse_diagnostic_events(&events); + assert!(steps.is_empty()); + } + + #[test] + fn estimates_gas_costs_accurately() { + assert_eq!(estimate_host_fn_gas_cost("put_contract_data"), 5000); + assert_eq!(estimate_host_fn_gas_cost("get_contract_data"), 2100); + assert_eq!(estimate_host_fn_gas_cost("crypto_keccak256"), 3000); + assert_eq!(estimate_host_fn_gas_cost("custom_host_fn"), 100); + } +} diff --git a/crates/atupa-stellar/src/types.rs b/crates/atupa-stellar/src/types.rs new file mode 100644 index 0000000..a2c53ab --- /dev/null +++ b/crates/atupa-stellar/src/types.rs @@ -0,0 +1,59 @@ +//! Data models for Stellar / Soroban diagnostic event RPC payloads. + +use serde::{Deserialize, Serialize}; + +/// A single Soroban diagnostic or contract event emitted during transaction execution. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct SorobanDiagnosticEvent { + /// Event category (e.g. `"diagnostic"`, `"contract"`). + #[serde(rename = "type")] + pub event_type: String, + /// Event topic strings representing the function action and target. + pub topics: Vec, + /// Base64 XDR or JSON value payload. + pub value: String, +} + +/// JSON-RPC response envelope from Stellar `getTransaction`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct StellarTransactionResponse { + /// Transaction status string (e.g. `"SUCCESS"`, `"FAILED"`). + pub status: String, + /// Transaction hash identifier. + pub tx_hash: String, + /// Optional list of diagnostic event logs emitted during Soroban execution. + pub diagnostic_events: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_diagnostic_event() { + let json_str = r#"{ + "type": "diagnostic", + "topics": ["fn_call", "put_contract_data"], + "value": "AAAAA..." + }"#; + + let event: SorobanDiagnosticEvent = serde_json::from_str(json_str).unwrap(); + assert_eq!(event.event_type, "diagnostic"); + assert_eq!(event.topics.len(), 2); + assert_eq!(event.topics[1], "put_contract_data"); + } + + #[test] + fn deserializes_stellar_response() { + let json_str = r#"{ + "status": "SUCCESS", + "tx_hash": "abc123", + "diagnostic_events": [] + }"#; + + let res: StellarTransactionResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(res.status, "SUCCESS"); + assert_eq!(res.tx_hash, "abc123"); + assert!(res.diagnostic_events.unwrap().is_empty()); + } +} diff --git a/docs/ADAPTER_GUIDE.md b/docs/ADAPTER_GUIDE.md new file mode 100644 index 0000000..30e60aa --- /dev/null +++ b/docs/ADAPTER_GUIDE.md @@ -0,0 +1,86 @@ +# 🛠 Atupa Adapter Guide: Building for a New VM + +Atupa is architected to be extensible to new execution environments (e.g. Move VM, Fuel VM, Aptos/Sui). This guide explains how to build a new VM adapter crate. + +--- + +## 1. Anatomy of an Adapter + +Every VM adapter should be an independent crate in the `crates/` directory (e.g., `crates/atupa-fuel`). An adapter's primary responsibility is to fetch raw RPC execution data and transform it into the unified `atupa_core::TraceStep` model. + +### Key Components: +1. **Error Types (`error.rs`)**: Domain-specific error enum (`FuelError`) with standard `std::error::Error` and `Display` implementations. +2. **Data Models (`types.rs`)**: Serde-compatible models mapping the target chain's JSON-RPC structures. +3. **Trace Parser / Flattener (`parser.rs`)**: Logic that converts logs, diagnostic events, or raw invocation trees into `Vec`. +4. **Client (`client.rs`)**: Async RPC client communicating with the target chain endpoint. +5. **Facade (`lib.rs`)**: Clean re-export of public client, parser, and error types. + +--- + +## 2. Implementation Steps + +### Step A: Define the `VmKind` +Add your new VM to the `VmKind` enum in `crates/atupa-core/src/vm.rs`: + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum VmKind { + Evm, + Stylus, + Starknet, + Solana, + Stellar, + Fuel, // New VM kind +} +``` + +### Step B: Create the Client and Parser +Implement the RPC fetching logic and map native execution events to `TraceStep`: + +```rust +pub struct FuelClient { + rpc_url: String, +} + +impl FuelClient { + pub fn new(rpc_url: String) -> Self { + Self { rpc_url } + } + + pub async fn get_transaction_trace(&self, tx: &str) -> FuelResult> { + // 1. Fetch raw transaction data from RPC + // 2. Parse opcodes or receipt receipts into TraceStep + // 3. Set vm_kind = VmKind::Fuel + } +} +``` + +### Step C: Handle Call-Stack Depth +Atupa flamegraphs rely on the `depth` field: +- **Flat sequential logs** (like Solana): Implement a state machine tracking call/return markers. +- **Recursive trees** (like Starknet): Recursively flatten the tree while incrementing the depth counter at each level. + +### Step D: Unit Normalization +Map native gas or resource units to a meaningful `gas_cost`: +- **Solana**: 1 Compute Unit (CU) = 1 `gas_cost`. +- **Soroban**: HostFn CPU/Memory weight = estimated `gas_cost`. +- **Starknet**: Cairo instruction steps + builtin resource weights = `gas_cost`. + +--- + +## 3. Registering the Adapter + +1. Add the new crate to the workspace `Cargo.toml`. +2. Add the dependency to `crates/atupa-sdk` and `bin/atupa`. +3. Add CLI hint handling in `bin/atupa/src/cli.rs` (`VmTarget`) and `crates/atupa-sdk/src/profile.rs` (`VmHint`). +4. Update `atupa-output` with chain-specific color schemes for SVG rendering. +5. Update `studio/src/types/trace.ts` with color tokens for Atupa Studio. + +--- + +## 4. Testing Your Adapter + +Add unit tests in your crate using fixture JSON files or mocked responses: +- Verify that total `gas_cost` matches expected weights. +- Verify that call depth increments and decrements correctly. +- Verify error display formatting and failure paths. diff --git a/docs/VISION.md b/docs/VISION.md new file mode 100644 index 0000000..6727342 --- /dev/null +++ b/docs/VISION.md @@ -0,0 +1,37 @@ +# 🏮 The Atupa Vision: Universal Execution Observability + +## The Problem: The Fog of Execution + +As the blockchain ecosystem evolves from a single monolithic EVM into a fragmented landscape of specialized Virtual Machines (Arbitrum Stylus, Starknet Cairo, Solana SVM, Soroban), developers and auditors are losing visibility. + +Each ecosystem has built its own siloed tooling: +- EVM developers have Geth traces. +- Solana developers have logs. +- Starknet developers have traces. + +There is no **unified layer** that allows a developer to reason about execution cost and performance across these boundaries. If a transaction starts on Ethereum and triggers a Stylus WASM contract, or if a protocol is ported from EVM to Solana, comparing their efficiency is a manual, error-prone process. + +## The Solution: A Unified Performance Standard + +Atupa is built on the belief that **execution is execution**, regardless of the underlying bytecode. It provides a unified performance standard and observability layer for the entire multi-chain landscape. + +### 1. VM Agnosticism +Atupa treats all Virtual Machines as equal producers of **Execution Events**. Whether it's an `SSTORE` opcode in EVM, a `put_contract_data` HostFn in Soroban, or a recursive Cairo frame, Atupa normalizes them into a common performance language. + +### 2. High-Fidelity Visual Analysis +Data is useless if it's buried in a 10MB JSON file. Atupa prioritizes **Visual First** analysis. Our flamegraphs and Studio dashboard are designed to make "hot paths" and "gas leaks" immediately obvious to the human eye. + +### 3. Developer-First CI/CD Integration +Performance shouldn't be an afterthought checked once a month. By making regression testing as simple as `atupa diff`, we enable developers to catch performance bottlenecks in every Pull Request. + +## The Future: Cross-Chain Regression Analysis + +Our ultimate goal is to enable **True Cross-Chain Performance Diffing**. + +Imagine a world where you can run: +`atupa diff --base 0xSOLANA_TX --target 0xSTARKNET_TX` + +And see a visual breakdown of why the same logic costs more or less on different architectures. This level of transparency will drive the next generation of efficient, secure, and performant decentralized applications. + +--- +🏮 *Atupa: Illuminating the path toward multi-VM transparency.* diff --git a/publish.sh b/publish.sh index 624f653..7070de3 100755 --- a/publish.sh +++ b/publish.sh @@ -2,7 +2,7 @@ # ───────────────────────────────────────────────────────────────────────────── # Atupa Workspace — Crates.io Publishing Script # -# This script publishes all crates in the workspace in the required order. +# This script publishes all crates in the workspace in topological dependency order. # Usage: ./publish.sh [--dry-run] # ───────────────────────────────────────────────────────────────────────────── @@ -17,10 +17,9 @@ fi # Robust publish function publish_crate() { local crate=$1 - local delay=$2 + local delay=${2:-10} echo "📦 Publishing $crate..." - # Run publish and capture output/exit status set +e output=$(cargo publish -p "$crate" $DRY_RUN 2>&1) status=$? @@ -35,16 +34,20 @@ publish_crate() { echo "$output" exit 1 fi + + if [ -n "$delay" ] && [ "$DRY_RUN" == "" ]; then + echo "⏳ Waiting ${delay}s for crates.io index..." + sleep "$delay" + fi } -# Robust publish function with flags +# Robust publish function with extra flags (e.g. --allow-dirty for embedded assets) publish_crate_with_flags() { local crate=$1 - local delay=$2 + local delay=${2:-10} local flags=$3 echo "📦 Publishing $crate with flags [$flags]..." - # Run publish and capture output/exit status set +e output=$(cargo publish -p "$crate" $DRY_RUN $flags 2>&1) status=$? @@ -69,35 +72,40 @@ publish_crate_with_flags() { # 1. Foundation publish_crate "atupa-core" 10 -# 2. Level 1 - Independent / Base modules +# 2. Base Networking & Registry Traits publish_crate "atupa-rpc" 10 publish_crate "atupa-adapters" 10 -# 3. Level 2 - Core Parsing & Visuals +# 3. Core Parsing & Visual Generators publish_crate "atupa-parser" 10 publish_crate "atupa-output" 15 -# 4. Level 3 - Protocol Adapters +# 4. Specialized Protocol Tracers & Nitro VM publish_crate "atupa-aave" 10 publish_crate "atupa-lido" 10 -publish_crate "atupa-nitro" 20 +publish_crate "atupa-nitro" 15 + +# 5. Non-EVM VM Adapters +publish_crate "atupa-starknet" 10 +publish_crate "atupa-solana" 10 +publish_crate "atupa-stellar" 10 -# 5. Facade SDK (Depends on adapters) -publish_crate "atupa-sdk" 30 +# 6. High-level SDK Facade +publish_crate "atupa-sdk" 20 -# 6. Final Binary (Depends on everything) +# 7. Final CLI Binary (Embeds Studio bundle) echo "📦 Preparing studio assets for atupa binary..." if [ -d "studio/dist" ]; then rm -rf bin/atupa/dist cp -r studio/dist bin/atupa/dist else - echo "❌ Error: studio/dist not found. Run npm build first." + echo "❌ Error: studio/dist not found. Run 'cd studio && npm run build' first." exit 1 fi publish_crate_with_flags "atupa" 0 "--allow-dirty" -# Cleanup +# Cleanup temporary build copy rm -rf bin/atupa/dist -echo "✅ All crates processed successfully!" +echo "🎉 All crates processed and published successfully!" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..395c143 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2024" +newline_style = "Unix" +use_small_heuristics = "Default" +max_width = 100 +tab_spaces = 4 diff --git a/studio/public/auto-load.json b/studio/public/auto-load.json index fc63ac9..b0ecc37 100644 --- a/studio/public/auto-load.json +++ b/studio/public/auto-load.json @@ -1,734 +1,137 @@ { - "tx_hash": "0x6bbe6b5f0e86f1cd2b3f2375888294d75962dad926cc93654783101fa219b5b1", - "chain_id": 421614, + "tx_hash": "0x8a923fc41b0294e75618b76a0293847562019485726194857261948572619485", "steps": [ { "index": 0, "vm": "Evm", - "label": "SLOAD", - "gas_cost": 0, - "cost_equiv": 0.0, + "label": "CALL (Entrypoint)", + "gas_cost": 21000, + "cost_equiv": 21000, "depth": 1, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "SLOAD", - "gas": 0, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x15fed0451499512d95f3ec5a41c878b9de55f21878b5b4e190d4667ec709b400" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "Call", + "target_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" }, { "index": 1, "vm": "Evm", - "label": "SLOAD", - "gas_cost": 0, - "cost_equiv": 0.0, + "label": "PUSH4 0x38ed1739", + "gas_cost": 3, + "cost_equiv": 3, "depth": 1, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "SLOAD", - "gas": 0, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x3c79da47f96b0f39664f73c0a1f350580be90742947dddfa21ba64d578dfe600" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "Execution" }, { "index": 2, "vm": "Evm", - "label": "CALLDATACOPY", - "gas_cost": 1, - "cost_equiv": 1.0, + "label": "SLOAD", + "gas_cost": 2100, + "cost_equiv": 2100, "depth": 1, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "CALLDATACOPY", - "gas": 148212, - "gasCost": 1, - "depth": 1, - "error": null, - "stack": [ - "0x84", - "0x0", - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "StorageRead" }, { "index": 3, "vm": "Evm", - "label": "CALLVALUE", - "gas_cost": 1, - "cost_equiv": 1.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "CALLVALUE", - "gas": 148182, - "gasCost": 1, - "depth": 1, - "error": null, - "stack": [], - "memory": null, - "storage": null - }, - "stylus": null + "label": "STATICCALL", + "gas_cost": 2600, + "cost_equiv": 2600, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" }, { "index": 4, - "vm": "Evm", - "label": "POP", + "vm": "Stylus", + "label": "stylus:msg_sender", "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 1, + "cost_equiv": 420.5, + "depth": 2, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "POP", - "gas": 148182, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "Execution" }, { "index": 5, - "vm": "Evm", - "label": "KECCAK256", - "gas_cost": 12, - "cost_equiv": 12.0, - "depth": 1, + "vm": "Stylus", + "label": "stylus:storage_load_bytes32", + "gas_cost": 0, + "cost_equiv": 1250.0, + "depth": 2, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "KECCAK256", - "gas": 147714, - "gasCost": 12, - "depth": 1, - "error": null, - "stack": [ - "0x4", - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "StorageRead" }, { "index": 6, - "vm": "Evm", - "label": "POP", + "vm": "Stylus", + "label": "stylus:native_keccak256", "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 1, + "cost_equiv": 680.0, + "depth": 2, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "POP", - "gas": 147714, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x2ea64222d73f6bed65c6e146c5134ff56758d059176393679833acbdc5ddb996" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "Crypto" }, { "index": 7, - "vm": "Evm", - "label": "KECCAK256", - "gas_cost": 12, - "cost_equiv": 12.0, - "depth": 1, + "vm": "Stylus", + "label": "stylus:storage_flush_cache", + "gas_cost": 0, + "cost_equiv": 5400.0, + "depth": 2, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "KECCAK256", - "gas": 147558, - "gasCost": 12, - "depth": 1, - "error": null, - "stack": [ - "0x40", - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "StorageWrite" }, { "index": 8, "vm": "Evm", - "label": "POP", - "gas_cost": 0, - "cost_equiv": 0.0, + "label": "SSTORE", + "gas_cost": 20000, + "cost_equiv": 20000, "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "POP", - "gas": 147558, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb" - ], - "memory": null, - "storage": null - }, - "stylus": null + "is_vm_boundary": true, + "category": "StorageWrite" }, { "index": 9, "vm": "Evm", - "label": "SLOAD", - "gas_cost": 2106, - "cost_equiv": 2106.0, + "label": "LOG2", + "gas_cost": 1875, + "cost_equiv": 1875, "depth": 1, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "SLOAD", - "gas": 145408, - "gasCost": 2106, - "depth": 1, - "error": null, - "stack": [ - "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb" - ], - "memory": null, - "storage": null - }, - "stylus": null + "category": "Execution" }, { "index": 10, "vm": "Evm", - "label": "POP", - "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "POP", - "gas": 145408, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 11, - "vm": "Evm", - "label": "KECCAK256", - "gas_cost": 12, - "cost_equiv": 12.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "KECCAK256", - "gas": 145165, - "gasCost": 12, - "depth": 1, - "error": null, - "stack": [ - "0x40", - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 12, - "vm": "Evm", - "label": "POP", - "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "POP", - "gas": 145165, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0xc6e5a39087be1d5cd5d0d8ab27c9b771ce9ebb1e6a826ce42e658b0300862a25" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 13, - "vm": "Evm", - "label": "SLOAD", - "gas_cost": 2106, - "cost_equiv": 2106.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "SLOAD", - "gas": 143025, - "gasCost": 2106, - "depth": 1, - "error": null, - "stack": [ - "0xc6e5a39087be1d5cd5d0d8ab27c9b771ce9ebb1e6a826ce42e658b0300862a25" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 14, - "vm": "Evm", - "label": "POP", - "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "POP", - "gas": 143025, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0x0" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 15, - "vm": "Evm", - "label": "SSTORE", - "gas_cost": 40006, - "cost_equiv": 40006.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "SSTORE", - "gas": 102927, - "gasCost": 40006, - "depth": 1, - "error": null, - "stack": [ - "0x546f706f00000000000000000000000000000000000000000000000000000008", - "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 16, - "vm": "Evm", - "label": "SSTORE", - "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 1, - "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "SSTORE", - "gas": 102927, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [ - "0xa239ccf7473f25021b73cef560aec6a2b54205e0", - "0xc6e5a39087be1d5cd5d0d8ab27c9b771ce9ebb1e6a826ce42e658b0300862a25" - ], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 17, - "vm": "Evm", - "label": "STOP", + "label": "RETURN", "gas_cost": 0, - "cost_equiv": 0.0, + "cost_equiv": 0, "depth": 1, "is_vm_boundary": false, - "evm": { - "pc": 0, - "op": "STOP", - "gas": 102915, - "gasCost": 0, - "depth": 1, - "error": null, - "stack": [], - "memory": null, - "storage": null - }, - "stylus": null - }, - { - "index": 18, - "vm": "Stylus", - "label": "user_entrypoint", - "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "user_entrypoint", - "args": "0x00000084", - "outs": "0x", - "startInk": 1482360000, - "endInk": 1482360000, - "address": null - } - }, - { - "index": 19, - "vm": "Stylus", - "label": "msg_reentrant", - "gas_cost": 0, - "cost_equiv": 0.84, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "msg_reentrant", - "args": "0x", - "outs": "0x00000000", - "startInk": 1482346196, - "endInk": 1482337796, - "address": null - } - }, - { - "index": 20, - "vm": "Stylus", - "label": "pay_for_memory_grow", - "gas_cost": 0, - "cost_equiv": 0.84, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "pay_for_memory_grow", - "args": "0x0000", - "outs": "0x", - "startInk": 1482319337, - "endInk": 1482310937, - "address": null - } - }, - { - "index": 21, - "vm": "Stylus", - "label": "read_args", - "gas_cost": 0, - "cost_equiv": 1.644, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "read_args", - "args": "0x", - "outs": "0x08ce483f000000000000000000000000a239ccf7473f25021b73cef560aec6a2b54205e000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000004546f706f00000000000000000000000000000000000000000000000000000000", - "startInk": 1482145155, - "endInk": 1482128715, - "address": null - } - }, - { - "index": 22, - "vm": "Stylus", - "label": "msg_value", - "gas_cost": 0, - "cost_equiv": 1.344, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "msg_value", - "args": "0x", - "outs": "0x0000000000000000000000000000000000000000000000000000000000000000", - "startInk": 1481838718, - "endInk": 1481825278, - "address": null - } - }, - { - "index": 23, - "vm": "Stylus", - "label": "native_keccak256", - "gas_cost": 0, - "cost_equiv": 12.18, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "native_keccak256", - "args": "0x546f706f", - "outs": "0x2ea64222d73f6bed65c6e146c5134ff56758d059176393679833acbdc5ddb996", - "startInk": 1477267363, - "endInk": 1477145563, - "address": null - } - }, - { - "index": 24, - "vm": "Stylus", - "label": "native_keccak256", - "gas_cost": 0, - "cost_equiv": 12.18, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "native_keccak256", - "args": "0x000000000000000000000000a239ccf7473f25021b73cef560aec6a2b54205e00000000000000000000000000000000000000000000000000000000000000000", - "outs": "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb", - "startInk": 1475711444, - "endInk": 1475589644, - "address": null - } - }, - { - "index": 25, - "vm": "Stylus", - "label": "storage_load_bytes32", - "gas_cost": 0, - "cost_equiv": 2106.848, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_load_bytes32", - "args": "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb", - "outs": "0x0000000000000000000000000000000000000000000000000000000000000000", - "startInk": 1475152034, - "endInk": 1454083554, - "address": null - } - }, - { - "index": 26, - "vm": "Stylus", - "label": "storage_cache_bytes32", - "gas_cost": 0, - "cost_equiv": 1.848, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_cache_bytes32", - "args": "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb0000000000000000000000000000000000000000000000000000000000000000", - "outs": "0x", - "startInk": 1453801396, - "endInk": 1453782916, - "address": null - } - }, - { - "index": 27, - "vm": "Stylus", - "label": "storage_load_bytes32", - "gas_cost": 0, - "cost_equiv": 1.848, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_load_bytes32", - "args": "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb", - "outs": "0x0000000000000000000000000000000000000000000000000000000000000000", - "startInk": 1453511095, - "endInk": 1453492615, - "address": null - } - }, - { - "index": 28, - "vm": "Stylus", - "label": "storage_cache_bytes32", - "gas_cost": 0, - "cost_equiv": 1.848, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_cache_bytes32", - "args": "0x4ada666fb50064f97287f2b34f4570a81a8905ca591a284c268f9178c3e493eb546f706f00000000000000000000000000000000000000000000000000000008", - "outs": "0x", - "startInk": 1452946651, - "endInk": 1452928171, - "address": null - } - }, - { - "index": 29, - "vm": "Stylus", - "label": "native_keccak256", - "gas_cost": 0, - "cost_equiv": 12.18, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "native_keccak256", - "args": "0x2ea64222d73f6bed65c6e146c5134ff56758d059176393679833acbdc5ddb9960000000000000000000000000000000000000000000000000000000000000001", - "outs": "0xc6e5a39087be1d5cd5d0d8ab27c9b771ce9ebb1e6a826ce42e658b0300862a25", - "startInk": 1451781379, - "endInk": 1451659579, - "address": null - } - }, - { - "index": 30, - "vm": "Stylus", - "label": "storage_load_bytes32", - "gas_cost": 0, - "cost_equiv": 2106.848, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_load_bytes32", - "args": "0xc6e5a39087be1d5cd5d0d8ab27c9b771ce9ebb1e6a826ce42e658b0300862a25", - "outs": "0x0000000000000000000000000000000000000000000000000000000000000000", - "startInk": 1451320253, - "endInk": 1430251773, - "address": null - } - }, - { - "index": 31, - "vm": "Stylus", - "label": "storage_cache_bytes32", - "gas_cost": 0, - "cost_equiv": 1.848, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_cache_bytes32", - "args": "0xc6e5a39087be1d5cd5d0d8ab27c9b771ce9ebb1e6a826ce42e658b0300862a25000000000000000000000000a239ccf7473f25021b73cef560aec6a2b54205e0", - "outs": "0x", - "startInk": 1429983151, - "endInk": 1429964671, - "address": null - } - }, - { - "index": 32, - "vm": "Stylus", - "label": "storage_flush_cache", - "gas_cost": 0, - "cost_equiv": 40006.8073, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "storage_flush_cache", - "args": "0x00", - "outs": "0x", - "startInk": 1429347116, - "endInk": 1029279043, - "address": null - } - }, - { - "index": 33, - "vm": "Stylus", - "label": "write_result", - "gas_cost": 0, - "cost_equiv": 4.1162, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "write_result", - "args": "0x", - "outs": "0x", - "startInk": 1029262306, - "endInk": 1029221144, - "address": null - } - }, - { - "index": 34, - "vm": "Stylus", - "label": "user_returned", - "gas_cost": 0, - "cost_equiv": 0.0, - "depth": 0, - "is_vm_boundary": false, - "evm": null, - "stylus": { - "name": "user_returned", - "args": "0x", - "outs": "0x00000000", - "startInk": 1482360000, - "endInk": 1482360000, - "address": null - } + "category": "Execution" } ], - "total_evm_gas": 44256, - "total_stylus_ink": 442732195, - "vm_boundary_count": 0, - "total_stylus_gas_equiv": 44273.2195, - "total_unified_cost": 88529.2195 + "total_evm_gas": 47578, + "total_stylus_ink": 7750500, + "total_stylus_gas_equiv": 7750.5, + "total_unified_cost": 55328.5, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 25400, + "StorageRead": 3350, + "Call": 23600, + "Crypto": 680, + "Memory": 0, + "Execution": 2298.5, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e": "StylusVault", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "WETH9" + } } \ No newline at end of file diff --git a/studio/public/demos/aave.json b/studio/public/demos/aave.json new file mode 100644 index 0000000..a12e4b6 --- /dev/null +++ b/studio/public/demos/aave.json @@ -0,0 +1,91 @@ +{ + "tx_hash": "0x39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80746201948", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "AaveV3::Pool.supply(asset=USDC, amount=100,000)", + "gas_cost": 45000, + "cost_equiv": 45000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" + }, + { + "index": 1, + "vm": "Evm", + "label": "ReserveLogic::updateState (LiquidityIndex & VariableBorrowIndex)", + "gas_cost": 18200, + "cost_equiv": 18200, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 2, + "vm": "Evm", + "label": "ValidationLogic::validateSupply", + "gas_cost": 4600, + "cost_equiv": 4600, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 3, + "vm": "Evm", + "label": "aUSDC::mint (ScaledBalance Updated)", + "gas_cost": 22100, + "cost_equiv": 22100, + "depth": 2, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0x98C23E9d8f34FEFb1B72F6d102F7573986B0C043" + }, + { + "index": 4, + "vm": "Evm", + "label": "GHOFlashMinter::flashLoan(amount=500,000 GHO)", + "gas_cost": 38400, + "cost_equiv": 38400, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0x40be3B02376E62C2666323497d50B6e4bE13fDE8" + }, + { + "index": 5, + "vm": "Evm", + "label": "AaveOracle::getAssetPrice(USDC)", + "gas_cost": 2400, + "cost_equiv": 2400, + "depth": 3, + "is_vm_boundary": false, + "category": "StorageRead", + "target_address": "0x54586bE62E3c3580375aE3723C145253060Ca0C2" + } + ], + "total_evm_gas": 130700, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 130700, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 40300, + "StorageRead": 7000, + "Call": 83400, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2": "Aave v3 Pool (Ethereum)", + "0x98C23E9d8f34FEFb1B72F6d102F7573986B0C043": "aEthUSDC Token", + "0x40be3B02376E62C2666323497d50B6e4bE13fDE8": "GHO FlashMinter", + "0x54586bE62E3c3580375aE3723C145253060Ca0C2": "Aave Oracle" + } +} diff --git a/studio/public/demos/diff.json b/studio/public/demos/diff.json new file mode 100644 index 0000000..aef3d07 --- /dev/null +++ b/studio/public/demos/diff.json @@ -0,0 +1,103 @@ +{ + "type": "diff", + "base": { + "tx_hash": "0xBASE923fc41b0294e75618b76a02938475620194857261948572619485726194", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "UniswapV3::exactInputSingle", + "gas_cost": 125000, + "cost_equiv": 125000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0xE592427A0AEce92De3Edee1F18E0157C05861564" + }, + { + "index": 1, + "vm": "Evm", + "label": "Pool::swap (Old Storage Layout)", + "gas_cost": 64000, + "cost_equiv": 64000, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + } + ], + "total_evm_gas": 189000, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 189000, + "vm_boundary_count": 0, + "category_costs": { + "StorageWrite": 64000, + "StorageRead": 0, + "Call": 125000, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0xE592427A0AEce92De3Edee1F18E0157C05861564": "Uniswap V3 SwapRouter" + } + }, + "target": { + "tx_hash": "0xTARGET3fc41b0294e75618b76a02938475620194857261948572619485726194", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "UniswapV4::swap (Hooks & Transient Storage)", + "gas_cost": 84000, + "cost_equiv": 84000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x498581fF718922c3f8e6A244956aF099B2652b2b" + }, + { + "index": 1, + "vm": "Evm", + "label": "TSTORE (Transient Storage Slot)", + "gas_cost": 100, + "cost_equiv": 100, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + } + ], + "total_evm_gas": 84100, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 84100, + "vm_boundary_count": 0, + "category_costs": { + "StorageWrite": 100, + "StorageRead": 0, + "Call": 84000, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x498581fF718922c3f8e6A244956aF099B2652b2b": "Uniswap V4 PoolManager" + } + }, + "metrics": { + "base_total_gas": 189000, + "target_total_gas": 84100, + "gas_delta": -104900, + "gas_pct": -55.5, + "base_unified_cost": 189000, + "target_unified_cost": 84100, + "unified_delta": -104900, + "unified_pct": -55.5 + } +} diff --git a/studio/public/demos/lido.json b/studio/public/demos/lido.json new file mode 100644 index 0000000..4695da8 --- /dev/null +++ b/studio/public/demos/lido.json @@ -0,0 +1,82 @@ +{ + "tx_hash": "0x1fca898234be45a198c234509172462839401726485019284756102938475619", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "Lido::submit(referral=0x0) [Stake 32 ETH]", + "gas_cost": 52000, + "cost_equiv": 52000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + }, + { + "index": 1, + "vm": "Evm", + "label": "StakingRouter::getDepositLimit", + "gas_cost": 3100, + "cost_equiv": 3100, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead", + "target_address": "0xFdDf38947aFB06167083462ea50162A7733ba05c" + }, + { + "index": 2, + "vm": "Evm", + "label": "stETH::mintShares (Rebase Balance Calculated)", + "gas_cost": 28400, + "cost_equiv": 28400, + "depth": 2, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" + }, + { + "index": 3, + "vm": "Evm", + "label": "LidoOracle::handleConsensusReport", + "gas_cost": 41200, + "cost_equiv": 41200, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0x442af784A788A5bd6F42A01Ebe1ED6480e6107b4" + }, + { + "index": 4, + "vm": "Evm", + "label": "WithdrawalQueueERC721::requestWithdrawalsWithPermit", + "gas_cost": 34600, + "cost_equiv": 34600, + "depth": 2, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1" + } + ], + "total_evm_gas": 159300, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 159300, + "vm_boundary_count": 3, + "category_costs": { + "StorageWrite": 63000, + "StorageRead": 3100, + "Call": 93200, + "Crypto": 0, + "Memory": 0, + "Execution": 0, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84": "Lido: stETH Token", + "0xFdDf38947aFB06167083462ea50162A7733ba05c": "Lido Staking Router", + "0x442af784A788A5bd6F42A01Ebe1ED6480e6107b4": "Lido Oracle", + "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1": "Lido: Withdrawal Queue NFT" + } +} diff --git a/studio/public/demos/solana.json b/studio/public/demos/solana.json new file mode 100644 index 0000000..af5dc98 --- /dev/null +++ b/studio/public/demos/solana.json @@ -0,0 +1,128 @@ +{ + "tx_hash": "5Z9mJkQp7rN2vX8yW1cE4uT6hY3aB8dF5gH7jK9mN2vX", + "steps": [ + { + "index": 0, + "vm": "Solana", + "label": "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "gas_cost": 150, + "cost_equiv": 150, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 1, + "vm": "Solana", + "label": "SetComputeUnitLimit(200000)", + "gas_cost": 150, + "cost_equiv": 150, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Solana", + "label": "Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 (Raydium Swap) invoke [1]", + "gas_cost": 4500, + "cost_equiv": 4500, + "depth": 1, + "is_vm_boundary": true, + "category": "Call", + "target_address": "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" + }, + { + "index": 3, + "vm": "Solana", + "label": "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA (SPL Token) invoke [2]", + "gas_cost": 3200, + "cost_equiv": 3200, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "index": 4, + "vm": "Solana", + "label": "TransferChecked: 1,500.00 USDC", + "gas_cost": 4120, + "cost_equiv": 4120, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 5, + "vm": "Solana", + "label": "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "gas_cost": 500, + "cost_equiv": 500, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 6, + "vm": "Solana", + "label": "AMM Pool Invariant Calculation", + "gas_cost": 12850, + "cost_equiv": 12850, + "depth": 1, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 7, + "vm": "Solana", + "label": "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA (Mint Output) invoke [2]", + "gas_cost": 3100, + "cost_equiv": 3100, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "index": 8, + "vm": "Solana", + "label": "TransferChecked: 0.45 SOL", + "gas_cost": 4050, + "cost_equiv": 4050, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 9, + "vm": "Solana", + "label": "Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 success", + "gas_cost": 620, + "cost_equiv": 620, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + } + ], + "total_evm_gas": 0, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 33240, + "vm_boundary_count": 3, + "category_costs": { + "StorageWrite": 8170, + "StorageRead": 0, + "Call": 10800, + "Crypto": 12850, + "Memory": 0, + "Execution": 1420, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8": "Raydium Liquidity Pool v4", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA": "SPL Token Program" + } +} diff --git a/studio/public/demos/starknet.json b/studio/public/demos/starknet.json new file mode 100644 index 0000000..61ee8f1 --- /dev/null +++ b/studio/public/demos/starknet.json @@ -0,0 +1,89 @@ +{ + "tx_hash": "0x04c8f429bc41b0294e75618b76a0293847562019485726194857261948572619", + "steps": [ + { + "index": 0, + "vm": "Starknet", + "label": "Account::execute [Cairo]", + "gas_cost": 4200, + "cost_equiv": 4200, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution", + "target_address": "0x0124367982f1b0294e75618b76a029384756201948572619485726194857261" + }, + { + "index": 1, + "vm": "Starknet", + "label": "builtin:ecdsa_signature_verification", + "gas_cost": 20480, + "cost_equiv": 20480, + "depth": 1, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 2, + "vm": "Starknet", + "label": "JediSwap::swap_exact_tokens_for_tokens", + "gas_cost": 8500, + "cost_equiv": 8500, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" + }, + { + "index": 3, + "vm": "Starknet", + "label": "builtin:pedersen_hash", + "gas_cost": 3200, + "cost_equiv": 3200, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 4, + "vm": "Starknet", + "label": "builtin:range_check", + "gas_cost": 1600, + "cost_equiv": 1600, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 5, + "vm": "Starknet", + "label": "ERC20::transfer (State Update)", + "gas_cost": 14200, + "cost_equiv": 14200, + "depth": 3, + "is_vm_boundary": true, + "category": "StorageWrite", + "target_address": "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8" + } + ], + "total_evm_gas": 0, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 52180, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 14200, + "StorageRead": 0, + "Call": 8500, + "Crypto": 23680, + "Memory": 0, + "Execution": 5800, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x0124367982f1b0294e75618b76a029384756201948572619485726194857261": "Braavos Smart Account", + "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": "JediSwap Router", + "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8": "Starknet USDC ERC20" + } +} diff --git a/studio/public/demos/stellar.json b/studio/public/demos/stellar.json new file mode 100644 index 0000000..8f03034 --- /dev/null +++ b/studio/public/demos/stellar.json @@ -0,0 +1,85 @@ +{ + "tx_hash": "c5949d28a49c4f1c998318182b8a74e534f3efd8544c45b85438efca88921a99", + "steps": [ + { + "index": 0, + "vm": "Stellar", + "label": "InvokeHostFunction: Soroban VM Init", + "gas_cost": 2500, + "cost_equiv": 2500, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 1, + "vm": "Stellar", + "label": "HostFn::get_ledger_sequence", + "gas_cost": 450, + "cost_equiv": 450, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Stellar", + "label": "HostFn::call (Soroban AMM Pool)", + "gas_cost": 5200, + "cost_equiv": 5200, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "CB4J4S547RGYV5T53F2H4W527G34LKVQ7M5RFTZ55YPQXQO6X7J34XYZ" + }, + { + "index": 3, + "vm": "Stellar", + "label": "HostFn::get_contract_data", + "gas_cost": 1850, + "cost_equiv": 1850, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 4, + "vm": "Stellar", + "label": "HostFn::compute_sha256", + "gas_cost": 3100, + "cost_equiv": 3100, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 5, + "vm": "Stellar", + "label": "HostFn::put_contract_data (Reserves Updated)", + "gas_cost": 16400, + "cost_equiv": 16400, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + } + ], + "total_evm_gas": 0, + "total_stylus_ink": 0, + "total_stylus_gas_equiv": 0, + "total_unified_cost": 29500, + "vm_boundary_count": 1, + "category_costs": { + "StorageWrite": 16400, + "StorageRead": 1850, + "Call": 5200, + "Crypto": 3100, + "Memory": 0, + "Execution": 2950, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "CB4J4S547RGYV5T53F2H4W527G34LKVQ7M5RFTZ55YPQXQO6X7J34XYZ": "Soroswap AMM Pair" + } +} diff --git a/studio/public/demos/stylus.json b/studio/public/demos/stylus.json new file mode 100644 index 0000000..305239b --- /dev/null +++ b/studio/public/demos/stylus.json @@ -0,0 +1,137 @@ +{ + "tx_hash": "0x8a923fc41b0294e75618b76a0293847562019485726194857261948572619485", + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "CALL (Entrypoint)", + "gas_cost": 21000, + "cost_equiv": 21000, + "depth": 1, + "is_vm_boundary": false, + "category": "Call", + "target_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e" + }, + { + "index": 1, + "vm": "Evm", + "label": "PUSH4 0x38ed1739", + "gas_cost": 3, + "cost_equiv": 3, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 2, + "vm": "Evm", + "label": "SLOAD", + "gas_cost": 2100, + "cost_equiv": 2100, + "depth": 1, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 3, + "vm": "Evm", + "label": "STATICCALL", + "gas_cost": 2600, + "cost_equiv": 2600, + "depth": 2, + "is_vm_boundary": true, + "category": "Call", + "target_address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + }, + { + "index": 4, + "vm": "Stylus", + "label": "stylus:msg_sender", + "gas_cost": 0, + "cost_equiv": 420.5, + "depth": 2, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 5, + "vm": "Stylus", + "label": "stylus:storage_load_bytes32", + "gas_cost": 0, + "cost_equiv": 1250.0, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageRead" + }, + { + "index": 6, + "vm": "Stylus", + "label": "stylus:native_keccak256", + "gas_cost": 0, + "cost_equiv": 680.0, + "depth": 2, + "is_vm_boundary": false, + "category": "Crypto" + }, + { + "index": 7, + "vm": "Stylus", + "label": "stylus:storage_flush_cache", + "gas_cost": 0, + "cost_equiv": 5400.0, + "depth": 2, + "is_vm_boundary": false, + "category": "StorageWrite" + }, + { + "index": 8, + "vm": "Evm", + "label": "SSTORE", + "gas_cost": 20000, + "cost_equiv": 20000, + "depth": 1, + "is_vm_boundary": true, + "category": "StorageWrite" + }, + { + "index": 9, + "vm": "Evm", + "label": "LOG2", + "gas_cost": 1875, + "cost_equiv": 1875, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + }, + { + "index": 10, + "vm": "Evm", + "label": "RETURN", + "gas_cost": 0, + "cost_equiv": 0, + "depth": 1, + "is_vm_boundary": false, + "category": "Execution" + } + ], + "total_evm_gas": 47578, + "total_stylus_ink": 7750500, + "total_stylus_gas_equiv": 7750.5, + "total_unified_cost": 55328.5, + "vm_boundary_count": 2, + "category_costs": { + "StorageWrite": 25400, + "StorageRead": 3350, + "Call": 23600, + "Crypto": 680, + "Memory": 0, + "Execution": 2298.5, + "Precompile": 0, + "Root": 0, + "Other": 0 + }, + "resolved_names": { + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e": "ArbitrumStylusVault", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "WETH9" + } +} diff --git a/studio/src/App.tsx b/studio/src/App.tsx index 684a1f6..710ab8b 100644 --- a/studio/src/App.tsx +++ b/studio/src/App.tsx @@ -7,6 +7,11 @@ import { shortHash, evmSteps, stylusSteps, + solanaSteps, + starknetSteps, + stellarSteps, + detectPrimaryVm, + getRuntimeBadge, isDiff, } from './types/trace'; import type { StudioReport } from './types/trace'; @@ -53,10 +58,14 @@ export default function App() { setFlameSearch(''); }, []); - const hostIOs = report ? aggregateHostIOs(isDiff(report) ? report.target : report) : []; + const activeTarget = report ? (isDiff(report) ? report.target : report) : null; + const primaryVm = activeTarget ? detectPrimaryVm(activeTarget) : 'evm'; + const runtimeBadge = getRuntimeBadge(primaryVm); + const hostIOs = activeTarget ? aggregateHostIOs(activeTarget) : []; + const flameRoot = useMemo( - () => (report ? reportToTree(isDiff(report) ? report.target : report) : null), - [report], + () => (activeTarget ? reportToTree(activeTarget) : null), + [activeTarget], ); return ( @@ -71,13 +80,31 @@ export default function App() { {report && ( <> - - - {isDiff(report) ? 'Comparison Loaded' : 'Single Trace Loaded'} + + + {runtimeBadge.icon} {runtimeBadge.label} - + + {isDiff(report) && ( + + ⚖️ Diff Mode + + )} + + {isDiff(report) ? 'Execution Comparison' : report.tx_hash} + - ); - })} + {([ + { id: 'overview' as const, icon: '📊', label: 'Overview' }, + { id: 'flame' as const, icon: '🔆', label: 'Visual Trace' }, + { id: 'trace' as const, icon: '🧩', label: 'Trace Inspector' }, + ...(hostIOs.length > 0 ? [{ id: 'hostio' as const, icon: '🔥', label: 'HostIO Hot Paths' }] : []), + ]).map((v) => ( + + ))} {report && !isDiff(report) && (
tx: {shortHash(report.tx_hash)}
-
steps: {report.steps.length.toLocaleString()}
-
evm: {evmSteps(report).length.toLocaleString()}
-
wasm: {stylusSteps(report).length.toLocaleString()}
+
total steps: {report.steps.length.toLocaleString()}
+ {primaryVm === 'solana' &&
svm: {solanaSteps(report).length.toLocaleString()}
} + {primaryVm === 'starknet' &&
cairo: {starknetSteps(report).length.toLocaleString()}
} + {primaryVm === 'stellar' &&
soroban: {stellarSteps(report).length.toLocaleString()}
} + {primaryVm === 'stylus' && ( + <> +
evm: {evmSteps(report).length.toLocaleString()}
+
wasm: {stylusSteps(report).length.toLocaleString()}
+ + )} + {primaryVm === 'evm' &&
evm: {evmSteps(report).length.toLocaleString()}
}
)} @@ -138,7 +170,7 @@ export default function App() {
⚖️ DELTA
0 ? '#ff4d4d' : '#4dff88' }}> - Gas: {report.metrics.gas_delta > 0 ? '+' : ''}{fmtGas(report.metrics.gas_delta)} + Gas: {report.metrics.gas_delta > 0 ? '+' : ''}{fmtGas(report.metrics.gas_delta)} ({report.metrics.gas_pct > 0 ? '+' : ''}{report.metrics.gas_pct.toFixed(1)}%)
)} @@ -167,50 +199,218 @@ export default function App() { )} - {/* Section: Metrics */} -
-
- Execution Metrics -
-
-
- - - - - + {/* Section: Dynamic Multi-VM Metrics */} + {activeTarget && ( +
+
+ + {runtimeBadge.icon} {runtimeBadge.label} Execution Metrics + +
+
+
+ {primaryVm === 'solana' && ( + <> + + + + + + + )} + + {primaryVm === 'starknet' && ( + <> + + + + + + + )} + + {primaryVm === 'stellar' && ( + <> + + + + + + + )} + + {primaryVm === 'stylus' && ( + <> + + + + + + + )} + + {primaryVm === 'evm' && ( + <> + + + + + + + )} +
-
+ )} {/* Section: HostIO summary on overview */} {hostIOs.length > 0 && ( @@ -242,10 +442,7 @@ export default function App() { border: '1px solid var(--color-border)', borderRadius: 6, color: 'var(--color-text-primary)', - fontSize: 11, - fontFamily: 'var(--font-mono)', - outline: 'none', - width: 180, + fontSize: 12, }} />
@@ -253,29 +450,23 @@ export default function App() {
)} - {view === 'hostio' && ( + {view === 'trace' && activeTarget && (
- 🔥 HostIO Hot Paths + 🧩 Trace Inspector
- - {hostIOs.length} unique operations -
- +
)} - {view === 'trace' && ( + {view === 'hostio' && hostIOs.length > 0 && (
- 🧩 Unified Execution Trace + 🔥 Stylus HostIO Hot Paths
- - {(isDiff(report) ? report.target.steps : report.steps).length.toLocaleString()} total steps -
- +
)} diff --git a/studio/src/components/CategoryBreakdown.tsx b/studio/src/components/CategoryBreakdown.tsx index b3a8002..2bafead 100644 --- a/studio/src/components/CategoryBreakdown.tsx +++ b/studio/src/components/CategoryBreakdown.tsx @@ -9,7 +9,7 @@ export function CategoryBreakdown({ report }: Props) { const categories = Object.entries(report.category_costs) as [GasCategory, number][]; // Filter out zero costs and sort by value const sorted = categories - .filter(([_, gas]) => gas > 0) + .filter(([, gas]) => gas > 0) .sort((a, b) => b[1] - a[1]); const total = report.total_unified_cost || 1; diff --git a/studio/src/components/DiffOverview.tsx b/studio/src/components/DiffOverview.tsx index bd1aba3..0535f1e 100644 --- a/studio/src/components/DiffOverview.tsx +++ b/studio/src/components/DiffOverview.tsx @@ -7,20 +7,20 @@ interface Props { report: DiffReport; } +function DeltaLabel({ val, pct }: { val: number; pct: number }) { + const isIncrease = val > 0; + const color = isIncrease ? '#ff4d4d' : '#4dff88'; + const sign = isIncrease ? '+' : ''; + return ( + + {sign}{fmtGas(Math.round(val))} ({sign}{pct.toFixed(1)}%) + + ); +} + export function DiffOverview({ report }: Props) { const { base, target, metrics } = report; - const DeltaLabel = ({ val, pct }: { val: number; pct: number }) => { - const isIncrease = val > 0; - const color = isIncrease ? '#ff4d4d' : '#4dff88'; - const sign = isIncrease ? '+' : ''; - return ( - - {sign}{fmtGas(Math.round(val))} ({sign}{pct.toFixed(1)}%) - - ); - }; - return (
{/* ── Summary Header ─────────────────────────────────────────────────── */} diff --git a/studio/src/components/DragDropZone.tsx b/studio/src/components/DragDropZone.tsx index 8216775..e7d9531 100644 --- a/studio/src/components/DragDropZone.tsx +++ b/studio/src/components/DragDropZone.tsx @@ -5,6 +5,16 @@ interface Props { onLoad: (report: StudioReport) => void; } +const DEMO_PRESETS = [ + { id: 'stylus', name: 'Arbitrum Stylus (Dual-VM)', icon: '🌐', path: '/demos/stylus.json', desc: 'EVM + Stylus WASM HostIO execution' }, + { id: 'solana', name: 'Solana (SVM)', icon: '☀️', path: '/demos/solana.json', desc: 'Raydium Swap Compute Units & SPL transfers' }, + { id: 'starknet', name: 'Starknet (Cairo)', icon: '🐺', path: '/demos/starknet.json', desc: 'Cairo execution & ECDSA/Pedersen builtins' }, + { id: 'stellar', name: 'Stellar (Soroban)', icon: '🚀', path: '/demos/stellar.json', desc: 'Diagnostic events & Soroban HostFn weights' }, + { id: 'aave', name: 'Aave v3 / GHO Audit', icon: '👻', path: '/demos/aave.json', desc: 'Supply, flash loans & liquidation state' }, + { id: 'lido', name: 'Lido stETH Audit', icon: '💧', path: '/demos/lido.json', desc: 'Staking pipeline, rebase oracle & withdrawals' }, + { id: 'diff', name: 'Differential Diff (Uniswap v3 vs v4)', icon: '⚖️', path: '/demos/diff.json', desc: 'Gas delta & transient storage regression check' }, +]; + export function DragDropZone({ onLoad }: Props) { const [dragging, setDragging] = useState(false); const [error, setError] = useState(null); @@ -18,16 +28,16 @@ export function DragDropZone({ onLoad }: Props) { const reader = new FileReader(); reader.onload = (e) => { try { - const data = JSON.parse(e.target?.result as string) as StudioReport; - const isSingle = (data as any).tx_hash && Array.isArray((data as any).steps); - const isDiffReport = (data as any).type === 'diff' && (data as any).base && (data as any).target; + const data = JSON.parse(e.target?.result as string) as Record; + const isSingle = typeof data.tx_hash === 'string' && Array.isArray(data.steps); + const isDiffReport = data.type === 'diff' && typeof data.base === 'object' && typeof data.target === 'object'; if (!isSingle && !isDiffReport) { setError('File does not appear to be an Atupa trace report or comparison.'); return; } setError(null); - onLoad(data); + onLoad(data as unknown as StudioReport); } catch { setError('Failed to parse JSON — is this a valid Atupa trace?'); } @@ -37,6 +47,20 @@ export function DragDropZone({ onLoad }: Props) { [onLoad] ); + const loadPreset = useCallback( + (path: string) => { + setError(null); + fetch(path) + .then((res) => { + if (!res.ok) throw new Error('Preset not found'); + return res.json(); + }) + .then((data) => onLoad(data as StudioReport)) + .catch(() => setError('Failed to load demo preset.')); + }, + [onLoad] + ); + const onDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); @@ -56,51 +80,97 @@ export function DragDropZone({ onLoad }: Props) { ); return ( -
{ e.preventDefault(); setDragging(true); }} - onDragLeave={() => setDragging(false)} - onDrop={onDrop} - > -
🏮
+
+
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + > +
🏮
-
-
Drop your Atupa trace here
-
- Generate a trace with the CLI, then drop the report.json file - to visualize its unified EVM + Stylus execution. +
+
Universal Multi-VM Trace Visualizer
+
+ Drop any report.json from EVM, Arbitrum Stylus, Solana, Starknet, or Stellar. +
-
- - - - {error && ( -
- ⚠ {error} +
+
- )} -
- atupa capture --tx 0x... --rpc <URL> --output report.json + + + {error && ( +
+ ⚠ {error} +
+ )} +
+ + {/* ── Multi-VM & Protocol Presets ────────────────────────────────────────── */} +
+
+ ✨ Explore Preloaded Multi-VM & Protocol Traces +
+
+
+ {DEMO_PRESETS.map((preset) => ( + + ))} +
); diff --git a/studio/src/components/FlameGraph.tsx b/studio/src/components/FlameGraph.tsx index f411840..5c5f39f 100644 --- a/studio/src/components/FlameGraph.tsx +++ b/studio/src/components/FlameGraph.tsx @@ -34,6 +34,15 @@ const COLORS = { rootFill: '#0d0f1a', rootStroke: '#1e2435', rootText: '#64748b', + starknetFill: '#1e1b4b', + starknetStroke: '#4338ca', + starknetText: '#a5b4fc', + solanaFill: '#064e3b', + solanaStroke: '#059669', + solanaText: '#6ee7b7', + stellarFill: '#172554', + stellarStroke: '#1e3a8a', + stellarText: '#93c5fd', tooltipBg: '#181c2a', tooltipBorder: '#2e3a5a', tooltipText: '#e2e8f0', @@ -47,12 +56,25 @@ interface TooltipState { node: FlameNode; } -function Tooltip({ tip }: { tip: TooltipState }) { - const selfPct = - tip.node.value > 0 - ? ((tip.node.selfCost / tip.node.value) * 100).toFixed(1) - : '0.0'; - const vmLabel = tip.node.vm === 'Evm' ? 'EVM' : 'WASM/Stylus'; +function Tooltip({ tip, rootValue }: { tip: TooltipState, rootValue: number }) { + const totalPct = ((tip.node.value / rootValue) * 100).toFixed(2); + const selfPct = ((tip.node.selfCost / rootValue) * 100).toFixed(2); + + const vmLabel = { + Evm: 'EVM', + Stylus: 'WASM/Stylus', + Starknet: 'Starknet Cairo', + Solana: 'Solana SVM', + Stellar: 'Stellar Soroban', + }[tip.node.vm] || tip.node.vm; + + const vmColor = { + Evm: COLORS.evmText, + Stylus: COLORS.stylusText, + Starknet: COLORS.starknetText, + Solana: COLORS.solanaText, + Stellar: COLORS.stellarText, + }[tip.node.vm] || '#fff'; return (
- VM: {vmLabel} + VM: {vmLabel}
- Total: {tip.node.value.toLocaleString('en-US', { maximumFractionDigits: 2 })} gas + Total: {tip.node.value.toLocaleString('en-US', { maximumFractionDigits: 2 })} gas ({totalPct}%)
Self: {tip.node.selfCost.toLocaleString('en-US', { maximumFractionDigits: 2 })} gas ({selfPct}%) @@ -136,19 +158,19 @@ function layoutTree( // ─── Bar ───────────────────────────────────────────────────────────────────── -interface BarProps { +interface FlameBarProps { lnode: LayoutNode; svgWidth: number; zoomX: number; // current zoom origin (fraction) zoomW: number; // current zoom width (fraction) highlight: string; - onHover: (tip: TooltipState | null, evt: React.MouseEvent) => void; + onHover: (tip: TooltipState | null) => void; onClick: (n: FlameNode) => void; } const Bar = React.memo(function Bar({ lnode, svgWidth, zoomX, zoomW, highlight, onHover, onClick, -}: BarProps) { +}: FlameBarProps) { const { node, x, w, row } = lnode; // Map fraction → pixel within the visible zoom window @@ -167,6 +189,12 @@ const Bar = React.memo(function Bar({ fill = COLORS.boundaryFill; stroke = COLORS.boundaryStroke; textColor = COLORS.boundaryText; } else if (node.vm === 'Stylus') { fill = COLORS.stylusFill; stroke = COLORS.stylusStroke; textColor = COLORS.stylusText; + } else if (node.vm === 'Starknet') { + fill = COLORS.starknetFill; stroke = COLORS.starknetStroke; textColor = COLORS.starknetText; + } else if (node.vm === 'Solana') { + fill = COLORS.solanaFill; stroke = COLORS.solanaStroke; textColor = COLORS.solanaText; + } else if (node.vm === 'Stellar') { + fill = COLORS.stellarFill; stroke = COLORS.stellarStroke; textColor = COLORS.stellarText; } else { fill = COLORS.evmFill; stroke = COLORS.evmStroke; textColor = COLORS.evmText; } @@ -191,8 +219,8 @@ const Bar = React.memo(function Bar({ row > 0 && onClick(node)} - onMouseMove={(e) => onHover({ x: e.nativeEvent.offsetX, y: py, node }, e)} - onMouseLeave={() => onHover(null, {} as React.MouseEvent)} + onMouseMove={(e) => onHover({ x: e.nativeEvent.offsetX, y: py, node })} + onMouseLeave={() => onHover(null)} > (null); // Zoom state: the zoomed-in node trail (first = virtual root) + const [prevRoot, setPrevRoot] = useState(root); const [zoomTrail, setZoomTrail] = useState([root]); - const zoomedNode = zoomTrail[zoomTrail.length - 1]; - // Recalculate when root changes (new report loaded) - useEffect(() => { + if (prevRoot !== root) { + setPrevRoot(root); setZoomTrail([root]); - }, [root]); + } + + const zoomedNode = zoomTrail[zoomTrail.length - 1] ?? root; // Observe container width useEffect(() => { @@ -348,7 +378,7 @@ export function FlameGraph({ root, search = '' }: Props) { }, []); const handleHover = useCallback( - (tip: TooltipState | null, _evt: React.MouseEvent) => { + (tip: TooltipState | null) => { setTooltip(tip); }, [], @@ -371,8 +401,11 @@ export function FlameGraph({ root, search = '' }: Props) { }} > {[ - { color: COLORS.evmStroke, label: 'EVM opcode' }, - { color: COLORS.stylusStroke, label: 'Stylus WASM' }, + { color: COLORS.evmStroke, label: 'EVM' }, + { color: COLORS.stylusStroke, label: 'Stylus' }, + { color: COLORS.starknetStroke, label: 'Starknet' }, + { color: COLORS.solanaStroke, label: 'Solana' }, + { color: COLORS.stellarStroke, label: 'Stellar' }, { color: '#6d28d9', label: 'VM Boundary' }, { color: '#ff2a4a', label: 'Search match' }, ].map(({ color, label }) => ( @@ -421,7 +454,7 @@ export function FlameGraph({ root, search = '' }: Props) { /> ))} - {tooltip && } + {tooltip && }
diff --git a/studio/src/components/TraceInspector.tsx b/studio/src/components/TraceInspector.tsx index baffc36..14d0d3f 100644 --- a/studio/src/components/TraceInspector.tsx +++ b/studio/src/components/TraceInspector.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo } from 'react'; import { getDisplayLabel } from '../types/trace'; -import type { StitchedReport, UnifiedStep } from '../types/trace'; +import type { StitchedReport, UnifiedStep, VmKind } from '../types/trace'; interface Props { report: StitchedReport; @@ -8,28 +8,55 @@ interface Props { const PAGE_SIZE = 150; -function StepRow({ step, report }: { step: UnifiedStep, report: StitchedReport }) { +function formatStepCost(step: UnifiedStep): string { + if (step.vm === 'Solana') { + return step.cost_equiv > 0 ? `${step.cost_equiv} CU` : ''; + } + if (step.vm === 'Starknet') { + return step.cost_equiv > 0 ? `${step.cost_equiv} steps` : ''; + } + if (step.vm === 'Stellar') { + return step.cost_equiv > 0 ? `${step.cost_equiv} units` : ''; + } + if (step.vm === 'Evm') { + return step.gas_cost > 0 ? `${step.gas_cost} gas` : ''; + } + if (step.vm === 'Stylus') { + return step.cost_equiv > 0 ? `${step.cost_equiv.toFixed(1)} gas-equiv` : ''; + } + return step.cost_equiv > 0 ? `${step.cost_equiv}` : ''; +} + +function getBadgeLabel(vm: VmKind): { text: string; className: string } { + switch (vm) { + case 'Evm': return { text: 'EVM', className: 'evm' }; + case 'Stylus': return { text: 'WASM', className: 'stylus' }; + case 'Solana': return { text: 'SVM', className: 'solana' }; + case 'Starknet': return { text: 'CAIRO', className: 'starknet' }; + case 'Stellar': return { text: 'SOROBAN', className: 'stellar' }; + } +} + +function StepRow({ step, report }: { step: UnifiedStep; report: StitchedReport }) { const indent = Array.from({ length: Math.max(0, step.depth - 1) }).map((_, i) => ( )); - const costStr = step.vm === 'Evm' - ? step.gas_cost > 0 ? `${step.gas_cost} gas` : '' - : `${step.cost_equiv.toFixed(2)} gas-equiv`; - + const costStr = formatStepCost(step); const displayLabel = getDisplayLabel(step, report); const isResolved = step.target_address && report.resolved_names[step.target_address]; + const badge = getBadgeLabel(step.vm); return (
#{step.index} {indent} - - {step.vm === 'Evm' ? 'EVM' : 'WASM'} + + {badge.text} {displayLabel} {costStr && {costStr}} @@ -41,21 +68,29 @@ function StepRow({ step, report }: { step: UnifiedStep, report: StitchedReport } } export function TraceInspector({ report }: Props) { - const [filter, setFilter] = useState<'all' | 'evm' | 'stylus' | 'boundary'>('all'); + const presentVms = useMemo(() => { + const set = new Set(); + for (const s of report.steps) set.add(s.vm); + return Array.from(set); + }, [report]); + + const [filter, setFilter] = useState('all'); const [page, setPage] = useState(0); const [search, setSearch] = useState(''); const filtered = useMemo(() => { return report.steps.filter((s: UnifiedStep) => { - if (filter === 'evm' && s.vm !== 'Evm') return false; - if (filter === 'stylus' && s.vm !== 'Stylus') return false; - if (filter === 'boundary' && !s.is_vm_boundary) return false; + if (filter === 'boundary') { + if (!s.is_vm_boundary) return false; + } else if (filter !== 'all') { + if (s.vm !== filter) return false; + } const label = getDisplayLabel(s, report).toLowerCase(); if (search && !label.includes(search.toLowerCase())) return false; return true; }); - }, [report.steps, filter, search]); + }, [report, filter, search]); const pageCount = Math.ceil(filtered.length / PAGE_SIZE); const visible = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); @@ -76,21 +111,37 @@ export function TraceInspector({ report }: Props) {
{/* Controls */}
- {(['all', 'evm', 'stylus', 'boundary'] as const).map((f) => ( + + + {presentVms.length > 1 && presentVms.map((vm) => ( ))} + + { setSearch(e.target.value); setPage(0); }} style={{ @@ -103,42 +154,57 @@ export function TraceInspector({ report }: Props) { fontSize: 12, fontFamily: 'var(--font-mono)', outline: 'none', - width: 220, + width: 260, }} />
- {/* Step count */} -
- Showing {visible.length} of {filtered.length} steps - {pageCount > 1 && ` (page ${page + 1}/${pageCount})`} -
- - {/* Steps list */} -
- {visible.length === 0 - ?
No steps match your filter.
- : visible.map((s) => ) - } + {/* Steps List */} +
+ {visible.length === 0 ? ( +
+ No steps matching filter or search. +
+ ) : ( + visible.map((step) => ) + )}
{/* Pagination */} {pageCount > 1 && ( -
+
- {page + 1} / {pageCount} + + Page {page + 1} of {pageCount} ({filtered.length} total) + diff --git a/studio/src/styles/design-system.css b/studio/src/styles/design-system.css index abc9942..f5048d5 100644 --- a/studio/src/styles/design-system.css +++ b/studio/src/styles/design-system.css @@ -398,6 +398,21 @@ body { color: var(--badge-stylus-color); } +.trace-step-badge.solana { + background: rgba(47, 228, 196, 0.15); + color: #2fe4c4; +} + +.trace-step-badge.starknet { + background: rgba(167, 139, 250, 0.15); + color: #a78bfa; +} + +.trace-step-badge.stellar { + background: rgba(96, 217, 255, 0.15); + color: #60d9ff; +} + .trace-step-label { font-family: var(--font-mono); font-size: 12px; diff --git a/studio/src/types/trace.ts b/studio/src/types/trace.ts index 36d184c..d4a1bba 100644 --- a/studio/src/types/trace.ts +++ b/studio/src/types/trace.ts @@ -1,7 +1,7 @@ // ─── Atupa Studio — Trace Data Types ───────────────────────────────────────── // Mirrors the Rust `StitchedReport` / `UnifiedStep` structures. -export type VmKind = 'Evm' | 'Stylus'; +export type VmKind = 'Evm' | 'Stylus' | 'Starknet' | 'Solana' | 'Stellar'; export type GasCategory = | 'StorageWrite' @@ -57,7 +57,7 @@ export interface DiffReport { export type StudioReport = StitchedReport | DiffReport; export function isDiff(report: StudioReport): report is DiffReport { - return (report as any).type === 'diff'; + return 'type' in report && report.type === 'diff'; } export function getDisplayLabel(step: UnifiedStep, report: StitchedReport): string { @@ -103,6 +103,39 @@ export function stylusSteps(report: StitchedReport): UnifiedStep[] { return report.steps.filter((s) => s.vm === 'Stylus'); } +export function solanaSteps(report: StitchedReport): UnifiedStep[] { + return report.steps.filter((s) => s.vm === 'Solana'); +} + +export function starknetSteps(report: StitchedReport): UnifiedStep[] { + return report.steps.filter((s) => s.vm === 'Starknet'); +} + +export function stellarSteps(report: StitchedReport): UnifiedStep[] { + return report.steps.filter((s) => s.vm === 'Stellar'); +} + +export type DetectedRuntime = 'solana' | 'starknet' | 'stellar' | 'stylus' | 'evm'; + +export function detectPrimaryVm(report: StitchedReport): DetectedRuntime { + const vms = new Set(report.steps.map((s) => s.vm)); + if (vms.has('Solana')) return 'solana'; + if (vms.has('Starknet')) return 'starknet'; + if (vms.has('Stellar')) return 'stellar'; + if (vms.has('Stylus')) return 'stylus'; + return 'evm'; +} + +export function getRuntimeBadge(runtime: DetectedRuntime): { label: string; icon: string; color: string } { + switch (runtime) { + case 'solana': return { label: 'Solana (SVM)', icon: '☀️', color: '#2fe4c4' }; + case 'starknet': return { label: 'Starknet (Cairo)', icon: '🐺', color: '#a78bfa' }; + case 'stellar': return { label: 'Stellar (Soroban)', icon: '🚀', color: '#60d9ff' }; + case 'stylus': return { label: 'Arbitrum Stylus (Dual-VM)', icon: '🌐', color: '#ff8c40' }; + case 'evm': return { label: 'EVM Mainnet', icon: '⛽', color: '#ff2a4a' }; + } +} + export function aggregateHostIOs(report: StitchedReport): AggregatedHostIO[] { const map = new Map(); for (const step of report.steps) { diff --git a/studio/vite.config.ts b/studio/vite.config.ts index 8b0f57b..ea9034d 100644 --- a/studio/vite.config.ts +++ b/studio/vite.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vite.dev/config/ +// https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + build: { + outDir: '../bin/atupa/dist', // Output to the bin directory so RustEmbed can pick it up + emptyOutDir: true, + } })