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
145 changes: 145 additions & 0 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ pub mod constants {
pub const PAUSED: DataKey = DataKey::Paused;
pub const CURVE_SLOPE: DataKey = DataKey::CurveSlope;
pub const TREASURY_BALANCE: DataKey = DataKey::TreasuryBalance;
pub const RETENTION_POLICY: DataKey = DataKey::RetentionPolicy;

pub fn curve_preset(creator: &Address) -> DataKey {
DataKey::CurvePreset(creator.clone())
Expand Down Expand Up @@ -611,6 +612,26 @@ pub enum CurvePreset {
Flat = 2,
}

/// Archive partition strategy for retention management.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[contracttype]
pub enum PartitionStrategy {
Daily = 0,
Weekly = 1,
Monthly = 2,
Ledger = 3,
}

/// Archive retention policy configuration.
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct RetentionPolicy {
pub retention_days: u32,
pub partition_strategy: PartitionStrategy,
pub compression_enabled: bool,
pub batch_size: u32,
}

/// Canonical storage key schema for persistent protocol state.
///
/// For quote-related key usage and invariants, see
Expand Down Expand Up @@ -651,6 +672,8 @@ pub enum DataKey {
/// Wallet addresses the protocol admin has barred from buying, selling,
/// or registering as a creator.
Blacklisted(Address),
/// Archive retention policy configuration.
RetentionPolicy,
}

/// Time-locked key allocation for creator self-vesting.
Expand Down Expand Up @@ -1155,6 +1178,38 @@ fn credit_treasury_balance(env: &Env, amount: i128) -> Result<(), ContractError>
Ok(())
}

/// Archive retention configuration module with canonical defaults.
pub mod retention {
use super::PartitionStrategy;

/// Default retention window in days (30 days).
pub const DEFAULT_RETENTION_DAYS: u32 = 30;
/// Default partition strategy (Daily).
pub const DEFAULT_PARTITION_STRATEGY: PartitionStrategy = PartitionStrategy::Daily;
/// Default compression enabled flag (true).
pub const DEFAULT_COMPRESSION_ENABLED: bool = true;
/// Default batch size for archive processing (100).
pub const DEFAULT_BATCH_SIZE: u32 = 100;
}

/// Returns the canonical default [`RetentionPolicy`].
pub fn default_retention_policy() -> RetentionPolicy {
RetentionPolicy {
retention_days: retention::DEFAULT_RETENTION_DAYS,
partition_strategy: retention::DEFAULT_PARTITION_STRATEGY,
compression_enabled: retention::DEFAULT_COMPRESSION_ENABLED,
batch_size: retention::DEFAULT_BATCH_SIZE,
}
}

/// Reads the current archive retention policy from storage, falling back to defaults.
pub fn read_retention_policy(env: &Env) -> RetentionPolicy {
env.storage()
.persistent()
.get(&constants::storage::RETENTION_POLICY)
.unwrap_or_else(default_retention_policy)
}

fn assert_buy_price_slippage(price: i128, max_price: Option<i128>) -> Result<(), ContractError> {
if let Some(max) = max_price {
if price > max {
Expand Down Expand Up @@ -2895,6 +2950,49 @@ impl CreatorKeysContract {
Ok(())
}

/// Sets the archive retention policy configuration.
///
/// Only callable by an authorized admin.
///
/// Parameter validation:
/// - `admin`: must authorize the call (`require_auth`).
/// - `batch_size`: must be strictly positive; returns [`ContractError::NotPositiveAmount`].
pub fn set_retention_policy(
env: Env,
admin: Address,
retention_days: u32,
partition_strategy: PartitionStrategy,
compression_enabled: bool,
batch_size: u32,
) -> Result<(), ContractError> {
admin.require_auth();
assert_is_admin(&env, &admin)?;
if batch_size == 0 {
return Err(ContractError::NotPositiveAmount);
}

let policy = RetentionPolicy {
retention_days,
partition_strategy,
compression_enabled,
batch_size,
};

env.storage()
.persistent()
.set(&constants::storage::RETENTION_POLICY, &policy);

Ok(())
}

/// Read-only view: returns the current archive retention configuration.
///
/// Returns the configured [`RetentionPolicy`] or canonical defaults if unset.
/// Does not mutate contract state or panic when uninitialized.
pub fn get_retention_policy(env: Env) -> RetentionPolicy {
read_retention_policy(&env)
}

/// 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 @@ -4732,6 +4830,53 @@ mod tests {
let bps = env.as_contract(&contract_id, || super::read_protocol_fee_bps(&env));
assert_eq!(bps, 1000, "must return stored protocol_fee_bps");
}

// --- retention policy unit tests (#724) ---

#[test]
fn test_read_retention_policy_returns_default_when_unset() {
let env = Env::default();
let contract_id = env.register(super::CreatorKeysContract, ());

let policy = env.as_contract(&contract_id, || super::read_retention_policy(&env));
assert_eq!(
policy.retention_days,
super::retention::DEFAULT_RETENTION_DAYS
);
assert_eq!(
policy.partition_strategy,
super::retention::DEFAULT_PARTITION_STRATEGY
);
assert_eq!(
policy.compression_enabled,
super::retention::DEFAULT_COMPRESSION_ENABLED
);
assert_eq!(policy.batch_size, super::retention::DEFAULT_BATCH_SIZE);
}

#[test]
fn test_get_retention_policy_view_returns_configured_values() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(super::CreatorKeysContract, ());
let client = super::CreatorKeysContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);

client.set_protocol_admin(&admin, &admin);
client.set_retention_policy(
&admin,
&90u32,
&super::PartitionStrategy::Monthly,
&false,
&500u32,
);

let policy = client.get_retention_policy();
assert_eq!(policy.retention_days, 90);
assert_eq!(policy.partition_strategy, super::PartitionStrategy::Monthly);
assert!(!policy.compression_enabled);
assert_eq!(policy.batch_size, 500);
}
}

#[cfg(test)]
Expand Down
177 changes: 177 additions & 0 deletions creator-keys/tests/retention_policy_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! Tests for get_retention_policy view and retention policy configuration (#724).

mod contract_test_env;

use contract_test_env::{register_creator_keys, test_env_with_auths};
use creator_keys::{
retention, ContractError, CreatorKeysContract, CreatorKeysContractClient, PartitionStrategy,
};
use soroban_sdk::{testutils::Address as _, Address, Env};

#[test]
fn test_get_retention_policy_unconfigured_returns_defaults_no_panic() {
let env = Env::default();
let contract_id = env.register(CreatorKeysContract, ());
let client = CreatorKeysContractClient::new(&env, &contract_id);

// Call get_retention_policy before any admin configuration
let policy = client.get_retention_policy();

// Assert no panic and matches default canonical configuration
assert_eq!(policy.retention_days, retention::DEFAULT_RETENTION_DAYS);
assert_eq!(
policy.partition_strategy,
retention::DEFAULT_PARTITION_STRATEGY
);
assert_eq!(
policy.compression_enabled,
retention::DEFAULT_COMPRESSION_ENABLED
);
assert_eq!(policy.batch_size, retention::DEFAULT_BATCH_SIZE);
}

#[test]
fn test_get_retention_policy_returns_configured_values() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

let configured_days = 90u32;
let configured_strategy = PartitionStrategy::Monthly;
let configured_compression = false;
let configured_batch = 500u32;

client.set_retention_policy(
&admin,
&configured_days,
&configured_strategy,
&configured_compression,
&configured_batch,
);

let policy = client.get_retention_policy();

// Acceptance Criteria validations
assert_eq!(policy.retention_days, configured_days);
assert_eq!(policy.partition_strategy, configured_strategy);
assert_eq!(policy.compression_enabled, configured_compression);
assert_eq!(policy.batch_size, configured_batch);
}

#[test]
fn test_get_retention_policy_all_partition_strategies() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

let strategies = [
PartitionStrategy::Daily,
PartitionStrategy::Weekly,
PartitionStrategy::Monthly,
PartitionStrategy::Ledger,
];

for strategy in strategies {
client.set_retention_policy(&admin, &60u32, &strategy, &true, &250u32);
let policy = client.get_retention_policy();
assert_eq!(policy.partition_strategy, strategy);
}
}

#[test]
fn test_get_retention_policy_compression_enabled_variants() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

// Test with compression enabled
client.set_retention_policy(&admin, &45u32, &PartitionStrategy::Weekly, &true, &150u32);
let policy_true = client.get_retention_policy();
assert!(policy_true.compression_enabled);

// Test with compression disabled
client.set_retention_policy(&admin, &45u32, &PartitionStrategy::Weekly, &false, &150u32);
let policy_false = client.get_retention_policy();
assert!(!policy_false.compression_enabled);
}

#[test]
fn test_get_retention_policy_is_read_only_and_idempotent() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

client.set_retention_policy(&admin, &180u32, &PartitionStrategy::Ledger, &true, &1000u32);

let first_read = client.get_retention_policy();
let second_read = client.get_retention_policy();

assert_eq!(first_read, second_read);
assert_eq!(first_read.retention_days, 180);
assert_eq!(first_read.partition_strategy, PartitionStrategy::Ledger);
assert!(first_read.compression_enabled);
assert_eq!(first_read.batch_size, 1000);
}

#[test]
fn test_get_retention_policy_updates_after_reconfiguration() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

// Initial configuration
client.set_retention_policy(&admin, &30u32, &PartitionStrategy::Daily, &true, &100u32);
let v1 = client.get_retention_policy();
assert_eq!(v1.retention_days, 30);
assert_eq!(v1.partition_strategy, PartitionStrategy::Daily);

// Reconfiguration
client.set_retention_policy(
&admin,
&365u32,
&PartitionStrategy::Monthly,
&false,
&5000u32,
);
let v2 = client.get_retention_policy();
assert_eq!(v2.retention_days, 365);
assert_eq!(v2.partition_strategy, PartitionStrategy::Monthly);
assert!(!v2.compression_enabled);
assert_eq!(v2.batch_size, 5000);
assert_ne!(v1, v2);
}

#[test]
fn test_set_retention_policy_unauthorized_reverts() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
let non_admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

let result = client.try_set_retention_policy(
&non_admin,
&90u32,
&PartitionStrategy::Daily,
&true,
&100u32,
);
assert_eq!(result, Err(Ok(ContractError::Unauthorized)));
}

#[test]
fn test_set_retention_policy_zero_batch_size_rejected() {
let env = test_env_with_auths();
let (client, _) = register_creator_keys(&env);
let admin = Address::generate(&env);
client.set_protocol_admin(&admin, &admin);

let result =
client.try_set_retention_policy(&admin, &90u32, &PartitionStrategy::Daily, &true, &0u32);
assert_eq!(result, Err(Ok(ContractError::NotPositiveAmount)));
}
1 change: 1 addition & 0 deletions docs/storage-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ distinguishable from other key types.
| `ReferralFeeBps` | Global | `u32` | `set_referral_fee_bps` (admin) | `buy_key_with_referrer` (referral split), `get_referral_fee_bps` |
| `DiscountTiers` | Global | `Vec<DiscountTier>` | `update_discount_tiers` (admin) | `get_discount_tiers` (volume-based fee discount evaluation) |
| `CreatorVolume(Address)` | Per-creator | `i128` | *(not currently written by any entrypoint)* | `get_creator_volume` |
| `RetentionPolicy` | Global | `RetentionPolicy` | `set_retention_policy` (admin) | `read_retention_policy`, `get_retention_policy` |
| `CreatorTtlLiveUntil(Address)` | Per-creator | `u32` | `register_creator` (initial write), `extend_creator_ttl` (updated after every trade) | `extend_creator_ttl` (TTL-extension event gate) |

> **Note:** `CreatorVolume(Address)` is read by `get_creator_volume` but has no
Expand Down
Loading