Skip to content
Open
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
160 changes: 37 additions & 123 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ pub enum ContractError {
SlippageExceeded = 16,
}

pub mod config {
/// Storage lifetime extension target for creator-scoped persistent keys.
///
/// The value is measured in ledgers. Keeping this as a named constant makes
/// future TTL policy changes possible without touching buy/sell trade logic.
pub const CREATOR_TTL_LEDGERS: u32 = 518_400;

/// Soroban extends a key only when its remaining TTL is below this threshold.
pub const CREATOR_TTL_THRESHOLD: u32 = 17_280;
}

pub mod fee {
use crate::ContractError;

Expand Down Expand Up @@ -291,6 +302,7 @@ pub struct CreatorDetailsView {
/// maintaining a separate off-chain index.
pub registered_at: u32,
}

/// Stable, non-optional view of a creator's fee configuration.
///
/// Returned by [`CreatorKeysContract::get_creator_fee_config`] for indexer-friendly consumption.
Expand Down Expand Up @@ -506,85 +518,26 @@ fn read_required_protocol_fee_config(env: &Env) -> Result<fee::FeeConfig, Contra
read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet)
}

fn read_protocol_fee_recipient_balance(env: &Env) -> i128 {
env.storage()
.persistent()
.get(&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE)
.unwrap_or(0)
}

fn credit_protocol_fee_recipient_balance(env: &Env, amount: i128) -> Result<(), ContractError> {
if amount <= 0 {
return Ok(());
}
let updated = read_protocol_fee_recipient_balance(env)
.checked_add(amount)
.ok_or(ContractError::Overflow)?;
env.storage().persistent().set(
&constants::storage::PROTOCOL_FEE_RECIPIENT_BALANCE,
&updated,
);
Ok(())
}

fn assert_buy_price_slippage(price: i128, max_price: Option<i128>) -> Result<(), ContractError> {
if let Some(max) = max_price {
if price > max {
return Err(ContractError::SlippageExceeded);
}
}
Ok(())
}

fn compute_sell_proceeds(env: &Env, price: i128) -> Result<i128, ContractError> {
let (creator_fee, protocol_fee) =
CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?;
let fees = fee::checked_fee_sum(creator_fee, protocol_fee).ok_or(ContractError::Overflow)?;
fee::checked_sub_i128(price, fees).ok_or(ContractError::SellUnderflow)
}

fn assert_sell_proceeds_slippage(
env: &Env,
min_proceeds: Option<i128>,
) -> Result<(), ContractError> {
if let Some(min) = min_proceeds {
let price: i128 = env
.storage()
.persistent()
.get(&constants::storage::KEY_PRICE)
.ok_or(ContractError::KeyPriceNotSet)?;
let proceeds = compute_sell_proceeds(env, price)?;
if proceeds < min {
return Err(ContractError::SlippageExceeded);
}
fn extend_creator_key_ttl(env: &Env, key: &DataKey) {
if env.storage().persistent().has(key) {
env.storage().persistent().extend_ttl(
key,
config::CREATOR_TTL_THRESHOLD,
config::CREATOR_TTL_LEDGERS,
);
}
Ok(())
}

fn accrue_sell_protocol_fee(env: &Env) -> Result<(), ContractError> {
if env
.storage()
.persistent()
.get::<DataKey, Address>(&constants::storage::PROTOCOL_FEE_RECIPIENT)
.is_none()
{
return Ok(());
}
fn extend_creator_storage_ttl(env: &Env, creator: &Address, holder: Option<&Address>) {
let creator_key = constants::storage::creator(creator);
extend_creator_key_ttl(env, &creator_key);

let Some(price) = env
.storage()
.persistent()
.get(&constants::storage::KEY_PRICE)
else {
return Ok(());
};

if read_protocol_fee_config(env).is_none() {
return Ok(());
if let Some(holder) = holder {
let holder_key = constants::storage::key_balance(creator, holder);
extend_creator_key_ttl(env, &holder_key);
}

let (_, protocol_fee) = CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?;
credit_protocol_fee_recipient_balance(env, protocol_fee)
extend_creator_key_ttl(env, &constants::storage::FEE_CONFIG);
}

/// Resolves and validates the shared inputs required by read-only quote methods.
Expand Down Expand Up @@ -710,6 +663,7 @@ impl CreatorKeysContract {
// Persist profile before event publication so indexers reading contract state
// after this tx observe the same registration payload that was emitted.
env.storage().persistent().set(&key, &profile);
extend_creator_storage_ttl(&env, &creator, None);
env.events().publish(
events::register_event_topics(&profile.creator),
events::CreatorRegisteredEvent {
Expand Down Expand Up @@ -777,6 +731,7 @@ impl CreatorKeysContract {
.ok_or(ContractError::Overflow)?;
// Balance key is scoped by (creator, holder) so creator positions cannot collide.
env.storage().persistent().set(&balance_key, &new_balance);
extend_creator_storage_ttl(&env, &creator, Some(&buyer));

if let Some(config) = read_protocol_fee_config(&env) {
let (creator_fee, protocol_fee) =
Expand Down Expand Up @@ -833,10 +788,7 @@ impl CreatorKeysContract {
// supply/holder_count invariants for subsequent reads.
env.storage().persistent().set(&key, &profile);
env.storage().persistent().set(&balance_key, &new_balance);
accrue_sell_protocol_fee(&env)?;

env.events()
.publish((events::SELL_EVENT_NAME, creator, seller), profile.supply);
extend_creator_storage_ttl(&env, &creator, Some(&seller));

Ok(profile.supply)
}
Expand Down Expand Up @@ -903,55 +855,17 @@ impl CreatorKeysContract {
}
}

/// Read-only batch view: returns [`CreatorDetailsView`] for each address in `creators`.
///
/// Iterates the provided addresses in order and fetches each creator's profile
/// from persistent storage. The output `Vec` is the same length as the input and
/// preserves input order, so clients can zip the two slices without an extra sort.
/// Read-only view: returns remaining ledger TTL for the creator's primary storage key.
///
/// Unregistered addresses never cause the call to fail: they produce a default
/// [`CreatorDetailsView`] with `is_registered: false` and `registered_at: 0`,
/// matching the single-address behaviour of [`get_creator_details`].
///
/// # Usage
///
/// ```text
/// let views = client.get_creators_batch(&vec![alice, bob, unknown]);
/// // views[0] → alice's details (is_registered: true)
/// // views[1] → bob's details (is_registered: true)
/// // views[2] → default view (is_registered: false, registered_at: 0)
/// ```
pub fn get_creators_batch(
env: Env,
creators: soroban_sdk::Vec<Address>,
) -> soroban_sdk::Vec<CreatorDetailsView> {
let mut results = soroban_sdk::Vec::new(&env);
for creator in creators.iter() {
let key = constants::storage::creator(&creator);
let view = match env
.storage()
.persistent()
.get::<DataKey, CreatorProfile>(&key)
{
Some(profile) => CreatorDetailsView {
creator: profile.creator,
handle: profile.handle,
supply: profile.supply,
is_registered: true,
registered_at: profile.registered_at,
},
None => CreatorDetailsView {
creator,
handle: read_none_string(&env),
supply: 0,
is_registered: false,
registered_at: 0,
},
};
results.push_back(view);
/// Returns `0` when the creator is not registered or the key is not live.
pub fn get_creator_ttl_remaining(env: Env, creator: Address) -> u32 {
let key = constants::storage::creator(&creator);
if !env.storage().persistent().has(&key) {
return 0;
}
results
env.storage().persistent().get_ttl(&key)
}

/// Read-only view: returns the protocol state version.
///
/// Returns a stable scalar value for clients and indexers to detect
Expand Down
150 changes: 150 additions & 0 deletions creator-keys/tests/ttl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use creator_keys::{config, constants, ContractError, CreatorKeysContract, CreatorKeysContractClient};
use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, String};

fn setup() -> (Env, CreatorKeysContractClient<'static>, Address) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(CreatorKeysContract, ());
let client = CreatorKeysContractClient::new(&env, &contract_id);
(env, client, contract_id)
}

fn advance_ledgers(env: &Env, ledgers: u32) {
env.ledger().with_mut(|li| {
li.sequence_number += ledgers;
});
}

fn creator_ttl(env: &Env, contract_id: &Address, creator: &Address) -> u32 {
env.as_contract(contract_id, || {
env.storage()
.persistent()
.get_ttl(&constants::storage::creator(creator))
})
}

fn holder_ttl(env: &Env, contract_id: &Address, creator: &Address, holder: &Address) -> u32 {
env.as_contract(contract_id, || {
env.storage()
.persistent()
.get_ttl(&constants::storage::key_balance(creator, holder))
})
}

fn fee_config_ttl(env: &Env, contract_id: &Address) -> u32 {
env.as_contract(contract_id, || {
env.storage()
.persistent()
.get_ttl(&constants::storage::FEE_CONFIG)
})
}

#[test]
fn registration_sets_initial_creator_ttl() {
let (env, client, contract_id) = setup();
let creator = Address::generate(&env);
let handle = String::from_str(&env, "alice");

client.register_creator(&creator, &handle);

let ttl = client.get_creator_ttl_remaining(&creator);
assert!(ttl > 0);
assert_eq!(ttl, creator_ttl(&env, &contract_id, &creator));
}

#[test]
fn buy_extends_creator_holder_and_fee_config_ttls() {
let (env, client, contract_id) = setup();
let admin = Address::generate(&env);
client.set_key_price(&admin, &100);
client.set_fee_config(&admin, &9000, &1000);

let creator = Address::generate(&env);
let buyer = Address::generate(&env);
let handle = String::from_str(&env, "alice");
client.register_creator(&creator, &handle);

advance_ledgers(
&env,
config::CREATOR_TTL_LEDGERS - config::CREATOR_TTL_THRESHOLD + 1,
);
let creator_before = creator_ttl(&env, &contract_id, &creator);
let fee_before = fee_config_ttl(&env, &contract_id);

client.buy_key(&creator, &buyer, &100);

let creator_after = creator_ttl(&env, &contract_id, &creator);
let holder_after = holder_ttl(&env, &contract_id, &creator, &buyer);
let fee_after = fee_config_ttl(&env, &contract_id);

assert!(creator_after > creator_before);
assert!(holder_after > 0);
assert!(fee_after > fee_before);
}

#[test]
fn sell_extends_creator_holder_and_fee_config_ttls() {
let (env, client, contract_id) = setup();
let admin = Address::generate(&env);
client.set_key_price(&admin, &100);
client.set_fee_config(&admin, &9000, &1000);

let creator = Address::generate(&env);
let seller = Address::generate(&env);
let handle = String::from_str(&env, "alice");
client.register_creator(&creator, &handle);
client.buy_key(&creator, &seller, &100);

advance_ledgers(
&env,
config::CREATOR_TTL_LEDGERS - config::CREATOR_TTL_THRESHOLD + 1,
);
let creator_before = creator_ttl(&env, &contract_id, &creator);
let holder_before = holder_ttl(&env, &contract_id, &creator, &seller);
let fee_before = fee_config_ttl(&env, &contract_id);

client.sell_key(&creator, &seller);

let creator_after = creator_ttl(&env, &contract_id, &creator);
let holder_after = holder_ttl(&env, &contract_id, &creator, &seller);
let fee_after = fee_config_ttl(&env, &contract_id);

assert!(creator_after > creator_before);
assert!(holder_after > holder_before);
assert!(fee_after > fee_before);
}

#[test]
fn failed_buy_does_not_extend_creator_ttl() {
let (env, client, contract_id) = setup();
let admin = Address::generate(&env);
client.set_key_price(&admin, &100);

let creator = Address::generate(&env);
let buyer = Address::generate(&env);
let handle = String::from_str(&env, "alice");
client.register_creator(&creator, &handle);

let before = creator_ttl(&env, &contract_id, &creator);
let result = client.try_buy_key(&creator, &buyer, &99);
let after = creator_ttl(&env, &contract_id, &creator);

assert_eq!(result, Err(Ok(ContractError::InsufficientPayment)));
assert_eq!(after, before);
}

#[test]
fn failed_sell_does_not_extend_creator_ttl() {
let (env, client, contract_id) = setup();
let creator = Address::generate(&env);
let seller = Address::generate(&env);
let handle = String::from_str(&env, "alice");
client.register_creator(&creator, &handle);

let before = creator_ttl(&env, &contract_id, &creator);
let result = client.try_sell_key(&creator, &seller);
let after = creator_ttl(&env, &contract_id, &creator);

assert_eq!(result, Err(Ok(ContractError::InsufficientBalance)));
assert_eq!(after, before);
}
Loading