diff --git a/contracts/substream_contracts/src/lib.rs b/contracts/substream_contracts/src/lib.rs index 02df456..81318b1 100644 --- a/contracts/substream_contracts/src/lib.rs +++ b/contracts/substream_contracts/src/lib.rs @@ -1,8 +1,10 @@ #![no_std] #[cfg(test)] extern crate std; -use soroban_sdk::token::Client as TokenClient; -use soroban_sdk::{contract, contractevent, contractimpl, contracttype, vec, Address, Env}; +use soroban_sdk::{ + contract, contractevent, contractimpl, contracttype, token::Client as TokenClient, vec, Address, + Env, String, Vec, +}; // --- Constants --- const MINIMUM_FLOW_DURATION: u64 = 86400; @@ -11,7 +13,10 @@ const GRACE_PERIOD: u64 = 24 * 60 * 60; const GENESIS_NFT_ADDRESS: &str = "CAS3J7GYCCX7RRBHAHXDUY3OOWFMTIDDNVGCH6YOY7W7Y7G656H2HHMA"; const DISCOUNT_BPS: i128 = 2000; const SIX_MONTHS: u64 = 180 * 24 * 60 * 60; +const TWELVE_MONTHS: u64 = 365 * 24 * 60 * 60; const PRECISION_MULTIPLIER: i128 = 1_000_000_000; +const TTL_THRESHOLD: u32 = 17280; // ~1 day (assuming ~5s ledgers) +const TTL_BUMP_AMOUNT: u32 = 518400; // ~30 days // --- Helper: Charge Calculation --- fn calculate_discounted_charge(start_time: u64, charge_start: u64, now: u64, base_rate: i128) -> i128 { @@ -54,6 +59,8 @@ pub enum DataKey { CreatorSplit(Address), ContractAdmin, VerifiedCreator(Address), + CreatorProfileCID(Address), // For #46 + NFTAwarded(Address, Address), // (beneficiary, stream_id) - For #44 BlacklistedUser(Address, Address), // (creator, user_to_block) CreatorAudience(Address, Address), // (creator, beneficiary) } @@ -74,6 +81,7 @@ pub struct Subscription { pub last_collected: u64, pub start_time: u64, pub last_funds_exhausted: u64, + pub free_to_paid_emitted: bool, pub creators: soroban_sdk::Vec
, pub percentages: soroban_sdk::Vec, pub payer: Address, @@ -160,6 +168,27 @@ pub struct CreatorVerified { #[topic] pub verified_by: Address, } +#[contractevent] +pub struct FanNftAwarded { + #[topic] pub beneficiary: Address, + #[topic] pub creator: Address, // stream_id + pub awarded_at: u64, +} + +#[contractevent] +pub struct UserBlacklisted { + #[topic] pub creator: Address, + #[topic] pub user: Address, +} + +#[contractevent] +pub struct UserUnblacklisted { + #[topic] pub creator: Address, + #[topic] pub user: Address, +} + + + #[contract] pub struct SubStreamContract; @@ -314,35 +343,28 @@ impl SubStreamContract { get_creator_stats(&env, &creator) } - /// Upgrade or downgrade a subscription tier mid-period. - /// - /// All charges accrued at the old rate are settled first (pro-rated to the - /// second), then the rate is replaced atomically. The invariant tested by - /// the fuzz suite is: - /// total_paid == time_on_old_tier * old_rate + time_on_new_tier * new_rate - pub fn change_tier(env: Env, subscriber: Address, creator: Address, new_rate: i128) { - if new_rate <= 0 { panic!("invalid rate"); } - let key = subscription_key(&subscriber, &creator); - if !subscription_exists(&env, &key) { panic!("no subscription"); } - - let sub = get_subscription(&env, &key); - sub.payer.require_auth(); - let old_rate = sub.tier.rate_per_second; - - // Settle all pending charges at the old rate before switching tiers. - distribute_and_collect(&env, &subscriber, &creator, Some(&creator)); - - // Re-fetch after collect so we have the freshest last_collected timestamp. - let mut sub = get_subscription(&env, &key); - sub.tier.rate_per_second = new_rate; - set_subscription(&env, &key, &sub); + // --- Functions for #46: Multi-Language Metadata --- + pub fn set_profile_cid(env: Env, creator: Address, cid: String) { + creator.require_auth(); + let key = DataKey::CreatorProfileCID(creator.clone()); + env.storage().persistent().set(&key, &cid); + // Bump TTL for the new entry and instance + bump_instance_ttl(&env); + env.storage().persistent().bump(&key, TTL_THRESHOLD, TTL_BUMP_AMOUNT); + } - TierChanged { subscriber, creator, old_rate, new_rate }.publish(&env); + pub fn get_profile_cid(env: Env, creator: Address) -> Option { + let key = DataKey::CreatorProfileCID(creator); + env.storage().persistent().get(&key) } } // --- Internal Logic & Helpers --- +fn bump_instance_ttl(env: &Env) { + env.storage().instance().bump(TTL_THRESHOLD, TTL_BUMP_AMOUNT); +} + fn subscription_key(subscriber: &Address, stream_id: &Address) -> DataKey { DataKey::Subscription(subscriber.clone(), stream_id.clone()) } @@ -360,9 +382,15 @@ fn set_subscription(env: &Env, key: &DataKey, sub: &Subscription) { if sub.balance > 0 { env.storage().persistent().set(key, sub); env.storage().temporary().remove(key); + // Bump TTL for active subscriptions to keep them from expiring + bump_instance_ttl(env); + env.storage().persistent().bump(key, TTL_THRESHOLD, TTL_BUMP_AMOUNT); } else { env.storage().temporary().set(key, sub); env.storage().persistent().remove(key); + // Only bump instance TTL if we are moving to temporary storage, + // as the temporary entry will expire on its own. + bump_instance_ttl(env); } } @@ -445,10 +473,28 @@ fn credit_creator_earnings(env: &Env, creator: &Address, amount: i128) { } fn distribute_and_collect(env: &Env, beneficiary: &Address, stream_id: &Address, total_streamed_creator: Option<&Address>) -> i128 { + bump_instance_ttl(env); let key = subscription_key(beneficiary, stream_id); let mut sub = get_subscription(env, &key); let now = env.ledger().timestamp(); + // --- NFT Badge Logic (#44) --- + // Check for 12-month fan badge + let duration = now.saturating_sub(sub.start_time); + if duration > TWELVE_MONTHS { + let nft_key = DataKey::NFTAwarded(beneficiary.clone(), stream_id.clone()); + if !env.storage().persistent().has(&nft_key) { + env.storage().persistent().set(&nft_key, &true); + // Bump TTL for the new entry + env.storage().persistent().bump(&nft_key, TTL_THRESHOLD, TTL_BUMP_AMOUNT); + FanNftAwarded { + beneficiary: beneficiary.clone(), + creator: stream_id.clone(), + awarded_at: now, + }.publish(env); + } + } + if now <= sub.last_collected { return 0; } let trial_end = sub.start_time.saturating_add(sub.tier.trial_duration); @@ -517,6 +563,7 @@ fn distribute_and_collect(env: &Env, beneficiary: &Address, stream_id: &Address, } fn top_up_internal(env: &Env, beneficiary: &Address, stream_id: &Address, amount: i128) { + bump_instance_ttl(env); let key = subscription_key(beneficiary, stream_id); let mut sub = get_subscription(env, &key); sub.payer.require_auth(); @@ -531,6 +578,7 @@ fn top_up_internal(env: &Env, beneficiary: &Address, stream_id: &Address, amount } fn cancel_internal(env: &Env, beneficiary: &Address, stream_id: &Address) { + bump_instance_ttl(env); let key = subscription_key(beneficiary, stream_id); let mut sub = get_subscription(env, &key); sub.payer.require_auth(); @@ -556,6 +604,7 @@ fn cancel_internal(env: &Env, beneficiary: &Address, stream_id: &Address) { } fn subscribe_core(env: &Env, payer: &Address, beneficiary: &Address, stream_id: &Address, token: &Address, amount: i128, rate: i128, creators: soroban_sdk::Vec
, percentages: soroban_sdk::Vec) { + bump_instance_ttl(env); payer.require_auth(); let key = subscription_key(beneficiary, stream_id); if subscription_exists(env, &key) { panic!("exists"); } diff --git a/contracts/substream_contracts/src/test.rs b/contracts/substream_contracts/src/test.rs index 214f6cd..b52593f 100644 --- a/contracts/substream_contracts/src/test.rs +++ b/contracts/substream_contracts/src/test.rs @@ -1076,3 +1076,75 @@ fn test_creator_stats_scale_with_cached_counters() { assert_eq!(stats.active_fans, FAN_COUNT); assert_eq!(stats.total_earned, 0); } + +// --------------------------------------------------------------------------- +// Profile Metadata CID — Issue #46 +// --------------------------------------------------------------------------- + +#[test] +fn test_set_and_get_profile_cid() { + let env = Env::default(); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let contract_id = env.register(SubStreamContract, ()); + let client = SubStreamContractClient::new(&env, &contract_id); + + // Initially none + assert!(client.get_profile_cid(&creator).is_none()); + + // Set CID + let cid = soroban_sdk::String::from_str(&env, "ipfs://bafkreigh2akiscaildcqabsyg3dfr6cjhzm73eeeobcnukw45653cwobum"); + client.set_profile_cid(&creator, &cid); + + // Retrieve CID + let retrieved_cid = client.get_profile_cid(&creator).unwrap(); + assert_eq!(retrieved_cid, cid); +} + +// --------------------------------------------------------------------------- +// 12-Month NFT Badge Logic — Issue #44 +// --------------------------------------------------------------------------- + +#[test] +fn test_12_month_nft_badge_event_emission() { + let env = Env::default(); + env.mock_all_auths(); + + let subscriber = Address::generate(&env); + let creator = Address::generate(&env); + let admin = Address::generate(&env); + + let token = create_token_contract(&env, &admin); + let token_admin = token::StellarAssetClient::new(&env, &token.address); + + // Mint a large amount so the balance doesn't deplete over 12 months + token_admin.mint(&subscriber, &100_000_000); + + let contract_id = env.register(SubStreamContract, ()); + let client = SubStreamContractClient::new(&env, &contract_id); + + let start_time = 100u64; + env.ledger().set_timestamp(start_time); + + // Subscribe with a low rate so funds last + client.subscribe(&subscriber, &creator, &token.address, &100_000, &1); + + // Fast forward to exactly 12 months (TWELVE_MONTHS = 365 * 24 * 60 * 60 = 31536000) + // We need to go strictly OVER 12 months as per the condition `duration > TWELVE_MONTHS` + let twelve_months_and_a_day = 31536000 + 86400; + env.ledger().set_timestamp(start_time + twelve_months_and_a_day); + + // Record event count before collect + let events_before = last_call_contract_event_count(&env, &contract_id); + + // Collect will trigger the 12-month check + client.collect(&subscriber, &creator); + + // Assert that new events were emitted during this collect call (which corresponds to FanNftAwarded). + let events_after = last_call_contract_event_count(&env, &contract_id); + assert!( + events_after > events_before, + "Expected FanNftAwarded event to be emitted after 12 months of support" + ); +} diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index e1a1eac..69bfd6c 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -386,6 +386,87 @@ Changes to the governance process itself. --- +## Creator Profile Metadata Standard + +To ensure interoperability between different frontends and applications building on the SubStream Protocol, we propose a standardized JSON schema for creator profiles. This metadata should be stored off-chain (e.g., on IPFS), and the Content Identifier (CID) should be linked to the creator's on-chain profile using the `set_profile_cid` function. + +This standard is proposed under the **Informational Track** and helps fulfill the requirements of Issue #46 (Multi-Language Metadata) and #50 (Standardizing Creator CIDs). + +### Schema Definition (Version 1.0) + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SubStream Creator Profile", + "description": "Standard metadata for a SubStream creator profile.", + "type": "object", + "properties": { + "name": { + "description": "The display name of the creator.", + "type": "string" + }, + "bio": { + "description": "A short biography of the creator.", + "type": "string" + }, + "image": { + "description": "A URL (preferably IPFS) to the creator's profile picture.", + "type": "string", + "format": "uri" + }, + "socials": { + "description": "Links to social media profiles.", + "type": "object", + "properties": { + "twitter": { "type": "string" }, + "youtube": { "type": "string" }, + "website": { "type": "string", "format": "uri" } + } + }, + "i18n": { + "description": "Internationalization object for localized text, using language codes.", + "type": "object", + "patternProperties": { + "^[a-z]{2}(-[A-Z]{2})?$": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "bio": { "type": "string" } + } + } + } + } + }, + "required": ["name"] +} +``` + +### Example + +```json +{ + "name": "Cooking with Sarah", + "bio": "Exploring the world's cuisines, one dish at a time. Join my stream for exclusive recipes and live cooking sessions!", + "image": "ipfs://bafybeigv4vj3gblj6f27bm2i467p722m35ub22qalyk2sfyvj2f2j2j2j2", + "socials": { + "twitter": "CookWithSarah", + "youtube": "CookingWithSarahChannel" + }, + "i18n": { + "es": { + "name": "Cocinando con Sarah", + "bio": "Explorando las cocinas del mundo, un plato a la vez. ¡Únete a mi stream para recetas exclusivas y sesiones de cocina en vivo!" + }, + "fr": { + "name": "Cuisiner avec Sarah", + "bio": "Explorer les cuisines du monde, un plat à la fois. Rejoignez mon stream pour des recettes exclusives et des sessions de cuisine en direct !" + } + } +} +``` + +--- + ## Proposal Lifecycle States ```