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
```