From 4fe3d83098599e419ea7c3eef8e74b874fbef91a Mon Sep 17 00:00:00 2001 From: harystyleseze Date: Sat, 27 Jun 2026 07:23:52 -0700 Subject: [PATCH 1/2] test: add regression tests and docs for issues #476, #478, #479, #480 Resolves #476 - holder_count_unchanged_after_supply_cap_exceeded.rs: regression test confirming holder count is not incremented when a buy reverts due to SupplyCapExceeded; the second wallet must not appear in the holder list and total supply must remain unchanged. Resolves #478 - dividend_claim_zeroes_claimable_regression.rs: regression test confirming get_claimable_dividend returns exactly zero after a successful claim, and that a second claim attempt reverts with NoDividendClaimable. Resolves #479 - docs/transfer-supply-cap-and-dividend-interaction.md: documents that the supply cap only applies to buy_key (not transfer), that past claimable amounts are settled before balance changes on transfer so neither party's claimable is affected, that future distributions use post-transfer balances, and that pending claimable is not carried with keys on transfer. Resolves #480 - buyback_event_new_supply_regression.rs: regression tests confirming the KeysBoughtBackEvent new_supply field equals pre-buyback supply minus buyback amount, is zero on a full buyback, and is nonzero when supply remains. --- .../buyback_event_new_supply_regression.rs | 104 ++++++++++++++++++ ...idend_claim_zeroes_claimable_regression.rs | 59 ++++++++++ ...unt_unchanged_after_supply_cap_exceeded.rs | 74 +++++++++++++ ...fer-supply-cap-and-dividend-interaction.md | 96 ++++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 creator-keys/tests/buyback_event_new_supply_regression.rs create mode 100644 creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs create mode 100644 creator-keys/tests/holder_count_unchanged_after_supply_cap_exceeded.rs create mode 100644 docs/transfer-supply-cap-and-dividend-interaction.md diff --git a/creator-keys/tests/buyback_event_new_supply_regression.rs b/creator-keys/tests/buyback_event_new_supply_regression.rs new file mode 100644 index 0000000..1921db1 --- /dev/null +++ b/creator-keys/tests/buyback_event_new_supply_regression.rs @@ -0,0 +1,104 @@ +//! Regression test: `KeysBoughtBack` event contains correct `new_supply` value. +//! +//! The `new_supply` field in the `KeysBoughtBackEvent` must equal the pre-buyback +//! supply minus the buyback amount. A full buyback of all supply must result in +//! `new_supply` of zero. + +mod contract_test_env; + +use contract_test_env::{register_creator_keys, register_test_creator, set_pricing_and_fees}; +use creator_keys::events; +use soroban_sdk::{ + testutils::Events, + Address, Env, IntoVal, +}; + +const KEY_PRICE: i128 = 1_000; +const CREATOR_BPS: u32 = 9_000; +const PROTOCOL_BPS: u32 = 1_000; + +fn setup(env: &Env) -> (creator_keys::CreatorKeysContractClient<'_>, Address) { + 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"); + (client, creator) +} + +fn self_buy_keys( + client: &creator_keys::CreatorKeysContractClient<'_>, + creator: &Address, + count: u32, +) { + for _ in 0..count { + let quote = client.get_buy_quote(creator); + client.buy_key(creator, creator, "e.total_amount, &None); + } +} + +fn last_buyback_payload( + env: &Env, +) -> events::KeysBoughtBackEvent { + let event_log = env.events().all(); + let last = event_log.last().unwrap(); + last.2.into_val(env) +} + +#[test] +fn test_buyback_event_new_supply_equals_supply_before_minus_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (client, creator) = setup(&env); + self_buy_keys(&client, &creator, 5); + + let supply_before = client.get_total_key_supply(&creator); + assert_eq!(supply_before, 5, "setup: expected supply of 5"); + + let buyback_amount: u32 = 3; + let total_cost = client.get_buyback_quote(&creator, &buyback_amount); + client.buyback(&creator, &creator, &buyback_amount, &total_cost, &None); + + let payload = last_buyback_payload(&env); + assert_eq!( + payload.new_supply, + supply_before - buyback_amount, + "new_supply must equal pre-buyback supply minus buyback amount" + ); +} + +#[test] +fn test_buyback_full_supply_results_in_new_supply_zero() { + let env = Env::default(); + env.mock_all_auths(); + let (client, creator) = setup(&env); + self_buy_keys(&client, &creator, 3); + + let total_cost = client.get_buyback_quote(&creator, &3); + client.buyback(&creator, &creator, &3, &total_cost, &None); + + let payload = last_buyback_payload(&env); + assert_eq!( + payload.new_supply, 0, + "full buyback of all supply must result in new_supply of zero" + ); +} + +#[test] +fn test_buyback_event_new_supply_nonzero_when_supply_remains() { + let env = Env::default(); + env.mock_all_auths(); + let (client, creator) = setup(&env); + self_buy_keys(&client, &creator, 4); + + let total_cost = client.get_buyback_quote(&creator, &1); + client.buyback(&creator, &creator, &1, &total_cost, &None); + + let payload = last_buyback_payload(&env); + assert!( + payload.new_supply > 0, + "new_supply must be nonzero when supply remains after buyback" + ); + assert_eq!( + payload.new_supply, 3, + "new_supply must equal 3 after buying back 1 from supply of 4" + ); +} diff --git a/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs b/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs new file mode 100644 index 0000000..2f28612 --- /dev/null +++ b/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs @@ -0,0 +1,59 @@ +//! Regression test: dividend claim zeroes claimable balance after successful withdrawal. +//! +//! After a holder successfully claims their dividend, their claimable balance must +//! be set to exactly zero. `get_claimable_dividend` must return zero after a claim, +//! not the previously claimed amount. A second claim attempt must revert. +//! +//! General dividend coverage lives in `claim_dividend.rs`. This file pins the specific +//! invariant that `get_claimable_dividend` returns 0 after a successful claim so it +//! cannot regress silently. + +mod contract_test_env; + +use contract_test_env::{ + assert_claimable, distribute_test_dividend, register_creator_keys, register_test_creator, + set_pricing_and_fees, test_env_with_auths, DEFAULT_CREATOR_BPS, DEFAULT_PROTOCOL_BPS, +}; +use creator_keys::ContractError; +use soroban_sdk::{testutils::Address as _, Address}; + +#[test] +fn test_claimable_balance_is_zero_after_successful_claim() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + set_pricing_and_fees(&env, &client, 100, DEFAULT_CREATOR_BPS, DEFAULT_PROTOCOL_BPS); + let creator = register_test_creator(&env, &client, "alice"); + let buyer = Address::generate(&env); + client.buy_key(&creator, &buyer, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator, &distributor, 10_000); + + client.claim_dividend(&creator, &buyer); + + // Claimable must be zero after claiming — not the previously claimed amount. + assert_claimable(&client, &creator, &buyer, 0); +} + +#[test] +fn test_second_claim_attempt_reverts_after_claimable_zeroed() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + set_pricing_and_fees(&env, &client, 100, DEFAULT_CREATOR_BPS, DEFAULT_PROTOCOL_BPS); + let creator = register_test_creator(&env, &client, "alice"); + let buyer = Address::generate(&env); + client.buy_key(&creator, &buyer, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator, &distributor, 10_000); + + client.claim_dividend(&creator, &buyer); + + // A second claim must fail because claimable is now zero. + let result = client.try_claim_dividend(&creator, &buyer); + assert_eq!( + result, + Err(Ok(ContractError::NoDividendClaimable)), + "second claim must fail with NoDividendClaimable after claimable is zeroed" + ); +} diff --git a/creator-keys/tests/holder_count_unchanged_after_supply_cap_exceeded.rs b/creator-keys/tests/holder_count_unchanged_after_supply_cap_exceeded.rs new file mode 100644 index 0000000..818eb64 --- /dev/null +++ b/creator-keys/tests/holder_count_unchanged_after_supply_cap_exceeded.rs @@ -0,0 +1,74 @@ +//! Regression test: holder count unchanged after a failed buy due to supply cap exceeded. +//! +//! When a buy reverts because it would exceed the creator's supply cap, the holder +//! count must remain unchanged. A new wallet that did not previously hold keys must +//! not be added to the holder count just because a buy attempt was made and reverted. + +mod contract_test_env; + +use contract_test_env::{register_creator_keys, set_key_price_for_tests, test_env_with_auths}; +use creator_keys::ContractError; +use soroban_sdk::{testutils::Address as _, Address, String}; + +#[test] +fn test_holder_count_unchanged_after_failed_buy_supply_cap_exceeded() { + let env = test_env_with_auths(); + let (client, _) = register_creator_keys(&env); + set_key_price_for_tests(&env, &client, 100_i128); + + // Register creator with a supply cap of 10. + let creator = Address::generate(&env); + client.register_creator( + &creator, + &String::from_str(&env, "alice"), + &None, + &Some(10u32), + ); + + // First wallet buys 10 keys to fill the cap. + let first_buyer = Address::generate(&env); + for _ in 0..10 { + client.buy_key(&creator, &first_buyer, &100_i128, &None); + } + + assert_eq!( + client.get_creator_holder_count(&creator), + 1, + "setup: holder count should be 1 after first wallet fills the cap" + ); + assert_eq!( + client.get_total_key_supply(&creator), + 10, + "setup: supply should be 10 after filling the cap" + ); + + // Second wallet attempts to buy — supply cap is already reached. + let second_buyer = Address::generate(&env); + let result = client.try_buy_key(&creator, &second_buyer, &100_i128, &None); + assert_eq!( + result, + Err(Ok(ContractError::SupplyCapExceeded)), + "buy must fail with SupplyCapExceeded when supply cap is reached" + ); + + // Holder count must remain unchanged after the failed buy. + assert_eq!( + client.get_creator_holder_count(&creator), + 1, + "holder count must remain 1 after failed buy due to supply cap" + ); + + // Second wallet must not be added to holders. + assert_eq!( + client.get_key_balance(&creator, &second_buyer), + 0, + "second wallet must have zero balance after failed buy" + ); + + // Total supply must remain unchanged. + assert_eq!( + client.get_total_key_supply(&creator), + 10, + "total supply must remain 10 after failed buy" + ); +} diff --git a/docs/transfer-supply-cap-and-dividend-interaction.md b/docs/transfer-supply-cap-and-dividend-interaction.md new file mode 100644 index 0000000..e5e1cfe --- /dev/null +++ b/docs/transfer-supply-cap-and-dividend-interaction.md @@ -0,0 +1,96 @@ +# Peer-to-Peer Transfer: Supply Cap and Dividend Claimable Balance Interaction + +This document explains how `transfer_keys` interacts with two other features: the creator supply cap and the dividend claimable balance. Both interactions are non-obvious because they involve state that is tracked per holder but computed differently depending on which flow (buy, transfer, or distribute) initiated the change. + +For the authorization model and fee behavior of `transfer_keys`, see [key-transfer-authorization.md](./key-transfer-authorization.md). For dividend accumulator mechanics, see [dividend-distribution-fee-behavior.md](./dividend-distribution-fee-behavior.md). + +--- + +## Supply Cap Interaction with `transfer_keys` + +**The supply cap does not apply to `transfer_keys`.** The cap is enforced only in `buy_key`. + +### Why + +The supply cap limits how many keys can be minted through the bonding curve. `transfer_keys` does not mint or burn keys — it moves existing keys between wallets. The total supply for the creator is invariant across a transfer: + +``` +before: sender_balance = N, recipient_balance = M, total_supply = N + M + rest +after: sender_balance = N - amount, recipient_balance = M + amount, total_supply = N + M + rest +``` + +No supply cap check is performed in `transfer_keys` because no supply change occurs. + +### Consequence + +A creator whose supply has reached the cap can still transfer keys between wallets. Clients and front-ends must not block transfer attempts based on the supply cap being reached. Only new buy attempts are subject to the cap. + +--- + +## Dividend Claimable Balance: Before and After Transfer + +**A transfer after a distribution does not change either wallet's claimable amount for that distribution.** + +### How it works + +When `transfer_keys` executes, it calls `settle_holder_dividends` for both the sender and the recipient **before** updating their balances. Settlement computes each wallet's earned dividend based on their current balance and freezes it in the pending storage slot: + +``` +earned = current_balance * (accumulator - checkpoint) +new_pending = old_pending + earned +checkpoint = current_accumulator +``` + +After settlement, the balances change. But since settlement already captured the earned amount at the old balance, the transfer has no effect on either wallet's claimable dividend from distributions that occurred before the transfer. + +### Consequence + +If a dividend was distributed before a transfer: +- The **sender's** claimable for that distribution is locked in at their pre-transfer balance and is unaffected by the transfer. +- The **recipient's** claimable for that distribution is locked in at their pre-transfer balance and is unaffected by receiving transferred keys. + +Calling `get_claimable_dividend` for either wallet immediately after a transfer returns the same value it would have returned immediately before the transfer. + +--- + +## Future Distribution Behavior After a Transfer + +**Distributions that occur after a transfer use the post-transfer balances.** + +After the transfer completes, both wallets' checkpoints are updated to the current accumulator. The next time a dividend is distributed, the per-key accumulator increases and each holder earns proportionally to their new balance: + +``` +earned = post_transfer_balance * (new_accumulator - checkpoint_at_transfer) +``` + +This means: +- The **recipient** earns a larger share of future distributions because their balance increased. +- The **sender** earns a smaller share of future distributions because their balance decreased. +- The effect is proportional and immediate: it applies to the very next distribution after the transfer. + +--- + +## Pending Claimable Dividend Is Not Transferred with Keys + +**Transferred keys do not carry any pending claimable dividend from the sender to the recipient.** + +When keys move from sender to recipient, only the `KeyBalance` storage entries change. The dividend state — `HolderDividendPending` and `HolderDividendCheckpoint` — belongs to each wallet independently and is not moved or split. + +Specifically: +- The sender's pending claimable balance is retained by the sender in full. +- The recipient's pending claimable balance is unaffected by the transfer. +- Each holder must call `claim_dividend` from their own wallet to withdraw their earned amount. +- There is no mechanism to transfer claimable dividend ownership alongside key ownership. + +This is intentional: dividend entitlements are personal to the wallet that held the keys at the time of each distribution. + +--- + +## Summary + +| Aspect | Behavior | +|--------|----------| +| **Supply cap check during transfer** | Not performed. Cap applies only to `buy_key`. | +| **Claimable balance for past distributions** | Unchanged by transfer. Settled at pre-transfer balance before any balance update. | +| **Future distributions after transfer** | Computed at post-transfer balance. Recipient share increases; sender share decreases. | +| **Pending claimable transferred with keys** | No. Each holder retains and claims their own earned dividends independently. | From a6c09d3f6d775a3f5bd7f5c7f78d29a33a53e19e Mon Sep 17 00:00:00 2001 From: harystyleseze Date: Sat, 27 Jun 2026 08:12:14 -0700 Subject: [PATCH 2/2] style: apply cargo fmt to new test files --- .../tests/buyback_event_new_supply_regression.rs | 9 ++------- ...dividend_claim_zeroes_claimable_regression.rs | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/creator-keys/tests/buyback_event_new_supply_regression.rs b/creator-keys/tests/buyback_event_new_supply_regression.rs index 1921db1..dc1caac 100644 --- a/creator-keys/tests/buyback_event_new_supply_regression.rs +++ b/creator-keys/tests/buyback_event_new_supply_regression.rs @@ -8,10 +8,7 @@ mod contract_test_env; use contract_test_env::{register_creator_keys, register_test_creator, set_pricing_and_fees}; use creator_keys::events; -use soroban_sdk::{ - testutils::Events, - Address, Env, IntoVal, -}; +use soroban_sdk::{testutils::Events, Address, Env, IntoVal}; const KEY_PRICE: i128 = 1_000; const CREATOR_BPS: u32 = 9_000; @@ -35,9 +32,7 @@ fn self_buy_keys( } } -fn last_buyback_payload( - env: &Env, -) -> events::KeysBoughtBackEvent { +fn last_buyback_payload(env: &Env) -> events::KeysBoughtBackEvent { let event_log = env.events().all(); let last = event_log.last().unwrap(); last.2.into_val(env) diff --git a/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs b/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs index 2f28612..0919986 100644 --- a/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs +++ b/creator-keys/tests/dividend_claim_zeroes_claimable_regression.rs @@ -21,7 +21,13 @@ use soroban_sdk::{testutils::Address as _, Address}; fn test_claimable_balance_is_zero_after_successful_claim() { let env = test_env_with_auths(); let (client, _) = register_creator_keys(&env); - set_pricing_and_fees(&env, &client, 100, DEFAULT_CREATOR_BPS, DEFAULT_PROTOCOL_BPS); + set_pricing_and_fees( + &env, + &client, + 100, + DEFAULT_CREATOR_BPS, + DEFAULT_PROTOCOL_BPS, + ); let creator = register_test_creator(&env, &client, "alice"); let buyer = Address::generate(&env); client.buy_key(&creator, &buyer, &100, &None); @@ -39,7 +45,13 @@ fn test_claimable_balance_is_zero_after_successful_claim() { fn test_second_claim_attempt_reverts_after_claimable_zeroed() { let env = test_env_with_auths(); let (client, _) = register_creator_keys(&env); - set_pricing_and_fees(&env, &client, 100, DEFAULT_CREATOR_BPS, DEFAULT_PROTOCOL_BPS); + set_pricing_and_fees( + &env, + &client, + 100, + DEFAULT_CREATOR_BPS, + DEFAULT_PROTOCOL_BPS, + ); let creator = register_test_creator(&env, &client, "alice"); let buyer = Address::generate(&env); client.buy_key(&creator, &buyer, &100, &None);