diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 1adb1f23..74a56011 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -2710,6 +2710,24 @@ impl CreatorKeysContract { checked_format_quote_response(price, creator_fee, protocol_fee, true) } + /// Read-only price query helper for a given creator and supply step. + /// + /// Computes the bonding curve price for `supply` without requiring authorization + /// or mutating contract state. + /// + /// Returns `Err(ContractError::KeyPriceNotSet)` if base key price is not set, + /// or `Err(ContractError::Overflow)` if arithmetic overflows or supply exceeds `u32::MAX`. + pub fn query_price(env: Env, creator: Address, supply: u64) -> Result { + let supply_u32 = u32::try_from(supply).map_err(|_| ContractError::Overflow)?; + let base_price: i128 = env + .storage() + .persistent() + .get(&constants::storage::KEY_PRICE) + .ok_or(ContractError::KeyPriceNotSet)?; + + compute_bonding_curve_price(&env, &creator, base_price, supply_u32) + } + /// Read-only view: returns the total creator buyback cost for a given amount. /// /// The returned value is `base_price(amount) + protocol_fee(amount)` because the diff --git a/creator-keys/src/test_issues.rs b/creator-keys/src/test_issues.rs index 18b0d992..189084fa 100644 --- a/creator-keys/src/test_issues.rs +++ b/creator-keys/src/test_issues.rs @@ -701,4 +701,125 @@ mod issue_tests { "different creators must produce different locked allocation keys" ); } + + // ============================================================================= + // Tests for Issue #572 + // ============================================================================= + + #[test] + fn test_query_price_at_boundary_supplies() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + let base_price = 100i128; + client.set_key_price(&admin, &base_price); + + // Test for Linear curve + let creator = register_creator(&env, &client, None); + for supply in [0u64, 1u64, 50u64] { + let queried_price = client.query_price(&creator, &supply); + let expected_price = env.as_contract(&contract_id, || { + compute_bonding_curve_price(&env, &creator, base_price, supply as u32).unwrap() + }); + assert_eq!( + queried_price, expected_price, + "query_price output must match buy-path bonding curve formula for supply {}", + supply + ); + } + + // Test for Flat curve + let flat_creator = Address::generate(&env); + let handle = String::from_str(&env, "bob"); + client.register_creator( + &crate::RegisterCreatorParams { + creator: flat_creator.clone(), + handle, + }, + &None, + &None, + &None, + &Some(CurvePreset::Flat), + &None, + &None, + ); + for supply in [0u64, 1u64, 50u64] { + let price = client.query_price(&flat_creator, &supply); + assert_eq!(price, base_price); + } + + // Test for Quadratic curve + let quad_creator = Address::generate(&env); + let handle_q = String::from_str(&env, "charlie"); + client.register_creator( + &crate::RegisterCreatorParams { + creator: quad_creator.clone(), + handle: handle_q, + }, + &None, + &None, + &None, + &Some(CurvePreset::Quadratic), + &None, + &None, + ); + for supply in [0u64, 1u64, 50u64] { + let queried_price = client.query_price(&quad_creator, &supply); + let expected_price = env.as_contract(&contract_id, || { + compute_bonding_curve_price(&env, &quad_creator, base_price, supply as u32).unwrap() + }); + assert_eq!(queried_price, expected_price); + } + } + + #[test] + fn test_protocol_fee_calculation_at_boundary_supplies() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + let base_price = 100i128; + client.set_key_price(&admin, &base_price); + + let protocol_bps = 250u32; // 2.5% + let creator_bps = 500u32; // 5.0% + client.set_fee_config(&admin, &creator_bps, &protocol_bps); + + let creator = register_creator(&env, &client, None); + + // Boundary supply values: 0, 1, 1000 + for supply in [0u64, 1u64, 1000u64] { + let price = client.query_price(&creator, &supply); + let computed_fee = crate::fee::apply_percentage_fee(price, protocol_bps).unwrap(); + let expected_fee = (price * protocol_bps as i128) / crate::fee::BPS_MAX as i128; + + assert_eq!( + computed_fee, expected_fee, + "Protocol fee for supply {} must match expected BPS calculation", + supply + ); + } + } + + #[test] + fn test_protocol_fee_floors_on_non_whole_stroops() { + // Price = 1050 stroops, protocol_bps = 50 bps (0.5%) + // Exact: 1050 * 50 / 10000 = 5.25 stroops -> Floors to 5 stroops + let price_a = 1050i128; + let bps_a = 50u32; + let fee_a = crate::fee::apply_percentage_fee(price_a, bps_a).unwrap(); + assert_eq!(fee_a, 5, "Fee 5.25 stroops must floor to 5 stroops"); + + // Price = 105 stroops, protocol_bps = 50 bps (0.5%) + // Exact: 105 * 50 / 10000 = 0.525 stroops -> Floors to 0 stroops + let price_b = 105i128; + let bps_b = 50u32; + let fee_b = crate::fee::apply_percentage_fee(price_b, bps_b).unwrap(); + assert_eq!(fee_b, 0, "Fee 0.525 stroops must floor to 0 stroops"); + } }