diff --git a/.github/workflows/gas-regression.yml b/.github/workflows/gas-regression.yml new file mode 100644 index 0000000..398d974 --- /dev/null +++ b/.github/workflows/gas-regression.yml @@ -0,0 +1,261 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Atupa Gas Regression — Automated CI Workflow +# +# On every PR against main: +# 1. Spins up Anvil (local EVM) for isolated testing +# 2. Deploys the contract at HEAD of `main` → runs a tx → saves BASELINE hash +# 3. Deploys the contract at HEAD of the PR branch → runs the same tx → TARGET hash +# 4. Runs `atupa diff` to compare gas usage +# 5. Posts a sticky Markdown report to the PR, uploads SVG flamegraph +# 6. Fails CI if any threshold in atupa.toml is exceeded +# +# REQUIRED SECRETS (in repo Settings → Secrets): +# ATUPA_RPC_URL — Optional: override Anvil with a real mainnet fork RPC +# +# REQUIRED REPO VARIABLES: +# PROFILE_SCRIPT — relative path to a forge/cast script that outputs a TX hash +# to stdout (default: script/ProfileScript.s.sol) +# ───────────────────────────────────────────────────────────────────────────── + +name: ⛽ Atupa Gas Regression + +on: + pull_request: + branches: [ main ] + # Allow manual trigger for debugging + workflow_dispatch: + inputs: + base_tx: + description: 'Override base transaction hash' + required: false + target_tx: + description: 'Override target transaction hash' + required: false + +permissions: + contents: read + pull-requests: write + +env: + CARGO_TERM_COLOR: always + # Use Anvil by default; override with a real RPC via secret for mainnet-fork + RPC_URL: ${{ secrets.ATUPA_RPC_URL || 'http://127.0.0.1:8545' }} + ANVIL_PORT: 8545 + ANVIL_MNEMONIC: 'test test test test test test test test test test test junk' + +jobs: + # ── Job 1: Capture BASELINE gas from `main` branch ───────────────────────── + baseline: + name: 📐 Baseline (main) + runs-on: ubuntu-latest + outputs: + tx_hash: ${{ steps.capture.outputs.tx_hash }} + + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 1 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + key: atupa-ci-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + + - name: Install Foundry (Anvil + Cast + Forge) + uses: foundry-rs/foundry-toolchain@v1 + + - name: Start Anvil (local ephemeral EVM) + shell: bash + run: | + anvil \ + --port ${{ env.ANVIL_PORT }} \ + --mnemonic "${{ env.ANVIL_MNEMONIC }}" \ + --steps-tracing \ + --silent & + echo "ANVIL_PID=$!" >> $GITHUB_ENV + # Wait for Anvil to be ready + for i in $(seq 1 20); do + cast block-number --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} &>/dev/null && break + sleep 0.5 + done + echo "✅ Anvil is ready on port ${{ env.ANVIL_PORT }}" + + - name: Deploy contract & capture baseline TX hash + id: capture + shell: bash + env: + PRIVATE_KEY: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + run: | + PROFILE_SCRIPT="${{ vars.PROFILE_SCRIPT || 'script/ProfileScript.s.sol' }}" + + if [ -f "$PROFILE_SCRIPT" ]; then + # Forge deployment path: run script, capture TX hash from stdout + TX_HASH=$(forge script "$PROFILE_SCRIPT" \ + --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} \ + --private-key $PRIVATE_KEY \ + --broadcast \ + --json 2>/dev/null \ + | jq -r '.receipts[-1].transactionHash' 2>/dev/null) + else + # Fallback: Use a simple cast send to a known deployed contract + echo "⚠️ No PROFILE_SCRIPT found. Sending dummy ETH transfer for baseline." + TX_HASH=$(cast send \ + --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} \ + --private-key $PRIVATE_KEY \ + --json \ + 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ + --value 0.001ether \ + | jq -r '.transactionHash') + fi + + echo "📌 Baseline TX: $TX_HASH" + echo "tx_hash=${TX_HASH}" >> $GITHUB_OUTPUT + + - name: Stop Anvil + if: always() + run: kill ${{ env.ANVIL_PID }} 2>/dev/null || true + + # ── Job 2: Capture TARGET gas from the PR branch ──────────────────────────── + target: + name: 🎯 Target (PR branch) + runs-on: ubuntu-latest + outputs: + tx_hash: ${{ steps.capture.outputs.tx_hash }} + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + key: atupa-ci-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Start Anvil + shell: bash + run: | + anvil \ + --port ${{ env.ANVIL_PORT }} \ + --mnemonic "${{ env.ANVIL_MNEMONIC }}" \ + --steps-tracing \ + --silent & + echo "ANVIL_PID=$!" >> $GITHUB_ENV + for i in $(seq 1 20); do + cast block-number --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} &>/dev/null && break + sleep 0.5 + done + echo "✅ Anvil is ready" + + - name: Deploy contract & capture target TX hash + id: capture + shell: bash + env: + PRIVATE_KEY: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + run: | + PROFILE_SCRIPT="${{ vars.PROFILE_SCRIPT || 'script/ProfileScript.s.sol' }}" + + if [ -f "$PROFILE_SCRIPT" ]; then + TX_HASH=$(forge script "$PROFILE_SCRIPT" \ + --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} \ + --private-key $PRIVATE_KEY \ + --broadcast \ + --json 2>/dev/null \ + | jq -r '.receipts[-1].transactionHash' 2>/dev/null) + else + echo "⚠️ No PROFILE_SCRIPT found. Sending dummy ETH transfer for target." + TX_HASH=$(cast send \ + --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} \ + --private-key $PRIVATE_KEY \ + --json \ + 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ + --value 0.001ether \ + | jq -r '.transactionHash') + fi + + echo "📌 Target TX: $TX_HASH" + echo "tx_hash=${TX_HASH}" >> $GITHUB_OUTPUT + + - name: Stop Anvil + if: always() + run: kill ${{ env.ANVIL_PID }} 2>/dev/null || true + + # ── Job 3: Diff & Report ───────────────────────────────────────────────────── + diff: + name: 🏮 Atupa Gas Diff + runs-on: ubuntu-latest + # Support manual override of hashes via workflow_dispatch + needs: [ baseline, target ] + permissions: + pull-requests: write # Required for posting PR comments + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + key: atupa-ci-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + + + - name: Install Foundry (for Anvil) + uses: foundry-rs/foundry-toolchain@v1 + + - name: Start Anvil (for diff RPC access) + shell: bash + run: | + anvil \ + --port ${{ env.ANVIL_PORT }} \ + --mnemonic "${{ env.ANVIL_MNEMONIC }}" \ + --steps-tracing \ + --silent & + echo "ANVIL_PID=$!" >> $GITHUB_ENV + for i in $(seq 1 20); do + cast block-number --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} &>/dev/null && break + sleep 0.5 + done + + # Resolve TX hashes: manual override > job outputs + - name: Resolve TX hashes + id: hashes + shell: bash + run: | + BASE="${{ github.event.inputs.base_tx || needs.baseline.outputs.tx_hash }}" + TARGET="${{ github.event.inputs.target_tx || needs.target.outputs.tx_hash }}" + echo "base=${BASE}" >> $GITHUB_OUTPUT + echo "target=${TARGET}" >> $GITHUB_OUTPUT + echo "📌 Comparing:" + echo " Base: $BASE" + echo " Target: $TARGET" + + # Use the Atupa composite action (the action.yml we just created) + - name: Run Atupa Gas Diff + uses: ./ + with: + base_tx: ${{ steps.hashes.outputs.base }} + target_tx: ${{ steps.hashes.outputs.target }} + rpc_url: 'http://127.0.0.1:${{ env.ANVIL_PORT }}' + config: 'atupa.toml' + post_comment: 'true' + upload_svg: 'true' + + - name: Stop Anvil + if: always() + run: kill ${{ env.ANVIL_PID }} 2>/dev/null || true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c31c652..97516d7 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -31,6 +31,11 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 + - name: Build Studio + run: | + cd studio + npm install + npm run build - name: Run clippy run: cargo clippy --workspace --all-targets -- -D warnings @@ -41,6 +46,11 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - name: Build Studio + run: | + cd studio + npm install + npm run build - name: Cargo Check run: cargo check --workspace - name: Run tests diff --git a/.gitignore b/.gitignore index 4edcc49..4186ae9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,10 +38,24 @@ profile_*.svg .ideanode_modules/ -# Atupa trace reports (local test artefacts) +# Atupa trace reports (local test artefacts — any *.json at workspace root) report.json *.report.json +*-report.json +arb-*.json +base-*.json +stylus-*.json + +# Atupa generated SVGs at workspace root +*.svg # Atupa Studio build output studio/dist/ studio/node_modules/ + +# Local artifacts +artifacts/ + +# Playground +playground/out/ +playground/cache/ diff --git a/Cargo.lock b/Cargo.lock index 034f440..a5264b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,15 +156,20 @@ dependencies = [ "atupa-parser", "atupa-rpc", "atupa-sdk", + "axum", "clap", "colored", "env_logger", "indicatif", "log", + "mime_guess", "open", + "rust-embed", "serde", "serde_json", "tokio", + "toml 1.1.2+spec-1.1.0", + "tower-http", ] [[package]] @@ -197,6 +202,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "dirs", "figment", "serde", "serde_json", @@ -263,6 +269,7 @@ version = "0.1.0" dependencies = [ "anyhow", "atupa-core", + "dirs", "log", "reqwest", "serde", @@ -280,6 +287,7 @@ dependencies = [ "atupa-adapters", "atupa-core", "atupa-lido", + "atupa-nitro", "atupa-output", "atupa-parser", "atupa-rpc", @@ -316,6 +324,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -337,6 +397,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -401,9 +470,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -423,9 +492,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -511,6 +580,56 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -662,6 +781,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -753,12 +882,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -773,6 +914,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1120,6 +1262,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "litemap" version = "0.8.2" @@ -1147,6 +1298,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1159,6 +1316,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.0" @@ -1208,6 +1375,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1436,6 +1609,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -1519,6 +1703,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1606,6 +1824,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1696,6 +1920,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1714,6 +1949,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1889,9 +2147,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2031,6 +2289,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2041,14 +2300,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2069,6 +2338,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -2088,6 +2358,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "uncased" version = "0.9.10" @@ -2097,6 +2373,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 9e852a3..559b305 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,8 +28,9 @@ categories = ["development-tools", "visualization", "cryptography::cryptocurrenc [workspace.dependencies] # CLI & Logging -clap = { version = "4.6.0", features = ["derive", "env"] } -open = "5" +clap = { version = "4.6.1", features = ["derive", "env"] } +dirs = "6.0.0" +open = "5.3.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" anyhow = "1.0.102" @@ -40,10 +41,14 @@ indicatif = "0.18.4" colored = "3.1.1" # Async & Network -tokio = { version = "1.51.0", features = ["full"] } +tokio = { version = "1.52.1", features = ["full"] } reqwest = { version = "0.13.2", features = ["json"] } futures = "0.3.32" chrono = { version = "0.4.44", features = ["serde"] } +axum = "0.8.9" +tower-http = { version = "0.6.8", features = ["fs", "cors"] } +mime_guess = "2.0.5" +rust-embed = "8.11.0" # Template & Configuration askama = "0.15.6" diff --git a/README.md b/README.md index 022ee36..a7e8785 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - **📊 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. @@ -37,6 +38,15 @@ cargo install atupa ``` +### 🏮 One-Click Initialization + +Bootstrap your project with Atupa profiling and automated CI regression in one command. + +```bash +# Detects Foundry/Hardhat and sets up atupa.toml + GitHub Action +atupa init +``` + ### Capturing a Unified Trace ```bash @@ -53,6 +63,17 @@ atupa audit --protocol lido --tx 0x... atupa diff --base 0x... --target 0x... ``` +## 🛡 Automated Gas Regression (CI) + +Atupa is designed to sit inside your CI/CD pipeline. Use `atupa init` to generate a `.github/workflows/atupa.yml` file that: +1. Runs your profile scripts on the base branch (baseline). +2. Runs your profile scripts on the pull request branch (target). +3. Compares results and fails the CI if gas regressions exceed your `atupa.toml` thresholds. + +```bash +atupa diff --base 0xBASE_TX --target 0xPR_TX --protocol lido +``` + ### Run the Demo ```bash atupa profile --demo diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..59c5b6d --- /dev/null +++ b/action.yml @@ -0,0 +1,173 @@ +name: 'Atupa Gas Regression Check' +description: | + Automated gas regression detection for Ethereum & Arbitrum Stylus transactions. + Runs atupa diff between two transactions, uploads an SVG flamegraph artifact, + and posts a sticky Markdown report to the Pull Request. +author: 'One Block Ltd' +branding: + icon: 'zap' + color: 'orange' + +# ─── Inputs ───────────────────────────────────────────────────────────────────── + +inputs: + base_tx: + description: 'Baseline transaction hash (e.g. last commit on main)' + required: true + target_tx: + description: 'Target transaction hash (e.g. current PR branch)' + required: true + rpc_url: + description: 'RPC endpoint for the target network' + required: false + default: 'http://localhost:8545' + protocol: + description: 'Optional: protocol-specific deep diff (aave | lido)' + required: false + default: '' + threshold: + description: 'Simple gas increase threshold % (overrides atupa.toml)' + required: false + default: '' + config: + description: 'Path to atupa.toml config file' + required: false + default: 'atupa.toml' + post_comment: + description: 'Post diff as a sticky PR comment (true | false)' + required: false + default: 'true' + upload_svg: + description: 'Upload the SVG flamegraph as a workflow artifact (true | false)' + required: false + default: 'true' + upload_json: + description: 'Upload the raw JSON trace reports for Atupa Studio (true | false)' + required: false + default: 'false' + +# ─── Outputs ──────────────────────────────────────────────────────────────────── + +outputs: + diff_md_path: + description: 'Path to the generated Markdown diff report' + value: ${{ steps.diff.outputs.md_path }} + diff_svg_path: + description: 'Path to the generated SVG flamegraph' + value: ${{ steps.diff.outputs.svg_path }} + regression_detected: + description: 'true if a gas regression threshold was exceeded' + value: ${{ steps.diff.outputs.regression_detected }} + +# ─── Steps ────────────────────────────────────────────────────────────────────── + +runs: + using: 'composite' + steps: + + # 1. Source-aware binary setup (stolen & improved from Stylus-Trace) + # - If we are INSIDE the Atupa repo, build from source and cache. + # - If we are in a CONSUMER repo, install from crates.io. + - name: Setup Atupa Binary + shell: bash + run: | + if [ -f "${{ github.workspace }}/bin/atupa/Cargo.toml" ]; then + echo "🏠 Running inside Atupa repo — building from source…" + if [ -d "${{ github.workspace }}/studio" ]; then + echo "🎨 Building Atupa Studio frontend…" + cd studio && npm install && npm run build && cd .. + fi + cargo build --release -p atupa + echo "${{ github.workspace }}/target/release" >> $GITHUB_PATH + elif command -v atupa &>/dev/null; then + echo "✅ atupa already installed: $(atupa --version)" + else + echo "📦 Installing atupa from crates.io…" + cargo install atupa --locked + fi + + # 2. Run diff — capture exit code without failing immediately + - name: Run Atupa Diff + id: diff + shell: bash + run: | + PROTO_FLAG="" + if [ -n "${{ inputs.protocol }}" ]; then + PROTO_FLAG="--protocol ${{ inputs.protocol }}" + fi + + THRESHOLD_FLAG="" + if [ -n "${{ inputs.threshold }}" ]; then + THRESHOLD_FLAG="--threshold ${{ inputs.threshold }}" + fi + + CONFIG_FLAG="" + if [ -f "${{ inputs.config }}" ]; then + CONFIG_FLAG="--config ${{ inputs.config }}" + fi + + JSON_FLAG="" + if [ "${{ inputs.upload_json }}" = "true" ]; then + JSON_FLAG="--output json" + fi + + # Run the diff, allow non-zero exit so we can post the comment before failing + atupa diff \ + -r "${{ inputs.rpc_url }}" \ + --base "${{ inputs.base_tx }}" \ + --target "${{ inputs.target_tx }}" \ + --markdown \ + --svg \ + $PROTO_FLAG \ + $THRESHOLD_FLAG \ + $CONFIG_FLAG \ + $JSON_FLAG && REGRESSION=false || REGRESSION=true + + # Discover output paths + BASE_SHORT="${{ inputs.base_tx }}" + TARGET_SHORT="${{ inputs.target_tx }}" + MD_PATH="artifacts/diff/${BASE_SHORT:0:10}_vs_${TARGET_SHORT:0:10}.md" + SVG_PATH="artifacts/diff/${BASE_SHORT:0:10}_vs_${TARGET_SHORT:0:10}.svg" + + echo "md_path=${MD_PATH}" >> $GITHUB_OUTPUT + echo "svg_path=${SVG_PATH}" >> $GITHUB_OUTPUT + echo "regression_detected=${REGRESSION}" >> $GITHUB_OUTPUT + + # 3. Upload SVG flamegraph as a workflow artifact (link appears in PR checks tab) + - name: Upload SVG Flamegraph + if: inputs.upload_svg == 'true' && always() + uses: actions/upload-artifact@v4 + with: + name: atupa-flamegraph-${{ inputs.base_tx != '' && inputs.base_tx || 'base' }} + path: ${{ steps.diff.outputs.svg_path }} + if-no-files-found: warn + retention-days: 30 + + # 3.5 Upload JSON Reports for Atupa Studio + - name: Upload JSON Reports + if: inputs.upload_json == 'true' && always() + uses: actions/upload-artifact@v4 + with: + name: atupa-reports-${{ inputs.base_tx != '' && inputs.base_tx || 'base' }} + path: artifacts/reports/ + if-no-files-found: warn + retention-days: 30 + + # 4. Post sticky PR comment (updates in-place on every new push to PR) + - name: Post Sticky PR Comment + if: inputs.post_comment == 'true' && github.event_name == 'pull_request' && always() + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: atupa-gas-diff + path: ${{ steps.diff.outputs.md_path }} + + # 5. Finally: fail the job if a regression was detected + - name: Enforce Regression Gate + shell: bash + run: | + if [ "${{ steps.diff.outputs.regression_detected }}" = "true" ]; then + echo "❌ Atupa detected a gas regression. See the PR comment for details." + exit 1 + fi + echo "✅ No gas regressions detected." diff --git a/artifacts/capture/test.json b/artifacts/capture/test.json new file mode 100644 index 0000000..94def93 --- /dev/null +++ b/artifacts/capture/test.json @@ -0,0 +1,212 @@ +{ + "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 new file mode 100644 index 0000000..5bd0352 --- /dev/null +++ b/artifacts/capture/test.svg @@ -0,0 +1 @@ +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/atupa.toml b/atupa.toml new file mode 100644 index 0000000..b1a4565 --- /dev/null +++ b/atupa.toml @@ -0,0 +1,5 @@ +[diff] +max_total_gas_increase_percent = 2.0 +max_execution_gas_increase_percent = 2.0 +max_evm_steps_increase = 100 +max_stylus_calls_increase = 0 diff --git a/bin/atupa/Cargo.toml b/bin/atupa/Cargo.toml index 67f58cd..d6b9cd2 100644 --- a/bin/atupa/Cargo.toml +++ b/bin/atupa/Cargo.toml @@ -6,15 +6,10 @@ authors = { workspace = true } license = { workspace = true } description = "atupa — Unified EVM + Stylus Execution Profiler CLI" -# Two binary targets so the tool works both as a standalone command -# and as a `cargo atupa` subcommand. -[[bin]] -name = "atupa" -path = "src/main.rs" +# Standalone binary target +# +# The binary is automatically inferred from src/main.rs as "atupa" -[[bin]] -name = "cargo-atupa" -path = "src/main.rs" [dependencies] # Atupa SDK @@ -36,11 +31,16 @@ tokio = { workspace = true } # Serialization serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } -# Logging & UX +# UX & Embedded anyhow = { workspace = true } log = { workspace = true } env_logger = { workspace = true } colored = { workspace = true } indicatif = { workspace = true } open = { workspace = true } +axum = { workspace = true } +tower-http = { workspace = true } +rust-embed = { workspace = true } +mime_guess = { workspace = true } diff --git a/bin/atupa/src/init.rs b/bin/atupa/src/init.rs new file mode 100644 index 0000000..43780c1 --- /dev/null +++ b/bin/atupa/src/init.rs @@ -0,0 +1,492 @@ +//! # `atupa init` +//! +//! Scaffolds all files required to integrate Atupa Gas Regression checking +//! into the current repository. Detects the project type (Foundry / Hardhat / +//! Stylus-only) and generates tailored configuration. + +use anyhow::{Context, Result}; +use colored::*; +use std::fs; +use std::path::Path; + +// ─── Embedded Templates ─────────────────────────────────────────────────────── +// +// All templates are baked directly into the binary at compile time using +// include_str!. This makes `atupa` a true zero-dependency setup tool. + +const ATUPA_TOML_FOUNDRY: &str = r#"# atupa.toml — Atupa Gas Regression Budget +# Generated by `atupa init` (Foundry project detected) +# https://github.com/One-Block-Org/Atupa + +[diff] +# Fail CI if total on-chain gas increases by more than this percentage. +max_total_gas_increase_percent = 2.0 + +# Fail CI if the EVM execution gas (excluding intrinsic cost) increases. +max_execution_gas_increase_percent = 2.0 + +# Maximum additional EVM opcode steps allowed across a change. +max_evm_steps_increase = 100 + +# Maximum additional Stylus cross-VM calls allowed (set 0 to disallow any). +max_stylus_calls_increase = 0 +"#; + +const ATUPA_TOML_STYLUS: &str = r#"# atupa.toml — Atupa Gas Regression Budget +# Generated by `atupa init` (Arbitrum Stylus project detected) +# https://github.com/One-Block-Org/Atupa + +[diff] +# Fail CI if total on-chain gas increases by more than this percentage. +max_total_gas_increase_percent = 2.0 + +# Tighter execution-gas budget for Stylus contracts (WASM is cheaper — keep it tight). +max_execution_gas_increase_percent = 1.0 + +# Maximum additional EVM opcode steps allowed. +max_evm_steps_increase = 50 + +# Maximum additional Stylus cross-VM calls allowed (set 0 to disallow any). +max_stylus_calls_increase = 0 +"#; + +const ATUPA_TOML_HARDHAT: &str = r#"# atupa.toml — Atupa Gas Regression Budget +# Generated by `atupa init` (Hardhat project detected) +# https://github.com/One-Block-Org/Atupa + +[diff] +# Fail CI if total on-chain gas increases by more than this percentage. +max_total_gas_increase_percent = 5.0 + +# EVM execution gas budget. Hardhat projects tend to have higher variance. +max_execution_gas_increase_percent = 5.0 + +# Maximum additional EVM opcode steps allowed. +max_evm_steps_increase = 500 + +# Maximum additional Stylus cross-VM calls allowed (0 = not a Stylus project). +max_stylus_calls_increase = 0 +"#; + +const WORKFLOW_YAML: &str = r#"# ───────────────────────────────────────────────────────────────────────────── +# Atupa Gas Regression — Auto-generated by `atupa init` +# +# On every PR against main: +# 1. Spins up Anvil for isolated gas benchmarking +# 2. Captures a BASELINE tx hash from main, and a TARGET hash from the PR +# 3. Runs `atupa diff` and posts a sticky Markdown report to the PR +# +# SETUP: Add ATUPA_RPC_URL to your GitHub Repository Secrets if you want +# to use a mainnet fork instead of a local Anvil node. +# ───────────────────────────────────────────────────────────────────────────── + +name: ⛽ Atupa Gas Regression + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + inputs: + base_tx: + description: 'Override base transaction hash' + required: false + target_tx: + description: 'Override target transaction hash' + required: false + +env: + CARGO_TERM_COLOR: always + RPC_URL: ${{ secrets.ATUPA_RPC_URL || 'http://127.0.0.1:8545' }} + ANVIL_PORT: 8545 + ANVIL_MNEMONIC: 'test test test test test test test test test test test junk' + +jobs: + baseline: + name: 📐 Baseline (main) + runs-on: ubuntu-latest + outputs: + tx_hash: ${{ steps.capture.outputs.tx_hash }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: foundry-rs/foundry-toolchain@v1 + - name: Start Anvil + run: | + anvil --port ${{ env.ANVIL_PORT }} --mnemonic "${{ env.ANVIL_MNEMONIC }}" --steps-tracing --silent & + sleep 2 + - name: Deploy & capture baseline TX + id: capture + env: + PRIVATE_KEY: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + run: | + SCRIPT="${{ vars.PROFILE_SCRIPT || 'script/AtupaProfile.s.sol' }}" + if [ -f "$SCRIPT" ]; then + TX=$(forge script "$SCRIPT" --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} --private-key $PRIVATE_KEY --broadcast --json 2>/dev/null | jq -r '.receipts[-1].transactionHash') + else + TX=$(cast send --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} --private-key $PRIVATE_KEY --json 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --value 0.001ether | jq -r '.transactionHash') + fi + echo "tx_hash=${TX}" >> $GITHUB_OUTPUT + + target: + name: 🎯 Target (PR branch) + runs-on: ubuntu-latest + outputs: + tx_hash: ${{ steps.capture.outputs.tx_hash }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: foundry-rs/foundry-toolchain@v1 + - name: Start Anvil + run: | + anvil --port ${{ env.ANVIL_PORT }} --mnemonic "${{ env.ANVIL_MNEMONIC }}" --steps-tracing --silent & + sleep 2 + - name: Deploy & capture target TX + id: capture + env: + PRIVATE_KEY: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + run: | + SCRIPT="${{ vars.PROFILE_SCRIPT || 'script/AtupaProfile.s.sol' }}" + if [ -f "$SCRIPT" ]; then + TX=$(forge script "$SCRIPT" --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} --private-key $PRIVATE_KEY --broadcast --json 2>/dev/null | jq -r '.receipts[-1].transactionHash') + else + TX=$(cast send --rpc-url http://127.0.0.1:${{ env.ANVIL_PORT }} --private-key $PRIVATE_KEY --json 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --value 0.001ether | jq -r '.transactionHash') + fi + echo "tx_hash=${TX}" >> $GITHUB_OUTPUT + + diff: + name: 🏮 Atupa Gas Diff + runs-on: ubuntu-latest + needs: [ baseline, target ] + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: foundry-rs/foundry-toolchain@v1 + - name: Start Anvil + run: | + anvil --port ${{ env.ANVIL_PORT }} --mnemonic "${{ env.ANVIL_MNEMONIC }}" --steps-tracing --silent & + sleep 2 + - name: Resolve TX hashes + id: hashes + run: | + BASE="${{ github.event.inputs.base_tx || needs.baseline.outputs.tx_hash }}" + TARGET="${{ github.event.inputs.target_tx || needs.target.outputs.tx_hash }}" + echo "base=${BASE}" >> $GITHUB_OUTPUT + echo "target=${TARGET}" >> $GITHUB_OUTPUT + - name: Run Atupa Gas Diff + uses: One-Block-Org/Atupa@main + with: + base_tx: ${{ steps.hashes.outputs.base }} + target_tx: ${{ steps.hashes.outputs.target }} + rpc_url: 'http://127.0.0.1:${{ env.ANVIL_PORT }}' + protocol: '${{ vars.ATUPA_PROTOCOL || '' }}' + config: 'atupa.toml' + post_comment: 'true' + upload_svg: 'true' + upload_json: 'true' +"#; + +const FORGE_PROFILE_SCRIPT: &str = r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +// AtupaProfile.s.sol — Auto-generated by `atupa init` +// +// Fill in the contract deployment and method call you want to benchmark. +// Atupa will run this script on both `main` and your PR branch, then +// compare the gas cost of the final transaction. +// +// TIP: Focus this script on the ONE operation that matters most for gas. + +import {Script} from "forge-std/Script.sol"; + +// TODO: Import your contract here +// import {MyContract} from "../src/MyContract.sol"; + +contract AtupaProfile is Script { + function run() external { + vm.startBroadcast(); + + // ── STEP 1: Deploy your contract ────────────────────────────────────── + // MyContract myContract = new MyContract(); + + // ── STEP 2: Call the method you want to benchmark ───────────────────── + // This is the transaction Atupa will profile and compare across branches. + // myContract.myExpensiveFunction(arg1, arg2); + + // ── PLACEHOLDER (remove once you add your contract above) ───────────── + // Sends a simple transfer so CI doesn't fail on an empty script. + payable(address(0xdead)).transfer(1 wei); + + vm.stopBroadcast(); + } +} +"#; + +const HARDHAT_PROFILE_SCRIPT: &str = r#"// AtupaProfile.js — Auto-generated by `atupa init` +// +// Fill in the contract deployment and method call you want to benchmark. +// Atupa will profile the last transaction emitted by this script. + +const { ethers } = require("hardhat"); + +async function main() { + const [deployer] = await ethers.getSigners(); + + // ── STEP 1: Deploy your contract ────────────────────────────────────────── + // const MyContract = await ethers.getContractFactory("MyContract"); + // const myContract = await MyContract.deploy(); + // await myContract.waitForDeployment(); + + // ── STEP 2: Call the method you want to benchmark ───────────────────────── + // const tx = await myContract.myExpensiveFunction(arg1, arg2); + // await tx.wait(); + // console.log(tx.hash); // <-- Atupa reads this hash + + // ── PLACEHOLDER ─────────────────────────────────────────────────────────── + const tx = await deployer.sendTransaction({ + to: "0x000000000000000000000000000000000000dEaD", + value: ethers.parseEther("0.001"), + }); + await tx.wait(); + console.log(tx.hash); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +"#; + +// ─── Project Detection ──────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq)] +pub enum ProjectKind { + Foundry, + Hardhat, + StylusOnly, + Unknown, +} + +impl ProjectKind { + pub fn label(&self) -> &'static str { + match self { + ProjectKind::Foundry => "Foundry", + ProjectKind::Hardhat => "Hardhat", + ProjectKind::StylusOnly => "Arbitrum Stylus (Rust-only)", + ProjectKind::Unknown => "Unknown", + } + } +} + +pub fn detect_project() -> ProjectKind { + if Path::new("foundry.toml").exists() || Path::new("forge.toml").exists() { + return ProjectKind::Foundry; + } + if Path::new("hardhat.config.js").exists() + || Path::new("hardhat.config.ts").exists() + || Path::new("hardhat.config.mjs").exists() + { + return ProjectKind::Hardhat; + } + // Stylus-only: Cargo.toml present but no JS/TS toolchain + if Path::new("Cargo.toml").exists() { + return ProjectKind::StylusOnly; + } + ProjectKind::Unknown +} + +pub fn detect_protocol() -> Option { + // Check for common protocol keywords in project files + let keywords = [("lido", "lido"), ("aave", "aave"), ("gho", "aave")]; + + // Check package.json or foundry.toml if they exist + let files = ["package.json", "foundry.toml", "Cargo.toml"]; + for file in files { + if let Ok(content) = fs::read_to_string(file) { + let content_lower = content.to_lowercase(); + for (kw, proto) in keywords { + if content_lower.contains(kw) { + return Some(proto.to_string()); + } + } + } + } + None +} + +// ─── Init Arguments ─────────────────────────────────────────────────────────── + +pub struct InitArgs { + pub force: bool, +} + +// ─── Public Entry Point ─────────────────────────────────────────────────────── + +pub fn execute_init(args: InitArgs) -> Result<()> { + println!(); + println!("{}", "🏮 Atupa — Initializing project integration".bold()); + println!("{}", "─".repeat(55).dimmed()); + println!(); + + // ── Detect Project ──────────────────────────────────────────────────────── + let kind = detect_project(); + println!( + " {} {}", + "🔍 Detected project type:".bold(), + kind.label().cyan().bold() + ); + + // Attempt to detect protocol + let protocol = detect_protocol(); + if let Some(p) = &protocol { + println!( + " {} {}", + "💉 Detected protocol adapter:".bold(), + p.cyan().bold() + ); + } + println!(); + + let mut created: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + + // ── 1. atupa.toml ───────────────────────────────────────────────────────── + let toml_content = match kind { + ProjectKind::Foundry => ATUPA_TOML_FOUNDRY, + ProjectKind::Hardhat => ATUPA_TOML_HARDHAT, + ProjectKind::StylusOnly => ATUPA_TOML_STYLUS, + ProjectKind::Unknown => ATUPA_TOML_FOUNDRY, // sensible default + }; + + scaffold_file( + "atupa.toml", + toml_content, + args.force, + &mut created, + &mut skipped, + )?; + + // ── 2. .github/workflows/atupa.yml ─────────────────────────────────────── + let workflow_dir = Path::new(".github/workflows"); + fs::create_dir_all(workflow_dir).context("Failed to create .github/workflows directory")?; + + scaffold_file( + ".github/workflows/atupa.yml", + WORKFLOW_YAML, + args.force, + &mut created, + &mut skipped, + )?; + + // ── 3. Profile Script (project-specific) ───────────────────────────────── + match kind { + ProjectKind::Foundry | ProjectKind::StylusOnly => { + fs::create_dir_all("script").context("Failed to create script/ directory")?; + scaffold_file( + "script/AtupaProfile.s.sol", + FORGE_PROFILE_SCRIPT, + args.force, + &mut created, + &mut skipped, + )?; + } + ProjectKind::Hardhat => { + fs::create_dir_all("scripts").context("Failed to create scripts/ directory")?; + scaffold_file( + "scripts/AtupaProfile.js", + HARDHAT_PROFILE_SCRIPT, + args.force, + &mut created, + &mut skipped, + )?; + } + ProjectKind::Unknown => { + // Both — let the user decide + fs::create_dir_all("script").ok(); + scaffold_file( + "script/AtupaProfile.s.sol", + FORGE_PROFILE_SCRIPT, + args.force, + &mut created, + &mut skipped, + )?; + } + } + + // ── Print Summary ───────────────────────────────────────────────────────── + println!(); + for path in &created { + println!(" {} {}", "✅ Created".green().bold(), path.cyan()); + } + for path in &skipped { + println!( + " {} {} {}", + "⚠️ Skipped".yellow(), + path.dimmed(), + "(already exists — use --force to overwrite)".dimmed() + ); + } + + println!(); + println!("{}", "─".repeat(55).dimmed()); + println!("{}", " 🚀 Next Steps".bold().underline()); + println!("{}", "─".repeat(55).dimmed()); + println!(); + + match kind { + ProjectKind::Foundry | ProjectKind::StylusOnly | ProjectKind::Unknown => { + println!( + " {} Edit {} to add your contract call.", + "1.".bold(), + "script/AtupaProfile.s.sol".cyan() + ); + } + ProjectKind::Hardhat => { + println!( + " {} Edit {} to add your contract call.", + "1.".bold(), + "scripts/AtupaProfile.js".cyan() + ); + } + } + + println!( + " {} Add {} to your GitHub Repository Secrets.", + "2.".bold(), + "ATUPA_RPC_URL".cyan() + ); + println!( + " {} Open a Pull Request — Atupa will automatically comment with a gas diff.", + "3.".bold() + ); + println!(); + println!( + " {} {}", + "Docs:".dimmed(), + "https://github.com/One-Block-Org/Atupa".dimmed() + ); + println!(); + + Ok(()) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +fn scaffold_file( + path: &str, + content: &str, + force: bool, + created: &mut Vec, + skipped: &mut Vec, +) -> Result<()> { + if Path::new(path).exists() && !force { + skipped.push(path.to_string()); + return Ok(()); + } + fs::write(path, content).with_context(|| format!("Failed to write {path}"))?; + created.push(path.to_string()); + Ok(()) +} diff --git a/bin/atupa/src/main.rs b/bin/atupa/src/main.rs index 67b7bb3..513ecda 100644 --- a/bin/atupa/src/main.rs +++ b/bin/atupa/src/main.rs @@ -7,15 +7,13 @@ //! ```text //! atupa profile --tx [--rpc ] [--out trace.svg] [--demo] //! atupa capture --tx [--rpc ] [--output summary|json|metric] [--file report.json] +//! [--profile] [--etherscan-key ] [--studio] //! atupa audit --tx [--rpc ] [--protocol aave|lido] //! atupa diff --base --target [--rpc ] //! ``` //! -//! Can also be invoked as a `cargo` subcommand: -//! -//! ```text -//! cargo atupa profile --tx -//! ``` +//! ## Standalone Usage +//! Atupa is designed to be used as a standalone CLI tool. use anyhow::{Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; @@ -25,10 +23,19 @@ use std::time::Duration; use atupa_aave::AaveDeepTracer; use atupa_core::TraceStep; +use atupa_core::config::AtupaConfig; use atupa_lido::LidoDeepTracer; use atupa_nitro::{NitroClient, StitchedReport, VmKind}; -use atupa_rpc::RawStructLog; +use atupa_output::SvgGenerator; +use atupa_parser::Parser as TraceParser; +use atupa_parser::aggregator::Aggregator; +use atupa_rpc::{EthClient, RawStructLog}; + +mod init; +mod studio; +mod thresholds; +use thresholds::AtupaConfigToml; // ─── CLI Definition ──────────────────────────────────────────────────────────── #[derive(Parser)] @@ -44,14 +51,8 @@ SOURCE: https://github.com/One-Block-Org/Atupa", )] struct Cli { /// Arbitrum / Ethereum RPC endpoint (or set ATUPA_RPC_URL) - #[arg( - short, - long, - global = true, - env = "ATUPA_RPC_URL", - default_value = "http://localhost:8547" - )] - rpc: String, + #[arg(short, long, global = true, value_name = "URL")] + rpc: Option, #[command(subcommand)] command: Commands, @@ -74,23 +75,38 @@ enum Commands { out: Option, /// Etherscan API key for contract name resolution - #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")] + #[arg(long, value_name = "KEY")] etherscan_key: Option, }, - /// Capture a unified EVM + Stylus execution trace (Arbitrum Nitro) + /// Capture a unified EVM + Stylus execution trace (Arbitrum Nitro). + /// + /// Add --profile to also generate an SVG flamegraph from the same RPC call. + /// Add --studio to automatically launch Atupa Studio with the report loaded. Capture { /// Transaction hash to profile (0x-prefixed) #[arg(short, long, value_name = "TX_HASH")] tx: String, - /// Output format + /// Output format for the JSON/summary report #[arg(short, long, value_enum, default_value_t = OutputFormat::Summary)] output: OutputFormat, - /// Write output to a file (e.g. --file report.json) + /// Write report to a file instead of stdout #[arg(short = 'f', long, value_name = "FILE")] file: Option, + + /// Also generate an SVG flamegraph (reuses the same RPC trace) + #[arg(long, default_value_t = false)] + profile: bool, + + /// Etherscan API key for contract name resolution + #[arg(long, value_name = "KEY")] + etherscan_key: Option, + + /// Launch Atupa Studio after capture and open it in the browser + #[arg(long, default_value_t = false)] + studio: bool, }, /// Protocol-aware execution auditing (Aave v3/GHO, Lido) @@ -113,6 +129,30 @@ enum Commands { /// Target transaction hash (0x-prefixed) #[arg(short, long, value_name = "TARGET_TX")] target: String, + + /// Simple mode override: Fail CI if gas increases by > X% + #[arg(long, value_name = "PERCENT")] + threshold: Option, + + /// Path to atupa.toml (defaults to looking in CWD) + #[arg(long, value_name = "FILE")] + config: Option, + + /// Generate artifacts/diff/report.md for GitHub PRs + #[arg(long, default_value_t = false)] + markdown: bool, + + /// Generate visual diff flamegraph in artifacts/diff/ + #[arg(long, default_value_t = false)] + svg: bool, + + /// Output format (summary | json | markdown) + #[arg(short, long, value_enum, default_value_t = OutputFormat::Summary)] + output: OutputFormat, + + /// Optional: Run DeepTracer on both and diff heuristics + #[arg(short, long, value_enum)] + protocol: Option, }, /// Launch Atupa Studio — the local web visualizer for trace reports @@ -122,16 +162,26 @@ enum Commands { port: u16, /// Path to the studio directory (overrides auto-detection) - #[arg(long, env = "ATUPA_STUDIO_DIR", value_name = "DIR")] + #[arg(long, value_name = "DIR")] dir: Option, /// Open the browser automatically after the server starts #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] open: bool, }, + + /// Scaffold Atupa config, GitHub Actions workflow, and a profile script + /// + /// Run this once in a new repository to get started. + /// Detects Foundry, Hardhat, or Stylus projects automatically. + Init { + /// Overwrite existing files + #[arg(long, default_value_t = false)] + force: bool, + }, } -#[derive(Clone, ValueEnum, Debug)] +#[derive(Clone, ValueEnum, Debug, PartialEq, Eq)] enum OutputFormat { /// Human-readable terminal summary (default) Summary, @@ -153,18 +203,7 @@ enum Protocol { #[tokio::main] async fn main() -> Result<()> { - // Support `cargo atupa ` by stripping the extra "atupa" argv[1] that - // cargo inserts when it dispatches to a cargo- subcommand binary. - let raw: Vec = std::env::args_os().collect(); - let args = if raw.get(1).and_then(|s| s.to_str()) == Some("atupa") { - raw.into_iter() - .enumerate() - .filter(|(i, _)| *i != 1) - .map(|(_, a)| a) - .collect::>() - } else { - raw - }; + let args = std::env::args_os(); env_logger::builder() .filter_level(log::LevelFilter::Warn) @@ -172,6 +211,11 @@ async fn main() -> Result<()> { .init(); let cli = Cli::parse_from(args); + let mut config = AtupaConfig::load(); + + if let Some(r) = cli.rpc { + config.rpc_url = r; + } print_banner(); @@ -182,19 +226,63 @@ async fn main() -> Result<()> { out, etherscan_key, } => { - cmd_profile(&cli.rpc, &tx, demo, out, etherscan_key).await?; + if let Some(key) = etherscan_key { + config.etherscan_key = Some(key); + } + cmd_profile(&config, &tx, demo, out).await?; } - Commands::Capture { tx, output, file } => { - cmd_capture(&cli.rpc, &tx, output, file).await?; + Commands::Capture { + tx, + output, + file, + profile, + etherscan_key, + studio, + } => { + if let Some(key) = etherscan_key { + config.etherscan_key = Some(key); + } + let report_path = cmd_capture(&config, &tx, output, file, profile).await?; + if studio { + // Pass the generated report path to Studio for auto-load + cmd_studio(&config, config.studio_port, true, report_path).await?; + } } Commands::Audit { tx, protocol } => { - cmd_audit(&cli.rpc, &tx, protocol).await?; + cmd_audit(&config, &tx, protocol).await?; } - Commands::Diff { base, target } => { - cmd_diff(&cli.rpc, &base, &target).await?; + Commands::Diff { + base, + target, + threshold, + config: diff_config, + markdown, + svg, + protocol, + output, + } => { + cmd_diff( + &config, + &base, + &target, + threshold, + diff_config, + markdown, + svg, + output, + protocol, + ) + .await?; } Commands::Studio { port, dir, open } => { - cmd_studio(port, dir, open).await?; + if let Some(d) = dir { + config.studio_dir = Some(std::path::PathBuf::from(d)); + } + config.studio_port = port; + cmd_studio(&config, port, open, None).await?; + } + Commands::Init { force } => { + init::execute_init(init::InitArgs { force })?; } } @@ -204,11 +292,10 @@ async fn main() -> Result<()> { // ─── Profile Command ────────────────────────────────────────────────────────── async fn cmd_profile( - rpc: &str, + config: &AtupaConfig, tx: &str, demo: bool, out: Option, - etherscan_key: Option, ) -> Result<()> { if !demo && tx.is_empty() { anyhow::bail!( @@ -219,71 +306,185 @@ async fn cmd_profile( let display = if demo { "demo" } else { tx }; eprintln!("{} {}", "→ Profiling:".bold(), display.cyan()); - eprintln!("{} {}\n", "→ Endpoint: ".bold(), rpc.dimmed()); + eprintln!("{} {}\n", "→ Endpoint: ".bold(), config.rpc_url.dimmed()); - atupa::execute_profile(tx, rpc, demo, out, etherscan_key) - .await - .context("Profile command failed") + // Route output through the standard artifacts directory (same as capture) + let svg_path = resolve_artifact_path(out, "profile", tx, "svg"); + + let (out_path, network) = atupa::execute_profile( + tx, + &config.rpc_url, + demo, + Some(svg_path), + config.etherscan_key.clone(), + ) + .await + .context("Profile command failed")?; + + eprintln!(); + eprintln!( + " {} ({})", + "PROFILE COMPLETE".bold().underline(), + network.cyan() + ); + let div = "─".repeat(40).dimmed().to_string(); + eprintln!("{div}"); + eprintln!( + " {:<24} {}", + "SVG saved to:".bold(), + out_path.green().bold() + ); + eprintln!("{div}"); + Ok(()) } // ─── Capture Command ────────────────────────────────────────────────────────── async fn cmd_capture( - rpc: &str, + config: &AtupaConfig, tx: &str, format: OutputFormat, file: Option, -) -> Result<()> { + generate_profile: bool, +) -> Result> { let tx = normalise_hash(tx); eprintln!("{} {}", "→ Transaction:".bold(), tx.cyan()); - eprintln!("{} {}\n", "→ Endpoint: ".bold(), rpc.dimmed()); + eprintln!("{} {}\n", "→ Endpoint: ".bold(), config.rpc_url.dimmed()); // Phase 1: fetch ────────────────────────────────────────────────────────── - let pb = spinner("Fetching dual-VM traces from Arbitrum Nitro…"); - let client = NitroClient::new(rpc.to_string()); + let pb = spinner("Detecting network and fetching execution trace…"); + let client = NitroClient::new(config.rpc_url.clone()); - let report = client + let mut report = client .trace_transaction(&tx) .await - .context("Failed to fetch trace — is the Arbitrum node running?")?; + .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!( - "{} Fetched {} EVM steps + {} Stylus HostIOs", + "{} Captured trace from {} ({} EVM steps{} )", "✔".green().bold(), + network_name.cyan().bold(), evm_count(&report).to_string().green(), - report.stylus_steps().len().to_string().yellow(), + if report.total_stylus_ink > 0 { + format!( + " + {} Stylus HostIOs", + report.stylus_steps().len().to_string().yellow() + ) + } else { + "".into() + } )); - // Phase 2: render ───────────────────────────────────────────────────────── + // 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 => render_capture_summary(&report), + OutputFormat::Summary => summary_text.clone(), OutputFormat::Json => serde_json::to_string_pretty(&report)?, OutputFormat::Metric => format!("{:.4}", report.total_unified_cost), }; pb2.finish_with_message(format!("{} Report ready.", "✔".green().bold())); eprintln!(); + println!("{}", summary_text); + eprintln!(); + + // Phase 4: output ───────────────────────────────────────────────────────── + let report_path = resolve_artifact_path(file, "capture", &tx, "json"); + + std::fs::write(&report_path, &rendered) + .with_context(|| format!("Failed to write report to '{report_path}'"))?; - // Phase 3: output ───────────────────────────────────────────────────────── - if let Some(path) = file { - std::fs::write(&path, &rendered) - .with_context(|| format!("Failed to write report to '{path}'"))?; + eprintln!( + "{} Report saved to {}", + "✔".green().bold(), + report_path.cyan().bold() + ); + + if let Some(ref svg) = svg_path { eprintln!( - "{} Report written to {}", + "{} SVG profile saved to {}", "✔".green().bold(), - path.cyan().bold() + svg.cyan().bold() ); - } else { - println!("{}", rendered); } - Ok(()) + Ok(Some(report_path)) } // ─── Audit Command ──────────────────────────────────────────────────────────── -async fn cmd_audit(rpc: &str, tx: &str, protocol: Protocol) -> Result<()> { +async fn cmd_audit(config: &AtupaConfig, tx: &str, protocol: Protocol) -> Result<()> { let tx = normalise_hash(tx); let label = match protocol { Protocol::Aave => "Aave v3 + GHO", @@ -296,10 +497,18 @@ async fn cmd_audit(rpc: &str, tx: &str, protocol: Protocol) -> Result<()> { label.yellow().bold(), tx.cyan() ); - eprintln!("{} {}\n", "→ Endpoint:".bold(), rpc.dimmed()); + eprintln!("{} {}\n", "→ Endpoint:".bold(), config.rpc_url.dimmed()); + + let eth_client = EthClient::new(config.rpc_url.clone()); + let client = NitroClient::new(config.rpc_url.clone()); + + // Fetch the top-level calldata selector (non-fatal) — gives us the real function being called + let top_level_selector = eth_client + .get_transaction_input(&tx) + .await + .and_then(|input| EthClient::selector_from_input(&input)); let pb = spinner(&format!("Fetching trace for {label} audit…")); - let client = NitroClient::new(rpc.to_string()); let report = client .trace_transaction(&tx) @@ -331,7 +540,7 @@ async fn cmd_audit(rpc: &str, tx: &str, protocol: Protocol) -> Result<()> { pb2.finish_with_message(format!("{} Aave v3 adapter complete.", "✔".green().bold())); eprintln!(); - print_aave_report(&liq, &report); + print_aave_report(&liq, &report, top_level_selector.as_deref()); } Protocol::Lido => { let pb2 = spinner("Applying Lido stETH protocol adapter…"); @@ -354,7 +563,7 @@ async fn cmd_audit(rpc: &str, tx: &str, protocol: Protocol) -> Result<()> { "✔".green().bold() )); eprintln!(); - print_lido_report(&res, &report); + print_lido_report(&res, &report, top_level_selector.as_deref()); } } @@ -363,7 +572,19 @@ async fn cmd_audit(rpc: &str, tx: &str, protocol: Protocol) -> Result<()> { // ─── Diff Command ───────────────────────────────────────────────────────────── -async fn cmd_diff(rpc: &str, base: &str, target: &str) -> Result<()> { +#[allow(clippy::too_many_arguments)] +#[allow(clippy::collapsible_if)] +async fn cmd_diff( + config: &AtupaConfig, + base: &str, + target: &str, + threshold: Option, + diff_config: Option, + markdown: bool, + svg: bool, + output_format: OutputFormat, + protocol: Option, +) -> Result<()> { let base = normalise_hash(base); let target = normalise_hash(target); @@ -374,192 +595,474 @@ async fn cmd_diff(rpc: &str, base: &str, target: &str) -> Result<()> { "Target:".bold(), target.yellow() ); - eprintln!("{} {}\n", "→ Endpoint:".bold(), rpc.dimmed()); + eprintln!("{} {}\n", "→ Endpoint:".bold(), config.rpc_url.dimmed()); - let client = NitroClient::new(rpc.to_string()); + let client = NitroClient::new(config.rpc_url.clone()); + let eth_client = EthClient::new(config.rpc_url.clone()); - let pb = spinner("Fetching both traces concurrently…"); + 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")?; - pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); + // Fetch receipts for actual gas used + let (base_receipt_gas, target_receipt_gas) = tokio::join!( + eth_client.get_gas_used(&base), + eth_client.get_gas_used(&target), + ); + + pb.finish_with_message(format!("{} Both traces fetched.", "✔".green().bold())); eprintln!(); - // Cost delta - let base_cost = base_report.total_unified_cost; - let target_cost = target_report.total_unified_cost; - let delta = target_cost - base_cost; - let pct = if base_cost > 0.0 { - delta / base_cost * 100.0 + // Cost deltas + let base_unified_cost = base_report.total_unified_cost; + let target_unified_cost = target_report.total_unified_cost; + let unified_delta = target_unified_cost - base_unified_cost; + let unified_pct = if base_unified_cost > 0.0 { + unified_delta / base_unified_cost * 100.0 } else { 0.0 }; - let div = "─".repeat(56).dimmed().to_string(); + let base_total_gas = base_receipt_gas.unwrap_or(base_unified_cost as u64); + let target_total_gas = target_receipt_gas.unwrap_or(target_unified_cost as u64); + let total_gas_delta = target_total_gas as f64 - base_total_gas as f64; + let total_gas_pct = if base_total_gas > 0 { + total_gas_delta / base_total_gas as f64 * 100.0 + } else { + 0.0 + }; + + let base_intrinsic = base_total_gas.saturating_sub(base_unified_cost as u64); + let target_intrinsic = target_total_gas.saturating_sub(target_unified_cost as u64); + + let div = "─".repeat(70).dimmed().to_string(); + println!("{}", " EXECUTION DIFF".bold().underline()); println!("{div}"); + + // Print Table Header println!( - " {:<30} {}", - "Base unified cost (gas):".bold(), - format!("{base_cost:.2}").green() + " {:<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!( - " {:<30} {}", - "Target unified cost (gas):".bold(), - format!("{target_cost:.2}").yellow() + " {:<25} {:<15} {:<15} {}", + "Total On-Chain Gas:", + base_total_gas.to_string().green(), + target_total_gas.to_string().yellow(), + colorize_delta(total_gas_delta, total_gas_pct) ); - println!("{div}"); - let sign = if delta >= 0.0 { "+" } else { "" }; - let color = if delta > 0.0 { - format!("{sign}{delta:.2}").red().to_string() - } else if delta < 0.0 { - format!("{sign}{delta:.2}").green().to_string() + println!( + " {:<25} {:<15} {:<15} {}", + "↳ Execution Gas (EVM):", + base_unified_cost.to_string().cyan(), + target_unified_cost.to_string().cyan(), + colorize_delta(unified_delta, unified_pct) + ); + + let intrinsic_delta = target_intrinsic as f64 - base_intrinsic as f64; + let intrinsic_pct = if base_intrinsic > 0 { + intrinsic_delta / base_intrinsic as f64 * 100.0 } else { - format!("{sign}{delta:.2}").dimmed().to_string() + 0.0 }; println!( - " {:<30} {} ({sign}{pct:.1}%)", - "Δ Unified Cost:".bold(), - color + " {:<25} {:<15} {:<15} {}", + "↳ Intrinsic Gas:", + base_intrinsic.to_string().dimmed(), + target_intrinsic.to_string().dimmed(), + colorize_delta(intrinsic_delta, intrinsic_pct) ); + println!("{div}"); // Step count comparison let base_evm = evm_count(&base_report); let tgt_evm = evm_count(&target_report); + let evm_delta = tgt_evm as f64 - base_evm as f64; + let evm_pct = if base_evm > 0 { + evm_delta / base_evm as f64 * 100.0 + } else { + 0.0 + }; println!( - " {:<30} {} EVM | {} Stylus", - "Base steps:".bold(), + " {:<25} {:<15} {:<15} {}", + "EVM Steps:", base_evm.to_string().green(), - base_report.stylus_steps().len().to_string().yellow() + 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!( - " {:<30} {} EVM | {} Stylus", - "Target steps:".bold(), - tgt_evm.to_string().green(), - target_report.stylus_steps().len().to_string().yellow() + " {:<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}"); - Ok(()) -} + // ── 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}"); + } + } + } -// ─── Studio Command ─────────────────────────────────────────────────────────── + let format_plain_delta = |delta: f64, pct: f64| -> String { + let sign = if delta >= 0.0 { "+" } else { "" }; + format!("{sign}{delta:.0} ({sign}{pct:.1}%)") + }; -/// Resolve the studio directory using a three-tier strategy: -/// 1. Explicit CLI flag / ATUPA_STUDIO_DIR env var -/// 2. `/studio/` (running from workspace root) -/// 3. `/studio/` (local dev install) -fn resolve_studio_path(explicit: Option) -> Result { - if let Some(p) = explicit { - let path = std::path::PathBuf::from(&p); - if path.is_dir() { - return Ok(path); - } - anyhow::bail!("ATUPA_STUDIO_DIR / --dir points to a non-existent directory: {p}"); + 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()); } - // CWD/studio - let cwd_studio = std::env::current_dir()?.join("studio"); - if cwd_studio.is_dir() { - return Ok(cwd_studio); + 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()); } - // binary-sibling/studio - if let Ok(exe) = std::env::current_exe() { - if let Some(exe_dir) = exe.parent() { - let sib = exe_dir.join("studio"); - if sib.is_dir() { - return Ok(sib); + // 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 + )); + } } } } - anyhow::bail!( - "Could not locate the Atupa Studio directory.\n\ - Run this command from the project root, or set ATUPA_STUDIO_DIR." - ) + // 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(()) } -async fn cmd_studio(port: u16, dir: Option, launch_browser: bool) -> Result<()> { - let studio_dir = resolve_studio_path(dir)?; - eprintln!( - "{} {}", - "→ Studio:".bold(), - studio_dir.display().to_string().cyan() - ); +// ─── Studio Command ─────────────────────────────────────────────────────────── - // ── Ensure node_modules is present ──────────────────────────────────────── - if !studio_dir.join("node_modules").exists() { - eprintln!("{}", "→ node_modules not found — running npm install…".dimmed()); - let status = std::process::Command::new("npm") - .arg("install") - .current_dir(&studio_dir) - .status() - .context("Failed to run `npm install` — is Node.js installed?")?; - if !status.success() { - anyhow::bail!("`npm install` failed. Check the output above."); - } +async fn cmd_studio( + _config: &AtupaConfig, + port: u16, + launch_browser: bool, + report_path: Option, +) -> Result<()> { + // 1. Read report if provided + let report_content = if let Some(path) = report_path.as_ref() { + Some(std::fs::read_to_string(path).context("Failed to read report file for Studio")?) + } else { + None + }; + + // 2. Prepare the server + let server = studio::StudioServer::new(report_content); + let mut url = format!("http://localhost:{port}/"); + if report_path.is_some() { + url += "?auto=true"; } - // ── Spawn the Vite dev server ────────────────────────────────────────────── - let url = format!("http://localhost:{port}/"); - eprintln!("{} {}\n", "→ Starting dev server at".bold(), url.cyan().bold()); + eprintln!("{} Launching Atupa Studio...", "→".bold().cyan()); - let mut child = std::process::Command::new("npm") - .args(["run", "dev", "--", "--port", &port.to_string(), "--host"]) - .current_dir(&studio_dir) - .spawn() - .context("Failed to spawn `npm run dev`")?; + // Spawn server in background + let server_handle = tokio::spawn(async move { + if let Err(e) = server.start(port).await { + eprintln!("\n{} Studio server error: {e}", "⚠".red().bold()); + } + }); - // ── Poll until the port opens (max 15 s) ────────────────────────────────── - let pb = spinner("Waiting for Studio to start…"); + // Wait for the port to be active let addr = format!("127.0.0.1:{port}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); - - loop { - if std::net::TcpStream::connect(&addr).is_ok() { - break; - } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::net::TcpStream::connect(&addr).is_err() { if std::time::Instant::now() > deadline { - let _ = child.kill(); - anyhow::bail!("Studio did not start within 15 s on port {port}."); + anyhow::bail!("Studio server failed to start on port {port} within 5s."); } - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; } - pb.finish_with_message(format!( + eprintln!( "{} Studio ready at {}", "✔".green().bold(), url.cyan().bold() - )); + ); - // ── Open browser ────────────────────────────────────────────────────────── - if launch_browser { - if let Err(e) = open::that(&url) { - eprintln!("{} Could not open browser: {e}", "⚠".yellow()); - } + // 3. Open browser + if launch_browser && let Err(e) = open::that(&url) { + eprintln!("{} Could not open browser: {e}", "⚠".yellow()); } - eprintln!( - "\n{}\n{}\n{}", - " Press Ctrl+C to stop the Studio server.".dimmed(), - format!(" Drop a report.json into {url} to visualize a trace.").dimmed(), - " Generate one with: atupa capture --tx 0x... --output json --file report.json".dimmed(), - ); + // 4. Footer info + if let Some(path) = report_path { + eprintln!( + "\n {} Report loaded: {}\n The Studio has automatically opened this report.", + "✔".green().bold(), + path.cyan().bold(), + ); + } + eprintln!("{}\n", " Press Ctrl+C to stop the Studio server.".dimmed()); - // ── Keep running until the user presses Ctrl+C ──────────────────────────── - let _ = child.wait(); + // Keep the main thread alive while the server runs + let _ = server_handle.await; Ok(()) } // ─── Banner & Rendering ─────────────────────────────────────────────────────── - fn print_banner() { eprintln!( "{}", @@ -578,87 +1081,187 @@ fn print_banner() { eprintln!(); } +fn hostio_category_color(label: &str) -> &'static str { + match label { + "storage_flush_cache" | "storage_store_bytes32" => "\x1b[31;1m", + "storage_load_bytes32" | "storage_cache_bytes32" => "\x1b[33m", + "native_keccak256" => "\x1b[35m", + "read_args" | "write_result" | "pay_for_memory_grow" => "\x1b[32m", + "msg_sender" | "msg_value" | "msg_reentrant" | "emit_log" | "account_balance" + | "block_hash" => "\x1b[36m", + "call" | "static_call" | "delegate_call" | "create" => "\x1b[34m", + _ => "\x1b[90m", + } +} + fn render_capture_summary(report: &StitchedReport) -> String { + const RESET: &str = "\x1b[0m"; let div = "─".repeat(56).dimmed().to_string(); + let wide_div = "━".repeat(72); let mut out = String::new(); - out += &format!("{}\n", " UNIFIED EXECUTION SUMMARY".bold().underline()); - out += &format!("{div}\n"); - out += &format!( - " {:<34} {}\n", - "EVM Trace Gas (Total):".bold(), - report.total_evm_gas.to_string().green() - ); - out += &format!( - " {:<34} {}\n", - "Stylus Ink (raw):".bold(), - report.total_stylus_ink.to_string().yellow() + " {} ({})\n", + "UNIFIED EXECUTION SUMMARY".bold().underline(), + get_network_name(report.chain_id).cyan() ); + out += &format!("{div}\n"); + + // ── Gas totals with Execution vs Intrinsic split ─────────────────────────────── + if let Some(on_chain) = report.on_chain_gas_used { + let execution_gas = report.total_evm_gas; + let intrinsic_gas = on_chain.saturating_sub(execution_gas); + out += &format!( + " {:<34} {}\n", + "Total Gas Used (on-chain):".bold(), + on_chain.to_string().green().bold() + ); + out += &format!( + " {:<34} {}\n", + " ├─ Execution:".dimmed(), + execution_gas.to_string().green() + ); + out += &format!( + " {:<34} {}\n", + " └─ Intrinsic (base + calldata):".dimmed(), + intrinsic_gas.to_string().yellow() + ); + } else { + out += &format!( + " {:<34} {}\n", + "EVM Trace Gas (Total):".bold(), + report.total_evm_gas.to_string().green() + ); + } + + if report.total_stylus_ink > 0 { + out += &format!( + " {:<34} {}\n", + "Stylus Ink (raw):".bold(), + report.total_stylus_ink.to_string().yellow() + ); + out += &format!( + " {:<34} {}\n", + " → Gas-equivalent (÷10,000):".dimmed(), + format!("{:.2}", report.total_stylus_gas_equiv).yellow() + ); + } + + if report.vm_boundary_count > 0 { + out += &format!( + " {:<34} {}\n", + "VM Boundaries (EVM ↔ WASM):".bold(), + report.vm_boundary_count.to_string().magenta() + ); + } + + out += &format!("{div}\n"); out += &format!( " {:<34} {}\n", - " → Gas-equivalent (÷10,000):".dimmed(), - format!("{:.2}", report.total_stylus_gas_equiv).yellow() + "TOTAL UNIFIED COST:".bold().cyan(), + format!("{:.2} gas", report.total_unified_cost) + .cyan() + .bold() ); out += &format!("{div}\n"); + // EVM step count always shown out += &format!( " {:<34} {}\n", "EVM Steps:".bold(), evm_count(report).to_string().green() ); - out += &format!( - " {:<34} {}\n", - "Stylus HostIO Steps:".bold(), - report.stylus_steps().len().to_string().yellow() - ); - out += &format!( - " {:<34} {}\n", - "VM Boundary Crossings (EVM→WASM):".bold(), - report.vm_boundary_count.to_string().cyan().bold() - ); - out += &format!("{div}\n"); - // EVM→WASM boundary detail - if report.vm_boundary_count > 0 { - out += &format!(" {}\n", "EVM→WASM Boundary Crossings:".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"); - } - - // Top Stylus ink consumers + // 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(); - // Sort descending by cost aggregated.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); - out += &format!(" {}\n", "🔥 Top Ink Consumers (Stylus HostIO):".bold()); - for (label, cost) in aggregated.iter().take(5) { + let total_ink_gas: f64 = aggregated.iter().map(|(_, c)| c).sum(); + let unique_paths = aggregated.len(); + + out += &format!( + " {:<34} {}\n", + "Stylus HostIO Calls:".bold(), + stylus.len().to_string().yellow() + ); + out += &format!( + " {:<34} {}\n", + "Unique HostIO Paths:".bold(), + unique_paths.to_string().yellow() + ); + + if report.vm_boundary_count > 0 { + out += &format!(" {}\n", "EVM→WASM Boundary Details:".bold()); + for (i, step) in report.boundary_steps().iter().take(5).enumerate() { + out += &format!( + " {} {} at depth {}\n", + format!("[{}]", i + 1).cyan(), + step.label.bold(), + step.depth.to_string().dimmed() + ); + } + if report.vm_boundary_count > 5 { + out += &format!( + " … and {} more\n", + (report.vm_boundary_count - 5).to_string().dimmed() + ); + } + } + + out += &format!("{div}\n"); + + // ── Colour-coded hot-path table ──────────────────────────────────── + out += &format!(" {}\n", "🔥 STYLUS HOT PATHS".bold()); + out += &format!(" {wide_div}\n"); + out += &format!( + " ┃ {:<42} ┃ {:>10} ┃ {:>14} ┃ {:>7} ┃\n", + "HostIO (Hottest First)", "GAS", "INK (raw)", "%" + ); + out += &format!(" {wide_div}\n"); + for (label, cost_gas) in aggregated.iter().take(10) { + let cost_ink = (cost_gas * 10_000.0) as u64; + let pct = if total_ink_gas > 0.0 { + cost_gas / total_ink_gas * 100.0 + } else { + 0.0 + }; + let color = hostio_category_color(label); + let gas_str = format!("{:.0}", cost_gas); out += &format!( - " {:<36} {:>8.2} gas-equiv\n", - label.yellow(), - cost + " ┃ {color}{:<42}{RESET} ┃ {gas_str:>10} ┃ {cost_ink:>14} ┃ {pct:>6.1}% ┃\n", + label, ); } + out += &format!(" {wide_div}\n"); + + // ── ASCII flamegraph ─────────────────────────────────────────────── + out += &format!("\n {}\n", "📊 SIMPLIFIED FLAMEGRAPH".bold()); + out += " root ██████████████████████████████████████████████████ 100%\n"; + for (label, cost_gas) in aggregated.iter().take(5) { + let pct = if total_ink_gas > 0.0 { + cost_gas / total_ink_gas * 100.0 + } else { + 0.0 + }; + let bar_width = (pct / 2.0) as usize; + let bar = "█".repeat(bar_width); + let color = hostio_category_color(label); + out += &format!( + " └─ {color}{:<20}{RESET} {color}{:<50}{RESET} {:>5.1}%\n", + label, bar, pct + ); + } + if unique_paths > 10 { + out += &format!("\n ({} of {} unique paths shown)\n", 10, unique_paths); + } + out += &format!("{div}\n"); } @@ -666,11 +1269,26 @@ fn render_capture_summary(report: &StitchedReport) -> String { out } -fn print_aave_report(aave: &atupa_aave::LiquidationReport, nitro: &StitchedReport) { +fn print_aave_report( + aave: &atupa_aave::LiquidationReport, + nitro: &StitchedReport, + top_selector: Option<&str>, +) { let div = "─".repeat(56).dimmed().to_string(); println!("{}", " AAVE v3 PROTOCOL AUDIT".bold().underline()); println!("{div}"); + // Show the actual top-level function called, resolved from calldata + if let Some(sel) = top_selector { + let fn_name = atupa_aave::AaveV3Adapter::resolve_selector_label(sel) + .unwrap_or_else(|| format!("unknown ({})", sel)); + println!( + " {:<34} {}", + "Top-Level Call:".bold(), + fn_name.yellow().bold() + ); + } + let rows: &[(&str, String)] = &[ ("Total Gas (Aave frame):", aave.total_gas.to_string()), ("Liquidation Gas:", aave.liquidation_gas.to_string()), @@ -719,18 +1337,36 @@ fn print_aave_report(aave: &atupa_aave::LiquidationReport, nitro: &StitchedRepor println!("{div}"); } -fn print_lido_report(lido: &atupa_lido::LidoReport, nitro: &StitchedReport) { +fn print_lido_report( + lido: &atupa_lido::LidoReport, + nitro: &StitchedReport, + top_selector: Option<&str>, +) { let div = "─".repeat(56).dimmed().to_string(); println!("{}", " LIDO stETH PROTOCOL AUDIT".bold().underline()); println!("{div}"); + // Show the actual top-level function called, resolved from calldata + if let Some(sel) = top_selector { + let fn_name = atupa_lido::LidoAdapter::resolve_selector_label(sel) + .unwrap_or_else(|| format!("unknown fn ({})", sel)); + println!( + " {:<34} {}", + "Top-Level Call:".bold(), + fn_name.yellow().bold() + ); + } + let rows: &[(&str, String)] = &[ ("Total Gas (Lido frame):", lido.total_gas.to_string()), - ("Staking Operations Gas:", lido.staking_gas.to_string()), + ("Storage Reads (SLOAD):", lido.storage_reads.to_string()), + ("Storage Writes (SSTORE):", lido.storage_writes.to_string()), + ("External Calls:", lido.external_calls.to_string()), ("Shares Transfers:", lido.shares_transfers.to_string()), - ("Token Transfers:", lido.token_transfers.to_string()), - ("Oracle Updates:", lido.oracle_updates.to_string()), - ("Wrapped TXs (wstETH):", lido.wrapped_txs.to_string()), + ("Oracle Reports:", lido.oracle_reports.to_string()), + ("Withdrawal Requests:", lido.withdrawal_requests.to_string()), + ("Withdrawal Claims:", lido.withdrawal_claims.to_string()), + ("Wrapped Ops (wstETH):", lido.wrapped_ops.to_string()), ( "Cross-VM Calls (Stylus):", nitro.vm_boundary_count.to_string(), @@ -802,6 +1438,7 @@ fn bridge_raw_to_trace_step(raw: &RawStructLog) -> TraceStep { memory: raw.memory.clone(), error: raw.error.clone(), reverted: raw.error.is_some(), + vm_kind: atupa_core::VmKind::Evm, } } @@ -816,3 +1453,51 @@ fn spinner(msg: &str) -> ProgressBar { pb.set_message(msg.to_string()); pb } + +fn get_network_name(chain_id: u64) -> String { + match chain_id { + 1 => "Ethereum Mainnet".to_string(), + 11155111 => "Sepolia Testnet".to_string(), + 17000 => "Holesky Testnet".to_string(), + 42161 => "Arbitrum One".to_string(), + 42170 => "Arbitrum Nova".to_string(), + 421614 => "Arbitrum Sepolia".to_string(), + 8453 => "Base Mainnet".to_string(), + 84532 => "Base Sepolia".to_string(), + 10 => "Optimism".to_string(), + 11155420 => "Optimism Sepolia".to_string(), + 137 => "Polygon POS".to_string(), + 1337 | 31337 => "Local Devnet".to_string(), + 412346 => "Nitro Local Devnet".to_string(), + 0 => "Unknown Network".to_string(), + id => format!("Chain ID: {}", id), + } +} + +fn resolve_artifact_path(path: Option, category: &str, tx_hash: &str, ext: &str) -> String { + let filename = path.unwrap_or_else(|| { + let short = tx_hash + .trim_start_matches("0x") + .get(..10) + .unwrap_or(tx_hash); + match ext { + "json" => format!("report_{short}.json"), + "svg" => format!("profile_{short}.svg"), + _ => format!("artifact_{short}.{ext}"), + } + }); + + let pb = std::path::PathBuf::from(&filename); + // If it's a simple filename (no parent directory), move it to artifacts// + if pb + .parent() + .map(|p| p.as_os_str().is_empty()) + .unwrap_or(true) + { + let dir = format!("artifacts/{}", category); + let _ = std::fs::create_dir_all(&dir); + format!("{}/{}", dir, filename) + } else { + filename + } +} diff --git a/bin/atupa/src/studio.rs b/bin/atupa/src/studio.rs new file mode 100644 index 0000000..adbc68b --- /dev/null +++ b/bin/atupa/src/studio.rs @@ -0,0 +1,92 @@ +use axum::{ + Router, + http::{StatusCode, Uri, header}, + response::{Html, IntoResponse, Response}, + routing::get, +}; +use rust_embed::RustEmbed; +use std::net::SocketAddr; +use std::sync::Arc; + +#[derive(RustEmbed)] +#[folder = "../../studio/dist/"] +struct Asset; + +pub struct StudioServer { + report_json: Arc>, +} + +impl StudioServer { + pub fn new(report_json: Option) -> Self { + Self { + report_json: Arc::new(report_json), + } + } + + pub async fn start(self, port: u16) -> anyhow::Result<()> { + let report_data = self.report_json.clone(); + + let app = Router::new() + .route( + "/auto-load.json", + get(move || { + let data = report_data.clone(); + async move { + if let Some(json) = &*data { + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .body(axum::body::Body::from(json.clone())) + .unwrap() + .into_response() + } else { + StatusCode::NOT_FOUND.into_response() + } + } + }), + ) + .fallback(static_handler); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) + } +} + +async fn static_handler(uri: Uri) -> impl IntoResponse { + let path = uri.path().trim_start_matches('/').to_string(); + + if path.is_empty() || path == "index.html" { + return index_html().await; + } + + // Try to find the asset + match Asset::get(&path) { + Some(content) => { + let mime = mime_guess::from_path(&path).first_or_octet_stream(); + Response::builder() + .header(header::CONTENT_TYPE, mime.as_ref()) + .body(axum::body::Body::from(content.data)) + .unwrap() + .into_response() + } + None => { + // If it's a sub-path, maybe it's an SPA route? + // Check if adding .html helps (unlikely for Vite but good for some) + // For Vite SPA, we usually just return index.html for any non-asset route + index_html().await + } + } +} + +async fn index_html() -> Response { + match Asset::get("index.html") { + Some(content) => Html(content.data).into_response(), + None => ( + StatusCode::NOT_FOUND, + "Studio assets not found. Did you run `npm run build` in the studio directory?", + ) + .into_response(), + } +} diff --git a/bin/atupa/src/thresholds.rs b/bin/atupa/src/thresholds.rs new file mode 100644 index 0000000..1c6d946 --- /dev/null +++ b/bin/atupa/src/thresholds.rs @@ -0,0 +1,35 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Default, Deserialize)] +pub struct AtupaConfigToml { + pub diff: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DiffConfig { + pub max_total_gas_increase_percent: Option, + pub max_execution_gas_increase_percent: Option, + pub max_evm_steps_increase: Option, + pub max_stylus_calls_increase: Option, +} + +impl AtupaConfigToml { + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file at {:?}", path))?; + let config: Self = toml::from_str(&content) + .with_context(|| format!("Failed to parse TOML config from {:?}", path))?; + Ok(config) + } + + pub fn auto_load() -> Option { + let path = Path::new("atupa.toml"); + if path.exists() { + Self::load(path).ok() + } else { + None + } + } +} diff --git a/crates/atupa-aave/src/lib.rs b/crates/atupa-aave/src/lib.rs index 6d76f21..84719b0 100644 --- a/crates/atupa-aave/src/lib.rs +++ b/crates/atupa-aave/src/lib.rs @@ -5,7 +5,7 @@ //! mechanics, and GHO stablecoin risk monitoring. use atupa_adapters::ProtocolAdapter; -use atupa_core::TraceStep; +use atupa_core::{DiffRow, ProtocolDiffReport, TraceStep}; use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- @@ -29,6 +29,7 @@ const POOL_SELECTORS: &[(&str, &str)] = &[ ("0x095ea7b3", "approve"), // ERC-20 ("0x1e9a6950", "setUserUseReserveAsCollateral"), ("0x02c205f0", "swapBorrowRateMode"), + ("0x1e9d0e2e", "claimRewards"), ]; /// Known GHO Facilitators (Ethereum Mainnet). @@ -107,6 +108,23 @@ impl ProtocolAdapter for AaveV3Adapter { } } +impl AaveV3Adapter { + /// Resolve a 4-byte selector string to a human-readable label (no instance needed). + pub fn resolve_selector_label(selector: &str) -> Option { + for &(known_sel, label) in POOL_SELECTORS { + if selector == known_sel { + return Some(format!("AaveV3Pool::{label}")); + } + } + for &(known_sel, label) in GHO_SELECTORS { + if selector == known_sel { + return Some(format!("GHO::{label}")); + } + } + None + } +} + // --------------------------------------------------------------------------- // Liquidation Report // --------------------------------------------------------------------------- @@ -307,6 +325,90 @@ impl AaveDeepTracer { metrics } + + /// Compares two traces with full Aave protocol analysis and returns a + /// `ProtocolDiffReport` containing field-by-field deltas. + pub fn diff_reports( + &self, + base_hash: &str, + base_steps: &[TraceStep], + target_hash: &str, + target_steps: &[TraceStep], + ) -> anyhow::Result { + let base = self.analyze_liquidation(base_hash, base_steps)?; + let target = self.analyze_liquidation(target_hash, target_steps)?; + + let base_gho = self.extract_gho_metrics(base_steps); + let target_gho = self.extract_gho_metrics(target_steps); + + let rows = vec![ + DiffRow::new( + "Total Gas", + base.total_gas as f64, + target.total_gas as f64, + true, + ), + DiffRow::new( + "Liquidation Gas", + base.liquidation_gas as f64, + target.liquidation_gas as f64, + true, + ), + DiffRow::new( + "Storage Reads (SLOAD)", + base.storage_reads as f64, + target.storage_reads as f64, + true, + ), + DiffRow::new( + "Storage Writes (SSTORE)", + base.storage_writes as f64, + target.storage_writes as f64, + true, + ), + DiffRow::new( + "External Calls", + base.external_calls as f64, + target.external_calls as f64, + true, + ), + DiffRow::new( + "Oracle Calls", + base.oracle_calls as f64, + target.oracle_calls as f64, + true, + ), + DiffRow::new( + "Max Call Depth", + base.max_depth as f64, + target.max_depth as f64, + true, + ), + DiffRow::new( + "Liq. Efficiency", + base.liquidation_efficiency, + target.liquidation_efficiency, + true, + ), + DiffRow::new( + "GHO Mint Count", + base_gho.mint_count as f64, + target_gho.mint_count as f64, + false, + ), + DiffRow::new( + "GHO Burn Count", + base_gho.burn_count as f64, + target_gho.burn_count as f64, + false, + ), + ]; + + Ok(ProtocolDiffReport { + protocol: "Aave v3 / GHO".to_string(), + rows, + }) + } } #[cfg(test)] @@ -315,15 +417,12 @@ mod tests { fn make_call_step(op: &str, selector: &str, gas_cost: u64) -> TraceStep { TraceStep { - pc: 0, op: op.to_string(), gas: 1_000_000, gas_cost, depth: 1, stack: Some(vec![selector.to_string()]), - memory: None, - error: None, - reverted: false, + ..Default::default() } } diff --git a/crates/atupa-adapters/src/lib.rs b/crates/atupa-adapters/src/lib.rs index 64ff8d2..55ebec2 100644 --- a/crates/atupa-adapters/src/lib.rs +++ b/crates/atupa-adapters/src/lib.rs @@ -61,6 +61,67 @@ impl ProtocolAdapter for AaveV3Adapter { } } +/// Adapter specifically for identifying Lido stETH operations +pub struct LidoAdapter; + +impl ProtocolAdapter for LidoAdapter { + fn name(&self) -> &str { + "Lido stETH" + } + + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { + // Known Lido protocol contract addresses (Mainnet) + const LIDO_ADDRESSES: &[(&str, &str)] = &[ + ( + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "stETH (Lido Core)", + ), + ( + "0x55032650b14df07b85bF18A3a3eC8E0Af2e028d5", + "NodeOperatorsRegistry", + ), + ("0x442af752419395f27ed54A848524a30028962bb2", "LidoOracle"), + ( + "0x889edC2Bf57978ed079b851D273218ee42a2b349", + "WithdrawalQueue", + ), + ("0x852f970761d74367f33B6C2e309a29D681E2F16a", "LegacyOracle"), + ("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", "wstETH"), + ]; + + if let Some(addr) = address { + for &(known_addr, name) in LIDO_ADDRESSES { + if addr.to_lowercase() == known_addr.to_lowercase() { + return Some(format!("Lido::{}", name)); + } + } + } + + let sel = selector?; + // Selectors for major Lido protocol operations + const LIDO_SELECTORS: &[(&str, &str)] = &[ + ("0xa1903eab", "submit"), + ("0xea598cb0", "requestWithdrawals"), + ("0x826a73d6", "requestWithdrawalsWithPermit"), + ("0xe35ea9a5", "claimWithdrawals"), + ("0x8b6ca260", "handleOracleReport"), + ("0x39ba163b", "transferShares"), + ("0x4dbcaef1", "transferSharesFrom"), + ("0xa9059cbb", "transfer"), + ("0x095ea7b3", "approve"), + ("0x0a19ea81", "wrap"), + ("0x1dfab2e1", "unwrap"), + ]; + + for &(known_sel, label) in LIDO_SELECTORS { + if sel.contains(known_sel) { + return Some(format!("stETH::{label}")); + } + } + None + } +} + /// The registry holding all known protocol adapters. pub struct AdapterRegistry { adapters: Vec>, @@ -74,6 +135,7 @@ impl AdapterRegistry { }; registry.register(Box::new(UniswapV4Adapter)); registry.register(Box::new(AaveV3Adapter)); + registry.register(Box::new(LidoAdapter)); registry } diff --git a/crates/atupa-core/Cargo.toml b/crates/atupa-core/Cargo.toml index 0aecaf0..3600f14 100644 --- a/crates/atupa-core/Cargo.toml +++ b/crates/atupa-core/Cargo.toml @@ -20,3 +20,4 @@ thiserror = { workspace = true } chrono = { workspace = true } figment = { workspace = true } toml = { workspace = true } +dirs = { workspace = true } diff --git a/crates/atupa-core/src/config.rs b/crates/atupa-core/src/config.rs index 4c8893d..c220363 100644 --- a/crates/atupa-core/src/config.rs +++ b/crates/atupa-core/src/config.rs @@ -3,32 +3,81 @@ use figment::{ providers::{Env, Format, Serialized, Toml}, }; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AtupaConfig { pub rpc_url: String, pub etherscan_key: Option, pub output_dir: String, + pub studio_dir: Option, + /// Port Atupa Studio's Vite dev-server will bind to (default: 5173). + pub studio_port: u16, } impl Default for AtupaConfig { fn default() -> Self { Self { - rpc_url: "http://localhost:8545".to_string(), + rpc_url: "http://localhost:8547".to_string(), etherscan_key: None, output_dir: ".".to_string(), + studio_dir: None, + studio_port: 5173, } } } impl AtupaConfig { /// Load configuration by merging multiple sources. - /// Priority: CLI Flags > Env Vars > atupa.toml > Defaults + /// Priority: CLI Flags (applied later) > Env Vars > atupa.toml > ~/.atupa/config.toml > Defaults pub fn load() -> Self { - Figment::from(Serialized::defaults(Self::default())) - .merge(Toml::file("atupa.toml")) - .merge(Env::prefixed("ATUPA_")) - .extract() - .unwrap_or_else(|_| Self::default()) + let mut figment = Figment::from(Serialized::defaults(Self::default())); + + // Global config + if let Some(mut home) = dirs::home_dir() { + home.push(".atupa"); + home.push("config.toml"); + figment = figment.merge(Toml::file(home)); + } + + // Local config + figment = figment.merge(Toml::file("atupa.toml")); + + // Environment variables + figment = figment.merge(Env::prefixed("ATUPA_")); + + figment.extract().unwrap_or_else(|_| Self::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_default_config() { + let config = AtupaConfig::default(); + assert_eq!(config.rpc_url, "http://localhost:8547"); + assert!(config.etherscan_key.is_none()); + } + + #[test] + fn test_env_override() { + unsafe { + env::set_var("ATUPA_RPC_URL", "http://test-rpc.local"); + env::set_var("ATUPA_ETHERSCAN_KEY", "test-key-123"); + } + + // Reloading should pick up env vars due to Env::prefixed("ATUPA_") + let config = AtupaConfig::load(); + + assert_eq!(config.rpc_url, "http://test-rpc.local"); + assert_eq!(config.etherscan_key, Some("test-key-123".to_string())); + + unsafe { + env::remove_var("ATUPA_RPC_URL"); + env::remove_var("ATUPA_ETHERSCAN_KEY"); + } } } diff --git a/crates/atupa-core/src/lib.rs b/crates/atupa-core/src/lib.rs index 78b7c4e..e07e8e7 100644 --- a/crates/atupa-core/src/lib.rs +++ b/crates/atupa-core/src/lib.rs @@ -26,8 +26,68 @@ pub enum GasCategory { Other, } +impl GasCategory { + pub fn from_step(op: &str, vm: VmKind) -> Self { + let op = op.trim(); + match vm { + VmKind::Evm => Self::from_evm(op), + VmKind::Stylus => Self::from_stylus(op), + } + } + + fn from_evm(op: &str) -> Self { + match op { + "SSTORE" | "TSTORE" => Self::StorageWrite, + "SLOAD" | "TLOAD" => Self::StorageRead, + "MLOAD" | "MSTORE" | "MSTORE8" | "MCOPY" | "MSIZE" => Self::Memory, + "KECCAK256" | "SHA3" => Self::Crypto, + "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" | "CREATE" | "CREATE2" + | "RETURN" | "REVERT" | "STOP" | "INVALID" | "SELFDESTRUCT" => Self::Call, + // Arithmetic, Logic, Stack, Flow + "ADD" | "SUB" | "MUL" | "DIV" | "SDIV" | "MOD" | "SMOD" | "ADDMOD" | "MULMOD" + | "EXP" | "SIGNEXTEND" | "LT" | "GT" | "SLT" | "SGT" | "EQ" | "ISZERO" | "AND" + | "OR" | "XOR" | "NOT" | "BYTE" | "SHL" | "SHR" | "SAR" | "POP" | "PUSH1" | "PUSH2" + | "PUSH3" | "PUSH4" | "PUSH5" | "PUSH6" | "PUSH7" | "PUSH8" | "PUSH9" | "PUSH10" + | "PUSH11" | "PUSH12" | "PUSH13" | "PUSH14" | "PUSH15" | "PUSH16" | "PUSH17" + | "PUSH18" | "PUSH19" | "PUSH20" | "PUSH21" | "PUSH22" | "PUSH23" | "PUSH24" + | "PUSH25" | "PUSH26" | "PUSH27" | "PUSH28" | "PUSH29" | "PUSH30" | "PUSH31" + | "PUSH32" | "DUP1" | "DUP2" | "DUP3" | "DUP4" | "DUP5" | "DUP6" | "DUP7" | "DUP8" + | "DUP9" | "DUP10" | "DUP11" | "DUP12" | "DUP13" | "DUP14" | "DUP15" | "DUP16" + | "SWAP1" | "SWAP2" | "SWAP3" | "SWAP4" | "SWAP5" | "SWAP6" | "SWAP7" | "SWAP8" + | "SWAP9" | "SWAP10" | "SWAP11" | "SWAP12" | "SWAP13" | "SWAP14" | "SWAP15" + | "SWAP16" | "JUMP" | "JUMPI" | "PC" | "GAS" | "JUMPDEST" => Self::Execution, + _ => Self::Other, + } + } + + fn from_stylus(hostio: &str) -> Self { + let n = hostio.to_lowercase(); + // Specific checks for flush (it's a write operation) + if n.contains("flush") || n.contains("storage_store") { + Self::StorageWrite + } else if n.contains("storage_load") || n.contains("storage_cache") { + Self::StorageRead + } else if n.contains("keccak") || n.contains("sha2") { + Self::Crypto + } else if n.contains("call") || n.contains("create") { + Self::Call + } else if n.contains("memory") || n.contains("args") || n.contains("return") { + Self::Memory + } else if n.contains("msg") + || n.contains("block") + || n.contains("tx") + || n.contains("evm") + || n.contains("user") + { + Self::Execution + } else { + Self::Other + } + } +} + /// A single step in the EVM execution trace (equivalent to structLog). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TraceStep { pub pc: u64, pub op: String, @@ -40,6 +100,16 @@ pub struct TraceStep { pub error: Option, #[serde(default)] pub reverted: bool, + #[serde(default)] + pub vm_kind: VmKind, +} + +/// Which virtual machine produced these execution steps. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum VmKind { + #[default] + Evm, + Stylus, } /// A single collapsed stack entry for aggregation. @@ -48,6 +118,12 @@ pub struct CollapsedStack { pub stack: String, pub weight: u64, pub last_pc: Option, + /// Maximum call depth seen for steps in this stack. + #[serde(default)] + pub depth: u16, + /// The VM that produced this collapsed stack. + #[serde(default)] + pub vm_kind: VmKind, #[serde(default)] pub target_address: Option, #[serde(default)] @@ -88,3 +164,43 @@ impl Profile { } } } + +// ─── Protocol Diff Structures ──────────────────────────────────────────────── + +/// A field-by-field delta between two protocol executions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtocolDiffReport { + pub protocol: String, + pub rows: Vec, +} + +/// A single comparable metric row for protocol-level diffing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiffRow { + pub metric: String, + pub base: f64, + pub target: f64, + pub delta: f64, + pub pct: f64, + /// true = a larger value is bad (gas, reads, calls), false = larger is better + pub higher_is_worse: bool, +} + +impl DiffRow { + pub fn new(metric: &str, base: f64, target: f64, higher_is_worse: bool) -> Self { + let delta = target - base; + let pct = if base > 0.0 { + delta / base * 100.0 + } else { + 0.0 + }; + Self { + metric: metric.to_string(), + base, + target, + delta, + pct, + higher_is_worse, + } + } +} diff --git a/crates/atupa-lido/src/lib.rs b/crates/atupa-lido/src/lib.rs index 11f06b1..8bbf58a 100644 --- a/crates/atupa-lido/src/lib.rs +++ b/crates/atupa-lido/src/lib.rs @@ -6,11 +6,12 @@ //! and handling withdrawals. use atupa_adapters::ProtocolAdapter; -use atupa_core::TraceStep; +use atupa_core::{DiffRow, ProtocolDiffReport, TraceStep}; +use serde::{Deserialize, Serialize}; /// Selectors for major Lido protocol operations. const LIDO_SELECTORS: &[(&str, &str)] = &[ - ("0xa19046a6", "submit"), // stETH.submit(address _referral) + ("0xa1903eab", "submit"), // stETH.submit(address _referral) ("0xea598cb0", "requestWithdrawals"), // Legacy request withdrawals ("0x826a73d6", "requestWithdrawalsWithPermit"), ("0xe35ea9a5", "claimWithdrawals"), @@ -23,6 +24,25 @@ const LIDO_SELECTORS: &[(&str, &str)] = &[ ("0x1dfab2e1", "unwrap"), // wstETH unwrap ]; +/// Known Lido protocol contract addresses (Mainnet). +const LIDO_ADDRESSES: &[(&str, &str)] = &[ + ( + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "stETH (Lido Core)", + ), + ( + "0x55032650b14df07b85bF18A3a3eC8E0Af2e028d5", + "NodeOperatorsRegistry", + ), + ("0x442af752419395f27ed54A848524a30028962bb2", "LidoOracle"), + ( + "0x889edC2Bf57978ed079b851D273218ee42a2b349", + "WithdrawalQueue", + ), + ("0x852f970761d74367f33B6C2e309a29D681E2F16a", "LegacyOracle"), + ("0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", "wstETH"), +]; + // --------------------------------------------------------------------------- // Protocol Adapter Implementation // --------------------------------------------------------------------------- @@ -35,10 +55,30 @@ impl ProtocolAdapter for LidoAdapter { "Lido stETH" } - fn resolve_label(&self, _address: Option<&str>, selector: Option<&str>) -> Option { + fn resolve_label(&self, address: Option<&str>, selector: Option<&str>) -> Option { + if let Some(addr) = address { + for &(known_addr, name) in LIDO_ADDRESSES { + if addr.to_lowercase() == known_addr.to_lowercase() { + return Some(format!("Lido::{}", name)); + } + } + } + let sel = selector?; for &(known_sel, label) in LIDO_SELECTORS { - if sel == known_sel { + if sel.contains(known_sel.trim_start_matches("0x")) { + return Some(format!("stETH::{label}")); + } + } + None + } +} + +impl LidoAdapter { + /// Resolve a 4-byte selector string to a human-readable label (no instance needed). + pub fn resolve_selector_label(selector: &str) -> Option { + for &(known_sel, label) in LIDO_SELECTORS { + if selector.contains(known_sel.trim_start_matches("0x")) { return Some(format!("stETH::{label}")); } } @@ -47,27 +87,38 @@ impl ProtocolAdapter for LidoAdapter { } // --------------------------------------------------------------------------- -// Deep Tracer Implementation +// Report Structures // --------------------------------------------------------------------------- +/// Detailed metrics for a Lido protocol interaction. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct LidoReport { + pub tx_hash: String, pub total_gas: u64, - pub staking_gas: u64, - pub shares_transfers: usize, - pub token_transfers: usize, - pub oracle_updates: usize, - pub wrapped_txs: usize, + pub storage_reads: u32, + pub storage_writes: u32, + pub external_calls: u32, + pub shares_transfers: u32, + pub oracle_reports: u32, + pub withdrawal_requests: u32, + pub withdrawal_claims: u32, + pub wrapped_ops: u32, pub max_depth: u16, pub reverted: bool, pub labeled_calls: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct LabeledCall { pub depth: u16, pub label: String, pub gas_cost: u64, } +// --------------------------------------------------------------------------- +// Deep Tracer Implementation +// --------------------------------------------------------------------------- + #[derive(Default)] pub struct LidoDeepTracer { adapter: LidoAdapter, @@ -80,93 +131,155 @@ impl LidoDeepTracer { } } + /// Analyze a sequence of trace steps for Lido-specific patterns. pub fn analyze_staking( &self, - _tx_hash: &str, + tx_hash: &str, steps: &[TraceStep], ) -> anyhow::Result { let mut total_gas = 0u64; - let mut staking_gas = 0u64; - let mut shares_transfers = 0usize; - let mut token_transfers = 0usize; - let mut oracle_updates = 0usize; - let mut wrapped_txs = 0usize; + let mut storage_reads = 0u32; + let mut storage_writes = 0u32; + let mut external_calls = 0u32; + let mut shares_transfers = 0u32; + let mut oracle_reports = 0u32; + let mut withdrawal_requests = 0u32; + let mut withdrawal_claims = 0u32; + let mut wrapped_ops = 0u32; let mut max_depth = 0u16; - let mut reverted = false; let mut labeled_calls = Vec::new(); for step in steps { - total_gas += step.gas_cost; - if step.depth > max_depth { - max_depth = step.depth; - } - if step.reverted { - reverted = true; - } + total_gas = total_gas.saturating_add(step.gas_cost); + max_depth = max_depth.max(step.depth); - // Identify external CALLs - if step.op == "CALL" || step.op == "DELEGATECALL" || step.op == "STATICCALL" { - // Peek stack to guess selector (naive inference based on typical abi dispatch) - // In actual deep tracing, we peek Memory using stack[argsOffset]. - // For Atupa, we do proxy heuristics if full decoded input isn't available. - // However, our Atupa adapter relies on `TraceStep` metadata or stack inference. - // Let's iterate using stack-based selector heuristics or memory offsets. - - // Typical: - // stack[-3] or stack[-4] might be args pointer. - // We'll leave it as a high-level aggregation based on our adapter - // (which, in a real env, parses memory. Here we use mocked selectors). - } + match step.op.as_str() { + "SLOAD" => storage_reads += 1, + "SSTORE" => storage_writes += 1, + "CALL" | "STATICCALL" | "DELEGATECALL" | "CALLCODE" => { + external_calls += 1; - // To be precise with `atupa-core`, we look for PUSH4 as a proxy for selectors in the trace, - // or if the trace gives it to us. The current Atupa architecture (like Aave V3) often - // relies on memory slices at CALL times. Let's do a reliable proxy: - - if step.op.starts_with("PUSH4") - && let Some(stack_vec) = step.stack.as_ref() - && let Some(val_str) = stack_vec.last() - { - // Parse hex selector from stack top (e.g. "0xa19046a6" or "a19046a6") - let trimmed = val_str.trim_start_matches("0x"); - if let Ok(val) = u64::from_str_radix(trimmed.trim_start_matches('0'), 16) { - let sel_str = format!("0x{:08x}", val as u32); - - if let Some(label) = self.adapter.resolve_label(None, Some(&sel_str)) { - if sel_str == "0xa19046a6" { - staking_gas += step.gas_cost.max(100_000); - } else if sel_str == "0x39ba163b" { + let selector = step + .stack + .as_ref() + .and_then(|s| s.last()) + .map(|s| s.as_str()); + + if let Some(label) = self.adapter.resolve_label(None, selector) { + if label.contains("transferShares") { shares_transfers += 1; - } else if sel_str == "0xa9059cbb" { - token_transfers += 1; - } else if sel_str == "0x8b6ca260" { - oracle_updates += 1; - } else if sel_str == "0x0a19ea81" || sel_str == "0x1dfab2e1" { - wrapped_txs += 1; + } else if label.contains("handleOracleReport") { + oracle_reports += 1; + } else if label.contains("requestWithdrawals") { + withdrawal_requests += 1; + } else if label.contains("claimWithdrawals") { + withdrawal_claims += 1; + } else if label.contains("wrap") || label.contains("unwrap") { + wrapped_ops += 1; } labeled_calls.push(LabeledCall { depth: step.depth, label, - gas_cost: 0, + gas_cost: step.gas_cost, }); } } + _ => {} } } - // Clean up sequential duplicate PUSH4/CALL inferences + let reverted = steps.last().is_some_and(|s| s.reverted); labeled_calls.dedup_by(|a, b| a.label == b.label && a.depth == b.depth); Ok(LidoReport { + tx_hash: tx_hash.to_string(), total_gas, - staking_gas, + storage_reads, + storage_writes, + external_calls, shares_transfers, - token_transfers, - oracle_updates, - wrapped_txs, + oracle_reports, + withdrawal_requests, + withdrawal_claims, + wrapped_ops, max_depth, reverted, labeled_calls, }) } + + /// Perform a deep field-by-field diff between two Lido executions. + pub fn diff_reports( + &self, + base_tx: &str, + base_steps: &[TraceStep], + target_tx: &str, + target_steps: &[TraceStep], + ) -> anyhow::Result { + let base = self.analyze_staking(base_tx, base_steps)?; + let target = self.analyze_staking(target_tx, target_steps)?; + + let rows = vec![ + DiffRow::new( + "Total Gas", + base.total_gas as f64, + target.total_gas as f64, + true, + ), + DiffRow::new( + "Storage Reads", + base.storage_reads as f64, + target.storage_reads as f64, + true, + ), + DiffRow::new( + "Storage Writes", + base.storage_writes as f64, + target.storage_writes as f64, + true, + ), + DiffRow::new( + "External Calls", + base.external_calls as f64, + target.external_calls as f64, + true, + ), + DiffRow::new( + "Shares Transfers", + base.shares_transfers as f64, + target.shares_transfers as f64, + true, + ), + DiffRow::new( + "Oracle Reports", + base.oracle_reports as f64, + target.oracle_reports as f64, + true, + ), + DiffRow::new( + "Withdrawal Requests", + base.withdrawal_requests as f64, + target.withdrawal_requests as f64, + true, + ), + DiffRow::new( + "Withdrawal Claims", + base.withdrawal_claims as f64, + target.withdrawal_claims as f64, + true, + ), + DiffRow::new( + "Wrapped Ops", + base.wrapped_ops as f64, + target.wrapped_ops as f64, + true, + ), + ]; + + Ok(ProtocolDiffReport { + protocol: "Lido stETH".to_string(), + rows, + }) + } } diff --git a/crates/atupa-nitro/src/lib.rs b/crates/atupa-nitro/src/lib.rs index 9c58df4..1f12c2a 100644 --- a/crates/atupa-nitro/src/lib.rs +++ b/crates/atupa-nitro/src/lib.rs @@ -1,6 +1,9 @@ +use atupa_core::GasCategory; +use atupa_core::VmKind as CoreVmKind; use atupa_rpc::{EthClient, RawStructLog, RpcError}; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::collections::HashMap; use thiserror::Error; // ─── Error Type ────────────────────────────────────────────────────────────── @@ -66,6 +69,15 @@ pub enum VmKind { Stylus, } +impl From for CoreVmKind { + fn from(v: VmKind) -> Self { + match v { + VmKind::Evm => CoreVmKind::Evm, + VmKind::Stylus => CoreVmKind::Stylus, + } + } +} + /// A single step in the merged, time-ordered execution timeline. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UnifiedStep { @@ -83,12 +95,64 @@ pub struct UnifiedStep { pub depth: u16, /// True when this is the EVM `CALL` opcode that dispatches into a WASM contract. pub is_vm_boundary: bool, + /// The logical category of this execution step. + pub category: GasCategory, + /// Target address for CALL/CREATE operations. + pub target_address: Option, /// Raw EVM structLog, present only for EVM steps. pub evm: Option, /// Raw Stylus HostIO, present only for Stylus steps. pub stylus: Option, } +impl UnifiedStep { + /// Converts a unified step back to a core TraceStep, preserving VM identity and depth. + pub fn to_trace_step(&self) -> atupa_core::TraceStep { + if let Some(evm) = &self.evm { + let reverted = evm.error.is_some() || evm.op == "REVERT" || evm.op == "INVALID"; + atupa_core::TraceStep { + pc: evm.pc, + op: evm.op.clone(), + gas: evm.gas, + gas_cost: evm.gas_cost, + depth: evm.depth, + stack: evm.stack.clone(), + memory: evm.memory.clone(), + error: evm.error.clone(), + reverted, + vm_kind: atupa_core::VmKind::Evm, + } + } else if let Some(stylus) = &self.stylus { + atupa_core::TraceStep { + pc: 0, + op: stylus.name.clone(), + gas: 0, + gas_cost: self.cost_equiv.round() as u64, + depth: self.depth, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: atupa_core::VmKind::Stylus, + } + } else { + // Fallback for label-only steps + atupa_core::TraceStep { + pc: 0, + op: self.label.clone(), + gas: 0, + gas_cost: 0, + depth: self.depth, + stack: None, + memory: None, + error: None, + reverted: false, + vm_kind: self.vm.clone().into(), + } + } + } +} + // ─── Stitched Report ────────────────────────────────────────────────────────── /// The complete output of the stitching engine for a single transaction. @@ -96,6 +160,8 @@ pub struct UnifiedStep { pub struct StitchedReport { /// The transaction hash that was traced. pub tx_hash: String, + /// The chain ID of the network being traced. + pub chain_id: u64, /// Merged, time-ordered execution steps across both VMs. pub steps: Vec, /// Total EVM gas consumed across all steps. @@ -108,6 +174,13 @@ pub struct StitchedReport { pub total_stylus_gas_equiv: f64, /// Combined cost: `total_evm_gas` + `total_stylus_gas_equiv`. pub total_unified_cost: f64, + /// Aggregated costs by gas category. + pub category_costs: HashMap, + /// Address labels resolved via Etherscan (Address -> Contract Name). + pub resolved_names: HashMap, + /// Actual gas used on-chain from eth_getTransactionReceipt (includes intrinsic cost). + /// None if the receipt fetch was skipped or failed. + pub on_chain_gas_used: Option, } impl StitchedReport { @@ -167,6 +240,7 @@ impl MixedTraceStitcher { /// 6. Aggregate totals and build the `StitchedReport`. pub fn stitch( tx_hash: impl Into, + chain_id: u64, evm_logs: Vec, stylus_logs: Vec, ) -> StitchedReport { @@ -186,6 +260,21 @@ impl MixedTraceStitcher { total_evm_gas = total_evm_gas.saturating_add(gas_cost); + let category = GasCategory::from_step(&log.op, VmKind::Evm.into()); + + // Extract target address for CALL/CREATE + let mut target_address = None; + if (log.op.contains("CALL") || log.op.contains("CREATE")) + && let Some(stack) = &log.stack + && stack.len() >= 2 + { + let hex_addr = &stack[stack.len() - 2]; + let clean_hex = hex_addr.trim_start_matches("0x"); + let padded = format!("{:0>40}", clean_hex); + let extracted = &padded[padded.len() - 40..]; + target_address = Some(format!("0x{}", extracted.to_lowercase())); + } + steps.push(UnifiedStep { index, vm: VmKind::Evm, @@ -193,10 +282,13 @@ impl MixedTraceStitcher { gas_cost, cost_equiv: gas_cost as f64, depth, - is_vm_boundary: is_boundary, + is_vm_boundary: false, + category, + target_address, evm: Some(log), stylus: None, }); + let call_step_index = index; index += 1; if !is_boundary { @@ -205,7 +297,6 @@ impl MixedTraceStitcher { // ── WASM Window ────────────────────────────────────────────────── // Drain Stylus HostIOs that belong to this boundary frame. - vm_boundary_count += 1; let mut window_host_io_count: usize = 0; loop { @@ -227,19 +318,30 @@ impl MixedTraceStitcher { total_stylus_ink = total_stylus_ink.saturating_add(ink_used); window_host_io_count += 1; + let cost_equiv = host_io.ink_as_gas_equiv(); + let category = GasCategory::from_step(&host_io.name, VmKind::Stylus.into()); steps.push(UnifiedStep { index, vm: VmKind::Stylus, label: host_io.name.clone(), gas_cost: 0, - cost_equiv: host_io.ink_as_gas_equiv(), - depth, // Inherit depth from the owning CALL frame. + cost_equiv, + depth: depth + 1, // Nest under the owning CALL frame. is_vm_boundary: false, + category, + target_address: None, evm: None, stylus: Some(host_io), }); index += 1; } + + if window_host_io_count > 0 { + vm_boundary_count += 1; + steps[call_step_index].is_vm_boundary = true; + // Boundaries are often categorized as 'Call', but the specific CALL that triggered + // it is already categorized above. + } } // ── Trailing Stylus Steps ──────────────────────────────────────────── @@ -249,14 +351,18 @@ impl MixedTraceStitcher { let ink_used = host_io.ink_consumed(); total_stylus_ink = total_stylus_ink.saturating_add(ink_used); + let cost_equiv = host_io.ink_as_gas_equiv(); + let category = GasCategory::from_step(&host_io.name, VmKind::Stylus.into()); steps.push(UnifiedStep { index, vm: VmKind::Stylus, label: host_io.name.clone(), gas_cost: 0, - cost_equiv: host_io.ink_as_gas_equiv(), + cost_equiv, depth: 0, is_vm_boundary: false, + category, + target_address: None, evm: None, stylus: Some(host_io), }); @@ -266,14 +372,24 @@ impl MixedTraceStitcher { let total_stylus_gas_equiv = total_stylus_ink as f64 / 10_000.0; let total_unified_cost = total_evm_gas as f64 + total_stylus_gas_equiv; + // Aggregate category costs + let mut category_costs = HashMap::new(); + for step in &steps { + *category_costs.entry(step.category.clone()).or_insert(0.0) += step.cost_equiv; + } + StitchedReport { tx_hash, + chain_id, steps, total_evm_gas, total_stylus_ink, vm_boundary_count, total_stylus_gas_equiv, total_unified_cost, + category_costs, + resolved_names: HashMap::new(), + on_chain_gas_used: None, } } } @@ -337,14 +453,38 @@ impl NitroClient { /// or an older node version), the error is silently downgraded and the report /// will contain only EVM steps with `total_stylus_ink = 0`. pub async fn trace_transaction(&self, tx_hash: &str) -> Result { - log::info!("atupa-nitro: fetching unified trace for {}", tx_hash); + let chain_id = self.base_client.get_chain_id().await.unwrap_or(0); + + let is_nitro = match chain_id { + // Known Arbitrum / Nitro chains + 42161 | 42170 | 421611 | 421613 | 421614 | 23011913 => true, + // Local devnets often used for Nitro + 1337 | 31337 => true, + // Known non-Nitro chains (skip tracer) + 1 | 11155111 | 17000 | 8453 | 84532 | 10 | 11155420 | 137 => false, + // Unknown – try it but don't fail hard + _ => true, + }; - // Fire both RPC calls in parallel to cut latency by ~50%. - let (evm_result, stylus_result) = tokio::join!( - self.base_client.get_transaction_trace(tx_hash), - self.get_stylus_trace(tx_hash), + log::info!( + "atupa-nitro: fetching trace for {} (chain_id: {}, nitro_aware: {})", + tx_hash, + chain_id, + is_nitro ); + let (evm_result, stylus_result) = if is_nitro { + tokio::join!( + self.base_client.get_transaction_trace(tx_hash), + self.get_stylus_trace(tx_hash), + ) + } else { + ( + self.base_client.get_transaction_trace(tx_hash).await, + Ok(Vec::new()), + ) + }; + let evm_trace = evm_result?; let stylus_trace = stylus_result.unwrap_or_else(|e| { log::warn!( @@ -355,11 +495,13 @@ impl NitroClient { Vec::new() }); - let report = MixedTraceStitcher::stitch(tx_hash, evm_trace.struct_logs, stylus_trace); + let report = + MixedTraceStitcher::stitch(tx_hash, chain_id, evm_trace.struct_logs, stylus_trace); log::info!( - "atupa-nitro: {} steps stitched | EVM gas: {} | Stylus ink: {} ({:.2} gas-equiv) | boundaries: {}", + "atupa-nitro: {} steps stitched | network: {} | EVM gas: {} | Stylus ink: {} ({:.2} gas-equiv) | boundaries: {}", report.steps.len(), + chain_id, report.total_evm_gas, report.total_stylus_ink, report.total_stylus_gas_equiv, @@ -404,7 +546,7 @@ mod tests { #[test] fn pure_evm_produces_no_stylus_steps() { let logs = vec![evm("PUSH1", 3, 1), evm("ADD", 3, 1), evm("RETURN", 0, 1)]; - let report = MixedTraceStitcher::stitch("0xabc", logs, vec![]); + let report = MixedTraceStitcher::stitch("0xabc", 1, logs, vec![]); assert_eq!(report.steps.len(), 3); assert_eq!(report.vm_boundary_count, 0); @@ -425,7 +567,7 @@ mod tests { host_io("storage_load_bytes32", 900_000, 800_000), // 100k ink ]; - let report = MixedTraceStitcher::stitch("0xdef", evm_logs, stylus_logs); + let report = MixedTraceStitcher::stitch("0xdef", 42161, evm_logs, stylus_logs); // 3 EVM + 2 Stylus = 5 total assert_eq!(report.steps.len(), 5); @@ -447,14 +589,20 @@ mod tests { host_io("user_entrypoint", 300_000, 200_000), // window 2: 100k ink ]; - let report = MixedTraceStitcher::stitch("0x111", evm_logs, stylus_logs); + let report = MixedTraceStitcher::stitch("0x111", 42161, evm_logs, stylus_logs); assert_eq!(report.vm_boundary_count, 2); assert_eq!(report.stylus_steps().len(), 2); // Each user_entrypoint should be in a separate window (depth preserved). // First window entry is at index 1, second at index 3. assert_eq!(report.steps[1].label, "user_entrypoint"); + assert_eq!(report.steps[1].category, GasCategory::Execution); assert_eq!(report.steps[3].label, "user_entrypoint"); + assert_eq!(report.steps[3].category, GasCategory::Execution); + + // Category costs check + assert!(report.category_costs.get(&GasCategory::Call).unwrap() > &0.0); + assert!(report.category_costs.get(&GasCategory::Execution).unwrap() > &0.0); } #[test] @@ -462,6 +610,7 @@ mod tests { // No EVM CALL — the outer frame IS the Stylus contract. let report = MixedTraceStitcher::stitch( "0x999", + 42161, vec![], vec![host_io("user_entrypoint", 1_000_000, 900_000)], ); @@ -479,9 +628,47 @@ mod tests { #[test] fn boundary_steps_filter_returns_only_calls() { + // Without any HostIO steps, a CALL must NOT be marked as a VM boundary. + // (Pure-EVM transactions have no Stylus crossing — no false positives.) let evm_logs = vec![evm("ADD", 3, 1), evm("CALL", 100, 1)]; - let report = MixedTraceStitcher::stitch("0xfff", evm_logs, vec![]); - assert_eq!(report.boundary_steps().len(), 1); - assert_eq!(report.boundary_steps()[0].label, "CALL"); + let report = MixedTraceStitcher::stitch("0xfff", 42161, evm_logs, vec![]); + assert_eq!( + report.boundary_steps().len(), + 0, + "CALL without Stylus steps should not be a boundary" + ); + + // With HostIO steps present, the CALL that precedes them IS a boundary. + let evm_logs2 = vec![evm("ADD", 3, 1), evm("CALL", 100, 1)]; + let stylus_steps = vec![host_io("user_entrypoint", 100_000, 90_000)]; + let report2 = MixedTraceStitcher::stitch("0xfff", 42161, evm_logs2, stylus_steps); + assert_eq!( + report2.boundary_steps().len(), + 1, + "CALL before Stylus steps should be a boundary" + ); + assert_eq!(report2.boundary_steps()[0].label, "CALL"); + } + + #[test] + fn target_address_is_extracted_from_evm_stack() { + // CALL stack: [gas, address, value, argsOffset, argsLength, retOffset, retLength] + // Address is at len-2. + let mut log = evm("CALL", 100, 1); + log.stack = Some(vec![ + "0x0".into(), // retLength + "0x0".into(), // retOffset + "0x4".into(), // argsLength + "0x20".into(), // argsOffset + "0x0".into(), // value + "0x00000000000000000000000071C7656EC7ab88b098defB751B7401B5f6d8976F".into(), // address + "0x1000".into(), // gas + ]); + + let report = MixedTraceStitcher::stitch("0xabc", 1, vec![log], vec![]); + assert_eq!( + report.steps[0].target_address.as_deref(), + Some("0x71c7656ec7ab88b098defb751b7401b5f6d8976f") + ); } } diff --git a/crates/atupa-output/src/diff.rs b/crates/atupa-output/src/diff.rs new file mode 100644 index 0000000..cfc2740 --- /dev/null +++ b/crates/atupa-output/src/diff.rs @@ -0,0 +1,355 @@ +use atupa_core::{CollapsedStack, VmKind}; +use std::collections::HashMap; + +struct DiffEntry { + stack: String, + depth: u16, + vm_kind: VmKind, + baseline_weight: u64, + target_weight: u64, + resolved_label: Option, + target_address: Option, + reverted: bool, +} + +pub fn generate_diff_flamegraph( + baseline_stacks: &[CollapsedStack], + target_stacks: &[CollapsedStack], +) -> anyhow::Result { + // 1. Merge Stacks by exact path string + let mut merged: HashMap = HashMap::new(); + + for s in baseline_stacks { + merged.insert( + s.stack.clone(), + DiffEntry { + stack: s.stack.clone(), + depth: s.depth, + vm_kind: s.vm_kind.clone(), + baseline_weight: s.weight, + target_weight: 0, + resolved_label: s.resolved_label.clone(), + target_address: s.target_address.clone(), + reverted: s.reverted, + }, + ); + } + + for s in target_stacks { + if let Some(entry) = merged.get_mut(&s.stack) { + entry.target_weight += s.weight; + } else { + merged.insert( + s.stack.clone(), + DiffEntry { + stack: s.stack.clone(), + depth: s.depth, + vm_kind: s.vm_kind.clone(), + baseline_weight: 0, + target_weight: s.weight, + resolved_label: s.resolved_label.clone(), + target_address: s.target_address.clone(), + reverted: s.reverted, + }, + ); + } + } + + let entries: Vec<&DiffEntry> = merged.values().collect(); + if entries.is_empty() + || entries + .iter() + .all(|e| e.baseline_weight == 0 && e.target_weight == 0) + { + return Ok( + "\ + \ + No execution data found for diff.\ + " + .to_string(), + ); + } + + const SVG_W: f64 = 1000.0; + const PAD_L: f64 = 10.0; + const CHART_W: f64 = SVG_W - PAD_L * 2.0; + const BAR_H: f64 = 26.0; + const GAP: f64 = 4.0; + const HEADER_H: f64 = 60.0; + const SEPARATOR_H: f64 = 28.0; + const MIN_BAR_PX: f64 = 2.0; + + let evm_entries: Vec<&&DiffEntry> = entries + .iter() + .filter(|e| e.vm_kind == VmKind::Evm) + .collect(); + let mut wasm_entries: Vec<&&DiffEntry> = entries + .iter() + .filter(|e| e.vm_kind == VmKind::Stylus) + .collect(); + let has_wasm = !wasm_entries.is_empty(); + + let mut depths: Vec = evm_entries.iter().map(|e| e.depth).collect(); + depths.sort_unstable(); + depths.dedup(); + + let mut svg = String::new(); + // We will build the SVG body first to know the total height + let mut body = String::new(); + let mut current_y = HEADER_H; + + // ── EVM lanes ───────────────────────────────────────────────────────────── + for depth in &depths { + let mut lane_entries: Vec<&&&DiffEntry> = + evm_entries.iter().filter(|e| e.depth == *depth).collect(); + // Sort by stack string to maintain deterministic left-to-right ordering + lane_entries.sort_by(|a, b| a.stack.cmp(&b.stack)); + + let lane_weight: u64 = lane_entries + .iter() + .map(|e| std::cmp::max(e.baseline_weight, e.target_weight)) + .sum(); + if lane_weight == 0 { + continue; + } + + let mut bar_x = PAD_L; + for entry in &lane_entries { + let node_weight = std::cmp::max(entry.baseline_weight, entry.target_weight); + if node_weight == 0 { + continue; + } + let bar_w = (node_weight as f64 / lane_weight as f64) * CHART_W; + if bar_w < MIN_BAR_PX { + continue; + } + + render_diff_bar(&mut body, entry, bar_x, current_y, bar_w - 1.0, BAR_H); + bar_x += bar_w; + } + current_y += BAR_H + GAP; + } + + // ── WASM lanes ──────────────────────────────────────────────────────────── + if has_wasm { + current_y += SEPARATOR_H; + + // Draw separator + body.push_str(&format!( + r##""##, + PAD_L, current_y - 14.0, SVG_W - PAD_L, current_y - 14.0 + )); + body.push_str(&format!( + r##"STYLUS HOST I/O"##, + SVG_W / 2.0, current_y - 10.0 + )); + + let global_wasm_weight: u64 = wasm_entries + .iter() + .map(|e| std::cmp::max(e.baseline_weight, e.target_weight)) + .sum(); + wasm_entries.sort_by(|a, b| a.stack.cmp(&b.stack)); + + let mut bar_x = PAD_L; + for entry in &wasm_entries { + let node_weight = std::cmp::max(entry.baseline_weight, entry.target_weight); + if node_weight == 0 { + continue; + } + let bar_w = if global_wasm_weight > 0 { + (node_weight as f64 / global_wasm_weight as f64) * CHART_W + } else { + CHART_W / wasm_entries.len() as f64 + }; + if bar_w < MIN_BAR_PX { + continue; + } + + render_diff_bar(&mut body, entry, bar_x, current_y, bar_w - 1.0, BAR_H); + bar_x += bar_w; + } + current_y += BAR_H + GAP; + } + + let total_height = current_y + 60.0; + + // Build final SVG + svg.push_str(&format!( + r##""##, + SVG_W, total_height, SVG_W, total_height + )); + + svg.push_str( + r##" + + + + + + + + + + + + + + "## + ); + + // Title + svg.push_str(&format!( + r##"Atupa Visual Diff Flamegraph"##, + SVG_W / 2.0 + )); + + // Legend + render_diff_legend(&mut svg, total_height - 20.0); + + svg.push_str(&body); + svg.push_str(""); + Ok(svg) +} + +fn render_diff_bar(out: &mut String, entry: &DiffEntry, x: f64, y: f64, w: f64, h: f64) { + let baseline = entry.baseline_weight; + let target = entry.target_weight; + + let class = get_diff_class(baseline, target); + let tooltip = format_diff_tooltip(entry); + + out.push_str(&format!( + r##""##, + x, y, w, h, class + )); + out.push_str(&format!(r##"{}"##, tooltip)); + + let display_name = get_truncated_name( + &entry.stack, + &entry.resolved_label, + &entry.target_address, + w, + target, + ); + if !display_name.is_empty() { + out.push_str(&format!( + r##"{}"##, + x + 6.0, + y + 13.0, + display_name + )); + } +} + +fn get_diff_class(baseline: u64, target: u64) -> &'static str { + if baseline == 0 && target == 0 { + return "box-stable"; + } + if baseline == 0 { + return "box-regress"; + } + if target == 0 { + return "box-improve"; + } + + let change = (target as f64 - baseline as f64) / baseline as f64; + + if change > 0.01 { + "box-regress" + } else if change < -0.01 { + "box-improve" + } else { + "box-stable" + } +} + +fn format_diff_tooltip(entry: &DiffEntry) -> String { + let baseline = entry.baseline_weight; + let target = entry.target_weight; + let leaf = entry.stack.split(';').next_back().unwrap_or(&entry.stack); + + let prefix = if entry.reverted { "REVERTED — " } else { "" }; + let vm = if entry.vm_kind == VmKind::Evm { + "EVM" + } else { + "Stylus" + }; + + if baseline == 0 { + return format!("{}{} [{}] | NEW: {} gas", prefix, leaf, vm, target); + } + if target == 0 { + return format!("{}{} [{}] | REMOVED: {} gas", prefix, leaf, vm, baseline); + } + + let diff = target as i64 - baseline as i64; + let percent = (diff as f64 / baseline as f64) * 100.0; + + format!( + "{}{} [{}] | {} -> {} gas ({:+.2}%)", + prefix, leaf, vm, baseline, target, percent + ) +} + +fn get_truncated_name( + stack: &str, + resolved: &Option, + addr: &Option, + w: f64, + weight: u64, +) -> String { + let leaf = stack.split(';').next_back().unwrap_or(stack); + let base = if let Some(r) = resolved { + r.clone() + } else if let Some(a) = addr { + format!("{} [{}]", leaf, a) + } else { + format!("{} ({} gas)", leaf, weight) + }; + + let max_chars = ((w - 12.0) / 7.0) as usize; + if max_chars < 3 { + return String::new(); + } + if base.len() <= max_chars { + base + } else { + format!("{}…", &base[..max_chars.saturating_sub(1)]) + } +} + +fn render_diff_legend(out: &mut String, y: f64) { + let items = [ + ("Regression (Target > Base)", "box-regress"), + ("Improvement (Target < Base)", "box-improve"), + ("No Change", "box-stable"), + ]; + + let start_x = (1000.0 - (items.len() as f64 * 200.0)) / 2.0; + + for (i, (label, class)) in items.iter().enumerate() { + let x = start_x + (i as f64 * 220.0); + out.push_str(&format!( + r##""##, + x, + y - 6.0, + class + )); + out.push_str(&format!( + r##"{}"##, + x + 18.0, + y, + label + )); + } +} diff --git a/crates/atupa-output/src/lib.rs b/crates/atupa-output/src/lib.rs index 510267a..c2fcec9 100644 --- a/crates/atupa-output/src/lib.rs +++ b/crates/atupa-output/src/lib.rs @@ -1,5 +1,10 @@ use askama::Template; -use atupa_core::CollapsedStack; +use atupa_core::{CollapsedStack, VmKind}; + +pub mod diff; +pub use diff::generate_diff_flamegraph; + +// ─── Template types ────────────────────────────────────────────────────────── #[derive(Template)] #[template(path = "flamegraph.svg")] @@ -7,67 +12,229 @@ struct FlamegraphTemplate { stacks: Vec, width: u32, height: u32, + has_wasm: bool, } struct StackEntry { + x: f64, y: f64, - width: f64, + bar_width: f64, label: String, + tooltip: String, class: String, + /// True for the very first Stylus/WASM bar — renderer draws separator above it. + is_wasm_section_start: bool, + /// y-coordinate of the separator line (only meaningful when is_wasm_section_start) + separator_y: f64, } +// ─── Renderer ──────────────────────────────────────────────────────────────── + pub struct SvgGenerator; impl SvgGenerator { - /// Generates a valid SVG visualization string matching Atupa Aesthetic using Askama. + /// Generates a depth-lane, dual-VM SVG flamegraph. + /// + /// Layout rules: + /// - EVM stacks are arranged in horizontal swim lanes by call depth. + /// Deeper calls are placed in lower lanes so the visual nesting matches + /// the actual call hierarchy. + /// - Within each depth lane the bars are laid out left-to-right proportional + /// to their gas weight. + /// - Stylus/WASM HostIO steps render below a separator in a dedicated amber lane. + /// - Reverted stacks use a red gradient. pub fn generate_flamegraph(stacks: &[CollapsedStack]) -> anyhow::Result { - let total_weight: u64 = stacks.iter().map(|s| s.weight).sum(); - if total_weight == 0 { - return Ok("No execution data found.".to_string()); + if stacks.is_empty() || stacks.iter().all(|s| s.weight == 0) { + return Ok( + "\ + \ + No execution data found.\ + " + .to_string(), + ); } - let max_width = 980.0; - let mut current_y = 20.0; - let mut template_stacks = Vec::new(); + const SVG_W: f64 = 1000.0; + const PAD_L: f64 = 10.0; + const CHART_W: f64 = SVG_W - PAD_L * 2.0; + const BAR_H: f64 = 26.0; + const GAP: f64 = 4.0; + const HEADER_H: f64 = 36.0; // row for legend + title + const SEPARATOR_H: f64 = 28.0; // height of the EVM/WASM divider row + const MIN_BAR_PX: f64 = 2.0; - for stack in stacks { - if stack.weight == 0 { - continue; - } - let width = (stack.weight as f64 / total_weight as f64) * max_width; - if width < 1.0 { + let evm_stacks: Vec<&CollapsedStack> = + stacks.iter().filter(|s| s.vm_kind == VmKind::Evm).collect(); + let wasm_stacks: Vec<&CollapsedStack> = stacks + .iter() + .filter(|s| s.vm_kind == VmKind::Stylus) + .collect(); + let has_wasm = !wasm_stacks.is_empty(); + + // Gather unique depths for EVM stacks, sorted ascending (depth 1 on top). + let mut depths: Vec = evm_stacks.iter().map(|s| s.depth).collect(); + depths.sort_unstable(); + depths.dedup(); + + // Total EVM weight is per depth-lane (each lane fills CHART_W independently) + // but we need the global total for the tooltip percentage. + let global_evm_weight: u64 = evm_stacks.iter().map(|s| s.weight).sum(); + let global_wasm_weight: u64 = wasm_stacks.iter().map(|s| s.weight).sum(); + + let mut entries: Vec = Vec::new(); + let mut current_y = HEADER_H; + + // ── EVM depth lanes ─────────────────────────────────────────────────── + for depth in &depths { + let lane_stacks: Vec<&&CollapsedStack> = + evm_stacks.iter().filter(|s| s.depth == *depth).collect(); + let lane_weight: u64 = lane_stacks.iter().map(|s| s.weight).sum(); + if lane_weight == 0 { continue; } - let box_class = if stack.reverted { "box-revert" } else { "box" }; - let leaf_name = stack.stack.split(';').next_back().unwrap_or("unknown"); + let mut bar_x = PAD_L; + for stack in &lane_stacks { + if stack.weight == 0 { + continue; + } + let bar_w = (stack.weight as f64 / lane_weight as f64) * CHART_W; + if bar_w < MIN_BAR_PX { + continue; + } - let mut label = format!("{} ({} gas)", leaf_name, stack.weight); - if let Some(r_label) = &stack.resolved_label { - label = format!("{} ({} gas)", r_label, stack.weight); - } else if let Some(addr) = &stack.target_address { - label = format!("{} [{}] ({} gas)", leaf_name, addr, stack.weight); - } - if stack.reverted { - label = format!("REVERTED: {}", label); + let class = if stack.reverted { + "box-revert" + } else { + "box-evm" + }; + let label = Self::make_label(stack, bar_w); + let pct = if global_evm_weight > 0 { + stack.weight as f64 / global_evm_weight as f64 * 100.0 + } else { + 0.0 + }; + let tooltip = if stack.reverted { + format!( + "REVERTED — {} | depth {} | {} gas ({:.1}%)", + Self::stack_leaf(stack), + stack.depth, + stack.weight, + pct + ) + } else { + format!( + "{} | depth {} | {} gas ({:.1}%)", + Self::stack_leaf(stack), + stack.depth, + stack.weight, + pct + ) + }; + + entries.push(StackEntry { + x: bar_x, + y: current_y, + bar_width: bar_w - 1.0, // 1px breathing gap between siblings + label, + tooltip, + class: class.to_string(), + is_wasm_section_start: false, + separator_y: 0.0, + }); + bar_x += bar_w; } - template_stacks.push(StackEntry { - y: current_y, - width, - label, - class: box_class.to_string(), - }); + current_y += BAR_H + GAP; + } + + // ── WASM section ───────────────────────────────────────────────────── + if has_wasm { + // Spacer / label row — rendered via template has_wasm flag not via entries + current_y += SEPARATOR_H; + + let mut bar_x = PAD_L; + for stack in &wasm_stacks { + if stack.weight == 0 { + continue; + } + let bar_w = if global_wasm_weight > 0 { + (stack.weight as f64 / global_wasm_weight as f64) * CHART_W + } else { + CHART_W / wasm_stacks.len() as f64 + }; + if bar_w < MIN_BAR_PX { + continue; + } - current_y += 25.0; + let label = Self::make_label(stack, bar_w); + let pct = if global_wasm_weight > 0 { + stack.weight as f64 / global_wasm_weight as f64 * 100.0 + } else { + 0.0 + }; + let tooltip = format!( + "{} | Stylus HostIO | {:.2} gas-equiv ({:.1}%)", + Self::stack_leaf(stack), + stack.weight as f64, + pct + ); + + let is_first_wasm = entries.iter().all(|e| e.class != "box-wasm"); + entries.push(StackEntry { + x: bar_x, + y: current_y, + bar_width: bar_w - 1.0, + label, + tooltip, + class: "box-wasm".to_string(), + is_wasm_section_start: is_first_wasm, + separator_y: current_y - 18.0, + }); + bar_x += bar_w; + } + + current_y += BAR_H + GAP; } + let height = (current_y + 16.0) as u32; let template = FlamegraphTemplate { - stacks: template_stacks, - width: 1000, - height: (current_y + 20.0) as u32, + stacks: entries, + width: SVG_W as u32, + height, + has_wasm, }; - Ok(template.render()?) } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Label shown inside the bar. Uses resolved_label if present, otherwise + /// builds "LEAF (N gas)". Truncates to fit the available pixel width. + fn make_label(stack: &CollapsedStack, bar_w: f64) -> String { + let base = if let Some(r) = &stack.resolved_label { + r.clone() + } else if let Some(addr) = &stack.target_address { + format!("{} [{}]", Self::stack_leaf(stack), addr) + } else { + format!("{} ({} gas)", Self::stack_leaf(stack), stack.weight) + }; + + // Approximate character fit: Inter/mono ≈ 7px per char at 12px + let max_chars = ((bar_w - 8.0) / 7.0) as usize; + if max_chars < 3 { + return String::new(); + } + if base.len() <= max_chars { + base + } else { + format!("{}…", &base[..max_chars.saturating_sub(1)]) + } + } + + fn stack_leaf(stack: &CollapsedStack) -> &str { + stack.stack.split(';').next_back().unwrap_or(&stack.stack) + } } diff --git a/crates/atupa-output/templates/flamegraph.svg b/crates/atupa-output/templates/flamegraph.svg index 7db55e0..7401091 100644 --- a/crates/atupa-output/templates/flamegraph.svg +++ b/crates/atupa-output/templates/flamegraph.svg @@ -1,22 +1,54 @@ - - - + + + - - + + + + + + + + + + + + + EVM opcode + {% if has_wasm %} + + Stylus / WASM + {% endif %} + + Reverted + + {% for entry in stacks %} - - {{ entry.label }} + {% if entry.is_wasm_section_start %} + + + ▼ STYLUS WASM (Ink → Gas-Equiv) + {% endif %} + + {{ entry.tooltip }} + + {% if entry.label != "" %} + {{ entry.label }} + {% endif %} {% endfor %} + diff --git a/crates/atupa-parser/examples/trace_analysis.rs b/crates/atupa-parser/examples/trace_analysis.rs index a1dee64..334e187 100644 --- a/crates/atupa-parser/examples/trace_analysis.rs +++ b/crates/atupa-parser/examples/trace_analysis.rs @@ -6,15 +6,11 @@ fn main() { // 1. Create mock trace steps (in a real app, these come from atupa-rpc) let steps = vec![ TraceStep { - pc: 0, op: "PUSH1".into(), gas: 100, gas_cost: 3, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { pc: 1, @@ -31,20 +27,14 @@ fn main() { "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".into(), // USDC "0xFFFF".into(), ]), - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { - pc: 0, op: "SSTORE".into(), gas: 50, gas_cost: 20000, depth: 2, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, ]; diff --git a/crates/atupa-parser/src/aggregator.rs b/crates/atupa-parser/src/aggregator.rs index 591a0d7..dfa5b24 100644 --- a/crates/atupa-parser/src/aggregator.rs +++ b/crates/atupa-parser/src/aggregator.rs @@ -1,4 +1,4 @@ -use atupa_core::{CollapsedStack, TraceStep}; +use atupa_core::{CollapsedStack, TraceStep, VmKind}; use log::debug; use std::collections::HashMap; @@ -24,9 +24,11 @@ impl Aggregator { struct AggregatedData { total_gas: u64, _last_pc: u64, + max_depth: u16, target_address: Option, resolved_label: Option, reverted: bool, + vm_kind: VmKind, } let registry = atupa_adapters::AdapterRegistry::new(); @@ -65,10 +67,9 @@ impl Aggregator { // Extract target address (second item from top) let hex_addr = &stack[stack.len() - 2]; let clean_hex = hex_addr.trim_start_matches("0x"); - if clean_hex.len() >= 40 { - let extracted = &clean_hex[clean_hex.len() - 40..]; - target_address = Some(format!("0x{}", extracted)); - } + let padded = format!("{:0>40}", clean_hex); + let extracted = &padded[padded.len() - 40..]; + target_address = Some(format!("0x{}", extracted)); } // Attempt to extract the 4-byte selector from Memory using Offset & Length @@ -145,13 +146,18 @@ impl Aggregator { // Accumulate gas cost and flags let entry = stack_map.entry(stack_str).or_insert(AggregatedData { total_gas: 0, - _last_pc: 0, + _last_pc: step.pc, + max_depth: step.depth, target_address: None, resolved_label: None, reverted: false, + vm_kind: step.vm_kind.clone(), }); entry.total_gas += step.gas_cost; entry._last_pc = step.pc; + if step.depth > entry.max_depth { + entry.max_depth = step.depth; + } if target_address.is_some() { entry.target_address = target_address; } @@ -161,6 +167,8 @@ impl Aggregator { if step.reverted { entry.reverted = true; } + // Leaf VM kind wins for the stack + entry.vm_kind = step.vm_kind.clone(); } let mut stacks: Vec = stack_map @@ -169,13 +177,15 @@ impl Aggregator { stack, weight: data.total_gas, last_pc: Some(data._last_pc), + depth: data.max_depth, + vm_kind: data.vm_kind, target_address: data.target_address, resolved_label: data.resolved_label, reverted: data.reverted, }) .collect(); - stacks.sort_by(|a, b| b.weight.cmp(&a.weight)); + stacks.sort_by_key(|b| std::cmp::Reverse(b.weight)); debug!("Built {} unique collapsed stacks", stacks.len()); stacks @@ -192,61 +202,41 @@ mod tests { let steps = vec![ // Root context opcodes (Depth 1) TraceStep { - pc: 0, op: "PUSH1".into(), gas: 100, gas_cost: 3, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { pc: 1, op: "CALL".into(), gas: 90, - gas_cost: 0, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, // Sub-context opcodes (Depth 2) TraceStep { - pc: 0, op: "SSTORE".into(), gas: 50, gas_cost: 20, depth: 2, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { pc: 1, op: "RETURN".into(), gas: 20, - gas_cost: 0, depth: 2, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, // Back to root (Depth 1) TraceStep { pc: 2, op: "STOP".into(), gas: 15, - gas_cost: 0, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, ]; @@ -263,59 +253,37 @@ mod tests { fn test_aggregator_recursive_calls() { let steps = vec![ TraceStep { - pc: 0, op: "CALL".into(), gas: 1000, - gas_cost: 0, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { - pc: 0, op: "CALL".into(), gas: 900, - gas_cost: 0, depth: 2, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { - pc: 0, op: "SSTORE".into(), gas: 800, gas_cost: 5000, depth: 3, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { pc: 1, op: "RETURN".into(), gas: 700, - gas_cost: 0, depth: 3, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { pc: 1, op: "RETURN".into(), gas: 600, - gas_cost: 0, depth: 2, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, ]; @@ -331,26 +299,19 @@ mod tests { fn test_aggregator_revert_propagation() { let steps = vec![ TraceStep { - pc: 0, op: "CALL".into(), gas: 1000, - gas_cost: 0, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, TraceStep { - pc: 0, op: "REVERT".into(), gas: 900, gas_cost: 200, depth: 2, - stack: None, - memory: None, error: Some("Reverted".into()), reverted: true, + ..Default::default() }, ]; @@ -392,26 +353,20 @@ mod tests { let steps = vec![ TraceStep { - pc: 0, op: "CALL".into(), gas: 1000, gas_cost: 50, depth: 1, stack: Some(stack), memory: Some(memory), - error: None, - reverted: false, + ..Default::default() }, TraceStep { pc: 1, op: "STOP".into(), gas: 900, - gas_cost: 0, depth: 1, - stack: None, - memory: None, - error: None, - reverted: false, + ..Default::default() }, ]; @@ -454,15 +409,13 @@ mod tests { ]; let steps = vec![TraceStep { - pc: 0, op: "CALL".into(), gas: 1000, gas_cost: 80, depth: 1, stack: Some(stack), memory: Some(memory), - error: None, - reverted: false, + ..Default::default() }]; let stacks = Aggregator::build_collapsed_stacks(&steps); diff --git a/crates/atupa-parser/src/lib.rs b/crates/atupa-parser/src/lib.rs index 147964a..a931b3e 100644 --- a/crates/atupa-parser/src/lib.rs +++ b/crates/atupa-parser/src/lib.rs @@ -1,6 +1,6 @@ pub mod aggregator; -use atupa_core::TraceStep; +use atupa_core::{TraceStep, VmKind}; use atupa_rpc::RawStructLog; pub struct Parser; @@ -22,8 +22,24 @@ impl Parser { memory: log.memory, error: log.error, reverted, + vm_kind: VmKind::Evm, } }) .collect() } + + /// Pass-through for steps that are already normalized (e.g. the unified + /// `UnifiedStep` timeline from `atupa-nitro`). Applies the same revert + /// detection logic so the Aggregator sees consistent flags. + pub fn normalize_raw(steps: Vec) -> Vec { + steps + .into_iter() + .map(|mut step| { + if step.error.is_some() || step.op == "REVERT" || step.op == "INVALID" { + step.reverted = true; + } + step + }) + .collect() + } } diff --git a/crates/atupa-rpc/Cargo.toml b/crates/atupa-rpc/Cargo.toml index 88acc3b..35b56ae 100644 --- a/crates/atupa-rpc/Cargo.toml +++ b/crates/atupa-rpc/Cargo.toml @@ -21,3 +21,4 @@ reqwest = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +dirs = { workspace = true } diff --git a/crates/atupa-rpc/src/etherscan.rs b/crates/atupa-rpc/src/etherscan.rs index 91002b3..739eada 100644 --- a/crates/atupa-rpc/src/etherscan.rs +++ b/crates/atupa-rpc/src/etherscan.rs @@ -1,6 +1,7 @@ use reqwest::{Client, Url}; use serde::Deserialize; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; @@ -8,7 +9,6 @@ use tokio::sync::Mutex; #[derive(Deserialize, Debug)] struct EtherscanResponse { status: String, - _message: String, result: Vec, } @@ -18,51 +18,109 @@ struct EtherscanContractItem { contract_name: String, } +/// Returns the path to the persistent Etherscan cache file. +/// Resolves to `~/.atupa/etherscan_cache.json` (or OS equivalent). +fn cache_path() -> Option { + dirs::home_dir().map(|h| h.join(".atupa").join("etherscan_cache.json")) +} + +/// Loads the serialized cache from disk. Returns an empty map if the file +/// doesn't exist or cannot be parsed (non-fatal — we'll just fetch from the API). +fn load_cache() -> HashMap { + let Some(path) = cache_path() else { + return HashMap::new(); + }; + match std::fs::read_to_string(&path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +/// Flushes the full in-memory cache to disk atomically. +/// Errors are logged but never propagated — a cache write failure is non-fatal. +fn flush_cache(cache: &HashMap) { + let Some(path) = cache_path() else { return }; + + // Ensure the parent directory exists + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + log::warn!("⚠️ Could not create cache dir {:?}: {}", parent, e); + return; + } + + match serde_json::to_string_pretty(cache) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::warn!("⚠️ Could not write Etherscan cache to {:?}: {}", path, e); + } + } + Err(e) => { + log::warn!("⚠️ Could not serialize Etherscan cache: {}", e); + } + } +} + /// A lightweight client to resolve EVM addresses into Human-Readable Contract Names. +/// Resolves are cached in memory during execution and persisted to +/// `~/.atupa/etherscan_cache.json` across sessions. #[derive(Clone)] pub struct EtherscanResolver { client: Client, pub cache: Arc>>, api_key: Option, + chain_id: u64, } impl Default for EtherscanResolver { fn default() -> Self { - Self::new(None) + Self::new(None, 1) // Default to Ethereum mainnet if not specified } } impl EtherscanResolver { - pub fn new(api_key: Option) -> Self { + pub fn new(api_key: Option, chain_id: u64) -> Self { + let disk_cache = load_cache(); + let cache_size = disk_cache.len(); + if cache_size > 0 { + log::info!( + "📦 Loaded {} Etherscan contract name(s) from disk cache", + cache_size + ); + } + Self { client: Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap_or_default(), - cache: Arc::new(Mutex::new(HashMap::new())), + cache: Arc::new(Mutex::new(disk_cache)), api_key, + chain_id, } } /// Resolves an address to its verified Contract Name via Etherscan. + /// Results are cached in memory and on disk to avoid redundant API calls. #[allow(clippy::collapsible_if)] pub async fn resolve_contract_name(&self, address: &str) -> Option { if address.len() < 40 { return None; } - // Fast local hit + // Fast local hit (in-memory, pre-warmed from disk) { let cache_lock = self.cache.lock().await; if let Some(name) = cache_lock.get(address) { + log::debug!("📦 Cache hit: {} -> {}", address, name); return Some(name.clone()); } } // Network fetch (Etherscan API V2 requires chainid) let mut url_str = format!( - "https://api.etherscan.io/v2/api?chainid=1&module=contract&action=getsourcecode&address={}", - address + "https://api.etherscan.io/v2/api?chainid={}&module=contract&action=getsourcecode&address={}", + self.chain_id, address ); if let Some(key) = &self.api_key { url_str.push_str(&format!("&apikey={}", key)); @@ -77,12 +135,25 @@ impl EtherscanResolver { match (api_res.status.as_str(), api_res.result.first()) { ("1", Some(item)) if !item.contract_name.is_empty() => { let name = item.contract_name.clone(); + log::info!("✅ Etherscan resolved {} -> {}", address, name); + + // Update in-memory cache then flush to disk let mut cache_lock = self.cache.lock().await; cache_lock.insert(address.to_string(), name.clone()); + flush_cache(&cache_lock); + return Some(name); } - _ => {} + _ => { + log::debug!( + "❌ Etherscan hit but no name for {}: {:?}", + address, + api_res + ); + } } + } else { + log::debug!("❌ Etherscan JSON parse failed for {}", address); } } diff --git a/crates/atupa-rpc/src/lib.rs b/crates/atupa-rpc/src/lib.rs index a47d327..c316692 100644 --- a/crates/atupa-rpc/src/lib.rs +++ b/crates/atupa-rpc/src/lib.rs @@ -70,7 +70,7 @@ impl EthClient { tx_hash, { "enableMemory": false, - "disableStack": true, + "disableStack": false, "disableStorage": true } ]); @@ -99,4 +99,93 @@ impl EthClient { .result .ok_or_else(|| RpcError::Node("Missing result in RPC response".to_string())) } + + /// Fetch the chain ID from the node + pub async fn get_chain_id(&self) -> Result { + let payload = json!({ + "jsonrpc": "2.0", + "method": "eth_chainId", + "params": [], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await?; + + let rpc_res: serde_json::Value = response.json().await?; + + if let Some(err) = rpc_res.get("error") { + return Err(RpcError::Node( + err["message"].as_str().unwrap_or("Unknown").to_string(), + )); + } + + let result = rpc_res["result"] + .as_str() + .ok_or_else(|| RpcError::Node("Missing result in eth_chainId response".to_string()))?; + + u64::from_str_radix(result.trim_start_matches("0x"), 16) + .map_err(|e| RpcError::Node(format!("Invalid chainId hex: {}", e))) + } + + /// Fetch the actual on-chain gasUsed from eth_getTransactionReceipt. + /// Returns None (non-fatal) if the receipt is unavailable or the call fails. + pub async fn get_gas_used(&self, tx_hash: &str) -> Option { + let payload = json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionReceipt", + "params": [tx_hash], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await + .ok()?; + + let rpc_res: serde_json::Value = response.json().await.ok()?; + + let gas_hex = rpc_res["result"]["gasUsed"].as_str()?; + u64::from_str_radix(gas_hex.trim_start_matches("0x"), 16).ok() + } + + /// Fetch the raw `input` (calldata) of a transaction via eth_getTransactionByHash. + /// Returns the hex-encoded input string (e.g. "0xa9059cbb000..."). + /// Returns None (non-fatal) if the call fails. + pub async fn get_transaction_input(&self, tx_hash: &str) -> Option { + let payload = json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionByHash", + "params": [tx_hash], + "id": 1 + }); + + let response = self + .client + .post(&self.rpc_url) + .json(&payload) + .send() + .await + .ok()?; + + let rpc_res: serde_json::Value = response.json().await.ok()?; + rpc_res["result"]["input"].as_str().map(|s| s.to_string()) + } + + /// Extract the 4-byte function selector from raw calldata. + /// Returns a lowercase hex string like "0xa9059cbb", or None if calldata is too short. + pub fn selector_from_input(input: &str) -> Option { + let stripped = input.trim_start_matches("0x"); + if stripped.len() < 8 { + return None; + } + Some(format!("0x{}", &stripped[..8].to_lowercase())) + } } diff --git a/crates/atupa-sdk/Cargo.toml b/crates/atupa-sdk/Cargo.toml index 64552aa..0c1a76e 100644 --- a/crates/atupa-sdk/Cargo.toml +++ b/crates/atupa-sdk/Cargo.toml @@ -21,6 +21,7 @@ atupa-adapters = { workspace = true } atupa-output = { workspace = true } atupa-aave = { workspace = true } atupa-lido = { workspace = true } +atupa-nitro = { 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 adb33ec..0df588a 100644 --- a/crates/atupa-sdk/src/lib.rs +++ b/crates/atupa-sdk/src/lib.rs @@ -52,10 +52,11 @@ pub use profile::execute_profile; /// High-level profile execution logic, usable independently from the CLI. pub mod profile { use anyhow::Result; - use atupa_core::TraceStep; + use atupa_core::{CollapsedStack, VmKind}; + use atupa_nitro::{NitroClient, VmKind as NitroVmKind}; use atupa_output::SvgGenerator; use atupa_parser::{Parser as AtupaParser, aggregator::Aggregator}; - use atupa_rpc::{EthClient, etherscan::EtherscanResolver}; + use atupa_rpc::etherscan::EtherscanResolver; use indicatif::{ProgressBar, ProgressStyle}; use std::{fs, time::Duration}; @@ -70,40 +71,85 @@ pub mod profile { is_demo: bool, out: Option, etherscan_key: Option, - ) -> Result<()> { + ) -> Result<(String, String)> { let pb = make_spinner(); // 1. Fetch ───────────────────────────────────────────────────────────── - let steps: Vec = if is_demo { - demo_trace(&pb) + let (mut stacks, network_name) = if is_demo { + pb.set_message("Generating offline demo trace…"); + (demo_stacks(), "Demo".to_string()) } else { - fetch_live(&pb, tx, rpc).await? - }; + pb.set_message("Detecting network and fetching execution trace…"); + let client = NitroClient::new(rpc.to_string()); + let report = + tokio::time::timeout(Duration::from_secs(30), client.trace_transaction(tx)) + .await + .map_err(|_| { + anyhow::anyhow!("RPC timed out after 30s — is the node reachable at {rpc}?") + })? + .map_err(|e| anyhow::anyhow!("RPC error: {e}"))?; + + let network = get_network_name(report.chain_id); + let evm_count = report + .steps + .iter() + .filter(|s| s.vm == NitroVmKind::Evm) + .count(); + let wasm_count = report + .steps + .iter() + .filter(|s| s.vm == NitroVmKind::Stylus) + .count(); + pb.set_message(format!( + "Processing {evm_count} EVM + {wasm_count} Stylus steps from {network}…" + )); + + // ── Unified single-pass aggregation ──────────────────────────────── + // Convert the interleaved UnifiedStep timeline into core TraceSteps. + // Stylus steps already carry depth = (parent CALL depth + 1) and a + // gas_cost equal to ink / 10_000, so the Aggregator nests them under + // their EVM CALL frames without any special-casing. + let unified_steps: Vec = + report.steps.iter().map(|s| s.to_trace_step()).collect(); - // 2. Aggregate ───────────────────────────────────────────────────────── - pb.set_message("Aggregating execution metrics…"); - let mut stacks = Aggregator::build_collapsed_stacks(&steps); - - // 3. Etherscan resolution ─────────────────────────────────────────────── - pb.set_message("Resolving contract names via Etherscan…"); - let resolver = EtherscanResolver::new(etherscan_key); - for stack in &mut stacks { - if let Some(addr) = &stack.target_address - && let Some(name) = resolver.resolve_contract_name(addr).await - { - stack.target_address = Some(name); + 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); + } } - } - // 4. Render + save ───────────────────────────────────────────────────── + (combined, network) + }; + + // Sort EVM stacks descending by weight; Stylus stacks come after + let evm_end = stacks.partition_point(|s| s.vm_kind == VmKind::Evm); + stacks[..evm_end].sort_by_key(|b| std::cmp::Reverse(b.weight)); + + // 2. Render + save ───────────────────────────────────────────────────── pb.set_message("Generating SVG flamegraph…"); let svg = SvgGenerator::generate_flamegraph(&stacks)?; - let out_path = - out.unwrap_or_else(|| format!("profile_{}.svg", if is_demo { "demo" } else { tx })); + let out_path = out.unwrap_or_else(|| { + if is_demo { + "profile_demo.svg".to_string() + } else { + // Shorten to first 10 hex chars after 0x + let short = tx.trim_start_matches("0x").get(..10).unwrap_or(tx); + format!("profile_{short}.svg") + } + }); fs::write(&out_path, svg)?; pb.finish_with_message(format!("✔ Profile saved → {out_path}")); - Ok(()) + Ok((out_path, network_name)) } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -119,96 +165,125 @@ pub mod profile { pb } - async fn fetch_live(pb: &ProgressBar, tx: &str, rpc: &str) -> Result> { - pb.set_message("Connecting to EVM node via JSON-RPC…"); - let client = EthClient::new(rpc.to_string()); - - let raw = tokio::time::timeout(Duration::from_secs(30), client.get_transaction_trace(tx)) - .await - .map_err(|_| { - anyhow::anyhow!("RPC timed out after 30 s — is the node reachable at {rpc}?") - })? - .map_err(|e| anyhow::anyhow!("RPC error: {e}\nHint: is your node running at {rpc}?"))?; - - pb.set_message(format!("Normalizing {} structLogs…", raw.struct_logs.len())); - Ok(AtupaParser::normalize(raw.struct_logs)) + fn get_network_name(chain_id: u64) -> String { + match chain_id { + 1 => "Ethereum Mainnet".to_string(), + 11155111 => "Sepolia Testnet".to_string(), + 17000 => "Holesky Testnet".to_string(), + 42161 => "Arbitrum One".to_string(), + 42170 => "Arbitrum Nova".to_string(), + 421614 => "Arbitrum Sepolia".to_string(), + 8453 => "Base Mainnet".to_string(), + 84532 => "Base Sepolia".to_string(), + 10 => "Optimism".to_string(), + 11155420 => "Optimism Sepolia".to_string(), + 137 => "Polygon POS".to_string(), + 1337 | 31337 => "Local Devnet".to_string(), + 412346 => "Nitro Local Devnet".to_string(), + 0 => "Unknown Network".to_string(), + id => format!("Chain ID: {id}"), + } } - fn demo_trace(pb: &ProgressBar) -> Vec { - pb.set_message("Generating offline demo trace…"); + /// A rich offline demo trace showcasing nested calls, reverts, and simulated Stylus steps. + fn demo_stacks() -> Vec { vec![ - TraceStep { - pc: 0, - op: "PUSH1".into(), - gas: 1_000_000, - gas_cost: 3, + // ── Root frame ops (depth 1) ──────────────────────────────────── + CollapsedStack { + stack: "CALL".to_string(), + weight: 21_000, + last_pc: Some(0), depth: 1, - stack: None, - memory: None, - error: None, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Root CALL (21,000 gas)".to_string()), reverted: false, }, - TraceStep { - pc: 1, - op: "CALL".into(), - gas: 999_997, - gas_cost: 2_600, - depth: 1, - stack: Some(vec![ - "0x0000000000000000000000000000000000000000".into(), - "0x0000000000000000000000000000000000000000".into(), - "0x0000000000000000000000000000000000000000".into(), - "0x0000000000000000000000000000000000000100".into(), - "0x0000000000000000000000000000000000000000".into(), - "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".into(), // USDC - "0x10000".into(), - ]), - memory: None, - error: None, + CollapsedStack { + stack: "CALL;SLOAD".to_string(), + weight: 2_100, + last_pc: Some(10), + depth: 2, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Storage Read (2,100 gas)".to_string()), reverted: false, }, - TraceStep { - pc: 0, - op: "SLOAD".into(), - gas: 500, - gas_cost: 2_100, + CollapsedStack { + stack: "CALL;SSTORE".to_string(), + weight: 20_000, + last_pc: Some(14), depth: 2, - stack: None, - memory: None, - error: None, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Storage Write (20,000 gas)".to_string()), reverted: false, }, - TraceStep { - pc: 1, - op: "SSTORE".into(), - gas: 480, - gas_cost: 20_000, - depth: 2, - stack: None, - memory: None, - error: None, + // ── Nested sub-call (depth 2 → 3) ────────────────────────────── + CollapsedStack { + stack: "CALL;CALL;KECCAK256".to_string(), + weight: 30, + last_pc: Some(20), + depth: 3, + vm_kind: VmKind::Evm, + target_address: Some("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()), + resolved_label: Some("USDC: KECCAK256 (30 gas)".to_string()), reverted: false, }, - TraceStep { - pc: 2, - op: "REVERT".into(), - gas: 400, - gas_cost: 5_000, + CollapsedStack { + stack: "CALL;CALL;SLOAD".to_string(), + weight: 2_100, + last_pc: Some(24), + depth: 3, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("Nested SLOAD (2,100 gas)".to_string()), + reverted: false, + }, + // ── Reverted sub-call (depth 2) ───────────────────────────────── + CollapsedStack { + stack: "CALL;REVERT".to_string(), + weight: 5_000, + last_pc: Some(40), depth: 2, - stack: None, - memory: None, - error: None, + vm_kind: VmKind::Evm, + target_address: None, + resolved_label: Some("REVERTED sub-call (5,000 gas)".to_string()), reverted: true, }, - TraceStep { - pc: 2, - op: "STOP".into(), - gas: 300, - gas_cost: 0, + // ── Simulated Stylus WASM steps ───────────────────────────────── + CollapsedStack { + stack: "storage_load_bytes32".to_string(), + weight: 421, + last_pc: None, + depth: 1, + vm_kind: VmKind::Stylus, + target_address: None, + resolved_label: Some( + "storage_load_bytes32 (4,215 ink → 0.42 gas-equiv)".to_string(), + ), + reverted: false, + }, + CollapsedStack { + stack: "storage_flush_cache".to_string(), + weight: 4_001, + last_pc: None, + depth: 1, + vm_kind: VmKind::Stylus, + target_address: None, + resolved_label: Some( + "storage_flush_cache (40,010 ink → 4.00 gas-equiv)".to_string(), + ), + reverted: false, + }, + CollapsedStack { + stack: "native_keccak256".to_string(), + weight: 4, + last_pc: None, depth: 1, - stack: None, - memory: None, - error: None, + vm_kind: VmKind::Stylus, + target_address: None, + resolved_label: Some("native_keccak256 (36 ink → 0.004 gas-equiv)".to_string()), reverted: false, }, ] diff --git a/studio/package-lock.json b/studio/package-lock.json index 9a881af..f5c1afd 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -8,6 +8,8 @@ "name": "studio", "version": "0.0.0", "dependencies": { + "@types/d3-hierarchy": "^3.1.7", + "d3-hierarchy": "^3.1.2", "react": "^19.2.4", "react-dom": "^19.2.4" }, @@ -881,6 +883,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1478,6 +1486,15 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/studio/package.json b/studio/package.json index d9af047..48edf2b 100644 --- a/studio/package.json +++ b/studio/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@types/d3-hierarchy": "^3.1.7", + "d3-hierarchy": "^3.1.2", "react": "^19.2.4", "react-dom": "^19.2.4" }, diff --git a/studio/public/auto-load.json b/studio/public/auto-load.json new file mode 100644 index 0000000..fc63ac9 --- /dev/null +++ b/studio/public/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/studio/src/App.tsx b/studio/src/App.tsx index e397e62..684a1f6 100644 --- a/studio/src/App.tsx +++ b/studio/src/App.tsx @@ -1,6 +1,5 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo, useEffect } from 'react'; import './styles/design-system.css'; -import type { StitchedReport } from './types/trace'; import { aggregateHostIOs, fmtGas, @@ -8,29 +7,57 @@ import { shortHash, evmSteps, stylusSteps, + isDiff, } from './types/trace'; +import type { StudioReport } from './types/trace'; +import { reportToTree } from './types/reportToTree'; import { DragDropZone } from './components/DragDropZone'; import { MetricCard } from './components/MetricCard'; import { HostIoAggregator } from './components/HostIoAggregator'; import { TraceInspector } from './components/TraceInspector'; +import { FlameGraph } from './components/FlameGraph'; +import { CategoryBreakdown } from './components/CategoryBreakdown'; +import { DiffOverview } from './components/DiffOverview'; -type View = 'overview' | 'trace' | 'hostio'; +type View = 'overview' | 'flame' | 'trace' | 'hostio'; export default function App() { - const [report, setReport] = useState(null); + const [report, setReport] = useState(null); const [view, setView] = useState('overview'); + const [flameSearch, setFlameSearch] = useState(''); - const handleLoad = useCallback((r: StitchedReport) => { + const handleLoad = useCallback((r: StudioReport) => { setReport(r); setView('overview'); }, []); + // ── Auto-load from URL ───────────────────────────────────────────────────── + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('auto') === 'true') { + fetch('/auto-load.json') + .then(res => { + if (!res.ok) throw new Error('Report not found'); + return res.json(); + }) + .then(handleLoad) + .catch(err => { + console.warn('Auto-load failed or no report found:', err); + }); + } + }, [handleLoad]); + const handleReset = useCallback(() => { setReport(null); setView('overview'); + setFlameSearch(''); }, []); - const hostIOs = report ? aggregateHostIOs(report) : []; + const hostIOs = report ? aggregateHostIOs(isDiff(report) ? report.target : report) : []; + const flameRoot = useMemo( + () => (report ? reportToTree(isDiff(report) ? report.target : report) : null), + [report], + ); return (
@@ -46,10 +73,10 @@ export default function App() { <> - Loaded + {isDiff(report) ? 'Comparison Loaded' : 'Single Trace Loaded'} - - {report.tx_hash} + + {isDiff(report) ? 'Execution Comparison' : report.tx_hash} + + ))} +
+ ); +} + +// ─── FlameGraph ─────────────────────────────────────────────────────────────── + +interface Props { + root: FlameNode; + search?: string; +} + +export function FlameGraph({ root, search = '' }: Props) { + const svgRef = useRef(null); + const [svgWidth, setSvgWidth] = useState(800); + const [tooltip, setTooltip] = useState(null); + + // Zoom state: the zoomed-in node trail (first = virtual root) + const [zoomTrail, setZoomTrail] = useState([root]); + const zoomedNode = zoomTrail[zoomTrail.length - 1]; + + // Recalculate when root changes (new report loaded) + useEffect(() => { + setZoomTrail([root]); + }, [root]); + + // Observe container width + useEffect(() => { + if (!svgRef.current) return; + const ro = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width; + if (width) setSvgWidth(width); + }); + ro.observe(svgRef.current); + return () => ro.disconnect(); + }, []); + + // Compute full layout (all nodes, fractions) + const allNodes = useMemo(() => { + const result: LayoutNode[] = []; + layoutTree(root, 0, 1, 0, result); + return result; + }, [root]); + + // Determine visible zoom window from the currently zoomed node + const { zoomX, zoomW } = useMemo(() => { + const found = allNodes.find((l) => l.node.id === zoomedNode.id); + if (!found) return { zoomX: 0, zoomW: 1 }; + return { zoomX: found.x, zoomW: found.w }; + }, [allNodes, zoomedNode]); + + // Filter to only nodes visible in current zoom (partially or fully) + const visibleNodes = useMemo( + () => + allNodes.filter((l) => { + if (l.w === 0) return false; + const visW = (l.w / zoomW) * svgWidth; + return visW >= MIN_PX; + }), + [allNodes, zoomW, svgWidth], + ); + + // SVG height + const maxRow = useMemo( + () => Math.max(...visibleNodes.map((l) => l.row), 0), + [visibleNodes], + ); + const svgHeight = (maxRow + 1) * (BAR_H + 2) + 8; + + // Handlers + const handleClick = useCallback( + (node: FlameNode) => { + setZoomTrail((t) => [...t, node]); + setTooltip(null); + }, + [], + ); + + const handleBreadcrumb = useCallback((idx: number) => { + setZoomTrail((t) => t.slice(0, idx + 1)); + setTooltip(null); + }, []); + + const handleHover = useCallback( + (tip: TooltipState | null, _evt: React.MouseEvent) => { + setTooltip(tip); + }, + [], + ); + + return ( +
+ + + {/* Legend */} +
+ {[ + { color: COLORS.evmStroke, label: 'EVM opcode' }, + { color: COLORS.stylusStroke, label: 'Stylus WASM' }, + { color: '#6d28d9', label: 'VM Boundary' }, + { color: '#ff2a4a', label: 'Search match' }, + ].map(({ color, label }) => ( + + + {label} + + ))} + + {allNodes.length} nodes · click to zoom + +
+ +
+ + {visibleNodes.map((l) => ( + + ))} + + {tooltip && } + +
+ + {/* Reset zoom hint */} + {zoomTrail.length > 1 && ( +
+ +
+ )} +
+ ); +} diff --git a/studio/src/components/MetricCard.tsx b/studio/src/components/MetricCard.tsx index 68689b1..ca5c807 100644 --- a/studio/src/components/MetricCard.tsx +++ b/studio/src/components/MetricCard.tsx @@ -1,7 +1,9 @@ +import type { ReactNode } from 'react'; + interface Props { label: string; value: string; - sub?: string; + sub?: ReactNode; kind: 'evm' | 'stylus' | 'boundary' | 'steps'; icon?: string; } diff --git a/studio/src/components/TraceInspector.tsx b/studio/src/components/TraceInspector.tsx index 2c7f173..baffc36 100644 --- a/studio/src/components/TraceInspector.tsx +++ b/studio/src/components/TraceInspector.tsx @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react'; +import { getDisplayLabel } from '../types/trace'; import type { StitchedReport, UnifiedStep } from '../types/trace'; interface Props { @@ -7,7 +8,7 @@ interface Props { const PAGE_SIZE = 150; -function StepRow({ step }: { step: UnifiedStep }) { +function StepRow({ step, report }: { step: UnifiedStep, report: StitchedReport }) { const indent = Array.from({ length: Math.max(0, step.depth - 1) }).map((_, i) => ( )); @@ -16,18 +17,21 @@ function StepRow({ step }: { step: UnifiedStep }) { ? step.gas_cost > 0 ? `${step.gas_cost} gas` : '' : `${step.cost_equiv.toFixed(2)} gas-equiv`; + const displayLabel = getDisplayLabel(step, report); + const isResolved = step.target_address && report.resolved_names[step.target_address]; + return (
#{step.index} {indent} {step.vm === 'Evm' ? 'EVM' : 'WASM'} - {step.label} + {displayLabel} {costStr && {costStr}} {step.is_vm_boundary && ( @@ -46,7 +50,9 @@ export function TraceInspector({ report }: Props) { if (filter === 'evm' && s.vm !== 'Evm') return false; if (filter === 'stylus' && s.vm !== 'Stylus') return false; if (filter === 'boundary' && !s.is_vm_boundary) return false; - if (search && !s.label.toLowerCase().includes(search.toLowerCase())) return false; + + const label = getDisplayLabel(s, report).toLowerCase(); + if (search && !label.includes(search.toLowerCase())) return false; return true; }); }, [report.steps, filter, search]); @@ -112,7 +118,7 @@ export function TraceInspector({ report }: Props) {
{visible.length === 0 ?
No steps match your filter.
- : visible.map((s) => ) + : visible.map((s) => ) }
diff --git a/studio/src/styles/design-system.css b/studio/src/styles/design-system.css index 4ee6db3..abc9942 100644 --- a/studio/src/styles/design-system.css +++ b/studio/src/styles/design-system.css @@ -582,3 +582,93 @@ body { } .app-sidebar { display: none; } } +/* ── Category Breakdown ─────────────────────────────────────────────────────── */ +.category-breakdown { + display: flex; + flex-direction: column; + gap: var(--sp-6); + padding: var(--sp-2) 0; +} + +.category-chart { + display: flex; + height: 32px; + background: var(--color-bg-void); + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--color-border); + box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); +} + +.category-slice { + height: 100%; + transition: width var(--t-slow), opacity var(--t-fast); + cursor: help; +} + +.category-slice:hover { + opacity: 0.85; + filter: brightness(1.2); +} + +.category-legend { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: var(--sp-3) var(--sp-6); +} + +.legend-item { + display: flex; + align-items: center; + gap: var(--sp-2); + font-family: var(--font-mono); + font-size: 12px; + padding: var(--sp-1) var(--sp-2); + border-radius: var(--radius-sm); + transition: background var(--t-fast); +} + +.legend-item:hover { + background: rgba(255,255,255,0.03); +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.legend-icon { + font-size: 14px; + margin-right: var(--sp-1); +} + +.legend-label { + color: var(--color-text-primary); + white-space: nowrap; +} + +.legend-spacer { + flex: 1; + border-bottom: 1px dotted var(--color-border); + margin: 0 var(--sp-2); + opacity: 0.5; +} + +.legend-value { + color: var(--color-text-secondary); + text-align: right; +} + +.legend-pct { + color: var(--color-text-muted); + min-width: 45px; + text-align: right; +} + +.trace-step-label.resolved { + color: var(--color-crimson); + font-weight: 600; + text-shadow: 0 0 8px rgba(255, 42, 74, 0.2); +} diff --git a/studio/src/types/reportToTree.ts b/studio/src/types/reportToTree.ts new file mode 100644 index 0000000..994d2ea --- /dev/null +++ b/studio/src/types/reportToTree.ts @@ -0,0 +1,102 @@ +// ─── Atupa Studio — Report to Flamegraph Tree ──────────────────────────────── +// +// Converts a flat `UnifiedStep[]` (with `depth` integers) into a tree of +// `FlameNode` objects suitable for hierarchical layout and d3-hierarchy. + +import type { VmKind, StitchedReport } from './trace'; + +// ─── Tree Node ──────────────────────────────────────────────────────────────── + +export interface FlameNode { + /** Unique identifier for stable React keys */ + id: string; + name: string; + vm: VmKind; + /** Cumulative cost in gas-equiv for this node and all its children */ + value: number; + /** Self cost only (no children) */ + selfCost: number; + /** Step index in the original trace — useful for cross-linking */ + stepIndex: number; + depth: number; + is_vm_boundary: boolean; + children: FlameNode[]; +} + +// ─── Conversion ─────────────────────────────────────────────────────────────── + +/** + * Builds a single virtual root node whose children represent the call tree. + * + * Strategy: + * - Maintain a stack of "open" ancestors by depth. + * - When a step's depth is <= top of stack, pop until we find the correct parent. + * - Each step becomes a leaf under its parent; costs are aggregated up during + * a separate post-order pass. + */ +export function reportToTree(report: StitchedReport): FlameNode { + const root: FlameNode = { + id: 'root', + name: report.tx_hash ? `tx ${report.tx_hash.slice(0, 8)}…` : 'Transaction', + vm: 'Evm', + value: 0, + selfCost: 0, + stepIndex: -1, + depth: 0, + is_vm_boundary: false, + children: [], + }; + + if (report.steps.length === 0) return root; + + // Stack tracks the ancestral chain by [depth, node]. + // depth-0 slot is the virtual root. + const stack: Array<{ depth: number; node: FlameNode }> = [ + { depth: 0, node: root }, + ]; + + for (const step of report.steps) { + const stepDepth = Math.max(1, step.depth); // never let depth be 0 + + // Pop ancestors that are shallower-or-equal to current step + while (stack.length > 1 && stack[stack.length - 1].depth >= stepDepth) { + stack.pop(); + } + + const parent = stack[stack.length - 1].node; + const selfCost = step.vm === 'Evm' ? step.gas_cost : step.cost_equiv; + + const node: FlameNode = { + id: `step-${step.index}`, + name: step.label, + vm: step.vm, + value: selfCost, // will be aggregated post-order + selfCost, + stepIndex: step.index, + depth: stepDepth, + is_vm_boundary: step.is_vm_boundary, + children: [], + }; + + parent.children.push(node); + stack.push({ depth: stepDepth, node }); + } + + // Post-order aggregation: parent.value = Σ children.value + selfCost + aggregateCosts(root); + + return root; +} + +function aggregateCosts(node: FlameNode): number { + if (node.children.length === 0) { + node.value = node.selfCost; + return node.value; + } + let childTotal = 0; + for (const child of node.children) { + childTotal += aggregateCosts(child); + } + node.value = node.selfCost + childTotal; + return node.value; +} diff --git a/studio/src/types/trace.ts b/studio/src/types/trace.ts index a196c69..36d184c 100644 --- a/studio/src/types/trace.ts +++ b/studio/src/types/trace.ts @@ -3,6 +3,17 @@ export type VmKind = 'Evm' | 'Stylus'; +export type GasCategory = + | 'StorageWrite' + | 'StorageRead' + | 'Memory' + | 'Crypto' + | 'Call' + | 'Execution' + | 'Precompile' + | 'Root' + | 'Other'; + export interface UnifiedStep { index: number; vm: VmKind; @@ -11,6 +22,8 @@ export interface UnifiedStep { cost_equiv: number; depth: number; is_vm_boundary: boolean; + category: GasCategory; + target_address?: string; } export interface StitchedReport { @@ -21,8 +34,57 @@ export interface StitchedReport { total_stylus_gas_equiv: number; total_unified_cost: number; vm_boundary_count: number; + category_costs: Record; + resolved_names: Record; +} + +export interface DiffReport { + type: 'diff'; + base: StitchedReport; + target: StitchedReport; + metrics: { + base_total_gas: number; + target_total_gas: number; + gas_delta: number; + gas_pct: number; + base_unified_cost: number; + target_unified_cost: number; + unified_delta: number; + unified_pct: number; + }; } +export type StudioReport = StitchedReport | DiffReport; + +export function isDiff(report: StudioReport): report is DiffReport { + return (report as any).type === 'diff'; +} + +export function getDisplayLabel(step: UnifiedStep, report: StitchedReport): string { + if (step.target_address && report.resolved_names[step.target_address]) { + return `${step.label} → ${report.resolved_names[step.target_address]}`; + } + return step.label; +} + +export interface CategoryMeta { + label: string; + color: string; + icon: string; +} + +export const CATEGORY_META: Record = { + 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: '❓' }, +}; + // ─── Derived helpers ────────────────────────────────────────────────────────── export interface AggregatedHostIO {