Skip to content

feat(benchmark): add gas and footprint profiling for critical admin operations#277

Merged
OlufunbiIK merged 2 commits intoOlufunbiIK:mainfrom
lishmanTech:benchmark
Mar 24, 2026
Merged

feat(benchmark): add gas and footprint profiling for critical admin operations#277
OlufunbiIK merged 2 commits intoOlufunbiIK:mainfrom
lishmanTech:benchmark

Conversation

@lishmanTech
Copy link
Contributor

@lishmanTech lishmanTech commented Mar 24, 2026

  • Introduced benchmark module with GasProfile and profile helper
  • Benchmarked create_tip, approve_tip, cancel_tip, distribute_tip
  • Prints CPU/memory delta for CI artifact collection
  • Supports baseline report tracking and regression detection

closes #275

Summary by CodeRabbit

  • New Features
    • Added admin access control with authenticated admin verification and secure admin transfer functionality, including safeguards against self-transfers.
    • Added gas profiling utility to measure CPU instructions and memory consumption during contract execution.

@vercel
Copy link

vercel bot commented Mar 24, 2026

@lishmanTech is attempting to deploy a commit to the olufunbiik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

Introduces an access control module for admin management and a benchmark module for gas profiling. The access control module provides functions to retrieve and transfer admin privileges with authentication. The benchmark module captures and computes CPU instruction and memory usage deltas to measure operation costs.

Changes

Cohort / File(s) Summary
Access Control Module
contracts/access_control/mod.rs, contracts/access_control/admin.rs, contracts/access_control/storage.rs
New module structure for admin access control. Implements require_admin() to retrieve and authenticate the current admin, transfer_admin() to securely update admin privileges with self-transfer validation, and storage functions set_admin() and get_admin() for persistent admin state management.
Benchmark & Profiling Module
contracts/benchmark/mod.rs, contracts/benchmark/profiler.rs
New module for measuring operation costs. Introduces GasProfile struct capturing CPU instructions and memory bytes, with methods to snapshot current gas state via new() and compute usage delta via delta(). Provides public profile() function that wraps closures to measure their gas consumption.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Hop, hop, the admin hops with care,
Guarding keys with auth to share,
Gas profilers measure every stride,
Baselines born where metrics guide!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses issue #275 by introducing benchmark infrastructure (GasProfile, profile function) and modular access control, but lacks explicit benchmark tests for the four critical methods and baseline report artifacts. Add benchmark tests for create_tip, approve_tip, cancel_tip, and distribute_tip methods; commit a baseline report and document its location in CI configuration.
Out of Scope Changes check ❓ Inconclusive The access control module (admin/storage) introduces authorization logic beyond the stated benchmark objective, though it may support admin operation profiling. Clarify whether the access control changes are required to profile the four critical admin operations or if they represent separate scope not mentioned in issue #275.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective: adding gas and footprint profiling for admin operations via the new benchmark module.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
contracts/benchmark/mod.rs (1)

6-15: Consider returning the closure result alongside the profile.

The current signature F: FnOnce() discards any return value from the profiled operation. This limits usability when callers need both the benchmark metrics and the operation result.

♻️ Proposed enhancement to return closure result
-/// Measure gas/footprint for a closure
-pub fn profile<F>(env: &Env, f: F) -> GasProfile
+/// Measure gas/footprint for a closure, returning both the result and profile
+pub fn profile<F, R>(env: &Env, f: F) -> (R, GasProfile)
 where
-    F: FnOnce(),
+    F: FnOnce() -> R,
 {
     let start = GasProfile::new(env);
-    f();
+    let result = f();
     let end = GasProfile::new(env);
-    start.delta(&end)
+    (result, start.delta(&end))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/benchmark/mod.rs` around lines 6 - 15, The profile function
currently drops the closure return value because it bounds F as FnOnce() and
returns only a GasProfile; change the signature to generic over the closure
return type (e.g., F: FnOnce() -> R) and capture the closure result when calling
f(), then return both the gas profile delta and the closure result (for example
as (GasProfile, R) or a small struct). Update the function body around profile,
GasProfile::new, f(), and start.delta(&end) to compute the delta and return it
together with the captured result.
contracts/access_control/admin.rs (1)

19-21: Consider using Result instead of panic! for recoverable errors.

Using panic!("New admin must be different") aborts the contract. Sibling contracts in this repo (e.g., staking, multisig) return Result<(), Error> for recoverable error conditions, allowing callers to handle failures gracefully. Consider defining an error enum and returning Err(Error::SameAdmin) instead.

♻️ Suggested pattern
#[derive(Debug, Clone, PartialEq)]
#[contracttype]
pub enum AccessControlError {
    SameAdmin,
    AdminNotInitialized,
}

pub fn transfer_admin(env: &Env, new_admin: Address) -> Result<(), AccessControlError> {
    let current_admin = require_admin(env)?;
    
    if current_admin == new_admin {
        return Err(AccessControlError::SameAdmin);
    }
    
    new_admin.require_auth();
    super::storage::set_admin(env, &new_admin);
    Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/access_control/admin.rs` around lines 19 - 21, The code uses
panic!("New admin must be different") inside transfer_admin which aborts the
contract; change transfer_admin to return Result<(), AccessControlError> (define
an AccessControlError enum with SameAdmin, AdminNotInitialized, etc.), replace
the panic with returning Err(AccessControlError::SameAdmin) when current_admin
== new_admin, keep calling require_admin() to obtain current_admin, ensure
new_admin.require_auth() and super::storage::set_admin(env, &new_admin) are
performed before returning Ok(()), and update any callers/tests to handle the
Result return type.
contracts/access_control/storage.rs (1)

9-13: get_admin panics when admin is uninitialized—consider a fallible alternative.

Using .expect("Admin not initialized") will abort the contract if no admin has been set. While this may be intentional, it makes it impossible for callers to gracefully handle the uninitialized state (e.g., during setup or migration). Consider adding an Option-returning variant like get_admin_opt alongside this function.

💡 Suggested addition
pub fn get_admin_opt(env: &Env) -> Option<Address> {
    env.storage().instance().get(&ADMIN_KEY)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/access_control/storage.rs` around lines 9 - 13, The current
get_admin(env: &Env) -> Address panics if ADMIN_KEY is missing; add a
non-panicking, Option-returning accessor get_admin_opt(env: &Env) ->
Option<Address> that returns env.storage().instance().get(&ADMIN_KEY) and keep
get_admin for callers that want to assert presence (or refactor callers to call
get_admin_opt and handle None); update any callers that should handle
uninitialized state to use get_admin_opt instead of relying on expect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/access_control/admin.rs`:
- Around line 15-23: The transfer_admin function currently only checks the
caller via require_admin(env) and sets the new admin without confirming the
recipient can authenticate; update transfer_admin to also call
new_admin.require_auth() (or equivalent) before calling
super::storage::set_admin to enforce two-sided authorization so the new admin
consents/controls the address (keep existing require_admin(env) check and the
self-transfer panic).

In `@contracts/benchmark/profiler.rs`:
- Line 1: The import list includes an unused symbol BytesN which should be
removed to clean up warnings: in the module's use declaration (currently "use
soroban_sdk::{Env, BytesN};") delete BytesN so only Env is imported; ensure no
other references to BytesN (e.g., variables or functions named BytesN) remain in
profiler.rs before committing.
- Around line 15-20: The delta method in GasProfile uses plain u64 subtraction
and can panic or wrap on underflow; update GasProfile::delta to use
saturating_sub for cpu_insns and mem_bytes so any negative delta clamps to zero
(e.g., replace after.cpu_insns - self.cpu_insns and after.mem_bytes -
self.mem_bytes with saturating_sub calls on those fields), keeping the function
signature and returned GasProfile unchanged.

---

Nitpick comments:
In `@contracts/access_control/admin.rs`:
- Around line 19-21: The code uses panic!("New admin must be different") inside
transfer_admin which aborts the contract; change transfer_admin to return
Result<(), AccessControlError> (define an AccessControlError enum with
SameAdmin, AdminNotInitialized, etc.), replace the panic with returning
Err(AccessControlError::SameAdmin) when current_admin == new_admin, keep calling
require_admin() to obtain current_admin, ensure new_admin.require_auth() and
super::storage::set_admin(env, &new_admin) are performed before returning
Ok(()), and update any callers/tests to handle the Result return type.

In `@contracts/access_control/storage.rs`:
- Around line 9-13: The current get_admin(env: &Env) -> Address panics if
ADMIN_KEY is missing; add a non-panicking, Option-returning accessor
get_admin_opt(env: &Env) -> Option<Address> that returns
env.storage().instance().get(&ADMIN_KEY) and keep get_admin for callers that
want to assert presence (or refactor callers to call get_admin_opt and handle
None); update any callers that should handle uninitialized state to use
get_admin_opt instead of relying on expect.

In `@contracts/benchmark/mod.rs`:
- Around line 6-15: The profile function currently drops the closure return
value because it bounds F as FnOnce() and returns only a GasProfile; change the
signature to generic over the closure return type (e.g., F: FnOnce() -> R) and
capture the closure result when calling f(), then return both the gas profile
delta and the closure result (for example as (GasProfile, R) or a small struct).
Update the function body around profile, GasProfile::new, f(), and
start.delta(&end) to compute the delta and return it together with the captured
result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e4a96606-8a5d-40e1-947b-ecb0590853aa

📥 Commits

Reviewing files that changed from the base of the PR and between ca7e9d8 and 9d7e812.

📒 Files selected for processing (5)
  • contracts/access_control/admin.rs
  • contracts/access_control/mod.rs
  • contracts/access_control/storage.rs
  • contracts/benchmark/mod.rs
  • contracts/benchmark/profiler.rs

Comment on lines +15 to +23
pub fn transfer_admin(env: &Env, new_admin: Address) {
let current_admin = require_admin(env);

// Prevent accidental self-transfer (optional safety)
if current_admin == new_admin {
panic!("New admin must be different");
}

super::storage::set_admin(env, &new_admin);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consider requiring new_admin.require_auth() for two-sided authorization.

The transfer_admin function only authenticates the current admin, not the new admin. This allows transferring ownership to any address without the recipient's consent, which could lead to:

  • Accidental transfer to an incorrect or non-existent address
  • No confirmation that the new admin can actually control that address

Per the relevant code snippets, sibling contracts (staking, multisig) use a two-sided pattern where the admin authenticates themselves during initialization. Consider requiring new_admin.require_auth() to ensure the recipient can control the address before accepting ownership.

🔒 Proposed fix for two-sided authorization
 pub fn transfer_admin(env: &Env, new_admin: Address) {
     let current_admin = require_admin(env);
 
     // Prevent accidental self-transfer (optional safety)
     if current_admin == new_admin {
         panic!("New admin must be different");
     }
 
+    // Require new admin to consent to ownership transfer
+    new_admin.require_auth();
+
     super::storage::set_admin(env, &new_admin);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn transfer_admin(env: &Env, new_admin: Address) {
let current_admin = require_admin(env);
// Prevent accidental self-transfer (optional safety)
if current_admin == new_admin {
panic!("New admin must be different");
}
super::storage::set_admin(env, &new_admin);
pub fn transfer_admin(env: &Env, new_admin: Address) {
let current_admin = require_admin(env);
// Prevent accidental self-transfer (optional safety)
if current_admin == new_admin {
panic!("New admin must be different");
}
// Require new admin to consent to ownership transfer
new_admin.require_auth();
super::storage::set_admin(env, &new_admin);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/access_control/admin.rs` around lines 15 - 23, The transfer_admin
function currently only checks the caller via require_admin(env) and sets the
new admin without confirming the recipient can authenticate; update
transfer_admin to also call new_admin.require_auth() (or equivalent) before
calling super::storage::set_admin to enforce two-sided authorization so the new
admin consents/controls the address (keep existing require_admin(env) check and
the self-transfer panic).

@@ -0,0 +1,21 @@
use soroban_sdk::{Env, BytesN};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused import BytesN.

BytesN is imported but never used in this file.

🧹 Proposed fix
-use soroban_sdk::{Env, BytesN};
+use soroban_sdk::Env;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
use soroban_sdk::{Env, BytesN};
use soroban_sdk::Env;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/benchmark/profiler.rs` at line 1, The import list includes an
unused symbol BytesN which should be removed to clean up warnings: in the
module's use declaration (currently "use soroban_sdk::{Env, BytesN};") delete
BytesN so only Env is imported; ensure no other references to BytesN (e.g.,
variables or functions named BytesN) remain in profiler.rs before committing.

Comment on lines +15 to +20
pub fn delta(&self, after: &GasProfile) -> GasProfile {
GasProfile {
cpu_insns: after.cpu_insns - self.cpu_insns,
mem_bytes: after.mem_bytes - self.mem_bytes,
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unchecked subtraction can panic or silently wrap on underflow.

The delta() method uses unchecked u64 subtraction. If after values are unexpectedly less than self values (e.g., due to budget reset, test misconfiguration, or SDK behavior changes), this will panic in debug builds or silently wrap around in release builds, producing misleading benchmark results.

Consider using saturating_sub() for safer arithmetic that clamps to zero instead of panicking/wrapping.

🛡️ Proposed fix using saturating subtraction
     pub fn delta(&self, after: &GasProfile) -> GasProfile {
         GasProfile {
-            cpu_insns: after.cpu_insns - self.cpu_insns,
-            mem_bytes: after.mem_bytes - self.mem_bytes,
+            cpu_insns: after.cpu_insns.saturating_sub(self.cpu_insns),
+            mem_bytes: after.mem_bytes.saturating_sub(self.mem_bytes),
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn delta(&self, after: &GasProfile) -> GasProfile {
GasProfile {
cpu_insns: after.cpu_insns - self.cpu_insns,
mem_bytes: after.mem_bytes - self.mem_bytes,
}
}
pub fn delta(&self, after: &GasProfile) -> GasProfile {
GasProfile {
cpu_insns: after.cpu_insns.saturating_sub(self.cpu_insns),
mem_bytes: after.mem_bytes.saturating_sub(self.mem_bytes),
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/benchmark/profiler.rs` around lines 15 - 20, The delta method in
GasProfile uses plain u64 subtraction and can panic or wrap on underflow; update
GasProfile::delta to use saturating_sub for cpu_insns and mem_bytes so any
negative delta clamps to zero (e.g., replace after.cpu_insns - self.cpu_insns
and after.mem_bytes - self.mem_bytes with saturating_sub calls on those fields),
keeping the function signature and returned GasProfile unchanged.

@OlufunbiIK
Copy link
Owner

LGTM

@OlufunbiIK OlufunbiIK merged commit 4a7fc57 into OlufunbiIK:main Mar 24, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gas and Footprint Profiling Baseline

2 participants