diff --git a/creator-keys/tests/fee_rounding_invariants.rs b/creator-keys/tests/fee_rounding_invariants.rs new file mode 100644 index 00000000..7aaec030 --- /dev/null +++ b/creator-keys/tests/fee_rounding_invariants.rs @@ -0,0 +1,184 @@ +//! Invariant tests for fee rounding behavior at boundary values. +//! +//! These tests verify that fee split logic maintains key invariants: +//! - Balance conservation: creator_amount + protocol_amount == total +//! - Rounding direction: remainder from integer division goes to creator +//! - Boundary handling: zero, negative, and minimum amounts +//! - Maximum input values near i128 limits + +use creator_keys::fee; + +#[test] +fn fee_split_preserves_balance_for_boundary_value_one() { + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(creator + protocol, 1, "balance conservation at amount=1"); +} + +#[test] +fn fee_split_preserves_balance_for_boundary_value_two() { + let (creator, protocol) = fee::compute_fee_split(2, 9000, 1000); + assert_eq!(creator + protocol, 2, "balance conservation at amount=2"); +} + +#[test] +fn fee_split_preserves_balance_for_boundary_value_ten() { + let (creator, protocol) = fee::compute_fee_split(10, 5000, 5000); + assert_eq!( + creator + protocol, + 10, + "balance conservation at 50/50 split" + ); +} + +#[test] +fn fee_split_remainder_favors_creator_at_boundary_999() { + let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); + // 999 * 1000 / 10000 = 99.9 -> 99 protocol; creator gets remainder + assert_eq!(protocol, 99); + assert_eq!(creator, 900); + assert_eq!(creator + protocol, 999); +} + +#[test] +fn fee_split_remainder_favors_creator_at_boundary_1001() { + let (creator, protocol) = fee::compute_fee_split(1001, 9000, 1000); + // 1001 * 1000 / 10000 = 100.1 -> 100 protocol; creator gets remainder + assert_eq!(protocol, 100); + assert_eq!(creator, 901); + assert_eq!(creator + protocol, 1001); +} + +#[test] +fn fee_split_dust_price_creates_zero_protocol_fee() { + let (creator, protocol) = fee::compute_fee_split(1, 1000, 9000); + // 1 * 9000 / 10000 = 0.9 -> 0 protocol; creator gets full amount + assert_eq!(protocol, 0); + assert_eq!(creator, 1); + assert_eq!(creator + protocol, 1); +} + +#[test] +fn fee_split_equal_split_50_50_even_amount() { + let (creator, protocol) = fee::compute_fee_split(100, 5000, 5000); + assert_eq!(creator, 50); + assert_eq!(protocol, 50); + assert_eq!(creator + protocol, 100); +} + +#[test] +fn fee_split_equal_split_50_50_odd_amount_remainder_to_creator() { + let (creator, protocol) = fee::compute_fee_split(101, 5000, 5000); + // 101 * 5000 / 10000 = 50.5 -> 50 protocol; creator gets 51 + assert_eq!(protocol, 50); + assert_eq!(creator, 51); + assert_eq!(creator + protocol, 101); +} + +#[test] +fn fee_split_preserves_balance_for_large_amount() { + let large_amount = 1_000_000_000_000i128; + let (creator, protocol) = fee::compute_fee_split(large_amount, 9000, 1000); + assert_eq!(creator + protocol, large_amount); +} + +#[test] +fn fee_split_preserves_balance_for_max_safe_amount() { + // Near i128::MAX but safe for multiplication by protocol_bps + let safe_amount = 9_223_372_036_854_775i128; // (i128::MAX / 10000) approx + let (creator, protocol) = fee::compute_fee_split(safe_amount, 9000, 1000); + assert_eq!(creator + protocol, safe_amount); +} + +#[test] +fn fee_split_zero_amount_returns_zero_split() { + let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); +} + +#[test] +fn fee_split_negative_amount_returns_zero_split() { + let (creator, protocol) = fee::compute_fee_split(-100, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); +} + +#[test] +fn fee_split_minimum_protocol_bps_zero() { + let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); + assert_eq!(creator, 1000); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1000); +} + +#[test] +fn fee_split_maximum_protocol_bps_5000() { + let (creator, protocol) = fee::compute_fee_split(10000, 5000, 5000); + assert_eq!(creator, 5000); + assert_eq!(protocol, 5000); + assert_eq!(creator + protocol, 10000); +} + +#[test] +fn fee_split_invariant_holds_across_typical_amounts() { + for total in [1i128, 10, 99, 100, 999, 1000, 10000, 100000, 1_000_000] { + let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); + assert_eq!( + creator + protocol, + total, + "balance conservation fails at amount={}", + total + ); + } +} + +#[test] +fn fee_split_invariant_holds_for_all_valid_fee_configs() { + let total = 1000i128; + for protocol_bps in [0u32, 1, 100, 1000, 5000, 9999, 10000] { + let creator_bps = 10000u32.saturating_sub(protocol_bps); + let (creator, protocol) = fee::compute_fee_split(total, creator_bps, protocol_bps); + assert_eq!( + creator + protocol, + total, + "balance conservation fails at protocol_bps={}", + protocol_bps + ); + } +} + +#[test] +fn checked_fee_split_boundary_prevents_overflow() { + // Test near-maximum safe amounts + let safe_amount = 9_223_372_036_854_775i128; + let result = fee::checked_compute_fee_split(safe_amount, 9000, 1000); + assert!( + result.is_some(), + "checked split should succeed for safe amount" + ); + let (creator, protocol) = result.unwrap(); + assert_eq!(creator + protocol, safe_amount); +} + +#[test] +fn checked_fee_split_rejects_overflow() { + // Amounts that would overflow during multiplication + let unsafe_amount = i128::MAX; + let result = fee::checked_compute_fee_split(unsafe_amount, 9000, 1000); + assert!( + result.is_none(), + "checked split should fail for unsafe amount" + ); +} + +#[test] +fn checked_fee_split_zero_amount_succeeds() { + let result = fee::checked_compute_fee_split(0, 9000, 1000); + assert_eq!(result, Some((0, 0))); +} + +#[test] +fn checked_fee_split_negative_amount_succeeds() { + let result = fee::checked_compute_fee_split(-100, 9000, 1000); + assert_eq!(result, Some((0, 0))); +} diff --git a/creator-keys/tests/max_amount_inputs.rs b/creator-keys/tests/max_amount_inputs.rs new file mode 100644 index 00000000..0944cc15 --- /dev/null +++ b/creator-keys/tests/max_amount_inputs.rs @@ -0,0 +1,145 @@ +//! Tests for near-maximum amount input values in buy and sell paths. +//! +//! These tests verify overflow-safe behavior and clear error handling when +//! amounts approach or exceed i128 limits. + +mod contract_test_env; + +use contract_test_env::{ + register_creator_keys, register_test_creator, set_stored_key_price, test_env_with_auths, +}; +use creator_keys::CreatorKeysContractClient; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn register_holder_with_one_key( + env: &Env, + client: &CreatorKeysContractClient<'_>, + creator: &Address, +) -> Address { + let holder = Address::generate(env); + let price = client.get_buy_quote(creator).price; + client.buy_key(creator, &holder, &price); + holder +} + +#[test] +fn test_buy_key_with_large_safe_amount_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let large_price = 1_000_000_000_000i128; + set_stored_key_price(&env, &contract_id, large_price); + let creator = register_test_creator(&env, &client, "creator1"); + let buyer = Address::generate(&env); + + let supply = client.buy_key(&creator, &buyer, &large_price); + assert_eq!(supply, 1, "buy should succeed with large amount"); +} + +#[test] +fn test_buy_key_with_maximum_safe_i128_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let max_safe_amount = 9_223_372_036_854_775i128; // (i128::MAX / 10000) approx + set_stored_key_price(&env, &contract_id, max_safe_amount); + let creator = register_test_creator(&env, &client, "creator2"); + let buyer = Address::generate(&env); + + let supply = client.buy_key(&creator, &buyer, &max_safe_amount); + assert_eq!(supply, 1, "buy should succeed at safe maximum"); +} + +#[test] +fn test_buy_quote_with_large_amount_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let large_price = 500_000_000_000i128; + set_stored_key_price(&env, &contract_id, large_price); + let admin = Address::generate(&env); + client.set_fee_config(&admin, &9000u32, &1000u32); + let creator = register_test_creator(&env, &client, "creator3"); + + let q = client.get_buy_quote(&creator); + assert_eq!(q.price, large_price); +} + +#[test] +fn test_buy_quote_with_maximum_safe_amount_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let max_safe_amount = 9_223_372_036_854_775i128; + set_stored_key_price(&env, &contract_id, max_safe_amount); + let admin = Address::generate(&env); + client.set_fee_config(&admin, &9000u32, &1000u32); + let creator = register_test_creator(&env, &client, "creator4"); + + let q = client.get_buy_quote(&creator); + assert_eq!(q.price, max_safe_amount); + assert!(q.total_amount > 0, "total amount should include fees"); +} + +#[test] +fn test_sell_quote_with_large_amount_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let large_price = 500_000_000_000i128; + set_stored_key_price(&env, &contract_id, large_price); + let admin = Address::generate(&env); + client.set_fee_config(&admin, &9000u32, &1000u32); + let creator = register_test_creator(&env, &client, "creator5"); + let holder = register_holder_with_one_key(&env, &client, &creator); + + let q = client.get_sell_quote(&creator, &holder); + assert_eq!(q.price, large_price); +} + +#[test] +fn test_sell_quote_with_maximum_safe_amount_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let max_safe_amount = 9_223_372_036_854_775i128; + set_stored_key_price(&env, &contract_id, max_safe_amount); + let admin = Address::generate(&env); + client.set_fee_config(&admin, &9000u32, &1000u32); + let creator = register_test_creator(&env, &client, "creator6"); + let holder = register_holder_with_one_key(&env, &client, &creator); + + let q = client.get_sell_quote(&creator, &holder); + assert_eq!(q.price, max_safe_amount); +} + +#[test] +fn test_buy_quote_with_maximum_safe_amount_50_50_fees_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let max_safe_amount = 9_223_372_036_854_775i128; + set_stored_key_price(&env, &contract_id, max_safe_amount); + let admin = Address::generate(&env); + client.set_fee_config(&admin, &5000u32, &5000u32); + let creator = register_test_creator(&env, &client, "creator7"); + + let q = client.get_buy_quote(&creator); + assert_eq!(q.price, max_safe_amount); + assert!( + q.total_amount > q.price, + "buy total should exceed base price with fees" + ); +} + +#[test] +fn test_sell_quote_with_maximum_safe_amount_50_50_fees_succeeds() { + let env = test_env_with_auths(); + let (client, contract_id) = register_creator_keys(&env); + let max_safe_amount = 9_223_372_036_854_775i128; + set_stored_key_price(&env, &contract_id, max_safe_amount); + let admin = Address::generate(&env); + client.set_fee_config(&admin, &5000u32, &5000u32); + let creator = register_test_creator(&env, &client, "creator8"); + let holder = register_holder_with_one_key(&env, &client, &creator); + + let q = client.get_sell_quote(&creator, &holder); + assert_eq!(q.price, max_safe_amount); + assert!( + q.total_amount < q.price, + "sell total should be less than base price" + ); +} diff --git a/docs/error-codes.md b/docs/error-codes.md new file mode 100644 index 00000000..b4a4c8d4 --- /dev/null +++ b/docs/error-codes.md @@ -0,0 +1,51 @@ +# Contract Error Codes + +This document maps `ContractError` enum values to their meanings, likely causes, and expected caller behavior. + +Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs) as a `#[contracterror]` enum with numeric values for on-chain serialization. + +## Error Reference + +| Code | Name | Numeric | Likely Cause | Expected Caller Behavior | +|------|------|---------|--------------|--------------------------| +| 1 | `AlreadyRegistered` | 1 | Attempt to register a creator address that is already registered in the contract | Retrieve existing creator profile or contact creator to select a different address | +| 2 | `NotRegistered` | 2 | Attempt to query or transact with a creator address that has not been registered | Register the creator first or verify the creator address is correct | +| 3 | `Overflow` | 3 | Integer arithmetic would overflow or underflow (e.g., supply > u32::MAX or fee math exceeds i128 bounds) | Check input amounts are within safe ranges or wait for supply to decrease; for quotes, verify key price is not near i128::MAX | +| 4 | `InsufficientPayment` | 4 | Payment amount is less than the current key price for a buy operation | Provide payment at least equal to the key price; call `get_buy_quote` to check current price and fees | +| 5 | `KeyPriceNotSet` | 5 | Attempt to use pricing operations (buy, sell, quote) before an admin has set a key price | Contact protocol admin to set a key price via `set_key_price` | +| 6 | `NotPositiveAmount` | 6 | Amount is zero or negative where a positive value is required (e.g., `set_key_price`, `buy_key` payment) | Use a positive amount; for buy/sell, check user input for negative or zero values | +| 7 | `FeeConfigNotSet` | 7 | Attempt to compute or query fees before an admin has set a protocol fee configuration | Contact protocol admin to set fee config via `set_fee_config` | +| 8 | `InvalidFeeConfig` | 8 | Proposed fee configuration violates constraints: `creator_bps + protocol_bps != 10000` or `protocol_bps > 5000` | Ensure basis points sum to 10,000 and protocol share does not exceed 50%; see `fee::validate_fee_bps` for validation rules | +| 9 | `InsufficientBalance` | 9 | Holder has no keys for the given creator (sell attempt with zero balance) | Verify holder owns keys before attempting a sell; call `get_key_balance` to check holdings | + +## Integration Notes + +### Authorization and Registration + +- Code 1 (`AlreadyRegistered`) is raised only if the same address calls `register_creator` twice. This is a guard against accidental re-registration; intended behavior should call `get_creator` first or handle registration state off-chain. +- Code 2 (`NotRegistered`) applies to both reads and writes. Callers must always register a creator before buy/sell/quote operations. + +### Pricing and Fees + +- Code 5 (`KeyPriceNotSet`) and Code 7 (`FeeConfigNotSet`) are initialization gates. Clients should detect these and display a message like "Pricing has not been configured yet" rather than retrying immediately. +- Code 8 (`InvalidFeeConfig`) is raised when basis points are invalid. Always validate client-side before sending transactions: `creator_bps + protocol_bps == 10000` and `protocol_bps <= 5000`. + +### Buy and Sell + +- Code 4 (`InsufficientPayment`) applies only to buy operations. Sellers use code 9 (`InsufficientBalance`). +- Code 6 (`NotPositiveAmount`) can be raised by `set_key_price` (amount must be > 0) or `buy_key` (payment must be > 0). + +### Overflow Handling + +- Code 3 (`Overflow`) should be rare in typical use. It can arise if: + - Protocol supply exceeds ~4 billion keys (unlikely in practice). + - A key price approaches i128::MAX and fee math tries to add fees on top (call `get_buy_quote` first to validate). + - Arithmetic during division or subtraction overflows (internal invariant violation; report if encountered). + +## Event Emission + +The contract does not emit error events. Errors are returned to the caller synchronously. Clients should log and handle error codes as part of transaction response validation. + +## Version Stability + +Error codes are stable and will not change. New error codes may be added in future contract versions without breaking existing error handling. Consumers should handle unexpected error codes gracefully (e.g., log and retry or display a generic error message). diff --git a/docs/storage-benchmarks.md b/docs/storage-benchmarks.md new file mode 100644 index 00000000..22781d29 --- /dev/null +++ b/docs/storage-benchmarks.md @@ -0,0 +1,104 @@ +# Storage Read Benchmarks and Hot Paths + +This document describes storage access patterns in the creator-keys contract and notes performance-sensitive paths for optimization candidates. + +## Storage Reads by Hot Path + +All storage reads use `env.storage().persistent()` to access the contract's persistent key-value store. The contract uses typed keys defined in [`constants::storage`](../creator-keys/src/lib.rs). + +### Query Read Paths (Read-Only, No Auth Required) + +These paths are called frequently by clients and indexers: + +| Method | Key | Type | Count | Notes | +|--------|-----|------|-------|-------| +| `get_creator_details` | `DataKey::Creator(address)` | `CreatorProfile` | 1 | Caller: indexers, client profile views. Single lookup, no sub-reads. | +| `get_holder_key_count` | `DataKey::Creator(address)` + optional `DataKey::KeyBalance(creator, holder)` | `CreatorProfile` + optional `u32` | 1–2 | Conditional: 1 read if creator unregistered; 2 reads if registered and checking holder balance. | +| `get_creator_fee_config` | `DataKey::Creator(address)` + `DataKey::FeeConfig` | `CreatorProfile` + `FeeConfig` | 1–2 | Conditional: 1 read if creator unregistered; 2 reads if registered (requires fee config check). | +| `get_buy_quote` | `DataKey::KeyPrice` + `DataKey::Creator(creator)` + `DataKey::FeeConfig` | `i128` + `CreatorProfile` + `FeeConfig` | 3 | Hot path: called per quote preview before each buy. All three reads required. | +| `get_sell_quote` | `DataKey::KeyPrice` + `DataKey::Creator(creator)` + `DataKey::KeyBalance(creator, holder)` + `DataKey::FeeConfig` | `i128` + `CreatorProfile` + `u32` + `FeeConfig` | 4 | Hot path: called per quote preview before each sell. All four reads required. | + +### State-Modifying Paths (Auth Required, Higher Latency Tolerance) + +These paths are called during buy/sell transactions and creator setup: + +| Method | Read Count | Reads | Notes | +|--------|-----------|-------|-------| +| `buy_key` | 3 | `DataKey::KeyPrice`, `DataKey::Creator(creator)`, `DataKey::KeyBalance(creator, buyer)` | Hot path for transactions. Reads key price and creator profile; reads (not writes) buyer balance to determine if holder count should increment. | +| `sell_key` | 1 | `DataKey::Creator(creator)` + implicit `DataKey::KeyBalance(creator, seller)` read in storage check | Hot path for transactions. Seller must hold keys. Requires two storage accesses (creator profile + holder balance) to verify balance and update both. | +| `register_creator` | 1 | `DataKey::Creator(creator)` (existence check only) | Infrequent. Single lookup to guard against double-registration. | +| `set_fee_config` | 1 | None (write-only) | Infrequent (admin-only). No reads; just validation and storage write. | +| `set_key_price` | 1 | None (write-only) | Infrequent (admin-only). No reads; just validation and storage write. | + +## Performance Optimization Candidates + +### Short-Term (No Schema Change) + +1. **Cache key price in quote loops**: If a client previews multiple quotes in a single session, the key price is read from storage on every quote call. Consider caching this client-side or in a batched quote endpoint if the contract API is extended. + +2. **Combine `get_holder_key_count` and `get_creator_fee_config` queries**: Both read the same `CreatorProfile` once. If used together in a client query, a new view method could return both in a single call. (Schema extension; see below.) + +3. **Monitor holder count churn**: Every buy/sell that changes the holder count for a creator writes the entire `CreatorProfile`. The profile is small (address, handle string, supply u32, holder_count u32, fee_recipient address), so this is not a bottleneck today but scales linearly with profile size. + +### Medium-Term (Schema Extensions) + +1. **Separate holder count storage**: If `CreatorProfile` grows, consider storing `holder_count` in its own data key (e.g., `DataKey::HolderCount(creator)`) to avoid rewriting the entire profile on every buy/sell. Verify with gas profiling before implementing. + +2. **Indexed key balance reads**: The contract uses a composite key `DataKey::KeyBalance(creator, holder)` for each holder balance. No bulk-read API exists. If analytics require scanning all holders for a creator, this requires off-chain polling or an indexer. + +3. **Quote view batching**: Extend the contract with a method like `get_quote_batch(creators: Vec
) -> Vec` to reduce round-trip latency for clients that show multiple creator quotes at once. + +## Reproducible Benchmarking + +To measure storage read cost in your environment: + +### Manual Profiling in Test + +```rust +// In a test file, measure a single hot-path call: +let env = Env::default(); +env.mock_all_auths(); +let (client, contract_id) = register_creator_keys(&env); +set_pricing_and_fees(&env, &client, 1000, 9000, 1000); +let creator = register_test_creator(&env, &client, "bench_creator"); + +// Measure quote call +let start = std::time::Instant::now(); +for _ in 0..100 { + let _ = client.get_buy_quote(&creator); +} +let elapsed = start.elapsed(); +println!("100 buy quotes: {:?} per call avg", elapsed / 100); +``` + +Run with `cargo test -- --nocapture bench_hot_path` to see console output. + +### On Testnet + +Use the Stellar RPC `estimateTransactionSize` or `simulateTransaction` to measure actual gas consumption: + +```bash +# After deploying contract to testnet, run a real transaction: +soroban contract invoke ... --network testnet -- get_buy_quote --creator
| jq '.result' + +# Check gas in the transaction envelope. +``` + +## Storage Layout Summary + +``` +Persistent Storage Keys: +- FeeConfig: Global protocol fee split (1 per contract) +- KeyPrice: Global key price (1 per contract) +- Creator(Address): Creator profile and supply (1 per creator) +- KeyBalance(creator, holder): Holder balance for creator (1 per holder per creator) +- TreasuryAddress, AdminAddress, ProtocolFeeRecipient: Global admin addresses (1 each) + +No ephemeral or temporary storage is used. +``` + +## Notes for Contributors + +- When adding new contract features, estimate storage reads and writes. Update this document if new hot paths are introduced. +- Use `env.storage().persistent()` for all data. Do not use ledger or volatile storage for contract state that must survive across invocations. +- Profile storage cost on testnet before mainnet deployment. Gas costs vary by network and may change with Soroban SDK updates.