Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions contracts/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,20 @@ table is the audit surface for admin access.
| --- | --- | --- | --- |
| `initialize(env, admin)` | The `admin` address must authorize the call (`admin.require_auth()`). Succeeds at most once. | Yes — `DataKey::Admin` (instance) | `Result<(), ContractError>` |
| `admin(env)` | None — read-only view. | No | `Result<Address, ContractError>` |
| `set_admin_cooldown(env, admin, seconds)` | `admin.require_auth()` **and** `admin` must equal the stored admin. | Yes — `DataKey::AdminCooldownSeconds` (persistent) | `Result<(), ContractError>` |
| `get_admin_cooldown(env)` | None — read-only view. | No | `u64` (`0` when unset) |
| `set_admin_cooldown(env, admin, seconds)` | `admin.require_auth()` **and** `admin` must equal the stored admin. `seconds` must be at least 60. | Yes — `DataKey::AdminCooldownSeconds` (persistent) | `Result<(), ContractError>` |
| `disable_admin_cooldown(env, admin)` | `admin.require_auth()` **and** `admin` must equal the stored admin. | Yes — `DataKey::AdminCooldownSeconds` (persistent, set to 0) | `Result<(), ContractError>` |
| `get_admin_cooldown(env)` | None — read-only view. | No | `u64` (`0` when unset or explicitly disabled) |
| `check_admin_cooldown(env, admin, function_name)` | `admin.require_auth()` **and** `admin` must equal the stored admin. | Yes — `DataKey::AdminLastAction(function_name)` on success only | `Result<(), ContractError>` |

### Authorization notes

- **`initialize`** is the only way to set the admin, and it is deliberately not
idempotent: a second call returns `AlreadyInitialized` (`1`).
- **`set_admin_cooldown`** and **`check_admin_cooldown`** are the only mutating
entrypoints that read the stored admin. Both return `AdminNotSet` (`2`) before
initialization, and `Unauthorized` (`3`) for any caller that has not proved it
is the stored admin. Authorization is checked *before* any storage write.
- **`set_admin_cooldown`**, **`disable_admin_cooldown`**, and
**`check_admin_cooldown`** return `AdminNotSet` (`2`) before initialization,
and `Unauthorized` (`3`) unless the caller is the stored admin. Authorization
is checked before any storage write. Use `disable_admin_cooldown` to explicitly
disable enforcement; `set_admin_cooldown` rejects zero.
- **`check_admin_cooldown`** is the throttle other contracts call. While the
cooldown is active it returns `AdminActionTimelocked` (`4`) and does **not**
update the last-action timestamp, so a blocked call does not extend the window.
Expand All @@ -41,6 +43,7 @@ table is the audit surface for admin access.
pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError>
pub fn admin(env: Env) -> Result<Address, ContractError>
pub fn set_admin_cooldown(env: Env, admin: Address, seconds: u64) -> Result<(), ContractError>
pub fn disable_admin_cooldown(env: Env, admin: Address) -> Result<(), ContractError>
pub fn get_admin_cooldown(env: Env) -> u64
pub fn check_admin_cooldown(env: Env, admin: Address, function_name: Symbol) -> Result<(), ContractError>
```
Expand Down Expand Up @@ -70,3 +73,4 @@ cargo test -p admin --test gas_snap
- `AdminNotSet = 2`: No admin configured
- `Unauthorized = 3`: Caller is not the admin
- `AdminActionTimelocked = 4`: Cooldown period has not elapsed
- `InvalidCooldown = 5`: Cooldown must be at least 60 seconds
27 changes: 27 additions & 0 deletions contracts/admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ pub enum ContractError {
AdminNotSet = 2,
Unauthorized = 3,
AdminActionTimelocked = 4,
InvalidCooldown = 5,
}

const MIN_COOLDOWN_SECONDS: u64 = 60;

#[contract]
pub struct AdminContract;

Expand Down Expand Up @@ -62,10 +65,34 @@ impl AdminContract {
return Err(ContractError::Unauthorized);
}

if seconds < MIN_COOLDOWN_SECONDS {
return Err(ContractError::InvalidCooldown);
}

env.storage().persistent().set(&DataKey::AdminCooldownSeconds, &seconds);
Ok(())
}

/// Explicitly disable admin action cooldown enforcement.
pub fn disable_admin_cooldown(env: Env, admin: Address) -> Result<(), ContractError> {
admin.require_auth();

let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(ContractError::AdminNotSet)?;

if admin != stored_admin {
return Err(ContractError::Unauthorized);
}

env.storage()
.persistent()
.set(&DataKey::AdminCooldownSeconds, &0u64);
Ok(())
}

/// Get the configured admin cooldown period in seconds.
pub fn get_admin_cooldown(env: Env) -> u64 {
env.storage()
Expand Down
45 changes: 45 additions & 0 deletions contracts/admin/tests/gas_snap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,51 @@ fn rejects_unauthorized_admin_call() {
fixture.client().try_set_admin_cooldown(&stranger, &100),
Err(Ok(ContractError::Unauthorized))
);
assert_eq!(
fixture.client().try_disable_admin_cooldown(&stranger),
Err(Ok(ContractError::Unauthorized))
);
}

#[test]
fn rejects_cooldowns_below_minimum() {
let fixture = Fixture::new();
fixture.initialize();

assert_eq!(
fixture
.client()
.try_set_admin_cooldown(&fixture.admin, &0),
Err(Ok(ContractError::InvalidCooldown))
);
assert_eq!(
fixture
.client()
.try_set_admin_cooldown(&fixture.admin, &59),
Err(Ok(ContractError::InvalidCooldown))
);
}

#[test]
fn disable_admin_cooldown_is_explicit() {
let fixture = Fixture::new();
fixture.initialize();
let client = fixture.client();
let function_name = Symbol::new(&fixture.env, "test_action");

fixture.env.ledger().with_mut(|ledger| {
ledger.timestamp = 100;
});
client.set_admin_cooldown(&fixture.admin, &300);
assert!(client
.check_admin_cooldown(&fixture.admin, &function_name)
.is_ok());

client.disable_admin_cooldown(&fixture.admin);
assert_eq!(client.get_admin_cooldown(), 0);
assert!(client
.check_admin_cooldown(&fixture.admin, &function_name)
.is_ok());
}

#[test]
Expand Down
89 changes: 63 additions & 26 deletions contracts/fees/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ enum DataKey {

/// Default timelock cooldown in seconds for fee updates (24 hours).
const DEFAULT_TIMELOCK_SECONDS: u64 = 86_400;
/// Minimum timelock cooldown in seconds for fee updates (1 hour).
const MIN_TIMELOCK_SECONDS: u64 = 3_600;

/// Maximum platform fee percentage in basis points (100% = 10_000).
const MAX_FEE_PERCENTAGE: i128 = 10_000;
Expand Down Expand Up @@ -306,27 +308,7 @@ impl FeesContract {
// Ensure fees are not paused for config updates (consistency)
Self::assert_fees_not_paused(&env)?;

let now = env.ledger().timestamp();
let eta = now.saturating_add(DEFAULT_TIMELOCK_SECONDS);

let queued = PendingFeeConfig {
config: new_config.clone(),
admin: admin.clone(),
proposed_at: now,
apply_eta: eta,
};

// Queue pending configuration for governance inspection
env.storage()
.persistent()
.set(&Symbol::new(&env, FEE_QUEUED_KEY), &queued);

// Consume active commit if present
env.storage()
.persistent()
.remove(&Symbol::new(&env, FEE_COMMIT_KEY));

Ok(())
Self::queue_fee_config(&env, &admin, new_config, DEFAULT_TIMELOCK_SECONDS)
}

/// Applies a previously queued fee configuration update after its timelock has expired.
Expand Down Expand Up @@ -385,6 +367,7 @@ impl FeesContract {
/// Sets only the platform fee percentage.
///
/// Convenience entrypoint that preserves all other fee configuration values.
/// The update is queued and subject to the standard fee configuration timelock.
///
/// # Authorization
/// The `admin` caller must authenticate via `require_auth()` and be the
Expand All @@ -408,11 +391,7 @@ impl FeesContract {

let mut config = Self::get_fee_config_internal(&env)?;
config.platform_fee_percentage = fee_percentage;
env.storage()
.persistent()
.set(&Symbol::new(&env, FEE_CONFIG_KEY), &config);

Ok(())
Self::queue_fee_config(&env, &admin, config, DEFAULT_TIMELOCK_SECONDS)
}

/// Authorizes a contract to record fees.
Expand Down Expand Up @@ -742,6 +721,34 @@ impl FeesContract {
.get(&Symbol::new(env, FEE_QUEUED_KEY))
}

fn queue_fee_config(
env: &Env,
admin: &Address,
config: FeeConfig,
delay_seconds: u64,
) -> Result<(), ContractError> {
if delay_seconds < MIN_TIMELOCK_SECONDS {
return Err(ContractError::InvalidInput);
}

let now = env.ledger().timestamp();
let queued = PendingFeeConfig {
config,
admin: admin.clone(),
proposed_at: now,
apply_eta: now.saturating_add(delay_seconds),
};

env.storage()
.persistent()
.set(&Symbol::new(env, FEE_QUEUED_KEY), &queued);
env.storage()
.persistent()
.remove(&Symbol::new(env, FEE_COMMIT_KEY));

Ok(())
}

/// Validates the caller is the registered admin.
fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> {
let stored_admin = Self::get_admin_internal(env)?;
Expand Down Expand Up @@ -818,3 +825,33 @@ impl FeesContract {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::testutils::Address as _;

#[test]
fn queue_fee_config_rejects_delay_below_minimum() {
let env = Env::default();
let admin = Address::generate(&env);
let config = FeeConfig {
platform_fee_percentage: 200,
creation_fee: 0,
min_fee_amount: 1,
max_fee_amount: MAX_FEE_AMOUNT,
collection_threshold: 100_000_000,
fees_enabled: true,
};

assert_eq!(
FeesContract::queue_fee_config(
&env,
&admin,
config,
MIN_TIMELOCK_SECONDS - 1,
),
Err(ContractError::InvalidInput)
);
}
}
20 changes: 20 additions & 0 deletions contracts/fees/tests/auth_boundary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,31 @@ fn test_set_platform_fee_succeeds_with_admin_auth() {
let admin = Address::generate(&env);
let client = register_and_initialize(&env, &admin);

let original_config = client.get_fee_config();
client.set_platform_fee(&admin, &300);
assert_eq!(client.get_fee_config(), original_config);

let pending = client
.get_pending_fee_config()
.expect("platform fee update should be queued");
assert_eq!(pending.config.platform_fee_percentage, 300);
assert_eq!(pending.apply_eta, env.ledger().timestamp() + 86_400);

let early_result = client.try_apply_fee_update(&admin);
match early_result {
Err(Ok(ContractError::FeeRevealTooEarly)) => {}
other => panic!("Expected FeeRevealTooEarly, got: {:?}", other),
}

let auths = env.auths();
assert_eq!(auths.len(), 1);
assert_eq!(auths[0].0, admin);

env.ledger().with_mut(|ledger| {
ledger.timestamp += 86_400;
});
client.apply_fee_update(&admin);
assert_eq!(client.get_fee_config().platform_fee_percentage, 300);
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions contracts/markets/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub enum ContractError {
InvalidPayoutCurve = 19,
/// Insufficient balance for withdrawal or operation.
InsufficientBalance = 20,
/// The configured maximum number of markets has been reached.
MarketLimitReached = 21,
}

#[cfg(test)]
Expand Down Expand Up @@ -76,5 +78,6 @@ mod tests {
assert_eq!(ContractError::ProposalNotExecutable as u32, 18);
assert_eq!(ContractError::InvalidPayoutCurve as u32, 19);
assert_eq!(ContractError::InsufficientBalance as u32, 20);
assert_eq!(ContractError::MarketLimitReached as u32, 21);
}
}
Loading