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,
}