From 3cf7c2cb66a1424b4f7459c26e504cc28246b2ce Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Fri, 27 Mar 2026 19:33:16 +0100 Subject: [PATCH 1/4] feat: implement universal rbac for contracts --- Cargo.toml | 2 +- src/auth/Cargo.toml | 16 ++++ src/auth/rbac.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/auth/Cargo.toml create mode 100644 src/auth/rbac.rs diff --git a/Cargo.toml b/Cargo.toml index 6efbc55..d9b5a12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance"] +members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth"] resolver = "2" [package] diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml new file mode 100644 index 0000000..2c8146c --- /dev/null +++ b/src/auth/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "anchorpoint-auth" +version = "0.1.0" +edition = "2021" +description = "Universal modular RBAC for Soroban contracts" + +[dependencies] +soroban-sdk = { version = "21.0.0", features = ["alloc", "testutils"] } + +[lib] +name = "anchorpoint_auth" +path = "rbac.rs" +crate-type = ["cdylib", "rlib"] + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/src/auth/rbac.rs b/src/auth/rbac.rs new file mode 100644 index 0000000..2a3e5a3 --- /dev/null +++ b/src/auth/rbac.rs @@ -0,0 +1,187 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, +}; + +/// Defined roles for the RBAC module. +/// Values are ordered such that lower values have more permissions. +/// Hierarchy: Admin (0) > Moderator (1) > Contributor (2) +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum AccessRole { + Admin = 0, + Moderator = 1, + Contributor = 2, +} + +/// Storage keys for the RBAC module. +#[contracttype] +pub enum AccessDataKey { + Role(Address), + AdminInitialized, +} + +/// A collection of utility functions to manage RBAC. +/// These can be used from within other contracts to implement role-based access. +pub struct RBAC; + +impl RBAC { + /// Checks if an address has the required role or a higher one. + /// Admin > Moderator > Contributor + pub fn has_role(env: &Env, address: &Address, required_role: AccessRole) -> bool { + let key = AccessDataKey::Role(address.clone()); + let current_role: Option = env.storage().instance().get(&key); + + match current_role { + Some(role) => (role as u32) <= (required_role as u32), + None => false, + } + } + + /// Panics if the address does not have the required role or higher. + pub fn require_role(env: &Env, address: &Address, required_role: AccessRole) { + if !Self::has_role(env, address, required_role) { + panic!("unauthorized: access denied for required role"); + } + } + + /// Sets the role of a target address. Only a verified Admin can call this. + /// This function performs its own admin authorization check using `admin.require_auth()`. + pub fn set_role(env: &Env, admin: &Address, target: &Address, role: AccessRole) { + admin.require_auth(); + Self::require_role(env, admin, AccessRole::Admin); + + let key = AccessDataKey::Role(target.clone()); + env.storage().instance().set(&key, &role); + + // Emit role change event + env.events().publish( + (symbol_short!("role_set"), target.clone()), + role + ); + } + + /// Revokes any role from a target address. Only an Admin can call this. + pub fn revoke_role(env: &Env, admin: &Address, target: &Address) { + admin.require_auth(); + Self::require_role(env, admin, AccessRole::Admin); + + let key = AccessDataKey::Role(target.clone()); + env.storage().instance().remove(&key); + + // Emit role revocation event + env.events().publish( + (symbol_short!("role_rev"), target.clone()), + () + ); + } + + /// Inits the first admin. This can only be called once. + pub fn init_admin(env: &Env, admin: &Address) { + if env.storage().instance().has(&AccessDataKey::AdminInitialized) { + panic!("rbac: admin already initialized"); + } + + env.storage().instance().set(&AccessDataKey::Role(admin.clone()), &AccessRole::Admin); + env.storage().instance().set(&AccessDataKey::AdminInitialized, &true); + + env.events().publish( + (symbol_short!("role_set"), admin.clone()), + AccessRole::Admin + ); + } +} + +/// A standalone contract implementation of RBAC that can be deployed independently. +/// This fulfills the "Universal modular contract" requirement. +#[contract] +pub struct RBACContract; + +#[contractimpl] +impl RBACContract { + /// Initializes the RBAC contract with an initial administrator. + pub fn initialize(env: Env, admin: Address) { + RBAC::init_admin(&env, &admin); + } + + /// Assigns a role to a target address. Only the admin can call this. + pub fn set_role(env: Env, from: Address, target: Address, role: AccessRole) { + RBAC::set_role(&env, &from, &target, role); + } + + /// Revokes any role from a target address. Only the admin can call this. + pub fn revoke_role(env: Env, from: Address, target: Address) { + RBAC::revoke_role(&env, &from, &target); + } + + /// Checks if an address has the specified role or higher (Admin > Moderator > Contributor). + pub fn has_role(env: Env, address: Address, role: AccessRole) -> bool { + RBAC::has_role(&env, &address, role) + } + + /// Returns the raw role of an address, if any. + pub fn get_role(env: Env, address: Address) -> Option { + env.storage().instance().get(&AccessDataKey::Role(address)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + #[test] + fn test_rbac_flow() { + let env = Env::default(); + let admin = Address::generate(&env); + let mod_user = Address::generate(&env); + let contributor = Address::generate(&env); + let random = Address::generate(&env); + + let contract_id = env.register(RBACContract, ()); + let client = RBACContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + // Verify initial admin + assert!(client.has_role(&admin, &AccessRole::Admin)); + assert!(client.has_role(&admin, &AccessRole::Moderator)); + assert!(client.has_role(&admin, &AccessRole::Contributor)); + + // Use mock auth for administrative actions + env.mock_all_auths(); + + // Assign Moderator + client.set_role(&admin, &mod_user, &AccessRole::Moderator); + assert!(!client.has_role(&mod_user, &AccessRole::Admin)); + assert!(client.has_role(&mod_user, &AccessRole::Moderator)); + assert!(client.has_role(&mod_user, &AccessRole::Contributor)); + + // Assign Contributor + client.set_role(&admin, &contributor, &AccessRole::Contributor); + assert!(!client.has_role(&contributor, &AccessRole::Admin)); + assert!(!client.has_role(&contributor, &AccessRole::Moderator)); + assert!(client.has_role(&contributor, &AccessRole::Contributor)); + + // Unassigned user + assert!(!client.has_role(&random, &AccessRole::Contributor)); + + // Revoke + client.revoke_role(&admin, &mod_user); + assert!(!client.has_role(&mod_user, &AccessRole::Contributor)); + } + + #[test] + #[should_panic(expected = "rbac: admin already initialized")] + fn test_double_initialization() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = env.register(RBACContract, ()); + let client = RBACContractClient::new(&env, &contract_id); + + client.initialize(&admin); + client.initialize(&admin); + } +} From 306dd5da793c0bad17cf6dfb6d89aa85f8c15066 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Fri, 27 Mar 2026 19:58:07 +0100 Subject: [PATCH 2/4] feat: implement simple amm pool --- Cargo.toml | 2 +- src/amm/Cargo.toml | 17 ++++ src/amm/lib.rs | 218 ++++++++++++++++++++++++++++++++++++++++++++ src/auth/Cargo.toml | 5 +- 4 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 src/amm/Cargo.toml create mode 100644 src/amm/lib.rs diff --git a/Cargo.toml b/Cargo.toml index d9b5a12..521dad5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth"] +members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth", "src/amm"] resolver = "2" [package] diff --git a/src/amm/Cargo.toml b/src/amm/Cargo.toml new file mode 100644 index 0000000..76a4190 --- /dev/null +++ b/src/amm/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "anchorpoint-amm" +version = "0.1.0" +edition = "2021" +description = "Simple X*Y=K constant product pool implementation" + +[dependencies] +soroban-sdk = "22.0.0" + +[lib] +name = "anchorpoint_amm" +path = "lib.rs" +crate-type = ["cdylib"] +doctest = false + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/src/amm/lib.rs b/src/amm/lib.rs new file mode 100644 index 0000000..6cb55c9 --- /dev/null +++ b/src/amm/lib.rs @@ -0,0 +1,218 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, IntoVal, +}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + TokenA, + TokenB, + ReserveA, + ReserveB, + TotalShares, + Shares(Address), +} + +#[contract] +pub struct AMM; + +#[contractimpl] +impl AMM { + /// Initializes the AMM pool for a specific pair of tokens. + pub fn initialize(env: Env, token_a: Address, token_b: Address) { + if env.storage().instance().has(&DataKey::TokenA) { + panic!("already initialized"); + } + + // Canonical order: ensures same pool for (A,B) and (B,A) + if token_a < token_b { + env.storage().instance().set(&DataKey::TokenA, &token_a); + env.storage().instance().set(&DataKey::TokenB, &token_b); + } else { + env.storage().instance().set(&DataKey::TokenA, &token_b); + env.storage().instance().set(&DataKey::TokenB, &token_a); + } + + env.storage().instance().set(&DataKey::ReserveA, &0_i128); + env.storage().instance().set(&DataKey::ReserveB, &0_i128); + env.storage().instance().set(&DataKey::TotalShares, &0_i128); + } + + /// Deposits liquidity into the pool. Returns the number of LP shares minted. + pub fn deposit(env: Env, from: Address, amount_a: i128, amount_b: i128) -> i128 { + from.require_auth(); + + let token_a: Address = env.storage().instance().get(&DataKey::TokenA).expect("not initialized"); + let token_b: Address = env.storage().instance().get(&DataKey::TokenB).expect("not initialized"); + let reserve_a: i128 = env.storage().instance().get(&DataKey::ReserveA).unwrap_or(0); + let reserve_b: i128 = env.storage().instance().get(&DataKey::ReserveB).unwrap_or(0); + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + + // Calculate shares to mint + let shares = if total_shares == 0 { + // Initial liquidity = geometric mean + sqrt(amount_a * amount_b) + } else { + // Proportional liquidity: min(amount_a/reserve_a, amount_b/reserve_b) * total_shares + let shares_a = (amount_a * total_shares) / reserve_a; + let shares_b = (amount_b * total_shares) / reserve_b; + if shares_a < shares_b { shares_a } else { shares_b } + }; + + if shares <= 0 { + panic!("insufficient liquidity provided"); + } + + // Transfer tokens into the contract (User -> Contract) + transfer(&env, &token_a, &from, &env.current_contract_address(), amount_a); + transfer(&env, &token_b, &from, &env.current_contract_address(), amount_b); + + // Update state + env.storage().instance().set(&DataKey::ReserveA, &(reserve_a + amount_a)); + env.storage().instance().set(&DataKey::ReserveB, &(reserve_b + amount_b)); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares + shares)); + + let old_shares: i128 = env.storage().persistent().get(&DataKey::Shares(from.clone())).unwrap_or(0); + env.storage().persistent().set(&DataKey::Shares(from.clone()), &(old_shares + shares)); + + env.events().publish((symbol_short!("deposit"), from), (amount_a, amount_b, shares)); + shares + } + + /// Swaps tokens using the constant product formula (x * y = k) with a 0.3% fee. + pub fn swap(env: Env, from: Address, token_in: Address, amount_in: i128, min_amount_out: i128) -> i128 { + from.require_auth(); + + let token_a: Address = env.storage().instance().get(&DataKey::TokenA).expect("not initialized"); + let token_b: Address = env.storage().instance().get(&DataKey::TokenB).expect("not initialized"); + let mut reserve_a: i128 = env.storage().instance().get(&DataKey::ReserveA).unwrap(); + let mut reserve_b: i128 = env.storage().instance().get(&DataKey::ReserveB).unwrap(); + + let (reserve_in, reserve_out, token_out) = if token_in == token_a { + (reserve_a, reserve_b, token_b.clone()) + } else if token_in == token_b { + (reserve_b, reserve_a, token_a.clone()) + } else { + panic!("invalid token for pool"); + }; + + // Transfer token_in from user to contract + transfer(&env, &token_in, &from, &env.current_contract_address(), amount_in); + + // Constant product formula with 0.3% fee: dy = (reserve_out * dx * 997) / (reserve_in * 1000 + dx * 997) + let amount_in_with_fee = amount_in * 997; + let numerator = amount_in_with_fee * reserve_out; + let denominator = (reserve_in * 1000) + amount_in_with_fee; + let amount_out = numerator / denominator; + + if amount_out < min_amount_out { + panic!("slippage exceeded"); + } + + // Update state + if token_in == token_a { + reserve_a += amount_in; + reserve_b -= amount_out; + } else { + reserve_b += amount_in; + reserve_a -= amount_out; + } + + env.storage().instance().set(&DataKey::ReserveA, &reserve_a); + env.storage().instance().set(&DataKey::ReserveB, &reserve_b); + + // Transfer token_out from contract to user + transfer(&env, &token_out, &env.current_contract_address(), &from, amount_out); + + env.events().publish((symbol_short!("swap"), from), (amount_in, amount_out)); + amount_out + } + + /// Withdraws liquidity from the pool. + pub fn withdraw(env: Env, from: Address, shares: i128) -> (i128, i128) { + from.require_auth(); + + let token_a: Address = env.storage().instance().get(&DataKey::TokenA).expect("not initialized"); + let token_b: Address = env.storage().instance().get(&DataKey::TokenB).expect("not initialized"); + let reserve_a: i128 = env.storage().instance().get(&DataKey::ReserveA).unwrap(); + let reserve_b: i128 = env.storage().instance().get(&DataKey::ReserveB).unwrap(); + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap(); + + let user_shares: i128 = env.storage().persistent().get(&DataKey::Shares(from.clone())).unwrap_or(0); + if user_shares < shares { + panic!("insufficient shares"); + } + + let amount_a = (shares * reserve_a) / total_shares; + let amount_b = (shares * reserve_b) / total_shares; + + // Update state + env.storage().instance().set(&DataKey::ReserveA, &(reserve_a - amount_a)); + env.storage().instance().set(&DataKey::ReserveB, &(reserve_b - amount_b)); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares)); + env.storage().persistent().set(&DataKey::Shares(from.clone()), &(user_shares - shares)); + + // Transfer tokens back to user + transfer(&env, &token_a, &env.current_contract_address(), &from, amount_a); + transfer(&env, &token_b, &env.current_contract_address(), &from, amount_b); + + env.events().publish((symbol_short!("withdraw"), from), (amount_a, amount_b, shares)); + (amount_a, amount_b) + } + + pub fn get_reserves(env: Env) -> (i128, i128) { + ( + env.storage().instance().get(&DataKey::ReserveA).unwrap_or(0), + env.storage().instance().get(&DataKey::ReserveB).unwrap_or(0), + ) + } +} + +/// Helper function to perform cross-contract token transfers. +fn transfer(env: &Env, token: &Address, from: &Address, to: &Address, amount: i128) { + env.invoke_contract::<()>( + token, + &symbol_short!("transfer"), + (from.clone(), to.clone(), amount).into_val(env), + ); +} + +/// Babylonian method for integer square root. +fn sqrt(y: i128) -> i128 { + if y > 3 { + let mut z = y; + let mut x = y / 2 + 1; + while x < z { + z = x; + x = (y / x + x) / 2; + } + z + } else if y != 0 { + 1 + } else { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _}; + + #[test] + fn test_initialization() { + let env = Env::default(); + let token_a = Address::generate(&env); + let token_b = Address::generate(&env); + + let contract_id = env.register(AMM, ()); + let client = AMMClient::new(&env, &contract_id); + + client.initialize(&token_a, &token_b); + let (r_a, r_b) = client.get_reserves(); + assert_eq!(r_a, 0); + assert_eq!(r_b, 0); + } +} diff --git a/src/auth/Cargo.toml b/src/auth/Cargo.toml index 2c8146c..4126554 100644 --- a/src/auth/Cargo.toml +++ b/src/auth/Cargo.toml @@ -5,12 +5,13 @@ edition = "2021" description = "Universal modular RBAC for Soroban contracts" [dependencies] -soroban-sdk = { version = "21.0.0", features = ["alloc", "testutils"] } +soroban-sdk = "22.0.0" [lib] name = "anchorpoint_auth" path = "rbac.rs" crate-type = ["cdylib", "rlib"] +doctest = false [dev-dependencies] -soroban-sdk = { version = "21.0.0", features = ["testutils"] } +soroban-sdk = { version = "22.0.0", features = ["testutils"] } From e1b5d1c16bf9bd2b23715e79ccdd94813ac01eba Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Fri, 27 Mar 2026 20:04:23 +0100 Subject: [PATCH 3/4] feat: implement price oracle consumer --- Cargo.toml | 2 +- src/oracle_consumer/Cargo.toml | 17 ++++++ src/oracle_consumer/lib.rs | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/oracle_consumer/Cargo.toml create mode 100644 src/oracle_consumer/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 521dad5..59cc7c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth", "src/amm"] +members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth", "src/amm", "src/oracle_consumer"] resolver = "2" [package] diff --git a/src/oracle_consumer/Cargo.toml b/src/oracle_consumer/Cargo.toml new file mode 100644 index 0000000..e68c1ff --- /dev/null +++ b/src/oracle_consumer/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "anchorpoint-oracle-consumer" +version = "0.1.0" +edition = "2021" +description = "Soroban Price Oracle Consumer module" + +[dependencies] +soroban-sdk = "22.0.0" + +[lib] +name = "anchorpoint_oracle_consumer" +path = "lib.rs" +crate-type = ["cdylib"] +doctest = false + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/src/oracle_consumer/lib.rs b/src/oracle_consumer/lib.rs new file mode 100644 index 0000000..7f4e03e --- /dev/null +++ b/src/oracle_consumer/lib.rs @@ -0,0 +1,106 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, IntoVal, +}; + +/// Standardized data structure for price, timestamp, and asset. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceData { + pub asset: Address, + pub price: i128, + pub timestamp: u64, +} + +#[contracttype] +pub enum DataKey { + OracleAddress, + PriceRecord(Address), + Admin, +} + +#[contract] +pub struct OracleConsumer; + +#[contractimpl] +impl OracleConsumer { + /// Initializes the consumer with an admin and the initial oracle source. + pub fn initialize(env: Env, admin: Address, oracle: Address) { + if env.storage().instance().has(&DataKey::OracleAddress) { + panic!("already initialized"); + } + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::OracleAddress, &oracle); + } + + /// Pulls the latest price for a given asset from the configured external oracle. + /// This updates the local storage with fresh data and returns it. + pub fn update_price(env: Env, asset: Address) -> PriceData { + let oracle: Address = env.storage().instance().get(&DataKey::OracleAddress).expect("oracle not set"); + + // Attempt to call the external oracle's 'get_price' method. + // The external oracle is expected to return the standardized PriceData structure. + let price_info: PriceData = env.invoke_contract( + &oracle, + &symbol_short!("get_price"), + (asset.clone(),).into_val(&env) + ); + + // Store the retrieved price record in the instance storage. + env.storage().instance().set(&DataKey::PriceRecord(asset.clone()), &price_info); + + // Emit an event to notify observers of the price update. + env.events().publish((symbol_short!("price_upd"), asset), price_info.price); + + price_info + } + + /// Retrieves the most recent locally stored price for an asset. + /// Includes a staleness check based on the provided `max_age_seconds`. + pub fn get_latest_price(env: Env, asset: Address, max_age_seconds: u64) -> i128 { + let price_info: PriceData = env.storage().instance() + .get(&DataKey::PriceRecord(asset)) + .expect("price record not found locally. call update_price first."); + + let current_time = env.ledger().timestamp(); + if current_time > price_info.timestamp + max_age_seconds { + panic!("price record is too stale and cannot be used."); + } + + price_info.price + } + + /// Reconfigures the oracle source address. Restricted to the administrator. + pub fn set_oracle(env: Env, new_oracle: Address) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("admin not configured"); + admin.require_auth(); + + env.storage().instance().set(&DataKey::OracleAddress, &new_oracle); + } + + /// Simple getter for the current oracle address. + pub fn get_oracle(env: Env) -> Address { + env.storage().instance().get(&DataKey::OracleAddress).unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _}; + + #[test] + fn test_initialization() { + let env = Env::default(); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + + let contract_id = env.register(OracleConsumer, ()); + let client = OracleConsumerClient::new(&env, &contract_id); + + client.initialize(&admin, &oracle); + assert_eq!(client.get_oracle(), oracle); + } +} From cfad0e6f5c6fddc867a82e3b7153bbece05cdb31 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Fri, 27 Mar 2026 22:26:12 +0100 Subject: [PATCH 4/4] feat: implement batch transaction executor --- Cargo.toml | 2 +- src/batch/Cargo.toml | 17 +++++++++++++++++ src/batch/lib.rs | 30 ++++++++++++++++++++++++++++++ src/batch/test.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/batch/Cargo.toml create mode 100644 src/batch/lib.rs create mode 100644 src/batch/test.rs diff --git a/Cargo.toml b/Cargo.toml index 59cc7c8..83f0434 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth", "src/amm", "src/oracle_consumer"] +members = ["src/upgradeable", "src/token", "src/escrow_multisig", "src/governance", "src/auth", "src/amm", "src/oracle_consumer", "src/batch"] resolver = "2" [package] diff --git a/src/batch/Cargo.toml b/src/batch/Cargo.toml new file mode 100644 index 0000000..3fa28e4 --- /dev/null +++ b/src/batch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "anchorpoint-batch-executor" +version = "0.1.0" +edition = "2021" +description = "Helper contract to execute multiple Soroban calls in a single transaction" + +[dependencies] +soroban-sdk = "22.0.0" + +[lib] +name = "anchorpoint_batch_executor" +path = "lib.rs" +crate-type = ["cdylib", "rlib"] +doctest = false + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/src/batch/lib.rs b/src/batch/lib.rs new file mode 100644 index 0000000..13abaec --- /dev/null +++ b/src/batch/lib.rs @@ -0,0 +1,30 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Val, Vec}; + +#[contracttype] +#[derive(Clone, Debug)] +pub struct Call { + pub contract: Address, + pub function: Symbol, + pub args: Vec, +} + +#[contract] +pub struct BatchExecutor; + +#[contractimpl] +impl BatchExecutor { + /// Executes a sequence of contract calls in a single transaction. + /// Returns a list of the execution results. + /// If any call fails, the entire transaction reverts. + pub fn execute_batch(env: Env, calls: Vec) -> Vec { + let mut results = Vec::new(&env); + for call in calls.iter() { + let result: Val = env.invoke_contract(&call.contract, &call.function, call.args.clone()); + results.push_back(result); + } + results + } +} + +mod test; diff --git a/src/batch/test.rs b/src/batch/test.rs new file mode 100644 index 0000000..7225c88 --- /dev/null +++ b/src/batch/test.rs @@ -0,0 +1,42 @@ +#![cfg(test)] +use super::{BatchExecutor, BatchExecutorClient, Call}; +use soroban_sdk::{contract, contractimpl, symbol_short, Env, Vec, IntoVal}; + +#[contract] +pub struct MockContract; + +#[contractimpl] +impl MockContract { + pub fn echo(_env: Env, value: u32) -> u32 { + value + } +} + +#[test] +fn test_execute_batch() { + let env = Env::default(); + let contract_id = env.register_contract(None, BatchExecutor); + let client = BatchExecutorClient::new(&env, &contract_id); + + let mock_id = env.register_contract(None, MockContract); + let mock_symbol = symbol_short!("echo"); + + let call1 = Call { + contract: mock_id.clone(), + function: mock_symbol.clone(), + args: (123u32,).into_val(&env), + }; + + let call2 = Call { + contract: mock_id.clone(), + function: mock_symbol.clone(), + args: (456u32,).into_val(&env), + }; + + let calls = Vec::from_array(&env, [call1, call2]); + let results = client.execute_batch(&calls); + + assert_eq!(results.len(), 2); + assert_eq!(results.get_unchecked(0).into_val::(&env), 123u32); + assert_eq!(results.get_unchecked(1).into_val::(&env), 456u32); +}