diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0ac6688..06e6b9d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -52,4 +52,30 @@ jobs: run: cargo test - name: Build WebAssembly (build) - run: cargo build --target wasm32-unknown-unknown --release \ No newline at end of file + run: cargo build --target wasm32-unknown-unknown --release + + - name: Run Gas Tracking (Current Branch) + run: | + chmod +x ../scripts/track_gas.sh + ../scripts/track_gas.sh + cp ../gas_report.json /tmp/pr_gas.json + cp ../scripts/track_gas.sh /tmp/track_gas.sh + cp ../scripts/compare_gas.py /tmp/compare_gas.py + + - name: Run Gas Tracking (Base Branch) + if: github.event_name == 'pull_request' + run: | + git fetch origin ${{ github.base_ref }} + git checkout origin/${{ github.base_ref }} || true + /tmp/track_gas.sh || true + cp ../gas_report.json /tmp/base_gas.json || echo "[]" > /tmp/base_gas.json + git checkout - || true + + - name: Compare Gas and Post PR Comment + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + chmod +x /tmp/compare_gas.py + /tmp/compare_gas.py /tmp/base_gas.json /tmp/pr_gas.json > /tmp/gas_diff.md + gh pr comment ${{ github.event.pull_request.number }} --body-file /tmp/gas_diff.md || true \ No newline at end of file diff --git a/contract/MIGRATION.md b/contract/MIGRATION.md new file mode 100644 index 0000000..30623ff --- /dev/null +++ b/contract/MIGRATION.md @@ -0,0 +1,56 @@ +# Smart Contract Migration on Soroban + +This document outlines the standardized operational process for introducing, releasing, and migrating smart contract code changes across `testnet` and `futurenet`. + +Because SoroTask relies on a `Persistent` state environment where task configurations and balances persist across runs, replacing our `.wasm` logic using the new *upgrade execution route* prevents any state-loss that would normally occur from deploying a new contract ID. + +--- + +## 🏗 Prerequisites +Ensure that: +1. You have the latest `soroban-cli` installed and configured. +2. An `admin` identity is securely accessible via your CLI keys: `soroban keys generate admin` +3. Network variables (`testnet`/`futurenet`) are bound in the CLI config. + +--- + +## 🚀 1. Initial Deployment (New Instance) + +To deploy the service from scratch (usually only done during the initial project bootstrap): + +```bash +# Deploys, installs, and initializes the state +./scripts/deploy.sh testnet +``` + +**What this does:** +- Triggers `cargo build --target wasm32-unknown-unknown --release` +- Uploads the `.wasm` binary up to the designated network. +- Resolves the contract ID and automatically invokes the `init()` method passing `--token` and importantly the `--admin` parameter mapped to the `DataKey::Admin` slot. + +--- + +## 🔄 2. Migration & Upgrade (Overwriting Existing Instance) + +Once live, tasks register and gas funds execute natively. When we require bug fixes or new features logically mapped inside the contract, we execute an Upgrade! + +```bash +# Installs new compiled code and instructs the old instance to transition over +./scripts/upgrade.sh testnet +``` + +**What this does:** +- Repackages the workspace to fetch the newest binary. +- Instead of using `deploy`, it relies on the `install` CLI to get the explicit **hash** of the `.wasm` bytecode on the ledger. +- Communicates directly to your existing smart contract ID, providing the `--new_wasm_hash`. +- The contract invokes `admin.require_auth()` via native Soroban traits ensuring only the deployed admin controls lifecycle migrations. +- Executes `env.deployer().update_current_contract_wasm(new_wasm_hash)`. + +### Operational Hazards to Monitor: +- **Testnet/Futurenet Ledger Resets:** Keep an eye out for Stellar Network Resets where the global ledger is dumped. You will need to start from Phase 1 `deploy.sh` rather than an upgrade. +- **Admin Configuration Change:** If in the future we want to *change* the admin, an additional function like `transfer_admin` would need parity inside the Rust contract execution. + +--- +## 👷🏻 Continuous Integration + +These scripts are fully compatible with CI/CD flows utilizing the standard `$SOROBAN_SECRET_KEY` variable mapping from GitHub equivalents. diff --git a/contract/src/lib.rs b/contract/src/lib.rs index 7931315..08f583b 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -37,6 +37,7 @@ pub enum DataKey { Task(u64), Counter, Token, + Admin, } #[contracttype] @@ -312,12 +313,13 @@ impl SoroTaskContract { } } - /// Initializes the contract with a gas token. - pub fn init(env: Env, token: Address) { + /// Initializes the contract with a gas token and an admin. + pub fn init(env: Env, token: Address, admin: Address) { if env.storage().instance().has(&DataKey::Token) { panic!("Already initialized"); } env.storage().instance().set(&DataKey::Token, &token); + env.storage().instance().set(&DataKey::Admin, &admin); } /// Deposits gas tokens to a task's balance. @@ -430,12 +432,22 @@ impl SoroTaskContract { .get(&DataKey::Token) .expect("Not initialized") } + + /// Upgrades the contract to a new Wasm execution hash. + pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("Not initialized"); + admin.require_auth(); + env.deployer().update_current_contract_wasm(new_wasm_hash); + } } // ============================================================================ // Tests // ============================================================================ +#[cfg(test)] +mod test_gas; + #[cfg(test)] mod tests { use super::*; @@ -881,7 +893,8 @@ mod tests { let token_client = soroban_sdk::token::Client::new(&env, &token_address); let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); - client.init(&token_address); + let admin = Address::generate(&env); + client.init(&token_address, &admin); let target = env.register_contract(None, MockTarget); let mut cfg = base_config(&env, target); @@ -912,7 +925,8 @@ mod tests { let token_id = env.register_stellar_asset_contract_v2(Address::generate(&env)); let token_address = token_id.address(); - client.init(&token_address); + let admin = Address::generate(&env); + client.init(&token_address, &admin); let target = env.register_contract(None, MockTarget); let mut cfg = base_config(&env, target); @@ -976,7 +990,8 @@ mod tests { let token_client = soroban_sdk::token::Client::new(&env, &token_address); let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); - client.init(&token_address); + let admin = Address::generate(&env); + client.init(&token_address, &admin); let target = env.register_contract(None, MockTarget); let mut cfg = base_config(&env, target); @@ -1022,7 +1037,8 @@ mod tests { let token_admin = Address::generate(&env); let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); let token_address = token_id.address(); - client.init(&token_address); + let admin = Address::generate(&env); + client.init(&token_address, &admin); let target = env.register_contract(None, MockTarget); let mut cfg = base_config(&env, target); @@ -1085,7 +1101,8 @@ mod tests { let token_client = soroban_sdk::token::Client::new(&env, &token_address); let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); - client.init(&token_address); + let admin = Address::generate(&env); + client.init(&token_address, &admin); let target = env.register_contract(None, MockTarget); let mut cfg = base_config(&env, target); @@ -1117,4 +1134,20 @@ mod tests { soroban_sdk::Symbol::new(&env, "TaskCancelled") ); } + + #[test] + fn test_upgrade_admin_only() { + let (env, id) = setup(); + let client = SoroTaskContractClient::new(&env, &id); + + let token_id = env.register_stellar_asset_contract_v2(Address::generate(&env)); + let token_address = token_id.address(); + let admin = Address::generate(&env); + client.init(&token_address, &admin); + + let new_wasm_hash = soroban_sdk::BytesN::from_array(&env, &[1; 32]); + + // As env is using mock_all_auths(), this successfully completes mapping to admin authority + client.upgrade(&new_wasm_hash); + } } diff --git a/contract/src/test_gas.rs b/contract/src/test_gas.rs new file mode 100644 index 0000000..7791a48 --- /dev/null +++ b/contract/src/test_gas.rs @@ -0,0 +1,160 @@ +#![cfg(test)] + +use crate::{SoroTaskContract, SoroTaskContractClient, TaskConfig}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + vec, Address, Env, Symbol, Vec, contract, contractimpl, +}; + +#[contract] +pub struct MockTarget; + +#[contractimpl] +impl MockTarget { + pub fn ping(_env: Env) -> bool { + true + } +} + +fn setup() -> (Env, SoroTaskContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, SoroTaskContract); + let client = SoroTaskContractClient::new(&env, &id); + (env, client) +} + +fn base_config(env: &Env, target: Address) -> TaskConfig { + TaskConfig { + creator: Address::generate(env), + target, + function: Symbol::new(env, "ping"), + args: Vec::new(env), + resolver: None, + interval: 3_600, + last_run: 0, + gas_balance: 1_000, + whitelist: Vec::new(env), + } +} + +fn track_gas(env: &Env, name: &str, operation: F) +where + F: FnOnce(), +{ + env.cost_estimate().budget().reset_tracker(); + operation(); + let cpu = env.cost_estimate().budget().cpu_instruction_cost(); + let mem = env.cost_estimate().budget().memory_bytes_cost(); + println!("GAS_TRACKER: {{\"function\": \"{}\", \"cpu\": {}, \"mem\": {}}}", name, cpu, mem); +} + +#[test] +fn test_gas_init() { + let (env, client) = setup(); + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let token_address = token_id.address(); + let admin = Address::generate(&env); + + track_gas(&env, "init", || { + client.init(&token_address, &admin); + }); +} + +#[test] +fn test_gas_register() { + let (env, client) = setup(); + let target = env.register_contract(None, MockTarget); + let cfg = base_config(&env, target); + + track_gas(&env, "register", || { + client.register(&cfg); + }); +} + +#[test] +fn test_gas_deposit() { + let (env, client) = setup(); + + // Setup token + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_id.address(); + let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + let admin = Address::generate(&env); + client.init(&token_address, &admin); + + let target = env.register_contract(None, MockTarget); + let cfg = base_config(&env, target); + let task_id = client.register(&cfg); + + // Mint tokens + token_admin_client.mint(&cfg.creator, &5000); + + track_gas(&env, "deposit_gas", || { + client.deposit_gas(&task_id, &cfg.creator, &2000); + }); +} + +#[test] +fn test_gas_withdraw() { + let (env, client) = setup(); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_id.address(); + let admin = Address::generate(&env); + client.init(&token_address, &admin); + + let target = env.register_contract(None, MockTarget); + let mut cfg = base_config(&env, target); + cfg.gas_balance = 1000; + let task_id = client.register(&cfg); + + track_gas(&env, "withdraw_gas", || { + client.withdraw_gas(&task_id, &500); + }); +} + +#[test] +fn test_gas_execute() { + let (env, client) = setup(); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_id.address(); + let admin = Address::generate(&env); + client.init(&token_address, &admin); + + let target = env.register_contract(None, MockTarget); + let cfg = base_config(&env, target); + let task_id = client.register(&cfg); + + let keeper = Address::generate(&env); + env.ledger().set_timestamp(99999); // Ensure it's runnable + + track_gas(&env, "execute", || { + client.execute(&keeper, &task_id); + }); +} + +#[test] +fn test_gas_cancel() { + let (env, client) = setup(); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_id.address(); + let admin = Address::generate(&env); + client.init(&token_address, &admin); + + let target = env.register_contract(None, MockTarget); + let mut cfg = base_config(&env, target); + cfg.gas_balance = 500; + let task_id = client.register(&cfg); + + track_gas(&env, "cancel_task", || { + client.cancel_task(&task_id); + }); +} diff --git a/scripts/compare_gas.py b/scripts/compare_gas.py new file mode 100644 index 0000000..19acc8f --- /dev/null +++ b/scripts/compare_gas.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +import json +import sys +import os + +def load_json(filepath): + if not os.path.exists(filepath): + return {} + try: + with open(filepath, 'r') as f: + data = json.load(f) + # convert list of dicts to dict keyed by function + return {item['function']: item for item in data} + except Exception as e: + print(f"Error loading {filepath}: {e}", file=sys.stderr) + return {} + +def format_diff(old, new): + if old == 0: + if new == 0: + return "0 (0.00%)" + return f"+{new} (+\u221E%)" + diff = new - old + pct = (diff / old) * 100 + sign = "+" if diff > 0 else "" + return f"{sign}{diff} ({sign}{pct:.2f}%)" + +def main(): + if len(sys.argv) < 3: + print("Usage: compare_gas.py ", file=sys.stderr) + sys.exit(1) + + base_path = sys.argv[1] + pr_path = sys.argv[2] + + base_data = load_json(base_path) + pr_data = load_json(pr_path) + + if not base_data and not pr_data: + print("No gas metrics found in either branch.", file=sys.stderr) + sys.exit(0) + + all_funcs = sorted(list(set(base_data.keys()) | set(pr_data.keys()))) + + output = [] + output.append("## \u26fd Gas Consumption Changes") + output.append("") + output.append("| Function | CPU Instructions | Diff (CPU) | Memory Bytes | Diff (Mem) |") + output.append("|---|---|---|---|---|") + + for func in all_funcs: + base_item = base_data.get(func, {"cpu": 0, "mem": 0}) + pr_item = pr_data.get(func, {"cpu": 0, "mem": 0}) + + cpu_diff = format_diff(base_item["cpu"], pr_item["cpu"]) + mem_diff = format_diff(base_item["mem"], pr_item["mem"]) + + # Highlight regressions in bold + if pr_item["cpu"] > base_item["cpu"]: + cpu_diff = f"**{cpu_diff}**" + if pr_item["mem"] > base_item["mem"]: + mem_diff = f"**{mem_diff}**" + + output.append(f"| `{func}` | {pr_item['cpu']} | {cpu_diff} | {pr_item['mem']} | {mem_diff} |") + + output.append("") + output.append("*(Metrics are derived from native Rust execution approximations and track relative changes.)*") + + print("\n".join(output)) + +if __name__ == "__main__": + main() diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..cd24d79 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -e + +# Usage: ./scripts/deploy.sh +# e.g., ./scripts/deploy.sh testnet C... G... + +NETWORK=$1 +TOKEN_ADDRESS=$2 +ADMIN_ADDRESS=$3 + +if [ -z "$NETWORK" ] || [ -z "$TOKEN_ADDRESS" ] || [ -z "$ADMIN_ADDRESS" ]; then + echo "Usage: ./deploy.sh " + exit 1 +fi + +echo "Deploying SoroTask contract on network: $NETWORK" + +# Build the contract +echo "Building the contract..." +cd contract +cargo build --target wasm32-unknown-unknown --release +# Optimize if soroban-cli is up to date (optional): +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/soro_task_contract.wasm +cd .. + +WASM_FILE="contract/target/wasm32-unknown-unknown/release/soro_task_contract.optimized.wasm" + +if [ ! -f "$WASM_FILE" ]; then + # Fallback to standard release Wasm if compression isn't configured + WASM_FILE="contract/target/wasm32-unknown-unknown/release/soro_task_contract.wasm" +fi + +echo "Deploying $WASM_FILE ..." +CONTRACT_ID=$(soroban contract deploy \ + --wasm $WASM_FILE \ + --source admin \ + --network $NETWORK) + +echo "Contract deployed successfully! Contract ID: $CONTRACT_ID" + +echo "Initializing the contract..." +soroban contract invoke \ + --id $CONTRACT_ID \ + --source admin \ + --network $NETWORK \ + -- \ + init \ + --token $TOKEN_ADDRESS \ + --admin $ADMIN_ADDRESS + +echo "Contract initialized!" +echo "Setup complete." diff --git a/scripts/track_gas.sh b/scripts/track_gas.sh new file mode 100644 index 0000000..bcf5523 --- /dev/null +++ b/scripts/track_gas.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -eo pipefail + +echo "Running gas tracking tests..." + +# CD into the contract directory where cargo can run +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$DIR/../contract" + +# Run the test_gas module tests, capture output +OUTPUT=$(cargo test test_gas --nocapture 2>&1 || true) + +# Extract only lines that start with "GAS_TRACKER: " +echo "[" > ../gas_report.json +FIRST=true + +while IFS= read -r line; do + if [[ "$line" == GAS_TRACKER:* ]]; then + JSON="${line#GAS_TRACKER: }" + if [ "$FIRST" = true ]; then + FIRST=false + else + echo "," >> ../gas_report.json + fi + echo "$JSON" >> ../gas_report.json + fi +done <<< "$OUTPUT" + +echo "]" >> ../gas_report.json + +echo "Gas tracking complete. Output saved to gas_report.json" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100755 index 0000000..2e57827 --- /dev/null +++ b/scripts/upgrade.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -e + +# Usage: ./scripts/upgrade.sh +# e.g., ./scripts/upgrade.sh testnet CA... + +NETWORK=$1 +CONTRACT_ID=$2 + +if [ -z "$NETWORK" ] || [ -z "$CONTRACT_ID" ]; then + echo "Usage: ./upgrade.sh " + exit 1 +fi + +echo "Upgrading SoroTask contract $CONTRACT_ID on network: $NETWORK" + +# Build the new contract Wasm +echo "Building the new contract..." +cd contract +cargo build --target wasm32-unknown-unknown --release +# Optional: Use soroban optimized output +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/soro_task_contract.wasm +cd .. + +WASM_FILE="contract/target/wasm32-unknown-unknown/release/soro_task_contract.optimized.wasm" +if [ ! -f "$WASM_FILE" ]; then + WASM_FILE="contract/target/wasm32-unknown-unknown/release/soro_task_contract.wasm" +fi + +# 1. Install the new wasm code (Does not execute init/upgrade on itself, just stores it) +echo "Installing the new wasm logic..." +NEW_WASM_HASH=$(soroban contract install \ + --wasm $WASM_FILE \ + --source admin \ + --network $NETWORK) + +echo "New Wasm Hash installed: $NEW_WASM_HASH" + +# 2. Invoke the upgrade() endpoint on the currently active contract +echo "Triggering the upgrade endpoint to migrate execution logic..." +soroban contract invoke \ + --id $CONTRACT_ID \ + --source admin \ + --network $NETWORK \ + -- \ + upgrade \ + --new_wasm_hash $NEW_WASM_HASH + +echo "Upgrade completed successfully!"