From 4ef3c5853c4a4adb6ae3eaf2f9726a1901992612 Mon Sep 17 00:00:00 2001 From: David-patrick-chuks Date: Wed, 25 Feb 2026 11:16:03 +0100 Subject: [PATCH] feat(contract): implement quest metadata system and fix batch tests --- contracts/earn-quest/src/errors.rs | 1 + contracts/earn-quest/src/lib.rs | 52 ++++- contracts/earn-quest/src/quest.rs | 84 ++++++- contracts/earn-quest/src/storage.rs | 33 ++- contracts/earn-quest/src/submission.rs | 1 - contracts/earn-quest/src/types.rs | 22 +- contracts/earn-quest/tests/test_batch.rs | 77 +++---- contracts/earn-quest/tests/test_metadata.rs | 242 ++++++++++++++++++++ 8 files changed, 458 insertions(+), 54 deletions(-) create mode 100644 contracts/earn-quest/tests/test_metadata.rs diff --git a/contracts/earn-quest/src/errors.rs b/contracts/earn-quest/src/errors.rs index d4f953b..ac991fe 100644 --- a/contracts/earn-quest/src/errors.rs +++ b/contracts/earn-quest/src/errors.rs @@ -47,4 +47,5 @@ pub enum Error { NoFundsToWithdraw = 73, QuestNotTerminal = 74, TokenMismatch = 75, + MetadataNotFound = 76, } diff --git a/contracts/earn-quest/src/lib.rs b/contracts/earn-quest/src/lib.rs index 4bd04fc..a4e65cf 100644 --- a/contracts/earn-quest/src/lib.rs +++ b/contracts/earn-quest/src/lib.rs @@ -14,7 +14,9 @@ mod submission; mod escrow; use crate::errors::Error; -use crate::types::{Badge, BatchApprovalInput, BatchQuestInput, UserStats, EscrowInfo}; +use crate::types::{ + Badge, BatchApprovalInput, BatchQuestInput, EscrowInfo, QuestMetadata, UserStats, +}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Symbol, Vec}; #[contract] @@ -70,6 +72,32 @@ impl EarnQuestContract { ) } + /// Register a new quest and attach metadata during creation. + pub fn register_quest_with_metadata( + env: Env, + id: Symbol, + creator: Address, + reward_asset: Address, + reward_amount: i128, + verifier: Address, + deadline: u64, + metadata: QuestMetadata, + ) -> Result<(), Error> { + security::require_not_paused(&env)?; + creator.require_auth(); + + quest::register_quest_with_metadata( + &env, + &id, + &creator, + &reward_asset, + reward_amount, + &verifier, + deadline, + &metadata, + ) + } + /// Register multiple quests in one transaction (gas-optimized). /// Creator must authorize; all quests are registered to that creator. /// Batch size is limited; on first error the entire batch reverts. @@ -257,6 +285,28 @@ impl EarnQuestContract { escrow::withdraw_unclaimed(&env, &quest_id, &creator) } + /// Update quest metadata (quest creator or admin). + pub fn update_quest_metadata( + env: Env, + quest_id: Symbol, + updater: Address, + metadata: QuestMetadata, + ) -> Result<(), Error> { + security::require_not_paused(&env)?; + updater.require_auth(); + quest::update_quest_metadata(&env, &quest_id, &updater, &metadata) + } + + /// Query quest metadata. + pub fn get_quest_metadata(env: Env, quest_id: Symbol) -> Result { + storage::get_quest_metadata(&env, &quest_id) + } + + /// Check whether metadata exists for a quest. + pub fn has_quest_metadata(env: Env, quest_id: Symbol) -> bool { + storage::has_quest_metadata(&env, &quest_id) + } + /// Query the available escrow balance for a quest. pub fn get_escrow_balance(env: Env, quest_id: Symbol) -> Result { escrow::get_balance(&env, &quest_id) diff --git a/contracts/earn-quest/src/quest.rs b/contracts/earn-quest/src/quest.rs index 6cc57db..605217c 100644 --- a/contracts/earn-quest/src/quest.rs +++ b/contracts/earn-quest/src/quest.rs @@ -1,10 +1,18 @@ use crate::errors::Error; use crate::events; use crate::storage; -use crate::types::{BatchQuestInput, Quest, QuestStatus}; +use crate::types::{BatchQuestInput, MetadataDescription, Quest, QuestMetadata, QuestStatus}; use crate::validation; use soroban_sdk::{Address, Env, Symbol, Vec}; +const MAX_METADATA_TITLE_LEN: u32 = 80; +const MAX_METADATA_CATEGORY_LEN: u32 = 40; +const MAX_METADATA_TAG_LEN: u32 = 32; +const MAX_METADATA_REQUIREMENT_LEN: u32 = 200; +const MAX_METADATA_INLINE_DESCRIPTION_LEN: u32 = 1200; +const MAX_METADATA_TAGS: u32 = 15; +const MAX_METADATA_REQUIREMENTS: u32 = 20; + /// Register a new quest with full input validation. /// /// Validates: @@ -65,6 +73,31 @@ pub fn register_quest( Ok(()) } +/// Register a new quest and store metadata in the same transaction. +pub fn register_quest_with_metadata( + env: &Env, + id: &Symbol, + creator: &Address, + reward_asset: &Address, + reward_amount: i128, + verifier: &Address, + deadline: u64, + metadata: &QuestMetadata, +) -> Result<(), Error> { + register_quest( + env, + id, + creator, + reward_asset, + reward_amount, + verifier, + deadline, + )?; + validate_metadata(metadata)?; + storage::set_quest_metadata(env, id, metadata); + Ok(()) +} + //================================================================================ // Batch registration (gas-optimized) //================================================================================ @@ -106,3 +139,52 @@ pub fn register_quests_batch( Ok(()) } + +/// Update metadata for an existing quest. +/// Only the quest creator or an admin can update metadata. +pub fn update_quest_metadata( + env: &Env, + quest_id: &Symbol, + updater: &Address, + metadata: &QuestMetadata, +) -> Result<(), Error> { + let quest = storage::get_quest(env, quest_id)?; + if &quest.creator != updater && !storage::is_admin(env, updater) { + return Err(Error::Unauthorized); + } + + validate_metadata(metadata)?; + storage::set_quest_metadata(env, quest_id, metadata); + Ok(()) +} + +fn validate_metadata(metadata: &QuestMetadata) -> Result<(), Error> { + validate_string_len(&metadata.title, MAX_METADATA_TITLE_LEN)?; + validate_string_len(&metadata.category, MAX_METADATA_CATEGORY_LEN)?; + + validation::validate_array_length(metadata.tags.len(), MAX_METADATA_TAGS)?; + for i in 0..metadata.tags.len() { + validate_string_len(&metadata.tags.get(i).unwrap(), MAX_METADATA_TAG_LEN)?; + } + + validation::validate_array_length(metadata.requirements.len(), MAX_METADATA_REQUIREMENTS)?; + for i in 0..metadata.requirements.len() { + validate_string_len( + &metadata.requirements.get(i).unwrap(), + MAX_METADATA_REQUIREMENT_LEN, + )?; + } + + if let MetadataDescription::Inline(desc) = &metadata.description { + validate_string_len(desc, MAX_METADATA_INLINE_DESCRIPTION_LEN)?; + } + + Ok(()) +} + +fn validate_string_len(value: &soroban_sdk::String, max: u32) -> Result<(), Error> { + if value.len() > max { + return Err(Error::StringTooLong); + } + Ok(()) +} diff --git a/contracts/earn-quest/src/storage.rs b/contracts/earn-quest/src/storage.rs index 77f1eb1..7981d9a 100644 --- a/contracts/earn-quest/src/storage.rs +++ b/contracts/earn-quest/src/storage.rs @@ -1,5 +1,7 @@ use crate::errors::Error; -use crate::types::{Quest, QuestStatus, Submission, SubmissionStatus, UserStats}; +use crate::types::{ + EscrowInfo, Quest, QuestMetadata, QuestStatus, Submission, SubmissionStatus, UserStats, +}; use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; /// Storage key definitions for the contract's persistent data. @@ -10,6 +12,8 @@ use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; pub enum DataKey { /// Stores individual Quest data, keyed by quest ID (Symbol) Quest(Symbol), + /// Stores quest metadata, keyed by quest ID (Symbol) + QuestMetadata(Symbol), /// Stores individual Submission data, keyed by quest ID and submitter address Submission(Symbol, Address), /// Stores UserStats data, keyed by user address @@ -91,6 +95,28 @@ pub fn set_quest(env: &Env, id: &Symbol, quest: &Quest) { .set(&DataKey::Quest(id.clone()), quest); } +/// Checks if metadata exists for a quest. +pub fn has_quest_metadata(env: &Env, id: &Symbol) -> bool { + env.storage() + .instance() + .has(&DataKey::QuestMetadata(id.clone())) +} + +/// Gets metadata for a quest, if present. +pub fn get_quest_metadata(env: &Env, id: &Symbol) -> Result { + env.storage() + .instance() + .get(&DataKey::QuestMetadata(id.clone())) + .ok_or(Error::MetadataNotFound) +} + +/// Stores metadata for a quest. +pub fn set_quest_metadata(env: &Env, id: &Symbol, metadata: &QuestMetadata) { + env.storage() + .instance() + .set(&DataKey::QuestMetadata(id.clone()), metadata); +} + //================================================================================ // Submission Storage Functions //================================================================================ @@ -258,6 +284,9 @@ pub fn delete_quest(env: &Env, id: &Symbol) -> Result<(), Error> { } env.storage().instance().remove(&DataKey::Quest(id.clone())); + env.storage() + .instance() + .remove(&DataKey::QuestMetadata(id.clone())); Ok(()) } @@ -681,4 +710,4 @@ pub fn set_escrow(env: &Env, quest_id: &Symbol, escrow: &EscrowInfo) { env.storage() .instance() .set(&DataKey::Escrow(quest_id.clone()), escrow); -} \ No newline at end of file +} diff --git a/contracts/earn-quest/src/submission.rs b/contracts/earn-quest/src/submission.rs index 17e6b21..35b6b0e 100644 --- a/contracts/earn-quest/src/submission.rs +++ b/contracts/earn-quest/src/submission.rs @@ -4,7 +4,6 @@ use crate::storage; use crate::types::{BatchApprovalInput, Submission, SubmissionStatus}; use crate::validation; use soroban_sdk::{Address, BytesN, Env, Symbol, Vec}; -use crate::storage; // already imported /// Submit proof for a quest with full input validation. /// diff --git a/contracts/earn-quest/src/types.rs b/contracts/earn-quest/src/types.rs index 334b5fa..4b5af3b 100644 --- a/contracts/earn-quest/src/types.rs +++ b/contracts/earn-quest/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Symbol, BytesN, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, String, Symbol, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -86,6 +86,26 @@ pub struct BatchApprovalInput { pub submitter: Address, } +/// Description storage mode for quest metadata. +/// Inline is simpler; hash reference is cheaper for large content. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MetadataDescription { + Inline(String), + Hash(BytesN<32>), +} + +/// Rich quest metadata shown to users. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuestMetadata { + pub title: String, + pub description: MetadataDescription, + pub requirements: Vec, + pub category: String, + pub tags: Vec, +} + /// Escrow tracks tokens locked per quest. /// Created when a creator calls deposit_escrow(). diff --git a/contracts/earn-quest/tests/test_batch.rs b/contracts/earn-quest/tests/test_batch.rs index e624daf..6f33336 100644 --- a/contracts/earn-quest/tests/test_batch.rs +++ b/contracts/earn-quest/tests/test_batch.rs @@ -1,7 +1,8 @@ #![cfg(test)] use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, testutils::Address as _, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::testutils::Events as _; +use soroban_sdk::{symbol_short, testutils::Address as _, Address, BytesN, Env, Symbol, TryFromVal, Vec}; extern crate earn_quest; use earn_quest::types::{BatchApprovalInput, BatchQuestInput}; @@ -34,7 +35,7 @@ fn setup_contract_and_token( } fn make_quest_input( - env: &Env, + _env: &Env, id: &Symbol, reward_asset: &Address, reward_amount: i128, @@ -100,17 +101,6 @@ fn test_register_quests_batch_success() { client.register_quests_batch(&creator, &quests); // All three quests should exist (read via single register would fail if duplicate) - client.register_quest( - &symbol_short!("BQ1"), - &creator, - &token_contract, - &100, - &verifier, - &deadline, - ); - // If we get here without panic, BQ1 was already registered - so we actually - // need to just verify we can't register again. So instead: register_quest - // for a new id would succeed; for BQ1 we expect QuestAlreadyExists. let res = client.try_register_quest( &symbol_short!("BQ1"), &creator, @@ -153,19 +143,18 @@ fn test_register_quests_batch_emits_events() { client.register_quests_batch(&creator, &quests); let events = env.events().all(); - let reg_events: Vec<_> = events + let reg_events = events .iter() .filter(|e| { - let (topics, _): (soroban_sdk::Vec, _) = - (e.0.clone(), e.1.clone()); - let t0: Symbol = topics.get(0).unwrap().into_val(&env); + let topics = &e.1; + let t0: Symbol = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); t0 == symbol_short!("quest_reg") }) - .collect(); + .count(); assert!( - reg_events.len() >= 2, + reg_events >= 2, "expected at least 2 quest_reg events, got {}", - reg_events.len() + reg_events ); } @@ -327,17 +316,16 @@ fn test_approve_submissions_batch_emits_events() { client.approve_submissions_batch(&verifier, &submissions); let events = env.events().all(); - let appr_events: Vec<_> = events + let appr_events = events .iter() .filter(|e| { - let (topics, _): (soroban_sdk::Vec, _) = - (e.0.clone(), e.1.clone()); - let t0: Symbol = topics.get(0).unwrap().into_val(&env); + let topics = &e.1; + let t0: Symbol = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); t0 == symbol_short!("sub_appr") }) - .collect(); + .count(); assert!( - appr_events.len() >= 1, + appr_events >= 1, "expected at least 1 submission_approved event" ); } @@ -350,33 +338,26 @@ fn test_approve_submissions_batch_size_limit_enforced() { let (_, client, token_contract, _) = setup_contract_and_token(&env); let creator = Address::generate(&env); let verifier = Address::generate(&env); + let submitter = Address::generate(&env); let deadline = 10000u64; let proof = BytesN::from_array(&env, &[1u8; 32]); - // Register 51 quests and create 51 submissions - let mut submitters = Vec::new(&env); - for i in 0u32..51 { - let sym = Symbol::new(&env, &format!("LQ{:02}", i)); - client.register_quest( - &sym, - &creator, - &token_contract, - &1, - &verifier, - &deadline, - ); - let sub = Address::generate(&env); - submitters.push_back(sub.clone()); - client.submit_proof(&sym, &sub, &proof); - } + // Prepare one valid submission and then exceed max batch size using duplicates. + // Validation checks size before processing entries. + let quest_id = symbol_short!("LQMAX"); + client.register_quest( + &quest_id, + &creator, + &token_contract, + &1, + &verifier, + &deadline, + ); + client.submit_proof(&quest_id, &submitter, &proof); let mut submissions = Vec::new(&env); - for i in 0u32..51 { - let sym = Symbol::new(&env, &format!("LQ{:02}", i)); - submissions.push_back(make_approval_input( - &sym, - &submitters.get(i).unwrap(), - )); + for _ in 0u32..51 { + submissions.push_back(make_approval_input(&quest_id, &submitter)); } let res = client.try_approve_submissions_batch(&verifier, &submissions); diff --git a/contracts/earn-quest/tests/test_metadata.rs b/contracts/earn-quest/tests/test_metadata.rs new file mode 100644 index 0000000..fc6bbb3 --- /dev/null +++ b/contracts/earn-quest/tests/test_metadata.rs @@ -0,0 +1,242 @@ +#![cfg(test)] + +use soroban_sdk::{symbol_short, testutils::Address as _, Address, BytesN, Env, String, Vec}; + +extern crate earn_quest; +use earn_quest::types::{MetadataDescription, QuestMetadata}; +use earn_quest::{EarnQuestContract, EarnQuestContractClient}; + +fn setup_contract(env: &Env) -> (Address, EarnQuestContractClient<'_>) { + let contract_id = env.register_contract(None, EarnQuestContract); + let client = EarnQuestContractClient::new(env, &contract_id); + (contract_id, client) +} + +fn make_inline_metadata( + env: &Env, + title: &str, + description: &str, + category: &str, + tags: &[&str], + requirements: &[&str], +) -> QuestMetadata { + let mut tags_vec = Vec::new(env); + for tag in tags { + tags_vec.push_back(String::from_str(env, tag)); + } + + let mut reqs_vec = Vec::new(env); + for req in requirements { + reqs_vec.push_back(String::from_str(env, req)); + } + + QuestMetadata { + title: String::from_str(env, title), + description: MetadataDescription::Inline(String::from_str(env, description)), + requirements: reqs_vec, + category: String::from_str(env, category), + tags: tags_vec, + } +} + +#[test] +fn test_metadata_set_during_quest_creation_and_queryable() { + let env = Env::default(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let quest_id = symbol_short!("META1"); + let creator = Address::generate(&env); + let token = Address::generate(&env); + let verifier = Address::generate(&env); + + let metadata = make_inline_metadata( + &env, + "Build Wallet", + "Implement wallet connect support.", + "development", + &["wallet", "frontend"], + &["Connect wallet", "Display address"], + ); + + client.register_quest_with_metadata( + &quest_id, &creator, &token, &1000, &verifier, &10_000, &metadata, + ); + + assert!(client.has_quest_metadata(&quest_id)); + let loaded = client.get_quest_metadata(&quest_id); + assert_eq!(loaded, metadata); +} + +#[test] +fn test_metadata_update_by_creator() { + let env = Env::default(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let quest_id = symbol_short!("META2"); + let creator = Address::generate(&env); + let token = Address::generate(&env); + let verifier = Address::generate(&env); + + let metadata = make_inline_metadata( + &env, + "Initial Title", + "Initial description.", + "build", + &["initial"], + &["Initial requirement"], + ); + client.register_quest_with_metadata( + &quest_id, &creator, &token, &1000, &verifier, &10_000, &metadata, + ); + + let updated = make_inline_metadata( + &env, + "Updated Title", + "Updated description.", + "engineering", + &["updated", "v2"], + &["Updated requirement"], + ); + client.update_quest_metadata(&quest_id, &creator, &updated); + + let loaded = client.get_quest_metadata(&quest_id); + assert_eq!(loaded, updated); +} + +#[test] +fn test_metadata_update_rejects_unauthorized_user() { + let env = Env::default(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let quest_id = symbol_short!("META3"); + let creator = Address::generate(&env); + let updater = Address::generate(&env); + let token = Address::generate(&env); + let verifier = Address::generate(&env); + + let metadata = make_inline_metadata( + &env, + "Base", + "Base description.", + "security", + &["base"], + &["Base req"], + ); + client.register_quest_with_metadata( + &quest_id, &creator, &token, &1000, &verifier, &10_000, &metadata, + ); + + let updated = make_inline_metadata( + &env, + "Should Fail", + "Unauthorized update attempt.", + "security", + &["invalid"], + &["No permission"], + ); + let result = client.try_update_quest_metadata(&quest_id, &updater, &updated); + assert!(result.is_err()); +} + +#[test] +fn test_metadata_update_allowed_for_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + + let quest_id = symbol_short!("META4"); + let creator = Address::generate(&env); + let token = Address::generate(&env); + let verifier = Address::generate(&env); + + let metadata = make_inline_metadata( + &env, + "Start", + "Start description.", + "qa", + &["qa"], + &["Run tests"], + ); + client.register_quest_with_metadata( + &quest_id, &creator, &token, &1000, &verifier, &10_000, &metadata, + ); + + let admin_update = make_inline_metadata( + &env, + "Admin Revised", + "Admin updated metadata.", + "qa", + &["admin"], + &["Review report"], + ); + client.update_quest_metadata(&quest_id, &admin, &admin_update); + + let loaded = client.get_quest_metadata(&quest_id); + assert_eq!(loaded, admin_update); +} + +#[test] +fn test_hash_reference_metadata_supported() { + let env = Env::default(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let quest_id = symbol_short!("META5"); + let creator = Address::generate(&env); + let token = Address::generate(&env); + let verifier = Address::generate(&env); + + let mut tags = Vec::new(&env); + tags.push_back(String::from_str(&env, "ipfs")); + let mut requirements = Vec::new(&env); + requirements.push_back(String::from_str(&env, "Upload proof")); + + let hash = BytesN::from_array(&env, &[7u8; 32]); + let metadata = QuestMetadata { + title: String::from_str(&env, "Off-chain heavy details"), + description: MetadataDescription::Hash(hash.clone()), + requirements, + category: String::from_str(&env, "content"), + tags, + }; + + client.register_quest_with_metadata( + &quest_id, &creator, &token, &1000, &verifier, &10_000, &metadata, + ); + + let loaded = client.get_quest_metadata(&quest_id); + assert_eq!(loaded.description, MetadataDescription::Hash(hash)); +} + +#[test] +fn test_large_inline_metadata_description_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let quest_id = symbol_short!("META6"); + let creator = Address::generate(&env); + let token = Address::generate(&env); + let verifier = Address::generate(&env); + + let large_desc = "a".repeat(1201); + let metadata = make_inline_metadata( + &env, + "Too Large", + &large_desc, + "gas", + &["limit"], + &["reject large inline description"], + ); + + let result = client.try_register_quest_with_metadata( + &quest_id, &creator, &token, &1000, &verifier, &10_000, &metadata, + ); + assert!(result.is_err()); +}