diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 9bad8ab..7b1509e 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1898,7 +1898,9 @@ impl CreatorKeysContract { creator: Address, amount: u32, ) -> Result { - validate_buyback_amount(amount)?; + if amount == 0 { + return Ok(0); + } let Some(price) = resolve_quote_inputs(&env, &creator)? else { return Ok(0); diff --git a/creator-keys/tests/buyback.rs b/creator-keys/tests/buyback.rs index 20421da..3a0ad3c 100644 --- a/creator-keys/tests/buyback.rs +++ b/creator-keys/tests/buyback.rs @@ -127,10 +127,8 @@ fn test_buyback_zero_amount_reverts() { let (client, creator) = setup(&env); self_buy_keys(&client, &creator, 1); - let quote_result = client.try_get_buyback_quote(&creator, &0); let buyback_result = client.try_buyback(&creator, &creator, &0, &1, &None); - assert_eq!(quote_result, Err(Ok(ContractError::NotPositiveAmount))); assert_eq!(buyback_result, Err(Ok(ContractError::NotPositiveAmount))); } diff --git a/creator-keys/tests/contract_test_env/mod.rs b/creator-keys/tests/contract_test_env/mod.rs index c46c6ca..33c1dc7 100644 --- a/creator-keys/tests/contract_test_env/mod.rs +++ b/creator-keys/tests/contract_test_env/mod.rs @@ -423,6 +423,27 @@ pub fn assert_claimable( ); } +/// Registers a creator with multiple holders at varied balances in one call. +/// +/// For each `(holder, amount)` pair, buys `amount` keys from `holder`. +/// The buy quote is fetched before each individual purchase so the helper +/// works correctly under both flat and bonding-curve pricing models. +/// Returns the total supply after all buys complete. +pub fn setup_holders( + _env: &Env, + client: &CreatorKeysContractClient<'_>, + creator: &Address, + holders: &[(Address, u32)], +) -> u32 { + for (holder, amount) in holders { + for _ in 0..*amount { + let quote = client.get_buy_quote(creator); + client.buy_key(creator, holder, "e.total_amount, &None); + } + } + client.get_total_key_supply(creator) +} + /// Computes the expected claimable dividend for a holder given distribution parameters. /// /// Mirrors the contract's per-key accumulator model: diff --git a/creator-keys/tests/get_buyback_quote_zero_amount.rs b/creator-keys/tests/get_buyback_quote_zero_amount.rs new file mode 100644 index 0000000..78cc872 --- /dev/null +++ b/creator-keys/tests/get_buyback_quote_zero_amount.rs @@ -0,0 +1,71 @@ +//! Unit tests for `get_buyback_quote` with a zero amount input. +//! +//! Calling the view with `amount = 0` must return zero without reverting and must +//! not mutate any contract state. The actual `buyback` operation still rejects zero +//! via `NotPositiveAmount`; only the read-only quote path is permissive here. + +mod contract_test_env; + +use contract_test_env::{ + capture_snapshot, register_creator_keys, register_test_creator, set_pricing_and_fees, + test_env_with_auths, +}; +use soroban_sdk::testutils::Address as _; + +const KEY_PRICE: i128 = 1_000; +const CREATOR_BPS: u32 = 9_000; +const PROTOCOL_BPS: u32 = 1_000; + +#[test] +fn test_get_buyback_quote_returns_zero_for_zero_amount() { + 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 result = client.get_buyback_quote(&creator, &0); + + assert_eq!( + result, 0, + "get_buyback_quote with amount zero must return zero" + ); +} + +#[test] +fn test_get_buyback_quote_zero_amount_does_not_change_supply() { + 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 buyer = soroban_sdk::Address::generate(&env); + let quote = client.get_buy_quote(&creator); + client.buy_key(&creator, &buyer, "e.total_amount, &None); + + let before = capture_snapshot(&client, &creator, &buyer); + client.get_buyback_quote(&creator, &0); + let after = capture_snapshot(&client, &creator, &buyer); + + before.assert_unchanged(&after); +} + +#[test] +fn test_get_buyback_quote_zero_amount_does_not_change_holder_balance() { + 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 buyer = soroban_sdk::Address::generate(&env); + let quote = client.get_buy_quote(&creator); + client.buy_key(&creator, &buyer, "e.total_amount, &None); + + let balance_before = client.get_key_balance(&creator, &buyer); + client.get_buyback_quote(&creator, &0); + let balance_after = client.get_key_balance(&creator, &buyer); + + assert_eq!( + balance_before, balance_after, + "get_buyback_quote must not alter holder balance" + ); +} diff --git a/creator-keys/tests/locked_allocation_bonding_curve_supply.rs b/creator-keys/tests/locked_allocation_bonding_curve_supply.rs new file mode 100644 index 0000000..67a7f84 --- /dev/null +++ b/creator-keys/tests/locked_allocation_bonding_curve_supply.rs @@ -0,0 +1,112 @@ +//! Regression tests for locked allocation moving bonding-curve supply at registration. +//! +//! When a creator registers with a locked allocation of N keys the contract immediately +//! counts those keys in `total_supply`. Because the bonding curve prices the next buy +//! against the current supply, the buy quote must reflect N, not zero. +//! A creator registered without a locked allocation must still start at supply zero. + +mod contract_test_env; + +use contract_test_env::{ + compute_expected_bonding_curve_price, register_creator_keys, set_curve_slope, + set_pricing_and_fees, test_env_with_auths, +}; +use creator_keys::LockedAllocation; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, String, +}; + +const KEY_PRICE: i128 = 100; +const CURVE_SLOPE: i128 = 10; +const ALLOCATION_AMOUNT: u32 = 5; +const UNLOCK_LEDGER: u32 = 100; +const CREATOR_BPS: u32 = 9_000; +const PROTOCOL_BPS: u32 = 1_000; + +fn setup( + env: &Env, +) -> ( + creator_keys::CreatorKeysContractClient<'_>, + Address, + Address, +) { + let (client, _) = register_creator_keys(env); + set_pricing_and_fees(env, &client, KEY_PRICE, CREATOR_BPS, PROTOCOL_BPS); + set_curve_slope(env, &client, CURVE_SLOPE); + + // unlock_ledger must be strictly greater than current sequence. + let mut ledger_info = env.ledger().get(); + ledger_info.sequence_number = 1; + env.ledger().set(ledger_info); + + let creator_with_alloc = Address::generate(env); + client.register_creator( + &creator_with_alloc, + &String::from_str(env, "alice"), + &Some(LockedAllocation { + amount: ALLOCATION_AMOUNT, + unlock_ledger: UNLOCK_LEDGER, + claimed: false, + }), + &None, + ); + + let creator_no_alloc = Address::generate(env); + client.register_creator( + &creator_no_alloc, + &String::from_str(env, "bob"), + &None, + &None, + ); + + (client, creator_with_alloc, creator_no_alloc) +} + +#[test] +fn test_locked_allocation_sets_total_supply_immediately() { + let env = test_env_with_auths(); + let (client, creator_with_alloc, _) = setup(&env); + + assert_eq!( + client.get_total_key_supply(&creator_with_alloc), + ALLOCATION_AMOUNT, + "total supply must equal the locked allocation amount immediately after registration" + ); +} + +#[test] +fn test_buy_quote_reflects_locked_supply() { + let env = test_env_with_auths(); + let (client, creator_with_alloc, _) = setup(&env); + + let quote = client.get_buy_quote(&creator_with_alloc); + let expected_price = + compute_expected_bonding_curve_price(CURVE_SLOPE, KEY_PRICE, ALLOCATION_AMOUNT); + + assert_eq!( + quote.price, expected_price, + "buy quote must be priced at supply {} (not zero) to reflect the locked allocation", + ALLOCATION_AMOUNT + ); +} + +#[test] +fn test_creator_without_allocation_starts_at_zero_supply() { + let env = test_env_with_auths(); + let (client, _, creator_no_alloc) = setup(&env); + + assert_eq!( + client.get_total_key_supply(&creator_no_alloc), + 0, + "creator without locked allocation must start with supply zero" + ); + + let quote = client.get_buy_quote(&creator_no_alloc); + let expected_price = compute_expected_bonding_curve_price(CURVE_SLOPE, KEY_PRICE, 0); + + assert_eq!( + quote.price, expected_price, + "buy quote for creator without allocation must be priced at supply zero" + ); +} diff --git a/creator-keys/tests/protocol_fee_recipient_updated_event.rs b/creator-keys/tests/protocol_fee_recipient_updated_event.rs new file mode 100644 index 0000000..145f348 --- /dev/null +++ b/creator-keys/tests/protocol_fee_recipient_updated_event.rs @@ -0,0 +1,93 @@ +//! Field-level assertions for the `ProtocolFeeRecipientUpdated` event. +//! +//! Each test asserts exactly one field of the event payload so that a regression +//! on any single field produces a focused, descriptive failure. +//! +//! Event shape emitted by `update_protocol_fee_recipient`: +//! - topics: `(PROTOCOL_FEE_RECIPIENT_UPDATED_EVENT_NAME, admin)` +//! - data: `ProtocolFeeRecipientUpdatedEvent { old_recipient, new_recipient }` + +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, Env, IntoVal, +}; + +fn setup_update( + env: &Env, +) -> ( + creator_keys::CreatorKeysContractClient<'_>, + Address, + Address, + Address, +) { + let (client, _) = register_creator_keys(env); + let admin = Address::generate(env); + let old_recipient = Address::generate(env); + let new_recipient = Address::generate(env); + + client.set_protocol_admin(&admin, &admin); + client.set_protocol_fee_recipient(&admin, &old_recipient); + client.update_protocol_fee_recipient(&admin, &new_recipient); + + (client, admin, old_recipient, new_recipient) +} + +fn last_event_data(env: &Env) -> events::ProtocolFeeRecipientUpdatedEvent { + let event_log = env.events().all(); + let (_, _, data) = event_log.last().expect("at least one event must exist"); + data.into_val(env) +} + +#[test] +fn test_protocol_fee_recipient_updated_event_old_recipient_field() { + let env = test_env_with_auths(); + let (_, _, old_recipient, _) = setup_update(&env); + + let payload = last_event_data(&env); + assert_eq!( + payload.old_recipient, old_recipient, + "old_recipient field must match the address stored before the update" + ); +} + +#[test] +fn test_protocol_fee_recipient_updated_event_new_recipient_field() { + let env = test_env_with_auths(); + let (_, _, _, new_recipient) = setup_update(&env); + + let payload = last_event_data(&env); + assert_eq!( + payload.new_recipient, new_recipient, + "new_recipient field must match the address passed to update_protocol_fee_recipient" + ); +} + +#[test] +fn test_protocol_fee_recipient_updated_event_emitted_once_per_update() { + let env = test_env_with_auths(); + let (_, _, old_recipient, _) = setup_update(&env); + + let all_events = env.events().all(); + let update_event_count = all_events + .iter() + .filter(|(_, topics, _)| { + topics + .get(events::TOPIC_EVENT_NAME_INDEX) + .map(|v| { + let sym: soroban_sdk::Symbol = v.into_val(&env); + sym == events::PROTOCOL_FEE_RECIPIENT_UPDATED_EVENT_NAME + }) + .unwrap_or(false) + }) + .count(); + + assert_eq!( + update_event_count, 1, + "update_protocol_fee_recipient must emit exactly one event per update call; \ + old_recipient={old_recipient:?}" + ); +} diff --git a/creator-keys/tests/setup_holders_helper.rs b/creator-keys/tests/setup_holders_helper.rs new file mode 100644 index 0000000..bcf9bab --- /dev/null +++ b/creator-keys/tests/setup_holders_helper.rs @@ -0,0 +1,78 @@ +//! Confirmation tests for the `setup_holders` fixture helper. +//! +//! Verifies that each wallet receives the expected key balance and that the +//! returned total supply equals the sum of all amounts passed to the helper. + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_pricing_and_fees, setup_holders, + test_env_with_auths, +}; +use soroban_sdk::testutils::Address as _; + +const KEY_PRICE: i128 = 100; +const CREATOR_BPS: u32 = 9_000; +const PROTOCOL_BPS: u32 = 1_000; + +#[test] +fn test_setup_holders_produces_correct_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, "creator"); + + let wallet_a = soroban_sdk::Address::generate(&env); + let wallet_b = soroban_sdk::Address::generate(&env); + let wallet_c = soroban_sdk::Address::generate(&env); + + setup_holders( + &env, + &client, + &creator, + &[ + (wallet_a.clone(), 2), + (wallet_b.clone(), 3), + (wallet_c.clone(), 1), + ], + ); + + assert_eq!( + client.get_key_balance(&creator, &wallet_a), + 2, + "wallet_a must hold 2 keys" + ); + assert_eq!( + client.get_key_balance(&creator, &wallet_b), + 3, + "wallet_b must hold 3 keys" + ); + assert_eq!( + client.get_key_balance(&creator, &wallet_c), + 1, + "wallet_c must hold 1 key" + ); +} + +#[test] +fn test_setup_holders_returns_correct_total_supply() { + 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, "creator"); + + let wallet_a = soroban_sdk::Address::generate(&env); + let wallet_b = soroban_sdk::Address::generate(&env); + + let total_supply = setup_holders(&env, &client, &creator, &[(wallet_a, 4), (wallet_b, 2)]); + + assert_eq!( + total_supply, 6, + "returned supply must equal the sum of all bought amounts" + ); + assert_eq!( + client.get_total_key_supply(&creator), + 6, + "on-chain supply must match the returned value" + ); +}