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
27 changes: 27 additions & 0 deletions creator-keys/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,20 @@ pub const PROTOCOL_FEE_RECIPIENT_UPDATED_EVENT_NAME: Symbol = symbol_short!("p_f
/// Event name for creator fee recipient updated.
pub const CREATOR_FEE_RECIPIENT_UPDATED_EVENT_NAME: Symbol = symbol_short!("c_fee_upd");

/// Event name for co-creator fee accrual.
pub const CO_CREATOR_FEE_EARNED_EVENT_NAME: Symbol = symbol_short!("co_fee");

/// Stable field order for dividend distributed event payloads.
pub const DIVIDEND_DISTRIBUTED_DATA_FIELDS: [&str; 4] =
["creator", "total_amount", "snapshot_supply", "ledger"];

/// Stable field order for dividend claimed event payloads.
pub const DIVIDEND_CLAIMED_DATA_FIELDS: [&str; 3] = ["creator", "claimant", "amount"];

/// Stable field order for co-creator fee earned event payloads.
pub const CO_CREATOR_FEE_EARNED_DATA_FIELDS: [&str; 4] =
["creator_id", "co_creator", "amount", "ledger"];

#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct DividendDistributedEvent {
Expand Down Expand Up @@ -246,6 +253,26 @@ pub struct CreatorFeeRecipientUpdatedEvent {
pub new_recipient: Address,
}

#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct CoCreatorFeeEarned {
pub creator_id: Address,
pub co_creator: Address,
pub amount: i128,
pub ledger: u32,
}

pub fn co_creator_fee_earned_topics(
creator_id: &Address,
co_creator: &Address,
) -> (Symbol, Address, Address) {
(
CO_CREATOR_FEE_EARNED_EVENT_NAME,
creator_id.clone(),
co_creator.clone(),
)
}

/// Event name for key transfer.
pub const KEYS_TRANSFERRED_EVENT_NAME: Symbol = symbol_short!("xfer");

Expand Down
178 changes: 171 additions & 7 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub enum ContractError {
ZeroTransferAmount = 27,
InsufficientTreasuryBalance = 28,
BatchClaimExceedsLimit = 29,
InvalidCoCreatorShare = 30,
}

pub mod fee {
Expand Down Expand Up @@ -195,6 +196,19 @@ pub mod fee {
Some((creator_amount, protocol_amount))
}

/// Splits `total` into `(remainder, shared_amount)` by basis points.
///
/// Remainder from integer division stays with the primary recipient so the
/// two outputs always sum to `total`.
pub fn checked_split_bps_amount(total: i128, share_bps: u32) -> Option<(i128, i128)> {
if total <= 0 {
return Some((0, 0));
}
let shared_amount = apply_percentage_fee(total, share_bps)?;
let remainder = checked_sub_i128(total, shared_amount)?;
Some((remainder, shared_amount))
}

/// Performs checked integer multiplication for quote math helpers.
pub fn checked_mul_i128(a: i128, b: i128) -> Option<i128> {
a.checked_mul(b)
Expand Down Expand Up @@ -291,6 +305,14 @@ pub mod constants {
DataKey::CreatorFeeBalance(creator.clone())
}

pub fn co_creator(creator: &Address) -> DataKey {
DataKey::CoCreator(creator.clone())
}

pub fn co_creator_fee_balance(creator: &Address, co_creator: &Address) -> DataKey {
DataKey::CoCreatorFeeBalance(creator.clone(), co_creator.clone())
}

pub fn creator(creator: &Address) -> DataKey {
creator_key(creator)
}
Expand Down Expand Up @@ -334,6 +356,8 @@ pub mod constants {
pub const FEE_CONFIG: &str = "get_creator_fee_config";
pub const FEE_RECIPIENT: &str = "get_creator_fee_recipient";
pub const FEE_RECIPIENT_BALANCE: &str = "get_creator_fee_balance";
pub const CO_CREATOR: &str = "get_co_creator";
pub const CO_CREATOR_FEE_BALANCE: &str = "get_co_creator_fee_balance";
pub const HOLDER_KEY_COUNT: &str = "get_holder_key_count";
pub const PROFILE: &str = "get_creator";
pub const SUPPLY: &str = "get_creator_supply";
Expand Down Expand Up @@ -492,6 +516,8 @@ pub enum DataKey {
CurveSlope,
CurvePreset(Address),
TreasuryBalance,
CoCreator(Address),
CoCreatorFeeBalance(Address, Address),
}

/// Time-locked key allocation for creator self-vesting.
Expand All @@ -506,6 +532,17 @@ pub struct LockedAllocation {
pub claimed: bool,
}

/// Optional immutable collaborator split configured at creator registration.
///
/// `share_bps` is the co-creator's share of the creator fee, not of the full
/// trade price. It must be in the inclusive range `1..=9999`.
#[derive(Clone, Debug, PartialEq)]
#[contracttype]
pub struct CoCreatorConfig {
pub address: Address,
pub share_bps: u32,
}

#[derive(Clone, Debug, PartialEq)]
#[contracttype]
pub struct CreatorProfile {
Expand Down Expand Up @@ -601,6 +638,73 @@ fn credit_creator_fee_recipient_balance(
Ok(())
}

fn read_co_creator_config(env: &Env, creator: &Address) -> Option<CoCreatorConfig> {
let key = constants::storage::co_creator(creator);
env.storage()
.persistent()
.get::<DataKey, CoCreatorConfig>(&key)
}

fn validate_co_creator_config(env: &Env, config: &CoCreatorConfig) -> Result<(), ContractError> {
validate_non_zero_address(env, &config.address)?;
if !(1..fee::BPS_MAX).contains(&config.share_bps) {
return Err(ContractError::InvalidCoCreatorShare);
}
Ok(())
}

/// Reads accrued fee balance for a creator's configured co-creator.
pub fn read_co_creator_fee_balance(env: &Env, creator: &Address, co_creator: &Address) -> i128 {
let key = constants::storage::co_creator_fee_balance(creator, co_creator);
env.storage().persistent().get(&key).unwrap_or(0)
}

fn credit_co_creator_fee_balance(
env: &Env,
creator: &Address,
co_creator: &Address,
amount: i128,
) -> Result<(), ContractError> {
if amount <= 0 {
return Ok(());
}
let key = constants::storage::co_creator_fee_balance(creator, co_creator);
let current = read_co_creator_fee_balance(env, creator, co_creator);
let updated = current.checked_add(amount).ok_or(ContractError::Overflow)?;
env.storage().persistent().set(&key, &updated);
Ok(())
}

fn credit_creator_fee(env: &Env, creator: &Address, amount: i128) -> Result<(), ContractError> {
if amount <= 0 {
return Ok(());
}

let Some(config) = read_co_creator_config(env, creator) else {
return credit_creator_fee_recipient_balance(env, creator, amount);
};

let co_creator = config.address;
let (creator_recipient_amount, co_creator_amount) =
fee::checked_split_bps_amount(amount, config.share_bps).ok_or(ContractError::Overflow)?;
credit_creator_fee_recipient_balance(env, creator, creator_recipient_amount)?;
credit_co_creator_fee_balance(env, creator, &co_creator, co_creator_amount)?;

if co_creator_amount > 0 {
env.events().publish(
events::co_creator_fee_earned_topics(creator, &co_creator),
events::CoCreatorFeeEarned {
creator_id: creator.clone(),
co_creator,
amount: co_creator_amount,
ledger: env.ledger().sequence(),
},
);
}

Ok(())
}

fn is_valid_handle_byte(byte: u8) -> bool {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
}
Expand Down Expand Up @@ -764,23 +868,26 @@ fn assert_sell_proceeds_slippage(
Ok(())
}

fn accrue_sell_protocol_fee(env: &Env, price: i128) -> Result<(), ContractError> {
fn accrue_sell_trade_fees(env: &Env, creator: &Address, price: i128) -> Result<(), ContractError> {
if read_protocol_fee_config(env).is_none() {
return Ok(());
}

let (_, protocol_fee) = CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?;
let (creator_fee, protocol_fee) =
CreatorKeysContract::compute_fees_for_payment(env.clone(), price)?;
credit_creator_fee(env, creator, creator_fee)?;
credit_treasury_balance(env, protocol_fee)?;

if env
.storage()
.persistent()
.get::<DataKey, Address>(&constants::storage::PROTOCOL_FEE_RECIPIENT)
.is_none()
.is_some()
{
return Ok(());
credit_protocol_fee_recipient_balance(env, protocol_fee)?;
}
credit_protocol_fee_recipient_balance(env, protocol_fee)

Ok(())
}

/// Resolves and validates the shared inputs required by read-only quote methods.
Expand Down Expand Up @@ -1031,6 +1138,25 @@ fn extend_creator_ttl(env: &Env, creator: &Address) {
.persistent()
.extend_ttl(&curve_preset_key, threshold, extend_to);
}

let co_creator_key = constants::storage::co_creator(creator);
if env.storage().persistent().has(&co_creator_key) {
env.storage()
.persistent()
.extend_ttl(&co_creator_key, threshold, extend_to);

if let Some(config) = read_co_creator_config(env, creator) {
let co_creator_balance_key =
constants::storage::co_creator_fee_balance(creator, &config.address);
if env.storage().persistent().has(&co_creator_balance_key) {
env.storage().persistent().extend_ttl(
&co_creator_balance_key,
threshold,
extend_to,
);
}
}
}
}

#[contract]
Expand All @@ -1053,18 +1179,24 @@ impl CreatorKeysContract {
/// - `locked_allocation`: optional time-locked key allocation for creator self-vesting.
/// If provided, `unlock_ledger` must be strictly greater than current ledger.
/// - `max_supply`: optional maximum supply cap. If provided, must be greater than zero.
/// - `co_creator`: optional immutable collaborator split. If provided, `share_bps`
/// must be in the inclusive range `1..=9999`.
pub fn register_creator(
env: Env,
creator: Address,
handle: String,
locked_allocation: Option<LockedAllocation>,
max_supply: Option<u32>,
curve_preset: Option<CurvePreset>,
co_creator: Option<CoCreatorConfig>,
) -> Result<(), ContractError> {
creator.require_auth();
assert_not_paused(&env)?;

validate_creator_handle(&handle)?;
if let Some(config) = co_creator.as_ref() {
validate_co_creator_config(&env, config)?;
}

let key = constants::storage::creator(&creator);
// Creator profile storage is a single source of truth keyed by creator address.
Expand Down Expand Up @@ -1124,6 +1256,12 @@ impl CreatorKeysContract {
let preset_key = constants::storage::curve_preset(&creator);
env.storage().persistent().set(&preset_key, &preset);

if let Some(config) = co_creator {
env.storage()
.persistent()
.set(&constants::storage::co_creator(&creator), &config);
}

let profile = CreatorProfile {
creator: creator.clone(),
handle,
Expand All @@ -1149,6 +1287,12 @@ impl CreatorKeysContract {
env.storage()
.persistent()
.extend_ttl(&preset_key, current_ledger, extend_to);
let co_creator_key = constants::storage::co_creator(&creator);
if env.storage().persistent().has(&co_creator_key) {
env.storage()
.persistent()
.extend_ttl(&co_creator_key, current_ledger, extend_to);
}

env.events().publish(
events::register_event_topics(&profile.creator),
Expand Down Expand Up @@ -1238,7 +1382,7 @@ impl CreatorKeysContract {
let (creator_fee, protocol_fee) =
fee::checked_compute_fee_split(price, config.creator_bps, config.protocol_bps)
.ok_or(ContractError::Overflow)?;
credit_creator_fee_recipient_balance(&env, &creator, creator_fee)?;
credit_creator_fee(&env, &creator, creator_fee)?;
credit_protocol_fee_recipient_balance(&env, protocol_fee)?;
credit_treasury_balance(&env, protocol_fee)?;
}
Expand Down Expand Up @@ -1308,7 +1452,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, price)?;
accrue_sell_trade_fees(&env, &creator, price)?;

env.events().publish(
(events::SELL_EVENT_NAME, creator.clone(), seller),
Expand Down Expand Up @@ -1643,6 +1787,26 @@ impl CreatorKeysContract {
Ok(read_creator_fee_recipient_balance(&env, &creator))
}

/// Read-only view: returns the optional immutable co-creator config.
///
/// Returns `None` when the creator was registered without a co-creator split.
pub fn get_co_creator(env: Env, creator: Address) -> Option<CoCreatorConfig> {
read_co_creator_config(&env, &creator)
}

/// Read-only view: returns accrued co-creator fee balance for a creator.
///
/// Fails with [`ContractError::NotRegistered`] if the creator is not registered.
/// Returns `0` when no co-creator fees have accrued for the address.
pub fn get_co_creator_fee_balance(
env: Env,
creator: Address,
co_creator: Address,
) -> Result<i128, ContractError> {
read_registered_creator_profile(&env, &creator)?;
Ok(read_co_creator_fee_balance(&env, &creator, &co_creator))
}

/// Read-only view: returns the configured creator fee rate in basis points.
///
/// The returned value is the creator-facing share stored in the current protocol
Expand Down
Loading
Loading