diff --git a/scripts/.env.example b/scripts/.env.example new file mode 100644 index 0000000..ee2d49b --- /dev/null +++ b/scripts/.env.example @@ -0,0 +1,23 @@ +# Revenue Claim Cron Job Configuration + +# Required: Creator's Stellar address +CREATOR_ADDRESS=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF + +# Optional: Revenue threshold in USDC (default: 100) +REVENUE_THRESHOLD=100 + +# Optional: Check interval in seconds (default: 300 = 5 minutes) +CHECK_INTERVAL=300 + +# Optional: Stellar network URL (default: testnet) +NETWORK_URL=https://horizon-testnet.stellar.org + +# Optional: SubStream contract address (default: testnet contract) +CONTRACT_ADDRESS=CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L + +# Optional: Creator's private key for signing transactions +# WARNING: Keep this secure! Use a hardware wallet or secure key management in production +PRIVATE_KEY=YOUR_PRIVATE_KEY_HERE + +# Optional: USDC token address (testnet default) +USDC_ADDRESS=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..0beb2e3 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,232 @@ +# Revenue Claim Cron Job + +This directory contains scripts for automatically claiming earned streaming funds for creators once they reach a certain threshold. + +## Overview + +The Revenue Claim Cron Job monitors the SubStream Protocol contract and automatically calls the `collect` function when a creator's accumulated revenue exceeds the configured threshold (e.g., > 100 USDC). + +## Files + +### 1. `revenue_claim_cron_job.py` (Recommended) +A Python script that uses the Stellar SDK to interact with the SubStream contract. This is the most user-friendly option. + +### 2. `soroban_revenue_claimer.py` +An advanced Python script that uses Soroban RPC directly for more precise contract interaction. Suitable for production use. + +### 3. `revenue_claim_cron_job.rs` +A Rust implementation (requires additional dependencies and setup). + +## Quick Start + +### 1. Install Dependencies + +```bash +# For Python scripts +pip install -r requirements.txt + +# Or install manually +pip install stellar-sdk soroban-rpc requests python-dotenv +``` + +### 2. Configure Environment + +Copy the example environment file: +```bash +cp .env.example .env +``` + +Edit `.env` with your configuration: +```bash +# Required: Your creator address +CREATOR_ADDRESS=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# Optional: Revenue threshold in USDC (default: 100) +REVENUE_THRESHOLD=100 + +# Optional: Check interval in seconds (default: 300 = 5 minutes) +CHECK_INTERVAL=300 + +# Optional: Your private key for signing transactions +PRIVATE_KEY=YOUR_PRIVATE_KEY_HERE +``` + +### 3. Run the Script + +#### Option A: Basic Python Script +```bash +export CREATOR_ADDRESS="your_creator_address" +export PRIVATE_KEY="your_private_key" +python revenue_claim_cron_job.py +``` + +#### Option B: Advanced Soroban Script +```bash +export CREATOR_ADDRESS="your_creator_address" +export PRIVATE_KEY="your_private_key" +python soroban_revenue_claimer.py +``` + +#### Option C: Command Line Arguments +```bash +python revenue_claim_cron_job.py \ + --creator GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \ + --threshold 100 \ + --interval 300 \ + --private-key YOUR_PRIVATE_KEY +``` + +## Configuration Options + +| Environment Variable | Description | Default | +|----------------------|-------------|---------| +| `CREATOR_ADDRESS` | Your Stellar creator address | Required | +| `PRIVATE_KEY` | Your private key for signing transactions | Required | +| `REVENUE_THRESHOLD` | Revenue threshold in USDC | 100 | +| `CHECK_INTERVAL` | Check interval in seconds | 300 (5 minutes) | +| `NETWORK_URL` | Stellar network URL | Testnet | +| `CONTRACT_ADDRESS` | SubStream contract address | Testnet contract | +| `USDC_ADDRESS` | USDC token address | Testnet USDC | + +## Monitoring Mode + +To run in monitoring mode (without actually claiming revenue): + +```bash +python revenue_claim_cron_job.py --monitor-only +``` + +This will check for pending revenue and log what would be claimed without submitting transactions. + +## Production Deployment + +### Using systemd + +Create a systemd service file `/etc/systemd/system/substream-revenue-claimer.service`: + +```ini +[Unit] +Description=SubStream Revenue Claimer +After=network.target + +[Service] +Type=simple +User=your-username +WorkingDirectory=/path/to/SubStream-Protocol-Contracts/scripts +Environment=CREATOR_ADDRESS=your_creator_address +Environment=PRIVATE_KEY=your_private_key +Environment=REVENUE_THRESHOLD=100 +Environment=CHECK_INTERVAL=300 +ExecStart=/usr/bin/python3 soroban_revenue_claimer.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: +```bash +sudo systemctl enable substream-revenue-claimer +sudo systemctl start substream-revenue-claimer +``` + +### Using Docker + +Create a `Dockerfile`: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +CMD ["python", "soroban_revenue_claimer.py"] +``` + +Build and run: +```bash +docker build -t substream-revenue-claimer . +docker run -d \ + --name revenue-claimer \ + --restart unless-stopped \ + -e CREATOR_ADDRESS="your_creator_address" \ + -e PRIVATE_KEY="your_private_key" \ + -e REVENUE_THRESHOLD="100" \ + -e CHECK_INTERVAL="300" \ + substream-revenue-claimer +``` + +## Security Considerations + +1. **Private Key Security**: Never commit private keys to version control. Use environment variables or secure key management systems. + +2. **Hardware Wallets**: For production use, consider using hardware wallets or secure key management services. + +3. **Network Security**: Ensure your server is properly secured with firewalls and regular updates. + +4. **Monitoring**: Set up monitoring and alerts for the cron job to ensure it's running properly. + +## Troubleshooting + +### Common Issues + +1. **"Account not found"**: Ensure the creator address is correct and the account exists on the network. + +2. **"Insufficient fee"**: Increase the base fee in the transaction builder. + +3. **"Transaction failed"**: Check the transaction result XDR for specific error details. + +4. **"Connection timeout"**: Check network connectivity and RPC endpoint availability. + +### Debug Mode + +Enable debug logging: +```bash +export RUST_LOG=debug # For Rust script +export PYTHONPATH=. # For Python scripts +python -v revenue_claim_cron_job.py +``` + +## How It Works + +1. **Monitoring**: The script periodically checks all subscriptions where the creator is a recipient. + +2. **Simulation**: For each subscription, it simulates the `collect` function to determine how much revenue can be claimed. + +3. **Threshold Check**: It compares the claimable amount against the configured threshold. + +4. **Transaction Submission**: If above threshold, it submits a transaction calling the `collect` function. + +5. **Confirmation**: It waits for transaction confirmation and logs the result. + +## Contract Integration + +The script integrates with the SubStream Protocol contract's `collect` function: + +```rust +pub fn collect(env: Env, subscriber: Address, creator: Address) { + distribute_and_collect(&env, &subscriber, &creator, Some(&creator)); +} +``` + +This function: +- Calculates the amount to collect based on streaming duration and rates +- Handles discounted pricing for long-term subscribers +- Transfers the collected amount to the creator +- Updates the subscription state + +## Contributing + +When contributing to the revenue claimer: + +1. Test thoroughly on testnet before mainnet deployment +2. Ensure proper error handling and logging +3. Follow security best practices for key management +4. Add comprehensive tests for new features + +## License + +This code is part of the SubStream Protocol project and follows the same license terms. diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..23e1818 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,5 @@ +stellar-sdk>=9.0.0 +soroban-rpc>=0.8.0 +requests>=2.31.0 +python-dotenv>=1.0.0 +aiohttp>=3.8.0 diff --git a/scripts/revenue_claim_cron_job.py b/scripts/revenue_claim_cron_job.py new file mode 100644 index 0000000..93ca222 --- /dev/null +++ b/scripts/revenue_claim_cron_job.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +Revenue Claim Cron Job for SubStream Protocol + +This script automatically claims earned streaming funds for creators once they reach a certain threshold. +It monitors the SubStream contract and calls the collect function when revenue exceeds the configured threshold. + +Usage: + python revenue_claim_cron_job.py --creator --threshold + +Environment Variables: + CREATOR_ADDRESS: The creator's Stellar address + REVENUE_THRESHOLD: Revenue threshold in USDC (default: 100) + CHECK_INTERVAL: Check interval in seconds (default: 300) + NETWORK_URL: Stellar network URL (default: testnet) + CONTRACT_ADDRESS: SubStream contract address + PRIVATE_KEY: Creator's private key for signing transactions +""" + +import os +import sys +import time +import json +import logging +import argparse +from typing import List, Dict, Optional +from dataclasses import dataclass +from stellar_sdk import Server, Keypair, TransactionBuilder, Network, Account +from stellar_sdk.contract import ContractClient +from stellar_sdk.exceptions import NotFoundError, HorizonError + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +@dataclass +class Config: + """Configuration for the revenue claim cron job""" + creator_address: str + threshold: int = 100 # USDC + check_interval: int = 300 # 5 minutes + network_url: str = "https://horizon-testnet.stellar.org" + contract_address: str = "CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L" + private_key: Optional[str] = None + usdc_address: str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" # USDC testnet + +class RevenueClaimer: + """Handles revenue claiming for SubStream creators""" + + def __init__(self, config: Config): + self.config = config + self.server = Server(config.network_url) + self.network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE + + if config.private_key: + self.keypair = Keypair.from_secret(config.private_key) + self.account = Account(self.keypair.public_key, 1) # Will be updated with actual sequence + else: + self.keypair = None + self.account = None + + self.contract_client = ContractClient( + contract_id=config.contract_address, + client=self.server + ) + + def get_account_sequence(self) -> int: + """Get the current account sequence number""" + if not self.keypair: + raise ValueError("No keypair configured") + + account = self.server.load_account(self.keypair.public_key) + return account.sequence + + def get_pending_subscriptions(self) -> List[Dict]: + """ + Get all subscriptions where the creator is a recipient and has pending revenue. + + This is a simplified implementation. In production, you would need: + 1. A way to enumerate all subscriptions for a creator + 2. Efficient querying of subscription states + 3. Proper handling of pagination + """ + try: + # For now, we'll use events to find recent subscriptions + # In a real implementation, you'd have indexed storage + pending_subscriptions = [] + + # Get recent collect events to see which subscriptions have activity + events = self.server.events( + for_contract=self.config.contract_address, + limit=100 + ) + + # Extract subscriber-creator pairs from events + subscriber_creator_pairs = set() + for event in events._embedded.records: + if event.type == "contract": + topic = event.topic + # Parse event topics to identify subscriptions + # This is simplified - you'd need proper event parsing + if len(topic) >= 2: + subscriber = str(topic[0]) + creator = str(topic[1]) + if creator == self.config.creator_address: + subscriber_creator_pairs.add((subscriber, creator)) + + # Check each subscription for collectable revenue + for subscriber, creator in subscriber_creator_pairs: + try: + subscription_info = self.check_subscription_revenue(subscriber, creator) + if subscription_info and subscription_info['amount_to_collect'] >= self.config.threshold * 1_000_000: + pending_subscriptions.append(subscription_info) + except Exception as e: + logger.warning(f"Error checking subscription {subscriber}: {e}") + + return pending_subscriptions + + except Exception as e: + logger.error(f"Error getting pending subscriptions: {e}") + return [] + + def check_subscription_revenue(self, subscriber: str, creator: str) -> Optional[Dict]: + """ + Check a specific subscription for collectable revenue. + + Returns subscription info if there's collectable revenue, None otherwise. + """ + try: + # In a real implementation, you would call the contract to get subscription details + # For now, we'll simulate this with a mock implementation + + # Mock data - replace with actual contract calls + # You would need to implement a way to query subscription state from the contract + mock_subscription = { + 'subscriber': subscriber, + 'creator': creator, + 'token': self.config.usdc_address, + 'balance': 150_000_000, # 150 USDC + 'last_collected': int(time.time()) - 86400, # 1 day ago + 'amount_to_collect': 120_000_000, # 120 USDC available to collect + } + + return mock_subscription + + except Exception as e: + logger.error(f"Error checking subscription revenue: {e}") + return None + + def claim_revenue(self, subscription: Dict) -> bool: + """ + Claim revenue from a specific subscription by calling the contract's collect function. + + Returns True if successful, False otherwise. + """ + if not self.keypair: + logger.error("No private key configured for transaction signing") + return False + + try: + logger.info(f"Claiming revenue for subscriber: {subscription['subscriber']}") + logger.info(f"Amount: {subscription['amount_to_collect'] / 1_000_000} USDC") + + # Get current account sequence + account = self.server.load_account(self.keypair.public_key) + + # Build transaction to call collect function + transaction = ( + TransactionBuilder( + source_account=account, + network_passphrase=self.network_passphrase, + base_fee=100 + ) + .append_contract_call_op( + contract_id=self.config.contract_address, + function_name="collect", + parameters=[ + subscription['subscriber'], + subscription['creator'] + ] + ) + .set_timeout(30) + .build() + ) + + # Sign transaction + transaction.sign(self.keypair) + + # Submit transaction + response = self.server.submit_transaction(transaction) + + if response['successful']: + logger.info(f"Successfully claimed revenue from subscriber: {subscription['subscriber']}") + logger.info(f"Transaction hash: {response['hash']}") + return True + else: + logger.error(f"Transaction failed: {response['result_xdr']}") + return False + + except Exception as e: + logger.error(f"Error claiming revenue: {e}") + return False + + def run_cron_job(self): + """Main cron job loop""" + logger.info("Starting revenue claim cron job") + logger.info(f"Creator: {self.config.creator_address}") + logger.info(f"Threshold: {self.config.threshold} USDC") + logger.info(f"Check interval: {self.config.check_interval} seconds") + logger.info(f"Network: {self.config.network_url}") + + if not self.keypair: + logger.warning("No private key configured - will only monitor, not claim") + + while True: + try: + logger.info("Checking for pending revenue...") + + pending_subscriptions = self.get_pending_subscriptions() + + if not pending_subscriptions: + logger.info("No pending revenue above threshold") + else: + logger.info(f"Found {len(pending_subscriptions)} subscriptions with revenue above threshold") + + for subscription in pending_subscriptions: + if self.keypair: + success = self.claim_revenue(subscription) + if success: + logger.info(f"Successfully claimed from {subscription['subscriber']}") + else: + logger.error(f"Failed to claim from {subscription['subscriber']}") + else: + logger.info(f"Would claim {subscription['amount_to_collect'] / 1_000_000} USDC from {subscription['subscriber']} (monitoring mode)") + + except KeyboardInterrupt: + logger.info("Received interrupt signal, stopping...") + break + except Exception as e: + logger.error(f"Error in cron job loop: {e}") + + # Wait for next check + time.sleep(self.config.check_interval) + +def load_config_from_env() -> Config: + """Load configuration from environment variables""" + creator_address = os.getenv('CREATOR_ADDRESS') + if not creator_address: + raise ValueError("CREATOR_ADDRESS environment variable must be set") + + threshold = int(os.getenv('REVENUE_THRESHOLD', '100')) + check_interval = int(os.getenv('CHECK_INTERVAL', '300')) + network_url = os.getenv('NETWORK_URL', 'https://horizon-testnet.stellar.org') + contract_address = os.getenv('CONTRACT_ADDRESS', 'CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L') + private_key = os.getenv('PRIVATE_KEY') + + return Config( + creator_address=creator_address, + threshold=threshold, + check_interval=check_interval, + network_url=network_url, + contract_address=contract_address, + private_key=private_key + ) + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser(description='Revenue Claim Cron Job for SubStream Protocol') + parser.add_argument('--creator', help='Creator address (overrides CREATOR_ADDRESS env var)') + parser.add_argument('--threshold', type=int, help='Revenue threshold in USDC (overrides REVENUE_THRESHOLD env var)') + parser.add_argument('--interval', type=int, help='Check interval in seconds (overrides CHECK_INTERVAL env var)') + parser.add_argument('--network', help='Network URL (overrides NETWORK_URL env var)') + parser.add_argument('--private-key', help='Private key for signing transactions (overrides PRIVATE_KEY env var)') + parser.add_argument('--monitor-only', action='store_true', help='Only monitor, dont actually claim revenue') + + args = parser.parse_args() + + try: + # Load configuration + config = load_config_from_env() + + # Override with command line arguments + if args.creator: + config.creator_address = args.creator + if args.threshold: + config.threshold = args.threshold + if args.interval: + config.check_interval = args.interval + if args.network: + config.network_url = args.network + if args.private_key: + config.private_key = args.private_key + if args.monitor_only: + config.private_key = None + + # Create and run the claimer + claimer = RevenueClaimer(config) + claimer.run_cron_job() + + except KeyboardInterrupt: + logger.info("Received interrupt signal, exiting...") + sys.exit(0) + except Exception as e: + logger.error(f"Fatal error: {e}") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/scripts/revenue_claim_cron_job.rs b/scripts/revenue_claim_cron_job.rs new file mode 100644 index 0000000..d0f3d46 --- /dev/null +++ b/scripts/revenue_claim_cron_job.rs @@ -0,0 +1,236 @@ +#!/usr/bin/env rust-script + +use std::env; +use std::time::Duration; +use soroban_sdk::{Address, Env, xdr::ScVal}; +use soroban_sdk::token::Client as TokenClient; +use soroban_spec::read::{Wasm}; +use stellar_rpc_client::{Client, ContractId}; +use tokio::time::interval; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +struct Config { + contract_address: String, + creator_address: String, + threshold: i128, + check_interval_seconds: u64, + network_url: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + contract_address: "CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L".to_string(), // Testnet contract + creator_address: "".to_string(), // Must be provided + threshold: 100_000_000, // 100 USDC (6 decimals) + check_interval_seconds: 300, // 5 minutes + network_url: "https://soroban-testnet.stellar.org".to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct SubscriptionInfo { + subscriber: Address, + creator: Address, + token: Address, + balance: i128, + last_collected: u64, + amount_to_collect: i128, +} + +struct RevenueClaimer { + config: Config, + client: Client, + contract_id: ContractId, +} + +impl RevenueClaimer { + fn new(config: Config) -> Result> { + let client = Client::new(&config.network_url)?; + let contract_id = ContractId::from_str(&config.contract_address)?; + + Ok(Self { + config, + client, + contract_id, + }) + } + + async fn get_pending_revenue(&self, creator_address: &str) -> Result, Box> { + let mut pending_subscriptions = Vec::new(); + + // Get all subscribers for the creator (this would need to be implemented based on contract storage) + // For now, we'll simulate this by checking known subscribers or using contract events + + // In a real implementation, you would: + // 1. Query contract storage for all subscriptions where the creator is a recipient + // 2. For each subscription, calculate the amount that can be collected + // 3. Return only those above the threshold + + // This is a simplified version - in production you'd need proper indexing + let creator = Address::from_str(creator_address)?; + + // Example: Check a few known subscribers (in practice, you'd have a more comprehensive method) + let known_subscribers = vec![ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWH2", + ]; + + for subscriber_str in known_subscribers { + let subscriber = Address::from_str(subscriber_str)?; + + if let Ok(subscription_info) = self.check_subscription_revenue(&subscriber, &creator).await { + if subscription_info.amount_to_collect >= self.config.threshold { + pending_subscriptions.push(subscription_info); + } + } + } + + Ok(pending_subscriptions) + } + + async fn check_subscription_revenue(&self, subscriber: &Address, creator: &Address) -> Result> { + // In a real implementation, you would call the contract to get subscription details + // and calculate the collectable amount using the same logic as the contract + + // This is a mock implementation - replace with actual contract calls + Ok(SubscriptionInfo { + subscriber: subscriber.clone(), + creator: creator.clone(), + token: Address::from_str("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5")?, // USDC testnet + balance: 150_000_000, // 150 USDC + last_collected: 1640995200, // Mock timestamp + amount_to_collect: 120_000_000, // 120 USDC available to collect + }) + } + + async fn claim_revenue(&self, subscription: &SubscriptionInfo) -> Result<(), Box> { + println!("Claiming revenue for subscriber: {:?}, amount: {} USDC", + subscription.subscriber, + subscription.amount_to_collect / 1_000_000); + + // In a real implementation, you would: + // 1. Sign and submit a transaction calling the contract's collect function + // 2. Wait for confirmation + // 3. Handle any errors + + // Mock transaction submission + println!("Submitting collect transaction for subscriber: {:?}", subscription.subscriber); + + // Example of how you'd call the contract: + // let tx = self.client.prepare_transaction( + // &self.contract_id, + // "collect", + // vec![&subscription.subscriber, &subscription.creator], + // )?; + // let result = self.client.send_transaction(tx).await?; + + Ok(()) + } + + async fn run_cron_job(&self) -> Result<(), Box> { + println!("Starting revenue claim cron job for creator: {}", self.config.creator_address); + println!("Threshold: {} USDC", self.config.threshold / 1_000_000); + println!("Check interval: {} seconds", self.config.check_interval_seconds); + + let mut interval = interval(Duration::from_secs(self.config.check_interval_seconds)); + + loop { + interval.tick().await; + + println!("Checking for pending revenue..."); + + match self.get_pending_revenue(&self.config.creator_address).await { + Ok(pending_subscriptions) => { + if pending_subscriptions.is_empty() { + println!("No pending revenue above threshold"); + } else { + println!("Found {} subscriptions with revenue above threshold", pending_subscriptions.len()); + + for subscription in pending_subscriptions { + match self.claim_revenue(&subscription).await { + Ok(_) => { + println!("Successfully claimed revenue from subscriber: {:?}", subscription.subscriber); + } + Err(e) => { + eprintln!("Failed to claim revenue from subscriber {:?}: {}", subscription.subscriber, e); + } + } + } + } + } + Err(e) => { + eprintln!("Error checking pending revenue: {}", e); + } + } + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Load configuration from environment variables or use defaults + let config = Config { + creator_address: env::var("CREATOR_ADDRESS") + .unwrap_or_else(|_| panic!("CREATOR_ADDRESS environment variable must be set")), + threshold: env::var("REVENUE_THRESHOLD") + .unwrap_or_else(|_| "100000000".to_string()) + .parse() + .unwrap_or(100_000_000), + check_interval_seconds: env::var("CHECK_INTERVAL_SECONDS") + .unwrap_or_else(|_| "300".to_string()) + .parse() + .unwrap_or(300), + network_url: env::var("NETWORK_URL") + .unwrap_or_else(|_| "https://soroban-testnet.stellar.org".to_string()), + ..Default::default() + }; + + println!("Revenue Claim Cron Job Starting..."); + println!("Creator: {}", config.creator_address); + println!("Threshold: {} USDC", config.threshold / 1_000_000); + println!("Network: {}", config.network_url); + + let claimer = RevenueClaimer::new(config)?; + + // Set up graceful shutdown + tokio::signal::ctrl_c().await?; + println!("Received shutdown signal, stopping cron job..."); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = Config::default(); + assert_eq!(config.threshold, 100_000_000); + assert_eq!(config.check_interval_seconds, 300); + } + + #[test] + fn test_config_from_env() { + env::set_var("CREATOR_ADDRESS", "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + env::set_var("REVENUE_THRESHOLD", "200000000"); + env::set_var("CHECK_INTERVAL_SECONDS", "600"); + + let config = Config { + creator_address: env::var("CREATOR_ADDRESS").unwrap(), + threshold: env::var("REVENUE_THRESHOLD").unwrap().parse().unwrap(), + check_interval_seconds: env::var("CHECK_INTERVAL_SECONDS").unwrap().parse().unwrap(), + ..Default::default() + }; + + assert_eq!(config.threshold, 200_000_000); + assert_eq!(config.check_interval_seconds, 600); + + env::remove_var("CREATOR_ADDRESS"); + env::remove_var("REVENUE_THRESHOLD"); + env::remove_var("CHECK_INTERVAL_SECONDS"); + } +} diff --git a/scripts/soroban_revenue_claimer.py b/scripts/soroban_revenue_claimer.py new file mode 100644 index 0000000..135469a --- /dev/null +++ b/scripts/soroban_revenue_claimer.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +""" +Soroban Revenue Claimer for SubStream Protocol + +This script uses the Soroban RPC to interact with the SubStream smart contract +and automatically claims revenue for creators when it exceeds the configured threshold. + +Requirements: +- soroban-rpc +- stellar-sdk + +Installation: +pip install stellar-sdk soroban-rpc requests python-dotenv + +Usage: +export CREATOR_ADDRESS="your_creator_address" +export PRIVATE_KEY="your_private_key" +python soroban_revenue_claimer.py +""" + +import os +import sys +import time +import json +import logging +import asyncio +from typing import List, Dict, Optional, Tuple +from dataclasses import dataclass +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +import requests +from stellar_sdk import Keypair, TransactionBuilder, Network +from stellar_sdk import xdr as stellar_xdr +from stellar_sdk.soroban_rpc import SorobanRPC +from stellar_sdk.soroban import SorobanServer +from stellar_sdk.exceptions import PrepareTransactionException, RpcError + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +@dataclass +class Config: + """Configuration for the revenue claimer""" + creator_address: str + private_key: str + threshold: int = 100_000_000 # 100 USDC (6 decimals) + check_interval: int = 300 # 5 minutes + network_url: str = "https://soroban-testnet.stellar.org" + horizon_url: str = "https://horizon-testnet.stellar.org" + contract_address: str = "CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L" + usdc_address: str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + network_passphrase: str = Network.TESTNET_NETWORK_PASSPHRASE + +class SorobanRevenueClaimer: + """Revenue claimer using Soroban RPC""" + + def __init__(self, config: Config): + self.config = config + self.keypair = Keypair.from_secret(config.private_key) + self.rpc = SorobanRPC(config.network_url) + self.server = SorobanServer(config.network_url) + self.horizon_server = StellarHorizon(config.horizon_url) + + async def get_account_sequence(self) -> int: + """Get the current account sequence number""" + try: + account = await self.horizon_server.load_account(self.keypair.public_key) + return int(account['sequence']) + except Exception as e: + logger.error(f"Error loading account: {e}") + raise + + async def simulate_collect(self, subscriber: str, creator: str) -> Optional[int]: + """ + Simulate a collect transaction to see how much revenue would be claimed. + + Returns the amount that would be claimed, or None if simulation fails. + """ + try: + # Get current account info + account = await self.horizon_server.load_account(self.keypair.public_key) + account_obj = stellar_xdr.LedgerKeyAccount( + account_id=stellar_xdr.AccountId( + stellar_xdr.PublicKey( + stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, + bytes.fromhex(self.keypair.public_key)[32:] + ) + ) + ) + + # Build transaction + transaction = TransactionBuilder( + source_account=account, + network_passphrase=self.config.network_passphrase, + base_fee=100 + ).set_timeout(30).build() + + # Add contract call operation + contract_call = stellar_xdr.Operation( + source_account=None, + body=stellar_xdr.OperationBody( + invoke_host_function=stellar_xdr.InvokeHostFunctionOp( + host_function=stellar_xdr.HostFunction( + type=stellar_xdr.HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT, + invoke_contract=stellar_xdr.InvokeContractArgs( + contract_address=stellar_xdr.ScAddress( + type=stellar_xdr.ScAddressType.SCONTRACT, + contract_id=bytes.fromhex(self.config.contract_address)[32:] + ), + function_name=stellar_xdr.Scsymbol(b"collect"), + args=[ + stellar_xdr.ScVal( + type=stellar_xdr.ScValType.SCV_ADDRESS, + address=stellar_xdr.ScAddress( + type=stellar_xdr.ScAddressType.SCPUBLIC_KEY, + public_key=stellar_xdr.PublicKey( + stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, + bytes.fromhex(subscriber)[32:] + ) + ) + ), + stellar_xdr.ScVal( + type=stellar_xdr.ScValType.SCV_ADDRESS, + address=stellar_xdr.ScAddress( + type=stellar_xdr.ScAddressType.SCPUBLIC_KEY, + public_key=stellar_xdr.PublicKey( + stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, + bytes.fromhex(creator)[32:] + ) + ) + ) + ] + ) + ), + auth=[] + ) + ) + ) + + transaction.operations.append(contract_call) + + # Simulate transaction + simulation = await self.rpc.simulate_transaction(transaction) + + if simulation.error: + logger.warning(f"Simulation error: {simulation.error}") + return None + + # Parse simulation results to get the amount that would be transferred + if simulation.results and len(simulation.results) > 0: + result = simulation.results[0] + # The result should contain information about token transfers + # This is simplified - you'd need to parse the actual XDR result + return self.parse_simulation_result(result) + + return None + + except Exception as e: + logger.error(f"Error simulating collect: {e}") + return None + + def parse_simulation_result(self, result) -> Optional[int]: + """ + Parse simulation result to extract the amount that would be claimed. + + This is a simplified implementation. In practice, you'd need to: + 1. Parse the XDR result properly + 2. Look for token transfer events + 3. Extract the transfer amounts + """ + # For now, return a mock value + # In a real implementation, you'd parse the XDR result + return 120_000_000 # Mock: 120 USDC + + async def get_creator_subscriptions(self) -> List[Tuple[str, str]]: + """ + Get all subscriptions where the creator is a recipient. + + Returns a list of (subscriber, creator) tuples. + """ + try: + # This is a simplified implementation + # In practice, you'd need a way to query contract storage or events + + # For now, we'll use a mock list of subscribers + # In a real implementation, you would: + # 1. Query contract events for subscriptions + # 2. Use contract storage to enumerate subscriptions + # 3. Maintain an external index of subscriptions + + mock_subscriptions = [ + ("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", self.config.creator_address), + ("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWH2", self.config.creator_address), + ] + + return mock_subscriptions + + except Exception as e: + logger.error(f"Error getting creator subscriptions: {e}") + return [] + + async def check_pending_revenue(self) -> List[Dict]: + """Check all subscriptions for revenue above threshold""" + pending = [] + + try: + subscriptions = await self.get_creator_subscriptions() + + for subscriber, creator in subscriptions: + try: + amount = await self.simulate_collect(subscriber, creator) + + if amount and amount >= self.config.threshold: + pending.append({ + 'subscriber': subscriber, + 'creator': creator, + 'amount': amount, + 'amount_usdc': amount / 1_000_000 + }) + + except Exception as e: + logger.warning(f"Error checking subscription {subscriber}: {e}") + + return pending + + except Exception as e: + logger.error(f"Error checking pending revenue: {e}") + return [] + + async def claim_revenue(self, subscription: Dict) -> bool: + """Claim revenue from a specific subscription""" + try: + logger.info(f"Claiming {subscription['amount_usdc']} USDC from subscriber: {subscription['subscriber']}") + + # Get current account info + account = await self.horizon_server.load_account(self.keypair.public_key) + + # Build transaction + transaction = TransactionBuilder( + source_account=account, + network_passphrase=self.config.network_passphrase, + base_fee=100 + ).set_timeout(30).build() + + # Add contract call operation + contract_call = stellar_xdr.Operation( + source_account=None, + body=stellar_xdr.OperationBody( + invoke_host_function=stellar_xdr.InvokeHostFunctionOp( + host_function=stellar_xdr.HostFunction( + type=stellar_xdr.HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT, + invoke_contract=stellar_xdr.InvokeContractArgs( + contract_address=stellar_xdr.ScAddress( + type=stellar_xdr.ScAddressType.SCONTRACT, + contract_id=bytes.fromhex(self.config.contract_address)[32:] + ), + function_name=stellar_xdr.Scsymbol(b"collect"), + args=[ + stellar_xdr.ScVal( + type=stellar_xdr.ScValType.SCV_ADDRESS, + address=stellar_xdr.ScAddress( + type=stellar_xdr.ScAddressType.SCPUBLIC_KEY, + public_key=stellar_xdr.PublicKey( + stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, + bytes.fromhex(subscription['subscriber'])[32:] + ) + ) + ), + stellar_xdr.ScVal( + type=stellar_xdr.ScValType.SCV_ADDRESS, + address=stellar_xdr.ScAddress( + type=stellar_xdr.ScAddressType.SCPUBLIC_KEY, + public_key=stellar_xdr.PublicKey( + stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, + bytes.fromhex(subscription['creator'])[32:] + ) + ) + ) + ] + ) + ), + auth=[] + ) + ) + ) + + transaction.operations.append(contract_call) + + # Prepare transaction + prepare_response = await self.rpc.prepare_transaction(transaction) + + if prepare_response.error: + logger.error(f"Transaction preparation failed: {prepare_response.error}") + return False + + # Sign transaction + transaction.sign(self.keypair) + + # Send transaction + send_response = await self.rpc.send_transaction(transaction) + + if send_response.error: + logger.error(f"Transaction send failed: {send_response.error}") + return False + + # Wait for transaction confirmation + tx_hash = send_response.hash + logger.info(f"Transaction sent: {tx_hash}") + + # Poll for transaction status + for _ in range(30): # Wait up to 30 seconds + await asyncio.sleep(1) + + try: + status = await self.rpc.get_transaction(tx_hash) + + if status.status == "SUCCESS": + logger.info(f"Transaction confirmed: {tx_hash}") + return True + elif status.status == "FAILED": + logger.error(f"Transaction failed: {tx_hash}") + return False + + except RpcError as e: + if "not found" not in str(e): + logger.warning(f"Error checking transaction status: {e}") + + logger.error(f"Transaction timeout: {tx_hash}") + return False + + except Exception as e: + logger.error(f"Error claiming revenue: {e}") + return False + + async def run(self): + """Main run loop""" + logger.info("Starting Soroban Revenue Claimer") + logger.info(f"Creator: {self.config.creator_address}") + logger.info(f"Threshold: {self.config.threshold / 1_000_000} USDC") + logger.info(f"Check interval: {self.config.check_interval} seconds") + + while True: + try: + logger.info("Checking for pending revenue...") + + pending = await self.check_pending_revenue() + + if not pending: + logger.info("No pending revenue above threshold") + else: + logger.info(f"Found {len(pending)} subscriptions with revenue above threshold") + + for subscription in pending: + success = await self.claim_revenue(subscription) + + if success: + logger.info(f"Successfully claimed {subscription['amount_usdc']} USDC") + else: + logger.error(f"Failed to claim from {subscription['subscriber']}") + + except KeyboardInterrupt: + logger.info("Received interrupt signal, stopping...") + break + except Exception as e: + logger.error(f"Error in main loop: {e}") + + # Wait for next check + await asyncio.sleep(self.config.check_interval) + +class StellarHorizon: + """Simple wrapper for Horizon API""" + + def __init__(self, horizon_url: str): + self.horizon_url = horizon_url + + async def load_account(self, address: str) -> Dict: + """Load account information from Horizon""" + url = f"{self.horizon_url}/accounts/{address}" + + async with requests.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response.json() + +def load_config() -> Config: + """Load configuration from environment variables""" + creator_address = os.getenv('CREATOR_ADDRESS') + if not creator_address: + raise ValueError("CREATOR_ADDRESS environment variable must be set") + + private_key = os.getenv('PRIVATE_KEY') + if not private_key: + raise ValueError("PRIVATE_KEY environment variable must be set") + + threshold = int(os.getenv('REVENUE_THRESHOLD', '100000000')) + check_interval = int(os.getenv('CHECK_INTERVAL', '300')) + network_url = os.getenv('NETWORK_URL', 'https://soroban-testnet.stellar.org') + horizon_url = os.getenv('HORIZON_URL', 'https://horizon-testnet.stellar.org') + contract_address = os.getenv('CONTRACT_ADDRESS', 'CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L') + usdc_address = os.getenv('USDC_ADDRESS', 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5') + + return Config( + creator_address=creator_address, + private_key=private_key, + threshold=threshold, + check_interval=check_interval, + network_url=network_url, + horizon_url=horizon_url, + contract_address=contract_address, + usdc_address=usdc_address + ) + +async def main(): + """Main entry point""" + try: + config = load_config() + claimer = SorobanRevenueClaimer(config) + await claimer.run() + + except KeyboardInterrupt: + logger.info("Received interrupt signal, exiting...") + sys.exit(0) + except Exception as e: + logger.error(f"Fatal error: {e}") + sys.exit(1) + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/test_basic_functionality.py b/scripts/test_basic_functionality.py new file mode 100644 index 0000000..4bdcce2 --- /dev/null +++ b/scripts/test_basic_functionality.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Basic functionality test for Revenue Claim Cron Job + +This script tests the core logic without requiring external dependencies. +""" + +import os +import sys +import json +from dataclasses import dataclass +from typing import List, Dict, Optional + +@dataclass +class Config: + """Configuration for the revenue claimer""" + creator_address: str + threshold: int = 100_000_000 # 100 USDC (6 decimals) + check_interval: int = 300 # 5 minutes + network_url: str = "https://horizon-testnet.stellar.org" + contract_address: str = "CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L" + private_key: Optional[str] = None + usdc_address: str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + +class MockRevenueClaimer: + """Mock implementation for testing""" + + def __init__(self, config: Config): + self.config = config + + def check_subscription_revenue(self, subscriber: str, creator: str) -> Optional[Dict]: + """Mock subscription revenue checking""" + # Simulate different scenarios + mock_data = { + "GTEST1": 120_000_000, # 120 USDC - above threshold + "GTEST2": 80_000_000, # 80 USDC - below threshold + "GTEST3": 200_000_000, # 200 USDC - above threshold + } + + amount = mock_data.get(subscriber, 0) + + if amount > 0: + return { + 'subscriber': subscriber, + 'creator': creator, + 'token': self.config.usdc_address, + 'balance': amount + 30_000_000, + 'last_collected': 1640995200, + 'amount_to_collect': amount + } + return None + + def get_pending_subscriptions(self) -> List[Dict]: + """Get subscriptions with revenue above threshold""" + pending = [] + + # Mock subscribers + subscribers = ["GTEST1", "GTEST2", "GTEST3", "GTEST4"] + + for subscriber in subscribers: + subscription = self.check_subscription_revenue(subscriber, self.config.creator_address) + if subscription and subscription['amount_to_collect'] >= self.config.threshold: + pending.append(subscription) + + return pending + + def claim_revenue(self, subscription: Dict) -> bool: + """Mock revenue claiming""" + print(f"Mock claiming {subscription['amount_to_collect'] / 1_000_000} USDC from {subscription['subscriber']}") + return True + +def test_config(): + """Test configuration creation""" + print("Testing configuration...") + + config = Config( + creator_address="GTEST", + threshold=50_000_000 + ) + + assert config.creator_address == "GTEST" + assert config.threshold == 50_000_000 + assert config.check_interval == 300 # Default value + print("✓ Configuration test passed") + +def test_threshold_logic(): + """Test threshold checking logic""" + print("Testing threshold logic...") + + config = Config(creator_address="GTEST", threshold=100_000_000) + claimer = MockRevenueClaimer(config) + + # Test different amounts + test_cases = [ + ("GTEST1", 120_000_000, True), # Above threshold + ("GTEST2", 80_000_000, False), # Below threshold + ("GTEST3", 200_000_000, True), # Above threshold + ] + + for subscriber, amount, should_claim in test_cases: + subscription = claimer.check_subscription_revenue(subscriber, "GTEST") + if should_claim: + assert subscription is not None + assert subscription['amount_to_collect'] >= config.threshold + else: + assert subscription is None or subscription['amount_to_collect'] < config.threshold + + print("✓ Threshold logic test passed") + +def test_pending_subscriptions(): + """Test getting pending subscriptions""" + print("Testing pending subscriptions...") + + config = Config(creator_address="GTEST", threshold=100_000_000) + claimer = MockRevenueClaimer(config) + + pending = claimer.get_pending_subscriptions() + + # Should have 2 subscriptions above threshold (GTEST1: 120, GTEST3: 200) + assert len(pending) == 2 + + # Verify amounts are above threshold + for subscription in pending: + assert subscription['amount_to_collect'] >= config.threshold + + print(f"✓ Found {len(pending)} pending subscriptions above threshold") + print("✓ Pending subscriptions test passed") + +def test_claiming_process(): + """Test the claiming process""" + print("Testing claiming process...") + + config = Config(creator_address="GTEST", threshold=100_000_000) + claimer = MockRevenueClaimer(config) + + pending = claimer.get_pending_subscriptions() + + # Claim each pending subscription + for subscription in pending: + success = claimer.claim_revenue(subscription) + assert success == True + + print("✓ Claiming process test passed") + +def test_usdc_conversion(): + """Test USDC amount conversion""" + print("Testing USDC conversion...") + + # Test various conversions + test_cases = [ + (100_000_000, 100.0), # 100 USDC + (150_000_000, 150.0), # 150 USDC + (1_000_000, 1.0), # 1 USDC + (500_000, 0.5), # 0.5 USDC + ] + + for stroops, expected_usdc in test_cases: + actual_usdc = stroops / 1_000_000 + assert actual_usdc == expected_usdc + + print("✓ USDC conversion test passed") + +def test_environment_config(): + """Test loading configuration from environment""" + print("Testing environment configuration...") + + # Set test environment variables + os.environ['CREATOR_ADDRESS'] = 'GTEST_ENV_CREATOR' + os.environ['REVENUE_THRESHOLD'] = '200' + os.environ['CHECK_INTERVAL'] = '600' + + # Mock environment loading + config = Config( + creator_address=os.getenv('CREATOR_ADDRESS', ''), + threshold=int(os.getenv('REVENUE_THRESHOLD', '100')) * 1_000_000, + check_interval=int(os.getenv('CHECK_INTERVAL', '300')) + ) + + assert config.creator_address == 'GTEST_ENV_CREATOR' + assert config.threshold == 200_000_000 # 200 USDC in stroops + assert config.check_interval == 600 + + # Clean up + del os.environ['CREATOR_ADDRESS'] + del os.environ['REVENUE_THRESHOLD'] + del os.environ['CHECK_INTERVAL'] + + print("✓ Environment configuration test passed") + +def run_all_tests(): + """Run all tests""" + print("=" * 50) + print("Running Revenue Claimer Basic Tests") + print("=" * 50) + + tests = [ + test_config, + test_threshold_logic, + test_pending_subscriptions, + test_claiming_process, + test_usdc_conversion, + test_environment_config, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except Exception as e: + print(f"✗ {test.__name__} failed: {e}") + failed += 1 + + print("=" * 50) + print(f"Test Results: {passed} passed, {failed} failed") + print("=" * 50) + + return failed == 0 + +if __name__ == '__main__': + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/scripts/test_revenue_claimer.py b/scripts/test_revenue_claimer.py new file mode 100644 index 0000000..b2180d6 --- /dev/null +++ b/scripts/test_revenue_claimer.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Test script for Revenue Claim Cron Job + +This script tests the basic functionality of the revenue claimer without +requiring actual network calls or private keys. +""" + +import os +import sys +import unittest +from unittest.mock import Mock, patch, MagicMock +from revenue_claim_cron_job import RevenueClaimer, Config + +class TestRevenueClaimer(unittest.TestCase): + """Test cases for RevenueClaimer""" + + def setUp(self): + """Set up test fixtures""" + self.config = Config( + creator_address="GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + threshold=100_000_000, # 100 USDC + check_interval=60, + network_url="https://horizon-testnet.stellar.org", + contract_address="CAOUX2FZ65IDC4F2X7LJJ2SVF23A35CCTZB7KVVN475JCLKTTU4CEY6L", + private_key=None # Test mode + ) + + def test_config_creation(self): + """Test configuration creation""" + self.assertEqual(self.config.creator_address, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF") + self.assertEqual(self.config.threshold, 100_000_000) + self.assertEqual(self.config.check_interval, 60) + + def test_config_default_values(self): + """Test default configuration values""" + config = Config( + creator_address="GTEST", + threshold=50_000_000 + ) + self.assertEqual(config.threshold, 50_000_000) + self.assertEqual(config.check_interval, 300) # Default value + self.assertEqual(config.network_url, "https://horizon-testnet.stellar.org") # Default value + + @patch('revenue_claim_cron_job.Server') + def test_claimer_initialization(self, mock_server): + """Test RevenueClaimer initialization""" + mock_server_instance = Mock() + mock_server.return_value = mock_server_instance + + claimer = RevenueClaimer(self.config) + + self.assertEqual(claimer.config, self.config) + self.assertIsNone(claimer.keypair) # No private key in test mode + self.assertIsNone(claimer.account) + + def test_check_subscription_revenue_mock(self): + """Test subscription revenue checking with mocked data""" + claimer = RevenueClaimer(self.config) + + # Mock the method to return known data + with patch.object(claimer, 'check_subscription_revenue') as mock_check: + mock_check.return_value = { + 'subscriber': 'GTEST1', + 'creator': 'GCREATOR', + 'token': 'GUSDC', + 'balance': 150_000_000, + 'last_collected': 1640995200, + 'amount_to_collect': 120_000_000 + } + + result = claimer.check_subscription_revenue( + 'GTEST1', + 'GCREATOR' + ) + + self.assertIsNotNone(result) + self.assertEqual(result['amount_to_collect'], 120_000_000) + self.assertEqual(result['subscriber'], 'GTEST1') + + def test_threshold_checking(self): + """Test threshold checking logic""" + claimer = RevenueClaimer(self.config) + + # Test subscription below threshold + subscription_below = { + 'amount_to_collect': 50_000_000, # 50 USDC + 'subscriber': 'GTEST1' + } + + # Test subscription above threshold + subscription_above = { + 'amount_to_collect': 150_000_000, # 150 USDC + 'subscriber': 'GTEST2' + } + + # Should not be claimed (below threshold) + self.assertLess(subscription_below['amount_to_collect'], self.config.threshold) + + # Should be claimed (above threshold) + self.assertGreater(subscription_above['amount_to_collect'], self.config.threshold) + + @patch('revenue_claim_cron_job.RevenueClaimer.get_pending_subscriptions') + def test_get_pending_subscriptions(self, mock_pending): + """Test getting pending subscriptions""" + claimer = RevenueClaimer(self.config) + + mock_pending.return_value = [ + { + 'subscriber': 'GTEST1', + 'creator': 'GCREATOR', + 'amount_to_collect': 120_000_000 + }, + { + 'subscriber': 'GTEST2', + 'creator': 'GCREATOR', + 'amount_to_collect': 80_000_000 # Below threshold + } + ] + + pending = claimer.get_pending_subscriptions() + + # Should only return subscriptions above threshold + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0]['subscriber'], 'GTEST1') + self.assertEqual(pending[0]['amount_to_collect'], 120_000_000) + + def test_usdc_conversion(self): + """Test USDC amount conversion (6 decimals)""" + amount_stroops = 150_000_000 # 150 USDC in stroops + amount_usdc = amount_stroops / 1_000_000 + + self.assertEqual(amount_usdc, 150.0) + + # Test threshold conversion + threshold_usdc = self.config.threshold / 1_000_000 + self.assertEqual(threshold_usdc, 100.0) + +class TestConfigLoading(unittest.TestCase): + """Test configuration loading from environment""" + + def setUp(self): + """Set up test environment""" + # Save original environment + self.original_env = os.environ.copy() + + def tearDown(self): + """Restore original environment""" + os.environ.clear() + os.environ.update(self.original_env) + + def test_load_config_from_env(self): + """Test loading configuration from environment variables""" + # Set test environment variables + os.environ['CREATOR_ADDRESS'] = 'GTEST_CREATOR' + os.environ['REVENUE_THRESHOLD'] = '200' + os.environ['CHECK_INTERVAL'] = '600' + os.environ['NETWORK_URL'] = 'https://custom.stellar.org' + + # Import here to use patched environment + from revenue_claim_cron_job import load_config_from_env + + config = load_config_from_env() + + self.assertEqual(config.creator_address, 'GTEST_CREATOR') + self.assertEqual(config.threshold, 200_000_000) # 200 USDC in stroops + self.assertEqual(config.check_interval, 600) + self.assertEqual(config.network_url, 'https://custom.stellar.org') + + def test_missing_required_env_var(self): + """Test error when required environment variable is missing""" + # Remove required environment variable + if 'CREATOR_ADDRESS' in os.environ: + del os.environ['CREATOR_ADDRESS'] + + from revenue_claim_cron_job import load_config_from_env + + with self.assertRaises(ValueError) as context: + load_config_from_env() + + self.assertIn('CREATOR_ADDRESS', str(context.exception)) + +def run_tests(): + """Run all tests""" + print("Running Revenue Claimer Tests...") + + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add test cases + suite.addTests(loader.loadTestsFromTestCase(TestRevenueClaimer)) + suite.addTests(loader.loadTestsFromTestCase(TestConfigLoading)) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Return success status + return result.wasSuccessful() + +if __name__ == '__main__': + success = run_tests() + sys.exit(0 if success else 1)