From 816ac57c2aad4730e51213392416bea8aab723b8 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Mon, 29 Jun 2026 00:05:54 +0100 Subject: [PATCH 1/2] feat: add batch_claim_dividend for multi-creator claims in one transaction Closes #521 --- creator-keys/src/lib.rs | 55 ++++++ creator-keys/tests/batch_claim_dividend.rs | 199 +++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 creator-keys/tests/batch_claim_dividend.rs diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 0b1af2b..d0bb655 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -74,6 +74,7 @@ pub enum ContractError { InsufficientSupply = 25, SelfTransfer = 26, ZeroTransferAmount = 27, + BatchClaimExceedsLimit = 28, } pub mod fee { @@ -518,6 +519,13 @@ pub struct CreatorProfile { pub registered_at: u32, } +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct ClaimResult { + pub creator: Address, + pub amount_claimed: i128, +} + /// Reads a creator profile from storage, returning `None` for unregistered creators. /// /// Use this helper wherever repeated creator read logic is needed to keep @@ -2099,6 +2107,53 @@ impl CreatorKeysContract { Ok(claimable) } + pub fn batch_claim_dividend( + env: Env, + creators: soroban_sdk::Vec
, + holder: Address, + ) -> Result, ContractError> { + holder.require_auth(); + assert_not_paused(&env)?; + + if creators.len() > 20 { + return Err(ContractError::BatchClaimExceedsLimit); + } + + let mut results = soroban_sdk::Vec::new(&env); + + for creator in creators.iter() { + let claimable = compute_claimable_dividend(&env, &creator, &holder); + + if claimable > 0 { + let accumulator = read_dividend_accumulator(&env, &creator); + env.storage().persistent().set( + &constants::storage::holder_dividend_pending(&creator, &holder), + &0i128, + ); + env.storage().persistent().set( + &constants::storage::holder_dividend_checkpoint(&creator, &holder), + &accumulator, + ); + + env.events().publish( + events::dividend_claimed_topics(&creator, &holder), + events::DividendClaimedEvent { + creator: creator.clone(), + claimant: holder.clone(), + amount: claimable, + }, + ); + } + + results.push_back(ClaimResult { + creator: creator.clone(), + amount_claimed: claimable, + }); + } + + Ok(results) + } + /// Read-only view: returns the total unclaimed dividend amount for `wallet` on `creator`. /// /// Returns `0` when no dividends have accumulated or wallet holds no keys. diff --git a/creator-keys/tests/batch_claim_dividend.rs b/creator-keys/tests/batch_claim_dividend.rs new file mode 100644 index 0000000..2cd2e52 --- /dev/null +++ b/creator-keys/tests/batch_claim_dividend.rs @@ -0,0 +1,199 @@ +mod contract_test_env; + +use contract_test_env::{ + compute_expected_holder_dividend, 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, Vec}; + +fn numbered_handle(i: u32) -> String { + format!("creator_{i:03}") +} + +#[test] +fn test_batch_claim_dividend_happy_path() { + 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_a = register_test_creator(&env, &client, "alice"); + let creator_b = register_test_creator(&env, &client, "bob"); + let holder = Address::generate(&env); + + client.buy_key(&creator_a, &holder, &100, &None); + client.buy_key(&creator_b, &holder, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator_a, &distributor, 10_000); + distribute_test_dividend(&client, &creator_b, &distributor, 20_000); + + let creators = Vec::from_array(&env, [creator_a.clone(), creator_b.clone()]); + let results = client.batch_claim_dividend(&creators, &holder); + + assert_eq!(results.len(), 2); + + let expected_a = compute_expected_holder_dividend(10_000, 1, 1, DEFAULT_PROTOCOL_BPS); + let expected_b = compute_expected_holder_dividend(20_000, 1, 1, DEFAULT_PROTOCOL_BPS); + + assert_eq!(results.get(0).unwrap().creator, creator_a); + assert_eq!(results.get(0).unwrap().amount_claimed, expected_a); + assert_eq!(results.get(1).unwrap().creator, creator_b); + assert_eq!(results.get(1).unwrap().amount_claimed, expected_b); +} + +#[test] +fn test_batch_claim_zero_claimable_returns_zero_no_revert() { + 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_a = register_test_creator(&env, &client, "alice"); + let creator_b = register_test_creator(&env, &client, "bob"); + let holder = Address::generate(&env); + + client.buy_key(&creator_a, &holder, &100, &None); + client.buy_key(&creator_b, &holder, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator_a, &distributor, 10_000); + // No distribution for creator_b + + let creators = Vec::from_array(&env, [creator_a.clone(), creator_b.clone()]); + let results = client.batch_claim_dividend(&creators, &holder); + + assert_eq!(results.len(), 2); + assert!(results.get(0).unwrap().amount_claimed > 0); + assert_eq!(results.get(1).unwrap().amount_claimed, 0); +} + +#[test] +fn test_batch_claim_zeroes_claimable_after_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_a = register_test_creator(&env, &client, "alice"); + let creator_b = register_test_creator(&env, &client, "bob"); + let holder = Address::generate(&env); + + client.buy_key(&creator_a, &holder, &100, &None); + client.buy_key(&creator_b, &holder, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator_a, &distributor, 10_000); + distribute_test_dividend(&client, &creator_b, &distributor, 20_000); + + let creators = Vec::from_array(&env, [creator_a.clone(), creator_b.clone()]); + client.batch_claim_dividend(&creators, &holder); + + assert_eq!(client.get_claimable_dividend(&creator_a, &holder), 0); + assert_eq!(client.get_claimable_dividend(&creator_b, &holder), 0); +} + +#[test] +fn test_batch_claim_exceeds_limit_reverts() { + 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 holder = Address::generate(&env); + + let mut creators = Vec::new(&env); + for i in 0..21u32 { + let creator = register_test_creator(&env, &client, &numbered_handle(i)); + creators.push_back(creator); + } + + let result = client.try_batch_claim_dividend(&creators, &holder); + assert_eq!(result, Err(Ok(ContractError::BatchClaimExceedsLimit))); +} + +#[test] +fn test_batch_claim_while_paused_fails() { + 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 holder = Address::generate(&env); + client.buy_key(&creator, &holder, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator, &distributor, 10_000); + + let admin = Address::generate(&env); + client.set_protocol_admin(&admin, &admin); + client.pause(&admin); + + let creators = Vec::from_array(&env, [creator.clone()]); + let result = client.try_batch_claim_dividend(&creators, &holder); + assert_eq!(result, Err(Ok(ContractError::ProtocolPaused))); +} + +#[test] +fn test_batch_claim_empty_list_returns_empty() { + 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 holder = Address::generate(&env); + let creators: Vec
= Vec::new(&env); + let results = client.batch_claim_dividend(&creators, &holder); + + assert_eq!(results.len(), 0); +} + +#[test] +fn test_batch_claim_individual_failure_does_not_revert_others() { + 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_a = register_test_creator(&env, &client, "alice"); + let creator_b = register_test_creator(&env, &client, "bob"); + let creator_c = register_test_creator(&env, &client, "charlie"); + let holder = Address::generate(&env); + + client.buy_key(&creator_a, &holder, &100, &None); + client.buy_key(&creator_b, &holder, &100, &None); + client.buy_key(&creator_c, &holder, &100, &None); + + let distributor = Address::generate(&env); + distribute_test_dividend(&client, &creator_a, &distributor, 10_000); + // No distribution for creator_b — zero claimable + distribute_test_dividend(&client, &creator_c, &distributor, 30_000); + + let creators = + Vec::from_array(&env, [creator_a.clone(), creator_b.clone(), creator_c.clone()]); + let results = client.batch_claim_dividend(&creators, &holder); + + assert_eq!(results.len(), 3); + + let expected_a = compute_expected_holder_dividend(10_000, 1, 1, DEFAULT_PROTOCOL_BPS); + let expected_c = compute_expected_holder_dividend(30_000, 1, 1, DEFAULT_PROTOCOL_BPS); + + assert_eq!(results.get(0).unwrap().amount_claimed, expected_a); + assert_eq!(results.get(1).unwrap().amount_claimed, 0); + assert_eq!(results.get(2).unwrap().amount_claimed, expected_c); +} + +#[test] +fn test_batch_claim_at_limit_of_20_succeeds() { + 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 holder = Address::generate(&env); + + let mut creators = Vec::new(&env); + for i in 0..20u32 { + let creator = register_test_creator(&env, &client, &numbered_handle(i)); + client.buy_key(&creator, &holder, &100, &None); + creators.push_back(creator); + } + + let results = client.batch_claim_dividend(&creators, &holder); + assert_eq!(results.len(), 20); +} From f63087e01c17fb871cccfa998f723b288cea74ac Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Mon, 29 Jun 2026 12:29:23 +0100 Subject: [PATCH 2/2] style: apply cargo fmt to batch_claim_dividend tests Closes #521 --- creator-keys/tests/batch_claim_dividend.rs | 70 ++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/creator-keys/tests/batch_claim_dividend.rs b/creator-keys/tests/batch_claim_dividend.rs index 2cd2e52..b4ba55f 100644 --- a/creator-keys/tests/batch_claim_dividend.rs +++ b/creator-keys/tests/batch_claim_dividend.rs @@ -16,7 +16,13 @@ fn numbered_handle(i: u32) -> String { fn test_batch_claim_dividend_happy_path() { 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_a = register_test_creator(&env, &client, "alice"); let creator_b = register_test_creator(&env, &client, "bob"); @@ -47,7 +53,13 @@ fn test_batch_claim_dividend_happy_path() { fn test_batch_claim_zero_claimable_returns_zero_no_revert() { 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_a = register_test_creator(&env, &client, "alice"); let creator_b = register_test_creator(&env, &client, "bob"); @@ -72,7 +84,13 @@ fn test_batch_claim_zero_claimable_returns_zero_no_revert() { fn test_batch_claim_zeroes_claimable_after_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_a = register_test_creator(&env, &client, "alice"); let creator_b = register_test_creator(&env, &client, "bob"); @@ -96,7 +114,13 @@ fn test_batch_claim_zeroes_claimable_after_claim() { fn test_batch_claim_exceeds_limit_reverts() { 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 holder = Address::generate(&env); @@ -114,7 +138,13 @@ fn test_batch_claim_exceeds_limit_reverts() { fn test_batch_claim_while_paused_fails() { 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 holder = Address::generate(&env); @@ -136,7 +166,13 @@ fn test_batch_claim_while_paused_fails() { fn test_batch_claim_empty_list_returns_empty() { 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 holder = Address::generate(&env); let creators: Vec
= Vec::new(&env); @@ -149,7 +185,13 @@ fn test_batch_claim_empty_list_returns_empty() { fn test_batch_claim_individual_failure_does_not_revert_others() { 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_a = register_test_creator(&env, &client, "alice"); let creator_b = register_test_creator(&env, &client, "bob"); @@ -165,8 +207,10 @@ fn test_batch_claim_individual_failure_does_not_revert_others() { // No distribution for creator_b — zero claimable distribute_test_dividend(&client, &creator_c, &distributor, 30_000); - let creators = - Vec::from_array(&env, [creator_a.clone(), creator_b.clone(), creator_c.clone()]); + let creators = Vec::from_array( + &env, + [creator_a.clone(), creator_b.clone(), creator_c.clone()], + ); let results = client.batch_claim_dividend(&creators, &holder); assert_eq!(results.len(), 3); @@ -183,7 +227,13 @@ fn test_batch_claim_individual_failure_does_not_revert_others() { fn test_batch_claim_at_limit_of_20_succeeds() { 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 holder = Address::generate(&env);