From 3719dbf530dd04ee26ff32a579e34f49375d4ac8 Mon Sep 17 00:00:00 2001 From: privexlabs Date: Sat, 27 Jun 2026 07:15:56 -0700 Subject: [PATCH 1/2] feat: add tests for issues #471, #472, #475, #477 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #477 — Add field-level tests for ProtocolFeeRecipientUpdated event - New protocol_fee_recipient_updated_event.rs with three tests asserting old_recipient field, new_recipient field, and exactly-one-event-per-call #475 — Change get_buyback_quote to return 0 for amount=0; add tests - get_buyback_quote now short-circuits with Ok(0) when amount is zero instead of returning NotPositiveAmount (view functions should not reject zero-valued reads) - buyback itself keeps its validate_buyback_amount guard unchanged - Updated test_buyback_zero_amount_reverts to cover only the operation path - New get_buyback_quote_zero_amount.rs with three tests: returns zero, does not change supply, does not change holder balance #471 — Add setup_holders fixture helper and confirmation tests - New setup_holders helper in contract_test_env/mod.rs: registers N wallets with varied key balances in one call and returns final total supply - New setup_holders_helper.rs with two confirmation tests #472 — Add regression tests for locked allocation bonding-curve supply - New locked_allocation_bonding_curve_supply.rs with three tests: supply equals locked amount immediately, buy quote reflects that supply via the bonding curve, creator without allocation starts at zero supply --- creator-keys/src/lib.rs | 4 +- creator-keys/tests/buyback.rs | 2 - creator-keys/tests/contract_test_env/mod.rs | 21 ++++ .../tests/get_buyback_quote_zero_amount.rs | 68 ++++++++++++ .../locked_allocation_bonding_curve_supply.rs | 103 ++++++++++++++++++ .../protocol_fee_recipient_updated_event.rs | 87 +++++++++++++++ creator-keys/tests/setup_holders_helper.rs | 83 ++++++++++++++ 7 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 creator-keys/tests/get_buyback_quote_zero_amount.rs create mode 100644 creator-keys/tests/locked_allocation_bonding_curve_supply.rs create mode 100644 creator-keys/tests/protocol_fee_recipient_updated_event.rs create mode 100644 creator-keys/tests/setup_holders_helper.rs 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..a36bea4 --- /dev/null +++ b/creator-keys/tests/get_buyback_quote_zero_amount.rs @@ -0,0 +1,68 @@ +//! 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..5fc6bcf --- /dev/null +++ b/creator-keys/tests/locked_allocation_bonding_curve_supply.rs @@ -0,0 +1,103 @@ +//! 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..22ae7ca --- /dev/null +++ b/creator-keys/tests/protocol_fee_recipient_updated_event.rs @@ -0,0 +1,87 @@ +//! 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..d047525 --- /dev/null +++ b/creator-keys/tests/setup_holders_helper.rs @@ -0,0 +1,83 @@ +//! 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" + ); +} From b70dc9ad4dbeb3cd61916597b0bf9e1b1083f1f3 Mon Sep 17 00:00:00 2001 From: privexlabs Date: Sat, 27 Jun 2026 08:10:40 -0700 Subject: [PATCH 2/2] style: apply rustfmt to new test files Reformats protocol_fee_recipient_updated_event.rs, get_buyback_quote_zero_amount.rs, locked_allocation_bonding_curve_supply.rs, and setup_holders_helper.rs to match the project's rustfmt configuration. --- creator-keys/tests/get_buyback_quote_zero_amount.rs | 5 ++++- .../tests/locked_allocation_bonding_curve_supply.rs | 13 +++++++++++-- .../tests/protocol_fee_recipient_updated_event.rs | 12 +++++++++--- creator-keys/tests/setup_holders_helper.rs | 7 +------ 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/creator-keys/tests/get_buyback_quote_zero_amount.rs b/creator-keys/tests/get_buyback_quote_zero_amount.rs index a36bea4..78cc872 100644 --- a/creator-keys/tests/get_buyback_quote_zero_amount.rs +++ b/creator-keys/tests/get_buyback_quote_zero_amount.rs @@ -25,7 +25,10 @@ fn test_get_buyback_quote_returns_zero_for_zero_amount() { let result = client.get_buyback_quote(&creator, &0); - assert_eq!(result, 0, "get_buyback_quote with amount zero must return zero"); + assert_eq!( + result, 0, + "get_buyback_quote with amount zero must return zero" + ); } #[test] diff --git a/creator-keys/tests/locked_allocation_bonding_curve_supply.rs b/creator-keys/tests/locked_allocation_bonding_curve_supply.rs index 5fc6bcf..67a7f84 100644 --- a/creator-keys/tests/locked_allocation_bonding_curve_supply.rs +++ b/creator-keys/tests/locked_allocation_bonding_curve_supply.rs @@ -12,7 +12,10 @@ use contract_test_env::{ set_pricing_and_fees, test_env_with_auths, }; use creator_keys::LockedAllocation; -use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, String}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, String, +}; const KEY_PRICE: i128 = 100; const CURVE_SLOPE: i128 = 10; @@ -21,7 +24,13 @@ 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) { +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); diff --git a/creator-keys/tests/protocol_fee_recipient_updated_event.rs b/creator-keys/tests/protocol_fee_recipient_updated_event.rs index 22ae7ca..145f348 100644 --- a/creator-keys/tests/protocol_fee_recipient_updated_event.rs +++ b/creator-keys/tests/protocol_fee_recipient_updated_event.rs @@ -16,7 +16,14 @@ use soroban_sdk::{ Address, Env, IntoVal, }; -fn setup_update(env: &Env) -> (creator_keys::CreatorKeysContractClient<'_>, Address, Address, Address) { +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); @@ -79,8 +86,7 @@ fn test_protocol_fee_recipient_updated_event_emitted_once_per_update() { .count(); assert_eq!( - update_event_count, - 1, + 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 index d047525..bcf9bab 100644 --- a/creator-keys/tests/setup_holders_helper.rs +++ b/creator-keys/tests/setup_holders_helper.rs @@ -64,12 +64,7 @@ fn test_setup_holders_returns_correct_total_supply() { 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)], - ); + let total_supply = setup_holders(&env, &client, &creator, &[(wallet_a, 4), (wallet_b, 2)]); assert_eq!( total_supply, 6,