From 1d2033621a8ea9cd43b64d86c71b9c0dcdc162d0 Mon Sep 17 00:00:00 2001 From: nasarajoe Date: Sat, 29 Aug 2026 18:47:44 +0100 Subject: [PATCH] feat(smart-contract): add agent subscription and recurring payment model --- .../contracts/agent_registry/src/errors.rs | 18 + .../contracts/agent_registry/src/events.rs | 76 +++- .../contracts/agent_registry/src/lib.rs | 369 +++++++++++++++-- .../contracts/agent_registry/src/test.rs | 377 ++++++++++++++++-- .../contracts/agent_registry/src/types.rs | 48 +++ 5 files changed, 833 insertions(+), 55 deletions(-) diff --git a/smart-contracts/contracts/agent_registry/src/errors.rs b/smart-contracts/contracts/agent_registry/src/errors.rs index 2675bd87..2bac8194 100644 --- a/smart-contracts/contracts/agent_registry/src/errors.rs +++ b/smart-contracts/contracts/agent_registry/src/errors.rs @@ -51,6 +51,18 @@ pub enum Error { SlaViolation = 25, /// Invalid SLA parameters. InvalidSla = 26, + /// Referenced subscription does not exist. + SubscriptionNotFound = 27, + /// An active subscription already exists for this (client, agent) pair. + SubscriptionAlreadyExists = 28, + /// The subscription term has expired. + SubscriptionExpired = 29, + /// The subscription is still within its paid term. + SubscriptionActive = 30, + /// Subscription parameters are invalid (non-positive amount or zero period). + InvalidSubscription = 31, + /// The subscription has already been cancelled. + SubscriptionAlreadyCancelled = 32, } impl Error { @@ -83,6 +95,12 @@ impl Error { 24 => Some(Error::SlaNotFound), 25 => Some(Error::SlaViolation), 26 => Some(Error::InvalidSla), + 27 => Some(Error::SubscriptionNotFound), + 28 => Some(Error::SubscriptionAlreadyExists), + 29 => Some(Error::SubscriptionExpired), + 30 => Some(Error::SubscriptionActive), + 31 => Some(Error::InvalidSubscription), + 32 => Some(Error::SubscriptionAlreadyCancelled), _ => None, } } diff --git a/smart-contracts/contracts/agent_registry/src/events.rs b/smart-contracts/contracts/agent_registry/src/events.rs index 3954088c..94e30f52 100644 --- a/smart-contracts/contracts/agent_registry/src/events.rs +++ b/smart-contracts/contracts/agent_registry/src/events.rs @@ -32,7 +32,7 @@ //! | `unfreeze_agent` | `unfreeze` | `agent_id` | //! | `update_pricing` | `price_upd` | `(agent_id, new_price)` | -use soroban_sdk::{contracttype, Address, BytesN, Symbol, String}; +use soroban_sdk::{contracttype, Address, BytesN, String, Symbol}; // ─── Legacy structs (kept for ABI compatibility) ────────────────────────────── @@ -396,3 +396,77 @@ pub struct SlaBonusAwardedEvent { /// Reputation boost awarded. pub reputation_boost: u32, } + +// ─── Subscription Event Data Structs (issue #258) ──────────────────────────── + +/// Data payload for `(registry, sub_creat)`. +/// Published when a client subscribes to an agent's service. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SubscriptionCreatedEvent { + /// Subscriber who funds the subscription. + pub client: Address, + /// Agent providing the service. + pub agent_id: Symbol, + /// Recurring payment per billing period, in stroops. + pub payment_amount: i128, + /// Length of one billing period, in seconds. + pub period_secs: u64, + /// Subscription start timestamp. + pub start_time: u64, + /// Timestamp the first paid term ends. + pub end_time: u64, + /// Whether auto-renewal was opted into at creation. + pub auto_renew: bool, +} + +/// Data payload for `(registry, sub_renew)`. +/// Published when a subscription's term is extended by a new payment. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SubscriptionRenewedEvent { + /// Subscriber. + pub client: Address, + /// Agent providing the service. + pub agent_id: Symbol, + /// Payment applied for this renewal, in stroops. + pub payment_amount: i128, + /// The subscription's new end timestamp. + pub new_end_time: u64, + /// Total billing periods paid after this renewal. + pub periods_paid: u32, + /// `true` if triggered by auto-renewal rather than an explicit client call. + pub auto: bool, +} + +/// Data payload for `(registry, sub_canc)`. +/// Published when a client cancels a subscription. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SubscriptionCancelledEvent { + /// Subscriber. + pub client: Address, + /// Agent providing the service. + pub agent_id: Symbol, + /// Prorated refund owed for the unused remainder of the current period. + pub refund_stroops: i128, + /// Cancellation timestamp. + pub cancelled_at: u64, +} + +/// Data payload for `(registry, pay_proc)`. +/// Published for every recurring payment (initial, renewal, or auto-renewal). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PaymentProcessedEvent { + /// Subscriber who paid. + pub client: Address, + /// Agent receiving the payment. + pub agent_id: Symbol, + /// Amount paid, in stroops. + pub amount: i128, + /// 1-based index of the billing period this payment covers. + pub period: u32, + /// Payment timestamp. + pub paid_at: u64, +} diff --git a/smart-contracts/contracts/agent_registry/src/lib.rs b/smart-contracts/contracts/agent_registry/src/lib.rs index 31e80a52..315e12b4 100644 --- a/smart-contracts/contracts/agent_registry/src/lib.rs +++ b/smart-contracts/contracts/agent_registry/src/lib.rs @@ -34,18 +34,22 @@ mod errors; mod events; +mod types; mod upgrade; #[cfg(test)] mod upgrade_tests; +pub use errors::Error; +pub use types::*; pub use upgrade::*; use events::{ - AdminChangedEvent, AgentDeregisteredEvent, AgentRegisteredEvent, ErrorReportedEvent, - ErrorResolvedEvent, OperationApproved, OperationCancelled, OperationExecuted, - OperationProposed, RegistryInitializedEvent, AnalyticsRecordedEvent, - LeaderboardUpdatedEvent, SlaSetEvent, SlaViolationDetectedEvent, SlaBonusAwardedEvent, + AdminChangedEvent, AgentDeregisteredEvent, AgentRegisteredEvent, AnalyticsRecordedEvent, + ErrorReportedEvent, ErrorResolvedEvent, LeaderboardUpdatedEvent, OperationApproved, + OperationCancelled, OperationExecuted, OperationProposed, PaymentProcessedEvent, + RegistryInitializedEvent, SlaBonusAwardedEvent, SlaSetEvent, SlaViolationDetectedEvent, + SubscriptionCancelledEvent, SubscriptionCreatedEvent, SubscriptionRenewedEvent, }; use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Map, String, Symbol, @@ -118,6 +122,11 @@ pub const DEFAULT_PAGE_SIZE: u32 = 20; /// Maximum upper bound on page size to guarantee execution within one ledger footprint budget. pub const MAX_PAGE_SIZE: u32 = 50; +/// Billing period applied when `create_subscription` is called with `0` (30 days). +pub const DEFAULT_SUBSCRIPTION_PERIOD_SECS: u64 = 2_592_000; +/// Minimum accepted subscription billing period (1 hour). +pub const MIN_SUBSCRIPTION_PERIOD_SECS: u64 = 3_600; + // ─── Types ─────────────────────────────────────────────────────────────────── /// Input / stored agent record. @@ -275,6 +284,8 @@ pub enum DataKey { // Pagination keys (issue #339) AgentByIndex(u32), RegistrationSequence, + // Subscription keys (issue #258): one record per (client, agent) pair. + Subscription(Address, Symbol), } /// Per-item outcome for batch registration (`Ok(agent_id)` / `Err(code)`). @@ -505,6 +516,16 @@ fn min_bond(env: &Env) -> i128 { .unwrap_or(DEFAULT_MIN_BOND_STROOPS) } +/// Prorated refund for cancelling `sub` at `now`: the payment for the unused +/// remainder of the current billing period, capped at one full period. +fn prorated_refund(sub: &Subscription, now: u64) -> i128 { + if sub.period_secs == 0 || now >= sub.end_time { + return 0; + } + let window = (sub.end_time - now).min(sub.period_secs); + sub.payment_amount.saturating_mul(window as i128) / (sub.period_secs as i128) +} + #[contractimpl] impl AgentRegistryContract { pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { @@ -1916,19 +1937,19 @@ impl AgentRegistryContract { earnings: i128, ) -> Result<(), Error> { let key = DataKey::AgentAnalytics(agent_id.clone()); - let mut analytics: AgentAnalytics = env - .storage() - .persistent() - .get(&key) - .unwrap_or(AgentAnalytics { - agent_id: agent_id.clone(), - total_tasks: 0, - successful_tasks: 0, - failed_tasks: 0, - total_earnings: 0, - avg_response_time: 0, - last_updated: env.ledger().sequence() as u64, - }); + let mut analytics: AgentAnalytics = + env.storage() + .persistent() + .get(&key) + .unwrap_or(AgentAnalytics { + agent_id: agent_id.clone(), + total_tasks: 0, + successful_tasks: 0, + failed_tasks: 0, + total_earnings: 0, + avg_response_time: 0, + last_updated: env.ledger().sequence() as u64, + }); let old_total = analytics.total_tasks; analytics.total_tasks += 1; @@ -1979,15 +2000,18 @@ impl AgentRegistryContract { /// Get aggregated analytics for an agent. pub fn get_analytics(env: Env, agent_id: Symbol) -> AgentAnalytics { let key = DataKey::AgentAnalytics(agent_id.clone()); - env.storage().persistent().get(&key).unwrap_or(AgentAnalytics { - agent_id, - total_tasks: 0, - successful_tasks: 0, - failed_tasks: 0, - total_earnings: 0, - avg_response_time: 0, - last_updated: 0, - }) + env.storage() + .persistent() + .get(&key) + .unwrap_or(AgentAnalytics { + agent_id, + total_tasks: 0, + successful_tasks: 0, + failed_tasks: 0, + total_earnings: 0, + avg_response_time: 0, + last_updated: 0, + }) } /// Get top N agents by a configurable metric. @@ -2035,8 +2059,7 @@ impl AgentRegistryContract { let mut j = i + 1; let mut max_idx = i; while j < entries.len() { - if entries.get(j).unwrap().metric_value - > entries.get(max_idx).unwrap().metric_value + if entries.get(j).unwrap().metric_value > entries.get(max_idx).unwrap().metric_value { max_idx = j; } @@ -2257,6 +2280,296 @@ impl AgentRegistryContract { Some((sla, compliance)) } + + // ── Agent Subscriptions & Recurring Payments (issue #258) ─────────────── + + /// Subscribe `client` to `agent_id`'s service with a recurring payment. + /// + /// Creating the subscription pays for the first billing period of + /// `period_secs` (`0` → [`DEFAULT_SUBSCRIPTION_PERIOD_SECS`], 30 days). The + /// agent must be registered and no active subscription may already exist for + /// this `(client, agent_id)` pair. `auto_renew` opts the subscription into + /// permissionless auto-renewal at term end. + /// + /// Emits `(registry, sub_creat)` and `(registry, pay_proc)`. + pub fn create_subscription( + env: Env, + client: Address, + agent_id: Symbol, + payment_amount: i128, + period_secs: u64, + auto_renew: bool, + ) -> Result<(), Error> { + client.require_auth(); + require_not_paused(&env)?; + + if payment_amount <= 0 { + return Err(Error::InvalidSubscription); + } + let period = if period_secs == 0 { + DEFAULT_SUBSCRIPTION_PERIOD_SECS + } else { + period_secs + }; + if period < MIN_SUBSCRIPTION_PERIOD_SECS { + return Err(Error::InvalidSubscription); + } + + // The agent being subscribed to must exist in the registry. + if !env + .storage() + .persistent() + .has(&DataKey::Agent(agent_id.clone())) + { + return Err(Error::NotFound); + } + + let now = env.ledger().timestamp(); + let key = DataKey::Subscription(client.clone(), agent_id.clone()); + if let Some(existing) = env.storage().persistent().get::<_, Subscription>(&key) { + if existing.status == SubscriptionStatus::Active && now < existing.end_time { + return Err(Error::SubscriptionAlreadyExists); + } + } + + let end_time = now.saturating_add(period); + let sub = Subscription { + client: client.clone(), + agent_id: agent_id.clone(), + payment_amount, + period_secs: period, + start_time: now, + end_time, + periods_paid: 1, + total_paid: payment_amount, + last_payment_at: now, + auto_renew, + status: SubscriptionStatus::Active, + }; + env.storage().persistent().set(&key, &sub); + extend_ttl_for_key(&env, &key); + + env.events().publish( + (symbol_short!("registry"), symbol_short!("sub_creat")), + SubscriptionCreatedEvent { + client: client.clone(), + agent_id: agent_id.clone(), + payment_amount, + period_secs: period, + start_time: now, + end_time, + auto_renew, + }, + ); + env.events().publish( + (symbol_short!("registry"), symbol_short!("pay_proc")), + PaymentProcessedEvent { + client, + agent_id, + amount: payment_amount, + period: 1, + paid_at: now, + }, + ); + Ok(()) + } + + /// Renew a subscription with another payment, extending its term by one + /// billing period. If the subscription is still within its term the new + /// period is appended to `end_time`; if it has lapsed the new term starts + /// from now. Cancelled subscriptions cannot be renewed. + /// + /// Emits `(registry, pay_proc)` and `(registry, sub_renew)`. + pub fn renew_subscription(env: Env, client: Address, agent_id: Symbol) -> Result<(), Error> { + client.require_auth(); + require_not_paused(&env)?; + + let key = DataKey::Subscription(client.clone(), agent_id.clone()); + let mut sub: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + if sub.status == SubscriptionStatus::Cancelled { + return Err(Error::SubscriptionAlreadyCancelled); + } + + let now = env.ledger().timestamp(); + let base = now.max(sub.end_time); + sub.end_time = base.saturating_add(sub.period_secs); + sub.periods_paid = sub.periods_paid.saturating_add(1); + sub.total_paid = sub.total_paid.saturating_add(sub.payment_amount); + sub.last_payment_at = now; + sub.status = SubscriptionStatus::Active; + env.storage().persistent().set(&key, &sub); + extend_ttl_for_key(&env, &key); + + env.events().publish( + (symbol_short!("registry"), symbol_short!("pay_proc")), + PaymentProcessedEvent { + client: client.clone(), + agent_id: agent_id.clone(), + amount: sub.payment_amount, + period: sub.periods_paid, + paid_at: now, + }, + ); + env.events().publish( + (symbol_short!("registry"), symbol_short!("sub_renew")), + SubscriptionRenewedEvent { + client, + agent_id, + payment_amount: sub.payment_amount, + new_end_time: sub.end_time, + periods_paid: sub.periods_paid, + auto: false, + }, + ); + Ok(()) + } + + /// Cancel an active subscription, returning the prorated refund owed for + /// the unused remainder of the current billing period. + /// + /// Emits `(registry, sub_canc)`. Returns the refund amount in stroops. + pub fn cancel_subscription(env: Env, client: Address, agent_id: Symbol) -> Result { + client.require_auth(); + + let key = DataKey::Subscription(client.clone(), agent_id.clone()); + let mut sub: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + if sub.status == SubscriptionStatus::Cancelled { + return Err(Error::SubscriptionAlreadyCancelled); + } + + let now = env.ledger().timestamp(); + let refund = prorated_refund(&sub, now); + + sub.status = SubscriptionStatus::Cancelled; + sub.end_time = now; + env.storage().persistent().set(&key, &sub); + extend_ttl_for_key(&env, &key); + + env.events().publish( + (symbol_short!("registry"), symbol_short!("sub_canc")), + SubscriptionCancelledEvent { + client, + agent_id, + refund_stroops: refund, + cancelled_at: now, + }, + ); + Ok(refund) + } + + /// Return `true` if the `(client, agent_id)` subscription is active and + /// still within its paid term. + pub fn check_subscription(env: Env, client: Address, agent_id: Symbol) -> bool { + env.storage() + .persistent() + .get::<_, Subscription>(&DataKey::Subscription(client, agent_id)) + .is_some_and(|s| { + s.status == SubscriptionStatus::Active && env.ledger().timestamp() < s.end_time + }) + } + + /// Fetch the full subscription record for `(client, agent_id)`, if any. + pub fn get_subscription(env: Env, client: Address, agent_id: Symbol) -> Option { + env.storage() + .persistent() + .get(&DataKey::Subscription(client, agent_id)) + } + + /// Enable or disable auto-renewal for a subscription (client opt-in). + pub fn set_auto_renew( + env: Env, + client: Address, + agent_id: Symbol, + enabled: bool, + ) -> Result<(), Error> { + client.require_auth(); + + let key = DataKey::Subscription(client.clone(), agent_id.clone()); + let mut sub: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + if sub.status == SubscriptionStatus::Cancelled { + return Err(Error::SubscriptionAlreadyCancelled); + } + sub.auto_renew = enabled; + env.storage().persistent().set(&key, &sub); + extend_ttl_for_key(&env, &key); + Ok(()) + } + + /// Process an auto-renewal for a subscription whose term has elapsed. + /// + /// Permissionless — anyone (typically the agent or a keeper bot) may call + /// it — but it only succeeds when the subscription opted into `auto_renew`, + /// is not cancelled, and its current term has ended. + /// + /// Emits `(registry, pay_proc)` and `(registry, sub_renew)` with `auto = true`. + pub fn process_auto_renewal(env: Env, client: Address, agent_id: Symbol) -> Result<(), Error> { + require_not_paused(&env)?; + + let key = DataKey::Subscription(client.clone(), agent_id.clone()); + let mut sub: Subscription = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::SubscriptionNotFound)?; + + if sub.status == SubscriptionStatus::Cancelled { + return Err(Error::SubscriptionAlreadyCancelled); + } + if !sub.auto_renew { + return Err(Error::InvalidSubscription); + } + + let now = env.ledger().timestamp(); + if now < sub.end_time { + return Err(Error::SubscriptionActive); + } + + let base = now.max(sub.end_time); + sub.end_time = base.saturating_add(sub.period_secs); + sub.periods_paid = sub.periods_paid.saturating_add(1); + sub.total_paid = sub.total_paid.saturating_add(sub.payment_amount); + sub.last_payment_at = now; + sub.status = SubscriptionStatus::Active; + env.storage().persistent().set(&key, &sub); + extend_ttl_for_key(&env, &key); + + env.events().publish( + (symbol_short!("registry"), symbol_short!("pay_proc")), + PaymentProcessedEvent { + client: client.clone(), + agent_id: agent_id.clone(), + amount: sub.payment_amount, + period: sub.periods_paid, + paid_at: now, + }, + ); + env.events().publish( + (symbol_short!("registry"), symbol_short!("sub_renew")), + SubscriptionRenewedEvent { + client, + agent_id, + payment_amount: sub.payment_amount, + new_end_time: sub.end_time, + periods_paid: sub.periods_paid, + auto: true, + }, + ); + Ok(()) + } } fn get_metadata_u32( diff --git a/smart-contracts/contracts/agent_registry/src/test.rs b/smart-contracts/contracts/agent_registry/src/test.rs index f40465b6..02606ab8 100644 --- a/smart-contracts/contracts/agent_registry/src/test.rs +++ b/smart-contracts/contracts/agent_registry/src/test.rs @@ -1722,9 +1722,15 @@ fn get_leaderboard_by_total_tasks() { let leaderboard = client.get_leaderboard(&metric, &3); assert_eq!(leaderboard.len(), 3); - assert_eq!(leaderboard.get(0).unwrap().agent_id, Symbol::new(&env, "a3")); + assert_eq!( + leaderboard.get(0).unwrap().agent_id, + Symbol::new(&env, "a3") + ); assert_eq!(leaderboard.get(0).unwrap().metric_value, 3); - assert_eq!(leaderboard.get(1).unwrap().agent_id, Symbol::new(&env, "a1")); + assert_eq!( + leaderboard.get(1).unwrap().agent_id, + Symbol::new(&env, "a1") + ); assert_eq!(leaderboard.get(1).unwrap().metric_value, 2); } @@ -1752,12 +1758,7 @@ fn set_sla_success() { let owner = Address::generate(&env); client.register_agent(&make_record(&env, "sla_agent", "research", owner)); - client.set_sla( - &Symbol::new(&env, "sla_agent"), - &200, - &95, - &80, - ); + client.set_sla(&Symbol::new(&env, "sla_agent"), &200, &95, &80); let sla = client.get_sla_status(&Symbol::new(&env, "sla_agent")); assert!(sla.is_some()); @@ -1832,7 +1833,9 @@ fn check_sla_compliance_pass() { ); assert!(compliant); - let sla = client.get_sla_status(&Symbol::new(&env, "sla_agent")).unwrap(); + let sla = client + .get_sla_status(&Symbol::new(&env, "sla_agent")) + .unwrap(); assert_eq!(sla.0.total_checks, 1); assert_eq!(sla.0.violations, 0); assert_eq!(sla.1, 100); @@ -1854,7 +1857,9 @@ fn check_sla_compliance_violation() { ); assert!(!compliant); - let sla = client.get_sla_status(&Symbol::new(&env, "sla_agent")).unwrap(); + let sla = client + .get_sla_status(&Symbol::new(&env, "sla_agent")) + .unwrap(); assert_eq!(sla.0.violations, 1); assert_eq!(sla.1, 0); // 0% compliance with 1 check and 1 violation } @@ -1869,8 +1874,8 @@ fn check_sla_compliance_uptime_violation() { let compliant = client.check_sla_compliance( &Symbol::new(&env, "sla_agent"), - &100, // ok - &80, // uptime < 95 - violation! + &100, // ok + &80, // uptime < 95 - violation! &90, ); assert!(!compliant); @@ -1923,15 +1928,12 @@ fn sla_bonus_awarded_after_consistent_compliance() { // Record 10 compliant checks for _ in 0..10 { - client.check_sla_compliance( - &Symbol::new(&env, "sla_agent"), - &100, - &99, - &95, - ); + client.check_sla_compliance(&Symbol::new(&env, "sla_agent"), &100, &99, &95); } - let sla = client.get_sla_status(&Symbol::new(&env, "sla_agent")).unwrap(); + let sla = client + .get_sla_status(&Symbol::new(&env, "sla_agent")) + .unwrap(); assert_eq!(sla.0.total_checks, 10); assert_eq!(sla.0.violations, 0); assert_eq!(sla.1, 100); // 100% compliance @@ -2018,25 +2020,46 @@ fn test_get_agents_cursor_pagination() { assert_eq!(page1.agents.len(), 3); assert_eq!(page1.total_count, 7); assert_eq!(page1.next_cursor, Some(3)); - assert_eq!(page1.agents.get(0).unwrap().id, Symbol::new(&env, "agent_1")); - assert_eq!(page1.agents.get(1).unwrap().id, Symbol::new(&env, "agent_2")); - assert_eq!(page1.agents.get(2).unwrap().id, Symbol::new(&env, "agent_3")); + assert_eq!( + page1.agents.get(0).unwrap().id, + Symbol::new(&env, "agent_1") + ); + assert_eq!( + page1.agents.get(1).unwrap().id, + Symbol::new(&env, "agent_2") + ); + assert_eq!( + page1.agents.get(2).unwrap().id, + Symbol::new(&env, "agent_3") + ); // Page 2: cursor 3, limit 3 let page2 = client.get_agents(&page1.next_cursor, &Some(3)); assert_eq!(page2.agents.len(), 3); assert_eq!(page2.total_count, 7); assert_eq!(page2.next_cursor, Some(6)); - assert_eq!(page2.agents.get(0).unwrap().id, Symbol::new(&env, "agent_4")); - assert_eq!(page2.agents.get(1).unwrap().id, Symbol::new(&env, "agent_5")); - assert_eq!(page2.agents.get(2).unwrap().id, Symbol::new(&env, "agent_6")); + assert_eq!( + page2.agents.get(0).unwrap().id, + Symbol::new(&env, "agent_4") + ); + assert_eq!( + page2.agents.get(1).unwrap().id, + Symbol::new(&env, "agent_5") + ); + assert_eq!( + page2.agents.get(2).unwrap().id, + Symbol::new(&env, "agent_6") + ); // Page 3: cursor 6, limit 3 -> last remaining agent let page3 = client.get_agents(&page2.next_cursor, &Some(3)); assert_eq!(page3.agents.len(), 1); assert_eq!(page3.total_count, 7); assert_eq!(page3.next_cursor, None); - assert_eq!(page3.agents.get(0).unwrap().id, Symbol::new(&env, "agent_7")); + assert_eq!( + page3.agents.get(0).unwrap().id, + Symbol::new(&env, "agent_7") + ); } #[test] @@ -2086,3 +2109,305 @@ fn test_get_agents_batch_registered_pagination() { assert_eq!(page2.total_count, 4); } +// ─── Agent Subscriptions & Recurring Payments (issue #258) ──────────────────── + +/// Register an agent and return its id symbol, for subscription tests. +fn setup_agent(env: &Env, client: &AgentRegistryContractClient<'static>, id: &str) -> Symbol { + let owner = Address::generate(env); + client.register_agent(&make_record(env, id, "research", owner)); + Symbol::new(env, id) +} + +const DAY: u64 = 86_400; + +#[test] +fn create_subscription_stores_record() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a1"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(30 * DAY), &false); + + let sub = client.get_subscription(&sub_client, &agent).unwrap(); + assert_eq!(sub.client, sub_client); + assert_eq!(sub.agent_id, agent); + assert_eq!(sub.payment_amount, 1_000_000); + assert_eq!(sub.period_secs, 30 * DAY); + assert_eq!(sub.periods_paid, 1); + assert_eq!(sub.total_paid, 1_000_000); + assert_eq!(sub.status, SubscriptionStatus::Active); + assert_eq!(sub.end_time, sub.start_time + 30 * DAY); + assert!(!sub.auto_renew); + assert!(client.check_subscription(&sub_client, &agent)); +} + +#[test] +fn create_subscription_defaults_period_when_zero() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a2"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &500_000, &0, &false); + let sub = client.get_subscription(&sub_client, &agent).unwrap(); + assert_eq!(sub.period_secs, DEFAULT_SUBSCRIPTION_PERIOD_SECS); +} + +#[test] +fn create_subscription_unknown_agent_fails() { + let (env, client) = setup(); + let sub_client = Address::generate(&env); + let ghost = Symbol::new(&env, "ghost"); + let err = client.try_create_subscription(&sub_client, &ghost, &1_000_000, &(30 * DAY), &false); + assert_eq!(err.err(), Some(Ok(Error::NotFound))); +} + +#[test] +fn create_subscription_rejects_bad_params() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a3"); + let sub_client = Address::generate(&env); + + let e1 = client.try_create_subscription(&sub_client, &agent, &0, &(30 * DAY), &false); + assert_eq!(e1.err(), Some(Ok(Error::InvalidSubscription))); + + let e2 = client.try_create_subscription(&sub_client, &agent, &1_000, &60, &false); + assert_eq!(e2.err(), Some(Ok(Error::InvalidSubscription))); +} + +#[test] +fn create_subscription_duplicate_active_fails() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a4"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(30 * DAY), &false); + let err = client.try_create_subscription(&sub_client, &agent, &1_000_000, &(30 * DAY), &false); + assert_eq!(err.err(), Some(Ok(Error::SubscriptionAlreadyExists))); +} + +#[test] +fn check_subscription_expires_with_time() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a5"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &false); + assert!(client.check_subscription(&sub_client, &agent)); + + env.ledger() + .set_timestamp(env.ledger().timestamp() + 7 * DAY + 1); + assert!(!client.check_subscription(&sub_client, &agent)); +} + +#[test] +fn renew_subscription_extends_term() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a6"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(30 * DAY), &false); + let before = client.get_subscription(&sub_client, &agent).unwrap(); + + client.renew_subscription(&sub_client, &agent); + let after = client.get_subscription(&sub_client, &agent).unwrap(); + + assert_eq!(after.end_time, before.end_time + 30 * DAY); + assert_eq!(after.periods_paid, 2); + assert_eq!(after.total_paid, 2_000_000); +} + +#[test] +fn renew_after_expiry_starts_from_now() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a7"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &false); + let t = env.ledger().timestamp() + 30 * DAY; + env.ledger().set_timestamp(t); + + client.renew_subscription(&sub_client, &agent); + let sub = client.get_subscription(&sub_client, &agent).unwrap(); + assert_eq!(sub.end_time, t + 7 * DAY); + assert!(client.check_subscription(&sub_client, &agent)); +} + +#[test] +fn cancel_subscription_prorated_refund() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a8"); + let sub_client = Address::generate(&env); + + // 10-day period, 1_000_000 stroops. + client.create_subscription(&sub_client, &agent, &1_000_000, &(10 * DAY), &false); + // Consume 4 of 10 days → 6 days unused → 60% refund. + env.ledger() + .set_timestamp(env.ledger().timestamp() + 4 * DAY); + + let refund = client.cancel_subscription(&sub_client, &agent); + assert_eq!(refund, 600_000); + + let sub = client.get_subscription(&sub_client, &agent).unwrap(); + assert_eq!(sub.status, SubscriptionStatus::Cancelled); + assert!(!client.check_subscription(&sub_client, &agent)); +} + +#[test] +fn cancel_after_expiry_zero_refund() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a9"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(10 * DAY), &false); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 20 * DAY); + + let refund = client.cancel_subscription(&sub_client, &agent); + assert_eq!(refund, 0); +} + +#[test] +fn cancel_twice_fails() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a10"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(10 * DAY), &false); + client.cancel_subscription(&sub_client, &agent); + let err = client.try_cancel_subscription(&sub_client, &agent); + assert_eq!(err.err(), Some(Ok(Error::SubscriptionAlreadyCancelled))); +} + +#[test] +fn renew_cancelled_subscription_fails() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a11"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(10 * DAY), &false); + client.cancel_subscription(&sub_client, &agent); + let err = client.try_renew_subscription(&sub_client, &agent); + assert_eq!(err.err(), Some(Ok(Error::SubscriptionAlreadyCancelled))); +} + +#[test] +fn cancel_unknown_subscription_fails() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a12"); + let sub_client = Address::generate(&env); + let err = client.try_cancel_subscription(&sub_client, &agent); + assert_eq!(err.err(), Some(Ok(Error::SubscriptionNotFound))); +} + +#[test] +fn auto_renewal_opt_in_extends_term() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a13"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &true); + let before = client.get_subscription(&sub_client, &agent).unwrap(); + + env.ledger().set_timestamp(before.end_time + 1); + client.process_auto_renewal(&sub_client, &agent); + + let after = client.get_subscription(&sub_client, &agent).unwrap(); + assert_eq!(after.periods_paid, 2); + assert_eq!(after.total_paid, 2_000_000); + assert!(after.end_time > before.end_time); + assert!(client.check_subscription(&sub_client, &agent)); +} + +#[test] +fn auto_renewal_requires_opt_in() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a14"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &false); + env.ledger() + .set_timestamp(env.ledger().timestamp() + 7 * DAY + 1); + + let err = client.try_process_auto_renewal(&sub_client, &agent); + assert_eq!(err.err(), Some(Ok(Error::InvalidSubscription))); +} + +#[test] +fn auto_renewal_before_term_end_fails() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a15"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &true); + let err = client.try_process_auto_renewal(&sub_client, &agent); + assert_eq!(err.err(), Some(Ok(Error::SubscriptionActive))); +} + +#[test] +fn set_auto_renew_toggles_flag() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a16"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &false); + client.set_auto_renew(&sub_client, &agent, &true); + assert!( + client + .get_subscription(&sub_client, &agent) + .unwrap() + .auto_renew + ); + + client.set_auto_renew(&sub_client, &agent, &false); + assert!( + !client + .get_subscription(&sub_client, &agent) + .unwrap() + .auto_renew + ); +} + +#[test] +fn subscription_lifecycle_emits_events() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a17"); + let sub_client = Address::generate(&env); + + let _ = env.events().all(); + client.create_subscription(&sub_client, &agent, &1_000_000, &(7 * DAY), &false); + assert!( + env.events().all().len() >= 2, + "expected created + payment events" + ); + + let _ = env.events().all(); + client.renew_subscription(&sub_client, &agent); + assert!( + env.events().all().len() >= 2, + "expected payment + renewed events" + ); + + let _ = env.events().all(); + client.cancel_subscription(&sub_client, &agent); + assert!(!env.events().all().is_empty(), "expected cancelled event"); +} + +#[test] +fn full_subscription_flow_recreate_after_cancel() { + let (env, client) = setup(); + let agent = setup_agent(&env, &client, "sub_a18"); + let sub_client = Address::generate(&env); + + client.create_subscription(&sub_client, &agent, &2_000_000, &(30 * DAY), &true); + client.renew_subscription(&sub_client, &agent); + let refund = client.cancel_subscription(&sub_client, &agent); + assert!(refund >= 0); + assert!(!client.check_subscription(&sub_client, &agent)); + + // A fresh subscription can be created once the previous one is cancelled. + client.create_subscription(&sub_client, &agent, &1_000_000, &(30 * DAY), &false); + let sub = client.get_subscription(&sub_client, &agent).unwrap(); + assert_eq!(sub.status, SubscriptionStatus::Active); + assert_eq!(sub.periods_paid, 1); + assert_eq!(sub.payment_amount, 1_000_000); +} diff --git a/smart-contracts/contracts/agent_registry/src/types.rs b/smart-contracts/contracts/agent_registry/src/types.rs index d296e81b..ee2ba1de 100644 --- a/smart-contracts/contracts/agent_registry/src/types.rs +++ b/smart-contracts/contracts/agent_registry/src/types.rs @@ -190,3 +190,51 @@ pub struct DiscoveryStats { /// Number of queries served from in-memory / temporary storage cache. pub cache_hits: u64, } + +// ─── Agent Subscriptions (issue #258) ──────────────────────────────────────── + +/// Lifecycle state of an agent-service subscription. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum SubscriptionStatus { + /// Subscription is paid; it provides service while `now < end_time`. + Active = 0, + /// The client cancelled the subscription before its term ended. + Cancelled = 1, + /// The term elapsed without renewal. + Expired = 2, +} + +/// On-chain subscription letting a client pay an agent for recurring service. +/// +/// Billing happens in fixed windows of `period_secs`. Creating the subscription +/// pays for the first window; `renew_subscription` (or opt-in auto-renewal) +/// pays for and appends another. The subscription is "active" while its +/// `status` is [`SubscriptionStatus::Active`] and `now < end_time`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Subscription { + /// The subscriber who funds the recurring payments. + pub client: Address, + /// The agent providing the subscribed service. + pub agent_id: Symbol, + /// Payment charged for each billing period, in stroops. + pub payment_amount: i128, + /// Length of one billing period, in ledger-seconds. + pub period_secs: u64, + /// Timestamp at which the subscription was first created. + pub start_time: u64, + /// Timestamp at which the currently-paid term ends. + pub end_time: u64, + /// Number of billing periods paid for over the subscription's lifetime. + pub periods_paid: u32, + /// Cumulative stroops paid into the subscription. + pub total_paid: i128, + /// Timestamp of the most recent processed payment. + pub last_payment_at: u64, + /// Whether the subscription auto-renews at term end (opt-in). + pub auto_renew: bool, + /// Current lifecycle state. + pub status: SubscriptionStatus, +}