Skip to content
Closed
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
8 changes: 2 additions & 6 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address, ContractError> {
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.
Expand Down Expand Up @@ -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(
(
Expand Down
78 changes: 78 additions & 0 deletions creator-keys/src/test_issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#[cfg(test)]
mod issue_tests {
extern crate std;
use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec};

use crate::{
Expand Down Expand Up @@ -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"
);
}
}
83 changes: 83 additions & 0 deletions creator-keys/tests/full_sell_removes_holder_balance_storage.rs
Original file line number Diff line number Diff line change
@@ -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);
}
129 changes: 129 additions & 0 deletions docs/contract-storage-layout.md
Original file line number Diff line number Diff line change
@@ -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<DiscountTier>` | Persistent | `get_discount_tiers` | `set_discount_tiers` |
| `CreatorVolume(Address)` | `DataKey::CreatorVolume(creator)` | `i128` (stroops) | Persistent | `get_creator_volume` | `buy_key`, `sell_key` |

---

## 3. Composite Key Naming & Construction Conventions

Composite keys in `creator-keys` bind multiple entity addresses into a single storage tuple.

### Naming Pattern
Constructors for composite keys live in `constants::storage` in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs). They accept references to addresses and return `DataKey` enum instances.

### Example: Holder Balance Key
```rust
pub fn holder_balance_key(creator_id: &Address, holder: &Address) -> DataKey {
DataKey::KeyBalance(creator_id.clone(), holder.clone())
}
```

### Usage
- `creator_id` identifies the key creator contract scope.
- `holder` identifies the wallet address holding the keys.
- **Order Invariant**: `holder_balance_key(creator, holder)` is strictly ordered (`(creator, holder)`). Order matters and reversing parameters produces a different storage key.

Other composite key helpers follow the same parameter convention:
- `co_creator_fee_balance(creator, co_creator)` -> `DataKey::CoCreatorFeeBalance(creator, co_creator)`
- `holder_dividend_checkpoint(creator, holder)` -> `DataKey::HolderDividendCheckpoint(creator, holder)`
- `holder_dividend_pending(creator, holder)` -> `DataKey::HolderDividendPending(creator, holder)`

---

## 4. TTL Extension Behavior & Minimum Thresholds

### TTL Extension Mechanism
Soroban persistent storage entries expire unless their TTL (time-to-live) is extended. The `creator-keys` contract automatically extends the TTL for all primary creator-related storage entries after every successful trade (`buy_key` or `sell_key`).

### Constants
- **`CREATOR_TTL_LEDGERS`**: `6311520` ledgers (~2 years at 5 seconds per ledger).
- **Extension Function**: `extend_creator_ttl(env: &Env, creator: &Address)`

### Threshold Decision Logic
Storage extension is evaluated using the pure helper `ttl::should_extend`:

```rust
pub mod ttl {
pub fn should_extend(current_ttl: u32, threshold: u32) -> bool {
current_ttl < threshold
}
}
```

- **Trigger Condition**: Extension only fires when the remaining ledger count drops strictly below `threshold` (`current_ledger`).
- **Target Ledger**: Entries are extended to `current_ledger + CREATOR_TTL_LEDGERS`.
- **Event Notification**: When an extension occurs on the main creator key, a `(TTL_EXTENDED_EVENT_NAME, creator)` event is emitted with the target ledger count.
Loading