Skip to content
Open
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
18 changes: 18 additions & 0 deletions creator-keys/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,24 @@ pub fn supply_cap_set_topics(creator: &Address) -> (Symbol, Address) {
(SUPPLY_CAP_SET_EVENT_NAME, creator.clone())
}

/// Event name emitted when a creator reduces an existing supply cap.
pub const MAX_SUPPLY_UPDATED_EVENT_NAME: Symbol = symbol_short!("max_sup");

/// Event shape:
/// - topics: `(MAX_SUPPLY_UPDATED_EVENT_NAME, creator_id)`
/// - data: `MaxSupplyUpdatedEvent`
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct MaxSupplyUpdatedEvent {
pub creator_id: Address,
pub old_max_supply: u32,
pub new_max_supply: u32,
}

pub fn max_supply_updated_topics(creator: &Address) -> (Symbol, Address) {
(MAX_SUPPLY_UPDATED_EVENT_NAME, creator.clone())
}

/// Event name emitted exactly once when a buy fills a capped key's supply to
/// its configured cap. Subsequent buys revert with `SupplyCapExceeded`, so the
/// event is never emitted again for the same key.
Expand Down
62 changes: 60 additions & 2 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ pub enum ContractError {
CircuitBreakerTripped = 102,
InvalidSignature = 103,
NonceAlreadyUsed = 104,
/// The requested holder cap is above the currently stored cap; the cap can
/// only be tightened (reduced), never raised above the value set at launch.
/// A holder or supply cap is above its currently stored value; caps can
/// only be tightened (reduced), never raised after being set.
CapCannotIncrease = 105,
/// The requested holder cap is below the minimum of 100 bps (1% of supply).
CapTooLow = 106,
Expand Down Expand Up @@ -15422,6 +15422,64 @@ impl CreatorKeysContract {
Ok(())
}

/// Sets an initial supply cap or reduces an existing cap for a creator's
/// keys. Only the creator may call this method. A cap cannot be removed,
/// increased, or lowered below the current supply. Existing caps emit a
/// [`events::MaxSupplyUpdatedEvent`] when reduced; an initial cap emits
/// [`events::SupplyCapSetEvent`].
pub fn set_max_supply(
env: Env,
creator: Address,
max_supply: u32,
) -> Result<(), ContractError> {
creator.require_auth();
let profile = read_registered_creator_profile(&env, &creator)?;

if max_supply == 0 {
return Err(ContractError::NotPositiveAmount);
}

let cap_key = constants::storage::max_supply(&creator);
let existing: Option<u32> = env.storage().persistent().get(&cap_key);

if profile.supply > max_supply {
return Err(ContractError::SupplyCapExceeded);
}

match existing {
None => {
env.storage().persistent().set(&cap_key, &max_supply);
env.events().publish(
events::supply_cap_set_topics(&creator),
events::SupplyCapSetEvent {
creator_id: creator,
cap: max_supply,
},
);
}
Some(old_max_supply) => {
if max_supply > old_max_supply {
return Err(ContractError::CapCannotIncrease);
}
if max_supply == old_max_supply {
return Ok(());
}

env.storage().persistent().set(&cap_key, &max_supply);
env.events().publish(
events::max_supply_updated_topics(&creator),
events::MaxSupplyUpdatedEvent {
creator_id: creator,
old_max_supply,
new_max_supply: max_supply,
},
);
}
}

Ok(())
}

// =========================================================================
// #761 — Multi-sig pause/unpause
// =========================================================================
Expand Down
76 changes: 72 additions & 4 deletions creator-keys/tests/supply_cap_enforcement.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Integration tests for configurable supply cap enforcement (#997).
//! Integration tests for configurable supply cap enforcement (#997, #1099).
//!
//! Covered acceptance criteria:
//! 1. A buy that would push post-trade supply past the cap is blocked.
Expand All @@ -10,14 +10,19 @@
//! 5. A configured cap of `0` is treated as unlimited: buys keep growing
//! supply past any small ceiling, and `get_supply_info` reports cap `0`
//! with unbounded remaining.
//! 6. A creator may lower, but not raise, an existing cap; reductions emit a
//! `MaxSupplyUpdated` event and cannot go below current supply.
//!
//! Caps are immutable once set, so each scenario registers its own creator
//! and then applies that key's cap via `set_supply_cap`.
//! Each scenario registers its own creator and applies the initial cap with
//! `set_supply_cap` before exercising it.

mod contract_test_env;

use contract_test_env::{register_creator_keys, set_pricing_and_fees, test_env_with_auths};
use creator_keys::events::{SupplyCapReachedEvent, SUPPLY_CAP_REACHED_EVENT_NAME};
use creator_keys::events::{
MaxSupplyUpdatedEvent, SupplyCapReachedEvent, MAX_SUPPLY_UPDATED_EVENT_NAME,
SUPPLY_CAP_REACHED_EVENT_NAME,
};
use creator_keys::{
ContractError, CreatorKeysContractClient, CurvePreset, KeyMetadata, RegisterCreatorParams,
};
Expand Down Expand Up @@ -93,6 +98,69 @@ fn supply_cap_reached_events(env: &Env) -> Vec<SupplyCapReachedEvent> {
found
}

fn max_supply_updated_events(env: &Env) -> Vec<MaxSupplyUpdatedEvent> {
let mut found = Vec::new(env);
for (_, topics, data) in env.events().all().iter() {
let name: Symbol = topics.get(0).unwrap().into_val(env);
if name == MAX_SUPPLY_UPDATED_EVENT_NAME {
found.push_back(data.into_val(env));
}
}
found
}

#[test]
fn test_set_max_supply_reduces_cap_and_emits_update_event() {
let env = test_env_with_auths();
let (client, admin) = setup(&env);
let creator = register_capped_key(&env, &client, &admin, "lower_cap", 10);

client.set_max_supply(&creator, &7);
let events = max_supply_updated_events(&env);

let info = client.get_supply_info(&creator);
assert_eq!(info.supply, 0);
assert_eq!(info.cap, 7);
assert_eq!(info.remaining, 7);

assert_eq!(events.len(), 1);
let event = events.get(0).unwrap();
assert_eq!(event.creator_id, creator);
assert_eq!(event.old_max_supply, 10);
assert_eq!(event.new_max_supply, 7);
}

#[test]
fn test_set_max_supply_rejects_increase() {
let env = test_env_with_auths();
let (client, admin) = setup(&env);
let creator = register_capped_key(&env, &client, &admin, "raise_cap", 10);

assert_eq!(
client.try_set_max_supply(&creator, &11),
Err(Ok(ContractError::CapCannotIncrease))
);
assert_eq!(client.get_supply_info(&creator).cap, 10);
assert!(max_supply_updated_events(&env).is_empty());
}

#[test]
fn test_set_max_supply_cannot_drop_below_current_supply() {
let env = test_env_with_auths();
let (client, admin) = setup(&env);
let creator = register_capped_key(&env, &client, &admin, "below_supply", 3);
let buyer = Address::generate(&env);
buy_one(&client, &creator, &buyer);
buy_one(&client, &creator, &buyer);

assert_eq!(
client.try_set_max_supply(&creator, &1),
Err(Ok(ContractError::SupplyCapExceeded))
);
assert_eq!(client.get_supply_info(&creator).cap, 3);
assert!(max_supply_updated_events(&env).is_empty());
}

// ---------------------------------------------------------------------------
// Criterion 1: buys that would exceed the cap are blocked
// ---------------------------------------------------------------------------
Expand Down
78 changes: 5 additions & 73 deletions docs/contract-event-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,82 +150,15 @@ Notes for indexers:
- Tiers are ratchet-only (`Bronze` = 0, `Silver` = 1, `Gold` = 2) and never downgrade regardless of subsequent volume drops.
- `reward_rate_bps` reports the new reward rate in basis points (10,000 bps = 100%).

### LP Reward Rate Update Events (#1032)
### Supply Cap Events (#1099)

| Event Name | Topics (Index 0, 1) | Data Fields | Data Type |
| :--- | :--- | :--- | :--- |
| `lp_rate` | `(Symbol("lp_rate"), key_id)` | `key_id`, `old_rate_bps`, `new_rate_bps`, `updated_at` | `struct RewardRateUpdated` |
| `cap_set` | `(Symbol("cap_set"), creator_id)` | `creator_id`, `cap` | `struct SupplyCapSetEvent` |
| `max_sup` | `(Symbol("max_sup"), creator_id)` | `creator_id`, `old_max_supply`, `new_max_supply` | `struct MaxSupplyUpdatedEvent` |
| `cap_reach` | `(Symbol("cap_reach"), creator_id)` | `creator_id`, `new_supply`, `cap`, `ledger` | `struct SupplyCapReachedEvent` |

Notes for indexers:

- `lp_rate` is emitted by `set_reward_rate` when a creator updates the reward rate for their LP pool.
- Topic index 1 is `key_id` (`Address`).
- `old_rate_bps` reports the previous rate in basis points (or `0` on first configuration); `new_rate_bps` reports the newly configured rate (`1..=5000` bps).
- `updated_at` is the ledger timestamp in seconds.

### Per-Key Dynamic Swap Fee Events (#1056)

| Event Name | Topics (Index 0, 1) | Data Fields | Data Type |
| :--- | :--- | :--- | :--- |
| `fee_adj` | `(Symbol("fee_adj"), key_id)` | `key_id`, `previous_fee_bps`, `new_fee_bps`, `rolling_volume`, `timestamp` | `struct FeeAdjustedEvent` |

Notes for indexers:

- `rolling_volume` is gross trade volume in the raw price base units at the
transition point. An expiry transition reports volume before the current
trade is added; a bracket crossing caused by that trade reports volume after
it is added.
- Swaps select their fee from pre-trade volume. Fee changes caused by the
current swap apply to later swaps.
- Time expiration and bracket updates do not autonomously emit events. A
later successful trade detects and emits the transition; reads never emit
or mutate.
- No event is emitted if the fee bps is unchanged.

### Supply Milestone Fee Reduction Events (#1076)

| Event Name | Topics (Index 0, 1) | Data Fields | Data Type |
| :--- | :--- | :--- | :--- |
| `ms_reach` | `(Symbol("ms_reach"), key_id)` | `key_id`, `tier`, `supply`, `new_fee_bps`, `ledger` | `struct MilestoneReachedEvent` |

Notes for indexers:

- `ms_reach` is emitted by `check_and_apply_milestone` (also invoked post-swap from buy/sell core paths) when a key's circulating supply newly reaches a milestone tier whose fee lowers the fee currently in effect.
- Topic index 1 is `key_id` (`Address`).
- `tier` is the 1-based index of the milestone tier reached; tiers are applied in ascending supply order and never roll back.
- `supply` is the circulating supply after the trade that crossed the threshold.
- `new_fee_bps` reports the new fee in basis points (`0..=10000`).
- `ledger` is the ledger sequence number at the time of the reduction.

### Key Liquidity Depth Events (#1133)

| Event Name | Topics (Index 0, 1) | Data Fields | Data Type |
| :--- | :--- | :--- | :--- |
| `depth_upd` | `(Symbol("depth_upd"), key_id)` | `key_id`, `price`, `amount`, `side`, `volume` | `struct DepthUpdated` |

Notes for indexers:

- `depth_upd` is emitted on each order placement (`record_order`) and cancellation (`cancel_order_depth`) when order book liquidity depth changes.
- Topic index 1 is `key_id` (`Address`).
- `price` is the affected price level (`i128`).
- `amount` is the order volume added or cancelled (`i128`).
- `side` is the order book side (`OrderSide::Buy` = 0, `OrderSide::Sell` = 1).
- `volume` is the remaining volume at this specific price level after the update.

### Creator Key Vote Delegation Events (#1116)

| Event Name | Topics (Index 0, 1, 2) | Data Fields | Data Type |
| :--- | :--- | :--- | :--- |
| `vote_del` | `(Symbol("vote_del"), key_id, holder)` | `holder`, `delegate_wallet`, `key_id`, `weight` | `struct VotingDelegated` |
| `v_del_rev` | `(Symbol("v_del_rev"), key_id, holder)` | `holder`, `previous_delegate_wallet`, `key_id`, `weight` | `struct DelegationRevoked` |

Notes for indexers:

- `vote_del` is emitted by `delegate_votes` when a key holder assigns their voting weight on `key_id` to a proxy delegate wallet.
- `v_del_rev` is emitted by `revoke_delegation` when a key holder revokes their voting delegation on `key_id`.
- Topic index 1 is `key_id` (`Address`) and topic index 2 is `holder` (`Address`).
- `weight` reports the key balance held by the delegator at delegation/revocation time.
- Note that because delegated weight dynamically tracks the delegator's balance while delegation is active, indexers tracking current voting weight should compute delegate weight dynamically from the active delegator set and their balances.
`max_sup` is emitted only when the creator lowers an already-configured cap. Initial caps continue to use `cap_set`; raising or removing a cap is rejected. A blocked buy returns the stable `SupplyCapExceeded` contract error code. Clients can query `get_supply_info` for the current supply and cap because Soroban contract errors carry numeric codes rather than dynamic payload fields.

### Key Freeze Events (#1174)

Expand All @@ -245,4 +178,3 @@ Notes for indexers:
## Data Type Inconsistency
While the general preference is for `struct` payloads (like `register`), some high-frequency events like `buy` and `sell` use `tuples` for gas efficiency. Indexers should check the `contracttype` encoding to distinguish between map-based structs and array-based tuples.


4 changes: 3 additions & 1 deletion docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.rs#L50-L83) as `p
| `21` | `NoKeyHolders` | Dividend distribution attempted for creator with zero key holders | Triggered in [`distribute_dividend`](../creator-keys/src/lib.rs#L2523) when holder count or total supply is `0`. |
| `22` | `AllocationLocked` | Creator locked allocation claimed before lockup ledger sequence | Triggered in [`register_creator`](../creator-keys/src/lib.rs#L1336) or [`claim_locked_allocation`](../creator-keys/src/lib.rs#L2668) when current ledger `< lockup_ledger`. |
| `23` | `AlreadyClaimed` | Creator locked allocation was already claimed previously | Triggered in [`claim_locked_allocation`](../creator-keys/src/lib.rs#L2663) when locked allocation flag is true. |
| `24` | `SupplyCapExceeded` | Action would cause total key supply to exceed the creator supply cap | Triggered in [`register_creator`](../creator-keys/src/lib.rs#L1369), [`buy_key`](../creator-keys/src/lib.rs#L1483), or [`airdrop_keys`](../creator-keys/src/lib.rs#L1759) when `supply + amount > supply_cap`. |
| `24` | `SupplyCapExceeded` | Action would cause total key supply to exceed the creator supply cap | Triggered in [`register_creator`](../creator-keys/src/lib.rs#L1369), [`buy_key`](../creator-keys/src/lib.rs#L1483), [`airdrop_keys`](../creator-keys/src/lib.rs#L1759), or [`set_max_supply`](../creator-keys/src/lib.rs#L13135) when the requested cap is below current supply. Query [`get_supply_info`](../creator-keys/src/lib.rs#L16600) for current supply, cap, and remaining mintable supply. |
| `25` | `InsufficientSupply` | Buyback quantity requested exceeds current circulating total supply | Triggered in [`get_buyback_quote`](../creator-keys/src/lib.rs#L2455) or [`buyback`](../creator-keys/src/lib.rs#L1648) when `amount > total_supply`. |
| `26` | `SelfTransfer` | Attempted key transfer to sender's own address (`from == to`) | Triggered in [`transfer_keys`](../creator-keys/src/lib.rs#L2846) when `from == to`. |
| `27` | `ZeroTransferAmount` | Attempted key transfer with an amount of `0` | Triggered in [`transfer_keys`](../creator-keys/src/lib.rs#L2843) when `amount == 0`. |
Expand All @@ -59,6 +59,8 @@ intentionally unassigned.

| Code | Variant | Description | Trigger Condition |
| :--- | :--- | :--- | :--- |
| `105` | `CapCannotIncrease` | A holder or supply cap cannot be raised after it has been set | Triggered in [`set_holder_cap`](../creator-keys/src/lib.rs) or [`set_max_supply`](../creator-keys/src/lib.rs#L13135) when the requested cap exceeds the stored cap. |
| `106` | `CapTooLow` | The requested holder cap is below the minimum allowed percentage | Triggered in [`set_holder_cap`](../creator-keys/src/lib.rs) when `cap_bps < HOLDER_CAP_MIN_BPS`. |
| `107` | `WalletAlertLimitReached` | The wallet already holds the maximum number of active price alerts | Triggered in [`price_alerts::register_alert`](../creator-keys/src/price_alerts.rs) when the wallet has `MAX_ACTIVE_ALERTS_PER_WALLET` (10) active alerts across all keys. Alerts that have fired or been cancelled do not count. |
| `108` | `KeyAlertLimitReached` | The key already has the maximum number of active price alerts | Triggered in [`price_alerts::register_alert`](../creator-keys/src/price_alerts.rs) when the key has `MAX_ACTIVE_ALERTS_PER_KEY` (16) active alerts from any wallets. |
| `109` | `AlertNotFound` | No active price alert with that id belongs to the wallet | Triggered in [`price_alerts::cancel_alert`](../creator-keys/src/price_alerts.rs) when the id never existed, belongs to another wallet, or the alert has already fired or been cancelled. |
Expand Down
2 changes: 1 addition & 1 deletion docs/storage-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ distinguishable from other key types.
| `HolderDividendCheckpoint(Address, Address)` | Per-creator-holder (composite) | `i128` | `settle_holder_dividends`, `claim_dividend`, `batch_claim_dividend` | `compute_claimable_dividend` |
| `HolderDividendPending(Address, Address)` | Per-creator-holder (composite) | `i128` | `settle_holder_dividends`, `claim_dividend`, `batch_claim_dividend` | `compute_claimable_dividend`, `get_claimable_dividend` |
| `LockedAllocation(Address)` | Per-creator | `LockedAllocation { amount, unlock_ledger, claimed }` | `register_creator` (initial write), `claim_locked_allocation` (marks claimed) | `get_locked_allocation`, `claim_locked_allocation` |
| `MaxSupply(Address)` | Per-creator | `u32` | `register_creator` (optional, if a cap is supplied) | `buy_key`, `airdrop_keys` (cap enforcement), `get_max_supply` |
| `MaxSupply(Address)` | Per-creator | `u32` | `register_creator` (optional), `set_supply_cap`, `set_max_supply` | `buy_key`, `airdrop_keys` (cap enforcement), `get_max_supply`, `get_supply_info` |
| `CurveSlope` | Global | `i128` | `set_curve_slope` (admin) | `read_curve_slope`, `compute_bonding_curve_price`, `get_curve_slope` |
| `CurvePreset(Address)` | Per-creator | `CurvePreset` (`Linear` \| `Quadratic` \| `Flat`) | `register_creator` (always set; defaults to `Linear` if not specified — presets are immutable after registration) | `compute_bonding_curve_price`, `get_curve_preset` |
| `TreasuryBalance` | Global | `i128` | `credit_treasury_balance`, `withdraw_treasury` (admin) | `read_treasury_balance`, `get_treasury_balance` |
Expand Down
Loading