Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 74 additions & 25 deletions contracts/substream_contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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<Address>,
pub percentages: soroban_sdk::Vec<u32>,
pub payer: Address,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> {
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())
}
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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<Address>, percentages: soroban_sdk::Vec<u32>) {
bump_instance_ttl(env);
payer.require_auth();
let key = subscription_key(beneficiary, stream_id);
if subscription_exists(env, &key) { panic!("exists"); }
Expand Down
72 changes: 72 additions & 0 deletions contracts/substream_contracts/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
81 changes: 81 additions & 0 deletions docs/GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down