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
4 changes: 3 additions & 1 deletion creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1898,7 +1898,9 @@ impl CreatorKeysContract {
creator: Address,
amount: u32,
) -> Result<i128, ContractError> {
validate_buyback_amount(amount)?;
if amount == 0 {
return Ok(0);
}

let Some(price) = resolve_quote_inputs(&env, &creator)? else {
return Ok(0);
Expand Down
2 changes: 0 additions & 2 deletions creator-keys/tests/buyback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

Expand Down
21 changes: 21 additions & 0 deletions creator-keys/tests/contract_test_env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &quote.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:
Expand Down
71 changes: 71 additions & 0 deletions creator-keys/tests/get_buyback_quote_zero_amount.rs
Original file line number Diff line number Diff line change
@@ -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, &quote.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, &quote.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"
);
}
112 changes: 112 additions & 0 deletions creator-keys/tests/locked_allocation_bonding_curve_supply.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
93 changes: 93 additions & 0 deletions creator-keys/tests/protocol_fee_recipient_updated_event.rs
Original file line number Diff line number Diff line change
@@ -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:?}"
);
}
Loading
Loading