Skip to content
Merged
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
53 changes: 49 additions & 4 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub enum ContractError {
InvalidFeeConfig = 8,
InsufficientBalance = 9,
SellUnderflow = 10,
ProtocolFeeExceedsCap = 11,
}

pub mod fee {
Expand All @@ -33,7 +34,7 @@ pub mod fee {
/// expected economic bounds before they affect market logic.
pub const PROTOCOL_BPS_MAX: u32 = 5_000;

#[derive(Clone)]
#[derive(Clone, Eq, PartialEq)]
#[contracttype]
pub struct FeeConfig {
pub creator_bps: u32,
Expand Down Expand Up @@ -354,7 +355,9 @@ fn checked_format_quote_response(
let total_amount = if is_buy {
price.checked_add(fees).ok_or(ContractError::Overflow)?
} else {
price.checked_sub(fees).ok_or(ContractError::Overflow)?
price
.checked_sub(fees)
.ok_or(ContractError::SellUnderflow)?
};

Ok(QuoteResponse {
Expand Down Expand Up @@ -652,14 +655,29 @@ impl CreatorKeysContract {
protocol_bps: u32,
) -> Result<(), ContractError> {
admin.require_auth();
if !fee::validate_fee_bps(creator_bps, protocol_bps) {
let Some(sum) = creator_bps.checked_add(protocol_bps) else {
return Err(ContractError::InvalidFeeConfig);
};
if sum != fee::BPS_MAX {
return Err(ContractError::InvalidFeeConfig);
}
if protocol_bps > fee::PROTOCOL_BPS_MAX {
return Err(ContractError::ProtocolFeeExceedsCap);
}

let config = fee::FeeConfig {
creator_bps,
protocol_bps,
};
if env
.storage()
.persistent()
.get::<DataKey, fee::FeeConfig>(&constants::storage::FEE_CONFIG)
.as_ref()
== Some(&config)
{
return Ok(());
}
env.storage()
.persistent()
.set(&constants::storage::FEE_CONFIG, &config);
Expand All @@ -671,6 +689,15 @@ impl CreatorKeysContract {
if price <= 0 {
return Err(ContractError::NotPositiveAmount);
}
if env
.storage()
.persistent()
.get::<DataKey, i128>(&constants::storage::KEY_PRICE)
.as_ref()
== Some(&price)
{
return Ok(());
}
env.storage()
.persistent()
.set(&constants::storage::KEY_PRICE, &price);
Expand All @@ -687,6 +714,15 @@ impl CreatorKeysContract {
/// for protocol fee routing.
pub fn set_treasury_address(env: Env, admin: Address, treasury: Address) {
admin.require_auth();
if env
.storage()
.persistent()
.get::<DataKey, Address>(&constants::storage::TREASURY_ADDRESS)
.as_ref()
== Some(&treasury)
{
return;
}
env.storage()
.persistent()
.set(&constants::storage::TREASURY_ADDRESS, &treasury);
Expand All @@ -709,6 +745,15 @@ impl CreatorKeysContract {
/// for protocol administration.
pub fn set_protocol_admin(env: Env, admin: Address, new_admin: Address) {
admin.require_auth();
if env
.storage()
.persistent()
.get::<DataKey, Address>(&constants::storage::ADMIN_ADDRESS)
.as_ref()
== Some(&new_admin)
{
return;
}
env.storage()
.persistent()
.set(&constants::storage::ADMIN_ADDRESS, &new_admin);
Expand Down Expand Up @@ -972,7 +1017,7 @@ mod tests {
#[test]
fn test_checked_format_quote_response_sell_underflow_total() {
let res = super::checked_format_quote_response(i128::MIN, 1, 0, false);
assert_eq!(res, Err(super::ContractError::Overflow));
assert_eq!(res, Err(super::ContractError::SellUnderflow));
}
}

Expand Down
2 changes: 1 addition & 1 deletion creator-keys/tests/fee_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ fn test_set_fee_config_protocol_bps_above_max_fails() {
let admin = soroban_sdk::Address::generate(&env);

let result = client.try_set_fee_config(&admin, &4999u32, &5001u32);
assert_eq!(result, Err(Ok(ContractError::InvalidFeeConfig)));
assert_eq!(result, Err(Ok(ContractError::ProtocolFeeExceedsCap)));
}

#[test]
Expand Down
8 changes: 8 additions & 0 deletions docs/deterministic-quote-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ Access Layer uses a **fixed price model**:
3. **Supply independence**: Buying or selling keys should not affect the quote price
4. **Creator independence**: All creators have the same price (but independent supplies)

### Sell Quote Monotonicity (Incremental Sells)

For the fixed price model, sell quotes are expected to be stable across incremental sells until the holder runs out of keys:

- Calling `get_sell_quote` repeatedly for the same `(creator, holder)` should return the same `QuoteResponse` as long as `get_key_balance(creator, holder) > 0`.
- Once the holder balance reaches `0`, `get_sell_quote` should reject with `ContractError::InsufficientBalance`.
- If fee configuration and price would imply a negative sell payout (fees exceed price), `get_sell_quote` should reject with `ContractError::SellUnderflow` rather than returning an invalid negative amount.

## Fee Configuration Rules

Understanding fee rules is essential for writing correct tests:
Expand Down
7 changes: 5 additions & 2 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.r
| 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 |
| 8 | `InvalidFeeConfig` | 8 | Proposed fee configuration violates constraints: `creator_bps + protocol_bps != 10000` | Ensure basis points sum to 10,000; validate client-side before sending |
| 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 |
| 10 | `SellUnderflow` | 10 | Sell operation would underflow supply/balance (internal invariant violation) or sell quote would result in a negative net amount | Report if encountered; for quotes, check fee configuration relative to key price |
| 11 | `ProtocolFeeExceedsCap` | 11 | Proposed fee configuration exceeds the maximum allowed protocol share (`protocol_bps > 5000`) | Reduce protocol share to 50% or lower; see `fee::PROTOCOL_BPS_MAX` |

## Integration Notes

Expand All @@ -28,7 +30,8 @@ Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.r
### 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`.
- Code 8 (`InvalidFeeConfig`) is raised when basis points are invalid. Always validate client-side before sending transactions: `creator_bps + protocol_bps == 10000`.
- Code 11 (`ProtocolFeeExceedsCap`) is raised when the protocol share exceeds the cap: `protocol_bps <= 5000`.

### Buy and Sell

Expand Down
1 change: 1 addition & 0 deletions docs/fee-assumptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This document describes the assumptions, behavior, and integration points for cr

- **Basis points (bps)**: Fee shares are expressed in basis points, where 10,000 = 100%.
- **Validation**: `creator_bps + protocol_bps` must equal 10,000. Any other sum is rejected.
- **Protocol cap**: `protocol_bps` is capped at 5,000 (50%) and `set_fee_config` rejects values above the cap with `ContractError::ProtocolFeeExceedsCap`.
- **Example**: 9,000 creator_bps + 1,000 protocol_bps = 90% creator, 10% protocol.

## Rounding and Remainder Handling
Expand Down
Loading