diff --git a/contracts/integration-tests/src/lib.rs b/contracts/integration-tests/src/lib.rs index a140027..6a3b3a3 100644 --- a/contracts/integration-tests/src/lib.rs +++ b/contracts/integration-tests/src/lib.rs @@ -81,11 +81,13 @@ fn setup_full_platform() -> ( stake_vault_client.initialize(&admin, &token_address); // QuestEngine: admin, token, reward_pool, stake_vault + let governance_address = governance_client.address.clone(); quest_engine_client.initialize( &admin, &token_address, &reward_pool_client.address, &stake_vault_client.address, + &Some(governance_address), ); // Governance: admin, badge_contract_address diff --git a/contracts/quest-engine/src/lib.rs b/contracts/quest-engine/src/lib.rs index ccb8aca..46dc4b1 100644 --- a/contracts/quest-engine/src/lib.rs +++ b/contracts/quest-engine/src/lib.rs @@ -18,16 +18,14 @@ pub const VERSION: u32 = 1; pub const MAX_QUEST_REWARD: i128 = 1_000_000_000_000_000; pub const PLATFORM_FEE_BASIS_POINTS: u32 = 1500; -// Crate overview — Build and Explore quests. Build quests are -// employer-funded and reviewed per submission. Explore quests are -// admin-verified and rewarded out of the RewardPool. +pub const DISPUTE_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60; // 7 days in seconds + // Crate overview — Build and Explore quests. Build quests are + // employer-funded and reviewed per submission. Explore quests are + // admin-verified and rewarded out of the RewardPool. pub mod types; pub use types::QuestType; -use types::{ - DataKey, ExploreSubmission, ExploreSubmissionStatus, Quest, Submission, SubmissionStatus, - MAX_REASON_LEN, -}; +use types::{DataKey, Dispute, Quest, Submission, SubmissionStatus}; use soroban_sdk::{ contract, contractclient, contractevent, contractimpl, token, Address, BytesN, Env, String, Vec, @@ -43,6 +41,11 @@ pub trait RewardPoolInterface { fn distribute_reward(env: Env, caller: Address, learner: Address, amount: i128); } +#[contractclient(name = "GovernanceClient")] +pub trait GovernanceInterface { + fn get_proposal(env: Env, proposal_id: u32) -> types::governance::Proposal; +} + #[contractevent] pub struct QuestCreated { #[topic] @@ -160,6 +163,37 @@ pub struct RewardPoolUpdated { pub struct StakeVaultUpdated { #[topic] pub admin: Address, + #[topic] + pub new_address: Address, +} + +#[contractevent] +pub struct DisputeOpened { + #[topic] + pub learner: Address, + #[topic] + pub quest_id: u32, + #[topic] + pub dispute_id: u32, + pub reason: BytesN<32>, +} + +#[contractevent] +pub struct DisputeResolved { + #[topic] + pub dispute_id: u32, + #[topic] + pub learner: Address, + #[topic] + pub quest_id: u32, + pub override_approve: bool, +} + +#[contractevent] +pub struct GovernanceUpdated { + #[topic] + pub admin: Address, + #[topic] pub new_address: Address, } @@ -191,6 +225,7 @@ impl QuestEngineContract { token: Address, reward_pool: Address, stake_vault: Address, + governance: Option
, ) { if env.storage().instance().has(&DataKey::Token) { panic!("Already initialized"); @@ -204,7 +239,13 @@ impl QuestEngineContract { env.storage() .instance() .set(&DataKey::StakeVault, &stake_vault); + if let Some(gov) = governance { + env.storage().instance().set(&DataKey::Governance, &gov); + } env.storage().instance().set(&DataKey::QuestCounter, &0u32); + env.storage() + .instance() + .set(&DataKey::DisputeCounter, &0u32); } /// Toggles the pause state of the contract (emergency circuit breaker). @@ -281,6 +322,26 @@ impl QuestEngineContract { StakeVaultUpdated { admin, new_address }.publish(&env); } + /// Updates the Governance contract address used for dispute resolution. + /// Admin-only. Emits `GovernanceUpdated` with the new address. + pub fn set_governance_address(env: Env, admin: Address, new_address: Address) { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if admin != stored_admin { + panic!("Unauthorized"); + } + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::Governance, &new_address); + + GovernanceUpdated { admin, new_address }.publish(&env); + } + /// Allows an employer to lock USDC directly in the QuestEngine contract. /// This acts as an isolated vault specifically for B2B bounties. /// Employer-funded quest that is funded out of the employer's @@ -458,10 +519,11 @@ impl QuestEngineContract { panic!("Submission already exists"); } - // 5. Save struct { proof_hash, status: SubmissionStatus::Pending } to storage. + // 5. Save struct { proof_hash, status: SubmissionStatus::Pending, reviewed_at: None } to storage. let submission = Submission { proof_hash: proof_hash.clone(), status: SubmissionStatus::Pending, + reviewed_at: None, }; env.storage().persistent().set(&submission_key, &submission); @@ -578,7 +640,8 @@ impl QuestEngineContract { submission.status = SubmissionStatus::Rejected; } - // 6. Save updated submission to Persistent storage. + // 6. Set reviewed_at timestamp and save updated submission to Persistent storage. + submission.reviewed_at = Some(env.ledger().timestamp()); env.storage().persistent().set(&submission_key, &submission); // 7. Emit SubmissionReviewed event. @@ -899,158 +962,221 @@ impl QuestEngineContract { .publish(&env); } - /// Records a learner's off-chain proof for an Explore quest on-chain. - /// - /// This is purely an intent/audit record — no tokens move. The admin - /// then calls `verify_explore_quest` to approve or `reject_explore_quest` - /// to deny the submission. Re-submission for the same (learner, quest_id) - /// pair is blocked once a record already exists. - /// - /// # Arguments - /// * `learner` - The learner submitting the proof (must authenticate) - /// * `quest_id` - The ID of the Explore quest - /// * `proof_hash` - 32-byte hash of the off-chain proof artifact - /// - /// # Panics - /// * If learner authentication fails - /// * If quest is not found or not active - /// * If quest type is not Explore - /// * If a submission already exists for this (learner, quest_id) pair - /// - /// # Events - /// Emits `ExploreProofSubmitted` on success. - pub fn submit_explore_proof(env: Env, learner: Address, quest_id: u32, proof_hash: BytesN<32>) { - // 1. learner.require_auth() + /// Allows a learner to open a dispute for a rejected submission within the dispute window. + /// Learners can only dispute submissions that were rejected, and only within the + /// DISPUTE_WINDOW_SECONDS (7 days) from when the submission was reviewed. + pub fn dispute_submission( + env: Env, + learner: Address, + quest_id: u32, + reason: BytesN<32>, + ) -> u32 { + // 1. Require learner authentication learner.require_auth(); - // 2. Retrieve quest — must be active and Explore type - let quest: Quest = env + // 2. Retrieve the submission + let submission_key = DataKey::Submission(learner.clone(), quest_id); + let submission: Submission = env .storage() .persistent() - .get(&DataKey::Quest(quest_id)) - .expect("Quest not found"); - if !quest.active { - panic!("Quest is not active"); + .get(&submission_key) + .expect("Submission not found"); + + // 3. Verify the submission was rejected + if submission.status != SubmissionStatus::Rejected { + panic!("Only rejected submissions can be disputed"); } - assert!( - quest.quest_type == QuestType::Explore, - "Only Explore quests accept explore proofs" - ); - // 3. Guard against duplicate submissions - let submission_key = DataKey::ExploreSubmission(learner.clone(), quest_id); - if env.storage().persistent().has(&submission_key) { - panic!("Explore submission already exists"); + // 4. Check that the submission was reviewed and we're within the dispute window + let reviewed_at = submission + .reviewed_at + .expect("Submission hasn't been reviewed yet"); + let current_time = env.ledger().timestamp(); + if current_time - reviewed_at > DISPUTE_WINDOW_SECONDS { + panic!("Dispute window has expired - disputes must be opened within 7 days of review"); } - // 4. Persist the submission record - let submission = ExploreSubmission { - proof_hash: proof_hash.clone(), - status: ExploreSubmissionStatus::Pending, + // We'll track the opened_at timestamp for the dispute + // Increment dispute counter + let mut dispute_id: u32 = env + .storage() + .instance() + .get(&DataKey::DisputeCounter) + .unwrap_or(0); + dispute_id += 1; + env.storage() + .instance() + .set(&DataKey::DisputeCounter, &dispute_id); + + // Create and store the dispute + let dispute = Dispute { + quest_id, + learner: learner.clone(), + reason: reason.clone(), + opened_at: current_time, + resolved: false, }; - env.storage().persistent().set(&submission_key, &submission); - // 5. Emit ExploreProofSubmitted event - ExploreProofSubmitted { + env.storage() + .persistent() + .set(&DataKey::Dispute(dispute_id), &dispute); + + // Emit DisputeOpened event + DisputeOpened { learner, quest_id, - proof_hash, + dispute_id, + reason, } .publish(&env); - } - /// Returns a learner's Explore quest submission record, if any. - pub fn get_explore_submission( - env: Env, - learner: Address, - quest_id: u32, - ) -> Option { - env.storage() - .persistent() - .get(&DataKey::ExploreSubmission(learner, quest_id)) + dispute_id } - /// Rejects a learner's Explore quest proof, recording the reason on-chain. - /// - /// The submission must be in `Pending` state. After rejection the status - /// is updated to `Rejected` so the decision is permanently auditable via - /// both storage reads and the emitted `ExploreSubmissionRejected` event. - /// - /// # Arguments - /// * `admin` - The admin address (must match stored admin) - /// * `learner` - The learner whose submission is being rejected - /// * `quest_id` - The ID of the Explore quest - /// * `reason` - Human-readable rejection reason (max [`MAX_REASON_LEN`] bytes) - /// - /// # Panics - /// * If admin authentication fails - /// * If admin does not match stored admin - /// * If quest is not found or not of Explore type - /// * If no pending submission exists for this (learner, quest_id) pair - /// * If `reason` exceeds `MAX_REASON_LEN` bytes - /// - /// # Events - /// Emits `ExploreSubmissionRejected` on success. - pub fn reject_explore_quest( - env: Env, - admin: Address, - learner: Address, - quest_id: u32, - reason: String, - ) { - // 1. admin.require_auth() - admin.require_auth(); - - // 2. Verify admin + /// Admin-only function to resolve a dispute. Can override the original rejection to approve it. + /// Emits a DisputeResolved event when completed. + pub fn resolve_dispute(env: Env, admin: Address, dispute_id: u32, override_approve: bool) { + // 1. Verify admin let stored_admin: Address = env .storage() .instance() .get(&DataKey::Admin) .expect("Not initialized"); - assert!(admin == stored_admin, "Unauthorized"); - - // 3. Guard reason length to keep Soroban string costs bounded - assert!( - reason.len() <= MAX_REASON_LEN, - "Reason exceeds maximum length" - ); + if admin != stored_admin { + panic!("Unauthorized"); + } + admin.require_auth(); - // 4. Get quest — must be Explore type - let quest: Quest = env + // 2. Retrieve the dispute + let mut dispute: Dispute = env .storage() .persistent() - .get(&DataKey::Quest(quest_id)) - .expect("Quest not found"); - assert!( - quest.quest_type == QuestType::Explore, - "Not an Explore quest" - ); + .get(&DataKey::Dispute(dispute_id)) + .expect("Dispute not found"); - // 5. Retrieve the pending explore submission - let submission_key = DataKey::ExploreSubmission(learner.clone(), quest_id); - let mut submission: ExploreSubmission = env - .storage() + // 3. Verify dispute is not already resolved + if dispute.resolved { + panic!("Dispute has already been resolved"); + } + + // 4. Mark dispute as resolved + dispute.resolved = true; + env.storage() .persistent() - .get(&submission_key) - .expect("No proof submission found for this learner"); - assert!( - submission.status == ExploreSubmissionStatus::Pending, - "Submission is not pending" - ); + .set(&DataKey::Dispute(dispute_id), &dispute); - // 6. Update status to Rejected - submission.status = ExploreSubmissionStatus::Rejected; - env.storage().persistent().set(&submission_key, &submission); + // 5. If we need to override and approve, process the payout + if override_approve { + let quest: Quest = env + .storage() + .persistent() + .get(&DataKey::Quest(dispute.quest_id)) + .expect("Quest not found"); - // 7. Emit ExploreSubmissionRejected event - ExploreSubmissionRejected { - admin, - learner, - quest_id, - reason, + // Update submission status to Approved + let submission_key = DataKey::Submission(dispute.learner.clone(), dispute.quest_id); + let mut submission: Submission = env + .storage() + .persistent() + .get(&submission_key) + .expect("Submission not found"); + submission.status = SubmissionStatus::Approved; + env.storage().persistent().set(&submission_key, &submission); + + // Process payout just like in review_submission + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .expect("Not initialized"); + let token_client = token::Client::new(&env, &token_address); + + // Fetch stake vault and get multiplier + let stake_vault_address: Address = env + .storage() + .instance() + .get(&DataKey::StakeVault) + .expect("Not initialized"); + let stake_vault_client = StakeVaultClient::new(&env, &stake_vault_address); + let multiplier = stake_vault_client.get_multiplier(&dispute.learner); + + let (fee, learner_amount, boost_actual, boost_capped) = + compute_learner_payout(quest.reward_amount, multiplier); + + let reward_pool: Address = env + .storage() + .instance() + .get(&DataKey::RewardPool) + .expect("Not initialized"); + + token_client.transfer(&env.current_contract_address(), &reward_pool, &fee); + token_client.transfer( + &env.current_contract_address(), + &dispute.learner, + &learner_amount, + ); + + if boost_capped { + PayoutComputed { + learner: dispute.learner.clone(), + quest_id: dispute.quest_id, + fee, + learner_amount, + boost_actual, + boost_capped, + } + .publish(&env); + } + } + + // 6. Emit DisputeResolved event + DisputeResolved { + dispute_id, + learner: dispute.learner, + quest_id: dispute.quest_id, + override_approve, } .publish(&env); } + + /// Governance-only function to resolve a dispute based on voting results. + /// Resolves the dispute if votes_for > votes_against, otherwise leaves it as rejected. + pub fn resolve_dispute_via_governance(env: Env, dispute_id: u32, proposal_id: u32) { + // 1. Get governance address + let governance_address: Address = env + .storage() + .instance() + .get(&DataKey::Governance) + .expect("Governance contract not configured"); + + // 2. Create governance client and get the proposal + let governance_client = GovernanceClient::new(&env, &governance_address); + let proposal = governance_client.get_proposal(&proposal_id); + + // 3. Verify proposal is executed and votes are in favor + if !proposal.executed { + panic!("Proposal has not been executed"); + } + + if proposal.votes_for <= proposal.votes_against { + panic!("Insufficient votes to approve the dispute"); + } + + // 4. If proposal passes, resolve the dispute with override_approve = true + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + Self::resolve_dispute(env, stored_admin, dispute_id, true); + } + + /// Returns a dispute by its ID. + pub fn get_dispute(env: Env, dispute_id: u32) -> Option { + env.storage() + .persistent() + .get(&DataKey::Dispute(dispute_id)) + } } #[cfg(test)] diff --git a/contracts/quest-engine/src/test.rs b/contracts/quest-engine/src/test.rs index f92b9d4..ca3de5e 100644 --- a/contracts/quest-engine/src/test.rs +++ b/contracts/quest-engine/src/test.rs @@ -46,10 +46,17 @@ fn setup() -> ( // Register mock stake vault let stake_vault_id = env.register(MockStakeVault, ()); - // Initialize the contract with admin, token, reward_pool, and stake_vault + // Initialize the contract with admin, token, reward_pool, stake_vault, and optional governance let admin = Address::generate(&env); let reward_pool = Address::generate(&env); - client.initialize(&admin, &token_id, &reward_pool, &stake_vault_id); + let governance: Option
= None; + client.initialize( + &admin, + &token_id, + &reward_pool, + &stake_vault_id, + &governance, + ); (env, client, token_id, reward_pool, admin, stake_vault_id) } @@ -69,7 +76,14 @@ fn token_balance(env: &Env, token_id: &Address, of: &Address) -> i128 { #[should_panic(expected = "Already initialized")] fn test_initialize_twice_panics() { let (_env, client, token_id, reward_pool, admin, stake_vault_id) = setup(); - client.initialize(&admin, &token_id, &reward_pool, &stake_vault_id); + let governance: Option
= None; + client.initialize( + &admin, + &token_id, + &reward_pool, + &stake_vault_id, + &governance, + ); } // ── set_reward_pool_address Tests ─────────────────────────────────────────── @@ -627,7 +641,14 @@ fn setup_with_multiplier( let admin = Address::generate(&env); let reward_pool = Address::generate(&env); - client.initialize(&admin, &token_id, &reward_pool, &stake_vault_id); + let governance: Option
= None; + client.initialize( + &admin, + &token_id, + &reward_pool, + &stake_vault_id, + &governance, + ); (env, client, token_id, reward_pool) } @@ -947,11 +968,13 @@ fn test_verify_explore_quest_success() { // Create a new client with mock reward pool let contract_id = env.register(QuestEngineContract, ()); let client = QuestEngineContractClient::new(&env, &contract_id); + let governance: Option
= None; client.initialize( &admin, &token_id, &mock_reward_pool_id, &mock_stake_vault_id, + &governance, ); // Create explore quest and submit proof first @@ -1118,7 +1141,14 @@ fn setup_vault_with_multiplier( let admin = Address::generate(&env); let reward_pool = Address::generate(&env); - client.initialize(&admin, &token_id, &reward_pool, &stake_vault_id); + let governance: Option
= None; + client.initialize( + &admin, + &token_id, + &reward_pool, + &stake_vault_id, + &governance, + ); (env, client, token_id, reward_pool) } diff --git a/contracts/quest-engine/src/types.rs b/contracts/quest-engine/src/types.rs index c14ed29..7c3b839 100644 --- a/contracts/quest-engine/src/types.rs +++ b/contracts/quest-engine/src/types.rs @@ -1,5 +1,21 @@ use soroban_sdk::{contracttype, Address, BytesN}; +pub mod governance { + use soroban_sdk::{contracttype, Address, BytesN}; + + #[contracttype] + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct Proposal { + pub id: u32, + pub proposer: Address, + pub metadata_hash: BytesN<32>, + pub votes_for: u32, + pub votes_against: u32, + pub end_time: u64, + pub executed: bool, + } +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum QuestType { @@ -42,6 +58,17 @@ pub enum SubmissionStatus { pub struct Submission { pub proof_hash: BytesN<32>, pub status: SubmissionStatus, + pub reviewed_at: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Dispute { + pub quest_id: u32, + pub learner: Address, + pub reason: BytesN<32>, + pub opened_at: u64, + pub resolved: bool, } /// Status of a learner's explore-quest submission. @@ -87,4 +114,7 @@ pub enum DataKey { /// 0 = pre-versioning (no Version key present). /// 1 = current schema (this build). Version, + Dispute(u32), // Dispute ID + DisputeCounter, + Governance, }