diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ace4f94..15ee3f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,7 @@ For guidance on writing deterministic quote tests, see [docs/deterministic-quote ## Documentation for contributors - **[CI Contract Checks](./docs/ci-contract-checks.md)**: Understanding the CI pipeline, running checks locally, and troubleshooting failures +- **[Contract Function Contribution Guide](./docs/contract-function-contribution-guide.md)**: How new contract entrypoints should be placed, authorized, and emitted in the repo's existing patterns - **[Storage Key Invariants](./docs/storage-key-invariants.md)**: Storage model, key structure, and invariants that must be maintained across all operations - **[Minimum Viable Test Structure](./docs/minimum-viable-test-structure.md)**: Required test categories and example structures for new contract entrypoints - **[Deterministic Quote Tests](./docs/deterministic-quote-tests.md)**: Guide for writing tests for quote operations with the fixed price model diff --git a/creator-keys/src/test.rs b/creator-keys/src/test.rs index 2f6a77c..d39af64 100644 --- a/creator-keys/src/test.rs +++ b/creator-keys/src/test.rs @@ -509,6 +509,136 @@ fn test_update_creator_fee_recipient_unauthorized_reverts() { assert!(result.is_err()); } +#[test] +fn test_update_creator_fee_recipient_reverts_when_current_recipient_is_not_authorized() { + let env = Env::default(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + let creator = Address::generate(&env); + let current_recipient = Address::generate(&env); + let new_recipient = Address::generate(&env); + let handle = String::from_str(&env, "alice"); + + let profile = CreatorProfile { + creator: creator.clone(), + handle: handle.clone(), + supply: 0, + holder_count: 0, + fee_recipient: current_recipient.clone(), + registered_at: 0, + }; + + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&constants::storage::creator(&creator), &profile); + }); + + let result = client.try_update_creator_fee_recipient(&creator, &new_recipient); + assert!(result.is_err()); +} + +#[test] +fn test_sell_key_accepts_exact_min_proceeds_boundary() { + 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 creator = Address::generate(&env); + let seller = Address::generate(&env); + let handle = String::from_str(&env, "alice"); + + client.set_key_price(&admin, &100); + client.set_fee_config(&admin, &9000, &1000); + client.register_creator(&creator, &handle, &None, &None, &None); + + client.buy_key(&creator, &seller, &100, &None); + client.buy_key(&creator, &seller, &100, &None); + + let quote = client.get_sell_quote(&creator, &seller).unwrap(); + let exact_result = client.try_sell_key(&creator, &seller, &Some(quote.total_amount)); + assert_eq!(exact_result, Ok(Ok(1))); + + let second_quote = client.get_sell_quote(&creator, &seller).unwrap(); + let slippage_result = client.try_sell_key(&creator, &seller, &Some(second_quote.total_amount + 1)); + assert_eq!(slippage_result, Err(Ok(ContractError::SlippageExceeded))); +} + +#[test] +fn test_sell_extends_creator_ttl_after_successful_sell() { + 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 creator = Address::generate(&env); + let seller = Address::generate(&env); + let handle = String::from_str(&env, "alice"); + + client.set_key_price(&admin, &100); + client.set_fee_config(&admin, &9000, &1000); + client.register_creator(&creator, &handle, &None, &None, &None); + client.buy_key(&creator, &seller, &100, &None); + + let creator_key = constants::storage::creator(&creator); + let initial_profile: CreatorProfile = env.as_contract(&contract_id, || { + env.storage().persistent().get(&creator_key).unwrap() + }); + assert_eq!(initial_profile.supply, 1); + + let mut ledger_info = env.ledger().get(); + ledger_info.sequence_number = 100; + env.ledger().set(ledger_info); + + let result = client.try_sell_key(&creator, &seller, &Some(1)); + assert_eq!(result, Ok(Ok(0))); + + let mut ledger_info = env.ledger().get(); + ledger_info.sequence_number = CREATOR_TTL_LEDGERS + 1; + env.ledger().set(ledger_info); + + let has_profile = env.as_contract(&contract_id, || { + env.storage().persistent().has(&creator_key) + }); + assert!(has_profile); +} + +#[test] +fn test_failed_sell_does_not_extend_creator_ttl() { + 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 creator = Address::generate(&env); + let seller = Address::generate(&env); + let handle = String::from_str(&env, "alice"); + + client.set_key_price(&admin, &100); + client.register_creator(&creator, &handle, &None, &None, &None); + + let creator_key = constants::storage::creator(&creator); + let mut ledger_info = env.ledger().get(); + ledger_info.sequence_number = 100; + env.ledger().set(ledger_info); + + let result = client.try_sell_key(&creator, &seller, &Some(1)); + assert_eq!(result, Err(Ok(ContractError::InsufficientBalance))); + + let mut ledger_info = env.ledger().get(); + ledger_info.sequence_number = CREATOR_TTL_LEDGERS + 1; + env.ledger().set(ledger_info); + + let has_profile = env.as_contract(&contract_id, || { + env.storage().persistent().has(&creator_key) + }); + assert!(!has_profile); +} + // --- TTL extension tests (#396) --- #[test] diff --git a/docs/contract-function-contribution-guide.md b/docs/contract-function-contribution-guide.md new file mode 100644 index 0000000..7e18e0f --- /dev/null +++ b/docs/contract-function-contribution-guide.md @@ -0,0 +1,56 @@ +# Contract function contribution guide + +This guide captures the conventions used by the `creator-keys` contract so contributors can add a new entrypoint without fighting the existing layout or CI expectations. + +## 1. Place the function in the right module + +Keep new public entrypoints in the main contract implementation in [creator-keys/src/lib.rs](../creator-keys/src/lib.rs) and group them by responsibility: + +- Trade entrypoints such as `buy_key` and `sell_key` belong with the trading flow near the other market operations. +- Config entrypoints such as fee or price updates belong with the configuration helpers in the same contract implementation. +- Read-only view methods belong near the other quote and profile read methods. +- Admin-only mutators should stay near the other admin-facing entrypoints so the access model is easy to audit. + +## 2. Follow the authorization pattern + +Use the same auth pattern as the surrounding entrypoints: + +- `admin.require_auth()` for protocol-admin-only entrypoints. +- `creator.require_auth()` for creator-owned actions. +- `buyer.require_auth()` or `seller.require_auth()` for trade actions that are authorized by the user acting on the market. +- Read-only methods should not call `require_auth()`. + +When a function needs to validate the currently authorized address, prefer the same address that the existing entrypoint uses for the relevant role rather than introducing a new authorization model. + +## 3. Emit events the same way + +New state-changing entrypoints should publish events via `env.events().publish(...)` using the centralized names and helpers in [creator-keys/src/events.rs](../creator-keys/src/events.rs). + +For a new event: + +1. Add a new event struct to [creator-keys/src/events.rs](../creator-keys/src/events.rs). +2. Define a stable event name constant and any topic helpers there. +3. Publish the event from the contract entrypoint after the state change is persisted. +4. Keep field ordering stable so downstream indexers and tests remain deterministic. + +## 4. Add the right tests + +Every new contract function should include tests for: + +- a happy path, +- the main error case, +- any relevant state change or regression case. + +The repo's minimum test structure lives in [docs/minimum-viable-test-structure.md](./minimum-viable-test-structure.md). + +## 5. Run the required CI checks before opening a PR + +The repository CI workflow in [.github/workflows/ci.yml](../.github/workflows/ci.yml) expects: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Run the same checks locally before pushing so the maintainer sees a passing PR.