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
33 changes: 27 additions & 6 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ pub mod constants {
pub const ADMIN_ADDRESS: DataKey = DataKey::AdminAddress;
pub const PROTOCOL_FEE_RECIPIENT: DataKey = DataKey::ProtocolFeeRecipient;
pub const PROTOCOL_FEE_RECIPIENT_BALANCE: DataKey = DataKey::ProtocolFeeRecipientBalance;
pub const PROTOCOL_STATE_VERSION: DataKey = DataKey::ProtocolStateVersion;

pub fn creator_fee_balance(creator: &Address) -> DataKey {
DataKey::CreatorFeeBalance(creator.clone())
Expand Down Expand Up @@ -317,10 +318,11 @@ pub struct QuoteResponse {
/// Shared result type for read-only quote methods.
pub type QuoteViewResult = Result<QuoteResponse, ContractError>;

/// Stable protocol state version for read-only consumers.
/// Initial protocol state version for read-only consumers.
///
/// Bump this value only when externally consumed protocol state semantics change.
pub const PROTOCOL_STATE_VERSION: u32 = 1;
/// The actual version is stored in storage and incremented on config updates.
/// This constant is only the starting value.
pub const PROTOCOL_STATE_VERSION_INITIAL: u32 = 1;

/// Decimal precision used by creator key values.
///
Expand All @@ -345,6 +347,7 @@ pub enum DataKey {
ProtocolFeeRecipient,
ProtocolFeeRecipientBalance,
CreatorFeeBalance(Address),
ProtocolStateVersion,
}

#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -832,9 +835,13 @@ impl CreatorKeysContract {
/// Read-only view: returns the protocol state version.
///
/// Returns a stable scalar value for clients and indexers to detect
/// protocol-state schema/semantics revisions without mutating contract state.
pub fn get_protocol_state_version(_env: Env) -> u32 {
PROTOCOL_STATE_VERSION
/// protocol-state schema/semantics revisions. The version is stored in
/// storage and increments on config updates.
pub fn get_protocol_state_version(env: Env) -> u32 {
env.storage()
.persistent()
.get(&constants::storage::PROTOCOL_STATE_VERSION)
.unwrap_or(PROTOCOL_STATE_VERSION_INITIAL)
}

/// Read-only view: returns the decimal precision used by creator key values.
Expand Down Expand Up @@ -976,6 +983,20 @@ impl CreatorKeysContract {
env.storage()
.persistent()
.set(&constants::storage::FEE_CONFIG, &config);

// Increment protocol state version on config update
let current_version = env
.storage()
.persistent()
.get(&constants::storage::PROTOCOL_STATE_VERSION)
.unwrap_or(PROTOCOL_STATE_VERSION_INITIAL);
let new_version = current_version
.checked_add(1)
.ok_or(ContractError::Overflow)?;
env.storage()
.persistent()
.set(&constants::storage::PROTOCOL_STATE_VERSION, &new_version);

Ok(())
}

Expand Down
38 changes: 38 additions & 0 deletions creator-keys/tests/buy_quote_across_creator_registration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Regression test: registering a new creator should not affect the buy price for an existing creator.

mod contract_test_env;

use contract_test_env::{
register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths,
};

#[test]
fn test_buy_quote_unchanged_after_creator_registration() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);

// Setup typical pricing and fee configuration
let key_price = 1000_i128;
let creator_bps = 9000;
let protocol_bps = 1000;
set_pricing_and_fees(&env, &client, key_price, creator_bps, protocol_bps);

// Register first creator and capture their buy quote
let creator1 = register_test_creator(&env, &client, "alice");
let quote_before = client.get_buy_quote(&creator1);

// Register a second, unrelated creator
let creator2 = register_test_creator(&env, &client, "bob");

// Assert the buy quote for the first creator is unchanged
let quote_after = client.get_buy_quote(&creator1);
assert_eq!(
quote_before, quote_after,
"Buy quote for first creator changed after second creator registration: before={:?}, after={:?}",
quote_before, quote_after
);

// Verify both creators are registered
assert!(client.is_creator_registered(&creator1));
assert!(client.is_creator_registered(&creator2));
}
94 changes: 94 additions & 0 deletions creator-keys/tests/identical_fee_configs_independent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! Regression test: two creators with identical fee configs should apply fees independently.

mod contract_test_env;

use contract_test_env::{
register_creator_keys, register_test_creator, set_pricing_and_fees, test_env_with_auths,
};
use soroban_sdk::{testutils::Address as _, Address};

#[test]
fn test_identical_fee_configs_apply_independently() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);

// Setup pricing and fee configuration
let key_price = 1000_i128;
let creator_bps = 9000;
let protocol_bps = 1000;
set_pricing_and_fees(&env, &client, key_price, creator_bps, protocol_bps);

// Register two creators with identical fee config (via global config)
let creator1 = register_test_creator(&env, &client, "alice");
let creator2 = register_test_creator(&env, &client, "bob");

// Both creators should have the same fee config view
let fee_config1 = client.get_creator_fee_config(&creator1);
let fee_config2 = client.get_creator_fee_config(&creator2);

assert_eq!(fee_config1.creator_bps, creator_bps);
assert_eq!(fee_config2.creator_bps, creator_bps);
assert_eq!(fee_config1.protocol_bps, protocol_bps);
assert_eq!(fee_config2.protocol_bps, protocol_bps);

// Perform a buy for each creator
let buyer1 = Address::generate(&env);
let buyer2 = Address::generate(&env);

let quote1 = client.get_buy_quote(&creator1);
let quote2 = client.get_buy_quote(&creator2);

// Quotes should be identical since they share the same config
assert_eq!(quote1.price, quote2.price);
assert_eq!(quote1.creator_fee, quote2.creator_fee);
assert_eq!(quote1.protocol_fee, quote2.protocol_fee);
assert_eq!(quote1.total_amount, quote2.total_amount);

// Execute buys
client.buy_key(&creator1, &buyer1, &quote1.total_amount);
client.buy_key(&creator2, &buyer2, &quote2.total_amount);

// Verify fee balances are tracked independently
let fee_balance1 = client.get_creator_fee_balance(&creator1);
let fee_balance2 = client.get_creator_fee_balance(&creator2);

assert_eq!(fee_balance1, quote1.creator_fee);
assert_eq!(fee_balance2, quote2.creator_fee);
assert_eq!(fee_balance1, fee_balance2);
}

#[test]
fn test_fee_config_update_does_not_affect_other_creator() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);

// Setup initial fee config
let key_price = 1000_i128;
let creator_bps = 9000;
let protocol_bps = 1000;
set_pricing_and_fees(&env, &client, key_price, creator_bps, protocol_bps);

// Register two creators
let creator1 = register_test_creator(&env, &client, "alice");
let creator2 = register_test_creator(&env, &client, "bob");

// Capture initial quotes
let quote1_initial = client.get_buy_quote(&creator1);
let quote2_initial = client.get_buy_quote(&creator2);

// Update global fee config
let admin = Address::generate(&env);
client.set_fee_config(&admin, &8000u32, &2000u32);

// Both creators should see the new fee config (since it's global)
let quote1_after = client.get_buy_quote(&creator1);
let quote2_after = client.get_buy_quote(&creator2);

// Both should have changed (global config affects all)
assert_ne!(quote1_initial.creator_fee, quote1_after.creator_fee);
assert_ne!(quote2_initial.creator_fee, quote2_after.creator_fee);

// But they should still be identical to each other
assert_eq!(quote1_after.creator_fee, quote2_after.creator_fee);
assert_eq!(quote1_after.protocol_fee, quote2_after.protocol_fee);
}
71 changes: 68 additions & 3 deletions creator-keys/tests/protocol_state_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use creator_keys::{CreatorKeysContract, CreatorKeysContractClient};
use soroban_sdk::{testutils::Address as _, Address, Env, String};

#[test]
fn test_get_protocol_state_version_returns_expected_value() {
fn test_get_protocol_state_version_returns_initial_value() {
let env = Env::default();
let contract_id = env.register(CreatorKeysContract, ());
let client = CreatorKeysContractClient::new(&env, &contract_id);
Expand All @@ -25,7 +25,63 @@ fn test_get_protocol_state_version_is_read_only() {
}

#[test]
fn test_get_protocol_state_version_stable_after_state_changes() {
fn test_protocol_state_version_increments_on_fee_config_update() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(CreatorKeysContract, ());
let client = CreatorKeysContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);

// Read initial version
let version_before = client.get_protocol_state_version();
assert_eq!(version_before, 1);

// Update fee config
client.set_fee_config(&admin, &9000u32, &1000u32);

// Assert version incremented
let version_after = client.get_protocol_state_version();
assert_eq!(version_after, 2);
assert!(version_after > version_before);

// Update fee config again
client.set_fee_config(&admin, &7500u32, &2500u32);

// Assert version incremented again
let version_after_second = client.get_protocol_state_version();
assert_eq!(version_after_second, 3);
assert!(version_after_second > version_after);
}

#[test]
fn test_protocol_state_version_monotonically_increasing() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(CreatorKeysContract, ());
let client = CreatorKeysContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);

let mut previous_version = client.get_protocol_state_version();

// Perform multiple config updates
for i in 2..=5 {
let creator_bps = 10000 - (i * 1000);
let protocol_bps = i * 1000;
client.set_fee_config(&admin, &creator_bps, &protocol_bps);

let current_version = client.get_protocol_state_version();
assert_eq!(current_version, i);
assert!(current_version > previous_version);
previous_version = current_version;
}
}

#[test]
fn test_get_protocol_state_version_increments_only_on_config_updates() {
let env = Env::default();
env.mock_all_auths();

Expand All @@ -36,11 +92,20 @@ fn test_get_protocol_state_version_stable_after_state_changes() {
let creator = Address::generate(&env);
let buyer = Address::generate(&env);

// Initial version
let initial_version = client.get_protocol_state_version();
assert_eq!(initial_version, 1);

// Fee config update should increment version
client.set_fee_config(&admin, &9000u32, &1000u32);
assert_eq!(client.get_protocol_state_version(), 2);

// Other state changes should not increment version
client.set_key_price(&admin, &100i128);
client.register_creator(&creator, &String::from_str(&env, "alice"));
client.buy_key(&creator, &buyer, &100i128);
client.set_treasury_address(&admin, &Address::generate(&env));

assert_eq!(client.get_protocol_state_version(), 1);
// Version should still be 2 (only incremented by fee config update)
assert_eq!(client.get_protocol_state_version(), 2);
}
76 changes: 76 additions & 0 deletions docs/event-stability-guarantees.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Event Stability Guarantees

This document clarifies which aspects of emitted events are considered stable across contract upgrades and what changes are considered breaking for event consumers (indexers, monitoring tools, etc.).

## Stable Guarantees

The following aspects of events are guaranteed to remain stable across contract upgrades:

### Topic Identifiers

- **Topic 0 (Event Name)**: The `Symbol` identifier for an event type (e.g., `buy`, `register`) is stable. Once an event name is introduced, its identifier will not change.
- **Topic Index Meaning**: The semantic meaning of a topic at a given index is stable. For example, if Topic 1 is the `creator` address for the `buy` event, it will always be the `creator` address at that index.
- **Topic Order**: The order of topics is stable. New topics may only be appended at the end; existing topics will not be reordered.

### Data Field Order

- **Field Order**: The order of fields in event data payloads (both structs and tuples) is stable. Once a field is introduced at a position, it will remain at that position.
- **Appends Only**: New fields may only be added to the end of a struct or tuple. Existing fields will not be removed or reordered.
- **Field Types**: The type of a field at a given position is stable. A field that is `Address` at position N will always be `Address` at position N.

## Breaking Changes

The following changes to events are considered breaking for event consumers:

### Topic Changes

- **Renaming Events**: Changing the `Symbol` identifier for an event (e.g., renaming `buy` to `purchase`) is breaking.
- **Reordering Topics**: Changing the order of topics (e.g., moving `buyer` from Topic 2 to Topic 1) is breaking.
- **Removing Topics**: Removing a topic from an event is breaking.
- **Changing Topic Types**: Changing the type of a topic at a given index (e.g., changing Topic 1 from `Address` to `u32`) is breaking.

### Data Payload Changes

- **Reordering Fields**: Changing the order of fields in event data is breaking.
- **Removing Fields**: Removing a field from event data is breaking.
- **Changing Field Types**: Changing the type of a field at a given position is breaking.
- **Changing Data Structure**: Converting a struct payload to a tuple (or vice versa) for an existing event is breaking.

## Non-Breaking Changes

The following changes to events are considered non-breaking for event consumers:

### Topic Changes

- **Appending Topics**: Adding a new topic at the end of the topic list is non-breaking. Consumers that only read existing topics will continue to work.

### Data Payload Changes

- **Appending Fields**: Adding a new field at the end of a struct or tuple is non-breaking. Consumers that only read existing fields will continue to work.
- **Adding Optional Fields**: Adding optional fields (e.g., `Option<T>`) at the end of a struct is non-breaking.

## Guidance for Event Consumers

### Robust Parsing

Event consumers should implement robust parsing strategies:

1. **Field Access by Position**: Access fields by their index/position rather than by name when parsing tuples.
2. **Graceful Degradation**: When new fields are appended, consumers should ignore unknown fields rather than failing.
3. **Version Awareness**: Consumers should track the protocol state version via `get_protocol_state_version` to detect when event schema changes may have occurred.

### Monitoring for Changes

Indexers and monitoring tools should:

1. **Log Unknown Fields**: When encountering unknown fields in event data, log a warning but continue processing.
2. **Track Protocol Version**: Use `get_protocol_state_version` to detect when contract upgrades may have introduced event changes.
3. **Validate Topic Structure**: Verify that topics match expected types and positions before processing.

## Versioning Strategy

When event schemas must change in a breaking way:

1. **Increment Protocol State Version**: The `PROTOCOL_STATE_VERSION` constant should be incremented to signal to consumers that event parsing logic may need updates.
2. **Deprecation Period**: Where possible, maintain backward compatibility by emitting both old and new event formats during a transition period.
3. **Documentation**: Update this document and the event naming conventions document to reflect the new schema.
Loading