diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml index fab24d3..a7b95aa 100644 --- a/.github/workflows/formal-verification.yml +++ b/.github/workflows/formal-verification.yml @@ -69,7 +69,7 @@ jobs: echo "Branch: ${{ github.ref_name }}" >> formal_verification_report.txt - name: Upload formal verification report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: formal-verification-report path: contracts/substream_contracts/formal_verification_report.txt @@ -163,7 +163,7 @@ jobs: echo "for institutional deployment readiness." >> comprehensive_report.txt - name: Upload comprehensive report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: comprehensive-formal-verification-report path: contracts/substream_contracts/comprehensive_report.txt diff --git a/Cargo.lock b/Cargo.lock index 877f0ed..4538821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1742,6 +1742,7 @@ version = "0.0.0" dependencies = [ "ed25519-dalek", "soroban-sdk", + "stellar-strkey 0.0.16", ] [[package]] diff --git a/contracts/substream_contracts/src/lib.rs b/contracts/substream_contracts/src/lib.rs index 48e82a6..1d13d4b 100644 --- a/contracts/substream_contracts/src/lib.rs +++ b/contracts/substream_contracts/src/lib.rs @@ -1,10 +1,16 @@ -#![no_std] +#![no_std] #[cfg(test)] extern crate std; mod billing_dispute; use soroban_sdk::token::Client as TokenClient; +use soroban_sdk::{contract, contractevent, contractimpl, contracttype, vec, Address, Env}; +// --- Constants --- +const MINIMUM_FLOW_DURATION: u64 = 86400; +const FREE_TRIAL_DURATION: u64 = 7 * 24 * 60 * 60; +const GRACE_PERIOD: u64 = 24 * 60 * 60; +#[allow(dead_code)] const GENESIS_NFT_ADDRESS: &str = "CAS3J7GYCCX7RRBHAHXDUY3OOWFMTIDDNVGCH6YOY7W7Y7G656H2HHMA"; #[allow(dead_code)] const DISCOUNT_BPS: i128 = 2000; @@ -17,12 +23,6 @@ const REFERRAL_REBATE_BPS: i128 = 100; // 1% rebate const TTL_THRESHOLD: u32 = 17280; // Assuming ~1 day in ledgers for example const TTL_BUMP_AMOUNT: u32 = 518400; // Assuming ~30 days in ledgers for example -/// Maximum subscription records per batch import (Soroban CPU / memory limits). -const MAX_BULK_SUBSCRIPTION_IMPORTS: u32 = 50; -/// Domain separation for offline import intents (SEP-10–style structured payload). -pub(crate) const BULK_IMPORT_INTENT_PREFIX: &[u8] = b"SubStream:batch_import:v1:"; - - // --- SLA Circuit Breaker Constants --- const SLA_THRESHOLD_BPS: u32 = 9990; // 99.9% uptime threshold (in basis points) const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60; @@ -31,7 +31,9 @@ const UPTIME_ORACLE_NONCE_TTL: u64 = 24 * 60 * 60; // 24 hour validity for oracl // --- Merchant Registry and KYC Whitelisting Constants --- pub(crate) const DAO_MULTISIG_THRESHOLD: u32 = 3; // Minimum signatures required for DAO decisions const MERCHANT_KYC_VALIDITY: u64 = 365 * 24 * 60 * 60; // 1 year validity for KYC credentials -pub(crate) const SEP12_KYC_ISSUER: &str = "GD5DQX2K7Q4D4PE4R6J4Y7Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2"; // SEP-12 KYC issuer address +const SEP12_KYC_ISSUER: &str = "GD5DQX2K7Q4D4PE4R6J4Y7Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2Q2"; // SEP-12 KYC issuer address +const TIMELOCK_DURATION: u64 = 48 * 60 * 60; // 48-hour timelock for registry updates +const SECURITY_COUNCIL_SIZE: u32 = 5; // 5-member security council for multi-sig // --- Subscription billing / dispute escrow --- pub(crate) const DISPUTE_WINDOW_SEC: u64 = 48 * 60 * 60; @@ -81,7 +83,6 @@ fn calculate_discounted_charge( #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { - Subscription(Address, Address), Stream(Address, Address), TotalStreamed(Address, Address), CliffThreshold(Address), @@ -92,29 +93,24 @@ pub enum DataKey { Nullifier(Bytes), NullifierExpirationIndex(u64), // Index for tracking nullifier expiration cleanup YieldConfig(Address), - SLAStatus(Address), - UptimeOracleNonce(u64), - ContractAdmin, + SLAStatus(Address), // Merged from main + UptimeOracleNonce(u64), // Merged from main + ContractAdmin, // Integrated for verify_creator VerifiedCreator(Address), UserReferrer(Address), ReferralTracker(Address, Address), - CurrentFlowRate(Address), - AcceptedToken(Address), - PlanRegistry(Address), - TrialUsed(Address, Address), - BillingCycle(Address, Address), - BlacklistedUser(Address, Address), - MinimumRate(Address), - CommunityGoal(Address), - CreatorAudience(Address, Address), - - MerchantRegistry(Address), - KYCCredential(Address), - DAOProposal(u64), - DAOVote(Address, u64), - BlacklistedMerchant(Address), -< - ReentrancyGuard, + CurrentFlowRate(Address), // Aggregated flow rate for a channel + AcceptedToken(Address), // Issue #49: Creator's enforced stablecoin token + DaoGrant(Address, Address), // (dao, creator) — DAO treasury grant stream + UserContributed(Address, Address), // (fan, creator) — lifetime tokens contributed by fan + TopFans(Address), // (creator) — Top 50 fans by contribution +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TopFan { + pub fan: Address, + pub amount: i128, } #[contracttype] @@ -142,13 +138,6 @@ pub struct Subscription { pub accrued_remainder: i128, // Dust/fractional units that haven't been paid as tokens } -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct WrappedSubscriptionRef { - pub beneficiary: Address, - pub stream_id: Address, -} - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct SplitPartition { @@ -395,31 +384,6 @@ pub struct SecurityCouncilVeto { pub vetoed_at: u64, } -// --- Dynamic Protocol Fee Data Structures --- - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProtocolFeeConfig { - pub current_fee_bps: u32, // Current protocol fee in basis points - pub last_updated: u64, // Timestamp of last fee update - pub updated_by: Address, // Who updated the fee -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProtocolFeeUpdateProposal { - pub proposal_id: u64, - pub new_fee_bps: u32, // Proposed new fee in basis points - pub old_fee_bps: u32, // Current fee at time of proposal - pub proposed_by: Address, // Who proposed the change - pub proposed_at: u64, // When the proposal was created - pub executable_at: u64, // When the proposal can be executed (7 days later for increases) - pub votes_for: soroban_sdk::Vec
, // DAO members voting for - pub executed: bool, // Whether the proposal has been executed - pub canceled: bool, // Whether the proposal was canceled - pub is_fee_increase: bool, // True if new fee > old fee (triggers timelock) -} - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct NullifierExpiration { @@ -610,27 +574,6 @@ pub struct SubscriptionUpgraded { pub upgraded_at: u64, } -/// One record in a bulk Web2 → on-chain subscription migration batch. -/// `user_public_key` must be the 32-byte Ed25519 public key of `user` (G-address owner). -/// `signature` is an Ed25519 signature over [`bulk_import_intent_message`] (raw bytes, matching Soroban `verify_sig_ed25519`). -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BulkImportItem { - pub user: Address, - pub user_public_key: BytesN<32>, - pub plan_id: u32, - pub nonce: u64, - pub signature: BytesN<64>, -} - -#[contractevent] -pub struct BatchImportExecuted { - #[topic] pub merchant: Address, - pub merkle_root: BytesN<32>, - pub import_count: u32, - pub executed_at: u64, -} - // --- Merchant Registry and KYC Whitelisting Events --- #[contractevent] @@ -735,23 +678,13 @@ pub struct ReplayAttackBlocked { } #[contractevent] -pub struct ProtocolFeeUpdateScheduled { - #[topic] pub proposal_id: u64, - #[topic] pub proposed_by: Address, - pub old_fee_bps: u32, - pub new_fee_bps: u32, - pub proposed_at: u64, - pub executable_at: u64, - pub is_fee_increase: bool, -} - -#[contractevent] -pub struct ProtocolFeeUpdateExecuted { - #[topic] pub proposal_id: u64, - #[topic] pub executed_by: Address, - pub old_fee_bps: u32, - pub new_fee_bps: u32, - pub executed_at: u64, +pub struct CliffUnlocked { + #[topic] + pub fan: Address, + #[topic] + pub creator: Address, + pub total_contributed: i128, + pub cliff_threshold: i128, } #[contract] @@ -767,17 +700,6 @@ impl SubStreamContract { env.storage() .persistent() .set(&DataKey::ContractAdmin, &admin); - - // Initialize protocol fee configuration with default fee - let now = env.ledger().timestamp(); - let fee_config = ProtocolFeeConfig { - current_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, - last_updated: now, - updated_by: admin.clone(), - }; - env.storage() - .persistent() - .set(&DataKey::ProtocolFeeConfig, &fee_config); } pub fn verify_creator(env: Env, admin: Address, creator: Address) { @@ -1012,120 +934,6 @@ impl SubStreamContract { cancel_internal(&env, &subscriber, &channel_id); } - /// Optional wrapper: mint an internal NFT-like token ID that represents - /// ownership of a specific subscription position. - pub fn enable_subscription_transferability( - env: Env, - beneficiary: Address, - stream_id: Address, - ) -> u64 { - let key = subscription_key(&beneficiary, &stream_id); - let sub = get_subscription(&env, &key); - sub.payer.require_auth(); - - let wrapped_key = DataKey::WrappedTokenForSubscription(beneficiary.clone(), stream_id.clone()); - if env.storage().persistent().has(&wrapped_key) { - panic!("subscription already wrapped"); - } - - let mut token_id: u64 = env - .storage() - .persistent() - .get(&DataKey::NextWrappedTokenId) - .unwrap_or(1); - if token_id == 0 { - token_id = 1; - } - - let sub_ref = WrappedSubscriptionRef { - beneficiary: beneficiary.clone(), - stream_id: stream_id.clone(), - }; - - env.storage().persistent().set(&wrapped_key, &token_id); - env.storage() - .persistent() - .set(&DataKey::WrappedTokenOwner(token_id), &beneficiary); - env.storage() - .persistent() - .set(&DataKey::WrappedSubscriptionRef(token_id), &sub_ref); - env.storage() - .persistent() - .set(&DataKey::NextWrappedTokenId, &(token_id.saturating_add(1))); - - token_id - } - - /// Transfer wrapped subscription rights and billing ownership to a new wallet. - pub fn transfer_subscription_token(env: Env, token_id: u64, new_owner: Address) { - let current_owner = wrapped_token_owner(&env, token_id); - current_owner.require_auth(); - - let mut sub_ref = wrapped_subscription_ref(&env, token_id); - let stream_id = sub_ref.stream_id.clone(); - - if new_owner == current_owner { - return; - } - - // Settle accrued charges first so users cannot bypass imminent billing. - distribute_and_collect(&env, ¤t_owner, &stream_id, None); - - let old_key = subscription_key(¤t_owner, &stream_id); - let mut sub = get_subscription(&env, &old_key); - sub.payer = new_owner.clone(); - sub.beneficiary = new_owner.clone(); - if sub.balance <= 0 && sub.last_funds_exhausted == 0 { - sub.last_funds_exhausted = env.ledger().timestamp(); - } - - let new_key = subscription_key(&new_owner, &stream_id); - env.storage().persistent().remove(&old_key); - env.storage().temporary().remove(&old_key); - set_subscription(&env, &new_key, &sub); - - env.storage() - .persistent() - .remove(&DataKey::WrappedTokenForSubscription(current_owner.clone(), stream_id.clone())); - env.storage().persistent().set( - &DataKey::WrappedTokenForSubscription(new_owner.clone(), stream_id.clone()), - &token_id, - ); - env.storage() - .persistent() - .set(&DataKey::WrappedTokenOwner(token_id), &new_owner); - - sub_ref.beneficiary = new_owner.clone(); - env.storage() - .persistent() - .set(&DataKey::WrappedSubscriptionRef(token_id), &sub_ref); - - SubscriptionTransferred { - token_id, - stream_id, - previous_owner: current_owner, - new_owner, - } - .publish(&env); - } - - /// Ownership-aware access check for wrapped subscriptions. - pub fn check_access(env: Env, token_id: u64, user: Address) -> bool { - let owner = wrapped_token_owner(&env, token_id); - if owner != user { - return false; - } - let sub_ref = wrapped_subscription_ref(&env, token_id); - Self::is_subscribed(env, owner, sub_ref.stream_id) - } - - /// Ownership-aware pull execution for wrapped subscriptions. - pub fn execute_pull(env: Env, token_id: u64) -> i128 { - let owner = wrapped_token_owner(&env, token_id); - let sub_ref = wrapped_subscription_ref(&env, token_id); - distribute_and_collect(&env, &owner, &sub_ref.stream_id, None) - } - // --- Blacklist functionality for Issue #25 --- pub fn blacklist_user(env: Env, creator: Address, user_to_block: Address) { @@ -1241,21 +1049,25 @@ impl SubStreamContract { .persistent() .set(&DataKey::CliffThreshold(creator), &threshold); } - - /// Allow subscriber to cancel with immediate refund if SLA breach exceeds 7 days - pub fn emergency_cancel_due_to_sla(env: Env, subscriber: Address, creator: Address) { - subscriber.require_auth(); - let sla_status = get_sla_status(&env, &creator); - if sla_status.cumulative_downtime_minutes < 10080 { - panic!("SLA emergency cancellation only available after 7+ days of downtime"); - } - - if !sla_status.active { - panic!("SLA emergency cancellation only available during active breach"); + /// Returns `true` when the fan's lifetime contributions to this creator + /// meet or exceed the creator's configured cliff threshold. + /// Returns `false` if no threshold is set (feature not enabled by creator). + pub fn check_cliff_access(env: Env, fan: Address, creator: Address) -> bool { + let threshold: i128 = env + .storage() + .persistent() + .get(&DataKey::CliffThreshold(creator.clone())) + .unwrap_or(0); + if threshold == 0 { + return false; } - - cancel_internal(&env, &subscriber, &creator); + let contributed: i128 = env + .storage() + .persistent() + .get(&DataKey::UserContributed(fan, creator)) + .unwrap_or(0); + contributed >= threshold } /// Returns the fan's total lifetime token contributions to a creator. @@ -1266,6 +1078,14 @@ impl SubStreamContract { .unwrap_or(0) } + /// Returns the top 50 fans for a creator by total lifetime contributions. + pub fn get_top_fans(env: Env, creator: Address) -> soroban_sdk::Vec { + env.storage() + .persistent() + .get(&DataKey::TopFans(creator)) + .unwrap_or(soroban_sdk::Vec::new(&env)) + } + // ----------------------------------------------------------------------- env: Env, @@ -1291,12 +1111,11 @@ impl SubStreamContract { .get(&plan_registry_key) .expect("no plans found"); - let new_plan = plans - .iter() + let new_plan = plans.iter() .find(|p| p.plan_id == new_tier_id && p.is_active) .expect("new plan not found or inactive"); - - let old_tier_id = get_current_plan_id(&env, &merchant, billing_info.billing_amount); + + let old_plan_id = get_current_plan_id(&env, &merchant, billing_info.billing_amount); // Calculate proration let now = env.ledger().timestamp(); @@ -1818,11 +1637,157 @@ impl SubStreamContract { .expect("merchant not found") } + // --- Anonymous Subscription Verification with Nullifier Tracking --- + + // Constants for nullifier management + const NULLIFIER_VALIDITY_PERIOD: u64 = 30 * 24 * 60 * 60; // 30 days + const NULLIFIER_CLEANUP_BATCH_SIZE: u64 = 100; // Process up to 100 nullifiers per cleanup + + // Verify anonymous subscription with ZK-proof and nullifier + pub fn verify_anonymous_subscription( + env: Env, + merchant: Address, + proof: soroban_sdk::Bytes, + nullifier: soroban_sdk::Bytes, + ) { + // Create reentrancy guard + let _guard = reentrancy_guard!(&env, "verify_anonymous_subscription"); + + // Check if merchant is verified + if !is_merchant_verified(&env, &merchant) { + panic!("merchant is not verified"); + } + + // Check if nullifier already exists (replay attack prevention) + let nullifier_key = DataKey::Nullifier(nullifier.clone()); + if env.storage().persistent().has(&nullifier_key) { + // Emit replay attack blocked event + ReplayAttackBlocked { + merchant: merchant.clone(), + nullifier: nullifier.clone(), + blocked_at: env.ledger().timestamp(), + }.publish(&env); + + panic!("replay attack detected: nullifier already used"); + } + + // In a real implementation, this would verify the ZK-proof + // For now, we'll assume the proof is valid if it has the expected length + if proof.len() != 64 { + panic!("invalid proof length"); + } + + // Store nullifier with expiration timestamp + let now = env.ledger().timestamp(); + let expires_at = now.saturating_add(NULLIFIER_VALIDITY_PERIOD); + + // Store nullifier to prevent reuse + env.storage().persistent().set(&nullifier_key, &true); + + // Store expiration info for cleanup + let expiration_index_key = DataKey::NullifierExpirationIndex(now); + let expiration_info = NullifierExpiration { + nullifier: nullifier.clone(), + expires_at, + }; + env.storage().persistent().set(&expiration_index_key, &expiration_info); + + // Set TTL for cleanup entry + env.storage().persistent().extend_ttl(&expiration_index_key, NULLIFIER_VALIDITY_PERIOD, NULLIFIER_VALIDITY_PERIOD); + } + + // Try to verify anonymous subscription (returns Result for testing) + pub fn try_verify_anonymous_subscription( + env: Env, + merchant: Address, + proof: soroban_sdk::Bytes, + nullifier: soroban_sdk::Bytes, + ) -> Result<(), soroban_sdk::Error> { + // Create reentrancy guard + let _guard = reentrancy_guard!(&env, "try_verify_anonymous_subscription"); + + // Check if merchant is verified + if !is_merchant_verified(&env, &merchant) { + return Err(soroban_sdk::Error::from_contract_error(1)); + } + + // Check if nullifier already exists (replay attack prevention) + let nullifier_key = DataKey::Nullifier(nullifier.clone()); + if env.storage().persistent().has(&nullifier_key) { + // Emit replay attack blocked event + ReplayAttackBlocked { + merchant: merchant.clone(), + nullifier: nullifier.clone(), + blocked_at: env.ledger().timestamp(), + }.publish(&env); + + return Err(soroban_sdk::Error::from_contract_error(2)); + } + + // Verify proof + if proof.len() != 64 { + return Err(soroban_sdk::Error::from_contract_error(3)); + } + + // Store nullifier with expiration timestamp + let now = env.ledger().timestamp(); + let expires_at = now.saturating_add(NULLIFIER_VALIDITY_PERIOD); + + // Store nullifier to prevent reuse + env.storage().persistent().set(&nullifier_key, &true); + + // Store expiration info for cleanup + let expiration_index_key = DataKey::NullifierExpirationIndex(now); + let expiration_info = NullifierExpiration { + nullifier: nullifier.clone(), + expires_at, + }; + env.storage().persistent().set(&expiration_index_key, &expiration_info); + + // Set TTL for cleanup entry + env.storage().persistent().extend_ttl(&expiration_index_key, NULLIFIER_VALIDITY_PERIOD, NULLIFIER_VALIDITY_PERIOD); + + Ok(()) + } + + // Cleanup expired nullifiers to prevent storage bloat + pub fn cleanup_expired_nullifiers(env: Env) { + // Create reentrancy guard + let _guard = reentrancy_guard!(&env, "cleanup_expired_nullifiers"); + + let now = env.ledger().timestamp(); + let mut processed = 0u64; + + // Scan for expired nullifiers by timestamp + let mut current_timestamp = now.saturating_sub(NULLIFIER_VALIDITY_PERIOD); + + while processed < NULLIFIER_CLEANUP_BATCH_SIZE && current_timestamp <= now { + let expiration_index_key = DataKey::NullifierExpirationIndex(current_timestamp); + + if let Some(expiration_info) = env.storage().persistent().get::(&expiration_index_key) { + if expiration_info.expires_at <= now { + // Remove expired nullifier + let nullifier_key = DataKey::Nullifier(expiration_info.nullifier.clone()); + env.storage().persistent().remove(&nullifier_key); + + // Remove expiration index entry + env.storage().persistent().remove(&expiration_index_key); + + processed += 1; + } + } + + current_timestamp = current_timestamp.saturating_add(1); + } + } + + // Check if nullifier exists (for testing purposes) + pub fn is_nullifier_used(env: Env, nullifier: soroban_sdk::Bytes) -> bool { + env.storage().persistent().has(&DataKey::Nullifier(nullifier)) + } -} - -pub(crate) fn subscription_key(subscriber: &Address, stream_id: &Address) -> DataKey { - DataKey::Subscription(subscriber.clone(), stream_id.clone()) + fn subscription_key(subscriber: &Address, stream_id: &Address) -> DataKey { + DataKey::Subscription(subscriber.clone(), stream_id.clone()) } pub(crate) fn subscription_exists(env: &Env, key: &DataKey) -> bool { @@ -1939,6 +1904,95 @@ fn credit_creator_earnings(env: &Env, creator: &Address, amount: i128) { set_creator_stats(env, creator, &stats); } +fn update_top_fans(env: &Env, creator: &Address, fan: &Address, new_amount: i128) { + let key = DataKey::TopFans(creator.clone()); + let mut top_fans: soroban_sdk::Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or(soroban_sdk::Vec::new(env)); + + let mut existing_idx: Option = None; + for i in 0..top_fans.len() { + if top_fans.get(i).unwrap().fan == *fan { + existing_idx = Some(i); + break; + } + } + + if let Some(idx) = existing_idx { + // Update existing entry + top_fans.set(idx, TopFan { + fan: fan.clone(), + amount: new_amount, + }); + // Bubble up if needed + let mut curr = idx; + while curr > 0 { + let prev = curr - 1; + if top_fans.get(prev).unwrap().amount < top_fans.get(curr).unwrap().amount { + let p_val = top_fans.get(prev).unwrap(); + let c_val = top_fans.get(curr).unwrap(); + top_fans.set(prev, c_val); + top_fans.set(curr, p_val); + curr = prev; + } else { + break; + } + } + } else { + // New fan + if top_fans.len() < 50 { + top_fans.push_back(TopFan { + fan: fan.clone(), + amount: new_amount, + }); + // Bubble up + let mut curr = top_fans.len() - 1; + while curr > 0 { + let prev = curr - 1; + if top_fans.get(prev).unwrap().amount < top_fans.get(curr).unwrap().amount { + let p_val = top_fans.get(prev).unwrap(); + let c_val = top_fans.get(curr).unwrap(); + top_fans.set(prev, c_val); + top_fans.set(curr, p_val); + curr = prev; + } else { + break; + } + } + } else { + // List full, check if we beat the last one (index 49) + let last_idx = 49; + if new_amount > top_fans.get(last_idx).unwrap().amount { + top_fans.set(last_idx, TopFan { + fan: fan.clone(), + amount: new_amount, + }); + // Bubble up + let mut curr = last_idx; + while curr > 0 { + let prev = curr - 1; + if top_fans.get(prev).unwrap().amount < top_fans.get(curr).unwrap().amount { + let p_val = top_fans.get(prev).unwrap(); + let c_val = top_fans.get(curr).unwrap(); + top_fans.set(prev, c_val); + top_fans.set(curr, p_val); + curr = prev; + } else { + break; + } + } + } + } + } + + env.storage().persistent().set(&key, &top_fans); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP_AMOUNT); +} + /// Increments the fan's lifetime contribution counter for a creator and emits /// a `CliffUnlocked` event the first time the threshold is crossed. fn credit_fan_contribution(env: &Env, fan: &Address, creator: &Address, amount: i128) { @@ -1953,6 +2007,9 @@ fn credit_fan_contribution(env: &Env, fan: &Address, creator: &Address, amount: .persistent() .extend_ttl(&key, TTL_THRESHOLD, TTL_BUMP_AMOUNT); + // Update the top 50 leaderboard + update_top_fans(env, creator, fan, next); + // Emit CliffUnlocked exactly once — when the fan crosses the threshold. let threshold: i128 = env .storage() @@ -2043,31 +2100,10 @@ fn distribute_and_collect( let creators_len = sub.creators.len(); let mut remaining = amount_to_payout_tokens; - // Get current protocol fee configuration - let fee_config: ProtocolFeeConfig = env.storage().persistent() - .get(&DataKey::ProtocolFeeConfig) - .unwrap_or(ProtocolFeeConfig { - current_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, - last_updated: 0, - updated_by: env.current_contract_address(), - }); - - // Calculate protocol fee - let protocol_fee = (amount_to_payout_tokens * fee_config.current_fee_bps as i128) / 10000; - let amount_for_creators = amount_to_payout_tokens - protocol_fee; - - // Send protocol fee to treasury (contract admin acts as treasury) - if protocol_fee > 0 { - let treasury: Address = env.storage().persistent() - .get(&DataKey::ContractAdmin) - .expect("contract admin not found"); - token_client.transfer(&env.current_contract_address(), &treasury, &protocol_fee); - } - // Check for referral rebate before distributing to creators - let referral_rebate = if let Some(referrer) = get_user_referrer(env, &sub.beneficiary) { - // Calculate 1% rebate on the amount going to creators (not including protocol fee) - (amount_for_creators * REFERRAL_REBATE_BPS) / 10000 + let referral_rebate = if let Some(_referrer) = get_user_referrer(env, &sub.beneficiary) { + // Calculate 1% rebate on the total amount being paid out + (amount_to_payout_tokens * REFERRAL_REBATE_BPS) / 10000 } else { 0 }; @@ -2076,9 +2112,9 @@ fn distribute_and_collect( let creator = sub.creators.get(i).unwrap(); let share = sub.percentages.get(i).unwrap() as i128; let mut payout = if i + 1 == creators_len { - remaining - protocol_fee - referral_rebate + remaining } else { - (amount_for_creators * share) / 100 + (amount_to_payout_tokens * share) / 100 }; // Apply referral rebate if applicable and this is the first creator @@ -2158,7 +2194,7 @@ fn cancel_internal(env: &Env, beneficiary: &Address, stream_id: &Address) { // flash-subscribe attacks where a user subscribes, scrapes content, and // immediately cancels to recover their deposit. // - // Penalty = rate_per_second × MINIMUM_FLOW_DURATION (in internal nano + // Penalty = rate_per_second × MINIMUM_FLOW_DURATION (in internal nano // units), capped at the remaining balance so we never overdraw. let min_entitled_nano = sub .tier @@ -2461,306 +2497,6 @@ fn get_current_plan_id(env: &Env, merchant: &Address, billing_amount: i128) -> u 0 // Default plan ID if not found } -fn address_from_ed25519_public_key(env: &Env, pk: &BytesN<32>) -> Address { - let u = Uint256(pk.to_array()); - let aid = AccountId(PublicKey::PublicKeyTypeEd25519(u)); - let saddr = ScAddress::Account(aid); - let scv = ScVal::Address(saddr); - let v: Val = scv.into_val(env); - Address::try_from_val(env, &v).expect("ed25519 public key does not form a valid account address") -} - -pub(crate) fn bulk_import_intent_message( - env: &Env, - contract: &Address, - merchant: &Address, - user: &Address, - plan_id: u32, - nonce: u64, -) -> Bytes { - let mut msg = Bytes::new(env); - msg.extend_from_slice(BULK_IMPORT_INTENT_PREFIX); - msg.append(&contract.to_xdr(env)); - msg.append(&merchant.to_xdr(env)); - msg.append(&user.to_xdr(env)); - msg.extend_from_slice(&plan_id.to_be_bytes()); - msg.extend_from_slice(&nonce.to_be_bytes()); - msg -} - -fn bulk_import_leaf_hash(env: &Env, user: &Address, plan_id: u32) -> BytesN<32> { - let mut b = Bytes::new(env); - b.append(&user.to_xdr(env)); - b.extend_from_slice(&plan_id.to_be_bytes()); - env.crypto().keccak256(&b).to_bytes() -} - -/// Binary Merkle (Keccak parent) over the leaf list; if odd, last hash pairs with itself. -fn merkle_root_from_leaves(env: &Env, leaves: &Vec>) -> BytesN<32> { - if leaves.is_empty() { - return BytesN::from_array(env, &[0u8; 32]); - } - let mut level = vec![&env]; - for i in 0..leaves.len() { - level.push_back(leaves.get(i).unwrap().clone()); - } - while level.len() > 1u32 { - let mut next = vec![&env]; - let mut i = 0u32; - while i < level.len() { - let left = level.get(i).unwrap(); - let right = if i + 1 < level.len() { - level.get(i + 1).unwrap() - } else { - left - }; - let mut c = Bytes::new(env); - c.extend_from_slice(&left.to_array()); - c.extend_from_slice(&right.to_array()); - let h = env.crypto().keccak256(&c).to_bytes(); - next.push_back(h); - i += 2; - } - level = next; - } - level.get(0u32).unwrap().clone() -} - -fn resolve_plan(env: &Env, merchant: &Address, plan_id: u32) -> Plan { - let plan_registry_key = DataKey::PlanRegistry(merchant.clone()); - let plans: Vec = env - .storage() - .persistent() - .get(&plan_registry_key) - .expect("no plans for merchant"); - for j in 0u32..plans.len() { - let p = plans.get(j).unwrap(); - if p.plan_id == plan_id { - if !p.is_active { - panic!("plan inactive"); - } - return p; - } - } - panic!("plan not found"); -} - -// --- Timelock and Multi-Sig Governance Helper Functions --- - -fn generate_registry_proposal_id(env: &Env) -> u64 { - // Generate unique proposal ID based on timestamp and existing proposals - let now = env.ledger().timestamp(); - let mut proposal_id = now; - - // Ensure uniqueness by checking existing proposals - while env.storage().persistent().has(&DataKey::RegistryUpdateProposal(proposal_id)) { - proposal_id += 1; - } - - proposal_id -} - -fn is_authorized_proposer(env: &Env, proposer: &Address) -> bool { - // Security Council members and contract admin can propose - if is_security_council_member(env, proposer) { - return true; - } - - if let Some(admin) = env.storage().persistent().get::
(&DataKey::ContractAdmin) { - proposer == &admin - } else { - false - } -} - -fn is_emergency_authorized(env: &Env, proposer: &Address) -> bool { - // Emergency bypass requires admin authorization (higher security) - if let Some(admin) = env.storage().persistent().get::
(&DataKey::ContractAdmin) { - proposer == &admin - } else { - false - } -} - -fn is_security_council_member(env: &Env, member: &Address) -> bool { - if let Some(council_member) = env.storage().persistent().get::(&DataKey::SecurityCouncilMember(member.clone())) { - council_member.is_active - } else { - false - } -} - -fn execute_registry_update(env: &Env, proposal_id: u64) { - let proposal_key = DataKey::RegistryUpdateProposal(proposal_id); - let mut proposal: RegistryUpdateProposal = env.storage().persistent() - .get(&proposal_key) - .expect("proposal not found"); - - let merchant_key = DataKey::MerchantRegistry(proposal.merchant_address.clone()); - - match proposal.update_type { - RegistryUpdateType::WhitelistMerchant => { - // Check if merchant exists, if not create basic entry - let mut merchant_status = if let Some(existing_status) = env.storage().persistent().get::(&merchant_key) { - existing_status - } else { - MerchantStatus { - is_verified: false, - is_blacklisted: false, - verification_method: VerificationMethod::DAOApproval, - registered_at: env.ledger().timestamp(), - last_verified: 0, - dao_approved: false, - } - }; - - merchant_status.is_verified = true; - merchant_status.is_blacklisted = false; - merchant_status.verification_method = VerificationMethod::DAOApproval; - merchant_status.dao_approved = true; - merchant_status.last_verified = env.ledger().timestamp(); - - env.storage().persistent().set(&merchant_key, &merchant_status); - - // Emit merchant whitelisted event - MerchantWhitelisted { - merchant: proposal.merchant_address.clone(), - verification_method: VerificationMethod::DAOApproval, - whitelisted_at: env.ledger().timestamp(), - }.publish(env); - } - RegistryUpdateType::BlacklistMerchant => { - // Check if merchant exists - let mut merchant_status = env.storage().persistent() - .get::(&merchant_key) - .expect("merchant not registered"); - - merchant_status.is_blacklisted = true; - merchant_status.is_verified = false; - merchant_status.last_verified = env.ledger().timestamp(); - - env.storage().persistent().set(&merchant_key, &merchant_status); - env.storage().persistent().set(&DataKey::BlacklistedMerchant(proposal.merchant_address.clone()), &true); - - // Emit merchant blacklisted event - MerchantBlacklisted { - merchant: proposal.merchant_address.clone(), - blacklisted_by: Address::from_string(&soroban_sdk::String::from_str(env, "Security Council")), - reason: soroban_sdk::String::from_str(env, "Registry update proposal executed"), - blacklisted_at: env.ledger().timestamp(), - }.publish(env); - } - RegistryUpdateType::RemoveMerchant => { - // Remove merchant from registry - env.storage().persistent().remove(&merchant_key); - env.storage().persistent().remove(&DataKey::BlacklistedMerchant(proposal.merchant_address.clone())); - env.storage().persistent().remove(&DataKey::KYCCredential(proposal.merchant_address.clone())); - } - } - - // Mark proposal as executed - proposal.executed = true; - env.storage().persistent().set(&proposal_key, &proposal); -} - -// --- Merchant Registry and KYC Whitelisting Helper Functions --- - -fn is_merchant_registered(env: &Env, merchant: &Address) -> bool { - env.storage().persistent().has(&DataKey::MerchantRegistry(merchant.clone())) -} - -fn is_merchant_blacklisted(env: &Env, merchant: &Address) -> bool { - env.storage().persistent().has(&DataKey::BlacklistedMerchant(merchant.clone())) -} - -fn generate_proposal_id(env: &Env) -> u64 { - // Generate unique proposal ID based on timestamp and existing proposals - let now = env.ledger().timestamp(); - let mut proposal_id = now; - - // Ensure uniqueness by checking existing proposals - while env.storage().persistent().has(&DataKey::DAOProposal(proposal_id)) { - proposal_id += 1; - } - - proposal_id -} - -fn is_authorized_voter(env: &Env, voter: &Address) -> bool { - // For now, we'll use the contract admin as authorized voter - // In a real implementation, this could be based on DAO token holdings - if let Some(admin) = env.storage().persistent().get::
(&DataKey::ContractAdmin) { - voter == &admin - } else { - false - } -} - -fn is_authorized_dao_member(env: &Env, member: &Address) -> bool { - // For now, we'll use the contract admin as authorized DAO member - // In a real implementation, this would check against a DAO member list - if let Some(admin) = env.storage().persistent().get::
(&DataKey::ContractAdmin) { - member == &admin - } else { - false - } -} - -fn execute_merchant_proposal(env: &Env, proposal_id: u64) { - let proposal_key = DataKey::DAOProposal(proposal_id); - let mut proposal: DAOProposal = env.storage().persistent() - .get(&proposal_key) - .expect("proposal not found"); - - let merchant_key = DataKey::MerchantRegistry(proposal.merchant_address.clone()); - let mut merchant_status: MerchantStatus = env.storage().persistent() - .get(&merchant_key) - .expect("merchant not registered"); - - match proposal.proposal_type { - ProposalType::WhitelistMerchant => { - merchant_status.is_verified = true; - merchant_status.dao_approved = true; - merchant_status.verification_method = VerificationMethod::DAOApproval; - merchant_status.last_verified = env.ledger().timestamp(); - - // Emit merchant whitelisted event - MerchantWhitelisted { - merchant: proposal.merchant_address.clone(), - verification_method: VerificationMethod::DAOApproval, - whitelisted_at: env.ledger().timestamp(), - }.publish(env); - } - ProposalType::BlacklistMerchant => { - merchant_status.is_blacklisted = true; - merchant_status.is_verified = false; - merchant_status.last_verified = env.ledger().timestamp(); - - // Emit merchant blacklisted event - MerchantBlacklisted { - merchant: proposal.merchant_address.clone(), - blacklisted_by: Address::from_string(&soroban_sdk::String::from_str(env, "DAO")), - reason: soroban_sdk::String::from_str(env, "DAO proposal executed"), - blacklisted_at: env.ledger().timestamp(), - }.publish(env); - } - } - - // Mark proposal as executed - proposal.executed = true; - env.storage().persistent().set(&proposal_key, &proposal); - env.storage().persistent().set(&merchant_key, &merchant_status); - - // Emit proposal executed event - DAOProposalExecuted { - proposal_id, - merchant: proposal.merchant_address.clone(), - proposal_type: proposal.proposal_type.clone(), - executed: true, - executed_at: env.ledger().timestamp(), - }.publish(env); -} - // --- Global Reentrancy Guard Helper Functions --- /// RAII-style reentrancy guard using temporary storage @@ -2833,4 +2569,4 @@ mod test_formal_verification; #[cfg(test)] mod test_reentrancy_guard; #[cfg(test)] -mod test_dispute_escrow; +mod test_timelock_governance;