use anyhow::{Context, Result};
use colored::*;
use std::fs;
use std::path::Path;
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); });
"#;
#[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;
}
if Path::new("Cargo.toml").exists() {
return ProjectKind::StylusOnly;
}
ProjectKind::Unknown
}
pub fn detect_protocol() -> Option<String> {
let keywords = [("lido", "lido"), ("aave", "aave"), ("gho", "aave")];
let files = ["package.json", "foundry.toml", "Cargo.toml"];
for file in files {
if let Ok(content) = fs::read_to_string(file) {
let content_lower = content.to_lowercase();
for (kw, proto) in keywords {
if content_lower.contains(kw) {
return Some(proto.to_string());
}
}
}
}
None
}
pub struct InitArgs {
pub force: bool,
}
pub fn execute_init(args: InitArgs) -> Result<()> {
println!();
println!("{}", "🏮 Atupa — Initializing project integration".bold());
println!("{}", "─".repeat(55).dimmed());
println!();
let kind = detect_project();
println!(
" {} {}",
"🔍 Detected project type:".bold(),
kind.label().cyan().bold()
);
let protocol = detect_protocol();
if let Some(p) = &protocol {
println!(
" {} {}",
"💉 Detected protocol adapter:".bold(),
p.cyan().bold()
);
}
println!();
let mut created: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let toml_content = match kind {
ProjectKind::Foundry => ATUPA_TOML_FOUNDRY,
ProjectKind::Hardhat => ATUPA_TOML_HARDHAT,
ProjectKind::StylusOnly => ATUPA_TOML_STYLUS,
ProjectKind::Unknown => ATUPA_TOML_FOUNDRY, };
scaffold_file(
"atupa.toml",
toml_content,
args.force,
&mut created,
&mut skipped,
)?;
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,
)?;
match kind {
ProjectKind::Foundry | ProjectKind::StylusOnly => {
fs::create_dir_all("script").context("Failed to create script/ directory")?;
scaffold_file(
"script/AtupaProfile.s.sol",
FORGE_PROFILE_SCRIPT,
args.force,
&mut created,
&mut skipped,
)?;
}
ProjectKind::Hardhat => {
fs::create_dir_all("scripts").context("Failed to create scripts/ directory")?;
scaffold_file(
"scripts/AtupaProfile.js",
HARDHAT_PROFILE_SCRIPT,
args.force,
&mut created,
&mut skipped,
)?;
}
ProjectKind::Unknown => {
fs::create_dir_all("script").ok();
scaffold_file(
"script/AtupaProfile.s.sol",
FORGE_PROFILE_SCRIPT,
args.force,
&mut created,
&mut skipped,
)?;
}
}
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(())
}
fn scaffold_file(
path: &str,
content: &str,
force: bool,
created: &mut Vec<String>,
skipped: &mut Vec<String>,
) -> 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(())
}