From 51d55d8e2779843ccffe0209ae6603271eed7d7a Mon Sep 17 00:00:00 2001 From: intelliDean Date: Mon, 4 May 2026 18:14:08 +0100 Subject: [PATCH 01/37] feat: transform into Universal Multi-VM Execution Profiler with Solana, Starknet, and Stellar support --- ARCHITECTURE.md | 107 ++++-- Cargo.lock | 72 +++- Cargo.toml | 7 +- README.md | 45 ++- artifacts/capture/test.json | 212 ----------- artifacts/capture/test.svg | 1 - bin/atupa/Cargo.toml | 4 +- bin/atupa/src/main.rs | 373 +++++++++++++++++++ crates/atupa-aave/Cargo.toml | 1 - crates/atupa-adapters/Cargo.toml | 1 - crates/atupa-core/Cargo.toml | 1 - crates/atupa-core/src/lib.rs | 22 ++ crates/atupa-lido/Cargo.toml | 1 - crates/atupa-nitro/Cargo.toml | 1 - crates/atupa-output/Cargo.toml | 1 - crates/atupa-output/src/lib.rs | 16 +- crates/atupa-output/templates/flamegraph.svg | 35 +- crates/atupa-parser/Cargo.toml | 1 - crates/atupa-rpc/Cargo.toml | 1 - crates/atupa-sdk/Cargo.toml | 4 +- crates/atupa-sdk/src/lib.rs | 125 ++++--- crates/atupa-solana/Cargo.toml | 24 ++ crates/atupa-solana/src/lib.rs | 212 +++++++++++ crates/atupa-starknet/Cargo.toml | 23 ++ crates/atupa-starknet/src/lib.rs | 231 ++++++++++++ crates/atupa-stellar/Cargo.toml | 23 ++ crates/atupa-stellar/src/lib.rs | 177 +++++++++ docs/ADAPTER_GUIDE.md | 85 +++++ docs/VISION.md | 37 ++ studio/src/components/FlameGraph.tsx | 44 ++- studio/src/types/trace.ts | 2 +- 31 files changed, 1561 insertions(+), 328 deletions(-) delete mode 100644 artifacts/capture/test.json delete mode 100644 artifacts/capture/test.svg create mode 100644 crates/atupa-solana/Cargo.toml create mode 100644 crates/atupa-solana/src/lib.rs create mode 100644 crates/atupa-starknet/Cargo.toml create mode 100644 crates/atupa-starknet/src/lib.rs create mode 100644 crates/atupa-stellar/Cargo.toml create mode 100644 crates/atupa-stellar/src/lib.rs create mode 100644 docs/ADAPTER_GUIDE.md create mode 100644 docs/VISION.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f0e947..26193d0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,32 +1,87 @@ -# 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 "gas" units, log formats, 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_cost: u64, // Normalized execution weight + pub depth: u16, // Call-stack depth + pub vm_kind: VmKind, // The source VM (Evm, Stylus, Solana, etc.) + pub stack: Option>, + // ... metadata +} +``` + +By mapping heterogeneous units (Solana Compute Units, Soroban HostFn weights, 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. Network Adapters (The Sources) +Atupa connects to diverse execution environments via specialized clients: +- **`atupa-nitro`**: Handles Arbitrum's dual-VM state. It stitches standard Geth-style EVM traces with `stylusTracer` WASM logs. +- **`atupa-starknet`**: Interacts with the Starknet gateway to fetch `traceTransaction` data and flattens recursive Cairo call frames. +- **`atupa-solana`**: Implements a complex **Log Stitcher** state machine. Since Solana RPCs only provide sequential logs, Atupa reconstructs the nested call stack by tracking `Program...invoke` and `Program...success` markers. +- **`atupa-stellar`**: Parses Soroban `diagnostic_events` to reconstruct Host Function call trees. + +### 2. The Aggregation Engine (`atupa-parser`) +Raw traces are often thousands of lines long. The parser performs: +- **Depth-Aware Folding**: Groups sequential opcodes into logical blocks while preserving call-stack integrity. +- **Instruction Normalization**: Maps VM-specific costs to a relative "unified cost" for cross-environment comparison. +- **Category Tagging**: Tags steps as `StorageRead`, `Memory`, `Crypto`, etc., to power the Studio's metric cards. + +### 3. Visualization Engine (`atupa-output`) +Atupa generates high-fidelity visual artifacts without relying on external SaaS platforms: +- **SVG Flamegraphs**: Hand-crafted SVG templates with dynamic gradients that visually differentiate between VMs (e.g., Green for Solana, Purple for Starknet). +- **Interactive Diffing**: A specialized visual mode that overlays two traces, using color intensities to highlight gas regressions or optimizations. + +### 4. Atupa Studio (`studio/`) +A local-first, high-performance web dashboard built with Vite + React + TypeScript. It features: +- **Zero-Dependency Flamegraphs**: Custom React components that render recursive trees directly into SVGs for maximum performance. +- **Trace Inspector**: A paginated, filterable view of the normalized execution timeline. + +--- + +## ๐Ÿ“ฆ Crate Hierarchy + +```mermaid +graph TD + CLI[bin/atupa] --> SDK[crates/atupa-sdk] + 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] + + Nitro --> Parser[crates/atupa-parser] + Solana --> Parser + Starknet --> Parser + Stellar --> Parser + + Parser --> Output[crates/atupa-output] + Output --> Core +``` + +--- + +## ๐Ÿฎ Data Lifecycle + +1. **Capture**: CLI fetches raw RPC data based on the transaction hash and endpoint signature. +2. **Normalize**: The chain-specific adapter converts raw logs/traces into `Vec`. +3. **Stitch**: If the transaction crosses VM boundaries (e.g., Arbitrum), the Nitro adapter synchronizes the EVM and WASM clocks. +4. **Aggregate**: The parser collapses steps into a searchable tree. +5. **Render**: The Output engine generates either a terminal summary, a JSON report, or an interactive SVG. --- -๐Ÿฎ *One Block: The Transparency Layer for the Hybrid Future.* +๐Ÿฎ *Atupa: Illuminating the path toward multi-VM transparency.* diff --git a/Cargo.lock b/Cargo.lock index a5264b2..5654d65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atupa" -version = "0.1.0" +version = "0.1.1" 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,7 +177,7 @@ dependencies = [ [[package]] name = "atupa-aave" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-adapters", @@ -187,7 +190,7 @@ dependencies = [ [[package]] name = "atupa-adapters" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-core", @@ -198,7 +201,7 @@ dependencies = [ [[package]] name = "atupa-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "chrono", @@ -212,7 +215,7 @@ dependencies = [ [[package]] name = "atupa-lido" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-adapters", @@ -224,7 +227,7 @@ dependencies = [ [[package]] name = "atupa-nitro" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-core", @@ -239,7 +242,7 @@ dependencies = [ [[package]] name = "atupa-output" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "askama", @@ -251,7 +254,7 @@ dependencies = [ [[package]] name = "atupa-parser" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-adapters", @@ -265,7 +268,7 @@ dependencies = [ [[package]] name = "atupa-rpc" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-core", @@ -280,7 +283,7 @@ dependencies = [ [[package]] name = "atupa-sdk" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "atupa-aave", @@ -291,11 +294,60 @@ dependencies = [ "atupa-output", "atupa-parser", "atupa-rpc", + "atupa-solana", + "atupa-starknet", + "atupa-stellar", "indicatif", "log", "tokio", ] +[[package]] +name = "atupa-solana" +version = "0.1.1" +dependencies = [ + "anyhow", + "atupa-core", + "atupa-rpc", + "log", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "atupa-starknet" +version = "0.1.1" +dependencies = [ + "anyhow", + "atupa-core", + "atupa-rpc", + "log", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "atupa-stellar" +version = "0.1.1" +dependencies = [ + "anyhow", + "atupa-core", + "atupa-rpc", + "log", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "autocfg" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 2446c85..04d4e07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,13 +10,15 @@ 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" edition = "2024" -authors = ["Dean "] description = "Atupa: High-Fidelity Ethereum Tracing & Visual Profiling Suite" readme = "README.md" license = "MIT OR Apache-2.0" @@ -66,3 +68,6 @@ 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-starknet = { path = "crates/atupa-starknet", version = "0.1.1" } +atupa-solana = { path = "crates/atupa-solana", version = "0.1.1" } +atupa-stellar = { path = "crates/atupa-stellar", version = "0.1.1" } diff --git a/README.md b/README.md index a7e8785..80e7396 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,19 @@ --- -**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 layer for the modular execution landscape, including EVM, Arbitrum Stylus (WASM), Starknet (Cairo), Solana (SVM), and Stellar (Soroban), turning raw execution logs into actionable visual insights. ## โœจ 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). +- **๐Ÿ“Š Protocol-Aware Gas Analysis**: Specialized cost mapping for non-EVM units, including Solana Compute Units (CU) and Soroban HostFn weights. +- **๐Ÿฎ Atupa Studio**: A local-first web visualizer โ€” drop a `report.json` to instantly render cross-chain metric cards and interactive flamegraphs. +- **๐Ÿšจ Crisp Revert Identification**: Instantly identifies failing sub-calls or program errors with high-contrast highlights. +- **๐Ÿ” Smart Contract Resolution**: Automatically resolves addresses to verified contract names via Etherscan, Starkscan, and Solana Explorers. +- **๐Ÿš€ Automated CI/CD Pipeline**: Built-in zero-config gas regression gating for GitHub Actions across all supported chains. +- **๐Ÿ’‰ Protocol-Specific Deep Auditing**: Built-in deep traces for **Lido stETH**, **Aave v3**, and upcoming Solana DeFi primitives. +- **๐Ÿ›  Modular Library Architecture**: Pure Rust workspace with specialized crates for each VM adapter and execution environment. ## ๐Ÿš€ Quick Start @@ -53,13 +54,19 @@ atupa init # Capture an Arbitrum Stylus transaction (summary to terminal) atupa capture --tx 0x... --rpc https://arb-mainnet.g.alchemy.com/v2/KEY +# Capture a Solana transaction (SVM Compute Unit breakdown) +atupa capture --tx 5Z9... --rpc https://api.mainnet-beta.solana.com + +# Capture a Starknet transaction (Cairo execution steps) +atupa capture --tx 0x... --rpc https://starknet-mainnet.public.blastapi.io + +# Capture a Stellar transaction (Soroban diagnostic events) +atupa capture --tx 0x... --rpc https://soroban-testnet.stellar.org + # Export as JSON for Atupa Studio atupa capture --tx 0x... --rpc https://... --output json --file report.json -# Deep protocol audit (Lido or Aave) -atupa audit --protocol lido --tx 0x... - -# Compare execution cost of two transactions +# Compare execution cost of two transactions (cross-chain diffing) atupa diff --base 0x... --target 0x... ``` @@ -106,7 +113,10 @@ Atupa is built as a highly modular monorepo: | [`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-starknet`](crates/atupa-starknet) | Starknet (Cairo) VM adapter. | +| [`crates/atupa-solana`](crates/atupa-solana) | Solana (SVM) log-stitching profiler. | +| [`crates/atupa-stellar`](crates/atupa-stellar) | Stellar (Soroban) diagnostic event parser. | +| [`crates/atupa-rpc`](crates/atupa-rpc) | Async multi-chain RPC client & resolver. | | [`crates/atupa-lido`](crates/atupa-lido) | Specialized adapter for Lido stETH. | | [`crates/atupa-aave`](crates/atupa-aave) | Specialized adapter for Aave v3 & GHO. | @@ -114,6 +124,13 @@ Atupa is built as a highly modular monorepo: We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more details. +## ๐Ÿ“– Documentation + +For a deep dive into Atupa's internals and philosophy: +- [**The Atupa Vision**](docs/VISION.md) โ€” Why we are building a universal profiler. +- [**System Architecture**](ARCHITECTURE.md) โ€” How the engine, adapters, and Studio interact. +- [**Adapter Guide**](docs/ADAPTER_GUIDE.md) โ€” A step-by-step guide to adding support for new VMs. + ## ๐Ÿ“„ License Atupa is dual-licensed under the [MIT License](LICENSE-MIT) and the [Apache License, Version 2.0](LICENSE-APACHE). 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..7738f8c 100644 --- a/bin/atupa/Cargo.toml +++ b/bin/atupa/Cargo.toml @@ -2,7 +2,6 @@ name = "atupa" version = { workspace = true } edition = { workspace = true } -authors = { workspace = true } license = { workspace = true } description = "atupa โ€” Unified EVM + Stylus Execution Profiler CLI" readme = { workspace = true } @@ -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/src/main.rs b/bin/atupa/src/main.rs index 513ecda..ee91eba 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -352,6 +352,236 @@ async fn cmd_capture( eprintln!("{} {}\n", "โ†’ Endpoint: ".bold(), config.rpc_url.dimmed()); // Phase 1: fetch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if config.rpc_url.contains("starknet") { + let pb = spinner("Detecting Starknet network and fetching execution traceโ€ฆ"); + let client = atupa_starknet::StarknetClient::new(config.rpc_url.clone()); + 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 mut svg_path: Option = None; + if generate_profile { + let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); + let normalized = TraceParser::normalize_raw(steps.clone()); + 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); + } + + let pb2 = spinner("Rendering reportโ€ฆ"); + let rendered = match format { + OutputFormat::Summary => format!("Starknet trace captured successfully with {} steps.", steps.len()), + OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + }; + pb2.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + + eprintln!(); + if format == OutputFormat::Summary { + println!("{}", rendered); + } + eprintln!(); + + 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() + ); + } + + return Ok(Some(report_path)); + } + + if config.rpc_url.contains("solana") { + let pb = spinner("Detecting Solana network and fetching execution traceโ€ฆ"); + let client = atupa_solana::SolanaClient::new(config.rpc_url.clone()); + 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 mut svg_path: Option = None; + if generate_profile { + let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); + let normalized = TraceParser::normalize_raw(steps.clone()); + 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); + } + + let pb2 = spinner("Rendering reportโ€ฆ"); + let rendered = match format { + OutputFormat::Summary => format!("Solana trace reconstructed successfully with {} steps.", steps.len()), + OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + }; + pb2.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + + eprintln!(); + if format == OutputFormat::Summary { + println!("{}", rendered); + } + eprintln!(); + + 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() + ); + } + + return Ok(Some(report_path)); + } + + if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { + let pb = spinner("Detecting Stellar network and fetching diagnostic eventsโ€ฆ"); + let client = atupa_stellar::StellarClient::new(config.rpc_url.clone()); + 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 mut svg_path: Option = None; + if generate_profile { + let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); + let normalized = TraceParser::normalize_raw(steps.clone()); + 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); + } + + let pb2 = spinner("Rendering reportโ€ฆ"); + let rendered = match format { + OutputFormat::Summary => format!("Stellar trace reconstructed successfully with {} host function calls.", steps.len()), + OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + }; + pb2.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + + eprintln!(); + if format == OutputFormat::Summary { + println!("{}", rendered); + } + eprintln!(); + + 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() + ); + } + + return Ok(Some(report_path)); + } + let pb = spinner("Detecting network and fetching execution traceโ€ฆ"); let client = NitroClient::new(config.rpc_url.clone()); @@ -600,6 +830,48 @@ async fn cmd_diff( let client = NitroClient::new(config.rpc_url.clone()); let eth_client = EthClient::new(config.rpc_url.clone()); + if config.rpc_url.contains("solana") { + let solana_client = atupa_solana::SolanaClient::new(config.rpc_url.clone()); + let pb = 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); + + return process_generic_diff("Solana", "Compute Units", &base, &target, base_steps, target_steps, svg, threshold); + } + + if config.rpc_url.contains("starknet") { + let starknet_client = atupa_starknet::StarknetClient::new(config.rpc_url.clone()); + let pb = 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!(); + + return process_generic_diff("Starknet Cairo", "Gas-Equivalent Steps", &base, &target, base_steps, target_steps, svg, threshold); + } + + if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { + let stellar_client = atupa_stellar::StellarClient::new(config.rpc_url.clone()); + let pb = 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!(); + + return process_generic_diff("Stellar Soroban", "HostFn Weight", &base, &target, base_steps, target_steps, svg, threshold); + } + let pb = spinner("Fetching both traces and receipts concurrentlyโ€ฆ"); // Fetch traces @@ -994,6 +1266,107 @@ async fn cmd_diff( Ok(()) } +fn process_generic_diff( + network_name: &str, + unit_name: &str, + base_tx: &str, + target_tx: &str, + base_steps: Vec, + target_steps: Vec, + svg: bool, + threshold: Option, +) -> Result<()> { + let base_cost = base_steps.iter().map(|s| s.gas_cost).sum::(); + let target_cost = 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 = base_steps.len(); + let target_count = 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 }; + + let div = "โ”€".repeat(70).dimmed().to_string(); + + println!("{}", format!(" {network_name} 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} {}", + format!("Total {unit_name}:"), + base_cost.to_string().cyan(), + target_cost.to_string().cyan(), + colorize_delta(cost_delta, cost_pct) + ); + + println!( + " {:<25} {:<15} {:<15} {}", + "Execution Steps:", + base_count.to_string().green(), + target_count.to_string().yellow(), + colorize_delta(count_delta, count_pct) + ); + println!("{div}\n"); + + let mut failures = Vec::new(); + if let Some(t) = threshold { + if cost_pct > t { + failures.push(format!( + "Total {unit_name} increased by {cost_pct:.1}% (limit: {t:.1}%)" + )); + } + } + + if svg { + let pb_svg = spinner("Generating diff flamegraphโ€ฆ"); + let base_norm = TraceParser::normalize_raw(base_steps); + let target_norm = TraceParser::normalize_raw(target_steps); + let base_stacks = Aggregator::build_collapsed_stacks(&base_norm); + let target_stacks = Aggregator::build_collapsed_stacks(&target_norm); + + 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", &base_tx[..10], &target_tx[..10]); + 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())); + } + + if !failures.is_empty() { + println!("\n {}", "โŒ [FAILED] Regression detected:".red().bold()); + for f in failures.iter() { + println!(" - {}", f.red()); + } + return Err(anyhow::anyhow!("{network_name} regression thresholds exceeded")); + } else if threshold.is_some() { + println!( + "\n {} Execution cost within acceptable limits.", + "โœ… [PASSED]".green().bold() + ); + } + + Ok(()) +} + // โ”€โ”€โ”€ Studio Command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async fn cmd_studio( diff --git a/crates/atupa-aave/Cargo.toml b/crates/atupa-aave/Cargo.toml index 4d7875b..0c107f7 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 } diff --git a/crates/atupa-adapters/Cargo.toml b/crates/atupa-adapters/Cargo.toml index e68db07..5803414 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 } diff --git a/crates/atupa-core/Cargo.toml b/crates/atupa-core/Cargo.toml index 3600f14..fec6925 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 } diff --git a/crates/atupa-core/src/lib.rs b/crates/atupa-core/src/lib.rs index e07e8e7..f684723 100644 --- a/crates/atupa-core/src/lib.rs +++ b/crates/atupa-core/src/lib.rs @@ -32,6 +32,8 @@ impl GasCategory { match vm { VmKind::Evm => Self::from_evm(op), VmKind::Stylus => Self::from_stylus(op), + VmKind::Starknet => Self::from_starknet(op), + _ => Self::Other, } } @@ -84,6 +86,23 @@ impl GasCategory { Self::Other } } + + fn from_starknet(op: &str) -> Self { + let op = op.to_lowercase(); + if op.contains("storage_read") { + Self::StorageRead + } else if op.contains("storage_write") { + Self::StorageWrite + } else if op.contains("keccak") || op.contains("pedersen") || op.contains("poseidon") { + Self::Crypto + } else if op.contains("call") || op.contains("deploy") || op.contains("invoke") { + Self::Call + } else if op.contains("range_check") || op.contains("bitwise") || op.contains("steps") { + Self::Execution + } else { + Self::Other + } + } } /// A single step in the EVM execution trace (equivalent to structLog). @@ -110,6 +129,9 @@ pub enum VmKind { #[default] Evm, Stylus, + Starknet, + Solana, + Stellar, } /// A single collapsed stack entry for aggregation. diff --git a/crates/atupa-lido/Cargo.toml b/crates/atupa-lido/Cargo.toml index 3a59d46..46b9bce 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 } diff --git a/crates/atupa-nitro/Cargo.toml b/crates/atupa-nitro/Cargo.toml index 0baa17c..2f8fa38 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 } diff --git a/crates/atupa-output/Cargo.toml b/crates/atupa-output/Cargo.toml index b1d8e6e..db85e0e 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 } diff --git a/crates/atupa-output/src/lib.rs b/crates/atupa-output/src/lib.rs index c2fcec9..bdadc56 100644 --- a/crates/atupa-output/src/lib.rs +++ b/crates/atupa-output/src/lib.rs @@ -13,6 +13,8 @@ struct FlamegraphTemplate { width: u32, height: u32, has_wasm: bool, + has_starknet: bool, + has_stellar: bool, } struct StackEntry { @@ -66,7 +68,7 @@ impl SvgGenerator { const MIN_BAR_PX: f64 = 2.0; let evm_stacks: Vec<&CollapsedStack> = - stacks.iter().filter(|s| s.vm_kind == VmKind::Evm).collect(); + stacks.iter().filter(|s| s.vm_kind != VmKind::Stylus).collect(); let wasm_stacks: Vec<&CollapsedStack> = stacks .iter() .filter(|s| s.vm_kind == VmKind::Stylus) @@ -108,7 +110,12 @@ impl SvgGenerator { let class = if stack.reverted { "box-revert" } else { - "box-evm" + match stack.vm_kind { + VmKind::Starknet => "box-starknet", + VmKind::Solana => "box-solana", + VmKind::Stellar => "box-stellar", + _ => "box-evm", + } }; let label = Self::make_label(stack, bar_w); let pct = if global_evm_weight > 0 { @@ -199,12 +206,17 @@ impl SvgGenerator { current_y += BAR_H + GAP; } + let has_starknet = evm_stacks.iter().any(|s| s.vm_kind == VmKind::Starknet); + 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_W as u32, height, has_wasm, + has_starknet, + has_stellar, }; Ok(template.render()?) } diff --git a/crates/atupa-output/templates/flamegraph.svg b/crates/atupa-output/templates/flamegraph.svg index 7401091..27c583f 100644 --- a/crates/atupa-output/templates/flamegraph.svg +++ b/crates/atupa-output/templates/flamegraph.svg @@ -15,6 +15,21 @@ + + + + + + + + + + + + + + + @@ -32,9 +50,24 @@ {% if has_wasm %} Stylus / WASM - {% endif %} Reverted + {% else if has_starknet %} + + Starknet Cairo + + Reverted + {% else if has_stellar %} + + Stellar Soroban + + Reverted + {% else %} + + Solana Program + + Reverted + {% endif %} {% for entry in stacks %} diff --git a/crates/atupa-parser/Cargo.toml b/crates/atupa-parser/Cargo.toml index 6c998ae..16762b8 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 } diff --git a/crates/atupa-rpc/Cargo.toml b/crates/atupa-rpc/Cargo.toml index 35b56ae..b0b400e 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 } 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..de4852b 100644 --- a/crates/atupa-sdk/src/lib.rs +++ b/crates/atupa-sdk/src/lib.rs @@ -45,6 +45,15 @@ pub use atupa_aave as aave; /// Lido stETH protocol adapter. pub use atupa_lido as lido; +/// Starknet (Cairo VM) protocol adapter. +pub use atupa_starknet as starknet; + +/// Solana (Sealevel VM) protocol adapter. +pub use atupa_solana as solana; + +/// Stellar (Soroban WASM VM) protocol adapter. +pub use atupa_stellar as stellar; + // โ”€โ”€โ”€ High-level API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ pub use profile::execute_profile; @@ -54,6 +63,9 @@ pub mod profile { use anyhow::Result; use atupa_core::{CollapsedStack, VmKind}; use atupa_nitro::{NitroClient, VmKind as NitroVmKind}; + use atupa_starknet::StarknetClient; + use atupa_solana::{SolanaClient, SolanaLogStitcher}; + use atupa_stellar::StellarClient; use atupa_output::SvgGenerator; use atupa_parser::{Parser as AtupaParser, aggregator::Aggregator}; use atupa_rpc::etherscan::EtherscanResolver; @@ -80,54 +92,83 @@ pub mod profile { (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}"))?; + + // Heuristic-based client selection + // In a production version, we would perform a chainId probe or use explicit flags. + if rpc.contains("starknet") || tx.len() > 66 { + 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); + (combined, "Starknet".to_string()) + } else if rpc.contains("solana") || tx.len() > 66 || tx.len() == 44 { + // Solana signatures are base58 and ~44-88 chars + 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); + (combined, "Solana".to_string()) + } else if rpc.contains("stellar") || rpc.contains("soroban") || tx.len() == 64 { + // Stellar hashes are 64 hex chars + 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); + (combined, "Stellar".to_string()) + } else { + 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 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 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); + 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); + // 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) } - - (combined, network) }; // Sort EVM stacks descending by weight; Stylus stacks come after diff --git a/crates/atupa-solana/Cargo.toml b/crates/atupa-solana/Cargo.toml new file mode 100644 index 0000000..b77d37b --- /dev/null +++ b/crates/atupa-solana/Cargo.toml @@ -0,0 +1,24 @@ +[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 } +anyhow = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true } +regex = "1.11.1" diff --git a/crates/atupa-solana/src/lib.rs b/crates/atupa-solana/src/lib.rs new file mode 100644 index 0000000..e4d2316 --- /dev/null +++ b/crates/atupa-solana/src/lib.rs @@ -0,0 +1,212 @@ +use atupa_core::{TraceStep, VmKind}; +use atupa_rpc::RpcError; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::OnceLock; +use thiserror::Error; + +static INVOKE_REGEX: OnceLock = OnceLock::new(); +static CONSUMED_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+)\]").unwrap()) +} + +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").unwrap()) +} + +static RETURN_REGEX: OnceLock = OnceLock::new(); +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)").unwrap()) +} + +// โ”€โ”€โ”€ Solana RPC Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[derive(Error, Debug)] +pub enum SolanaError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + #[error("Parsing error: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SolanaTransactionResponse { + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SolanaMeta { + #[serde(rename = "logMessages")] + pub log_messages: Option>, + pub fee: u64, +} + +// โ”€โ”€โ”€ Solana Log Parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +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(); + + struct ActiveFrame { + addr: String, + start_idx: usize, + total_cu: u64, + children_cu: u64, + } + + 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) { + 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 at return + 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, + }); + } else if let Some(caps) = consumed_re.captures(log) { + let addr = caps["addr"].to_string(); + let cu: u64 = caps["cu"].parse().unwrap_or(0); + + // Match the consumed log to the current active frame for this address + if let Some(frame) = active_frames.iter_mut().rev().find(|f| f.addr == addr) { + frame.total_cu = cu; + } + } else if let Some(caps) = return_re.captures(log) { + let addr = caps["addr"].to_string(); + let status = &caps["status"]; + + // Pop frames until we find the matching address + // This handles cases where intermediate frames failed without a clear return log + while let Some(frame) = active_frames.pop() { + let is_match = frame.addr == addr; + + let exclusive_cu = frame.total_cu.saturating_sub(frame.children_cu); + steps[frame.start_idx].gas_cost = exclusive_cu; + + if is_match && status == "failed" { + steps[frame.start_idx].reverted = true; + } + + if let Some(parent) = active_frames.last_mut() { + parent.children_cu += frame.total_cu; + } + + if is_match { + break; + } + } + } + } + + steps + } +} + +// โ”€โ”€โ”€ Solana Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub struct SolanaClient { + rpc_url: String, + client: reqwest::Client, +} + +impl SolanaClient { + pub fn new(rpc_url: String) -> Self { + Self { + rpc_url, + client: reqwest::Client::new(), + } + } + + pub async fn get_transaction_logs(&self, tx_sig: &str) -> Result, SolanaError> { + 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())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_solana_log_parsing() { + 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 + + // 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 + } +} diff --git a/crates/atupa-starknet/Cargo.toml b/crates/atupa-starknet/Cargo.toml new file mode 100644 index 0000000..5ec8914 --- /dev/null +++ b/crates/atupa-starknet/Cargo.toml @@ -0,0 +1,23 @@ +[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 } +anyhow = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true } diff --git a/crates/atupa-starknet/src/lib.rs b/crates/atupa-starknet/src/lib.rs new file mode 100644 index 0000000..8714365 --- /dev/null +++ b/crates/atupa-starknet/src/lib.rs @@ -0,0 +1,231 @@ +use atupa_core::{TraceStep, VmKind}; +use atupa_rpc::RpcError; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use thiserror::Error; + +// โ”€โ”€โ”€ Starknet RPC Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[derive(Error, Debug)] +pub enum StarknetError { + #[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("Processing error: {0}")] + Process(String), +} + +/// Execution resources consumed by a Starknet call. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExecutionResources { + pub steps: u64, + #[serde(default)] + pub pedersen_builtin: u64, + #[serde(default)] + pub range_check_builtin: u64, + #[serde(default)] + pub bitwise_builtin: u64, + #[serde(default)] + pub poseidon_builtin: u64, + #[serde(default)] + pub ec_op_builtin: u64, + #[serde(default)] + pub ecdsa_builtin: u64, +} + +/// A recursive call in a Starknet transaction trace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionInvocation { + pub contract_address: String, + pub entry_point_selector: String, + pub calldata: Vec, + pub execution_resources: ExecutionResources, + #[serde(default)] + pub calls: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StarknetTransactionTrace { + pub validate_invocation: Option, + pub execute_invocation: Option, + pub fee_transfer_invocation: Option, +} + +// โ”€โ”€โ”€ Starknet Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub struct StarknetClient { + rpc_url: String, + client: reqwest::Client, +} + +impl StarknetClient { + pub fn new(rpc_url: String) -> Self { + Self { + rpc_url, + client: reqwest::Client::new(), + } + } + + pub async fn get_transaction_trace(&self, tx_hash: &str) -> Result { + 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 trace into Atupa-compatible TraceSteps. + pub fn flatten_trace(&self, invocation: &FunctionInvocation, depth: u16) -> Vec { + let mut steps = Vec::new(); + + // 1. Map execution resources to virtual "opcodes" for Atupa aggregation + // In Starknet, we don't have individual opcodes in the RPC trace (usually), + // but we have aggregated resources per call frame. + + // Root step for this call frame + let selector_label = if invocation.entry_point_selector.len() > 12 { + &invocation.entry_point_selector[0..12] + } else { + &invocation.entry_point_selector + }; + + // For target resolution, we can add the contract_address to the stack + let mut stack_info = Vec::new(); + stack_info.push(invocation.contract_address.clone()); + + steps.push(TraceStep { + pc: 0, + op: format!("CALL:{}", selector_label), + gas: 0, + gas_cost: invocation.execution_resources.steps, // Use steps as base weight + depth, + stack: Some(stack_info), + memory: None, + error: None, + reverted: false, + vm_kind: VmKind::Starknet, + }); + + // Add virtual steps for builtins if they were used + 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 * weight, + depth: depth + 1, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: VmKind::Starknet, + }); + } + }; + + add_builtin("PEDERSEN", invocation.execution_resources.pedersen_builtin, 32); + add_builtin("RANGE_CHECK", invocation.execution_resources.range_check_builtin, 16); + add_builtin("BITWISE", invocation.execution_resources.bitwise_builtin, 64); + add_builtin("POSEIDON", invocation.execution_resources.poseidon_builtin, 32); + add_builtin("EC_OP", invocation.execution_resources.ec_op_builtin, 1024); + add_builtin("ECDSA", invocation.execution_resources.ecdsa_builtin, 2048); + + // 2. Recursively process nested calls + for sub_call in &invocation.calls { + steps.extend(self.flatten_trace(sub_call, depth + 1)); + } + + steps + } + + pub async fn profile_transaction(&self, tx_hash: &str) -> Result, StarknetError> { + let trace = self.get_transaction_trace(tx_hash).await?; + let mut all_steps = Vec::new(); + + if let Some(invoke) = trace.validate_invocation { + all_steps.extend(self.flatten_trace(&invoke, 1)); + } + if let Some(invoke) = trace.execute_invocation { + all_steps.extend(self.flatten_trace(&invoke, 1)); + } + if let Some(invoke) = trace.fee_transfer_invocation { + all_steps.extend(self.flatten_trace(&invoke, 1)); + } + + Ok(all_steps) + } +} + +// โ”€โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flatten_recursive_trace() { + 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 client = StarknetClient::new("http://localhost".to_string()); + let steps = client.flatten_trace(&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[1].op, "PEDERSEN"); + assert_eq!(steps[1].depth, 2); + assert_eq!(steps[2].op, "RANGE_CHECK"); + assert_eq!(steps[2].depth, 2); + assert_eq!(steps[3].op, "CALL:0xdeadbeef"); + assert_eq!(steps[3].depth, 2); + } +} diff --git a/crates/atupa-stellar/Cargo.toml b/crates/atupa-stellar/Cargo.toml new file mode 100644 index 0000000..25746ee --- /dev/null +++ b/crates/atupa-stellar/Cargo.toml @@ -0,0 +1,23 @@ +[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 } +anyhow = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true } diff --git a/crates/atupa-stellar/src/lib.rs b/crates/atupa-stellar/src/lib.rs new file mode 100644 index 0000000..c3db413 --- /dev/null +++ b/crates/atupa-stellar/src/lib.rs @@ -0,0 +1,177 @@ +use atupa_core::{TraceStep, VmKind}; +use atupa_rpc::RpcError; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use thiserror::Error; + +// โ”€โ”€โ”€ Stellar/Soroban RPC Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[derive(Error, Debug)] +pub enum StellarError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + #[error("Parsing error: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SorobanDiagnosticEvent { + #[serde(rename = "type")] + pub event_type: String, + pub topics: Vec, + pub value: String, // Base64 XDR or simplified JSON +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StellarTransactionResponse { + pub status: String, + pub tx_hash: String, + pub diagnostic_events: Option>, +} + +// โ”€โ”€โ”€ Stellar Trace Parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub struct StellarTraceParser; + +impl StellarTraceParser { + /// Maps Stellar diagnostic events to Atupa TraceSteps. + /// + /// In a full implementation, this would involve decoding XDR topics + /// to identify host function calls and their resource consumption. + 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; + } + + // In Soroban, diagnostic events for host calls often look like: + // topics: ["fn_call", "invoke_contract"] + // or ["fn_return", "invoke_contract"] + + 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 nested contract calls + if event_action.contains("return") { + depth = depth.saturating_sub(1); + continue; // Don't create a step for the return event itself + } + + let gas_cost = match fn_name { + name if name.contains("put_contract_data") => 5000, + name if name.contains("get_contract_data") => 2100, + name if name.contains("crypto") || name.contains("hash") => 3000, + name if name.contains("invoke") => 1500, + _ => 100, // base cost for generic host functions + }; + + 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 it was an invocation, subsequent events happen at a deeper level + if fn_name.contains("invoke_contract") && event_action.contains("call") { + depth += 1; + } + } + + steps + } +} + +// โ”€โ”€โ”€ Stellar Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub struct StellarClient { + rpc_url: String, + client: reqwest::Client, +} + +impl StellarClient { + pub fn new(rpc_url: String) -> Self { + Self { + rpc_url, + client: reqwest::Client::new(), + } + } + + pub async fn get_transaction_trace(&self, tx_hash: &str) -> Result, StellarError> { + 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 test_stellar_event_parsing() { + 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, 1500); + + 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, 5000); + } +} diff --git a/docs/ADAPTER_GUIDE.md b/docs/ADAPTER_GUIDE.md new file mode 100644 index 0000000..86e6a84 --- /dev/null +++ b/docs/ADAPTER_GUIDE.md @@ -0,0 +1,85 @@ +# ๐Ÿ›  Atupa Adapter Guide: Building for a New VM + +Atupa is designed to be easily extended to new execution environments. This guide explains how to build a new VM adapter crate. + +--- + +## 1. Anatomy of an Adapter + +Every VM adapter should be a separate crate in the `crates/` directory (e.g., `atupa-fvm`). An adapter's primary responsibility is to fetch raw RPC data and transform it into the unified `atupa_core::TraceStep` model. + +### Key Components: +1. **The Client**: An async struct that wraps the target chain's JSON-RPC. +2. **The Parser/Stitcher**: Logic that converts logs, diagnostic events, or raw traces into `Vec`. +3. **The Normalizer**: Mapping of native units (e.g., Compute Units) to gas-equivalent weights. + +--- + +## 2. Implementation Steps + +### Step A: Define the `VmKind` +Add your new VM to the `VmKind` enum in `crates/atupa-core/src/lib.rs`. + +```rust +pub enum VmKind { + Evm, + Stylus, + Starknet, + Solana, + Stellar, + MyNewVM, // Add this +} +``` + +### Step B: Create the Client +Implement the RPC fetching logic. Ensure you handle common error cases like missing traces or invalid hashes. + +```rust +pub struct MyVMClient { ... } + +impl MyVMClient { + pub async fn get_trace(&self, hash: &str) -> Result, MyVMError> { + // 1. Fetch raw data + // 2. Map to TraceSteps + // 3. Return + } +} +``` + +### Step C: Handle Call-Stack Depth +Atupa flamegraphs rely on the `depth` field. +- If your RPC provides a flat list (like Solana logs), you must implement a state machine to track `invoke` and `return` markers to calculate the current depth. +- If your RPC is recursive (like Starknet), you must flatten the tree while incrementing the depth at each level. + +### Step D: Unit Normalization +Decide how to weight your native instructions. For example, in Solana, we use the `Compute Unit` directly as the `gas_cost`. In Starknet, we use the `steps` count. + +--- + +## 3. Registering the Adapter + +Once your crate is ready: +1. Add it to the workspace `Cargo.toml`. +2. Update the CLI router in `bin/atupa/src/main.rs`. +3. Add color tokens to `atupa-output` (SVG) and `Atupa Studio` (TypeScript). + +### CLI Dispatch Pattern: +In `cmd_capture` and `cmd_diff`, use the RPC URL signature to auto-detect the correct adapter: + +```rust +if config.rpc_url.contains("mynewvm") { + let client = atupa_mynewvm::MyVMClient::new(config.rpc_url.clone()); + // ... logic +} +``` + +--- + +## 4. Testing Your Adapter +Create a small integration test in your crate using a mock or a saved JSON sample of a real transaction trace. Ensure that: +- Total `gas_cost` matches the expected value. +- Maximum `depth` is correctly calculated. +- All steps have the correct `vm_kind`. + +--- +๐Ÿฎ *Build the future of observability with Atupa.* 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/studio/src/components/FlameGraph.tsx b/studio/src/components/FlameGraph.tsx index f411840..56e87bb 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', @@ -48,11 +57,21 @@ interface TooltipState { } 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'; + 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 @@ -167,6 +186,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; } @@ -371,8 +396,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 }) => ( diff --git a/studio/src/types/trace.ts b/studio/src/types/trace.ts index 36d184c..5908606 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' From 8aef220128fcd1439317c5e44e2b0935f9e6c52e Mon Sep 17 00:00:00 2001 From: intelliDean Date: Mon, 4 May 2026 18:17:48 +0100 Subject: [PATCH 02/37] chore: fix clippy warnings and run fmt --- bin/atupa/src/main.rs | 170 +++++++++++++++++++++---------- crates/atupa-output/src/lib.rs | 8 +- crates/atupa-sdk/src/lib.rs | 30 ++++-- crates/atupa-solana/src/lib.rs | 46 +++++---- crates/atupa-starknet/src/lib.rs | 66 ++++++++---- crates/atupa-stellar/src/lib.rs | 14 ++- 6 files changed, 225 insertions(+), 109 deletions(-) diff --git a/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index ee91eba..da6e0da 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -355,10 +355,9 @@ async fn cmd_capture( if config.rpc_url.contains("starknet") { let pb = spinner("Detecting Starknet network and fetching execution traceโ€ฆ"); let client = atupa_starknet::StarknetClient::new(config.rpc_url.clone()); - let steps = client - .profile_transaction(&tx) - .await - .context("Failed to fetch Starknet trace โ€” ensure the RPC endpoint is valid and accessible.")?; + 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)", @@ -395,7 +394,10 @@ async fn cmd_capture( let pb2 = spinner("Rendering reportโ€ฆ"); let rendered = match format { - OutputFormat::Summary => format!("Starknet trace captured successfully with {} steps.", steps.len()), + OutputFormat::Summary => format!( + "Starknet trace captured successfully with {} steps.", + steps.len() + ), OutputFormat::Json => serde_json::to_string_pretty(&steps)?, OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), }; @@ -431,10 +433,9 @@ async fn cmd_capture( if config.rpc_url.contains("solana") { let pb = spinner("Detecting Solana network and fetching execution traceโ€ฆ"); let client = atupa_solana::SolanaClient::new(config.rpc_url.clone()); - let logs = client - .get_transaction_logs(&tx) - .await - .context("Failed to fetch Solana logs โ€” ensure the RPC endpoint is valid and accessible.")?; + 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); @@ -473,7 +474,10 @@ async fn cmd_capture( let pb2 = spinner("Rendering reportโ€ฆ"); let rendered = match format { - OutputFormat::Summary => format!("Solana trace reconstructed successfully with {} steps.", steps.len()), + OutputFormat::Summary => format!( + "Solana trace reconstructed successfully with {} steps.", + steps.len() + ), OutputFormat::Json => serde_json::to_string_pretty(&steps)?, OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), }; @@ -549,7 +553,10 @@ async fn cmd_capture( let pb2 = spinner("Rendering reportโ€ฆ"); let rendered = match format { - OutputFormat::Summary => format!("Stellar trace reconstructed successfully with {} host function calls.", steps.len()), + OutputFormat::Summary => format!( + "Stellar trace reconstructed successfully with {} host function calls.", + steps.len() + ), OutputFormat::Json => serde_json::to_string_pretty(&steps)?, OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), }; @@ -836,14 +843,24 @@ async fn cmd_diff( 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")?; + ) + .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); - - return process_generic_diff("Solana", "Compute Units", &base, &target, base_steps, target_steps, svg, threshold); + + return process_generic_diff(GenericDiffArgs { + network_name: "Solana", + unit_name: "Compute Units", + base_tx: &base, + target_tx: &target, + base_steps, + target_steps, + svg, + threshold, + }); } if config.rpc_url.contains("starknet") { @@ -852,11 +869,21 @@ async fn cmd_diff( let (base_steps, target_steps) = tokio::try_join!( starknet_client.profile_transaction(&base), starknet_client.profile_transaction(&target), - ).context("Failed to fetch Starknet traces")?; + ) + .context("Failed to fetch Starknet traces")?; pb.finish_with_message(format!("{} Both traces fetched.", "โœ”".green().bold())); eprintln!(); - - return process_generic_diff("Starknet Cairo", "Gas-Equivalent Steps", &base, &target, base_steps, target_steps, svg, threshold); + + return 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, + }); } if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { @@ -865,11 +892,21 @@ async fn cmd_diff( 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")?; + ) + .context("Failed to fetch Stellar traces")?; pb.finish_with_message(format!("{} Both traces fetched.", "โœ”".green().bold())); eprintln!(); - - return process_generic_diff("Stellar Soroban", "HostFn Weight", &base, &target, base_steps, target_steps, svg, threshold); + + return process_generic_diff(GenericDiffArgs { + network_name: "Stellar Soroban", + unit_name: "HostFn Weight", + base_tx: &base, + target_tx: &target, + base_steps, + target_steps, + svg, + threshold, + }); } let pb = spinner("Fetching both traces and receipts concurrentlyโ€ฆ"); @@ -1266,29 +1303,44 @@ async fn cmd_diff( Ok(()) } -fn process_generic_diff( - network_name: &str, - unit_name: &str, - base_tx: &str, - target_tx: &str, +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, -) -> Result<()> { - let base_cost = base_steps.iter().map(|s| s.gas_cost).sum::(); - let target_cost = target_steps.iter().map(|s| s.gas_cost).sum::(); +} + +fn process_generic_diff(args: GenericDiffArgs) -> Result<()> { + 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 cost_pct = if base_cost > 0 { + cost_delta / base_cost as f64 * 100.0 + } else { + 0.0 + }; - let base_count = base_steps.len(); - let target_count = target_steps.len(); + 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 }; + let count_pct = if base_count > 0 { + count_delta / base_count as f64 * 100.0 + } else { + 0.0 + }; let div = "โ”€".repeat(70).dimmed().to_string(); - println!("{}", format!(" {network_name} EXECUTION DIFF").bold().underline()); + println!( + "{}", + format!(" {} EXECUTION DIFF", args.network_name) + .bold() + .underline() + ); println!("{div}"); println!( " {:<25} {:<15} {:<15} {}", @@ -1302,17 +1354,23 @@ fn process_generic_diff( 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() + 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() + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .green() + .to_string() } else { - format!("{sign}{delta:.0} ({sign}{pct:.1}%)").dimmed().to_string() + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + .dimmed() + .to_string() } }; println!( " {:<25} {:<15} {:<15} {}", - format!("Total {unit_name}:"), + format!("Total {}:", args.unit_name), base_cost.to_string().cyan(), target_cost.to_string().cyan(), colorize_delta(cost_delta, cost_pct) @@ -1328,27 +1386,34 @@ fn process_generic_diff( println!("{div}\n"); let mut failures = Vec::new(); - if let Some(t) = threshold { - if cost_pct > t { - failures.push(format!( - "Total {unit_name} increased by {cost_pct:.1}% (limit: {t:.1}%)" - )); - } + if let Some(t) = args.threshold.filter(|&t| cost_pct > t) { + failures.push(format!( + "Total {} increased by {cost_pct:.1}% (limit: {t:.1}%)", + args.unit_name + )); } - if svg { + if args.svg { let pb_svg = spinner("Generating diff flamegraphโ€ฆ"); - let base_norm = TraceParser::normalize_raw(base_steps); - let target_norm = TraceParser::normalize_raw(target_steps); + let base_norm = TraceParser::normalize_raw(args.base_steps); + let target_norm = TraceParser::normalize_raw(args.target_steps); let base_stacks = Aggregator::build_collapsed_stacks(&base_norm); let target_stacks = Aggregator::build_collapsed_stacks(&target_norm); 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", &base_tx[..10], &target_tx[..10]); + let out_path = format!( + "artifacts/diff/{}_vs_{}.svg", + &args.base_tx[..10], + &args.target_tx[..10] + ); 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())); + pb_svg.finish_with_message(format!( + "{} Diff SVG saved โ†’ {}", + "โœ”".green().bold(), + out_path.cyan() + )); } if !failures.is_empty() { @@ -1356,8 +1421,11 @@ fn process_generic_diff( for f in failures.iter() { println!(" - {}", f.red()); } - return Err(anyhow::anyhow!("{network_name} regression thresholds exceeded")); - } else if threshold.is_some() { + 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() diff --git a/crates/atupa-output/src/lib.rs b/crates/atupa-output/src/lib.rs index bdadc56..2a3bd10 100644 --- a/crates/atupa-output/src/lib.rs +++ b/crates/atupa-output/src/lib.rs @@ -67,8 +67,10 @@ impl SvgGenerator { 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::Stylus).collect(); + 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) @@ -208,7 +210,7 @@ impl SvgGenerator { let has_starknet = evm_stacks.iter().any(|s| s.vm_kind == VmKind::Starknet); 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, diff --git a/crates/atupa-sdk/src/lib.rs b/crates/atupa-sdk/src/lib.rs index de4852b..481bc6d 100644 --- a/crates/atupa-sdk/src/lib.rs +++ b/crates/atupa-sdk/src/lib.rs @@ -63,12 +63,12 @@ pub mod profile { use anyhow::Result; use atupa_core::{CollapsedStack, VmKind}; use atupa_nitro::{NitroClient, VmKind as NitroVmKind}; - use atupa_starknet::StarknetClient; - use atupa_solana::{SolanaClient, SolanaLogStitcher}; - use atupa_stellar::StellarClient; 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}; @@ -92,15 +92,17 @@ pub mod profile { (demo_stacks(), "Demo".to_string()) } else { pb.set_message("Detecting network and fetching execution traceโ€ฆ"); - + // Heuristic-based client selection // In a production version, we would perform a chainId probe or use explicit flags. if rpc.contains("starknet") || tx.len() > 66 { pb.set_message("Starknet node detected. Fetching Cairo VM traceโ€ฆ"); let client = StarknetClient::new(rpc.to_string()); - let steps = client.profile_transaction(tx).await + 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); (combined, "Starknet".to_string()) @@ -108,9 +110,11 @@ pub mod profile { // Solana signatures are base58 and ~44-88 chars 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 + 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); @@ -119,9 +123,11 @@ pub mod profile { // Stellar hashes are 64 hex chars 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 + 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); (combined, "Stellar".to_string()) @@ -131,7 +137,9 @@ pub mod profile { 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}?") + anyhow::anyhow!( + "RPC timed out after 30s โ€” is the node reachable at {rpc}?" + ) })? .map_err(|e| anyhow::anyhow!("RPC error: {e}"))?; diff --git a/crates/atupa-solana/src/lib.rs b/crates/atupa-solana/src/lib.rs index e4d2316..6bf33ec 100644 --- a/crates/atupa-solana/src/lib.rs +++ b/crates/atupa-solana/src/lib.rs @@ -10,7 +10,10 @@ static INVOKE_REGEX: OnceLock = OnceLock::new(); static CONSUMED_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+)\]").unwrap()) + INVOKE_REGEX.get_or_init(|| { + Regex::new(r"Program (?P[1-9A-HJ-NP-Za-km-z]{32,44}) invoke \[(?P\d+)\]") + .unwrap() + }) } fn get_consumed_regex() -> &'static Regex { @@ -19,7 +22,10 @@ fn get_consumed_regex() -> &'static Regex { static RETURN_REGEX: OnceLock = OnceLock::new(); 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)").unwrap()) + RETURN_REGEX.get_or_init(|| { + Regex::new(r"Program (?P[1-9A-HJ-NP-Za-km-z]{32,44}) (?Psuccess|failed)") + .unwrap() + }) } // โ”€โ”€โ”€ Solana RPC Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -54,16 +60,16 @@ impl SolanaLogStitcher { /// Reconstructs a trace timeline from raw Solana log strings. pub fn parse_logs(logs: &[String]) -> Vec { let mut steps = Vec::new(); - + struct ActiveFrame { addr: String, start_idx: usize, total_cu: u64, children_cu: u64, } - + 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(); @@ -72,9 +78,9 @@ impl SolanaLogStitcher { if let Some(caps) = invoke_re.captures(log) { 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), @@ -87,7 +93,7 @@ impl SolanaLogStitcher { reverted: false, vm_kind: VmKind::Solana, }); - + active_frames.push(ActiveFrame { addr, start_idx: steps.len() - 1, @@ -97,7 +103,7 @@ impl SolanaLogStitcher { } else if let Some(caps) = consumed_re.captures(log) { let addr = caps["addr"].to_string(); let cu: u64 = caps["cu"].parse().unwrap_or(0); - + // Match the consumed log to the current active frame for this address if let Some(frame) = active_frames.iter_mut().rev().find(|f| f.addr == addr) { frame.total_cu = cu; @@ -105,15 +111,15 @@ impl SolanaLogStitcher { } else if let Some(caps) = return_re.captures(log) { let addr = caps["addr"].to_string(); let status = &caps["status"]; - + // Pop frames until we find the matching address // This handles cases where intermediate frames failed without a clear return log while let Some(frame) = active_frames.pop() { let is_match = frame.addr == addr; - + let exclusive_cu = frame.total_cu.saturating_sub(frame.children_cu); steps[frame.start_idx].gas_cost = exclusive_cu; - + if is_match && status == "failed" { steps[frame.start_idx].reverted = true; } @@ -121,14 +127,14 @@ impl SolanaLogStitcher { if let Some(parent) = active_frames.last_mut() { parent.children_cu += frame.total_cu; } - + if is_match { break; } } } } - + steps } } @@ -167,14 +173,18 @@ impl SolanaClient { if let Some(error) = response.get("error") { return Err(SolanaError::Rpc(RpcError::Node( - error["message"].as_str().unwrap_or("Unknown RPC error").to_string(), + 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 + result + .meta .and_then(|m| m.log_messages) .ok_or_else(|| SolanaError::Parse("No log messages found in transaction".into())) } @@ -198,12 +208,12 @@ mod tests { 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 - + // Step 1 is the child assert_eq!(steps[1].op, "INVOKE:Tokenkeg"); assert_eq!(steps[1].depth, 2); diff --git a/crates/atupa-starknet/src/lib.rs b/crates/atupa-starknet/src/lib.rs index 8714365..d666c33 100644 --- a/crates/atupa-starknet/src/lib.rs +++ b/crates/atupa-starknet/src/lib.rs @@ -69,7 +69,10 @@ impl StarknetClient { } } - pub async fn get_transaction_trace(&self, tx_hash: &str) -> Result { + pub async fn get_transaction_trace( + &self, + tx_hash: &str, + ) -> Result { let payload = json!({ "jsonrpc": "2.0", "method": "starknet_traceTransaction", @@ -88,7 +91,10 @@ impl StarknetClient { if let Some(error) = response.get("error") { return Err(StarknetError::Rpc(RpcError::Node( - error["message"].as_str().unwrap_or("Unknown RPC error").to_string(), + error["message"] + .as_str() + .unwrap_or("Unknown RPC error") + .to_string(), ))); } @@ -106,7 +112,7 @@ impl StarknetClient { // 1. Map execution resources to virtual "opcodes" for Atupa aggregation // In Starknet, we don't have individual opcodes in the RPC trace (usually), // but we have aggregated resources per call frame. - + // Root step for this call frame let selector_label = if invocation.entry_point_selector.len() > 12 { &invocation.entry_point_selector[0..12] @@ -115,8 +121,7 @@ impl StarknetClient { }; // For target resolution, we can add the contract_address to the stack - let mut stack_info = Vec::new(); - stack_info.push(invocation.contract_address.clone()); + let stack_info = vec![invocation.contract_address.clone()]; steps.push(TraceStep { pc: 0, @@ -149,10 +154,26 @@ impl StarknetClient { } }; - add_builtin("PEDERSEN", invocation.execution_resources.pedersen_builtin, 32); - add_builtin("RANGE_CHECK", invocation.execution_resources.range_check_builtin, 16); - add_builtin("BITWISE", invocation.execution_resources.bitwise_builtin, 64); - add_builtin("POSEIDON", invocation.execution_resources.poseidon_builtin, 32); + add_builtin( + "PEDERSEN", + invocation.execution_resources.pedersen_builtin, + 32, + ); + add_builtin( + "RANGE_CHECK", + invocation.execution_resources.range_check_builtin, + 16, + ); + add_builtin( + "BITWISE", + invocation.execution_resources.bitwise_builtin, + 64, + ); + add_builtin( + "POSEIDON", + invocation.execution_resources.poseidon_builtin, + 32, + ); add_builtin("EC_OP", invocation.execution_resources.ec_op_builtin, 1024); add_builtin("ECDSA", invocation.execution_resources.ecdsa_builtin, 2048); @@ -164,7 +185,10 @@ impl StarknetClient { steps } - pub async fn profile_transaction(&self, tx_hash: &str) -> Result, StarknetError> { + pub async fn profile_transaction( + &self, + tx_hash: &str, + ) -> Result, StarknetError> { let trace = self.get_transaction_trace(tx_hash).await?; let mut all_steps = Vec::new(); @@ -200,18 +224,16 @@ mod tests { 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![], - } - ], + 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 client = StarknetClient::new("http://localhost".to_string()); diff --git a/crates/atupa-stellar/src/lib.rs b/crates/atupa-stellar/src/lib.rs index c3db413..af49a79 100644 --- a/crates/atupa-stellar/src/lib.rs +++ b/crates/atupa-stellar/src/lib.rs @@ -52,7 +52,7 @@ impl StellarTraceParser { // In Soroban, diagnostic events for host calls often look like: // topics: ["fn_call", "invoke_contract"] // or ["fn_return", "invoke_contract"] - + 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"); @@ -108,7 +108,10 @@ impl StellarClient { } } - pub async fn get_transaction_trace(&self, tx_hash: &str) -> Result, StellarError> { + pub async fn get_transaction_trace( + &self, + tx_hash: &str, + ) -> Result, StellarError> { let payload = json!({ "jsonrpc": "2.0", "method": "getTransaction", @@ -127,7 +130,10 @@ impl StellarClient { if let Some(error) = response.get("error") { return Err(StellarError::Rpc(RpcError::Node( - error["message"].as_str().unwrap_or("Unknown RPC error").to_string(), + error["message"] + .as_str() + .unwrap_or("Unknown RPC error") + .to_string(), ))); } @@ -165,7 +171,7 @@ mod tests { 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, 1500); From da2b77be887c30739aa155c686258090da6195d9 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Mon, 4 May 2026 18:27:46 +0100 Subject: [PATCH 03/37] fix: resolve TS2304 error in Studio FlameGraph tooltip --- studio/src/components/FlameGraph.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/studio/src/components/FlameGraph.tsx b/studio/src/components/FlameGraph.tsx index 56e87bb..99e6dd6 100644 --- a/studio/src/components/FlameGraph.tsx +++ b/studio/src/components/FlameGraph.tsx @@ -56,7 +56,10 @@ interface TooltipState { node: FlameNode; } -function Tooltip({ tip }: { tip: TooltipState }) { +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', @@ -102,7 +105,7 @@ function Tooltip({ tip }: { tip: TooltipState }) { 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}%) @@ -449,7 +452,7 @@ export function FlameGraph({ root, search = '' }: Props) { /> ))} - {tooltip && } + {tooltip && }
From 9d163b2852d7acd65de549db756c43ba66ae3d61 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Mon, 4 May 2026 21:38:19 +0100 Subject: [PATCH 04/37] Refactor Atupa CLI core logic - Extracted network-specific handlers from monolithic routers (cmd_capture, cmd_diff). - Modularized Nitro diff processing (calculate, print, render markdown, svg). - Modularized generic diff processing and rendering terminal summaries. - Fixed Clippy warnings (too_many_arguments, collapsible if). - Fully formatted the codebase. --- bin/atupa/src/main.rs | 2153 ++++++++++++++++++++++------------------- 1 file changed, 1167 insertions(+), 986 deletions(-) diff --git a/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index da6e0da..1234c16 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -42,10 +42,10 @@ use thresholds::AtupaConfigToml; #[command( name = "atupa", bin_name = "atupa", - about = "๐Ÿฎ Atupa โ€” Unified Ethereum & Stylus Execution Profiler", + about = "๐Ÿฎ Atupa โ€” Universal Multi-VM 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\ +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 )] @@ -351,370 +351,15 @@ async fn cmd_capture( eprintln!("{} {}", "โ†’ Transaction:".bold(), tx.cyan()); eprintln!("{} {}\n", "โ†’ Endpoint: ".bold(), config.rpc_url.dimmed()); - // Phase 1: fetch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if config.rpc_url.contains("starknet") { - let pb = spinner("Detecting Starknet network and fetching execution traceโ€ฆ"); - let client = atupa_starknet::StarknetClient::new(config.rpc_url.clone()); - 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 mut svg_path: Option = None; - if generate_profile { - let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); - let normalized = TraceParser::normalize_raw(steps.clone()); - 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); - } - - let pb2 = spinner("Rendering reportโ€ฆ"); - let rendered = match format { - OutputFormat::Summary => format!( - "Starknet trace captured successfully with {} steps.", - steps.len() - ), - OutputFormat::Json => serde_json::to_string_pretty(&steps)?, - OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), - }; - pb2.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); - - eprintln!(); - if format == OutputFormat::Summary { - println!("{}", rendered); - } - eprintln!(); - - 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() - ); - } - - return Ok(Some(report_path)); - } - - if config.rpc_url.contains("solana") { - let pb = spinner("Detecting Solana network and fetching execution traceโ€ฆ"); - let client = atupa_solana::SolanaClient::new(config.rpc_url.clone()); - 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 mut svg_path: Option = None; - if generate_profile { - let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); - let normalized = TraceParser::normalize_raw(steps.clone()); - 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); - } - - let pb2 = spinner("Rendering reportโ€ฆ"); - let rendered = match format { - OutputFormat::Summary => format!( - "Solana trace reconstructed successfully with {} steps.", - steps.len() - ), - OutputFormat::Json => serde_json::to_string_pretty(&steps)?, - OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), - }; - pb2.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); - - eprintln!(); - if format == OutputFormat::Summary { - println!("{}", rendered); - } - eprintln!(); - - 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() - ); - } - - return Ok(Some(report_path)); - } - - if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { - let pb = spinner("Detecting Stellar network and fetching diagnostic eventsโ€ฆ"); - let client = atupa_stellar::StellarClient::new(config.rpc_url.clone()); - 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 mut svg_path: Option = None; - if generate_profile { - let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); - let normalized = TraceParser::normalize_raw(steps.clone()); - 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); - } - - let pb2 = spinner("Rendering reportโ€ฆ"); - let rendered = match format { - OutputFormat::Summary => format!( - "Stellar trace reconstructed successfully with {} host function calls.", - steps.len() - ), - OutputFormat::Json => serde_json::to_string_pretty(&steps)?, - OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), - }; - pb2.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); - - eprintln!(); - if format == OutputFormat::Summary { - println!("{}", rendered); - } - eprintln!(); - - 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() - ); - } - - return Ok(Some(report_path)); - } - - 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), + let report_path = if config.rpc_url.contains("starknet") { + handle_starknet_capture(&config.rpc_url, &tx, format, file, generate_profile).await? + } else if config.rpc_url.contains("solana") { + handle_solana_capture(&config.rpc_url, &tx, format, file, generate_profile).await? + } else if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { + handle_stellar_capture(&config.rpc_url, &tx, format, file, generate_profile).await? + } else { + handle_nitro_capture(config, &tx, format, file, generate_profile).await? }; - 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)) } @@ -834,127 +479,93 @@ async fn cmd_diff( ); 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()); - if config.rpc_url.contains("solana") { - let solana_client = atupa_solana::SolanaClient::new(config.rpc_url.clone()); - let pb = 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); - - return process_generic_diff(GenericDiffArgs { - network_name: "Solana", - unit_name: "Compute Units", - base_tx: &base, - target_tx: &target, - base_steps, - target_steps, - svg, + handle_solana_diff(&config.rpc_url, &base, &target, threshold, svg).await?; + } else if config.rpc_url.contains("starknet") { + handle_starknet_diff(&config.rpc_url, &base, &target, threshold, svg).await?; + } else if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { + handle_stellar_diff(&config.rpc_url, &base, &target, threshold, svg).await?; + } else { + handle_nitro_diff( + config, + &base, + &target, threshold, - }); - } - - if config.rpc_url.contains("starknet") { - let starknet_client = atupa_starknet::StarknetClient::new(config.rpc_url.clone()); - let pb = 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!(); - - return process_generic_diff(GenericDiffArgs { - network_name: "Starknet Cairo", - unit_name: "Gas-Equivalent Steps", - base_tx: &base, - target_tx: &target, - base_steps, - target_steps, + diff_config, + markdown, svg, - threshold, - }); - } - - if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { - let stellar_client = atupa_stellar::StellarClient::new(config.rpc_url.clone()); - let pb = 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), + output_format, + protocol, ) - .context("Failed to fetch Stellar traces")?; - pb.finish_with_message(format!("{} Both traces fetched.", "โœ”".green().bold())); - eprintln!(); - - return process_generic_diff(GenericDiffArgs { - network_name: "Stellar Soroban", - unit_name: "HostFn Weight", - base_tx: &base, - target_tx: &target, - base_steps, - target_steps, - svg, - threshold, - }); + .await?; } - 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")?; + Ok(()) +} - // 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), - ); +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, +} - pb.finish_with_message(format!("{} Both traces fetched.", "โœ”".green().bold())); - eprintln!(); +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, +} - // 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 +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_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 + 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 }; - 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); + 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!("{}", " EXECUTION DIFF".bold().underline()); + println!( + "{}", + format!(" {} EXECUTION DIFF", args.network_name) + .bold() + .underline() + ); println!("{div}"); - - // Print Table Header println!( " {:<25} {:<15} {:<15} {}", "Metric".bold(), @@ -983,437 +594,65 @@ async fn cmd_diff( 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) + 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 Gas (EVM):", - base_unified_cost.to_string().cyan(), - target_unified_cost.to_string().cyan(), - colorize_delta(unified_delta, unified_pct) + "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"); +} - 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) +fn evaluate_generic_thresholds(args: &GenericDiffArgs, data: &GenericDiffData) -> Vec { + let mut failures = Vec::new(); + if let Some(t) = args.threshold.filter(|&t| data.cost_pct > t) { + failures.push(format!( + "Total {} increased by {:.1}% (limit: {:.1}%)", + args.unit_name, data.cost_pct, t + )); + } + failures +} + +fn generate_generic_diff_svg(args: &GenericDiffArgs) -> Result<()> { + let pb_svg = 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 base_stacks = Aggregator::build_collapsed_stacks(&base_norm); + let target_stacks = Aggregator::build_collapsed_stacks(&target_norm); + + 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], + &args.target_tx[..10] ); + 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(()) +} - println!("{div}"); +fn process_generic_diff(args: GenericDiffArgs) -> Result<()> { + let data = calculate_generic_diff_data(&args); + print_generic_diff_summary(&args, &data); - // 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(()) -} - -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, -} - -fn process_generic_diff(args: GenericDiffArgs) -> Result<()> { - 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 - }; - - 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), - base_cost.to_string().cyan(), - target_cost.to_string().cyan(), - colorize_delta(cost_delta, cost_pct) - ); - - println!( - " {:<25} {:<15} {:<15} {}", - "Execution Steps:", - base_count.to_string().green(), - target_count.to_string().yellow(), - colorize_delta(count_delta, count_pct) - ); - println!("{div}\n"); - - let mut failures = Vec::new(); - if let Some(t) = args.threshold.filter(|&t| cost_pct > t) { - failures.push(format!( - "Total {} increased by {cost_pct:.1}% (limit: {t:.1}%)", - args.unit_name - )); - } + let failures = evaluate_generic_thresholds(&args, &data); if args.svg { - let pb_svg = spinner("Generating diff flamegraphโ€ฆ"); - let base_norm = TraceParser::normalize_raw(args.base_steps); - let target_norm = TraceParser::normalize_raw(args.target_steps); - let base_stacks = Aggregator::build_collapsed_stacks(&base_norm); - let target_stacks = Aggregator::build_collapsed_stacks(&target_norm); - - 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], - &args.target_tx[..10] - ); - 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() - )); + generate_generic_diff_svg(&args)?; } if !failures.is_empty() { @@ -1536,19 +775,44 @@ fn hostio_category_color(label: &str) -> &'static str { } 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", + " {} ({})\n{}\n", "UNIFIED EXECUTION SUMMARY".bold().underline(), - get_network_name(report.chain_id).cyan() + 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 +} - // โ”€โ”€ Gas totals with Execution vs Intrinsic split โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +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); @@ -1595,118 +859,115 @@ fn render_capture_summary(report: &StitchedReport) -> String { report.vm_boundary_count.to_string().magenta() ); } + out +} + +fn render_stylus_summary( + report: &StitchedReport, + stylus: &[&atupa_nitro::UnifiedStep], + div: &str, +) -> String { + let mut out = String::new(); + 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!("{div}\n"); out += &format!( " {:<34} {}\n", - "TOTAL UNIFIED COST:".bold().cyan(), - format!("{:.2} gas", report.total_unified_cost) - .cyan() - .bold() + "Stylus HostIO Calls:".bold(), + stylus.len().to_string().yellow() ); - out += &format!("{div}\n"); - - // EVM step count always shown out += &format!( " {:<34} {}\n", - "EVM Steps:".bold(), - evm_count(report).to_string().green() + "Unique HostIO Paths:".bold(), + unique_paths.to_string().yellow() ); - // 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); + 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!( - " โ”ƒ {color}{:<42}{RESET} โ”ƒ {gas_str:>10} โ”ƒ {cost_ink:>14} โ”ƒ {pct:>6.1}% โ”ƒ\n", - label, + " {} {} at depth {}\n", + format!("[{}]", i + 1).cyan(), + step.label.bold(), + step.depth.to_string().dimmed() ); } - 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); + if report.vm_boundary_count > 5 { out += &format!( - " โ””โ”€ {color}{:<20}{RESET} {color}{:<50}{RESET} {:>5.1}%\n", - label, bar, pct + " โ€ฆ and {} more\n", + (report.vm_boundary_count - 5).to_string().dimmed() ); } - if unique_paths > 10 { - out += &format!("\n ({} of {} unique paths shown)\n", 10, unique_paths); - } + } + + 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"); - out += &format!("{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 +} - out += &format!(" tx {}\n", report.tx_hash.dimmed()); +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 } @@ -1849,6 +1110,926 @@ fn print_lido_report( ); println!("{div}"); } +/// Unified helper to generate an SVG flamegraph and save it. +fn generate_and_save_svg( + steps: &[atupa_core::TraceStep], + tx: &str, + file_option: &Option, +) -> Result { + let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); + let normalized = TraceParser::normalize_raw(steps.to_vec()); + let stacks = Aggregator::build_collapsed_stacks(&normalized); + 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) +} + +/// Helper to save the report to disk and print the final summary. +fn finalize_report( + rendered: &str, + format: &OutputFormat, + file_option: Option, + tx: &str, + svg_path: Option, +) -> Result { + eprintln!(); + if *format == OutputFormat::Summary { + println!("{}", rendered); + } + eprintln!(); + + let report_path = resolve_artifact_path(file_option, "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(svg) = svg_path { + eprintln!( + "{} SVG profile saved to {}", + "โœ”".green().bold(), + svg.cyan().bold() + ); + } + + Ok(report_path) +} + +/// Generic handler for Starknet traces +async fn handle_starknet_capture( + rpc_url: &str, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = 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 = spinner("Rendering reportโ€ฆ"); + let rendered = match format { + OutputFormat::Summary => format!( + "Starknet trace captured successfully with {} steps.", + steps.len() + ), + OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + }; + pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + + finalize_report(&rendered, &format, file, tx, svg_path) +} + +/// Generic handler for Solana traces +async fn handle_solana_capture( + rpc_url: &str, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = 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 = spinner("Rendering reportโ€ฆ"); + let rendered = match format { + OutputFormat::Summary => format!( + "Solana trace reconstructed successfully with {} steps.", + steps.len() + ), + OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + }; + pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + + finalize_report(&rendered, &format, file, tx, svg_path) +} + +/// Generic handler for Soroban (Stellar) traces +async fn handle_stellar_capture( + rpc_url: &str, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + let pb = 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 = spinner("Rendering reportโ€ฆ"); + let rendered = match format { + OutputFormat::Summary => format!( + "Stellar trace reconstructed successfully with {} host function calls.", + steps.len() + ), + OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + }; + pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + + finalize_report(&rendered, &format, file, tx, svg_path) +} + +/// Orchestrates the multi-VM capture for Arbitrum Nitro / EVM +async fn handle_nitro_capture( + config: &AtupaConfig, + tx: &str, + format: OutputFormat, + file: Option, + generate_profile: bool, +) -> Result { + 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() { + resolve_names_via_etherscan(&mut report, &key).await?; + } + + // Phase 2: 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 = render_nitro_report(&report, &format)?; + finalize_report(&rendered, &format, file, tx, svg_path) +} + +async fn resolve_names_via_etherscan( + report: &mut StitchedReport, + etherscan_key: &str, +) -> Result<()> { + let pb_names = spinner("Resolving contract names via Etherscanโ€ฆ"); + let resolver = atupa_rpc::etherscan::EtherscanResolver::new( + Some(etherscan_key.to_string()), + 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() + )); + Ok(()) +} + +fn render_nitro_report(report: &StitchedReport, format: &OutputFormat) -> Result { + let pb_render = spinner("Rendering reportโ€ฆ"); + let summary_text = render_capture_summary(report); + + let rendered = match format { + OutputFormat::Summary => summary_text, + OutputFormat::Json => serde_json::to_string_pretty(report)?, + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), + }; + pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); + Ok(rendered) +} + +/// Helper data for Nitro/EVM diff calculation +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, +} + +/// Handler for Solana execution diffing +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 = 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, + }) +} + +/// Handler for Starknet execution diffing +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 = 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, + }) +} + +/// Handler for Stellar/Soroban execution diffing +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 = 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, + }) +} + +/// Orchestrates the Nitro/EVM diffing process +#[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 = 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.iter() { + 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: &[atupa_core::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### ๐Ÿ”ฌ {} Protocol Deep Diff\n\n", proto_name)); + 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], + &data.target_tx[..10] + ); + 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: &[atupa_core::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 base_stacks = Aggregator::build_collapsed_stacks(&TraceParser::normalize_raw(base_steps)); + + let target_steps: Vec = data + .target_report + .steps + .iter() + .map(|s| s.to_trace_step()) + .collect(); + let target_stacks = + Aggregator::build_collapsed_stacks(&TraceParser::normalize_raw(target_steps)); + + let svg_content = atupa_output::generate_diff_flamegraph(&base_stacks, &target_stacks)?; + let out_path = format!( + "artifacts/diff/{}_vs_{}.svg", + &data.base_tx[..10], + &data.target_tx[..10] + ); + 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 = if let Some(path) = diff_config { + AtupaConfigToml::load(std::path::Path::new(&path)).ok() + } else { + AtupaConfigToml::auto_load() + }; + + if let Some(t) = threshold { + if data.total_gas_pct > t { + failures.push(format!( + "Total Gas increased by {:.1}% (limit: {:.1}%)", + data.total_gas_pct, t + )); + } + } else if let Some(ref cfg) = config_toml + && let Some(diff_cfg) = &cfg.diff + { + if let Some(max_total) = diff_cfg.max_total_gas_increase_percent + && data.total_gas_pct > max_total + { + failures.push(format!( + "Total Gas increased by {:.1}% (limit: {:.1}%)", + data.total_gas_pct, max_total + )); + } + if let Some(max_exec) = diff_cfg.max_execution_gas_increase_percent + && data.unified_pct > max_exec + { + failures.push(format!( + "Execution Gas increased by {:.1}% (limit: {:.1}%)", + data.unified_pct, max_exec + )); + } + if let Some(max_evm) = diff_cfg.max_evm_steps_increase + && data.evm_delta > max_evm as f64 + { + failures.push(format!( + "EVM Steps increased by {:.0} (limit: {})", + data.evm_delta, max_evm + )); + } + if let Some(max_stylus) = diff_cfg.max_stylus_calls_increase + && data.stylus_delta > max_stylus as f64 + { + failures.push(format!( + "Stylus Calls increased by {:.0} (limit: {})", + data.stylus_delta, max_stylus + )); + } + } + failures +} // โ”€โ”€โ”€ Shared Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From dae94049ba8af96cf2f987972968d44b0a07820b Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 6 May 2026 14:03:54 +0100 Subject: [PATCH 05/37] fix(capture): report file always writes clean JSON regardless of --output format Previously, finalize_report() unconditionally wrote the 'rendered' string to the .json artifact file. When --output=summary (the default), rendered contained ANSI terminal escape codes, making the file unreadable by Studio, CI tooling, and any downstream JSON parser. Fix: introduce a separate json_for_disk parameter that always holds the full serde_json-serialised report. The terminal-rendered string (with ANSI codes, short summaries, metric values) is printed to stdout only. All four capture handlers (nitro, starknet, solana, stellar) updated. render_nitro_report now returns (terminal_text, json) as a tuple. --- bin/atupa/Cargo.toml | 2 +- bin/atupa/src/main.rs | 49 +++++++++++++++++++++++++++++-------------- studio/vite.config.ts | 7 ------- 3 files changed, 34 insertions(+), 24 deletions(-) delete mode 100644 studio/vite.config.ts diff --git a/bin/atupa/Cargo.toml b/bin/atupa/Cargo.toml index 7738f8c..c1a4eee 100644 --- a/bin/atupa/Cargo.toml +++ b/bin/atupa/Cargo.toml @@ -3,7 +3,7 @@ name = "atupa" version = { workspace = true } edition = { 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 diff --git a/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index 1234c16..c077be0 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -1141,21 +1141,30 @@ fn generate_and_save_svg( } /// Helper to save the report to disk and print the final summary. +/// +/// `rendered` โ€” the terminal-facing string (may contain ANSI escape codes for +/// Summary format). Printed to stdout; never written to disk. +/// `json_for_disk` โ€” always a clean, machine-readable JSON payload that is written +/// to the `.json` artifact file regardless of the `--output` flag. +/// Studio, CI diffing, and any downstream tooling read this file. fn finalize_report( rendered: &str, format: &OutputFormat, + json_for_disk: &str, file_option: Option, tx: &str, svg_path: Option, ) -> Result { eprintln!(); - if *format == OutputFormat::Summary { - println!("{}", rendered); + 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, rendered) + std::fs::write(&report_path, json_for_disk) .with_context(|| format!("Failed to write report to '{report_path}'"))?; eprintln!( @@ -1202,17 +1211,18 @@ async fn handle_starknet_capture( }; let pb_render = spinner("Rendering reportโ€ฆ"); + let json_for_disk = serde_json::to_string_pretty(&steps)?; let rendered = match format { OutputFormat::Summary => format!( "Starknet trace captured successfully with {} steps.", steps.len() ), - OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Json => json_for_disk.clone(), OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), }; pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); - finalize_report(&rendered, &format, file, tx, svg_path) + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) } /// Generic handler for Solana traces @@ -1244,17 +1254,18 @@ async fn handle_solana_capture( }; let pb_render = spinner("Rendering reportโ€ฆ"); + let json_for_disk = serde_json::to_string_pretty(&steps)?; let rendered = match format { OutputFormat::Summary => format!( "Solana trace reconstructed successfully with {} steps.", steps.len() ), - OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Json => json_for_disk.clone(), OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), }; pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); - finalize_report(&rendered, &format, file, tx, svg_path) + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) } /// Generic handler for Soroban (Stellar) traces @@ -1285,17 +1296,18 @@ async fn handle_stellar_capture( }; let pb_render = spinner("Rendering reportโ€ฆ"); + let json_for_disk = serde_json::to_string_pretty(&steps)?; let rendered = match format { OutputFormat::Summary => format!( "Stellar trace reconstructed successfully with {} host function calls.", steps.len() ), - OutputFormat::Json => serde_json::to_string_pretty(&steps)?, + OutputFormat::Json => json_for_disk.clone(), OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), }; pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); - finalize_report(&rendered, &format, file, tx, svg_path) + finalize_report(&rendered, &format, &json_for_disk, file, tx, svg_path) } /// Orchestrates the multi-VM capture for Arbitrum Nitro / EVM @@ -1348,8 +1360,8 @@ async fn handle_nitro_capture( None }; - let rendered = render_nitro_report(&report, &format)?; - finalize_report(&rendered, &format, file, tx, svg_path) + 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( @@ -1390,17 +1402,22 @@ async fn resolve_names_via_etherscan( Ok(()) } -fn render_nitro_report(report: &StitchedReport, format: &OutputFormat) -> Result { +/// Returns `(terminal_rendered, json_for_disk)`. +/// +/// `terminal_rendered` may contain ANSI escape codes and is only for stdout. +/// `json_for_disk` is always the full `StitchedReport` JSON โ€” clean and +/// machine-readable regardless of the user's `--output` flag. +fn render_nitro_report(report: &StitchedReport, format: &OutputFormat) -> Result<(String, String)> { let pb_render = spinner("Rendering reportโ€ฆ"); - let summary_text = render_capture_summary(report); + let json_for_disk = serde_json::to_string_pretty(report)?; let rendered = match format { - OutputFormat::Summary => summary_text, - OutputFormat::Json => serde_json::to_string_pretty(report)?, + 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) + Ok((rendered, json_for_disk)) } /// Helper data for Nitro/EVM diff calculation diff --git a/studio/vite.config.ts b/studio/vite.config.ts deleted file mode 100644 index 8b0f57b..0000000 --- a/studio/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], -}) From 7e591f5af2ff99ebd00738d096fb114655bb3a51 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 6 May 2026 14:35:59 +0100 Subject: [PATCH 06/37] fix(capture): unify all chain reports to StitchedReport shape for Studio compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starknet, Solana and Stellar capture handlers previously wrote a raw Vec array to disk. The Studio's App.tsx and reportToTree.ts expect a StitchedReport object (tx_hash, steps: UnifiedStep[], totals). Loading non-Nitro reports in Studio would crash. Changes: - atupa-nitro VmKind: add Starknet / Solana / Stellar variants (From impl updated) - main.rs: add trace_steps_to_report() helper that wraps Vec into a proper StitchedReport with chain-specific VmKind on each step - All three handlers use trace_steps_to_report() so every .json artifact is a uniform StitchedReport โ€” loadable by Studio, diff, audit commands --- bin/atupa/src/main.rs | 84 ++++++++++++++++++++++++++++++----- crates/atupa-nitro/src/lib.rs | 6 +++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index c077be0..a8058f2 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -1211,14 +1211,16 @@ async fn handle_starknet_capture( }; let pb_render = spinner("Rendering reportโ€ฆ"); - let json_for_disk = serde_json::to_string_pretty(&steps)?; + 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 captured successfully with {} steps.", - steps.len() + "Starknet trace: {} steps ยท {:.2} gas-equiv", + report.steps.len(), + report.total_unified_cost ), OutputFormat::Json => json_for_disk.clone(), - OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), }; pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); @@ -1254,14 +1256,16 @@ async fn handle_solana_capture( }; let pb_render = spinner("Rendering reportโ€ฆ"); - let json_for_disk = serde_json::to_string_pretty(&steps)?; + 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 reconstructed successfully with {} steps.", - steps.len() + "Solana trace: {} steps ยท {} compute units", + report.steps.len(), + report.total_evm_gas ), OutputFormat::Json => json_for_disk.clone(), - OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), }; pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); @@ -1296,14 +1300,16 @@ async fn handle_stellar_capture( }; let pb_render = spinner("Rendering reportโ€ฆ"); - let json_for_disk = serde_json::to_string_pretty(&steps)?; + 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 trace reconstructed successfully with {} host function calls.", - steps.len() + "Stellar/Soroban trace: {} host function calls ยท {} resource units", + report.steps.len(), + report.total_evm_gas ), OutputFormat::Json => json_for_disk.clone(), - OutputFormat::Metric => steps.iter().map(|s| s.gas_cost).sum::().to_string(), + OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), }; pb_render.finish_with_message(format!("{} Report ready.", "โœ”".green().bold())); @@ -2081,6 +2087,60 @@ fn bridge_raw_to_trace_step(raw: &RawStructLog) -> TraceStep { } } +/// Converts a flat `Vec` (from Starknet/Solana/Stellar adapters) +/// into a `StitchedReport` that the Studio and downstream tooling can consume. +/// +/// All steps are assigned the given `chain_vm` kind so the Studio flame graph +/// renders them with the correct chain-specific colour palette. +fn trace_steps_to_report( + tx: &str, + steps: Vec, + chain_vm: VmKind, +) -> StitchedReport { + let mut total_gas: u64 = 0; + let mut category_costs: std::collections::HashMap = + std::collections::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 = + atupa_core::GasCategory::from_step(&s.op, s.vm_kind.clone()); + *category_costs.entry(category.clone()).or_insert(0.0) += cost; + atupa_nitro::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: std::collections::HashMap::new(), + on_chain_gas_used: None, + } +} + fn spinner(msg: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.set_style( diff --git a/crates/atupa-nitro/src/lib.rs b/crates/atupa-nitro/src/lib.rs index 1f12c2a..f03090e 100644 --- a/crates/atupa-nitro/src/lib.rs +++ b/crates/atupa-nitro/src/lib.rs @@ -67,6 +67,9 @@ impl StylusHostIO { pub enum VmKind { Evm, Stylus, + Starknet, + Solana, + Stellar, } impl From for CoreVmKind { @@ -74,6 +77,9 @@ impl From for CoreVmKind { match v { VmKind::Evm => CoreVmKind::Evm, VmKind::Stylus => CoreVmKind::Stylus, + VmKind::Starknet => CoreVmKind::Starknet, + VmKind::Solana => CoreVmKind::Solana, + VmKind::Stellar => CoreVmKind::Stellar, } } } From 0d03db73365ecd0fec6f9bb313708939d8064463 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 6 May 2026 23:52:39 +0100 Subject: [PATCH 07/37] chore: fix studio server error --- bin/atupa/dist/assets/index-CNp2t9ft.css | 1 + bin/atupa/dist/assets/index-qx0D38BR.js | 9 + bin/atupa/dist/auto-load.json | 734 +++++++++++++++++++++++ bin/atupa/dist/index.html | 21 + studio/vite.config.ts | 11 + 5 files changed, 776 insertions(+) create mode 100644 bin/atupa/dist/assets/index-CNp2t9ft.css create mode 100644 bin/atupa/dist/assets/index-qx0D38BR.js create mode 100644 bin/atupa/dist/auto-load.json create mode 100644 studio/vite.config.ts diff --git a/bin/atupa/dist/assets/index-CNp2t9ft.css b/bin/atupa/dist/assets/index-CNp2t9ft.css new file mode 100644 index 0000000..352f37f --- /dev/null +++ b/bin/atupa/dist/assets/index-CNp2t9ft.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-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/assets/index-qx0D38BR.js b/bin/atupa/dist/assets/index-qx0D38BR.js new file mode 100644 index 0000000..830bc40 --- /dev/null +++ b/bin/atupa/dist/assets/index-qx0D38BR.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},w=Object.prototype.hasOwnProperty;function te(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ne(e,t){return te(e.type,t,e.props)}function re(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ie(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ae=/\/+/g;function T(e,t){return typeof e==`object`&&e&&e.key!=null?ie(``+e.key):t.toString(36)}function oe(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 se(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,se(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+T(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(ae,`$&/`)+`/`),se(o,r,i,``,function(e){return e})):o!=null&&(re(o)&&(o=ne(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ae,`$&/`)+`/`)+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,re());else{var t=n(l);t!==null&&T(x,t.startTime-e)}}var ee=!1,S=-1,C=5,w=-1;function te(){return g?!0:!(e.unstable_now()-wt&&te());){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&&T(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?re():ee=!1}}}var re;if(typeof y==`function`)re=function(){y(ne)};else if(typeof MessageChannel<`u`){var ie=new MessageChannel,ae=ie.port2;ie.port1.onmessage=ne,re=function(){ae.postMessage(null)}}else re=function(){_(ne,0)};function T(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,T(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,re()))),r},e.unstable_shouldYield=te,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=de[fe],de[fe]=null,fe--)}function k(e,t){fe++,de[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 be(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 xe(e){he.current===e&&(O(me),O(he)),_e.current===e&&(O(_e),Qf._currentValue=ue)}var Se,Ce;function we(e){if(Se===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Se=t&&t[1]||``,Ce=-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{Te=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?we(n):``}function De(e,t){switch(e.tag){case 26:case 27:case 5:return we(e.type);case 16:return we(`Lazy`);case 13:return e.child!==t&&t!==null?we(`Suspense Fallback`):we(`Suspense`);case 19:return we(`SuspenseList`);case 0:case 15:return Ee(e.type,!1);case 11:return Ee(e.type.render,!1);case 1:return Ee(e.type,!0);case 31:return we(`Activity`);default:return``}}function Oe(e){try{var t=``,n=null;do t+=De(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,Me=t.unstable_shouldYield,Ne=t.unstable_requestPaint,Pe=t.unstable_now,Fe=t.unstable_getCurrentPriorityLevel,Ie=t.unstable_ImmediatePriority,Le=t.unstable_UserBlockingPriority,Re=t.unstable_NormalPriority,ze=t.unstable_LowPriority,Be=t.unstable_IdlePriority,Ve=t.log,He=t.unstable_setDisableYieldValue,Ue=null,We=null;function Ge(e){if(typeof Ve==`function`&&He(e),We&&typeof We.setStrictMode==`function`)try{We.setStrictMode(Ue,e)}catch{}}var Ke=Math.clz32?Math.clz32:Ye,qe=Math.log,Je=Math.LN2;function Ye(e){return e>>>=0,e===0?32:31-(qe(e)/Je|0)|0}var Xe=256,Ze=262144,Qe=4194304;function $e(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 et(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=$e(n))):i=$e(o):i=$e(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=$e(n))):i=$e(o)):i=$e(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 tt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function nt(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 rt(){var e=Qe;return Qe<<=1,!(Qe&62914560)&&(Qe=4194304),e}function it(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function at(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ot(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),yn=!1;if(vn)try{var bn={};Object.defineProperty(bn,`passive`,{get:function(){yn=!0}}),window.addEventListener(`test`,bn,bn),window.removeEventListener(`test`,bn,bn)}catch{yn=!1}var xn=null,Sn=null,Cn=null;function wn(){if(Cn)return Cn;var e,t=Sn,n=t.length,r,i=`value`in xn?xn.value:xn.textContent,a=i.length;for(e=0;e=nr),ar=` `,or=!1;function sr(e,t){switch(e){case`keyup`:return er.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function cr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var lr=!1;function ur(e,t){switch(e){case`compositionend`:return cr(t);case`keypress`:return t.which===32?(or=!0,ar):null;case`textInput`:return e=t.data,e===ar&&or?null:e;default:return null}}function dr(e,t){if(lr)return e===`compositionend`||!tr&&sr(e,t)?(e=wn(),Cn=Sn=xn=null,lr=!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=Nr(n)}}function Fr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Fr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ir(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Gt(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=Gt(e.document)}return t}function Lr(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 Rr=vn&&`documentMode`in document&&11>=document.documentMode,zr=null,Br=null,Vr=null,Hr=!1;function Ur(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Hr||zr==null||zr!==Gt(r)||(r=zr,`selectionStart`in r&&Lr(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}),Vr&&Mr(Vr,r)||(Vr=r,r=Ed(Br,`onSelect`),0>=o,i-=o,Fi=1<<32-Ke(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),j&&Li(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),j&&Li(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 j&&Li(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)}),j&&Li(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===re&&Fa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ha(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=Si(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=xi(o.type,o.key,o.props,null,e.mode,c),Ha(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=Ti(o,e.mode,c),c.return=e,e=c}return s(e);case re:return o=Fa(o),b(e,r,o,c)}if(le(o))return h(e,r,o,c);if(oe(o)){if(l=oe(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,Va(o),c);if(o.$$typeof===S)return b(e,r,ua(e,o),c);Ua(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=Ci(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ba=0;var i=b(e,t,n,r);return za=null,i}catch(t){if(t===ka||t===ja)throw t;var a=_i(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ga=Wa(!0),Ka=Wa(!1),qa=!1;function Ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ya(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 Xa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Za(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=mi(e),pi(e,null,n),t}return ui(e,r,t,n),mi(e)}function Qa(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,ct(e,n)}}function $a(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 eo=!1;function to(){if(eo){var e=ba;if(e!==null)throw e}}function no(e,t,n,r){eo=!1;var i=e.updateQueue;qa=!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===ya&&(eo=!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:qa=!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 ro(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function io(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=E.T,s={};E.T=s,zs(e,!1,t,n);try{var c=i(),l=E.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Rs(e,t,Ca(c,r),pu(e)):Rs(e,t,r,pu(e))}catch(n){Rs(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{D.p=a,o!==null&&s.types!==null&&(o.types=s.types),E.T=o}}function Os(){}function ks(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=As(e).queue;Ds(e,a,t,ue,n===null?Os:function(){return js(e),n(r)})}function As(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ue,baseState:ue,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:ue},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function js(e){var t=As(e);t.next===null&&(t=e.alternate.memoizedState),Rs(e,t.next.queue,{},pu())}function Ms(){return la(Qf)}function Ns(){return R().memoizedState}function Ps(){return R().memoizedState}function Fs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Xa(n);var r=Za(t,e,n);r!==null&&(hu(r,t,n),Qa(r,t,n)),t={cache:ha()},e.payload=t;return}t=t.return}}function Is(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Bs(e)?Vs(t,n):(n=di(e,t,n,r),n!==null&&(hu(n,e,r),Hs(n,t,r)))}function Ls(e,t,n){Rs(e,t,n,pu())}function Rs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Bs(e))Vs(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,jr(s,o))return ui(e,t,i,0),G===null&&li(),!1}catch{}if(n=di(e,t,i,r),n!==null)return hu(n,e,r),Hs(n,t,r),!0}return!1}function zs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Bs(e)){if(t)throw Error(i(479))}else t=di(e,n,r,2),t!==null&&hu(t,e,2)}function Bs(e){var t=e.alternate;return e===P||t!==null&&t===P}function Vs(e,t){xo=bo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Hs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ct(e,n)}}var Us={readContext:la,use:Ro,useCallback:L,useContext:L,useEffect:L,useImperativeHandle:L,useLayoutEffect:L,useInsertionEffect:L,useMemo:L,useReducer:L,useRef:L,useState:L,useDebugValue:L,useDeferredValue:L,useTransition:L,useSyncExternalStore:L,useId:L,useHostTransitionStatus:L,useFormState:L,useActionState:L,useOptimistic:L,useMemoCache:L,useCacheRefresh:L};Us.useEffectEvent=L;var Ws={readContext:la,use:Ro,useCallback:function(e,t){return Fo().memoizedState=[e,t===void 0?null:t],e},useContext:la,useEffect:ms,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),fs(4194308,4,bs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return fs(4194308,4,e,t)},useInsertionEffect:function(e,t){fs(4,2,e,t)},useMemo:function(e,t){var n=Fo();t=t===void 0?null:t;var r=e();if(So){Ge(!0);try{e()}finally{Ge(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Fo();if(n!==void 0){var i=n(t);if(So){Ge(!0);try{n(t)}finally{Ge(!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=Is.bind(null,P,e),[r.memoizedState,e]},useRef:function(e){var t=Fo();return e={current:e},t.memoizedState=e},useState:function(e){e=Xo(e);var t=e.queue,n=Ls.bind(null,P,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ss,useDeferredValue:function(e,t){return Ts(Fo(),e,t)},useTransition:function(){var e=Xo(!1);return e=Ds.bind(null,P,e.queue,!0,!1),Fo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=P,a=Fo();if(j){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),G===null)throw Error(i(349));q&127||Go(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ms(qo.bind(null,r,o,e),[e]),r.flags|=2048,us(9,{destroy:void 0},Ko.bind(null,r,o,n,t),null),n},useId:function(){var e=Fo(),t=G.identifierPrefix;if(j){var n=Ii,r=Fi;n=(r&~(1<<32-Ke(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=Co++,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[ht]=t,o[gt]=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&&Lc(t)}}return B(t),Rc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Lc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ge.current,Yi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Hi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[ht]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Ki(t,!0)}else e=Bd(e).createTextNode(r),e[ht]=t,t.stateNode=e}return B(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Yi(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[ht]=t}else Xi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),e=!1}else n=Zi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(_o(t),t):(_o(t),null);if(t.flags&128)throw Error(i(558))}return B(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Yi(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[ht]=t}else Xi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),a=!1}else a=Zi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(_o(t),t):(_o(t),null)}return _o(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),Bc(t,t.updateQueue),B(t),null);case 4:return ye(),e===null&&Sd(t.stateNode.containerInfo),B(t),null;case 10:return ra(t.type),B(t),null;case 19:if(O(N),r=t.memoizedState,r===null)return B(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Vc(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=vo(e),o!==null){for(t.flags|=128,Vc(r,!1),e=o.updateQueue,t.updateQueue=e,Bc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)bi(n,e),n=n.sibling;return k(N,N.current&1|2),j&&Li(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Pe()>nu&&(t.flags|=128,a=!0,Vc(r,!1),t.lanes=4194304)}else{if(!a)if(e=vo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Bc(t,e),Vc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!j)return B(t),null}else 2*Pe()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,Vc(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?(B(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Pe(),e.sibling=null,n=N.current,k(N,a?n&1|2:n&1),j&&Li(t,r.treeForkCount),e);case 22:case 23:return _o(t),lo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(B(t),t.subtreeFlags&6&&(t.flags|=8192)):B(t),n=t.updateQueue,n!==null&&Bc(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(Ta),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ra(M),B(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Uc(e,t){switch(Bi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ra(M),ye(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return xe(t),null;case 31:if(t.memoizedState!==null){if(_o(t),t.alternate===null)throw Error(i(340));Xi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(_o(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Xi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return O(N),null;case 4:return ye(),null;case 10:return ra(t.type),null;case 22:case 23:return _o(t),lo(),e!==null&&O(Ta),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ra(M),null;case 25:return null;default:return null}}function Wc(e,t){switch(Bi(t),t.tag){case 3:ra(M),ye();break;case 26:case 27:case 5:xe(t);break;case 4:ye();break;case 31:t.memoizedState!==null&&_o(t);break;case 13:_o(t);break;case 19:O(N);break;case 10:ra(t.type);break;case 22:case 23:_o(t),lo(),e!==null&&O(Ta);break;case 24:ra(M)}}function Gc(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 Kc(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 qc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{io(t,n)}catch(t){Z(e,e.return,t)}}}function Jc(e,t,n){n.props=Zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Yc(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 Xc(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 Zc(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 Qc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[gt]=t}catch(t){Z(e,e.return,t)}}function $c(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function el(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||$c(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 tl(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=ln));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),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,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(nl(e,t,n),e=e.sibling;e!==null;)nl(e,t,n),e=e.sibling}function rl(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[ht]=e,t[gt]=n}catch(t){Z(e,e.return,t)}}var il=!1,V=!1,al=!1,ol=typeof WeakSet==`function`?WeakSet:Set,H=null;function sl(e,t){if(e=e.containerInfo,Rd=sp,e=Ir(e),Lr(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,H=t;H!==null;)if(t=H,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,H=e;else for(;H!==null;){switch(t=H,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[ht]=e,Ot(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 _=Pr(s,h),v=Pr(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,E.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),We&&typeof We.onPostCommitFiberRoot==`function`)try{We.onPostCommitFiberRoot(Ue,o)}catch{}return!0}finally{D.p=a,E.T=r,Vu(e,t)}}function Wu(e,t,n){t=Di(n,t),t=rc(e.stateNode,t,2),e=Za(e,t,2),e!==null&&(at(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=Di(n,e),n=ic(2),r=Za(t,n,2),r!==null&&(ac(n,r,t,e),at(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>Pe()-eu?!(W&2)&&Su(e,0):Jl|=n,Xl===q&&(Xl=0)),rd(e)}function qu(e,t){t===0&&(t=rt()),e=fi(e,t),e!==null&&(at(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 Ae(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-Ke(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=et(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||tt(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=Pe(),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=qt(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),Ot(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="`+qt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+qt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+qt(n.imageSizes)+`"]`)):i+=`[href="`+qt(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),Ot(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="`+qt(r)+`"][href="`+qt(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),Ot(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Dt(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`);Ot(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=Dt(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`),Ot(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=Dt(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`),Ot(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=Dt(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=Dt(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=Dt(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="`+qt(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),Ot(t),e.head.appendChild(t))}function Pf(e){return`[src="`+qt(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~="`+qt(n.href)+`"]`);if(r)return t.instance=r,Ot(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`),Ot(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,Ot(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Ot(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,Ot(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Ot(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,Ot(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Ot(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 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){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 te(e){return e.toFixed(2)}function ne(e){return e.length<12?e:`${e.slice(0,8)}โ€ฆ${e.slice(-6)}`}function re(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 ie(t),t}function ie(e){if(e.children.length===0)return e.value=e.selfCost,e.value;let t=0;for(let n of e.children)t+=ie(n);return e.value=e.selfCost+t,e.value}var ae=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=ae()}))();function oe({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=n.tx_hash&&Array.isArray(n.steps),a=n.type===`diff`&&n.base&&n.target;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)(e=>{e.preventDefault(),n(!1);let t=e.dataTransfer.files[0];t&&a(t)},[a]),s=(0,_.useCallback)(e=>{let t=e.target.files?.[0];t&&a(t)},[a]);return(0,T.jsxs)(`div`,{id:`drop-zone`,className:`drop-zone${t?` dragging`:``}`,onDragOver:e=>{e.preventDefault(),n(!0)},onDragLeave:()=>n(!1),onDrop:o,children:[(0,T.jsx)(`div`,{className:`drop-icon`,children:`๐Ÿฎ`}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`div`,{className:`drop-title`,children:`Drop your Atupa trace here`}),(0,T.jsxs)(`div`,{className:`drop-subtitle`,style:{marginTop:8},children:[`Generate a trace with the CLI, then drop the `,(0,T.jsx)(`code`,{style:{color:`var(--color-amber)`,fontSize:12},children:`report.json`}),` file to visualize its unified EVM + Stylus execution.`]})]}),(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:`๐Ÿ“‚`}),` Choose File`]})}),(0,T.jsx)(`input`,{id:`file-input`,type:`file`,accept:`.json`,onChange:s,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.jsx)(`div`,{className:`drop-hint`,children:`atupa capture --tx 0x... --rpc --output report.json`})]})}function se({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 ce({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:te(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 le=150;function E({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=e.vm===`Evm`?e.gas_cost>0?`${e.gas_cost} gas`:``:`${e.cost_equiv.toFixed(2)} gas-equiv`,i=b(e,t),a=e.target_address&&t.resolved_names[e.target_address];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?`EVMโ†’WASM 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 ${e.vm===`Evm`?`evm`:`stylus`}`,children:e.vm===`Evm`?`EVM`:`WASM`}),(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 D({report:e}){let[t,n]=(0,_.useState)(`all`),[r,i]=(0,_.useState)(0),[a,o]=(0,_.useState)(``),s=(0,_.useMemo)(()=>e.steps.filter(n=>{if(t===`evm`&&n.vm!==`Evm`||t===`stylus`&&n.vm!==`Stylus`||t===`boundary`&&!n.is_vm_boundary)return!1;let r=b(n,e).toLowerCase();return!(a&&!r.includes(a.toLowerCase()))}),[e.steps,t,a]),c=Math.ceil(s.length/le),l=s.slice(r*le,(r+1)*le),u=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:[[`all`,`evm`,`stylus`,`boundary`].map(e=>(0,T.jsx)(`button`,{id:`filter-${e}`,style:u(t===e),onClick:()=>{n(e),i(0)},children:e===`all`?`All Steps`:e===`evm`?`EVM Only`:e===`stylus`?`WASM Only`:`Boundaries`},e)),(0,T.jsx)(`input`,{id:`trace-search`,type:`search`,placeholder:`Search opcode / HostIOโ€ฆ`,value:a,onChange:e=>{o(e.target.value),i(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:220}})]}),(0,T.jsxs)(`div`,{style:{fontSize:11,color:`var(--color-text-muted)`,fontFamily:`var(--font-mono)`},children:[`Showing `,l.length,` of `,s.length,` steps`,c>1&&` (page ${r+1}/${c})`]}),(0,T.jsx)(`div`,{className:`trace-list glass-card`,role:`list`,style:{maxHeight:480,overflowY:`auto`,padding:`var(--sp-3)`},children:l.length===0?(0,T.jsx)(`div`,{style:{color:`var(--color-text-muted)`,fontSize:13,padding:`var(--sp-4)`},children:`No steps match your filter.`}):l.map(t=>(0,T.jsx)(E,{step:t,report:e},t.index))}),c>1&&(0,T.jsxs)(`div`,{style:{display:`flex`,gap:`var(--sp-2)`,alignItems:`center`,justifyContent:`center`},children:[(0,T.jsx)(`button`,{id:`page-prev`,onClick:()=>i(e=>Math.max(0,e-1)),disabled:r===0,style:u(!1),children:`โ† Prev`}),(0,T.jsxs)(`span`,{style:{fontSize:11,color:`var(--color-text-muted)`},children:[r+1,` / `,c]}),(0,T.jsx)(`button`,{id:`page-next`,onClick:()=>i(e=>Math.min(c-1,e+1)),disabled:r===c-1,style:u(!1),children:`Next โ†’`})]})]})}var ue=24,de=6,fe=2,pe=6.5,O={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 k({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:O.evmText,Stylus:O.stylusText,Starknet:O.starknetText,Solana:O.solanaText,Stellar:O.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:O.tooltipBg,border:`1px solid ${O.tooltipBorder}`,borderRadius:8,padding:`8px 12px`,fontFamily:`'JetBrains Mono', monospace`,fontSize:11,color:O.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 me(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;me(s,o,o+e,r+1,i),o+=e}}var he=_.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=O.highlightFill,h=`#ff2a4a`);let v=Math.max(0,Math.floor((f-de*2)/pe)),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},e),onMouseLeave:()=>a(null,{}),children:[(0,T.jsx)(`rect`,{x:d+1,y:p+1,width:Math.max(0,f-2),height:ue-2,rx:3,fill:m,stroke:_?`#ff2a4a`:h,strokeWidth:_?1.5:.8,style:{transition:`fill 120ms ease`}}),y&&(0,T.jsx)(`text`,{x:d+de,y:p+ue/2+4,fill:g,fontSize:11,fontFamily:`'JetBrains Mono', monospace`,style:{pointerEvents:`none`,userSelect:`none`},children:y})]})});function ge({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 _e({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=s[s.length-1];(0,_.useEffect)(()=>{c([e])},[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 u=(0,_.useMemo)(()=>{let t=[];return me(e,0,1,0,t),t},[e]),{zoomX:d,zoomW:f}=(0,_.useMemo)(()=>{let e=u.find(e=>e.node.id===l.id);return e?{zoomX:e.x,zoomW:e.w}:{zoomX:0,zoomW:1}},[u,l]),p=(0,_.useMemo)(()=>u.filter(e=>e.w===0?!1:e.w/f*r>=fe),[u,f,r]),m=((0,_.useMemo)(()=>Math.max(...p.map(e=>e.row),0),[p])+1)*(ue+2)+8,h=(0,_.useCallback)(e=>{c(t=>[...t,e]),o(null)},[]),g=(0,_.useCallback)(e=>{c(t=>t.slice(0,e+1)),o(null)},[]),v=(0,_.useCallback)((e,t)=>{o(e)},[]);return(0,T.jsxs)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:0},children:[(0,T.jsx)(ge,{trail:s,onJump:g}),(0,T.jsxs)(`div`,{style:{display:`flex`,alignItems:`center`,gap:16,marginBottom:10,fontSize:10,fontFamily:`'JetBrains Mono', monospace`,color:`#64748b`},children:[[{color:O.evmStroke,label:`EVM`},{color:O.stylusStroke,label:`Stylus`},{color:O.starknetStroke,label:`Starknet`},{color:O.solanaStroke,label:`Solana`},{color:O.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:[u.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:m,style:{display:`block`},children:[p.map(e=>(0,T.jsx)(he,{lnode:e,svgWidth:r,zoomX:d,zoomW:f,highlight:t,onHover:v,onClick:h},e.node.id)),a&&(0,T.jsx)(k,{tip:a,rootValue:e.value})]})}),s.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:()=>c([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 ve({report:e}){let t=Object.entries(e.category_costs).filter(([e,t])=>t>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 ye({report:e}){let{base:t,target:n,metrics:r}=e,i=({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),`%)`]})};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)(se,{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)(i,{val:r.gas_delta,pct:r.gas_pct})]})}),(0,T.jsx)(se,{kind:`stylus`,icon:`๐Ÿฆพ`,label:`Execution Cost (Unified)`,value:te(r.target_unified_cost),sub:(0,T.jsxs)(T.Fragment,{children:[`Baseline: `,te(r.base_unified_cost),(0,T.jsx)(i,{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)(ve,{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)(ve,{report:n})]})]})]})}function be(){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?C(y(e)?e.target:e):[],l=(0,_.useMemo)(()=>e?re(y(e)?e.target:e):null,[e]);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`,children:[(0,T.jsx)(`span`,{className:`live-dot`}),y(e)?`Comparison Loaded`:`Single Trace Loaded`]}),(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`}),[`overview`,`flame`,`trace`,`hostio`].map(t=>{let i={overview:{icon:`๐Ÿ“Š`,label:`Overview`},flame:{icon:`๐Ÿ”†`,label:`Visual Trace`},trace:{icon:`๐Ÿงฉ`,label:`Trace Inspector`},hostio:{icon:`๐Ÿ”ฅ`,label:`HostIO Hot Paths`}}[t];return(0,T.jsxs)(`button`,{id:`nav-${t}`,className:`sidebar-nav-item${n===t&&e?` active`:``}`,onClick:()=>e&&r(t),disabled:!e,style:{opacity:e?1:.4},children:[(0,T.jsx)(`span`,{className:`nav-icon`,children:i.icon}),i.label]},t)}),e&&!y(e)&&(0,T.jsxs)(`div`,{className:`sidebar-meta`,children:[(0,T.jsxs)(`div`,{children:[`tx: `,ne(e.tx_hash)]}),(0,T.jsxs)(`div`,{children:[`steps: `,e.steps.length.toLocaleString()]}),(0,T.jsxs)(`div`,{children:[`evm: `,ee(e).length.toLocaleString()]}),(0,T.jsxs)(`div`,{children:[`wasm: `,S(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)]})]})]}),(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)(ye,{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)(ve,{report:e})]})}),(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`Execution Metrics`}),(0,T.jsx)(`div`,{className:`section-divider`})]}),(0,T.jsxs)(`div`,{className:`metric-grid`,children:[(0,T.jsx)(se,{kind:`evm`,icon:`โ›ฝ`,label:`EVM Trace Gas`,value:w(y(e)?e.target.total_evm_gas:e.total_evm_gas),sub:`gas units`}),(0,T.jsx)(se,{kind:`stylus`,icon:`๐Ÿฆพ`,label:`Stylus Ink`,value:w(y(e)?e.target.total_stylus_ink:e.total_stylus_ink),sub:`โ‰ˆ ${te(y(e)?e.target.total_stylus_gas_equiv:e.total_stylus_gas_equiv)} gas-equiv`}),(0,T.jsx)(se,{kind:`steps`,icon:`๐Ÿงฉ`,label:`EVM Steps`,value:w(ee(y(e)?e.target:e).length),sub:`struct log entries`}),(0,T.jsx)(se,{kind:`stylus`,icon:`๐Ÿ“ก`,label:`Stylus HostIOs`,value:w(S(y(e)?e.target:e).length),sub:`WASM host calls`}),(0,T.jsx)(se,{kind:`boundary`,icon:`โ‡Œ`,label:`VM Boundaries`,value:w(y(e)?e.target.vm_boundary_count:e.vm_boundary_count),sub:`EVM โ†’ WASM crossings`})]})]}),c.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)(ce,{rows:c.slice(0,6)})]})]}),n===`flame`&&l&&(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:11,fontFamily:`var(--font-mono)`,outline:`none`,width:180}})]}),(0,T.jsx)(_e,{root:l,search:i})]}),n===`hostio`&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`๐Ÿ”ฅ HostIO Hot Paths`}),(0,T.jsx)(`div`,{className:`section-divider`}),(0,T.jsxs)(`span`,{style:{fontSize:11,color:`var(--color-text-muted)`,fontFamily:`var(--font-mono)`},children:[c.length,` unique operations`]})]}),(0,T.jsx)(ce,{rows:c})]}),n===`trace`&&(0,T.jsxs)(`div`,{className:`glass-card`,children:[(0,T.jsxs)(`div`,{className:`section-header`,children:[(0,T.jsx)(`span`,{className:`section-title`,children:`๐Ÿงฉ Unified Execution Trace`}),(0,T.jsx)(`div`,{className:`section-divider`}),(0,T.jsxs)(`span`,{style:{fontSize:11,color:`var(--color-text-muted)`,fontFamily:`var(--font-mono)`},children:[(y(e)?e.target.steps:e.steps).length.toLocaleString(),` total steps`]})]}),(0,T.jsx)(D,{report:y(e)?e.target:e})]})]}):(0,T.jsx)(oe,{onLoad:o})})]})}(0,v.createRoot)(document.getElementById(`root`)).render((0,T.jsx)(_.StrictMode,{children:(0,T.jsx)(be,{})})); \ No newline at end of file diff --git a/bin/atupa/dist/auto-load.json b/bin/atupa/dist/auto-load.json new file mode 100644 index 0000000..fc63ac9 --- /dev/null +++ b/bin/atupa/dist/auto-load.json @@ -0,0 +1,734 @@ +{ + "tx_hash": "0x6bbe6b5f0e86f1cd2b3f2375888294d75962dad926cc93654783101fa219b5b1", + "chain_id": 421614, + "steps": [ + { + "index": 0, + "vm": "Evm", + "label": "SLOAD", + "gas_cost": 0, + "cost_equiv": 0.0, + "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 + }, + { + "index": 1, + "vm": "Evm", + "label": "SLOAD", + "gas_cost": 0, + "cost_equiv": 0.0, + "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 + }, + { + "index": 2, + "vm": "Evm", + "label": "CALLDATACOPY", + "gas_cost": 1, + "cost_equiv": 1.0, + "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 + }, + { + "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 + }, + { + "index": 4, + "vm": "Evm", + "label": "POP", + "gas_cost": 0, + "cost_equiv": 0.0, + "depth": 1, + "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 + }, + { + "index": 5, + "vm": "Evm", + "label": "KECCAK256", + "gas_cost": 12, + "cost_equiv": 12.0, + "depth": 1, + "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 + }, + { + "index": 6, + "vm": "Evm", + "label": "POP", + "gas_cost": 0, + "cost_equiv": 0.0, + "depth": 1, + "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 + }, + { + "index": 7, + "vm": "Evm", + "label": "KECCAK256", + "gas_cost": 12, + "cost_equiv": 12.0, + "depth": 1, + "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 + }, + { + "index": 8, + "vm": "Evm", + "label": "POP", + "gas_cost": 0, + "cost_equiv": 0.0, + "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 + }, + { + "index": 9, + "vm": "Evm", + "label": "SLOAD", + "gas_cost": 2106, + "cost_equiv": 2106.0, + "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 + }, + { + "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", + "gas_cost": 0, + "cost_equiv": 0.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 + } + } + ], + "total_evm_gas": 44256, + "total_stylus_ink": 442732195, + "vm_boundary_count": 0, + "total_stylus_gas_equiv": 44273.2195, + "total_unified_cost": 88529.2195 +} \ No newline at end of file diff --git a/bin/atupa/dist/index.html b/bin/atupa/dist/index.html index e69de29..ce72385 100644 --- a/bin/atupa/dist/index.html +++ b/bin/atupa/dist/index.html @@ -0,0 +1,21 @@ + + + + + + + + Atupa Studio + + + + + + + +
+ + diff --git a/studio/vite.config.ts b/studio/vite.config.ts new file mode 100644 index 0000000..ea9034d --- /dev/null +++ b/studio/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// 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, + } +}) From 9bc273184e100e754856bb3ce327b15d5b16618c Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 3 Jun 2026 17:05:15 +0100 Subject: [PATCH 08/37] refactor: resolve release blockers, add --vm flag, and deduplicate adapters --- .gitignore | 1 + ARBITRUM_UNIFIED_PROPOSAL.md | 48 ------ bin/atupa/src/main.rs | 110 +++++++++++-- crates/atupa-adapters/src/lib.rs | 155 +++++++------------ crates/atupa-output/src/lib.rs | 130 ++++++++++++++++ crates/atupa-output/templates/flamegraph.svg | 11 +- crates/atupa-parser/src/aggregator.rs | 30 +++- crates/atupa-rpc/src/etherscan.rs | 81 ++++++---- crates/atupa-sdk/src/lib.rs | 52 ++++++- publish.sh | 9 +- 10 files changed, 415 insertions(+), 212 deletions(-) delete mode 100644 ARBITRUM_UNIFIED_PROPOSAL.md diff --git a/.gitignore b/.gitignore index 4186ae9..00ddbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ stylus-*.json # Atupa Studio build output studio/dist/ studio/node_modules/ +bin/atupa/dist/ # Local artifacts artifacts/ 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/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index a8058f2..0958339 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -77,6 +77,10 @@ enum Commands { /// 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 EVM + Stylus execution trace (Arbitrum Nitro). @@ -107,6 +111,10 @@ enum Commands { /// 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) @@ -153,6 +161,10 @@ enum Commands { /// 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 @@ -191,6 +203,22 @@ enum OutputFormat { 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, ValueEnum, Debug, PartialEq, Eq)] +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, +} + #[derive(Clone, ValueEnum, Debug)] enum Protocol { /// Aave v3 + GHO stablecoin protocol adapters @@ -225,11 +253,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 +267,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 +290,7 @@ async fn main() -> Result<()> { svg, protocol, output, + vm, } => { cmd_diff( &config, @@ -271,6 +302,7 @@ async fn main() -> Result<()> { svg, output, protocol, + vm, ) .await?; } @@ -296,6 +328,7 @@ async fn cmd_profile( tx: &str, demo: bool, out: Option, + vm: Option, ) -> Result<()> { if !demo && tx.is_empty() { anyhow::bail!( @@ -308,6 +341,15 @@ async fn cmd_profile( eprintln!("{} {}", "โ†’ Profiling:".bold(), display.cyan()); eprintln!("{} {}\n", "โ†’ Endpoint: ".bold(), config.rpc_url.dimmed()); + // Convert CLI VmTarget into the SDK's VmHint + let vm_hint = vm.map(|v| match v { + VmTarget::Evm => atupa::profile::VmHint::Evm, + VmTarget::Stylus => atupa::profile::VmHint::Stylus, + VmTarget::Starknet => atupa::profile::VmHint::Starknet, + VmTarget::Solana => atupa::profile::VmHint::Solana, + VmTarget::Stellar => atupa::profile::VmHint::Stellar, + }); + // Route output through the standard artifacts directory (same as capture) let svg_path = resolve_artifact_path(out, "profile", tx, "svg"); @@ -317,6 +359,7 @@ async fn cmd_profile( demo, Some(svg_path), config.etherscan_key.clone(), + vm_hint, ) .await .context("Profile command failed")?; @@ -346,16 +389,29 @@ async fn cmd_capture( 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()); - let report_path = if config.rpc_url.contains("starknet") { + // Hint-or-heuristic routing (same priority logic as execute_profile) + 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 config.rpc_url.contains("solana") { + } else if use_solana { handle_solana_capture(&config.rpc_url, &tx, format, file, generate_profile).await? - } else if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { + } 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? @@ -466,6 +522,7 @@ async fn cmd_diff( svg: bool, output_format: OutputFormat, protocol: Option, + vm: Option, ) -> Result<()> { let base = normalise_hash(base); let target = normalise_hash(target); @@ -479,11 +536,23 @@ async fn cmd_diff( ); eprintln!("{} {}\n", "โ†’ Endpoint:".bold(), config.rpc_url.dimmed()); - if config.rpc_url.contains("solana") { + // Hint-or-heuristic routing + 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 config.rpc_url.contains("starknet") { + } else if use_starknet { handle_starknet_diff(&config.rpc_url, &base, &target, threshold, svg).await?; - } else if config.rpc_url.contains("stellar") || config.rpc_url.contains("soroban") { + } else if use_stellar { handle_stellar_diff(&config.rpc_url, &base, &target, threshold, svg).await?; } else { handle_nitro_diff( @@ -625,8 +694,9 @@ fn generate_generic_diff_svg(args: &GenericDiffArgs) -> Result<()> { let pb_svg = 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 base_stacks = Aggregator::build_collapsed_stacks(&base_norm); - let target_stacks = Aggregator::build_collapsed_stacks(&target_norm); + 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")?; @@ -1118,7 +1188,8 @@ fn generate_and_save_svg( ) -> Result { let pb_svg = spinner("Generating SVG flamegraphโ€ฆ"); let normalized = TraceParser::normalize_raw(steps.to_vec()); - let stacks = Aggregator::build_collapsed_stacks(&normalized); + 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")?; @@ -1973,7 +2044,8 @@ fn generate_diff_svg(data: &NitroDiffData) -> Result<()> { .iter() .map(|s| s.to_trace_step()) .collect(); - let base_stacks = Aggregator::build_collapsed_stacks(&TraceParser::normalize_raw(base_steps)); + 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 @@ -1982,7 +2054,7 @@ fn generate_diff_svg(data: &NitroDiffData) -> Result<()> { .map(|s| s.to_trace_step()) .collect(); let target_stacks = - Aggregator::build_collapsed_stacks(&TraceParser::normalize_raw(target_steps)); + 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!( @@ -2056,12 +2128,22 @@ fn evaluate_thresholds( // โ”€โ”€โ”€ Shared Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -/// Normalise a transaction hash to lowercase 0x-prefixed form. +/// Normalise a transaction hash or signature. +/// EVM hashes get lowercased and `0x`-prefixed. +/// Solana signatures (Base58, >70 chars) are preserved exactly as provided. fn normalise_hash(tx: &str) -> String { let t = tx.trim(); + + // Solana signatures are Base58 and much longer than standard 64-char hex hashes + if t.len() > 70 { + return t.to_string(); + } + if t.to_lowercase().starts_with("0x") { t.to_lowercase() } else { + // EVM / Starknet typically expect 0x prefix. + // If it's exactly 64 chars, we'll prefix it. format!("0x{}", t.to_lowercase()) } } diff --git a/crates/atupa-adapters/src/lib.rs b/crates/atupa-adapters/src/lib.rs index 55ebec2..19c2434 100644 --- a/crates/atupa-adapters/src/lib.rs +++ b/crates/atupa-adapters/src/lib.rs @@ -1,12 +1,21 @@ +/// The shared trait every protocol adapter must implement. 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. + /// 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; } -/// Adapter specifically for identifying Uniswap v4 Hooks +// โ”€โ”€โ”€ Built-in adapters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Identifies Uniswap v4 Hook interface calls by their 4-byte selectors. +/// +/// This adapter lives here because Uniswap v4 has no dedicated `atupa-*` crate. +/// Protocol-specific adapters (Aave, Lido, โ€ฆ) live in their own crates and +/// register themselves into the [`AdapterRegistry`] at the call site. pub struct UniswapV4Adapter; impl ProtocolAdapter for UniswapV4Adapter { @@ -35,116 +44,42 @@ impl ProtocolAdapter for UniswapV4Adapter { } } -/// 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. +// โ”€โ”€โ”€ Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// A runtime registry of [`ProtocolAdapter`]s. +/// +/// Create an empty registry and register exactly the adapters you need: +/// +/// ```rust,no_run +/// use atupa_adapters::{AdapterRegistry, UniswapV4Adapter}; +/// +/// let mut registry = AdapterRegistry::new(); +/// registry.register(Box::new(UniswapV4Adapter)); +/// // registry.register(Box::new(atupa_aave::AaveV3Adapter::default())); +/// // registry.register(Box::new(atupa_lido::LidoAdapter::default())); +/// ``` +/// +/// `AdapterRegistry::default()` pre-loads only `UniswapV4Adapter` so that +/// the adapters crate stays free of dependencies on the deep-tracer crates. 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 { + /// Creates an empty registry. Use [`AdapterRegistry::default()`] to get + /// one pre-loaded with the built-in Uniswap v4 adapter. + pub fn empty() -> Self { + Self { adapters: Vec::new(), - }; - registry.register(Box::new(UniswapV4Adapter)); - registry.register(Box::new(AaveV3Adapter)); - registry.register(Box::new(LidoAdapter)); - registry + } } - /// Register a custom adapter + /// Register a protocol adapter. pub fn register(&mut self, adapter: Box) { self.adapters.push(adapter); } - /// Iterates through adapters to find a descriptive label for the call. + /// 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) { @@ -153,10 +88,28 @@ impl AdapterRegistry { } None } + + /// Returns the names of all currently registered adapters. + pub fn adapter_names(&self) -> Vec<&str> { + self.adapters.iter().map(|a| a.name()).collect() + } } 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 { - Self::new() + let mut registry = Self::empty(); + registry.register(Box::new(UniswapV4Adapter)); + registry + } +} + +// Preserve the old `new()` alias so existing call-sites don't break. +impl AdapterRegistry { + /// Alias for [`AdapterRegistry::default()`]. + pub fn new() -> Self { + Self::default() } } diff --git a/crates/atupa-output/src/lib.rs b/crates/atupa-output/src/lib.rs index 2a3bd10..6a6bd20 100644 --- a/crates/atupa-output/src/lib.rs +++ b/crates/atupa-output/src/lib.rs @@ -14,6 +14,7 @@ struct FlamegraphTemplate { height: u32, has_wasm: bool, has_starknet: bool, + has_solana: bool, has_stellar: bool, } @@ -209,6 +210,7 @@ impl SvgGenerator { } 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; @@ -218,6 +220,7 @@ impl SvgGenerator { height, has_wasm, has_starknet, + has_solana, has_stellar, }; Ok(template.render()?) @@ -252,3 +255,130 @@ impl SvgGenerator { stack.stack.split(';').next_back().unwrap_or(&stack.stack) } } + +// โ”€โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + use atupa_core::{CollapsedStack, VmKind}; + + 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, + } + } + + /// A pure-EVM trace must NOT show the Solana legend swatch. + #[test] + fn legend_pure_evm_shows_revert_not_solana() { + let stacks = vec![evm_stack()]; + let svg = SvgGenerator::generate_flamegraph(&stacks).expect("SVG generated"); + + // The legend rect for EVM appears in the template as: class="box-evm" + assert!( + svg.contains(r#"class="box-evm""#), + "EVM swatch must appear in legend" + ); + // Solana legend rect must NOT be rendered (the CSS rule .box-solana is always + // present in