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
14 changes: 14 additions & 0 deletions contract/src/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use crate::charge_exec;
use crate::grace;
use crate::{DataKey, Subscription};

pub const MAX_BATCH_SIZE: u32 = 50;

/// The outcome for a single user in a batch_charge call.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
Expand All @@ -22,13 +24,25 @@ pub enum ChargeResult {
GracePeriodElapsed,
}

pub(crate) fn get_max_batch_size(env: &Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::MaxBatchSize)
.unwrap_or(MAX_BATCH_SIZE)
}

/// Attempts to charge each user in `users`.
///
/// Individual failures do **not** abort the batch — every address is
/// processed and its outcome is recorded in the returned `Vec`.
pub fn batch_charge(env: &Env, users: Vec<Address>) -> Vec<ChargeResult> {
let mut results: Vec<ChargeResult> = Vec::new(env);

let max_size = get_max_batch_size(env);
if users.len() > max_size {
env.panic_with_error(crate::errors::ContractError::BatchTooLarge);
}

let now = env.ledger().timestamp();
let grace_period = grace::get_grace_period(env);

Expand Down
8 changes: 5 additions & 3 deletions contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ pub enum ContractError {
ContractPaused = 18,
/// Returned when a subscription interval is below the minimum permitted floor
IntervalTooShort = 19,
/// Returned when the batch size exceeds the maximum allowed
BatchTooLarge = 20,
/// Returned when a merchant attempts to withdraw with no accrued revenue
ZeroBalanceAvailable = 20,
/// Returned when the batch size exceeds the maximum allowed (25)
BatchSizeExceeded = 21,
ZeroBalanceAvailable = 21,
/// Returned when a configured batch limit is invalid
InvalidBatchSize = 29,
/// Returned when attempting to subscribe to a frozen merchant
MerchantFrozen = 22,
/// Returned when a two-step commit is attempted without a pending proposal
Expand Down
71 changes: 63 additions & 8 deletions contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ use soroban_sdk::{Address, BytesN, Env, Symbol};

use crate::Subscription;

pub fn publish_subscribed(env: &Env, user: &Address, sub: &Subscription) {
env.events().publish(
(Symbol::new(env, "subscribed"), user.clone()),
(sub.merchant.clone(), sub.amount, sub.interval),
);
#[soroban_sdk::contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscribedEventData {
pub merchant: Address,
pub amount: i128,
pub interval: u64,
pub ledger_sequence: u32,
}

#[soroban_sdk::contracttype]
Expand All @@ -17,6 +19,40 @@ pub struct ChargeEventData {
pub fee: i128,
pub net: i128,
pub charged_at: u64,
pub ledger_sequence: u32,
}

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PayPerUseEventData {
pub merchant: Address,
pub amount: i128,
pub ledger_sequence: u32,
}

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CancelledEventData {
pub ledger_sequence: u32,
}

#[soroban_sdk::contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CancelledWithRefundEventData {
pub refund_amount: i128,
pub ledger_sequence: u32,
}

pub fn publish_subscribed(env: &Env, user: &Address, sub: &Subscription) {
env.events().publish(
(Symbol::new(env, "subscribed"), user.clone()),
SubscribedEventData {
merchant: sub.merchant.clone(),
amount: sub.amount,
interval: sub.interval,
ledger_sequence: env.ledger().sequence(),
},
);
}

pub fn publish_charged(
Expand All @@ -35,20 +71,39 @@ pub fn publish_charged(
fee: fee_amount,
net,
charged_at,
ledger_sequence: env.ledger().sequence(),
},
);
}

pub fn publish_pay_per_use(env: &Env, user: &Address, merchant: &Address, amount: i128) {
env.events().publish(
(Symbol::new(env, "pay_per_use"), user.clone()),
(merchant.clone(), amount),
PayPerUseEventData {
merchant: merchant.clone(),
amount,
ledger_sequence: env.ledger().sequence(),
},
);
}

pub fn publish_cancelled(env: &Env, user: &Address) {
env.events()
.publish((Symbol::new(env, "cancelled"), user.clone()), ());
env.events().publish(
(Symbol::new(env, "cancelled"), user.clone()),
CancelledEventData {
ledger_sequence: env.ledger().sequence(),
},
);
}

pub fn publish_cancelled_with_refund(env: &Env, user: &Address, refund_amount: i128) {
env.events().publish(
(Symbol::new(env, "subscription_cancelled_with_refund"), user.clone()),
CancelledWithRefundEventData {
refund_amount,
ledger_sequence: env.ledger().sequence(),
},
);
}

pub fn publish_min_interval_updated(env: &Env, seconds: u64) {
Expand Down
78 changes: 65 additions & 13 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pub enum DataKey {
ChargeHistory(Address),
// Feature: global volume cap
GlobalVolumeWindow,
// Feature: batch size limit override
MaxBatchSize,
// Feature: contract pause
ContractPaused,
// Feature: minimum subscription interval floor
Expand Down Expand Up @@ -161,6 +163,26 @@ pub struct ProtocolStats {
// Contract
// ─────────────────────────────────────────────────────────────

fn cancel_inner(env: &Env, user: &Address) -> Subscription {
let key = DataKey::Subscription(user.clone());
let mut sub: Subscription = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| env.panic_with_error(ContractError::NoSubscriptionFound));

sub.active = false;

env.storage().persistent().set(&key, &sub);
extend_subscription_ttl(env, user);

subscription_count::decrement(env);
merchant_stats::decrement_subscriber_count(env, &sub.merchant);
referral::remove_referral(env, user);

sub
}

#[contract]
pub struct FlowPay;

Expand All @@ -176,6 +198,18 @@ impl FlowPay {
env.storage().instance().extend_ttl(50_000, 100_000);
}

pub fn get_max_batch_size(env: Env) -> u32 {
batch::get_max_batch_size(&env)
}

pub fn set_max_batch_size(env: Env, size: u32) {
admin::require_admin(&env);
if size > 200 {
env.panic_with_error(ContractError::InvalidBatchSize);
}
env.storage().instance().set(&DataKey::MaxBatchSize, &size);
}

/// Creates or replaces a recurring subscription for `user`.
///
/// # Parameters
Expand Down Expand Up @@ -241,6 +275,7 @@ impl FlowPay {
if label.len() > 64 {
env.panic_with_error(ContractError::MetadataLabelTooLong);
}

subscribe_inner(
&env,
user.clone(),
Expand All @@ -251,6 +286,7 @@ impl FlowPay {
trial_period,
referrer,
);

let _ = subscription_metadata::set_metadata(&env, &user, label);
}

Expand Down Expand Up @@ -411,24 +447,36 @@ impl FlowPay {
/// emits `cancelled`.
pub fn cancel(env: Env, user: Address) {
user.require_auth();
cancel_inner(&env, &user);
events::publish_cancelled(&env, &user);
}

let key = DataKey::Subscription(user.clone());
pub fn cancel_and_refund_prorated(env: Env, user: Address, merchant: Address) {
user.require_auth();
merchant.require_auth();

let mut sub: Subscription = env
let key = DataKey::Subscription(user.clone());
let sub: Subscription = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| env.panic_with_error(ContractError::NoSubscriptionFound));

sub.active = false;
let now = env.ledger().timestamp();
let elapsed = now.saturating_sub(sub.last_charged);
let remaining = if elapsed >= sub.interval {
0
} else {
sub.interval - elapsed
};
let refund = (sub.amount * i128::from(remaining)) / i128::from(sub.interval);

env.storage().persistent().set(&key, &sub);
extend_subscription_ttl(&env, &user);
if refund > 0 {
token::Client::new(&env, &sub.token).transfer(&merchant, &user, &refund);
}

subscription_count::decrement(&env);
merchant_stats::decrement_subscriber_count(&env, &sub.merchant);
referral::remove_referral(&env, &user);
events::publish_cancelled(&env, &user);
cancel_inner(&env, &user);
events::publish_cancelled_with_refund(&env, &user, refund);
}

/// Pauses `user`'s subscription without cancelling it.
Expand Down Expand Up @@ -581,7 +629,7 @@ impl FlowPay {

let max_batch: u32 = 25;
if users.len() > max_batch {
env.panic_with_error(ContractError::BatchSizeExceeded);
env.panic_with_error(ContractError::BatchTooLarge);
}

for user in users.iter() {
Expand Down Expand Up @@ -1366,6 +1414,7 @@ impl FlowPay {

fn extend_subscription_ttl(env: &Env, user: &Address) {
storage::extend_subscription_ttl(env, user);
env.storage().instance().extend_ttl(SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS);
env.storage()
.instance()
.extend_ttl(SUBSCRIPTION_TTL_LEDGERS, SUBSCRIPTION_TTL_LEDGERS);
Expand Down Expand Up @@ -1438,8 +1487,10 @@ fn subscribe_inner(
) {
user.require_auth();

if whitelist::is_whitelist_enabled(env) && !whitelist::is_whitelisted(env, &merchant) {
env.panic_with_error(ContractError::MerchantNotWhitelisted);
if whitelist::is_whitelist_enabled(env) {
if !whitelist::is_whitelisted(env, &merchant) {
env.panic_with_error(ContractError::MerchantNotWhitelisted);
}
}

if whitelist::is_frozen(env, &merchant) {
Expand All @@ -1466,6 +1517,7 @@ fn subscribe_inner(
env.panic_with_error(ContractError::IntervalTooShort);
}

validation::validate_token_address(env, &token);
use soroban_sdk::xdr::ToXdr;
if token.clone().to_xdr(env).get(7) == Some(0) {
env.panic_with_error(ContractError::InvalidTokenAddress);
Expand All @@ -1478,7 +1530,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
Loading
Loading