diff --git a/backend/scripts/009_create_event_tables.sql b/backend/scripts/009_create_event_tables.sql index a3a1eda..44e59db 100644 --- a/backend/scripts/009_create_event_tables.sql +++ b/backend/scripts/009_create_event_tables.sql @@ -41,6 +41,9 @@ CREATE INDEX idx_renewal_approvals_sub_id ON renewal_approvals(blockchain_sub_id -- Add blockchain_sub_id to subscriptions if not exists ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS blockchain_sub_id BIGINT, -ADD COLUMN IF NOT EXISTS failure_count INTEGER DEFAULT 0; +ADD COLUMN IF NOT EXISTS failure_count INTEGER DEFAULT 0, +ADD COLUMN IF NOT EXISTS executor_address VARCHAR(56), +ADD COLUMN IF NOT EXISTS billing_start_timestamp TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS billing_end_timestamp TIMESTAMP WITH TIME ZONE; CREATE INDEX IF NOT EXISTS idx_subscriptions_blockchain_sub_id ON subscriptions(blockchain_sub_id); diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts index a5fae88..1e61d0b 100644 --- a/backend/src/routes/subscriptions.ts +++ b/backend/src/routes/subscriptions.ts @@ -1,12 +1,3 @@ -<<<<<<< HEAD -import { Router, Response } from 'express'; -import { subscriptionService } from '../services/subscription-service'; -import { giftCardService } from '../services/gift-card-service'; -import { idempotencyService } from '../services/idempotency'; -import { authenticate, AuthenticatedRequest } from '../middleware/auth'; -import { validateSubscriptionOwnership, validateBulkSubscriptionOwnership } from '../middleware/ownership'; -import logger from '../config/logger'; -======= import { Router, Response } from "express"; import { subscriptionService } from "../services/subscription-service"; import { idempotencyService } from "../services/idempotency"; @@ -16,7 +7,6 @@ import { validateBulkSubscriptionOwnership, } from "../middleware/ownership"; import logger from "../config/logger"; ->>>>>>> main const router = Router(); @@ -195,7 +185,7 @@ router.patch("/:id", validateSubscriptionOwnership, async (req: AuthenticatedReq const result = await subscriptionService.updateSubscription( req.user!.id, - req.params.id, + Array.isArray(req.params.id) ? req.params.id[0] : req.params.id, req.body, expectedVersion ? parseInt(expectedVersion) : undefined, ); diff --git a/backend/src/services/subscription-service.ts b/backend/src/services/subscription-service.ts index ccaa517..aeb3c46 100644 --- a/backend/src/services/subscription-service.ts +++ b/backend/src/services/subscription-service.ts @@ -25,6 +25,7 @@ export class SubscriptionService { async createSubscription( userId: string, input: SubscriptionCreateInput, + idempotencyKey?: string ): Promise { return await DatabaseTransaction.execute(async (client) => { try { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 7ae5ee7..78d1c0a 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -23,6 +23,7 @@ "include": [ "src/**/*"], "exclude": [ "node_modules", - "dist" + "dist", + "src/services/blockchain-service-impl-example.ts" ] } \ No newline at end of file diff --git a/contracts/DELEGATED_EXECUTION.md b/contracts/DELEGATED_EXECUTION.md new file mode 100644 index 0000000..cce38f8 --- /dev/null +++ b/contracts/DELEGATED_EXECUTION.md @@ -0,0 +1,49 @@ +# Delegated Execution Feature + +## Overview +Users can now assign another address to execute renewals without transferring ownership. + +## Contract Changes + +### New Storage +- `ExecutorKey` - Storage key for executor addresses per subscription + +### New Functions +- `set_executor(sub_id, executor)` - Assign executor (owner only) +- `remove_executor(sub_id)` - Remove executor (owner only) +- `get_executor(sub_id)` - Query current executor + +### Updated Functions +- `renew()` - Now accepts `caller` parameter and verifies caller is owner OR executor + +### New Events +- `ExecutorAssigned { sub_id, executor }` - Emitted when executor is assigned +- `ExecutorRemoved { sub_id }` - Emitted when executor is removed + +## Backend Changes + +### Database +- Added `executor_address` column to `subscriptions` table + +### Event Listener +- Handles `ExecutorAssigned` events → Updates `executor_address` in DB +- Handles `ExecutorRemoved` events → Clears `executor_address` in DB + +## Usage Example + +```rust +// Owner assigns executor +contract.set_executor(env, sub_id, executor_address); + +// Executor can now call renew +contract.renew(env, executor_address, sub_id, approval_id, amount, max_retries, cooldown, true); + +// Owner can remove executor +contract.remove_executor(env, sub_id); +``` + +## Security +- Only owner can assign/remove executors +- Executor cannot transfer ownership +- Executor can only execute renewals (with valid approvals) +- Owner retains full control diff --git a/contracts/RENEWAL_WINDOW.md b/contracts/RENEWAL_WINDOW.md new file mode 100644 index 0000000..a19d3e4 --- /dev/null +++ b/contracts/RENEWAL_WINDOW.md @@ -0,0 +1,52 @@ +# Renewal Time Window Feature + +## Overview +Restricts renewal execution to a defined time window to prevent renewals from executing too early or too late relative to the billing schedule. + +## Contract Changes + +### New Storage +- `WindowKey` - Storage key for renewal windows per subscription +- `RenewalWindow` - Struct containing `billing_start` and `billing_end` timestamps + +### New Functions +- `set_window(sub_id, billing_start, billing_end)` - Set renewal window (owner only) +- `get_window(sub_id)` - Query current renewal window + +### Updated Functions +- `renew()` - Now validates current timestamp is within the renewal window before executing + +### New Events +- `WindowUpdated { sub_id, billing_start, billing_end }` - Emitted when window is set/updated + +### Validation +- Reverts with "Outside renewal window" if current timestamp is before `billing_start` or after `billing_end` +- Reverts with "Invalid window: start must be before end" if window is misconfigured + +## Backend Changes + +### Database +- Added `billing_start_timestamp` column to `subscriptions` table +- Added `billing_end_timestamp` column to `subscriptions` table + +### Event Listener +- Handles `WindowUpdated` events → Updates billing timestamps in DB + +## Usage Example + +```rust +// Owner sets renewal window (Unix timestamps) +let start = 1704067200; // Jan 1, 2024 00:00:00 UTC +let end = 1704153600; // Jan 2, 2024 00:00:00 UTC +contract.set_window(env, sub_id, start, end); + +// Renewal only succeeds within window +contract.renew(env, caller, sub_id, approval_id, amount, max_retries, cooldown, true); +// ✅ Success if current time is between start and end +// ❌ Reverts if outside window +``` + +## Security +- Only owner can set/update renewal window +- Window validation happens before approval consumption +- Prevents premature or delayed renewals diff --git a/contracts/contracts/subscription_renewal/src/lib.rs b/contracts/contracts/subscription_renewal/src/lib.rs index 9af6c6a..de2b5bd 100644 --- a/contracts/contracts/subscription_renewal/src/lib.rs +++ b/contracts/contracts/subscription_renewal/src/lib.rs @@ -16,6 +16,295 @@ use soroban_sdk::{ enum ContractKey { Admin, Paused, +} + +/// Storage key for approvals: (sub_id, approval_id) +#[contracttype] +#[derive(Clone)] +struct ApprovalKey { + sub_id: u64, + approval_id: u64, +} + +/// Storage key for executor: sub_id +#[contracttype] +#[derive(Clone)] +struct ExecutorKey { + sub_id: u64, +} + +/// Storage key for renewal window: sub_id +#[contracttype] +#[derive(Clone)] +struct WindowKey { + sub_id: u64, +} + +/// Renewal approval bound to subscription, amount, and expiration +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RenewalApproval { + pub sub_id: u64, + pub max_spend: i128, + pub expires_at: u32, + pub used: bool, +} + +/// Renewal time window +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RenewalWindow { + pub billing_start: u64, + pub billing_end: u64, +} + +/// Represents the current state of a subscription +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SubscriptionState { + Active, + Retrying, + Failed, +} + +/// Core subscription data stored on-chain +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SubscriptionData { + pub owner: Address, + pub state: SubscriptionState, + pub failure_count: u32, + pub last_attempt_ledger: u32, +} + +/// Events for subscription renewal tracking +#[contractevent] +pub struct RenewalSuccess { + pub sub_id: u64, + pub owner: Address, +} + +#[contractevent] +pub struct RenewalFailed { + pub sub_id: u64, + pub failure_count: u32, + pub ledger: u32, +} + +#[contractevent] +pub struct StateTransition { + pub sub_id: u64, + pub new_state: SubscriptionState, +} + +#[contractevent] +pub struct PauseToggled { + pub paused: bool, +} + +#[contractevent] +pub struct ApprovalCreated { + pub sub_id: u64, + pub approval_id: u64, + pub max_spend: i128, + pub expires_at: u32, +} + +#[contractevent] +pub struct ApprovalRejected { + pub sub_id: u64, + pub approval_id: u64, + pub reason: u32, // 1=expired, 2=used, 3=amount_exceeded, 4=not_found +} + +#[contractevent] +pub struct ExecutorAssigned { + pub sub_id: u64, + pub executor: Address, +} + +#[contractevent] +pub struct ExecutorRemoved { + pub sub_id: u64, +} + +#[contractevent] +pub struct WindowUpdated { + pub sub_id: u64, + pub billing_start: u64, + pub billing_end: u64, +} + +#[contract] +pub struct SubscriptionRenewalContract; + +#[contractimpl] +impl SubscriptionRenewalContract { + // ── Admin / Pause management ────────────────────────────────── + + /// Initialize the contract admin. Can only be called once. + pub fn init(env: Env, admin: Address) { + if env.storage().instance().has(&ContractKey::Admin) { + panic!("Already initialized"); + } + env.storage().instance().set(&ContractKey::Admin, &admin); + env.storage().instance().set(&ContractKey::Paused, &false); + } + + /// Internal helper – loads admin and calls `require_auth`. + fn require_admin(env: &Env) { + let admin: Address = env + .storage() + .instance() + .get(&ContractKey::Admin) + .expect("Contract not initialized"); + admin.require_auth(); + } + + /// Pause or unpause all renewal execution. Admin only. + pub fn set_paused(env: Env, paused: bool) { + Self::require_admin(&env); + env.storage().instance().set(&ContractKey::Paused, &paused); + PauseToggled { paused }.publish(&env); + } + + /// Query the current pause state. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&ContractKey::Paused) + .unwrap_or(false) + } + + // ── Subscription logic ──────────────────────────────────────── + + /// Initialize a subscription + pub fn init_sub(env: Env, info: Address, sub_id: u64) { + let key = sub_id; + let data = SubscriptionData { + owner: info, + state: SubscriptionState::Active, + failure_count: 0, + last_attempt_ledger: 0, + }; + env.storage().persistent().set(&key, &data); + } + + // ── Executor management ─────────────────────────────────────── + + /// Assign executor for subscription (owner only) + pub fn set_executor(env: Env, sub_id: u64, executor: Address) { + let data: SubscriptionData = env + .storage() + .persistent() + .get(&sub_id) + .expect("Subscription not found"); + + data.owner.require_auth(); + + let key = ExecutorKey { sub_id }; + env.storage().persistent().set(&key, &executor); + + ExecutorAssigned { sub_id, executor }.publish(&env); + } + + /// Remove executor (owner only) + pub fn remove_executor(env: Env, sub_id: u64) { + let data: SubscriptionData = env + .storage() + .persistent() + .get(&sub_id) + .expect("Subscription not found"); + + data.owner.require_auth(); + + let key = ExecutorKey { sub_id }; + env.storage().persistent().remove(&key); + + ExecutorRemoved { sub_id }.publish(&env); + } + + /// Get executor for subscription + pub fn get_executor(env: Env, sub_id: u64) -> Option
{ + let key = ExecutorKey { sub_id }; + env.storage().persistent().get(&key) + } + + // ── Renewal window management ───────────────────────────────── + + /// Set renewal window (owner only) + pub fn set_window(env: Env, sub_id: u64, billing_start: u64, billing_end: u64) { + let data: SubscriptionData = env + .storage() + .persistent() + .get(&sub_id) + .expect("Subscription not found"); + + data.owner.require_auth(); + + if billing_start >= billing_end { + panic!("Invalid window: start must be before end"); + } + + let window = RenewalWindow { + billing_start, + billing_end, + }; + + let key = WindowKey { sub_id }; + env.storage().persistent().set(&key, &window); + + WindowUpdated { + sub_id, + billing_start, + billing_end, + } + .publish(&env); + } + + /// Get renewal window + pub fn get_window(env: Env, sub_id: u64) -> Option { + let key = WindowKey { sub_id }; + env.storage().persistent().get(&key) + } + + // ── Approval management ─────────────────────────────────────── + + /// Create a renewal approval for a subscription + pub fn approve_renewal( + env: Env, + sub_id: u64, + approval_id: u64, + max_spend: i128, + expires_at: u32, + ) { + let sub_key = sub_id; + let data: SubscriptionData = env + .storage() + .persistent() + .get(&sub_key) + .expect("Subscription not found"); + + data.owner.require_auth(); + + let approval = RenewalApproval { + sub_id, + max_spend, + expires_at, + used: false, + }; + + let key = ApprovalKey { + sub_id, + approval_id, + }; + env.storage().persistent().set(&key, &approval); + + ApprovalCreated { + sub_id, + approval_id, + max_spend, + expires_at, FeeConfig, LoggingContract, } /// Admin function to manage the protocol fee configuration. @@ -36,6 +325,118 @@ enum ContractKey { .publish(&env); } + // ── Renewal logic ───────────────────────────────────────────── + + /// Attempt to renew the subscription. + /// Callable by owner or assigned executor. + /// Returns true if renewal is successful (simulated), false if it failed and retry logic was triggered. + /// limits: max retries allowed. + /// cooldown: min ledgers between retries. + pub fn renew( + env: Env, + caller: Address, + sub_id: u64, + approval_id: u64, + amount: i128, + max_retries: u32, + cooldown_ledgers: u32, + succeed: bool, + ) -> bool { + // Check global pause + if Self::is_paused(env.clone()) { + panic!("Protocol is paused"); + } + + let key = sub_id; + let mut data: SubscriptionData = env + .storage() + .persistent() + .get(&key) + .expect("Subscription not found"); + + // Verify caller is owner or executor + caller.require_auth(); + let executor_key = ExecutorKey { sub_id }; + let executor: Option
= env.storage().persistent().get(&executor_key); + + if caller != data.owner && Some(caller.clone()) != executor { + panic!("Unauthorized: caller must be owner or executor"); + } + + // Validate and consume approval + if !Self::consume_approval(&env, sub_id, approval_id, amount) { + panic!("Invalid or expired approval"); + } + + // Validate renewal window + let window_key = WindowKey { sub_id }; + if let Some(window) = env.storage().persistent().get::(&window_key) { + let current_time = env.ledger().timestamp(); + if current_time < window.billing_start || current_time > window.billing_end { + panic!("Outside renewal window"); + } + } + + // If already failed, we can't renew + if data.state == SubscriptionState::Failed { + panic!("Subscription is in FAILED state"); + } + + let current_ledger = env.ledger().sequence(); + + // Check cooldown + if data.failure_count > 0 && current_ledger < data.last_attempt_ledger + cooldown_ledgers { + panic!("Cooldown period active"); + } + + if succeed { + // Simulated success - renewal successful + data.state = SubscriptionState::Active; + data.failure_count = 0; + data.last_attempt_ledger = current_ledger; + env.storage().persistent().set(&key, &data); + + // Emit renewal success event + RenewalSuccess { + sub_id, + owner: data.owner.clone(), + } + .publish(&env); + + true + } else { + // Simulated failure - renewal failed, apply retry logic + data.failure_count += 1; + data.last_attempt_ledger = current_ledger; + + // Emit renewal failure event + RenewalFailed { + sub_id, + failure_count: data.failure_count, + ledger: current_ledger, + } + .publish(&env); + + // Determine new state based on retry count + if data.failure_count > max_retries { + data.state = SubscriptionState::Failed; + StateTransition { + sub_id, + new_state: SubscriptionState::Failed, + } + .publish(&env); + } else { + data.state = SubscriptionState::Retrying; + StateTransition { + sub_id, + new_state: SubscriptionState::Retrying, + } + .publish(&env); + } + + env.storage().persistent().set(&key, &data); + false + } /// Retrieve the current fee configuration pub fn get_fee_config(env: Env) -> Option { env.storage().instance().get(&ContractKey::FeeConfig)