diff --git a/contracts/liquid_staking/src/lib.rs b/contracts/liquid_staking/src/lib.rs index f6170fc..e20ed41 100644 --- a/contracts/liquid_staking/src/lib.rs +++ b/contracts/liquid_staking/src/lib.rs @@ -18,6 +18,23 @@ pub enum DataKey { StakeLockTime(u64), // NFT ID -> Lock expiration timestamp NftRewardPerTokenPaid(u64), // NFT ID -> Snapshot NftRewards(u64), // NFT ID -> Accrued rewards + /// Branding / project metadata (description, icon_url, website) + ContractMeta, +} + +/// On-chain branding metadata for the contract. +/// +/// Stored independently of staking logic so it can be updated at any time +/// by the admin without touching the core contract state. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ContractMetadata { + /// Human-readable description of the contract. + pub description: String, + /// URL pointing to the project icon / logo. + pub icon_url: String, + /// Project or protocol website URL. + pub website: String, } #[contracttype] @@ -50,6 +67,13 @@ impl LiquidStaking { env.storage().instance().set(&DataKey::NftContract, &nft_contract); env.storage().instance().set(&DataKey::TotalStaked, &0_i128); env.storage().instance().set(&DataKey::RewardPerTokenStored, &0_i128); + + // Initialise branding metadata with empty strings. + env.storage().instance().set(&DataKey::ContractMeta, &ContractMetadata { + description: String::from_str(&env, ""), + icon_url: String::from_str(&env, ""), + website: String::from_str(&env, ""), + }); } pub fn deposit_rewards(env: Env, from: Address, amount: i128) { @@ -233,6 +257,48 @@ impl LiquidStaking { } } + // ── Contract Metadata ───────────────────────────────────────────────────── + + /// Update the contract's branding metadata (admin only). + /// + /// All three fields are replaced atomically. Pass the current value for + /// any field you do not want to change. + /// + /// # Arguments + /// * `caller` – Must be the contract admin + /// * `description` – New human-readable description + /// * `icon_url` – New icon / logo URL + /// * `website` – New project website URL + pub fn update_contract_meta( + env: Env, + caller: Address, + description: String, + icon_url: String, + website: String, + ) { + caller.require_auth(); + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("admin not found"); + assert!(caller == admin, "only admin can update contract metadata"); + + let meta = ContractMetadata { description, icon_url, website }; + env.storage().instance().set(&DataKey::ContractMeta, &meta); + + env.events().publish((symbol_short!("meta_upd"),), meta); + } + + /// Return the current contract branding metadata. + pub fn get_contract_meta(env: Env) -> ContractMetadata { + env.storage() + .instance() + .get(&DataKey::ContractMeta) + .expect("contract metadata not initialised") + } + fn _update_reward(env: &Env, token_id: u64) { let rpt: i128 = env.storage().instance().get(&DataKey::RewardPerTokenStored).unwrap_or(0); let nft_rpt: i128 = env.storage().persistent().get(&DataKey::NftRewardPerTokenPaid(token_id)).unwrap_or(0); @@ -354,4 +420,43 @@ mod tests { // Should panic because 3600 seconds haven't passed client.unstake(&alice, &token_id); } -} + + #[test] + fn test_update_contract_meta() { + let (env, ls_id, _, admin, _, _, _) = setup(); + let client = LiquidStakingClient::new(&env, &ls_id); + + // Initial metadata should be empty strings. + let initial = client.get_contract_meta(); + assert_eq!(initial.description, String::from_str(&env, "")); + assert_eq!(initial.icon_url, String::from_str(&env, "")); + assert_eq!(initial.website, String::from_str(&env, "")); + + // Admin updates branding. + client.update_contract_meta( + &admin, + &String::from_str(&env, "Liquid staking protocol on Stellar"), + &String::from_str(&env, "https://example.com/icon.png"), + &String::from_str(&env, "https://example.com"), + ); + + let updated = client.get_contract_meta(); + assert_eq!(updated.description, String::from_str(&env, "Liquid staking protocol on Stellar")); + assert_eq!(updated.icon_url, String::from_str(&env, "https://example.com/icon.png")); + assert_eq!(updated.website, String::from_str(&env, "https://example.com")); + } + + #[test] + #[should_panic(expected = "only admin can update contract metadata")] + fn test_update_contract_meta_non_admin() { + let (env, ls_id, _, _, alice, _, _) = setup(); + let client = LiquidStakingClient::new(&env, &ls_id); + + // Non-admin should be rejected. + client.update_contract_meta( + &alice, + &String::from_str(&env, "Hacked"), + &String::from_str(&env, ""), + &String::from_str(&env, ""), + ); + } diff --git a/contracts/nft_metadata/src/lib.rs b/contracts/nft_metadata/src/lib.rs index b1a1093..6f46817 100644 --- a/contracts/nft_metadata/src/lib.rs +++ b/contracts/nft_metadata/src/lib.rs @@ -35,6 +35,27 @@ pub enum DataKey { TokenApproval(u64, Address), /// Whether an operator is approved for all tokens of an owner OperatorApproval(Address, Address), + /// Branding / project metadata (description, icon_url, website) + ContractMeta, +} + +// ============================================================================ +// Data Structures +// ============================================================================ + +/// On-chain branding metadata for the contract. +/// +/// Stored independently of NFT logic so it can be updated at any time +/// by the admin without touching token state. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ContractMetadata { + /// Human-readable description of the contract. + pub description: String, + /// URL pointing to the project icon / logo. + pub icon_url: String, + /// Project or protocol website URL. + pub website: String, } // ============================================================================ @@ -174,6 +195,13 @@ impl NftMetadataContract { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::CollectionMetadata, &collection); env.storage().instance().set(&DataKey::TokenCounter, &0u64); + + // Initialise branding metadata with empty strings. + env.storage().instance().set(&DataKey::ContractMeta, &ContractMetadata { + description: String::from_str(&env, ""), + icon_url: String::from_str(&env, ""), + website: String::from_str(&env, ""), + }); } // ======================================================================== @@ -698,6 +726,50 @@ impl NftMetadataContract { env.storage().instance().set(&DataKey::CollectionMetadata, &collection); } + // ======================================================================== + // Contract Metadata + // ======================================================================== + + /// Update the contract's branding metadata (admin only). + /// + /// All three fields are replaced atomically. Pass the current value for + /// any field you do not want to change. + /// + /// # Arguments + /// * `caller` – Must be the contract admin + /// * `description` – New human-readable description + /// * `icon_url` – New icon / logo URL + /// * `website` – New project website URL + pub fn update_contract_meta( + env: Env, + caller: Address, + description: String, + icon_url: String, + website: String, + ) { + caller.require_auth(); + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("admin not found"); + assert!(caller == admin, "only admin can update contract metadata"); + + let meta = ContractMetadata { description, icon_url, website }; + env.storage().instance().set(&DataKey::ContractMeta, &meta); + + env.events().publish((symbol_short!("meta_upd"),), meta); + } + + /// Return the current contract branding metadata. + pub fn get_contract_meta(env: Env) -> ContractMetadata { + env.storage() + .instance() + .get(&DataKey::ContractMeta) + .expect("contract metadata not initialised") + } + /// Get total supply of tokens /// /// # Returns @@ -926,4 +998,43 @@ mod tests { assert!(client.is_approved(&token_id, &spender)); } + + #[test] + fn test_update_contract_meta() { + let (env, client, admin) = setup(); + + // Initial metadata should be empty strings. + let initial = client.get_contract_meta(); + assert_eq!(initial.description, String::from_str(&env, "")); + assert_eq!(initial.icon_url, String::from_str(&env, "")); + assert_eq!(initial.website, String::from_str(&env, "")); + + // Admin updates branding. + client.update_contract_meta( + &admin, + &String::from_str(&env, "NFT collection on Stellar"), + &String::from_str(&env, "https://example.com/icon.png"), + &String::from_str(&env, "https://example.com"), + ); + + let updated = client.get_contract_meta(); + assert_eq!(updated.description, String::from_str(&env, "NFT collection on Stellar")); + assert_eq!(updated.icon_url, String::from_str(&env, "https://example.com/icon.png")); + assert_eq!(updated.website, String::from_str(&env, "https://example.com")); + } + + #[test] + #[should_panic(expected = "only admin can update contract metadata")] + fn test_update_contract_meta_non_admin() { + let (env, client, _admin) = setup(); + let non_admin = Address::generate(&env); + + // Non-admin should be rejected. + client.update_contract_meta( + &non_admin, + &String::from_str(&env, "Hacked"), + &String::from_str(&env, ""), + &String::from_str(&env, ""), + ); + } } diff --git a/src/utils/contract_metadata.rs b/src/utils/contract_metadata.rs new file mode 100644 index 0000000..9cb92b4 --- /dev/null +++ b/src/utils/contract_metadata.rs @@ -0,0 +1,99 @@ +//! Reusable contract metadata module. +//! +//! Provides a `ContractMetadata` struct (description, icon_url, website) and +//! helper functions to read/write it from any Soroban contract's instance +//! storage. Metadata is stored under a caller-supplied key so it never +//! conflicts with the host contract's own storage layout. +//! +//! # Usage +//! ```ignore +//! // In your DataKey enum: +//! ContractMeta, +//! +//! // In initialize(): +//! contract_metadata::init(&env, &DataKey::ContractMeta); +//! +//! // Admin-only update: +//! contract_metadata::update(&env, &DataKey::ContractMeta, &admin, description, icon_url, website); +//! +//! // Public read: +//! let meta = contract_metadata::get(&env, &DataKey::ContractMeta); +//! ``` + +use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, TryFromVal, Val}; + +/// On-chain branding / project metadata for a contract. +/// +/// All fields are optional in the sense that they may be empty strings; +/// the struct is always present after `init` is called. +#[soroban_sdk::contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ContractMetadata { + /// Human-readable description of the contract's purpose. + pub description: String, + /// URL pointing to the project's icon / logo image. + pub icon_url: String, + /// Project or protocol website URL. + pub website: String, +} + +/// Initialise metadata with empty strings. +/// +/// Must be called once during the contract's `initialize` function. +/// Subsequent calls are no-ops (idempotent). +pub fn init(env: &Env, key: &K) +where + K: IntoVal + TryFromVal, +{ + if !env.storage().instance().has(key) { + env.storage().instance().set(key, &ContractMetadata { + description: String::from_str(env, ""), + icon_url: String::from_str(env, ""), + website: String::from_str(env, ""), + }); + } +} + +/// Return the current contract metadata. +/// +/// # Panics +/// Panics if `init` was never called (metadata not found). +pub fn get(env: &Env, key: &K) -> ContractMetadata +where + K: IntoVal + TryFromVal, +{ + env.storage() + .instance() + .get(key) + .expect("contract metadata not initialised") +} + +/// Replace the contract metadata (admin-only). +/// +/// The caller must be the contract admin; `admin.require_auth()` is called +/// internally so the transaction will fail if the signature is absent. +/// +/// # Arguments +/// * `env` – Soroban environment +/// * `key` – Storage key used by the host contract for metadata +/// * `admin` – Admin address (must sign the transaction) +/// * `description` – New description (pass current value to leave unchanged) +/// * `icon_url` – New icon URL +/// * `website` – New website URL +pub fn update( + env: &Env, + key: &K, + admin: &Address, + description: String, + icon_url: String, + website: String, +) where + K: IntoVal + TryFromVal, +{ + admin.require_auth(); + + let meta = ContractMetadata { description, icon_url, website }; + env.storage().instance().set(key, &meta); + + env.events().publish((symbol_short!("meta_upd"),), meta); +} diff --git a/src/utils/lib.rs b/src/utils/lib.rs index e3e066a..2764aba 100644 --- a/src/utils/lib.rs +++ b/src/utils/lib.rs @@ -1,3 +1,4 @@ #![no_std] +pub mod contract_metadata; pub mod events; pub mod fees;