diff --git a/Cargo.lock b/Cargo.lock index b27764f..5d72894 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,15 @@ dependencies = [ "libc", ] +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "ark-bls12-381" version = "0.4.0" @@ -287,6 +296,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -420,6 +439,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -1145,6 +1175,7 @@ version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" dependencies = [ + "arbitrary", "crate-git-revision", "ethnum", "num-derive", @@ -1238,7 +1269,11 @@ version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff18e8d7ca6d5340a211605ca2c86383bd4dfacc4f8253d72a1573974ffffe69" dependencies = [ + "arbitrary", "bytes-lit", + "ctor", + "derive_arbitrary", + "ed25519-dalek", "rand", "rustc_version", "serde", @@ -1350,6 +1385,7 @@ version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" dependencies = [ + "arbitrary", "base64 0.13.1", "crate-git-revision", "escape-bytes", diff --git a/creator-keys/Cargo.toml b/creator-keys/Cargo.toml index c54f824..83e8675 100644 --- a/creator-keys/Cargo.toml +++ b/creator-keys/Cargo.toml @@ -5,8 +5,11 @@ edition = "2021" publish = false [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index ee78f53..faf502d 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -2,10 +2,38 @@ use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String}; +pub mod fee { + use soroban_sdk::contracttype; + + /// Basis points per 100% (10000 = 100%). + pub const BPS_MAX: u32 = 10_000; + + #[derive(Clone)] + #[contracttype] + pub struct FeeConfig { + pub creator_bps: u32, + pub protocol_bps: u32, + } + + /// Computes the fee split for a given total amount. + /// + /// Returns `(creator_amount, protocol_amount)`. Remainder from integer division + /// is assigned to the creator. Ensures creator_amount + protocol_amount == total. + pub fn compute_fee_split(total: i128, _creator_bps: u32, protocol_bps: u32) -> (i128, i128) { + if total <= 0 { + return (0, 0); + } + let protocol_amount = (total * protocol_bps as i128) / BPS_MAX as i128; + let creator_amount = total - protocol_amount; + (creator_amount, protocol_amount) + } +} + #[derive(Clone)] #[contracttype] pub enum DataKey { Creator(Address), + FeeConfig, } #[derive(Clone)] @@ -57,4 +85,91 @@ impl CreatorKeysContract { let key = DataKey::Creator(creator); env.storage().persistent().get(&key) } + + pub fn set_fee_config(env: Env, admin: Address, creator_bps: u32, protocol_bps: u32) { + admin.require_auth(); + if creator_bps + protocol_bps != fee::BPS_MAX { + panic!("creator_bps + protocol_bps must equal 10000"); + } + let config = fee::FeeConfig { + creator_bps, + protocol_bps, + }; + env.storage().persistent().set(&DataKey::FeeConfig, &config); + } + + pub fn get_fee_config(env: Env) -> Option { + env.storage().persistent().get(&DataKey::FeeConfig) + } + + pub fn compute_fees_for_payment(env: Env, total: i128) -> (i128, i128) { + let config: fee::FeeConfig = env + .storage() + .persistent() + .get(&DataKey::FeeConfig) + .unwrap_or_else(|| panic!("fee config not set")); + fee::compute_fee_split(total, config.creator_bps, config.protocol_bps) + } +} + +#[cfg(test)] +mod tests { + use super::fee; + + #[test] + fn test_fee_split_90_10_1000() { + let (creator, protocol) = fee::compute_fee_split(1000, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 100); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_100_creator() { + let (creator, protocol) = fee::compute_fee_split(1000, 10000, 0); + assert_eq!(creator, 1000); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_100_protocol() { + let (creator, protocol) = fee::compute_fee_split(1000, 0, 10000); + assert_eq!(creator, 0); + assert_eq!(protocol, 1000); + assert_eq!(creator + protocol, 1000); + } + + #[test] + fn test_fee_split_remainder_to_creator() { + // 999 * 1000 / 10000 = 99 (protocol floor), creator gets remainder + let (creator, protocol) = fee::compute_fee_split(999, 9000, 1000); + assert_eq!(creator, 900); + assert_eq!(protocol, 99); + assert_eq!(creator + protocol, 999); + } + + #[test] + fn test_fee_split_zero_total() { + let (creator, protocol) = fee::compute_fee_split(0, 9000, 1000); + assert_eq!(creator, 0); + assert_eq!(protocol, 0); + } + + #[test] + fn test_fee_split_dust_total_one() { + // 1 * 1000 / 10000 = 0 protocol, creator gets full amount + let (creator, protocol) = fee::compute_fee_split(1, 9000, 1000); + assert_eq!(creator, 1); + assert_eq!(protocol, 0); + assert_eq!(creator + protocol, 1); + } + + #[test] + fn test_fee_split_balance_conservation() { + for total in [100_i128, 1, 999, 10000, 1234567] { + let (creator, protocol) = fee::compute_fee_split(total, 9000, 1000); + assert_eq!(creator + protocol, total, "total={}", total); + } + } } diff --git a/creator-keys/test_snapshots/test_compute_fees_for_payment.1.json b/creator-keys/test_snapshots/test_compute_fees_for_payment.1.json new file mode 100644 index 0000000..49aad91 --- /dev/null +++ b/creator-keys/test_snapshots/test_compute_fees_for_payment.1.json @@ -0,0 +1,190 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "set_fee_config", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u32": 9000 + }, + { + "u32": 1000 + } + ] + } + }, + "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": "FeeConfig" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "FeeConfig" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "creator_bps" + }, + "val": { + "u32": 9000 + } + }, + { + "key": { + "symbol": "protocol_bps" + }, + "val": { + "u32": 1000 + } + } + ] + } + } + }, + "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_compute_fees_without_config_panics.1.json b/creator-keys/test_snapshots/test_compute_fees_without_config_panics.1.json new file mode 100644 index 0000000..a90f00a --- /dev/null +++ b/creator-keys/test_snapshots/test_compute_fees_without_config_panics.1.json @@ -0,0 +1,76 @@ +{ + "generators": { + "address": 1, + "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_set_and_get_fee_config.1.json b/creator-keys/test_snapshots/test_set_and_get_fee_config.1.json new file mode 100644 index 0000000..49aad91 --- /dev/null +++ b/creator-keys/test_snapshots/test_set_and_get_fee_config.1.json @@ -0,0 +1,190 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "set_fee_config", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u32": 9000 + }, + { + "u32": 1000 + } + ] + } + }, + "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": "FeeConfig" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "FeeConfig" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "creator_bps" + }, + "val": { + "u32": 9000 + } + }, + { + "key": { + "symbol": "protocol_bps" + }, + "val": { + "u32": 1000 + } + } + ] + } + } + }, + "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_set_fee_config_invalid_sum_panics.1.json b/creator-keys/test_snapshots/test_set_fee_config_invalid_sum_panics.1.json new file mode 100644 index 0000000..5655749 --- /dev/null +++ b/creator-keys/test_snapshots/test_set_fee_config_invalid_sum_panics.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/fee_split.rs b/creator-keys/tests/fee_split.rs new file mode 100644 index 0000000..03d45c9 --- /dev/null +++ b/creator-keys/tests/fee_split.rs @@ -0,0 +1,61 @@ +//! Deterministic tests for fee split logic and contract integration. + +use creator_keys::{CreatorKeysContract, CreatorKeysContractClient}; +use soroban_sdk::{testutils::Address as _, Env}; + +#[test] +fn test_set_and_get_fee_config() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = soroban_sdk::Address::generate(&env); + client.set_fee_config(&admin, &9000u32, &1000u32); + + let config = client.get_fee_config(); + let config = config.unwrap(); + assert_eq!(config.creator_bps, 9000); + assert_eq!(config.protocol_bps, 1000); +} + +#[test] +fn test_compute_fees_for_payment() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + let admin = soroban_sdk::Address::generate(&env); + + client.set_fee_config(&admin, &9000u32, &1000u32); + + let (creator, protocol) = client.compute_fees_for_payment(&1000i128); + assert_eq!(creator, 900); + assert_eq!(protocol, 100); + assert_eq!(creator + protocol, 1000); +} + +#[test] +#[should_panic(expected = "creator_bps + protocol_bps must equal 10000")] +fn test_set_fee_config_invalid_sum_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + let admin = soroban_sdk::Address::generate(&env); + + client.set_fee_config(&admin, &8000u32, &1000u32); +} + +#[test] +#[should_panic(expected = "fee config not set")] +fn test_compute_fees_without_config_panics() { + let env = Env::default(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + client.compute_fees_for_payment(&1000i128); +} diff --git a/docs/fee-assumptions.md b/docs/fee-assumptions.md new file mode 100644 index 0000000..1a3c083 --- /dev/null +++ b/docs/fee-assumptions.md @@ -0,0 +1,72 @@ +# Fee Split Assumptions + +This document describes the assumptions, behavior, and integration points for creator and protocol fee split logic in Access Layer contracts. + +## Fee Representation + +- **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. +- **Example**: 9,000 creator_bps + 1,000 protocol_bps = 90% creator, 10% protocol. + +## Rounding and Remainder Handling + +- **Protocol amount**: Computed as `floor(total * protocol_bps / 10000)`. +- **Creator amount**: `total - protocol_amount`, so the remainder from integer division goes to the creator. +- **Balance conservation**: `creator_amount + protocol_amount == total` for all valid inputs. +- **Zero and negative**: For `total <= 0`, returns `(0, 0)`. + +### Examples + +| total | creator_bps | protocol_bps | creator | protocol | +|-------|-------------|--------------|---------|----------| +| 1000 | 9000 | 1000 | 900 | 100 | +| 1000 | 10000 | 0 | 1000 | 0 | +| 1000 | 0 | 10000 | 0 | 1000 | +| 999 | 9000 | 1000 | 900 | 99 | +| 0 | 9000 | 1000 | 0 | 0 | +| 1 | 9000 | 1000 | 1 | 0 | + +## Overflow and Precision Limits + +- **Amount type**: `i128` (Soroban token amounts). +- **Overflow**: `total * protocol_bps` must fit in `i128`. For typical 7-decimal tokens, this is safe for amounts up to approximately 10^20 smallest units. +- **Dust**: Amounts of 1 or 2 units may round protocol share to 0; creator receives the full amount. + +## Storage and Configuration + +- **DataKey**: `FeeConfig` stores a global `FeeConfig` struct with `creator_bps` and `protocol_bps`. +- **Initialization**: Fee config must be set explicitly via `set_fee_config` before `compute_fees_for_payment` can be used. +- **Admin**: `set_fee_config` requires authorization of the `admin` address. Admin key management is out of scope for this module. + +## Contract Interface + +| Function | Purpose | +|----------|---------| +| `set_fee_config(env, admin, creator_bps, protocol_bps)` | Set fee split; requires admin auth. | +| `get_fee_config(env)` | Read current fee config. | +| `compute_fees_for_payment(env, total)` | Compute `(creator_amount, protocol_amount)` from stored config. | + +## Integration with Payment Flow + +The fee split logic is designed to be called from the payment handler (see `contracts-economics-02`): + +1. Receive total payment amount (e.g., from bonding-curve buy). +2. Call `compute_fees_for_payment(env, total)` to get `(creator_amount, protocol_amount)`. +3. Transfer `creator_amount` to the creator. +4. Transfer `protocol_amount` to the protocol treasury. + +Token transfers are not implemented in this module. + +## Client and Server Implications + +### Events + +When buy/sell flows emit events, include `(creator_amount, protocol_amount)` in the payload so indexers and UIs can display the fee breakdown without recomputing. The payment handler (future work) should emit these values. + +### Quote Flow + +Clients can call `get_fee_config` and `compute_fees_for_payment` (or a view that wraps them) to show users a fee preview before they sign a transaction. + +### Indexing + +Server/indexer can rely on fee breakdown in events for analytics and display. No additional off-chain computation is required for fee display.