diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index ee2afdd0..8d545b3d 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -2317,8 +2317,7 @@ impl CreatorKeysContract { /// Fails with [`ContractError::NotRegistered`] if the creator is not registered. /// Reuses current creator storage access patterns. pub fn get_creator_fee_recipient(env: Env, creator: Address) -> Result
{ - let profile = read_registered_creator_profile(&env, &creator)?; - Ok(profile.fee_recipient) + read_creator_fee_recipient(&env, &creator).ok_or(ContractError::NotRegistered) } /// Read-only view: returns accrued creator fee balance for the creator's fee recipient. @@ -3031,10 +3030,7 @@ impl CreatorKeysContract { return Ok(()); } - let mut profile = profile; - profile.fee_recipient = new_recipient.clone(); - let key = constants::storage::creator(&creator); - env.storage().persistent().set(&key, &profile); + write_creator_fee_recipient(&env, &creator, &new_recipient); env.events().publish( ( diff --git a/creator-keys/src/test_issues.rs b/creator-keys/src/test_issues.rs index 18b0d992..0f3a036d 100644 --- a/creator-keys/src/test_issues.rs +++ b/creator-keys/src/test_issues.rs @@ -4,6 +4,7 @@ #[cfg(test)] mod issue_tests { + extern crate std; use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; use crate::{ @@ -701,4 +702,81 @@ mod issue_tests { "different creators must produce different locked allocation keys" ); } + + // --- holder_balance_key helper unit tests (#619) --- + + #[test] + fn test_holder_balance_key_non_empty_and_valid_variant() { + let env = Env::default(); + let creator = Address::generate(&env); + let holder = Address::generate(&env); + + let key = constants::storage::holder_balance_key(&creator, &holder); + assert_eq!( + key, + crate::DataKey::KeyBalance(creator.clone(), holder.clone()), + "holder_balance_key must produce a valid DataKey::KeyBalance variant" + ); + } + + #[test] + fn test_holder_balance_key_deterministic() { + let env = Env::default(); + let creator = Address::generate(&env); + let holder = Address::generate(&env); + + let key1 = constants::storage::holder_balance_key(&creator, &holder); + let key2 = constants::storage::holder_balance_key(&creator, &holder); + + assert_eq!(key1, key2, "same inputs must always produce equal keys"); + } + + #[test] + fn test_holder_balance_key_different_holders_produce_equal_length_keys() { + let env = Env::default(); + let creator = Address::generate(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + let key_a = constants::storage::holder_balance_key(&creator, &holder_a); + let key_b = constants::storage::holder_balance_key(&creator, &holder_b); + + // Both keys are DataKey::KeyBalance(Address, Address) variants with 32-byte address payloads + assert_ne!(key_a, key_b, "different holders must produce distinct keys"); + + // Debug representation length check for structural equality + let str_a = soroban_sdk::String::from_str(&env, &std::format!("{:?}", key_a)); + let str_b = soroban_sdk::String::from_str(&env, &std::format!("{:?}", key_b)); + assert_eq!( + str_a.len(), + str_b.len(), + "keys for different holders must have equal bounds/length" + ); + } + + #[test] + fn test_holder_balance_key_distinguishable_from_other_storage_keys() { + let env = Env::default(); + let creator = Address::generate(&env); + let holder = Address::generate(&env); + + let balance_key = constants::storage::holder_balance_key(&creator, &holder); + + let creator_profile_key = constants::storage::creator(&creator); + let dividend_acc_key = constants::storage::dividend_accumulator(&creator); + let fee_balance_key = constants::storage::creator_fee_balance(&creator); + + assert_ne!( + balance_key, creator_profile_key, + "balance key must differ from creator profile key" + ); + assert_ne!( + balance_key, dividend_acc_key, + "balance key must differ from dividend accumulator key" + ); + assert_ne!( + balance_key, fee_balance_key, + "balance key must differ from creator fee balance key" + ); + } } diff --git a/creator-keys/tests/full_sell_removes_holder_balance_storage.rs b/creator-keys/tests/full_sell_removes_holder_balance_storage.rs new file mode 100644 index 00000000..65034e22 --- /dev/null +++ b/creator-keys/tests/full_sell_removes_holder_balance_storage.rs @@ -0,0 +1,83 @@ +//! Integration test for full sell removing holder balance entry from persistent storage (#613). + +mod contract_test_env; + +use contract_test_env::{ + assert_storage_absent, register_creator_keys, register_test_creator, set_key_price_for_tests, +}; +use creator_keys::constants; +use soroban_sdk::{testutils::Address as _, Address}; + +const KEY_PRICE: i128 = 100; + +fn setup(env: &soroban_sdk::Env) -> (creator_keys::CreatorKeysContractClient<'_>, Address) { + let (client, _) = register_creator_keys(env); + set_key_price_for_tests(env, &client, KEY_PRICE); + let creator = register_test_creator(env, &client, "alice"); + (client, creator) +} + +#[test] +fn test_full_sell_removes_holder_balance_storage_key() { + let env = soroban_sdk::Env::default(); + env.mock_all_auths(); + let (client, creator) = setup(&env); + let holder = Address::generate(&env); + + // 1. Set up a holder with exactly 3 keys via buy transactions + for _ in 0..3 { + client.buy_key(&creator, &holder, &KEY_PRICE, &None); + } + assert_eq!(client.get_key_balance(&creator, &holder), 3); + assert_eq!(client.get_total_key_supply(&creator), 3); + + // Verify key exists in persistent storage before full sell + let balance_key = constants::storage::holder_balance_key(&creator, &holder); + let contract_id = client.address.clone(); + env.as_contract(&contract_id, || { + assert!(env.storage().persistent().has(&balance_key)); + }); + + // 2. Sell all 3 keys in a single session / transactions + for _ in 0..3 { + client.sell_key(&creator, &holder, &None); + } + + // 3. Assert holder balance storage key is absent from persistent storage (not present with value 0) + env.as_contract(&contract_id, || { + assert_storage_absent(&env, &balance_key); + }); + + // 4. Assert creator supply is decremented by the full sold quantity (3 -> 0) + assert_eq!(client.get_total_key_supply(&creator), 0); + + // 5. Assert a subsequent read of holder balance returns 0 (default) without error + assert_eq!(client.get_key_balance(&creator, &holder), 0); +} + +#[test] +fn test_partial_sell_does_not_remove_holder_balance_storage_key() { + let env = soroban_sdk::Env::default(); + env.mock_all_auths(); + let (client, creator) = setup(&env); + let holder = Address::generate(&env); + + // Set up a holder with 3 keys + for _ in 0..3 { + client.buy_key(&creator, &holder, &KEY_PRICE, &None); + } + + // Perform a partial sell of 2 keys + client.sell_key(&creator, &holder, &None); + client.sell_key(&creator, &holder, &None); + + // Assert key is still present in storage + let balance_key = constants::storage::holder_balance_key(&creator, &holder); + let contract_id = client.address.clone(); + env.as_contract(&contract_id, || { + assert!(env.storage().persistent().has(&balance_key)); + }); + + assert_eq!(client.get_key_balance(&creator, &holder), 1); + assert_eq!(client.get_total_key_supply(&creator), 1); +} diff --git a/docs/contract-storage-layout.md b/docs/contract-storage-layout.md new file mode 100644 index 00000000..360f3290 --- /dev/null +++ b/docs/contract-storage-layout.md @@ -0,0 +1,129 @@ +# Contract Storage Layout & Key Naming Conventions + +This reference document describes the complete persistent storage layout, key naming conventions, data types, TTL policies, and read/write function mappings for the Access Layer `creator-keys` Soroban smart contract. + +--- + +## 1. Overview & DataKey Architecture + +All contract state is stored in Soroban's persistent storage schema defined by the `DataKey` enum in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs): + +```rust +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub enum DataKey { + Creator(Address), + FeeConfig, + KeyPrice, + KeyBalance(Address, Address), + TreasuryAddress, + AdminAddress, + ProtocolFeeRecipient, + ProtocolFeeRecipientBalance, + CreatorFeeBalance(Address), + ProtocolStateVersion, + Paused, + DividendPerKeyAccumulated(Address), + HolderDividendCheckpoint(Address, Address), + HolderDividendPending(Address, Address), + LockedAllocation(Address), + MaxSupply(Address), + CurveSlope, + CurvePreset(Address), + TreasuryBalance, + CoCreator(Address), + CoCreatorFeeBalance(Address, Address), + Whitelist(Address), + MaxKeysPerWallet(Address), + ReferralFeeBps, + DiscountTiers, + CreatorVolume(Address), +} +``` + +--- + +## 2. Comprehensive Storage Key Directory + +Below is the complete table of every persistent storage key used by the contract: + +| Key Variant | Key Construction / Format | Stored Data Type | TTL Policy | Read Functions / Accessors | Write Functions / Mutators | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `Creator(Address)` | `DataKey::Creator(creator)` | `CreatorProfile` | Extended on trade (`CREATOR_TTL_LEDGERS`) | `get_creator`, `get_creator_details`, `read_registered_creator_profile` | `register_creator`, `update_creator_fee_recipient` | +| `FeeConfig` | `DataKey::FeeConfig` | `fee::FeeConfig` | Persistent | `get_fee_config`, `get_protocol_fee_view`, `read_protocol_fee_config` | `set_fee_config` | +| `KeyPrice` | `DataKey::KeyPrice` | `i128` (stroops) | Persistent | `get_key_price`, `get_buy_quote`, `get_sell_quote` | `set_key_price` | +| `KeyBalance(Address, Address)` | `DataKey::KeyBalance(creator, holder)` | `u32` | Extended on trade; removed on 0 balance | `get_key_balance`, `get_holder_key_count` | `buy_key`, `sell_key`, `transfer_keys` | +| `TreasuryAddress` | `DataKey::TreasuryAddress` | `Address` | Persistent | `get_treasury_address` | `set_treasury_address` | +| `AdminAddress` | `DataKey::AdminAddress` | `Address` | Persistent | `get_protocol_admin` | `set_protocol_admin` | +| `ProtocolFeeRecipient` | `DataKey::ProtocolFeeRecipient` | `Address` | Persistent | `get_protocol_fee_recipient` | `set_protocol_fee_recipient`, `update_protocol_fee_recipient` | +| `ProtocolFeeRecipientBalance` | `DataKey::ProtocolFeeRecipientBalance` | `i128` (stroops) | Persistent | `get_protocol_recipient_balance` | `sell_key`, `buyback` | +| `CreatorFeeBalance(Address)` | `DataKey::CreatorFeeBalance(creator)` | `i128` (stroops) | Extended on trade | `get_creator_fee_balance` | `buy_key`, `sell_key`, `withdraw_creator_fee` | +| `ProtocolStateVersion` | `DataKey::ProtocolStateVersion` | `u32` | Persistent | `get_protocol_state_version` | `set_fee_config` (increments version) | +| `Paused` | `DataKey::Paused` | `bool` | Persistent | `get_is_paused` | `pause`, `unpause` | +| `DividendPerKeyAccumulated(Address)` | `DataKey::DividendPerKeyAccumulated(creator)` | `i128` (per key) | Extended on trade | `read_dividend_accumulator` | `distribute_dividend` | +| `HolderDividendCheckpoint(Address, Address)` | `DataKey::HolderDividendCheckpoint(creator, holder)` | `i128` | Per-holder persistent | `compute_claimable_dividend`, `settle_holder_dividends` | `claim_dividend`, `settle_holder_dividends` | +| `HolderDividendPending(Address, Address)` | `DataKey::HolderDividendPending(creator, holder)` | `i128` | Per-holder persistent | `compute_claimable_dividend`, `settle_holder_dividends` | `claim_dividend`, `settle_holder_dividends` | +| `LockedAllocation(Address)` | `DataKey::LockedAllocation(creator)` | `LockedAllocation` | Extended on trade; deleted after claim | `get_locked_allocation` | `register_creator`, `claim_locked_allocation` | +| `MaxSupply(Address)` | `DataKey::MaxSupply(creator)` | `u32` | Extended on trade | `get_max_supply` | `register_creator` | +| `CurveSlope` | `DataKey::CurveSlope` | `i128` | Persistent | `get_curve_slope` | `set_curve_slope` | +| `CurvePreset(Address)` | `DataKey::CurvePreset(creator)` | `CurvePreset` | Extended on trade | `get_curve_preset` | `register_creator` | +| `TreasuryBalance` | `DataKey::TreasuryBalance` | `i128` (stroops) | Persistent | `get_treasury_balance` | `buy_key`, `withdraw_treasury` | +| `CoCreator(Address)` | `DataKey::CoCreator(creator)` | `CoCreatorConfig` | Extended on trade | `read_co_creator_config` | `register_creator` | +| `CoCreatorFeeBalance(Address, Address)` | `DataKey::CoCreatorFeeBalance(creator, co_creator)` | `i128` (stroops) | Extended on trade | `get_co_creator_fee_balance` | `buy_key`, `sell_key`, `withdraw_co_creator_fee` | +| `Whitelist(Address)` | `DataKey::Whitelist(creator)` | `WhitelistConfig` | Persistent | `get_whitelist_config`, `get_whitelist_status` | `register_creator` | +| `MaxKeysPerWallet(Address)` | `DataKey::MaxKeysPerWallet(creator)` | `u32` | Persistent | `get_max_keys_per_wallet` | `register_creator` | +| `ReferralFeeBps` | `DataKey::ReferralFeeBps` | `u32` | Persistent | `get_referral_fee_bps` | `set_referral_fee_bps` | +| `DiscountTiers` | `DataKey::DiscountTiers` | `Vec