Skip to content
Closed
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
1 change: 0 additions & 1 deletion Issue.md

This file was deleted.

2 changes: 1 addition & 1 deletion contract/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn accept_admin(env: &Env) {
.storage()
.instance()
.get(&DataKey::PendingAdmin)
.expect("no pending admin");
.unwrap_or_else(|| env.panic_with_error(crate::errors::ContractError::NoPendingAdmin));
pending.require_auth();

let old_admin = get_admin(env);
Expand Down
4 changes: 3 additions & 1 deletion contract/src/grace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ pub fn is_grace_lapsed(env: &Env, sub: &Subscription) -> bool {

/// Proposes a new contract-wide grace period.
pub fn propose_grace_period(env: &Env, seconds: u64) {
assert!(seconds <= u64::MAX / 2, "grace period too large");
if seconds > u64::MAX / 2 {
env.panic_with_error(crate::errors::ContractError::AmountExceedsMaximum);
}
crate::admin::require_admin(env);

env.storage()
Expand Down
31 changes: 24 additions & 7 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,20 @@ impl FlowPay {
trial::get_trial_end(env, user)
}

/// Returns the pause expiry timestamp for `user`, or `None` if no timed
/// pause is active.
///
/// A non-`None` value means the subscription was paused via `pause_until()`
/// and will auto-resume when the ledger timestamp reaches the returned value.
/// A value of `u64::MAX` indicates an indefinite pause set by `pause()`.
///
/// # Auth
///
/// None required — view-only read.
pub fn get_pause_expiry(env: Env, user: Address) -> Option<u64> {
storage::get_pause_expiry(&env, &user)
}

/// Proposes a new contract-wide grace period for charges.
/// Only the contract admin can call this.
pub fn propose_grace_period(env: Env, seconds: u64) {
Expand Down Expand Up @@ -1191,9 +1205,11 @@ impl FlowPay {
}

/// Sets the minimum allowed subscription interval in seconds.
/// Only the contract admin can call this. Panics if seconds == 0.
/// Only the contract admin can call this. Returns IntervalMustBePositive if seconds == 0.
pub fn set_min_interval(env: Env, seconds: u64) {
assert!(seconds > 0, "min interval must be positive");
if seconds == 0 {
env.panic_with_error(ContractError::IntervalMustBePositive);
}
admin::require_admin(&env);
min_interval::set_min_interval(&env, seconds);
}
Expand Down Expand Up @@ -1916,6 +1932,7 @@ impl FlowPay {
pub fn set_initial_admin(env: Env, admin: Address) {
admin.require_auth();
if env.storage().instance().has(&DataKey::Admin) {
env.panic_with_error(ContractError::AlreadyInitialized);
env.panic_with_error(ContractError::AdminAlreadySet);
}
storage::set_admin(&env, &admin);
Expand Down Expand Up @@ -2324,10 +2341,10 @@ fn subscribe_inner(
validation::require_valid_amount(env, amount);
validation::validate_interval(env, interval);

use soroban_sdk::xdr::ToXdr;
if token.clone().to_xdr(env).get(7) == Some(0) {
env.panic_with_error(ContractError::InvalidTokenAddress);
}
// Validate token address and SAC interface before writing any subscription
// state. Uses require_valid_token_address which checks XDR discriminant +
// probes the token interface. No subscription row is written on failure.
validation::require_valid_token_address(env, &token);

validation::check_allowance(env, &user, &token, amount);

Expand All @@ -2336,7 +2353,7 @@ fn subscribe_inner(
let last_charged = now + trial_duration;

let existing = storage::get_subscription(env, &user);
let should_increment = existing.as_ref().is_none_or(|s| !s.active);
let should_increment = existing.as_ref().map_or(true, |s| !s.active);

if let Some(ref existing_sub) = existing {
if existing_sub.active && existing_sub.merchant != merchant {
Expand Down
31 changes: 31 additions & 0 deletions contract/src/merchant_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@ use soroban_sdk::{Address, Env, Vec};

use crate::DataKey;

// ─────────────────────────────────────────────────────────────
// Day-index cap (Issue #817)
// ─────────────────────────────────────────────────────────────
//
// High-volume merchants can accumulate an unbounded number of distinct revenue
// days, which inflates persistent storage and makes `get_merchant_revenue_day_page`
// reads proportionally expensive to process on-chain.
//
// This constant is the hard ceiling on how many day-buckets a single merchant
// may have in their `MerchantRevenueDayIndex`. 365 days is chosen to cover
// roughly one year of daily revenue, which is large enough for normal usage and
// conservative enough to keep storage bounded to a predictable size (~365 * 2
// Soroban persistent entries per merchant at most).
//
// When the index is at capacity:
// • Adding a *new* day fails closed with `MerchantDayIndexFull` (#41). This
// ensures the ledger never silently drops revenue data.
// • Updating an *existing* day's bucket always succeeds because no new entry
// is appended to the index.
//
// Operators must call `prune_merchant_revenue_days` to remove expired or
// unneeded buckets before the cap blocks new days from being recorded.
pub const MAX_MERCHANT_REVENUE_DAY_INDEX_SIZE: u32 = 365;

/// Returns the total revenue accumulated for a merchant.
pub fn get_merchant_revenue(env: &Env, merchant: &Address) -> i128 {
env.storage()
Expand Down Expand Up @@ -79,6 +103,13 @@ pub fn increment_revenue_with_daily(env: &Env, merchant: &Address, amount: i128)
.persistent()
.get(&index_key)
.unwrap_or_else(|| Vec::new(env));
// Enforce the day-index cap before appending. If the index is already
// at MAX_MERCHANT_REVENUE_DAY_INDEX_SIZE, reject the new day so storage
// pressure stays bounded. Operators must call `prune_merchant_revenue_days`
// to free capacity.
if index.len() >= MAX_MERCHANT_REVENUE_DAY_INDEX_SIZE {
env.panic_with_error(crate::errors::ContractError::MerchantDayIndexFull);
}
index.push_back(today);
env.storage().persistent().set(&index_key, &index);
env.storage()
Expand Down
2 changes: 1 addition & 1 deletion contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub fn get_admin(env: &Env) -> Address {
env.storage()
.instance()
.get(&DataKey::Admin)
.expect("admin not set")
.unwrap_or_else(|| env.panic_with_error(crate::errors::ContractError::NotInitialized))
}

pub fn get_admin_optional(env: &Env) -> Option<Address> {
Expand Down
Loading