Skip to content

Commit ccdb575

Browse files
authored
Merge branch 'main' into fix/issue-800-unset-route-fee
2 parents 4592be1 + 89fc095 commit ccdb575

11 files changed

Lines changed: 2352 additions & 135 deletions

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,39 @@ jobs:
133133
path: target/wasm32-unknown-unknown/release/*.wasm
134134
retention-days: 7
135135

136+
local-integration-tests:
137+
name: Local Integration Tests (In-Process)
138+
runs-on: ubuntu-latest
139+
needs: test
140+
steps:
141+
- uses: actions/checkout@v4
142+
143+
- name: Set up Rust
144+
uses: dtolnay/rust-toolchain@stable
145+
with:
146+
targets: wasm32-unknown-unknown
147+
148+
- name: Cache
149+
uses: actions/cache@v4
150+
with:
151+
path: |
152+
~/.cargo/registry
153+
~/.cargo/git
154+
target
155+
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
156+
157+
# These three test binaries were previously never executed by CI because
158+
# all workflows scoped to --test integration_tests only. They run entirely
159+
# in-process using soroban_sdk::Env with no network access required.
160+
- name: Run cross_contract_tests
161+
run: cargo test --manifest-path integration-tests/Cargo.toml --test cross_contract_tests
162+
163+
- name: Run failure_scenarios
164+
run: cargo test --manifest-path integration-tests/Cargo.toml --test failure_scenarios
165+
166+
- name: Run quote_execution_multicall_pipeline
167+
run: cargo test --manifest-path integration-tests/Cargo.toml --test quote_execution_multicall_pipeline
168+
136169
integration-test:
137170
name: Integration Tests (Testnet)
138171
runs-on: ubuntu-latest

contracts/router-access/src/lib.rs

Lines changed: 157 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -122,65 +122,7 @@ impl RouterAccess {
122122
return Err(AccessError::RoleNotFound);
123123
}
124124

125-
// Decrement active-member counter only if this grant was currently active.
126-
// (If the role is expired, it may still exist in HasRole but shouldn't be
127-
// counted as an active member.)
128-
let was_active = Self::has_role_internal(&env, &target, &role);
129-
130-
env.storage().instance().remove(&key);
131-
132-
if was_active {
133-
let current: u32 = env
134-
.storage()
135-
.instance()
136-
.get::<DataKey, u32>(&DataKey::RoleMemberCount(role.clone()))
137-
.unwrap_or(0u32);
138-
let new_count = current.saturating_sub(1);
139-
env.storage()
140-
.instance()
141-
.set(&DataKey::RoleMemberCount(role.clone()), &new_count);
142-
}
143-
144-
let mut members: Vec<Address> = env
145-
.storage()
146-
.instance()
147-
.get(&DataKey::RoleMembers(role.clone()))
148-
.unwrap_or_else(|| Vec::new(&env));
149-
if let Some(i) = members.iter().position(|a| a == target) {
150-
members.remove(i as u32);
151-
}
152-
env.storage()
153-
.instance()
154-
.set(&DataKey::RoleMembers(role.clone()), &members);
155-
156-
let mut roles: Vec<String> = env
157-
.storage()
158-
.instance()
159-
.get(&DataKey::AddressRoles(target.clone()))
160-
.unwrap_or_else(|| Vec::new(&env));
161-
if let Some(i) = roles.iter().position(|r| r == role) {
162-
roles.remove(i as u32);
163-
}
164-
env.storage()
165-
.instance()
166-
.set(&DataKey::AddressRoles(target.clone()), &roles);
167-
168-
env.storage()
169-
.instance()
170-
.remove(&DataKey::RoleExpiry(role.clone(), target.clone()));
171-
172-
// Keep RoleMemberCount consistent for expiry-based removal.
173-
if Self::has_role_internal(&env, &target, &role) {
174-
let current: u32 = env
175-
.storage()
176-
.instance()
177-
.get::<DataKey, u32>(&DataKey::RoleMemberCount(role.clone()))
178-
.unwrap_or(0u32);
179-
let new_count = current.saturating_sub(1);
180-
env.storage()
181-
.instance()
182-
.set(&DataKey::RoleMemberCount(role.clone()), &new_count);
183-
}
125+
Self::deactivate_role_grant(&env, &role, &target);
184126

185127
env.events().publish(
186128
(Symbol::new(&env, router_common::EVENT_ROLE_REVOKED),),
@@ -241,7 +183,7 @@ impl RouterAccess {
241183
admin: Address,
242184
) -> Result<(), AccessError> {
243185
caller.require_auth();
244-
Self::require_super_admin(&env, &caller)?;
186+
router_common::require_admin_simple!(&env, &caller, &DataKey::SuperAdmin, AccessError)?;
245187
if Self::is_blacklisted_internal(&env, &admin) {
246188
return Err(AccessError::Blacklisted);
247189
}
@@ -272,7 +214,7 @@ impl RouterAccess {
272214
parent_role: String,
273215
) -> Result<(), AccessError> {
274216
caller.require_auth();
275-
Self::require_super_admin(&env, &caller)?;
217+
router_common::require_admin_simple!(&env, &caller, &DataKey::SuperAdmin, AccessError)?;
276218
Self::ensure_no_role_parent_cycle(&env, &role, &parent_role)?;
277219

278220
Self::track_role_in_all_roles(&env, &role);
@@ -318,7 +260,7 @@ impl RouterAccess {
318260
/// Blacklist an address.
319261
pub fn blacklist(env: Env, caller: Address, target: Address) -> Result<(), AccessError> {
320262
caller.require_auth();
321-
Self::require_super_admin(&env, &caller)?;
263+
router_common::require_admin_simple!(&env, &caller, &DataKey::SuperAdmin, AccessError)?;
322264

323265
let super_admin: Address = env
324266
.storage()
@@ -349,7 +291,7 @@ impl RouterAccess {
349291
/// Remove from blacklist.
350292
pub fn unblacklist(env: Env, caller: Address, target: Address) -> Result<(), AccessError> {
351293
caller.require_auth();
352-
Self::require_super_admin(&env, &caller)?;
294+
router_common::require_admin_simple!(&env, &caller, &DataKey::SuperAdmin, AccessError)?;
353295
env.storage()
354296
.instance()
355297
.remove(&DataKey::Blacklisted(target.clone()));
@@ -589,7 +531,11 @@ impl RouterAccess {
589531
new_admin: Address,
590532
) -> Result<(), AccessError> {
591533
current.require_auth();
534+
router_common::require_admin_simple!(&env, &current, &DataKey::SuperAdmin, AccessError)?;
592535
Self::require_super_admin(&env, &current)?;
536+
if Self::is_blacklisted_internal(&env, &new_admin) {
537+
return Err(AccessError::Blacklisted);
538+
}
593539
env.storage()
594540
.instance()
595541
.set(&DataKey::SuperAdmin, &new_admin);
@@ -614,13 +560,15 @@ impl RouterAccess {
614560
target: Address,
615561
) -> Result<(), AccessError> {
616562
caller.require_auth();
617-
Self::require_super_admin(&env, &caller)?;
563+
router_common::require_admin_simple!(&env, &caller, &DataKey::SuperAdmin, AccessError)?;
618564
env.storage()
619565
.instance()
620566
.remove(&DataKey::RoleExpiry(role.clone(), target.clone()));
621567
env.storage()
622568
.instance()
623569
.remove(&DataKey::HasRole(role.clone(), target.clone()));
570+
Self::require_super_admin(&env, &caller)?;
571+
Self::deactivate_role_grant(&env, &role, &target);
624572
env.events().publish(
625573
(Symbol::new(&env, router_common::EVENT_ROLE_EXPIRED),),
626574
(role, target),
@@ -630,6 +578,59 @@ impl RouterAccess {
630578

631579
// ── Helpers ───────────────────────────────────────────────────────────────
632580

581+
/// Fully deactivates a role grant: removes `HasRole`, decrements
582+
/// `RoleMemberCount` if the grant was currently active, and removes the
583+
/// grant from `RoleMembers(role)`, `AddressRoles(target)`, and
584+
/// `RoleExpiry(role, target)`. Shared by `revoke_role` and `expire_role`
585+
/// so both leave identical, consistent bookkeeping behind.
586+
fn deactivate_role_grant(env: &Env, role: &String, target: &Address) {
587+
let was_active = Self::has_role_internal(env, target, role);
588+
589+
env.storage()
590+
.instance()
591+
.remove(&DataKey::HasRole(role.clone(), target.clone()));
592+
593+
if was_active {
594+
let current: u32 = env
595+
.storage()
596+
.instance()
597+
.get::<DataKey, u32>(&DataKey::RoleMemberCount(role.clone()))
598+
.unwrap_or(0u32);
599+
let new_count = current.saturating_sub(1);
600+
env.storage()
601+
.instance()
602+
.set(&DataKey::RoleMemberCount(role.clone()), &new_count);
603+
}
604+
605+
let mut members: Vec<Address> = env
606+
.storage()
607+
.instance()
608+
.get(&DataKey::RoleMembers(role.clone()))
609+
.unwrap_or_else(|| Vec::new(env));
610+
if let Some(i) = members.iter().position(|a| a == *target) {
611+
members.remove(i as u32);
612+
}
613+
env.storage()
614+
.instance()
615+
.set(&DataKey::RoleMembers(role.clone()), &members);
616+
617+
let mut roles: Vec<String> = env
618+
.storage()
619+
.instance()
620+
.get(&DataKey::AddressRoles(target.clone()))
621+
.unwrap_or_else(|| Vec::new(env));
622+
if let Some(i) = roles.iter().position(|r| r == *role) {
623+
roles.remove(i as u32);
624+
}
625+
env.storage()
626+
.instance()
627+
.set(&DataKey::AddressRoles(target.clone()), &roles);
628+
629+
env.storage()
630+
.instance()
631+
.remove(&DataKey::RoleExpiry(role.clone(), target.clone()));
632+
}
633+
633634
/// Track a role name in the AllRoles list if it hasn't been seen before.
634635
fn track_role_in_all_roles(env: &Env, role: &String) {
635636
let mut all_roles: Vec<String> = env
@@ -651,7 +652,27 @@ impl RouterAccess {
651652
AccessError::InvalidExpiry => router_common::BatchItemError::Custom(
652653
soroban_sdk::String::from_str(env, "InvalidExpiry"),
653654
),
654-
_ => router_common::BatchItemError::Custom(soroban_sdk::String::from_str(env, "Error")),
655+
AccessError::AlreadyInitialized => router_common::BatchItemError::Custom(
656+
soroban_sdk::String::from_str(env, "AlreadyInitialized"),
657+
),
658+
AccessError::NotInitialized => router_common::BatchItemError::Custom(
659+
soroban_sdk::String::from_str(env, "NotInitialized"),
660+
),
661+
AccessError::RoleNotFound => router_common::BatchItemError::Custom(
662+
soroban_sdk::String::from_str(env, "RoleNotFound"),
663+
),
664+
AccessError::CannotBlacklistAdmin => router_common::BatchItemError::Custom(
665+
soroban_sdk::String::from_str(env, "CannotBlacklistAdmin"),
666+
),
667+
AccessError::DestinationAlreadyHasRole => router_common::BatchItemError::Custom(
668+
soroban_sdk::String::from_str(env, "DestinationAlreadyHasRole"),
669+
),
670+
AccessError::HierarchyCycle => router_common::BatchItemError::Custom(
671+
soroban_sdk::String::from_str(env, "HierarchyCycle"),
672+
),
673+
AccessError::HierarchyTooDeep => router_common::BatchItemError::Custom(
674+
soroban_sdk::String::from_str(env, "HierarchyTooDeep"),
675+
),
655676
}
656677
}
657678

@@ -783,7 +804,11 @@ impl RouterAccess {
783804
Ok(())
784805
}
785806

807+
786808
fn require_super_admin(env: &Env, caller: &Address) -> Result<(), AccessError> {
809+
if Self::is_blacklisted_internal(env, caller) {
810+
return Err(AccessError::Blacklisted);
811+
}
787812
let admin: Address = env
788813
.storage()
789814
.instance()
@@ -1593,6 +1618,73 @@ mod tests {
15931618
assert_eq!(client.get_role_parent(&viewer), Some(editor));
15941619
}
15951620

1621+
// ── Issue #819: require_super_admin / transfer_super_admin blacklist gap ──
1622+
1623+
#[test]
1624+
fn test_blacklisted_address_cannot_regain_super_admin_via_transfer() {
1625+
let (env, admin_a, client) = setup();
1626+
let admin_b = Address::generate(&env);
1627+
1628+
// (1) A transfers super-admin to B.
1629+
client.transfer_super_admin(&admin_a, &admin_b);
1630+
assert_eq!(client.super_admin(), admin_b);
1631+
1632+
// (2) B blacklists A — allowed, since A is no longer the current
1633+
// super-admin.
1634+
client.blacklist(&admin_b, &admin_a);
1635+
assert!(client.is_blacklisted(&admin_a));
1636+
1637+
// (3) B attempts to transfer super-admin back to the now-blacklisted
1638+
// A. This must be rejected.
1639+
let result = client.try_transfer_super_admin(&admin_b, &admin_a);
1640+
assert_eq!(result, Err(Ok(AccessError::Blacklisted)));
1641+
1642+
// Super-admin remains B; A never regains privileged authority.
1643+
assert_eq!(client.super_admin(), admin_b);
1644+
1645+
// Even if A were to somehow be reinstated, a blacklisted caller must
1646+
// be rejected up front by require_super_admin. Simulate this by
1647+
// checking a blacklisted address can never pass the super-admin gate
1648+
// for any privileged action, using set_role_admin as a probe.
1649+
let role = String::from_str(&env, "operator");
1650+
let victim = Address::generate(&env);
1651+
let probe = client.try_set_role_admin(&admin_a, &role, &victim);
1652+
assert_eq!(probe, Err(Ok(AccessError::Unauthorized)));
1653+
}
1654+
1655+
#[test]
1656+
fn test_transfer_super_admin_rejects_blacklisted_new_admin_directly() {
1657+
let (env, admin, client) = setup();
1658+
let blacklisted = Address::generate(&env);
1659+
1660+
client.blacklist(&admin, &blacklisted);
1661+
assert!(client.is_blacklisted(&blacklisted));
1662+
1663+
let result = client.try_transfer_super_admin(&admin, &blacklisted);
1664+
assert_eq!(result, Err(Ok(AccessError::Blacklisted)));
1665+
assert_eq!(client.super_admin(), admin);
1666+
}
1667+
1668+
// ── Issue #820: expire_role bookkeeping parity with revoke_role ──────────
1669+
1670+
#[test]
1671+
fn test_expire_role_clears_member_count_and_address_roles() {
1672+
let (env, admin, client) = setup();
1673+
let role = String::from_str(&env, "operator");
1674+
let user = Address::generate(&env);
1675+
1676+
client.grant_role(&admin, &user, &role, &Some(9999));
1677+
assert_eq!(client.get_role_member_count(&role), 1);
1678+
assert!(client.get_roles_for_address(&user).contains(&role));
1679+
assert!(client.get_role_members(&role).contains(&user));
1680+
1681+
client.expire_role(&admin, &role, &user);
1682+
1683+
assert_eq!(client.get_role_member_count(&role), 0);
1684+
assert!(!client.get_roles_for_address(&user).contains(&role));
1685+
assert!(!client.get_role_members(&role).contains(&user));
1686+
}
1687+
15961688
#[test]
15971689
fn test_set_role_parent_rejects_transitive_cycle() {
15981690
let (env, admin, client) = setup();

contracts/router-common/src/Cargo.toml

Lines changed: 0 additions & 7 deletions
This file was deleted.

contracts/router-common/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ pub const EVENT_ROUTE_FEE_SET: &str = "route_fee_set";
207207

208208
/// Standard event topic for removing a custom per-route fee (reverts to default)
209209
pub const EVENT_ROUTE_FEE_UNSET: &str = "route_fee_unset";
210+
/// Standard event topic for per-route tiered fee schedule updates
211+
pub const EVENT_ROUTE_FEE_TIERS_SET: &str = "route_fee_tiers_set";
210212

211213
/// Standard event topic for a quote being calculated
212214
pub const EVENT_QUOTE_CALCULATED: &str = "quote_calculated";

0 commit comments

Comments
 (0)