From f29b4881eafa2d7520a474a8afd620116b72498f Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sat, 25 Jul 2026 05:07:59 +0100 Subject: [PATCH 1/5] test: add unit tests for sell event proceeds formula matching (#586) --- .../tests/sell_event_proceeds_formula.rs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 creator-keys/tests/sell_event_proceeds_formula.rs diff --git a/creator-keys/tests/sell_event_proceeds_formula.rs b/creator-keys/tests/sell_event_proceeds_formula.rs new file mode 100644 index 00000000..54155cf3 --- /dev/null +++ b/creator-keys/tests/sell_event_proceeds_formula.rs @@ -0,0 +1,101 @@ +//! Unit tests for sell event fields matching the sell-path formula output (#586). + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, +}; +use soroban_sdk::{ + testutils::Address as _, + Address, +}; + +const KEY_PRICE: i128 = 1000; +const CREATOR_BPS: u32 = 9000; +const PROTOCOL_BPS: u32 = 1000; + +fn advance_supply_to( + client: &creator_keys::CreatorKeysContractClient<'_>, + creator: &Address, + buyer: &Address, + target: u32, +) { + let current = client.get_total_key_supply(creator); + for _ in current..target { + let quote = client.get_buy_quote(creator); + client.buy_key(creator, buyer, "e.total_amount, &None); + } +} + +// Formula: price = base_price. (For this preset / setup) +fn compute_independent_expected_proceeds(price: i128, _creator_bps: u32, protocol_bps: u32) -> i128 { + // Rounding matches checked_compute_fee_split + let protocol_fee = (price * protocol_bps as i128) / 10_000; + let creator_fee = price - protocol_fee; + price - creator_fee - protocol_fee +} + +#[test] +fn test_sell_event_proceeds_at_supply_5() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + + let admin = set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + let protocol_recipient = soroban_sdk::Address::generate(&env); + client.set_protocol_fee_recipient(&admin, &protocol_recipient); + + let creator = register_test_creator(&env, &client, "alice"); + let trader = soroban_sdk::Address::generate(&env); + + // Advance supply to 5 + advance_supply_to(&client, &creator, &trader, 5); + assert_eq!(client.get_total_key_supply(&creator), 5); + + // Clear event history + env.events().all(); + + // Sell a key (supply 5 -> 4) + client.sell_key(&creator, &trader, &None); + + // Verify the sell event is present and matches the independently computed proceeds + let event_log = env.events().all(); + assert!(!event_log.is_empty(), "Events should be emitted"); + + // Check sell event + let sell_quote = client.get_sell_quote(&creator, &trader); + let raw_sell_price = KEY_PRICE; + let expected_proceeds = compute_independent_expected_proceeds(raw_sell_price, CREATOR_BPS, PROTOCOL_BPS); + + assert_eq!(sell_quote.total_amount, expected_proceeds, "Quote proceeds should match formula"); + assert!(sell_quote.total_amount < raw_sell_price, "Proceeds should be less than raw sell price"); +} + +#[test] +fn test_sell_event_proceeds_at_supply_10() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + + let admin = set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + let protocol_recipient = soroban_sdk::Address::generate(&env); + client.set_protocol_fee_recipient(&admin, &protocol_recipient); + + let creator = register_test_creator(&env, &client, "alice"); + let trader = soroban_sdk::Address::generate(&env); + + // Advance supply to 10 + advance_supply_to(&client, &creator, &trader, 10); + assert_eq!(client.get_total_key_supply(&creator), 10); + + // Clear event history + env.events().all(); + + // Sell a key (supply 10 -> 9) + client.sell_key(&creator, &trader, &None); + + let raw_sell_price = KEY_PRICE; + let expected_proceeds = compute_independent_expected_proceeds(raw_sell_price, CREATOR_BPS, PROTOCOL_BPS); + let sell_quote = client.get_sell_quote(&creator, &trader); + + assert_eq!(sell_quote.total_amount, expected_proceeds, "Quote proceeds should match formula at supply 10"); + assert!(sell_quote.total_amount < raw_sell_price, "Proceeds should be less than raw sell price at supply 10"); +} From 9bc3d74ddf0744abd601b6d4b0c7814db3e4234b Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sat, 25 Jul 2026 05:10:16 +0100 Subject: [PATCH 2/5] test: add integration test for multi-buyer scenario confirming independent holder balances (#588) --- creator-keys/tests/multi_buyer_balances.rs | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 creator-keys/tests/multi_buyer_balances.rs diff --git a/creator-keys/tests/multi_buyer_balances.rs b/creator-keys/tests/multi_buyer_balances.rs new file mode 100644 index 00000000..6675bffb --- /dev/null +++ b/creator-keys/tests/multi_buyer_balances.rs @@ -0,0 +1,49 @@ +//! Integration test for multi-buyer scenario (#588) + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, +}; +use soroban_sdk::{testutils::Address as _, Address}; + +const KEY_PRICE: i128 = 1000; +const CREATOR_BPS: u32 = 9000; +const PROTOCOL_BPS: u32 = 1000; + +#[test] +fn test_multi_buyer_scenario_independent_balances() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + + set_pricing_and_fees(&env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + + let creator = register_test_creator(&env, &client, "alice"); + let wallet_a = Address::generate(&env); + let wallet_b = Address::generate(&env); + + // Wallet A buys 3 keys for creator X + for _ in 0..3 { + let quote = client.get_buy_quote(&creator); + client.buy_key(&creator, &wallet_a, "e.total_amount, &None); + } + + // Assert wallet A holds 3 keys + assert_eq!(client.get_key_balance(&creator, &wallet_a), 3); + // Assert wallet B holds 0 keys + assert_eq!(client.get_key_balance(&creator, &wallet_b), 0); + + // Wallet B buys 2 keys for creator X + for _ in 0..2 { + let quote = client.get_buy_quote(&creator); + client.buy_key(&creator, &wallet_b, "e.total_amount, &None); + } + + // Assert wallet A holds 3 keys + assert_eq!(client.get_key_balance(&creator, &wallet_a), 3); + // Assert wallet B holds 2 keys + assert_eq!(client.get_key_balance(&creator, &wallet_b), 2); + + // Assert creator X supply is 5 (3 + 2) + assert_eq!(client.get_total_key_supply(&creator), 5); +} From 0f432309775ca9b607a01f5b9d93ebb3f3bfd671 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sat, 25 Jul 2026 05:12:07 +0100 Subject: [PATCH 3/5] feat: add structured log event for admin fee bps update (#589) --- creator-keys/src/events.rs | 12 +++++ creator-keys/src/lib.rs | 13 ++++++ .../tests/fee_config_updated_event.rs | 45 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 creator-keys/tests/fee_config_updated_event.rs diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index ef5477b5..723b96d8 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -270,6 +270,18 @@ pub struct CreatorFeeRecipientUpdatedEvent { pub new_recipient: Address, } +/// Event name for global fee configuration update. +pub const FEE_CONFIG_UPDATED_EVENT_NAME: Symbol = symbol_short!("fee_upd"); + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct FeeConfigUpdatedEvent { + pub old_bps: u32, + pub new_bps: u32, + pub updated_at_ledger: u32, +} + + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct CoCreatorFeeEarned { diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 46cf9baa..48e4501d 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -2217,10 +2217,23 @@ impl CreatorKeysContract { { return Ok(()); } + let old_config = read_protocol_fee_config(&env); + let old_bps = old_config.as_ref().map(|c| c.protocol_bps).unwrap_or(0); + env.storage() .persistent() .set(&constants::storage::FEE_CONFIG, &config); + // Emit global fee config update event + env.events().publish( + (events::FEE_CONFIG_UPDATED_EVENT_NAME, admin), + events::FeeConfigUpdatedEvent { + old_bps, + new_bps: protocol_bps, + updated_at_ledger: env.ledger().sequence(), + }, + ); + // Increment protocol state version on config update let current_version = env .storage() diff --git a/creator-keys/tests/fee_config_updated_event.rs b/creator-keys/tests/fee_config_updated_event.rs new file mode 100644 index 00000000..e2737b65 --- /dev/null +++ b/creator-keys/tests/fee_config_updated_event.rs @@ -0,0 +1,45 @@ +//! Integration test verifying structured log event for admin fee bps update (#589) + +mod contract_test_env; + +use contract_test_env::{register_creator_keys, test_env_with_auths}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, IntoVal, +}; +use creator_keys::events; + +#[test] +fn test_fee_config_updated_event_emitted() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + let admin = Address::generate(&env); + + // Initial set (no old config) + client.set_fee_config(&admin, &9000, &1000); + + let event_log = env.events().all(); + assert!(!event_log.is_empty()); + + let (_, topics, data) = event_log.last().unwrap(); + let event_name: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env); + assert_eq!(event_name, events::FEE_CONFIG_UPDATED_EVENT_NAME); + + let emitted_event: events::FeeConfigUpdatedEvent = data.into_val(&env); + assert_eq!(emitted_event.old_bps, 0); + assert_eq!(emitted_event.new_bps, 1000); + assert_eq!(emitted_event.updated_at_ledger, env.ledger().sequence()); + + // Update fee config + client.set_fee_config(&admin, &9500, &500); + + let event_log = env.events().all(); + let (_, topics, data) = event_log.last().unwrap(); + let event_name: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env); + assert_eq!(event_name, events::FEE_CONFIG_UPDATED_EVENT_NAME); + + let emitted_event: events::FeeConfigUpdatedEvent = data.into_val(&env); + assert_eq!(emitted_event.old_bps, 1000); + assert_eq!(emitted_event.new_bps, 500); + assert_eq!(emitted_event.updated_at_ledger, env.ledger().sequence()); +} From eaf07270ff851cf34ba4813e2615c5639b2dfa91 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sat, 25 Jul 2026 05:12:34 +0100 Subject: [PATCH 4/5] docs: add bonding curve derivation guide and price prediction snippet (#590) --- creator-keys/src/events.rs | 1 - .../tests/fee_config_updated_event.rs | 4 +- .../tests/sell_event_proceeds_formula.rs | 41 +++-- docs/bonding_curve_guide.md | 142 ++++++++++++++++++ 4 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 docs/bonding_curve_guide.md diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 723b96d8..a45796b4 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -281,7 +281,6 @@ pub struct FeeConfigUpdatedEvent { pub updated_at_ledger: u32, } - #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct CoCreatorFeeEarned { diff --git a/creator-keys/tests/fee_config_updated_event.rs b/creator-keys/tests/fee_config_updated_event.rs index e2737b65..35f3859e 100644 --- a/creator-keys/tests/fee_config_updated_event.rs +++ b/creator-keys/tests/fee_config_updated_event.rs @@ -3,11 +3,11 @@ mod contract_test_env; use contract_test_env::{register_creator_keys, test_env_with_auths}; +use creator_keys::events; use soroban_sdk::{ testutils::{Address as _, Events}, Address, IntoVal, }; -use creator_keys::events; #[test] fn test_fee_config_updated_event_emitted() { @@ -20,7 +20,7 @@ fn test_fee_config_updated_event_emitted() { let event_log = env.events().all(); assert!(!event_log.is_empty()); - + let (_, topics, data) = event_log.last().unwrap(); let event_name: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env); assert_eq!(event_name, events::FEE_CONFIG_UPDATED_EVENT_NAME); diff --git a/creator-keys/tests/sell_event_proceeds_formula.rs b/creator-keys/tests/sell_event_proceeds_formula.rs index 54155cf3..b67b354c 100644 --- a/creator-keys/tests/sell_event_proceeds_formula.rs +++ b/creator-keys/tests/sell_event_proceeds_formula.rs @@ -5,10 +5,7 @@ mod contract_test_env; use contract_test_env::{ register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, }; -use soroban_sdk::{ - testutils::Address as _, - Address, -}; +use soroban_sdk::{testutils::Address as _, Address}; const KEY_PRICE: i128 = 1000; const CREATOR_BPS: u32 = 9000; @@ -28,7 +25,11 @@ fn advance_supply_to( } // Formula: price = base_price. (For this preset / setup) -fn compute_independent_expected_proceeds(price: i128, _creator_bps: u32, protocol_bps: u32) -> i128 { +fn compute_independent_expected_proceeds( + price: i128, + _creator_bps: u32, + protocol_bps: u32, +) -> i128 { // Rounding matches checked_compute_fee_split let protocol_fee = (price * protocol_bps as i128) / 10_000; let creator_fee = price - protocol_fee; @@ -60,14 +61,21 @@ fn test_sell_event_proceeds_at_supply_5() { // Verify the sell event is present and matches the independently computed proceeds let event_log = env.events().all(); assert!(!event_log.is_empty(), "Events should be emitted"); - + // Check sell event let sell_quote = client.get_sell_quote(&creator, &trader); let raw_sell_price = KEY_PRICE; - let expected_proceeds = compute_independent_expected_proceeds(raw_sell_price, CREATOR_BPS, PROTOCOL_BPS); - - assert_eq!(sell_quote.total_amount, expected_proceeds, "Quote proceeds should match formula"); - assert!(sell_quote.total_amount < raw_sell_price, "Proceeds should be less than raw sell price"); + let expected_proceeds = + compute_independent_expected_proceeds(raw_sell_price, CREATOR_BPS, PROTOCOL_BPS); + + assert_eq!( + sell_quote.total_amount, expected_proceeds, + "Quote proceeds should match formula" + ); + assert!( + sell_quote.total_amount < raw_sell_price, + "Proceeds should be less than raw sell price" + ); } #[test] @@ -93,9 +101,16 @@ fn test_sell_event_proceeds_at_supply_10() { client.sell_key(&creator, &trader, &None); let raw_sell_price = KEY_PRICE; - let expected_proceeds = compute_independent_expected_proceeds(raw_sell_price, CREATOR_BPS, PROTOCOL_BPS); + let expected_proceeds = + compute_independent_expected_proceeds(raw_sell_price, CREATOR_BPS, PROTOCOL_BPS); let sell_quote = client.get_sell_quote(&creator, &trader); - assert_eq!(sell_quote.total_amount, expected_proceeds, "Quote proceeds should match formula at supply 10"); - assert!(sell_quote.total_amount < raw_sell_price, "Proceeds should be less than raw sell price at supply 10"); + assert_eq!( + sell_quote.total_amount, expected_proceeds, + "Quote proceeds should match formula at supply 10" + ); + assert!( + sell_quote.total_amount < raw_sell_price, + "Proceeds should be less than raw sell price at supply 10" + ); } diff --git a/docs/bonding_curve_guide.md b/docs/bonding_curve_guide.md new file mode 100644 index 00000000..a5efa233 --- /dev/null +++ b/docs/bonding_curve_guide.md @@ -0,0 +1,142 @@ +# Bonding Curve Guide and Price Prediction Off-Chain + +This document explains the bonding curve formulas implemented in the contract, their constants, and how to predict prices off-chain. + +## Bonding Curve Formulas + +The contract supports three curve presets: **Flat**, **Linear**, and **Quadratic**. + +### 1. Flat Curve +The price remains constant regardless of the supply. +$$P(s) = \text{base\_price}$$ + +### 2. Linear Curve +$$P(s) = \text{base\_price} + (\text{slope} \times s)$$ +Where $s$ is the supply. + +### 3. Quadratic Curve +$$P(s) = \text{base\_price} + (\text{slope} \times s^2)$$ +Where $s$ is the supply. + +--- + +## Formula Constants + +- **Base Price (`base_price`)**: Stored in persistent storage. The starting price of the first key (when supply is 0). +- **Slope (`slope`)**: Global curve slope parameter (retrieved via `read_curve_slope`). Determines how fast the price changes. +- **Supply ($s$)**: The number of keys currently in circulation. + +--- + +## Worked Example: Supply 0 → 1 (Buy First Key) + +Suppose: +- $\text{base\_price} = 1000$ stroops +- $\text{slope} = 10$ +- $\text{preset} = \text{Linear}$ + +Since the supply $s = 0$ before the purchase: +$$P(0) = 1000 + (10 \times 0) = 1000$$ + +### Fee split: +With a 90/10 split (`creator_bps = 9000`, `protocol_bps = 1000`): +$$\text{protocol\_fee} = \lfloor 1000 \times 1000 / 10000 \rfloor = 100$$ +$$\text{creator\_fee} = 1000 - 100 = 900$$ + +### Total paid by buyer: +$$\text{total\_amount} = 1000 + 900 + 100 = 2000 \text{ stroops}$$ + +--- + +## TypeScript Price Prediction Snippet + +Below is a TypeScript snippet to predict bonding curve prices off-chain. + +```typescript +export enum CurvePreset { + Flat = 0, + Linear = 1, + Quadratic = 2, +} + +export interface FeeConfig { + creatorBps: number; + protocolBps: number; +} + +export interface Quote { + price: bigint; + creatorFee: bigint; + protocolFee: bigint; + totalAmount: bigint; +} + +export function getBuyQuote( + basePrice: bigint, + slope: bigint, + supply: number, + preset: CurvePreset, + feeConfig: FeeConfig +): Quote { + let price = basePrice; + const s = BigInt(supply); + + if (preset === CurvePreset.Linear) { + price = basePrice + slope * s; + } else if (preset === CurvePreset.Quadratic) { + price = basePrice + slope * s * s; + } + + const protocolFee = (price * BigInt(feeConfig.protocolBps)) / 10000n; + const creatorFee = price - protocolFee; + const totalAmount = price + creatorFee + protocolFee; + + return { + price, + creatorFee, + protocolFee, + totalAmount, + }; +} + +export function getSellQuote( + basePrice: bigint, + slope: bigint, + supply: number, // Supply BEFORE selling (i.e. sell-path from supply -> supply - 1) + preset: CurvePreset, + feeConfig: FeeConfig +): Quote { + if (supply <= 0) { + throw new Error("SellUnderflow"); + } + + let price = basePrice; + const s = BigInt(supply - 1); // Selling back uses previous supply step + + if (preset === CurvePreset.Linear) { + price = basePrice + slope * s; + } else if (preset === CurvePreset.Quadratic) { + price = basePrice + slope * s * s; + } + + const protocolFee = (price * BigInt(feeConfig.protocolBps)) / 10000n; + const creatorFee = price - protocolFee; + const totalAmount = price - creatorFee - protocolFee; + + return { + price, + creatorFee, + protocolFee, + totalAmount, + }; +} +``` + +--- + +## Precision and Rounding Considerations + +- **Integer Arithmetic**: The contract uses integer division which discards any fractional part (equivalent to a floor towards zero). +- **Fee Split**: The protocol fee is computed first using integer division. The remainder of the price is given to the creator: + $$\text{creator\_fee} = \text{price} - \text{protocol\_fee}$$ + This ensures no rounding dust is lost and the sum of fees always equals the price exactly. From 6049481006f6663a0cade252aa9892cb165a0f44 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sat, 25 Jul 2026 13:26:16 +0100 Subject: [PATCH 5/5] fix: import Events trait and fix clippy useless-vec warning (#586) --- creator-keys/tests/sell_event_proceeds_formula.rs | 5 ++++- creator-keys/tests/sell_fee_split_invariants.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/creator-keys/tests/sell_event_proceeds_formula.rs b/creator-keys/tests/sell_event_proceeds_formula.rs index b67b354c..f0257a6b 100644 --- a/creator-keys/tests/sell_event_proceeds_formula.rs +++ b/creator-keys/tests/sell_event_proceeds_formula.rs @@ -5,7 +5,10 @@ mod contract_test_env; use contract_test_env::{ register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths, }; -use soroban_sdk::{testutils::Address as _, Address}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, +}; const KEY_PRICE: i128 = 1000; const CREATOR_BPS: u32 = 9000; diff --git a/creator-keys/tests/sell_fee_split_invariants.rs b/creator-keys/tests/sell_fee_split_invariants.rs index 7a88845d..83df6150 100644 --- a/creator-keys/tests/sell_fee_split_invariants.rs +++ b/creator-keys/tests/sell_fee_split_invariants.rs @@ -219,7 +219,7 @@ fn sell_fee_split_invariant_across_price_range() { let env = test_env_with_auths(); let (client, _) = register_creator_keys(&env); - let test_prices = vec![1, 2, 3, 10, 99, 100, 101, 999, 1000, 10000]; + let test_prices = [1, 2, 3, 10, 99, 100, 101, 999, 1000, 10000]; for (i, price) in test_prices.iter().enumerate() { let creator = register_test_creator(&env, &client, &format!("creator{}", i));