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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions creator-keys/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
56 changes: 56 additions & 0 deletions docs/contract-function-contribution-guide.md
Original file line number Diff line number Diff line change
@@ -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.
Loading