diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 657557c6..99bdff21 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -20,6 +20,9 @@ pub enum ContractError { InsufficientBalance = 9, SellUnderflow = 10, ProtocolFeeExceedsCap = 11, + HandleTooShort = 12, + HandleTooLong = 13, + InvalidHandleCharacter = 14, } pub mod fee { @@ -213,6 +216,8 @@ pub const PROTOCOL_STATE_VERSION: u32 = 1; /// /// Matches the standard Soroban token decimal convention (7 decimal places). pub const KEY_DECIMALS: u32 = 7; +pub const HANDLE_LEN_MIN: u32 = 3; +pub const HANDLE_LEN_MAX: u32 = 32; #[derive(Clone)] #[contracttype] @@ -286,6 +291,31 @@ pub fn read_creator_handle(env: &Env, creator: &Address) -> String { .unwrap_or_else(|| read_none_string(env)) } +fn is_valid_handle_byte(byte: u8) -> bool { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' +} + +fn validate_creator_handle(handle: &String) -> Result<(), ContractError> { + let len = handle.len(); + if len < HANDLE_LEN_MIN { + return Err(ContractError::HandleTooShort); + } + if len > HANDLE_LEN_MAX { + return Err(ContractError::HandleTooLong); + } + + let mut bytes = [0u8; HANDLE_LEN_MAX as usize]; + handle.copy_into_slice(&mut bytes[..len as usize]); + if bytes[..len as usize] + .iter() + .any(|byte| !is_valid_handle_byte(*byte)) + { + return Err(ContractError::InvalidHandleCharacter); + } + + Ok(()) +} + fn read_protocol_fee_config(env: &Env) -> Option { env.storage() .persistent() @@ -380,7 +410,11 @@ impl CreatorKeysContract { ) -> Result<(), ContractError> { creator.require_auth(); + validate_creator_handle(&handle)?; + let key = constants::storage::creator(&creator); + // Creator profile storage is a single source of truth keyed by creator address. + // Once written, this key's existence is the registration invariant. if env.storage().persistent().has(&key) { return Err(ContractError::AlreadyRegistered); } @@ -393,6 +427,8 @@ impl CreatorKeysContract { fee_recipient: creator.clone(), }; + // Persist profile before event publication so indexers reading contract state + // after this tx observe the same registration payload that was emitted. env.storage().persistent().set(&key, &profile); env.events().publish( events::register_event_topics(&profile.creator), @@ -432,6 +468,7 @@ impl CreatorKeysContract { let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; let balance_key = constants::storage::key_balance(&creator, &buyer); + // Missing balance entries are treated as zero to keep storage sparse. let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); if current_balance == 0 { @@ -447,11 +484,13 @@ impl CreatorKeysContract { .ok_or(ContractError::Overflow)?; let key = constants::storage::creator(&creator); + // Supply and holder_count must always move together with buyer balance writes. env.storage().persistent().set(&key, &profile); let new_balance = current_balance .checked_add(1) .ok_or(ContractError::Overflow)?; + // Balance key is scoped by (creator, holder) so creator positions cannot collide. env.storage().persistent().set(&balance_key, &new_balance); env.events().publish( @@ -468,6 +507,7 @@ impl CreatorKeysContract { let mut profile: CreatorProfile = read_registered_creator_profile(&env, &creator)?; let balance_key = constants::storage::key_balance(&creator, &seller); + // Missing balance entries are interpreted as zero and rejected consistently. let current_balance: u32 = env.storage().persistent().get(&balance_key).unwrap_or(0); if current_balance == 0 { return Err(ContractError::InsufficientBalance); @@ -489,6 +529,8 @@ impl CreatorKeysContract { } let key = constants::storage::creator(&creator); + // Profile and holder balance are updated in the same call to preserve + // supply/holder_count invariants for subsequent reads. env.storage().persistent().set(&key, &profile); env.storage().persistent().set(&balance_key, &new_balance); @@ -497,6 +539,7 @@ impl CreatorKeysContract { pub fn get_key_balance(env: Env, creator: Address, wallet: Address) -> u32 { let key = constants::storage::key_balance(&creator, &wallet); + // Read-only callers get `0` for unseen balances to avoid sparse-map lookups failing. env.storage().persistent().get(&key).unwrap_or(0) } diff --git a/creator-keys/test_snapshots/sell_quote_100_percent_creator_seller_net_is_zero_fees_absorb_full_price.1.json b/creator-keys/test_snapshots/sell_quote_100_percent_creator_seller_net_is_zero_fees_absorb_full_price.1.json index 6dbbaea9..91c04761 100644 --- a/creator-keys/test_snapshots/sell_quote_100_percent_creator_seller_net_is_zero_fees_absorb_full_price.1.json +++ b/creator-keys/test_snapshots/sell_quote_100_percent_creator_seller_net_is_zero_fees_absorb_full_price.1.json @@ -68,7 +68,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, { - "string": "c5" + "string": "cr5" } ] } @@ -176,7 +176,7 @@ "symbol": "handle" }, "val": { - "string": "c5" + "string": "cr5" } }, { diff --git a/creator-keys/test_snapshots/sell_quote_50_50_price_ten_equal_split_zero_net.1.json b/creator-keys/test_snapshots/sell_quote_50_50_price_ten_equal_split_zero_net.1.json index ba7708a5..3a850ce3 100644 --- a/creator-keys/test_snapshots/sell_quote_50_50_price_ten_equal_split_zero_net.1.json +++ b/creator-keys/test_snapshots/sell_quote_50_50_price_ten_equal_split_zero_net.1.json @@ -68,7 +68,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, { - "string": "c4" + "string": "cr4" } ] } @@ -177,7 +177,7 @@ "symbol": "handle" }, "val": { - "string": "c4" + "string": "cr4" } }, { diff --git a/creator-keys/test_snapshots/sell_quote_50_50_small_price_protocol_takes_first_floor_unit.1.json b/creator-keys/test_snapshots/sell_quote_50_50_small_price_protocol_takes_first_floor_unit.1.json index 5c004493..7e2b833c 100644 --- a/creator-keys/test_snapshots/sell_quote_50_50_small_price_protocol_takes_first_floor_unit.1.json +++ b/creator-keys/test_snapshots/sell_quote_50_50_small_price_protocol_takes_first_floor_unit.1.json @@ -68,7 +68,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, { - "string": "c3" + "string": "cr3" } ] } @@ -176,7 +176,7 @@ "symbol": "handle" }, "val": { - "string": "c3" + "string": "cr3" } }, { diff --git a/creator-keys/test_snapshots/sell_quote_90_10_dust_price_one_all_creator_no_protocol_rounding.1.json b/creator-keys/test_snapshots/sell_quote_90_10_dust_price_one_all_creator_no_protocol_rounding.1.json index 7beab699..0c1fb2e6 100644 --- a/creator-keys/test_snapshots/sell_quote_90_10_dust_price_one_all_creator_no_protocol_rounding.1.json +++ b/creator-keys/test_snapshots/sell_quote_90_10_dust_price_one_all_creator_no_protocol_rounding.1.json @@ -68,7 +68,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, { - "string": "c2" + "string": "cr2" } ] } @@ -177,7 +177,7 @@ "symbol": "handle" }, "val": { - "string": "c2" + "string": "cr2" } }, { diff --git a/creator-keys/test_snapshots/sell_quote_90_10_remainder_favors_creator_on_indivisible_price.1.json b/creator-keys/test_snapshots/sell_quote_90_10_remainder_favors_creator_on_indivisible_price.1.json index 14e9383d..d091f584 100644 --- a/creator-keys/test_snapshots/sell_quote_90_10_remainder_favors_creator_on_indivisible_price.1.json +++ b/creator-keys/test_snapshots/sell_quote_90_10_remainder_favors_creator_on_indivisible_price.1.json @@ -68,7 +68,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, { - "string": "c1" + "string": "cr1" } ] } @@ -176,7 +176,7 @@ "symbol": "handle" }, "val": { - "string": "c1" + "string": "cr1" } }, { diff --git a/creator-keys/test_snapshots/sell_quote_max_allowed_protocol_bps_50_50_dust_price_floors_protocol_share_to_zero.1.json b/creator-keys/test_snapshots/sell_quote_max_allowed_protocol_bps_50_50_dust_price_floors_protocol_share_to_zero.1.json index cf7edd09..290539fb 100644 --- a/creator-keys/test_snapshots/sell_quote_max_allowed_protocol_bps_50_50_dust_price_floors_protocol_share_to_zero.1.json +++ b/creator-keys/test_snapshots/sell_quote_max_allowed_protocol_bps_50_50_dust_price_floors_protocol_share_to_zero.1.json @@ -68,7 +68,7 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, { - "string": "c6" + "string": "cr6" } ] } @@ -177,7 +177,7 @@ "symbol": "handle" }, "val": { - "string": "c6" + "string": "cr6" } }, { diff --git a/creator-keys/test_snapshots/test_register_creator_accepts_max_handle_length.1.json b/creator-keys/test_snapshots/test_register_creator_accepts_max_handle_length.1.json new file mode 100644 index 00000000..a3be84bc --- /dev/null +++ b/creator-keys/test_snapshots/test_register_creator_accepts_max_handle_length.1.json @@ -0,0 +1,217 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "register_creator", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "string": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Creator" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Creator" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "creator" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "handle" + }, + "val": { + "string": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "key": { + "symbol": "holder_count" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "supply" + }, + "val": { + "u32": 0 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/creator-keys/test_snapshots/test_register_creator_accepts_min_handle_length.1.json b/creator-keys/test_snapshots/test_register_creator_accepts_min_handle_length.1.json new file mode 100644 index 00000000..7c61f822 --- /dev/null +++ b/creator-keys/test_snapshots/test_register_creator_accepts_min_handle_length.1.json @@ -0,0 +1,217 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "register_creator", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "string": "aaa" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Creator" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Creator" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "creator" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "handle" + }, + "val": { + "string": "aaa" + } + }, + { + "key": { + "symbol": "holder_count" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "supply" + }, + "val": { + "u32": 0 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/creator-keys/test_snapshots/test_register_creator_rejects_handle_longer_than_max.1.json b/creator-keys/test_snapshots/test_register_creator_rejects_handle_longer_than_max.1.json new file mode 100644 index 00000000..56557491 --- /dev/null +++ b/creator-keys/test_snapshots/test_register_creator_rejects_handle_longer_than_max.1.json @@ -0,0 +1,76 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/creator-keys/test_snapshots/test_register_creator_rejects_handle_shorter_than_min.1.json b/creator-keys/test_snapshots/test_register_creator_rejects_handle_shorter_than_min.1.json new file mode 100644 index 00000000..56557491 --- /dev/null +++ b/creator-keys/test_snapshots/test_register_creator_rejects_handle_shorter_than_min.1.json @@ -0,0 +1,76 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/creator-keys/test_snapshots/test_register_creator_rejects_invalid_characters_in_handle.1.json b/creator-keys/test_snapshots/test_register_creator_rejects_invalid_characters_in_handle.1.json new file mode 100644 index 00000000..56557491 --- /dev/null +++ b/creator-keys/test_snapshots/test_register_creator_rejects_invalid_characters_in_handle.1.json @@ -0,0 +1,76 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/creator-keys/tests/creator_registration.rs b/creator-keys/tests/creator_registration.rs index d70984b7..a448fdb3 100644 --- a/creator-keys/tests/creator_registration.rs +++ b/creator-keys/tests/creator_registration.rs @@ -1,6 +1,8 @@ //! Tests for is_creator_registered view method (#28) and duplicate registration rejection (#31). -use creator_keys::{ContractError, CreatorKeysContract, CreatorKeysContractClient}; +use creator_keys::{ + ContractError, CreatorKeysContract, CreatorKeysContractClient, HANDLE_LEN_MAX, HANDLE_LEN_MIN, +}; use soroban_sdk::{testutils::Address as _, Address, Env, String}; // ── is_creator_registered tests (#28) ─────────────────────────────────── @@ -117,3 +119,75 @@ fn test_register_creator_different_addresses_succeeds() { assert!(client.is_creator_registered(&alice)); assert!(client.is_creator_registered(&bob)); } + +#[test] +fn test_register_creator_accepts_min_handle_length() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let min_handle = "a".repeat(HANDLE_LEN_MIN as usize); + client.register_creator(&creator, &String::from_str(&env, &min_handle)); + + assert!(client.is_creator_registered(&creator)); +} + +#[test] +fn test_register_creator_accepts_max_handle_length() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let max_handle = "a".repeat(HANDLE_LEN_MAX as usize); + client.register_creator(&creator, &String::from_str(&env, &max_handle)); + + assert!(client.is_creator_registered(&creator)); +} + +#[test] +fn test_register_creator_rejects_handle_shorter_than_min() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let short_handle = "a".repeat((HANDLE_LEN_MIN - 1) as usize); + let result = client.try_register_creator(&creator, &String::from_str(&env, &short_handle)); + assert_eq!(result, Err(Ok(ContractError::HandleTooShort))); +} + +#[test] +fn test_register_creator_rejects_handle_longer_than_max() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let long_handle = "a".repeat((HANDLE_LEN_MAX + 1) as usize); + let result = client.try_register_creator(&creator, &String::from_str(&env, &long_handle)); + assert_eq!(result, Err(Ok(ContractError::HandleTooLong))); +} + +#[test] +fn test_register_creator_rejects_invalid_characters_in_handle() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let creator = Address::generate(&env); + let invalid_handle = String::from_str(&env, "Alice-01"); + let result = client.try_register_creator(&creator, &invalid_handle); + assert_eq!(result, Err(Ok(ContractError::InvalidHandleCharacter))); +} diff --git a/creator-keys/tests/sell_quote_rounding.rs b/creator-keys/tests/sell_quote_rounding.rs index 0567d6d8..bc348983 100644 --- a/creator-keys/tests/sell_quote_rounding.rs +++ b/creator-keys/tests/sell_quote_rounding.rs @@ -60,7 +60,7 @@ fn sell_quote_90_10_remainder_favors_creator_on_indivisible_price() { let (client, _) = register_creator_keys(&env); let key_price = 999_i128; set_pricing_and_fees(&env, &client, key_price, 9000, 1000); - let creator = register_test_creator(&env, &client, "c1"); + let creator = register_test_creator(&env, &client, "cr1"); let holder = register_holder_with_one_key(&env, &client, &creator); // 999 * 1000 / 10000 = 99 protocol; creator gets 900; net = 0 assert_sell_quote(&client, &creator, &holder, key_price, 9000, 1000); @@ -72,7 +72,7 @@ fn sell_quote_90_10_dust_price_one_all_creator_no_protocol_rounding() { let (client, _) = register_creator_keys(&env); let key_price = 1_i128; set_pricing_and_fees(&env, &client, key_price, 9000, 1000); - let creator = register_test_creator(&env, &client, "c2"); + let creator = register_test_creator(&env, &client, "cr2"); let holder = register_holder_with_one_key(&env, &client, &creator); assert_sell_quote(&client, &creator, &holder, key_price, 9000, 1000); let q = client.get_sell_quote(&creator, &holder); @@ -87,7 +87,7 @@ fn sell_quote_50_50_small_price_protocol_takes_first_floor_unit() { let (client, _) = register_creator_keys(&env); let key_price = 3_i128; set_pricing_and_fees(&env, &client, key_price, 5000, 5000); - let creator = register_test_creator(&env, &client, "c3"); + let creator = register_test_creator(&env, &client, "cr3"); let holder = register_holder_with_one_key(&env, &client, &creator); // floor(3 * 5000 / 10000) = 1 protocol; creator = 2; net = 0 assert_sell_quote(&client, &creator, &holder, key_price, 5000, 5000); @@ -99,7 +99,7 @@ fn sell_quote_50_50_price_ten_equal_split_zero_net() { let (client, _) = register_creator_keys(&env); let key_price = 10_i128; set_pricing_and_fees(&env, &client, key_price, 5000, 5000); - let creator = register_test_creator(&env, &client, "c4"); + let creator = register_test_creator(&env, &client, "cr4"); let holder = register_holder_with_one_key(&env, &client, &creator); assert_sell_quote(&client, &creator, &holder, key_price, 5000, 5000); let q = client.get_sell_quote(&creator, &holder); @@ -112,7 +112,7 @@ fn sell_quote_100_percent_creator_seller_net_is_zero_fees_absorb_full_price() { let (client, _) = register_creator_keys(&env); let key_price = 100_i128; set_pricing_and_fees(&env, &client, key_price, 10000, 0); - let creator = register_test_creator(&env, &client, "c5"); + let creator = register_test_creator(&env, &client, "cr5"); let holder = register_holder_with_one_key(&env, &client, &creator); let q = client.get_sell_quote(&creator, &holder); assert_eq!((q.creator_fee, q.protocol_fee, q.total_amount), (100, 0, 0)); @@ -125,7 +125,7 @@ fn sell_quote_max_allowed_protocol_bps_50_50_dust_price_floors_protocol_share_to // 50% / 50% is the maximum protocol share allowed (`PROTOCOL_BPS_MAX` = 5000). let key_price = 1_i128; set_pricing_and_fees(&env, &client, key_price, 5000, 5000); - let creator = register_test_creator(&env, &client, "c6"); + let creator = register_test_creator(&env, &client, "cr6"); let holder = register_holder_with_one_key(&env, &client, &creator); // floor(1 * 5000 / 10000) = 0 protocol; creator 1; net 0 assert_sell_quote(&client, &creator, &holder, key_price, 5000, 5000); diff --git a/docs/error-codes.md b/docs/error-codes.md index 74ed2efd..84adfc6e 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -19,6 +19,9 @@ Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.r | 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` | +| 12 | `HandleTooShort` | 12 | Creator handle length is below minimum bound (`< 3`) during registration | Provide a handle with at least 3 characters | +| 13 | `HandleTooLong` | 13 | Creator handle length exceeds maximum bound (`> 32`) during registration | Provide a handle with at most 32 characters | +| 14 | `InvalidHandleCharacter` | 14 | Creator handle contains unsupported characters (allowed set: lowercase `a-z`, digits `0-9`, underscore `_`) | Normalize handle to allowed characters before calling `register_creator` | ## Integration Notes @@ -26,6 +29,7 @@ Error codes are defined in [`creator-keys/src/lib.rs`](../creator-keys/src/lib.r - 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. +- Codes 12 (`HandleTooShort`), 13 (`HandleTooLong`), and 14 (`InvalidHandleCharacter`) are deterministic registration validation failures. Validate handles client-side with the same bounds and character set before submitting transactions. ### Pricing and Fees diff --git a/docs/open-source/creator-profile-expansion-roadmap.md b/docs/open-source/creator-profile-expansion-roadmap.md new file mode 100644 index 00000000..d5fe58b6 --- /dev/null +++ b/docs/open-source/creator-profile-expansion-roadmap.md @@ -0,0 +1,45 @@ +# Creator Profile Optional Fields Roadmap + +This roadmap item scopes optional creator profile field expansion without breaking current storage and read semantics. + +## Candidate Optional Fields + +These fields are explicitly optional and must not be required for registration: + +- `display_name`: human-readable name distinct from handle. +- `bio`: short creator description. +- `avatar_url`: off-chain image URL reference. +- `social_links`: bounded list of external profile links. +- `website_url`: primary website link. + +## Backward Compatibility Expectations + +- Existing `CreatorProfile` storage entries remain readable without migration. +- Existing read methods (`get_creator`, `get_creator_details`, key/supply/fee reads) keep current return shapes unless a new versioned method is introduced. +- Optional fields should use additive storage patterns: + - either a new optional map keyed by creator address, or + - a versioned profile representation with explicit default handling. +- Missing optional data must resolve to deterministic defaults in view methods. +- Existing indexers should continue to decode current events and reads without schema breakage. + +## Phased Contributor Plan + +1. Design phase + - Decide additive storage model (`DataKey::CreatorOptionalProfile` map vs versioned struct). + - Define max sizes and allowed formats for each optional field. +2. Contract phase + - Add write entrypoint(s) for optional profile updates with auth and validation. + - Add read entrypoint(s) that return stable, non-panicking views. +3. Migration/compatibility phase + - Confirm pre-existing creator records read correctly with no backfill required. + - Add tests for missing optional fields and mixed old/new records. +4. Integration phase + - Document consumer behavior for missing fields and default values. + - Align event/indexer expectations for optional profile updates. + +## Follow-Up Implementation Tasks + +- Add `ContractError` variants for optional profile validation failures. +- Add storage key invariants for new optional profile storage paths. +- Add tests for bounds, invalid URL formats (if enforced), and auth constraints. +- Add read-only documentation for optional profile response semantics. diff --git a/docs/open-source/issue-backlog.md b/docs/open-source/issue-backlog.md index 3f134f63..0074ddb0 100644 --- a/docs/open-source/issue-backlog.md +++ b/docs/open-source/issue-backlog.md @@ -13,7 +13,7 @@ This backlog is organized into contributor-ready sections. Each section contains 7. `contracts-registry-07` Add helper accessors for supply and creator lookup responses. 8. `contracts-registry-08` Document storage keys and invariants for contributor onboarding. 9. `contracts-registry-09` Add contract comments that explain persistent storage choices. -10. `contracts-registry-10` Define a roadmap issue for optional creator profile expansion fields. +10. `contracts-registry-10` Define a roadmap issue for optional creator profile expansion fields. See [creator-profile-expansion-roadmap.md](./creator-profile-expansion-roadmap.md). ## Section 2: Trading, Fees, and Economics @@ -33,7 +33,7 @@ This backlog is organized into contributor-ready sections. Each section contains 1. `contracts-tooling-01` Add a reusable test helper module for common contract setup. 2. `contracts-tooling-02` Add cargo aliases or scripts for formatter, clippy, and test workflows. 3. `contracts-tooling-03` Add deployment notes for Stellar testnet. -4. `contracts-tooling-04` Add a contract release checklist covering verification and deployment. +4. `contracts-tooling-04` Add a contract release checklist covering verification and deployment. See [testnet-release-checklist.md](../testnet-release-checklist.md). 5. `contracts-tooling-05` Add one `good first issue` test-only task with clear acceptance criteria. 6. `contracts-tooling-06` Add CI documentation explaining what each contract check validates. 7. `contracts-tooling-07` Add a local developer guide for Soroban prerequisites. diff --git a/docs/stellar-testnet-deployment.md b/docs/stellar-testnet-deployment.md index ca762050..203c52bb 100644 --- a/docs/stellar-testnet-deployment.md +++ b/docs/stellar-testnet-deployment.md @@ -115,14 +115,10 @@ If the second command returns a populated profile with `supply: 0`, the deployme ## Lightweight release checklist -Use this checklist for any contract update that is intended for shared review or a testnet rollout. - -- Confirm the branch includes the intended contract changes only. -- Run `cargo fmt --all -- --check`. -- Run `cargo clippy --workspace --all-targets -- -D warnings`. -- Run `cargo test --workspace`. -- Rebuild the wasm with `stellar contract build --package creator-keys`. -- Deploy the new wasm to Stellar testnet from a funded identity. -- Run the register and read smoke tests against the deployed contract. -- Capture the deployed contract ID and relevant CLI output in the PR description, issue, or release notes. -- Call out any storage layout, event schema, or authorization changes explicitly for reviewers. +Use the short actionable checklist in [testnet-release-checklist.md](./testnet-release-checklist.md) for shared review or testnet rollout validation. + +At minimum, always capture: + +- built wasm artifact path and checksum, +- deployed contract ID, +- smoke-test results for register/read calls. diff --git a/docs/testnet-release-checklist.md b/docs/testnet-release-checklist.md new file mode 100644 index 00000000..5bbb286c --- /dev/null +++ b/docs/testnet-release-checklist.md @@ -0,0 +1,14 @@ +# Contract Testnet Release Checklist + +Use this checklist for any `creator-keys` update targeting Stellar testnet verification. + +- Confirm the branch contains only intended contract/docs/test updates. +- Run `cargo fmt --all -- --check`. +- Run `cargo clippy --workspace --all-targets -- -D warnings`. +- Run `cargo test --workspace`. +- Build artifact: `stellar contract build --package creator-keys`. +- Verify artifact exists at `target/wasm32v1-none/release/creator_keys.wasm`. +- Record artifact metadata (at minimum path + SHA256 checksum) in PR notes. +- Deploy to testnet and capture the returned contract ID. +- Run post-deploy smoke checks (`register_creator`, then `get_creator`) against that contract ID. +- Add contract ID, artifact metadata, and smoke-test output summary to the PR.