From ed7a6417be091b29b23df5bf8fdee2ddcce68713 Mon Sep 17 00:00:00 2001 From: Caleb Peterson Date: Tue, 24 Mar 2026 19:00:47 +0100 Subject: [PATCH 1/2] feat: add subscription pausing by subscriber with duration limits and auto-resume --- contracts/src/lib.rs | 115 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 0d20c07..5762466 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -12,6 +12,8 @@ pub enum Interval { Yearly, // 31536000s (365 days) } +const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days + impl Interval { pub fn seconds(&self) -> u64 { match self { @@ -59,6 +61,8 @@ pub struct Subscription { pub last_charged_at: u64, pub next_charge_at: u64, pub total_paid: i128, + pub paused_at: u64, + pub pause_duration: u64, } #[contracttype] @@ -209,6 +213,8 @@ impl SubTrackrContract { last_charged_at: now, next_charge_at: now + plan.interval.seconds(), total_paid: 0, + paused_at: 0, + pause_duration: 0, }; env.storage() @@ -280,6 +286,16 @@ impl SubTrackrContract { /// User pauses their subscription pub fn pause_subscription(env: Env, subscriber: Address, subscription_id: u64) { + Self::pause_by_subscriber(env, subscriber, subscription_id, MAX_PAUSE_DURATION); + } + + /// User pauses their subscription with a specific duration + pub fn pause_by_subscriber( + env: Env, + subscriber: Address, + subscription_id: u64, + duration: u64, + ) { subscriber.require_auth(); let mut sub: Subscription = env @@ -293,12 +309,24 @@ impl SubTrackrContract { sub.status == SubscriptionStatus::Active, "Only active subscriptions can be paused" ); + assert!( + duration <= MAX_PAUSE_DURATION, + "Pause duration exceeds limit" + ); sub.status = SubscriptionStatus::Paused; + sub.paused_at = env.ledger().timestamp(); + sub.pause_duration = duration; env.storage() .persistent() .set(&DataKey::Subscription(subscription_id), &sub); + + // Publish event + env.events().publish( + (String::from_str(&env, "subscription_paused"), subscriber), + (subscription_id, sub.paused_at, duration), + ); } /// User resumes a paused subscription @@ -313,7 +341,8 @@ impl SubTrackrContract { assert!(sub.subscriber == subscriber, "Only subscriber can resume"); assert!( - sub.status == SubscriptionStatus::Paused, + sub.status == SubscriptionStatus::Paused + || Self::check_and_resume_internal(&env, &mut sub), "Only paused subscriptions can be resumed" ); @@ -326,10 +355,18 @@ impl SubTrackrContract { sub.status = SubscriptionStatus::Active; sub.next_charge_at = now + plan.interval.seconds(); + sub.paused_at = 0; + sub.pause_duration = 0; env.storage() .persistent() .set(&DataKey::Subscription(subscription_id), &sub); + + // Publish event + env.events().publish( + (String::from_str(&env, "subscription_resumed"), subscriber), + subscription_id, + ); } // ── Payment Processing ── @@ -342,6 +379,13 @@ impl SubTrackrContract { .get(&DataKey::Subscription(subscription_id)) .expect("Subscription not found"); + // Handle auto-resume if needed + if Self::check_and_resume_internal(&env, &mut sub) { + env.storage() + .persistent() + .set(&DataKey::Subscription(subscription_id), &sub); + } + assert!( sub.status == SubscriptionStatus::Active, "Subscription not active" @@ -382,10 +426,14 @@ impl SubTrackrContract { /// Get subscription details pub fn get_subscription(env: Env, subscription_id: u64) -> Subscription { - env.storage() + let mut sub: Subscription = env + .storage() .persistent() .get(&DataKey::Subscription(subscription_id)) - .expect("Subscription not found") + .expect("Subscription not found"); + + Self::check_and_resume_internal(&env, &mut sub); + sub } /// Get all subscription IDs for a user @@ -419,6 +467,21 @@ impl SubTrackrContract { .get(&DataKey::SubscriptionCount) .unwrap_or(0) } + + // ── Internal Helpers ── + + fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool { + if sub.status == SubscriptionStatus::Paused { + let now = env.ledger().timestamp(); + if now >= sub.paused_at + sub.pause_duration { + sub.status = SubscriptionStatus::Active; + sub.paused_at = 0; + sub.pause_duration = 0; + return true; + } + } + false + } } #[cfg(test)] @@ -453,7 +516,7 @@ mod test { fn test_create_plan_and_subscribe() { let env = Env::default(); let contract_id = env.register_contract(None, SubTrackrContract); - let client = SubTrackrContract::new(&env, &contract_id); + let client = SubTrackrContractClient::new(&env, &contract_id); let admin = Address::generate(&env); let merchant = Address::generate(&env); @@ -593,4 +656,48 @@ mod test { assert_eq!(resumed.next_charge_at, env.ledger().timestamp() + Interval::Monthly.seconds()); assert!(resumed.next_charge_at > initial.next_charge_at); } + + #[test] + #[should_panic(expected = "Pause duration exceeds limit")] + fn test_pause_by_subscriber_limit_enforced() { + let env = Env::default(); + let (client, _admin, _merchant, subscriber, _token) = setup(&env); + let sub_id = client.subscribe(&subscriber, &1); + + // Max is 30 days (2,592_000s). Try 31 days. + client.pause_by_subscriber(&subscriber, &sub_id, &2_678_400); + } + + #[test] + fn test_auto_resume() { + let env = Env::default(); + let (client, _admin, _merchant, subscriber, _token) = setup(&env); + let sub_id = client.subscribe(&subscriber, &1); + + // Pause for 1 day (86,400s) + client.pause_by_subscriber(&subscriber, &sub_id, &86_400); + let paused = client.get_subscription(&sub_id); + assert_eq!(paused.status, SubscriptionStatus::Paused); + + // Fast forward 2 days (172,800s) + env.ledger().with_mut(|li| { + li.timestamp += 172_800; + }); + + // get_subscription should now return Active due to auto-resume + let resumed = client.get_subscription(&sub_id); + assert_eq!(resumed.status, SubscriptionStatus::Active); + assert_eq!(resumed.paused_at, 0); + assert_eq!(resumed.pause_duration, 0); + + // charge_subscription should also work now + // But we need to make sure next_charge_at is reached + env.ledger().with_mut(|li| { + li.timestamp += Interval::Monthly.seconds(); + }); + client.charge_subscription(&sub_id); + + let charged = client.get_subscription(&sub_id); + assert_eq!(charged.total_paid, 500); + } } From efe98cbb5f65603db697aaf8897b41ae42e71e93 Mon Sep 17 00:00:00 2001 From: Caleb Peterson Date: Tue, 24 Mar 2026 19:11:31 +0100 Subject: [PATCH 2/2] feat: add contract deployment scripts and guide --- contracts/DEPLOYMENT.md | 72 +++++++++++++++++++++++++++++++++++++++ scripts/deploy-local.sh | 48 ++++++++++++++++++++++++++ scripts/deploy-mainnet.sh | 58 +++++++++++++++++++++++++++++++ scripts/deploy-testnet.sh | 51 +++++++++++++++++++++++++++ scripts/utils.sh | 41 ++++++++++++++++++++++ scripts/verify.sh | 35 +++++++++++++++++++ 6 files changed, 305 insertions(+) create mode 100644 contracts/DEPLOYMENT.md create mode 100755 scripts/deploy-local.sh create mode 100755 scripts/deploy-mainnet.sh create mode 100755 scripts/deploy-testnet.sh create mode 100755 scripts/utils.sh create mode 100755 scripts/verify.sh diff --git a/contracts/DEPLOYMENT.md b/contracts/DEPLOYMENT.md new file mode 100644 index 0000000..21d8947 --- /dev/null +++ b/contracts/DEPLOYMENT.md @@ -0,0 +1,72 @@ +# SubTrackr Contract Deployment Guide + +This guide describes how to deploy SubTrackr smart contracts to various Stellar networks using the provided automation scripts. + +## Prerequisites + +- [Soroban CLI](https://developers.stellar.org/docs/smart-contracts/getting-started/setup#install-the-soroban-cli) installed. +- [Rust](https://rustup.rs/) and `wasm32-unknown-unknown` target installed. +- A Stellar account with enough XLM for the target network. + +## Deployment Scripts + +All scripts are located in the `scripts/` directory at the project root. + +### 1. Local Deployment + +For development and testing on a local Soroban network. + +```bash +./scripts/deploy-local.sh +``` + +**Note**: Assumes a local network is running and an identity `alice` exists. + +### 2. Testnet Deployment + +For deploying to the Stellar Testnet. + +```bash +export SOROBAN_ACCOUNT="your-testnet-account-name" +export ADMIN_ADDRESS="GB..." +./scripts/deploy-testnet.sh +``` + +### 3. Mainnet Deployment + +For deploying to the Stellar Public network (Mainnet). + +```bash +export SOROBAN_ACCOUNT="your-mainnet-account-name" +export ADMIN_ADDRESS="GD..." +./scripts/deploy-mainnet.sh +``` + +**⚠️ WARNING**: Mainnet deployment costs real XLM. Ensure you have sufficient funds and have reviewed the contract code. + +## Environment Variables + +| Variable | Description | Required For | +|---|---|---| +| `SOROBAN_ACCOUNT` | The identity name (configured in Soroban CLI) or secret key to use for deployment. | Testnet, Mainnet | +| `ADMIN_ADDRESS` | The Stellar address that will be set as the contract admin during initialization. | Testnet, Mainnet | + +## Verification + +After deployment, you can verify that the contract is active by running: + +```bash +./scripts/verify.sh +``` + +Replace `` with the ID returned by the deployment script and `` with `local`, `testnet`, or `public`. + +## Rollback Procedure + +Since smart contracts on Soroban are immutable (unless explicitly designed otherwise), a "rollback" typically involves: + +1. Fixing the issue in the contract source code. +2. Deploying a new version of the contract. +3. Updating the application (frontend/backend) to use the new `CONTRACT_ID`. + +Ensure you keep track of the `CONTRACT_ID` for each deployment (these are automatically saved to `contracts/.env.`). diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh new file mode 100755 index 0000000..abc3ed0 --- /dev/null +++ b/scripts/deploy-local.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# SubTrackr Local Deployment Script +# Deploys smart contracts to a local Soroban network + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +print_status "🚀 Starting local deployment..." + +# Check prerequisites +check_command "soroban" +check_command "cargo" + +# Build and optimize contract +print_status "Building and optimizing contract..." +cd contracts +cargo build --target wasm32-unknown-unknown --release +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm + +# Deploy to local network +# Assumes a local network is running and an identity 'alice' exists +print_status "Deploying to local network..." +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm \ + --source alice \ + --network local) + +print_success "Contract deployed successfully! ID: $CONTRACT_ID" + +# Initialize contract +# Use alice as admin for local testing +print_status "Initializing contract..." +soroban contract invoke \ + --id "$CONTRACT_ID" \ + --source alice \ + --network local \ + -- initialize \ + --admin alice + +print_success "Contract initialized successfully!" +echo "CONTRACT_ID=$CONTRACT_ID" > .env.local +print_status "Contract ID saved to contracts/.env.local" + +cd .. +print_success "🎉 Local deployment complete!" diff --git a/scripts/deploy-mainnet.sh b/scripts/deploy-mainnet.sh new file mode 100755 index 0000000..606adee --- /dev/null +++ b/scripts/deploy-mainnet.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# SubTrackr Mainnet Deployment Script +# Deploys smart contracts to the Stellar Public network + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +print_warning "⚠️ WARNING: You are about to deploy to the Stellar Public Mainnet!" +print_warning "Ensure that your account has enough XLM for transaction fees and minimum balance." +echo "" + +# Validate required environment variables +validate_env "SOROBAN_ACCOUNT" +validate_env "ADMIN_ADDRESS" + +read -p "Are you sure you want to proceed? (y/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_status "Deployment cancelled." + exit 0 +fi + +# Check prerequisites +check_command "soroban" +check_command "cargo" + +print_status "Build and optimize contract..." +cd contracts +cargo build --target wasm32-unknown-unknown --release +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm + +# Deploy to Mainnet +print_status "Deploying to Mainnet using account: $SOROBAN_ACCOUNT" +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm \ + --source "$SOROBAN_ACCOUNT" \ + --network public) + +print_success "Contract deployed successfully! ID: $CONTRACT_ID" + +# Initialize contract +print_status "Initializing contract with admin: $ADMIN_ADDRESS" +soroban contract invoke \ + --id "$CONTRACT_ID" \ + --source "$SOROBAN_ACCOUNT" \ + --network public \ + -- initialize \ + --admin "$ADMIN_ADDRESS" + +print_success "Contract initialized successfully!" +echo "CONTRACT_ID=$CONTRACT_ID" > .env.public +print_status "Contract ID saved to contracts/.env.public" + +cd .. +print_success "🎉 Mainnet deployment complete!" diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh new file mode 100755 index 0000000..17a64bf --- /dev/null +++ b/scripts/deploy-testnet.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# SubTrackr Testnet Deployment Script +# Deploys smart contracts to the Stellar Testnet + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +print_status "🚀 Starting testnet deployment..." + +# Check prerequisites +check_command "soroban" +check_command "cargo" + +# Validate required environment variables +# SOROBAN_ACCOUNT: The identity name or secret key to use for deployment +# ADMIN_ADDRESS: The address to initialize the contract with as admin +validate_env "SOROBAN_ACCOUNT" +validate_env "ADMIN_ADDRESS" + +print_status "Build and optimize contract..." +cd contracts +cargo build --target wasm32-unknown-unknown --release +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm + +# Deploy to Testnet +print_status "Deploying to Testnet using account: $SOROBAN_ACCOUNT" +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm \ + --source "$SOROBAN_ACCOUNT" \ + --network testnet) + +print_success "Contract deployed successfully! ID: $CONTRACT_ID" + +# Initialize contract +print_status "Initializing contract with admin: $ADMIN_ADDRESS" +soroban contract invoke \ + --id "$CONTRACT_ID" \ + --source "$SOROBAN_ACCOUNT" \ + --network testnet \ + -- initialize \ + --admin "$ADMIN_ADDRESS" + +print_success "Contract initialized successfully!" +echo "CONTRACT_ID=$CONTRACT_ID" > .env.testnet +print_status "Contract ID saved to contracts/.env.testnet" + +cd .. +print_success "🎉 Testnet deployment complete!" diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100755 index 0000000..9f419c1 --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Shared utility functions for SubTrackr deployment scripts + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +check_command() { + if ! command -v $1 &> /dev/null; then + print_error "$1 is not installed. Please install it to proceed." + exit 1 + fi +} + +validate_env() { + local var_name=$1 + if [ -z "${!var_name}" ]; then + print_error "Environment variable $var_name is not set." + exit 1 + fi +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..3ac3954 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# SubTrackr Contract Verification Script +# Verifies a deployed contract by running simple queries + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +# Usage: ./verify.sh +if [ -z "$1" ] || [ -z "$2" ]; then + print_error "Usage: ./scripts/verify.sh " + exit 1 +fi + +CONTRACT_ID=$1 +NETWORK=$2 + +print_status "🔍 Verifying contract: $CONTRACT_ID on network: $NETWORK" + +# Check if contract is alive by querying the plan count +print_status "Querying plan count..." +PLAN_COUNT=$(soroban contract invoke \ + --id "$CONTRACT_ID" \ + --network "$NETWORK" \ + --source alice \ + -- get_plan_count) + +if [ $? -eq 0 ]; then + print_success "Contract verification successful! Plan count: $PLAN_COUNT" +else + print_error "Contract verification failed. Could not query plan count." + exit 1 +fi