Skip to content
Open
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
6 changes: 4 additions & 2 deletions bettapay_common/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub const TTL_THRESHOLD_LEDGERS: u32 = LEDGERS_PER_DAY * 14;
pub const TTL_BUMP_LEDGERS: u32 = LEDGERS_PER_DAY * 30;

/// Cooldown between `initiate_recovery` and `execute_recovery`: seven days,
/// expressed in seconds. Both contracts use the same delay window so they can
/// share a single definition.
/// expressed in seconds. Scheduled settlement administrative operations use a
/// delay of at least this long. This ordering is part of the threat model:
/// recovery must be able to veto compromised-admin upgrades and admin
/// transfers before they execute.
pub const RECOVERY_DELAY_SECONDS: u64 = 7 * 24 * 60 * 60;
13 changes: 13 additions & 0 deletions settlement_contract/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ impl SettlementContract {
);
}

/// Initiates recovery and vetoes every pending scheduled operation.
///
/// Recovery is intentionally the emergency veto path: once the recovery
/// address authenticates, no operation scheduled under the compromised
/// admin can execute, including an upgrade or admin transfer.
pub fn initiate_recovery(env: Env, new_admin: Address) {
let recovery_address = read_recovery_address(&env);
recovery_address.require_auth();
Expand All @@ -114,6 +119,7 @@ impl SettlementContract {
env.storage()
.instance()
.set(&CommonDataKey::PendingRecovery, &pending);
// `PendingRecovery` itself is the veto marker checked by `execute`.
events::emit_recovery_initiated(&env, &recovery_address, &new_admin, pending.execute_after);
}

Expand Down Expand Up @@ -296,6 +302,13 @@ impl SettlementContract {

/// Executes a previously scheduled administrative operation.
pub fn execute(env: Env, operation: Operation) {
if env.storage().instance().has(&CommonDataKey::PendingRecovery) {
// A pending recovery is an emergency veto over all scheduled ops.
// The recovery record remains until recovery execution/cancellation,
// so this also closes the race between the two transactions.
panic_with_error!(&env, SettlementError::ExecutionNotReady);
}
let op_hash: BytesN<32> = env.crypto().sha256(&operation.clone().to_xdr(&env)).into();
let operation_xdr = operation.clone().to_xdr(&env);
let op_hash: BytesN<32> = env.crypto().sha256(&operation_xdr).into();
let key = DataKey::ScheduledOperation(op_hash.clone());
Expand Down
8 changes: 7 additions & 1 deletion settlement_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,13 @@ pub(crate) const RULE_TTL_BUMP: u32 = LEDGERS_PER_DAY * 30;
pub(crate) const MERCHANT_TTL_THRESHOLD: u32 = LEDGERS_PER_DAY * 14;
pub(crate) const MERCHANT_TTL_BUMP: u32 = LEDGERS_PER_DAY * 30;

pub(crate) const DEFAULT_TIMELOCK_DELAY_SECONDS: u64 = 2 * 24 * 60 * 60; // 48 hours
/// Minimum delay for scheduled administrative operations.
///
/// This matches the seven-day recovery window so the recovery address has
/// time to replace compromised admins before a scheduled upgrade or admin
/// transfer can execute. The recovery path is the veto authority for pending
/// schedules; ordinary admin cancellation remains available as well.
pub(crate) const DEFAULT_TIMELOCK_DELAY_SECONDS: u64 = 7 * 24 * 60 * 60;

// Settlement-specific TTL policy for short-lived reads of admin / governance /
// recovery addresses. Deliberately shorter than the protocol defaults so that
Expand Down
6 changes: 5 additions & 1 deletion settlement_contract/src/payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use bettapay_common::{constants::BPS_DENOMINATOR, events};

use crate::errors::SettlementError;
use crate::storage::{
assert_not_paused, is_merchant_registered_internal, read_rule_or_default,
read_rule_or_default_readonly,
assert_not_paused, assert_payments_readable, is_merchant_registered_and_bump_ttl,
is_merchant_registered_internal, read_min_payment_amount, read_rule_or_default,
};
Expand Down Expand Up @@ -288,7 +290,9 @@ impl SettlementContract {
if amount < min_amount {
panic_with_error!(env, SettlementError::AmountTooSmall);
}
let rule = read_rule_or_default(&env, merchant);
// Quote reads must be side-effect free: do not extend merchant/rule
// TTLs and do not emit bootstrap telemetry for arbitrary callers.
let rule = read_rule_or_default_readonly(&env, merchant);
calculate_split(&env, amount, &rule)
}

Expand Down
41 changes: 31 additions & 10 deletions settlement_contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,16 +220,33 @@ pub(crate) fn is_merchant_registered_and_bump_ttl(env: &Env, merchant: Address)
/// Resolves the effective settlement rule for a merchant by preferring the merchant-specific override,
/// then falling back to the global default, and finally using the bootstrap fallback.
pub(crate) fn read_rule_or_default(env: &Env, merchant: Address) -> SettlementRule {
read_rule_or_default_with_effects(env, merchant, true)
}

/// Resolves a settlement rule without changing persistent TTLs or emitting
/// bootstrap telemetry. This is used by public quote/read paths, where any
/// caller can otherwise keep entries alive and generate unbounded events.
pub(crate) fn read_rule_or_default_readonly(env: &Env, merchant: Address) -> SettlementRule {
read_rule_or_default_with_effects(env, merchant, false)
}

fn read_rule_or_default_with_effects(
env: &Env,
merchant: Address,
apply_effects: bool,
) -> SettlementRule {
// Merchant-specific rule wins over any shared configuration.
let merchant_key = DataKey::Rule(merchant);
if let Some(rule) = env
.storage()
.persistent()
.get::<_, SettlementRule>(&merchant_key)
{
env.storage()
.persistent()
.extend_ttl(&merchant_key, RULE_TTL_THRESHOLD, RULE_TTL_BUMP);
if apply_effects {
env.storage()
.persistent()
.extend_ttl(&merchant_key, RULE_TTL_THRESHOLD, RULE_TTL_BUMP);
}
return rule;
}
// Fall back to the admin-controlled global default when present.
Expand All @@ -239,20 +256,24 @@ pub(crate) fn read_rule_or_default(env: &Env, merchant: Address) -> SettlementRu
.persistent()
.get::<_, SettlementRule>(&default_key)
{
env.storage()
.persistent()
.extend_ttl(&default_key, RULE_TTL_THRESHOLD, RULE_TTL_BUMP);
if apply_effects {
env.storage()
.persistent()
.extend_ttl(&default_key, RULE_TTL_THRESHOLD, RULE_TTL_BUMP);
}
return rule;
}
// Protocol fee source: governance's GovFeeConfig, when available.
if let Some(rule) = read_governance_fee_rule(env) {
return rule;
}
// Final fallback keeps the contract usable before any config is stored.
env.events().publish(
(Symbol::new(env, events::BOOTSTRAP_FALLBACK_EVENT),),
BOOTSTRAP_DEFAULT_RULE,
);
if apply_effects {
env.events().publish(
(Symbol::new(env, events::BOOTSTRAP_FALLBACK_EVENT),),
BOOTSTRAP_DEFAULT_RULE,
);
}
BOOTSTRAP_DEFAULT_RULE
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,7 @@ fn bootstrap_fallback_uses_canonical_topic() {
// bootstrap fallback rule.
let before = env.events().all().len();
client.calculate_fee_split(&merchant, &1_000);
assert!(env.events().all().len() > before);
assert_eq!(
last_topic(&env),
Symbol::new(&env, events::BOOTSTRAP_FALLBACK_EVENT)
);
assert_eq!(env.events().all().len(), before);
}

#[test]
Expand Down
53 changes: 53 additions & 0 deletions settlement_contract/src/tests/timelock_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
//! Regression coverage for the settlement administrative timelock.

use crate::{Operation, DEFAULT_TIMELOCK_DELAY_SECONDS};
use bettapay_common::constants::RECOVERY_DELAY_SECONDS;
use soroban_sdk::testutils::{storage::Persistent, Address as _, Events, Ledger};
use soroban_sdk::Address;
use crate::{Operation, SettlementContractClient, DEFAULT_TIMELOCK_DELAY_SECONDS};
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::{Address, Env};
Expand All @@ -26,6 +30,55 @@ fn scheduled_operation_executes_only_after_delay() {
assert!(client.try_execute(&operation).is_err());
}

#[test]
fn calculate_fee_split_read_is_ttl_and_event_neutral() {
let (env, client, admins, merchant) = setup();
client.register_merchant(&admins, &merchant);

let merchant_key = crate::types::DataKey::Merchant(merchant.clone());
let before_ttl = env.as_contract(&client.address, || {
env.storage().persistent().get_ttl(&merchant_key)
});
let before_events = env.events().all().len();

client.calculate_fee_split(&merchant, &1_000);

let after_ttl = env.as_contract(&client.address, || {
env.storage().persistent().get_ttl(&merchant_key)
});
assert_eq!(after_ttl, before_ttl);
assert_eq!(env.events().all().len(), before_events);
}

#[test]
fn recovery_vetoes_scheduled_operation_before_timelock_expiry() {
let (env, client, admins, recovery) = setup();
let operation = Operation::TransferAdmin(
soroban_sdk::vec![&env, Address::generate(&env)],
1,
);
let admin = admins.get(0).unwrap();

client.schedule(&admin, &operation, &DEFAULT_TIMELOCK_DELAY_SECONDS);
env.ledger().with_mut(|ledger| {
ledger.timestamp += RECOVERY_DELAY_SECONDS;
});
let recovery_target = Address::generate(&env);
client.initiate_recovery(&recovery_target);

// Recovery begins at the same boundary as the timelock and must win the
// transaction race: a scheduled operation cannot execute while recovery
// is pending, even when its nominal delay has elapsed.
env.ledger().with_mut(|ledger| {
ledger.timestamp += DEFAULT_TIMELOCK_DELAY_SECONDS;
});
assert!(client.try_execute(&operation).is_err());
assert_eq!(client.get_admin(), admins);
// The client address is the contract address; the recovery address is
// intentionally not exposed by this helper's return tuple.
assert_ne!(client.address, recovery);
}

#[test]
fn schedule_rejects_non_admin_and_insufficient_delay() {
let (env, client, admins, merchant) = setup();
Expand Down
Loading