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
132 changes: 132 additions & 0 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub enum ContractError {
HandleTooShort = 12,
HandleTooLong = 13,
InvalidHandleCharacter = 14,
ZeroAddress = 15,
}

pub mod fee {
Expand Down Expand Up @@ -429,6 +430,23 @@ fn read_protocol_fee_config(env: &Env) -> Option<fee::FeeConfig> {
.get(&constants::storage::FEE_CONFIG)
}

/// Validates that an address is not the Stellar zero address.
///
/// The zero address (`GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`)
/// is the all-zero public key. Setting it as a fee recipient would silently
/// burn all protocol fees. This helper rejects it at the point of assignment.
fn validate_non_zero_address(env: &Env, addr: &Address) -> Result<(), ContractError> {
let zero_str = String::from_str(
env,
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
);
let zero_addr = Address::from_string(&zero_str);
if *addr == zero_addr {
return Err(ContractError::ZeroAddress);
}
Ok(())
}

fn read_required_protocol_fee_config(env: &Env) -> Result<fee::FeeConfig, ContractError> {
read_protocol_fee_config(env).ok_or(ContractError::FeeConfigNotSet)
}
Expand Down Expand Up @@ -970,6 +988,38 @@ impl CreatorKeysContract {
.get(&constants::storage::PROTOCOL_FEE_RECIPIENT)
}

/// Sets the protocol fee recipient address.
///
/// Only callable by an authorized admin. Rejects the Stellar zero address
/// to prevent silent fee burning.
///
/// Parameter validation:
/// - `admin`: must authorize the call (`require_auth`).
/// - `recipient`: must not be the Stellar zero address, otherwise
/// [`ContractError::ZeroAddress`].
pub fn set_protocol_fee_recipient(
env: Env,
admin: Address,
recipient: Address,
) -> Result<(), ContractError> {
admin.require_auth();
validate_non_zero_address(&env, &recipient)?;

if env
.storage()
.persistent()
.get::<DataKey, Address>(&constants::storage::PROTOCOL_FEE_RECIPIENT)
.as_ref()
== Some(&recipient)
{
return Ok(());
}
env.storage()
.persistent()
.set(&constants::storage::PROTOCOL_FEE_RECIPIENT, &recipient);
Ok(())
}

/// Read-only view: returns whether protocol configuration has been initialized.
///
/// Returns `true` once a protocol fee configuration has been stored and `false`
Expand Down Expand Up @@ -1408,6 +1458,88 @@ mod tests {
// One above the boundary must overflow
assert_eq!(fee::checked_fee_sum(i128::MAX, 1), None);
}

// --- BPS truncation on small amounts ---

/// Bps calculation on very small amounts produces zero due to integer division
/// truncation. These tests document the behavior at the lower precision boundary.
///
/// Formula: `amount * bps / 10_000` (floor division).
/// When the product `amount * bps < 10_000`, the result truncates to zero.
#[test]
fn test_apply_percentage_fee_truncation_1_stroop() {
// 1 * 1000 / 10_000 = 0.1 → truncated to 0
// At 1 stroop with 10% bps, the fee is zero — value is silently lost.
let result = fee::apply_percentage_fee(1, 1000);
assert_eq!(result, Some(0), "1 stroop at 1000 bps truncates to 0");
}

#[test]
fn test_apply_percentage_fee_truncation_10_stroops() {
// 10 * 1000 / 10_000 = 1.0 → exactly 1
// At 10 stroops with 10% bps, the fee is exactly 1.
let result = fee::apply_percentage_fee(10, 1000);
assert_eq!(result, Some(1), "10 stroops at 1000 bps yields 1");
}

#[test]
fn test_apply_percentage_fee_truncation_100_stroops() {
// 100 * 1000 / 10_000 = 10.0 → exactly 10
let result = fee::apply_percentage_fee(100, 1000);
assert_eq!(result, Some(10), "100 stroops at 1000 bps yields 10");
}

#[test]
fn test_fee_split_truncation_1_stroop() {
// 1 * 1000 / 10_000 = 0 protocol, 1 creator (remainder to creator)
// Truncation causes the full amount to go to creator.
let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000);
assert_eq!(protocol, 0, "1 stroop: protocol fee truncated to 0");
assert_eq!(creator, 1, "1 stroop: creator gets full amount");
assert_eq!(creator + protocol, 1, "conservation holds");
}

#[test]
fn test_fee_split_truncation_10_stroops() {
// 10 * 1000 / 10_000 = 1 protocol, 9 creator
let (creator, protocol) = fee::compute_fee_split(10, 9000, 1000);
assert_eq!(protocol, 1, "10 stroops: protocol fee is 1");
assert_eq!(creator, 9, "10 stroops: creator gets 9");
assert_eq!(creator + protocol, 10, "conservation holds");
}

#[test]
fn test_fee_split_truncation_100_stroops() {
// 100 * 1000 / 10_000 = 10 protocol, 90 creator
let (creator, protocol) = fee::compute_fee_split(100, 9000, 1000);
assert_eq!(protocol, 10, "100 stroops: protocol fee is 10");
assert_eq!(creator, 90, "100 stroops: creator gets 90");
assert_eq!(creator + protocol, 100, "conservation holds");
}

// --- Zero address validation ---

#[test]
fn test_validate_non_zero_address_rejects_zero() {
use soroban_sdk::{Address, Env, String};
let env = Env::default();
let zero_str = String::from_str(
&env,
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
);
let zero_addr = Address::from_string(&zero_str);
let result = super::validate_non_zero_address(&env, &zero_addr);
assert_eq!(result, Err(super::ContractError::ZeroAddress));
}

#[test]
fn test_validate_non_zero_address_accepts_valid() {
use soroban_sdk::{testutils::Address as _, Address, Env};
let env = Env::default();
let valid = Address::generate(&env);
let result = super::validate_non_zero_address(&env, &valid);
assert_eq!(result, Ok(()));
}
}

#[cfg(test)]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
{
"generators": {
"address": 3,
"nonce": 0
},
"auth": [
[],
[
[
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4",
{
"function": {
"contract_fn": {
"contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"function_name": "set_protocol_fee_recipient",
"args": [
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"
}
]
}
},
"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": "ProtocolFeeRecipient"
}
]
},
"durability": "persistent"
}
},
[
{
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
"key": {
"vec": [
{
"symbol": "ProtocolFeeRecipient"
}
]
},
"durability": "persistent",
"val": {
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"
}
}
},
"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": []
}
Loading
Loading