diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml new file mode 100644 index 00000000..17723c58 --- /dev/null +++ b/contracts/subscription/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "subscription" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "22.0.10" + +[dev-dependencies] +soroban-sdk = { version = "22.0.10", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs new file mode 100644 index 00000000..e7efca7d --- /dev/null +++ b/contracts/subscription/src/lib.rs @@ -0,0 +1,107 @@ +#![cfg_attr(target_family = "wasm", no_std)] + +use soroban_sdk::{contract, contractimpl, contracttype, contracterror, token, Address, Env}; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum Error { + AlreadySubscribed = 1, + NotSubscribed = 2, + PaymentNotDue = 3, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Subscription { + pub subscriber: Address, + pub merchant: Address, + pub token: Address, + pub amount: i128, + pub interval: u64, + pub next_payment: u64, +} + +#[contracttype] +pub enum DataKey { + Sub(Address), +} + +#[contract] +pub struct SubscriptionContract; + +#[contractimpl] +impl SubscriptionContract { + /// Create a new subscription. The first payment is due immediately (next_payment = now). + pub fn subscribe( + env: Env, + subscriber: Address, + merchant: Address, + token: Address, + amount: i128, + interval: u64, + ) -> Result<(), Error> { + subscriber.require_auth(); + + let key = DataKey::Sub(subscriber.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::AlreadySubscribed); + } + + let now = env.ledger().timestamp(); + let sub = Subscription { + subscriber, + merchant, + token, + amount, + interval, + next_payment: now, + }; + env.storage().persistent().set(&key, &sub); + Ok(()) + } + + /// Execute a payment. Fails with PaymentNotDue if called before next_payment. + pub fn execute_payment(env: Env, subscriber: Address) -> Result<(), Error> { + let key = DataKey::Sub(subscriber.clone()); + let mut sub: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::NotSubscribed)?; + + let now = env.ledger().timestamp(); + if now < sub.next_payment { + return Err(Error::PaymentNotDue); + } + + let token_client = token::Client::new(&env, &sub.token); + token_client.transfer(&sub.subscriber, &sub.merchant, &sub.amount); + + sub.next_payment = now + sub.interval; + env.storage().persistent().set(&key, &sub); + Ok(()) + } + + /// Cancel an active subscription. + pub fn cancel(env: Env, subscriber: Address) -> Result<(), Error> { + subscriber.require_auth(); + + let key = DataKey::Sub(subscriber); + if !env.storage().persistent().has(&key) { + return Err(Error::NotSubscribed); + } + + env.storage().persistent().remove(&key); + Ok(()) + } + + /// Read the stored subscription record. + pub fn get_subscription(env: Env, subscriber: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::Sub(subscriber)) + } +} + +mod test; diff --git a/contracts/subscription/src/test.rs b/contracts/subscription/src/test.rs new file mode 100644 index 00000000..15178775 --- /dev/null +++ b/contracts/subscription/src/test.rs @@ -0,0 +1,108 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, +}; + +fn setup() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, SubscriptionContract); + let admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + + let subscriber = Address::generate(&env); + let merchant = Address::generate(&env); + + let token_admin = token::StellarAssetClient::new(&env, &token_id); + token_admin.mint(&subscriber, &10_000); + + (env, contract_id, token_id, subscriber, merchant) +} + +// --------------------------------------------------------------------------- +// Issue #188 – execute_payment fails with PaymentNotDue before next_payment +// --------------------------------------------------------------------------- +#[test] +fn test_execute_payment_before_due_time_returns_error() { + let (env, contract_id, token_id, subscriber, merchant) = setup(); + let client = SubscriptionContractClient::new(&env, &contract_id); + + env.ledger().with_mut(|l| l.timestamp = 1000); + client.subscribe(&subscriber, &merchant, &token_id, &100, &3600).unwrap(); + + env.ledger().with_mut(|l| l.timestamp = 1001); + client.execute_payment(&subscriber).unwrap(); + + // next_payment is now 1001 + 3600 = 4601; calling again at T=1001 must fail + let err = client.try_execute_payment(&subscriber).unwrap_err().unwrap(); + assert_eq!(err, Error::PaymentNotDue); + + let sub = client.get_subscription(&subscriber).unwrap(); + assert_eq!(sub.next_payment, 4601); + assert_eq!(sub.amount, 100); +} + +#[test] +fn test_execute_payment_succeeds_at_due_time() { + let (env, contract_id, token_id, subscriber, merchant) = setup(); + let client = SubscriptionContractClient::new(&env, &contract_id); + + env.ledger().with_mut(|l| l.timestamp = 500); + client.subscribe(&subscriber, &merchant, &token_id, &200, &3600).unwrap(); + client.execute_payment(&subscriber).unwrap(); + + let sub = client.get_subscription(&subscriber).unwrap(); + assert_eq!(sub.next_payment, 500 + 3600); + + let token_client = token::Client::new(&env, &token_id); + assert_eq!(token_client.balance(&merchant), 200); +} + +// --------------------------------------------------------------------------- +// Issue #189 – cancel then re-subscribe replaces the storage record cleanly +// --------------------------------------------------------------------------- +#[test] +fn test_cancel_and_resubscribe_replaces_record() { + let (env, contract_id, token_id, subscriber, merchant) = setup(); + let client = SubscriptionContractClient::new(&env, &contract_id); + + env.ledger().with_mut(|l| l.timestamp = 0); + client.subscribe(&subscriber, &merchant, &token_id, &50, &600).unwrap(); + assert_eq!(client.get_subscription(&subscriber).unwrap().amount, 50); + + client.cancel(&subscriber).unwrap(); + assert!(client.get_subscription(&subscriber).is_none()); + + env.ledger().with_mut(|l| l.timestamp = 100); + client.subscribe(&subscriber, &merchant, &token_id, &99, &1200).unwrap(); + + let sub = client.get_subscription(&subscriber).unwrap(); + assert_eq!(sub.amount, 99); + assert_eq!(sub.interval, 1200); + assert_eq!(sub.next_payment, 100); +} + +#[test] +fn test_cancel_nonexistent_subscription_returns_error() { + let (env, contract_id, _, subscriber, _) = setup(); + let client = SubscriptionContractClient::new(&env, &contract_id); + + let err = client.try_cancel(&subscriber).unwrap_err().unwrap(); + assert_eq!(err, Error::NotSubscribed); +} + +#[test] +fn test_double_subscribe_returns_error() { + let (env, contract_id, token_id, subscriber, merchant) = setup(); + let client = SubscriptionContractClient::new(&env, &contract_id); + + client.subscribe(&subscriber, &merchant, &token_id, &10, &100).unwrap(); + + let err = client.try_subscribe(&subscriber, &merchant, &token_id, &10, &100) + .unwrap_err().unwrap(); + assert_eq!(err, Error::AlreadySubscribed); +}