From f632178ca122f3f1ec93088f1034e42e5532671d Mon Sep 17 00:00:00 2001 From: Skinny001 Date: Thu, 23 Jul 2026 17:36:29 +0100 Subject: [PATCH 1/3] Add Kani formal verification harnesses and fix pre-existing clippy lint errors - 14 Kani proof harnesses (18 proof functions) covering fee math, vault invariants, escrow safety, reputation bounds, oracle overflow, state machine, dispute split, governance thresholds, swap routes - gated behind #[cfg(kani)] in src/kani_proofs.rs - build.rs registers cfg(kani) for clippy compatibility - CI workflow at .github/workflows/formal-verification.yml - Fix pre-existing clippy errors across 6 source files --- .github/workflows/formal-verification.yml | 25 + build.rs | 3 + src/access_control.rs | 28 +- src/escrow.rs | 72 ++- src/events.rs | 189 ++----- src/governance.rs | 88 ++-- src/kani_proofs.rs | 589 ++++++++++++++++++++++ src/lib.rs | 384 +++++++------- src/pausable.rs | 2 +- src/reputation.rs | 49 +- src/storage.rs | 31 +- src/swap_router.rs | 87 ++-- src/swap_router_test.rs | 43 +- src/test.rs | 90 +++- src/user_profile.rs | 4 +- src/vault.rs | 28 +- 16 files changed, 1171 insertions(+), 541 deletions(-) create mode 100644 .github/workflows/formal-verification.yml create mode 100644 build.rs create mode 100644 src/kani_proofs.rs diff --git a/.github/workflows/formal-verification.yml b/.github/workflows/formal-verification.yml new file mode 100644 index 00000000..99de066e --- /dev/null +++ b/.github/workflows/formal-verification.yml @@ -0,0 +1,25 @@ +name: Formal Verification (Kani) + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + kani: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install Kani + run: cargo install --locked kani-verifier && cargo kani setup + + - name: Run all Kani proof harnesses + run: cargo kani diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..ab89e013 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo::rustc-check-cfg=cfg(kani)"); +} diff --git a/src/access_control.rs b/src/access_control.rs index 7a3cb893..3149ec19 100644 --- a/src/access_control.rs +++ b/src/access_control.rs @@ -26,26 +26,26 @@ pub enum AccessControlKey { pub fn grant_role(env: Env, admin: Address, user: Address, role: Role) { admin.require_auth(); - + let stored_admin: Address = env .storage() .instance() .get(&AccessControlKey::Admin) .unwrap_or_else(|| panic!("not initialized")); - + if admin != stored_admin { panic!("only admin can grant roles"); } - + let key = AccessControlKey::Role(user.clone()); let role_data = RoleData { role: role.clone(), granted_at: env.ledger().timestamp(), granted_by: admin, }; - + env.storage().instance().set(&key, &role_data); - + // Track role members let members_key = AccessControlKey::RoleMembers(role); let mut members: Vec
= env @@ -53,7 +53,7 @@ pub fn grant_role(env: Env, admin: Address, user: Address, role: Role) { .instance() .get(&members_key) .unwrap_or_else(|| Vec::new(&env)); - + if !members.contains(&user) { members.push_back(user); env.storage().instance().set(&members_key, &members); @@ -62,23 +62,27 @@ pub fn grant_role(env: Env, admin: Address, user: Address, role: Role) { pub fn revoke_role(env: Env, admin: Address, user: Address) { admin.require_auth(); - + let stored_admin: Address = env .storage() .instance() .get(&AccessControlKey::Admin) .unwrap_or_else(|| panic!("not initialized")); - + if admin != stored_admin { panic!("only admin can revoke roles"); } - + let key = AccessControlKey::Role(user.clone()); - + if let Some(role_data) = env.storage().instance().get::<_, RoleData>(&key) { // Remove from role members list let members_key = AccessControlKey::RoleMembers(role_data.role); - if let Some(mut members) = env.storage().instance().get::<_, Vec
>(&members_key) { + if let Some(members) = env + .storage() + .instance() + .get::<_, Vec
>(&members_key) + { let mut new_members = Vec::new(&env); for member in members.iter() { if member != user { @@ -87,7 +91,7 @@ pub fn revoke_role(env: Env, admin: Address, user: Address) { } env.storage().instance().set(&members_key, &new_members); } - + env.storage().instance().remove(&key); } } diff --git a/src/escrow.rs b/src/escrow.rs index b502ce9e..f6629d54 100644 --- a/src/escrow.rs +++ b/src/escrow.rs @@ -51,7 +51,7 @@ pub fn create_milestone( let count_key = EscrowKey::MilestoneCount(task_id); let mut count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); count += 1; - + let milestone = Milestone { id: count, task_id, @@ -62,11 +62,11 @@ pub fn create_milestone( submission_url: None, feedback: None, }; - + let key = EscrowKey::Milestone(task_id, count); env.storage().persistent().set(&key, &milestone); env.storage().persistent().set(&count_key, &count); - + count } @@ -82,14 +82,15 @@ pub fn submit_milestone( .persistent() .get(&key) .unwrap_or_else(|| panic!("milestone not found")); - - if milestone.status != MilestoneStatus::Pending && milestone.status != MilestoneStatus::Rejected { + + if milestone.status != MilestoneStatus::Pending && milestone.status != MilestoneStatus::Rejected + { panic!("milestone cannot be submitted"); } - + milestone.status = MilestoneStatus::Submitted; milestone.submission_url = Some(submission_url); - + env.storage().persistent().set(&key, &milestone); } @@ -105,44 +106,39 @@ pub fn approve_milestone( .persistent() .get(&key) .unwrap_or_else(|| panic!("milestone not found")); - + if milestone.status != MilestoneStatus::Submitted { panic!("milestone not submitted"); } - + milestone.status = MilestoneStatus::Approved; milestone.feedback = feedback; - + let amount = milestone.amount; - + env.storage().persistent().set(&key, &milestone); - + // Update stats update_stats(&env, 0, amount, 0, 0, 0); - + amount } -pub fn reject_milestone( - env: Env, - task_id: u32, - milestone_id: u32, - feedback: soroban_sdk::String, -) { +pub fn reject_milestone(env: Env, task_id: u32, milestone_id: u32, feedback: soroban_sdk::String) { let key = EscrowKey::Milestone(task_id, milestone_id); let mut milestone: Milestone = env .storage() .persistent() .get(&key) .unwrap_or_else(|| panic!("milestone not found")); - + if milestone.status != MilestoneStatus::Submitted { panic!("milestone not submitted"); } - + milestone.status = MilestoneStatus::Rejected; milestone.feedback = Some(feedback); - + env.storage().persistent().set(&key, &milestone); } @@ -154,14 +150,14 @@ pub fn get_milestone(env: Env, task_id: u32, milestone_id: u32) -> Option Vec { let count_key = EscrowKey::MilestoneCount(task_id); let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - + let mut milestones = Vec::new(&env); for i in 1..=count { if let Some(milestone) = get_milestone(env.clone(), task_id, i) { milestones.push_back(milestone); } } - + milestones } @@ -174,24 +170,20 @@ pub fn update_stats( completed_delta: u32, ) { let key = EscrowKey::EscrowStats; - let mut stats: EscrowStats = env - .storage() - .persistent() - .get(&key) - .unwrap_or(EscrowStats { - total_locked: 0, - total_released: 0, - total_refunded: 0, - active_escrows: 0, - completed_escrows: 0, - }); - + let mut stats: EscrowStats = env.storage().persistent().get(&key).unwrap_or(EscrowStats { + total_locked: 0, + total_released: 0, + total_refunded: 0, + active_escrows: 0, + completed_escrows: 0, + }); + stats.total_locked += locked_delta; stats.total_released += released_delta; stats.total_refunded += refunded_delta; stats.active_escrows = (stats.active_escrows as i32 + active_delta as i32).max(0) as u32; stats.completed_escrows += completed_delta; - + env.storage().persistent().set(&key, &stats); } @@ -218,14 +210,14 @@ pub fn lock_escrow(env: Env, task_id: u32, amount: i128) { pub fn release_escrow(env: Env, task_id: u32, amount: i128) { let key = EscrowKey::TaskEscrow(task_id); let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); - + if current < amount { panic!("insufficient escrow balance"); } - + let new_balance = current - amount; env.storage().persistent().set(&key, &new_balance); - + if new_balance == 0 { update_stats(&env, 0, amount, 0, 0, 1); } else { diff --git a/src/events.rs b/src/events.rs index 57ed99c5..f3dbe254 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Address, Env, String, symbol_short}; +use soroban_sdk::{symbol_short, Address, Env, String}; /// Event module for the LatterFix TaskManager Soroban contract. /// @@ -10,16 +10,8 @@ use soroban_sdk::{Address, Env, String, symbol_short}; /// /// Topic layout: (symbol, primary_id) /// Data layout: tuple of relevant fields - // ── Task Events ──────────────────────────────────────────────────────────── - -pub fn emit_task_created( - env: &Env, - task_id: u32, - creator: Address, - title: String, - reward: i128, -) { +pub fn emit_task_created(env: &Env, task_id: u32, creator: Address, title: String, reward: i128) { let ledger_ts = env.ledger().timestamp(); env.events().publish( (symbol_short!("task_cre"), task_id), @@ -27,71 +19,42 @@ pub fn emit_task_created( ); } -pub fn emit_task_assigned( - env: &Env, - task_id: u32, - assignee: Address, -) { +pub fn emit_task_assigned(env: &Env, task_id: u32, assignee: Address) { env.events().publish( (symbol_short!("task_assg"), task_id), (assignee, env.ledger().timestamp()), ); } -pub fn emit_task_submitted( - env: &Env, - task_id: u32, - assignee: Address, - delivery_url: String, -) { +pub fn emit_task_submitted(env: &Env, task_id: u32, assignee: Address, delivery_url: String) { env.events().publish( (symbol_short!("task_subm"), task_id), (assignee, delivery_url, env.ledger().timestamp()), ); } -pub fn emit_task_completed( - env: &Env, - task_id: u32, - assignee: Address, - payout: i128, - fee: i128, -) { +pub fn emit_task_completed(env: &Env, task_id: u32, assignee: Address, payout: i128, fee: i128) { env.events().publish( (symbol_short!("task_comp"), task_id), (assignee, payout, fee, env.ledger().timestamp()), ); } -pub fn emit_task_cancelled( - env: &Env, - task_id: u32, - creator: Address, - refund: i128, -) { +pub fn emit_task_cancelled(env: &Env, task_id: u32, creator: Address, refund: i128) { env.events().publish( (symbol_short!("task_canc"), task_id), (creator, refund, env.ledger().timestamp()), ); } -pub fn emit_task_disputed( - env: &Env, - task_id: u32, - caller: Address, -) { +pub fn emit_task_disputed(env: &Env, task_id: u32, caller: Address) { env.events().publish( (symbol_short!("task_disp"), task_id), (caller, env.ledger().timestamp()), ); } -pub fn emit_dispute_resolved( - env: &Env, - task_id: u32, - creator_refund: i128, - assignee_payout: i128, -) { +pub fn emit_dispute_resolved(env: &Env, task_id: u32, creator_refund: i128, assignee_payout: i128) { env.events().publish( (symbol_short!("disp_resl"), task_id), (creator_refund, assignee_payout, env.ledger().timestamp()), @@ -100,34 +63,21 @@ pub fn emit_dispute_resolved( // ── Profile Events ───────────────────────────────────────────────────────── -pub fn emit_profile_created( - env: &Env, - user: Address, - username: String, -) { +pub fn emit_profile_created(env: &Env, user: Address, username: String) { env.events().publish( (symbol_short!("prof_cre"), user), (username, env.ledger().timestamp()), ); } -pub fn emit_profile_updated( - env: &Env, - user: Address, - field: String, -) { +pub fn emit_profile_updated(env: &Env, user: Address, field: String) { env.events().publish( (symbol_short!("prof_upd"), user), (field, env.ledger().timestamp()), ); } -pub fn emit_reputation_awarded( - env: &Env, - user: Address, - points: u32, - new_total: u32, -) { +pub fn emit_reputation_awarded(env: &Env, user: Address, points: u32, new_total: u32) { env.events().publish( (symbol_short!("rep_award"), user), (points, new_total, env.ledger().timestamp()), @@ -136,48 +86,28 @@ pub fn emit_reputation_awarded( // ── Milestone Events ─────────────────────────────────────────────────────── -pub fn emit_milestone_created( - env: &Env, - task_id: u32, - milestone_id: u32, - amount: i128, -) { +pub fn emit_milestone_created(env: &Env, task_id: u32, milestone_id: u32, amount: i128) { env.events().publish( (symbol_short!("mile_cre"), (task_id, milestone_id)), (amount, env.ledger().timestamp()), ); } -pub fn emit_milestone_submitted( - env: &Env, - task_id: u32, - milestone_id: u32, - assignee: Address, -) { +pub fn emit_milestone_submitted(env: &Env, task_id: u32, milestone_id: u32, assignee: Address) { env.events().publish( (symbol_short!("mile_subm"), (task_id, milestone_id)), (assignee, env.ledger().timestamp()), ); } -pub fn emit_milestone_approved( - env: &Env, - task_id: u32, - milestone_id: u32, - amount: i128, -) { +pub fn emit_milestone_approved(env: &Env, task_id: u32, milestone_id: u32, amount: i128) { env.events().publish( (symbol_short!("mile_appr"), (task_id, milestone_id)), (amount, env.ledger().timestamp()), ); } -pub fn emit_milestone_rejected( - env: &Env, - task_id: u32, - milestone_id: u32, - feedback: String, -) { +pub fn emit_milestone_rejected(env: &Env, task_id: u32, milestone_id: u32, feedback: String) { env.events().publish( (symbol_short!("mile_rej"), (task_id, milestone_id)), (feedback, env.ledger().timestamp()), @@ -186,36 +116,21 @@ pub fn emit_milestone_rejected( // ── Governance Events ────────────────────────────────────────────────────── -pub fn emit_proposal_created( - env: &Env, - proposal_id: u32, - proposer: Address, - title: String, -) { +pub fn emit_proposal_created(env: &Env, proposal_id: u32, proposer: Address, title: String) { env.events().publish( (symbol_short!("prop_cre"), proposal_id), (proposer, title, env.ledger().timestamp()), ); } -pub fn emit_vote_cast( - env: &Env, - proposal_id: u32, - voter: Address, - vote_type: String, - weight: u32, -) { +pub fn emit_vote_cast(env: &Env, proposal_id: u32, voter: Address, vote_type: String, weight: u32) { env.events().publish( (symbol_short!("vote_cast"), (proposal_id, voter)), (vote_type, weight, env.ledger().timestamp()), ); } -pub fn emit_proposal_executed( - env: &Env, - proposal_id: u32, - passed: bool, -) { +pub fn emit_proposal_executed(env: &Env, proposal_id: u32, passed: bool) { env.events().publish( (symbol_short!("prop_exec"), proposal_id), (passed, env.ledger().timestamp()), @@ -224,24 +139,14 @@ pub fn emit_proposal_executed( // ── Access Control Events ────────────────────────────────────────────────── -pub fn emit_role_granted( - env: &Env, - user: Address, - role: String, - granted_by: Address, -) { +pub fn emit_role_granted(env: &Env, user: Address, role: String, granted_by: Address) { env.events().publish( (symbol_short!("role_gr"), user), (role, granted_by, env.ledger().timestamp()), ); } -pub fn emit_role_revoked( - env: &Env, - user: Address, - role: String, - revoked_by: Address, -) { +pub fn emit_role_revoked(env: &Env, user: Address, role: String, revoked_by: Address) { env.events().publish( (symbol_short!("role_rev"), user), (role, revoked_by, env.ledger().timestamp()), @@ -250,22 +155,14 @@ pub fn emit_role_revoked( // ── Pause Events ─────────────────────────────────────────────────────────── -pub fn emit_paused( - env: &Env, - action: String, - admin: Address, -) { +pub fn emit_paused(env: &Env, action: String, admin: Address) { env.events().publish( (symbol_short!("paused"), action), (admin, env.ledger().timestamp()), ); } -pub fn emit_unpaused( - env: &Env, - action: String, - admin: Address, -) { +pub fn emit_unpaused(env: &Env, action: String, admin: Address) { env.events().publish( (symbol_short!("unpaused"), action), (admin, env.ledger().timestamp()), @@ -274,24 +171,14 @@ pub fn emit_unpaused( // ── Transfer Events ──────────────────────────────────────────────────────── -pub fn emit_tokens_locked( - env: &Env, - task_id: u32, - from: Address, - amount: i128, -) { +pub fn emit_tokens_locked(env: &Env, task_id: u32, from: Address, amount: i128) { env.events().publish( (symbol_short!("lock"), task_id), (from, amount, env.ledger().timestamp()), ); } -pub fn emit_tokens_released( - env: &Env, - task_id: u32, - to: Address, - amount: i128, -) { +pub fn emit_tokens_released(env: &Env, task_id: u32, to: Address, amount: i128) { env.events().publish( (symbol_short!("release"), task_id), (to, amount, env.ledger().timestamp()), @@ -301,12 +188,7 @@ pub fn emit_tokens_released( // ── Platform Events ──────────────────────────────────────────────────────── /// Emitted when platform fee basis points are updated by an admin. -pub fn emit_fee_updated( - env: &Env, - old_fee_bps: u32, - new_fee_bps: u32, - updated_by: Address, -) { +pub fn emit_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32, updated_by: Address) { env.events().publish( (symbol_short!("fee_upd"), updated_by), (old_fee_bps, new_fee_bps, env.ledger().timestamp()), @@ -314,11 +196,7 @@ pub fn emit_fee_updated( } /// Emitted when the contract is first initialized. -pub fn emit_contract_initialized( - env: &Env, - admin: Address, - fee_bps: u32, -) { +pub fn emit_contract_initialized(env: &Env, admin: Address, fee_bps: u32) { env.events().publish( (symbol_short!("init"), admin), (fee_bps, env.ledger().timestamp()), @@ -337,7 +215,12 @@ pub fn emit_router_configured( ) { env.events().publish( (symbol_short!("rtr_cfg"), admin), - (oracle, max_hops, default_slippage_bps, env.ledger().timestamp()), + ( + oracle, + max_hops, + default_slippage_bps, + env.ledger().timestamp(), + ), ); } @@ -367,7 +250,13 @@ pub fn emit_swap_executed( ) { env.events().publish( (symbol_short!("swap_exec"), sender), - (token_in, token_out, amount_in, amount_out, env.ledger().timestamp()), + ( + token_in, + token_out, + amount_in, + amount_out, + env.ledger().timestamp(), + ), ); } diff --git a/src/governance.rs b/src/governance.rs index 37cb2d02..44c701c1 100644 --- a/src/governance.rs +++ b/src/governance.rs @@ -31,8 +31,8 @@ pub struct Proposal { pub votes_for: u32, pub votes_against: u32, pub votes_abstain: u32, - pub quorum: u32, // Minimum votes needed - pub threshold: u32, // Percentage needed to pass (e.g., 51 = 51%) + pub quorum: u32, // Minimum votes needed + pub threshold: u32, // Percentage needed to pass (e.g., 51 = 51%) pub executed_at: Option, } @@ -42,7 +42,7 @@ pub struct Vote { pub voter: Address, pub proposal_id: u32, pub vote_type: VoteType, - pub weight: u32, // Based on reputation + pub weight: u32, // Based on reputation pub voted_at: u64, } @@ -50,17 +50,17 @@ pub struct Vote { pub enum GovernanceKey { Proposal(u32), ProposalCount, - Vote(u32, Address), // (proposal_id, voter) + Vote(u32, Address), // (proposal_id, voter) Config, - Delegations(Address), // Delegated voting + Delegations(Address), // Delegated voting } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct GovernanceConfig { - pub voting_period: u64, // Duration in seconds - pub quorum: u32, // Minimum votes needed - pub threshold: u32, // Percentage to pass + pub voting_period: u64, // Duration in seconds + pub quorum: u32, // Minimum votes needed + pub threshold: u32, // Percentage to pass pub min_reputation_to_propose: u32, pub min_reputation_to_vote: u32, } @@ -80,7 +80,7 @@ pub fn get_config(env: Env) -> GovernanceConfig { pub fn set_config(env: Env, admin: Address, config: GovernanceConfig) { admin.require_auth(); - + // Verify admin - this should be called from the main contract env.storage() .persistent() @@ -94,20 +94,20 @@ pub fn create_proposal( description: String, quorum: Option, threshold: Option, - min_reputation: u32, + _min_reputation: u32, ) -> u32 { proposer.require_auth(); - + // Check reputation let config = get_config(env.clone()); - + let count_key = GovernanceKey::ProposalCount; let mut count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); count += 1; - + let now = env.ledger().timestamp(); let voting_ends_at = now + config.voting_period; - + let proposal = Proposal { id: count, title, @@ -123,46 +123,40 @@ pub fn create_proposal( threshold: threshold.unwrap_or(config.threshold), executed_at: None, }; - + env.storage() .persistent() .set(&GovernanceKey::Proposal(count), &proposal); env.storage().persistent().set(&count_key, &count); - + count } -pub fn cast_vote( - env: Env, - voter: Address, - proposal_id: u32, - vote_type: VoteType, - weight: u32, -) { +pub fn cast_vote(env: Env, voter: Address, proposal_id: u32, vote_type: VoteType, weight: u32) { voter.require_auth(); - + let proposal_key = GovernanceKey::Proposal(proposal_id); let mut proposal: Proposal = env .storage() .persistent() .get(&proposal_key) .unwrap_or_else(|| panic!("proposal not found")); - + if proposal.status != ProposalStatus::Active { panic!("proposal not active"); } - + let now = env.ledger().timestamp(); if now > proposal.voting_ends_at { panic!("voting period ended"); } - + // Check if already voted let vote_key = GovernanceKey::Vote(proposal_id, voter.clone()); if env.storage().persistent().has(&vote_key) { panic!("already voted"); } - + // Record vote let vote = Vote { voter: voter.clone(), @@ -171,45 +165,45 @@ pub fn cast_vote( weight, voted_at: now, }; - + env.storage().persistent().set(&vote_key, &vote); - + // Update proposal vote counts match vote_type { VoteType::For => proposal.votes_for += weight, VoteType::Against => proposal.votes_against += weight, VoteType::Abstain => proposal.votes_abstain += weight, } - + env.storage().persistent().set(&proposal_key, &proposal); } -pub fn execute_proposal(env: Env, caller: Address, proposal_id: u32) -> bool { +pub fn execute_proposal(env: Env, _caller: Address, proposal_id: u32) -> bool { let proposal_key = GovernanceKey::Proposal(proposal_id); let mut proposal: Proposal = env .storage() .persistent() .get(&proposal_key) .unwrap_or_else(|| panic!("proposal not found")); - + if proposal.status != ProposalStatus::Active { panic!("proposal not active"); } - + let now = env.ledger().timestamp(); if now <= proposal.voting_ends_at { panic!("voting period not ended"); } - + let total_votes = proposal.votes_for + proposal.votes_against + proposal.votes_abstain; - + // Check quorum if total_votes < proposal.quorum { proposal.status = ProposalStatus::Rejected; env.storage().persistent().set(&proposal_key, &proposal); return false; } - + // Check threshold (percentage of non-abstain votes) let non_abstain = proposal.votes_for + proposal.votes_against; if non_abstain == 0 { @@ -217,39 +211,39 @@ pub fn execute_proposal(env: Env, caller: Address, proposal_id: u32) -> bool { env.storage().persistent().set(&proposal_key, &proposal); return false; } - + let for_percentage = (proposal.votes_for * 100) / non_abstain; - + if for_percentage >= proposal.threshold { proposal.status = ProposalStatus::Executed; proposal.executed_at = Some(now); } else { proposal.status = ProposalStatus::Rejected; } - + env.storage().persistent().set(&proposal_key, &proposal); - + proposal.status == ProposalStatus::Executed } pub fn cancel_proposal(env: Env, proposer: Address, proposal_id: u32) { proposer.require_auth(); - + let proposal_key = GovernanceKey::Proposal(proposal_id); let mut proposal: Proposal = env .storage() .persistent() .get(&proposal_key) .unwrap_or_else(|| panic!("proposal not found")); - + if proposal.proposer != proposer { panic!("not proposer"); } - + if proposal.status != ProposalStatus::Active { panic!("proposal not active"); } - + proposal.status = ProposalStatus::Cancelled; env.storage().persistent().set(&proposal_key, &proposal); } @@ -272,7 +266,7 @@ pub fn get_active_proposals(env: Env) -> Vec { .persistent() .get(&GovernanceKey::ProposalCount) .unwrap_or(0); - + let mut active = Vec::new(&env); for i in 1..=count { if let Some(proposal) = get_proposal(env.clone(), i) { @@ -281,6 +275,6 @@ pub fn get_active_proposals(env: Env) -> Vec { } } } - + active } diff --git a/src/kani_proofs.rs b/src/kani_proofs.rs new file mode 100644 index 00000000..4ccc58eb --- /dev/null +++ b/src/kani_proofs.rs @@ -0,0 +1,589 @@ +#![cfg(kani)] + +// ============================================================================ +// Formal Verification Harnesses — Kani Rust Model Checker +// +// Pure-logic models of core contract invariants. No Soroban dependencies; +// every function is self-contained so Kani can symbolically execute it. +// ============================================================================ + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 1: Fee Calculation Invariant +// ═══════════════════════════════════════════════════════════════════════════ +// +// contract fee = reward * platform_fee_bps / 10000 +// payout = reward - fee +// Invariant: fee + payout == reward (no rounding loss) + +fn pure_fee_calculation(reward: i128, bps: u32) -> (i128, i128) { + let fee = reward * bps as i128 / 10000; + let payout = reward - fee; + (fee, payout) +} + +#[kani::proof] +fn verify_fee_invariant() { + let reward: i128 = kani::any(); + let bps: u32 = kani::any(); + + kani::assume(bps <= 1000); + kani::assume(reward > 0); + kani::assume(reward.checked_mul(bps as i128).is_some()); + + let (fee, payout) = pure_fee_calculation(reward, bps); + + assert!(fee >= 0); + assert!(payout >= 0); + assert!(payout <= reward); + assert_eq!(fee + payout, reward); + assert_eq!(payout, reward - fee); +} + +#[kani::proof] +fn verify_fee_edge_cases() { + let (fee, payout) = pure_fee_calculation(0, 500); + assert_eq!(fee, 0); + assert_eq!(payout, 0); + + let (fee, payout) = pure_fee_calculation(1000, 0); + assert_eq!(fee, 0); + assert_eq!(payout, 1000); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 2: Vault Deposit Invariant +// ═══════════════════════════════════════════════════════════════════════════ +// +// On deposit: +// vault_total' = vault_total + amount +// dep_balance' = dep_balance + amount +// Invariant: vault_total' - vault_total == amount +// Invariant: vault_total' >= vault_total (monotonic) + +fn pure_vault_deposit( + vault_total: i128, + depositor_balance: i128, + amount: i128, +) -> Option<(i128, i128)> { + if amount <= 0 { + return None; + } + let new_vault = vault_total.checked_add(amount)?; + let new_dep = depositor_balance.checked_add(amount)?; + Some((new_vault, new_dep)) +} + +#[kani::proof] +fn verify_vault_deposit_no_overflow() { + let vault_total: i128 = kani::any(); + let dep_balance: i128 = kani::any(); + let amount: i128 = kani::any(); + + kani::assume(vault_total >= 0); + kani::assume(dep_balance >= 0); + kani::assume(amount > 0); + kani::assume(vault_total.checked_add(amount).is_some()); + kani::assume(dep_balance.checked_add(amount).is_some()); + + let result = pure_vault_deposit(vault_total, dep_balance, amount); + assert!(result.is_some()); + let (new_vault, new_dep) = result.unwrap(); + + assert!(new_vault >= 0); + assert!(new_dep >= 0); + assert!(new_vault >= vault_total); + assert!(new_dep >= dep_balance); + assert_eq!(new_vault - vault_total, amount); + assert_eq!(new_dep - dep_balance, amount); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 3: Vault Claim Invariant +// ═══════════════════════════════════════════════════════════════════════════ +// +// On claim: +// vault_total' = vault_total - amount +// dep_balance' = dep_balance - amount +// Invariant: vault_total' >= 0 AND dep_balance' >= 0 +// Invariant: vault_total - vault_total' == amount + +fn pure_vault_claim( + vault_total: i128, + depositor_balance: i128, + amount: i128, +) -> Option<(i128, i128)> { + if amount <= 0 || depositor_balance < amount || vault_total < amount { + return None; + } + let new_vault = vault_total.checked_sub(amount)?; + let new_dep = depositor_balance.checked_sub(amount)?; + Some((new_vault, new_dep)) +} + +#[kani::proof] +fn verify_vault_claim_no_underflow() { + let vault_total: i128 = kani::any(); + let dep_balance: i128 = kani::any(); + let amount: i128 = kani::any(); + + kani::assume(vault_total >= 0); + kani::assume(dep_balance >= 0); + kani::assume(amount > 0); + kani::assume(dep_balance >= amount); + kani::assume(vault_total >= amount); + + let result = pure_vault_claim(vault_total, dep_balance, amount); + assert!(result.is_some()); + let (new_vault, new_dep) = result.unwrap(); + + assert!(new_vault >= 0); + assert!(new_dep >= 0); + assert_eq!(vault_total - new_vault, amount); + assert_eq!(dep_balance - new_dep, amount); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 4: Escrow Release Safety +// ═══════════════════════════════════════════════════════════════════════════ +// +// release_escrow requires current >= amount. +// Invariant: new_balance >= 0 +// Invariant: new_balance <= balance + +fn pure_escrow_release(balance: i128, amount: i128) -> Option { + if amount < 0 || balance < amount { + return None; + } + balance.checked_sub(amount) +} + +#[kani::proof] +fn verify_escrow_release_no_underflow() { + let balance: i128 = kani::any(); + let amount: i128 = kani::any(); + + kani::assume(balance >= 0); + kani::assume(amount >= 0); + kani::assume(balance >= amount); + + let result = pure_escrow_release(balance, amount); + assert!(result.is_some()); + let new_balance = result.unwrap(); + + assert!(new_balance >= 0); + assert!(new_balance <= balance); + assert_eq!(balance - new_balance, amount); +} + +#[kani::proof] +fn verify_escrow_release_rejects_insufficient() { + let balance: i128 = kani::any(); + let amount: i128 = kani::any(); + + kani::assume(balance >= 0); + kani::assume(amount >= 0); + kani::assume(balance < amount); + + let result = pure_escrow_release(balance, amount); + assert!(result.is_none()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 5: Escrow Lock Monotonicity +// ═══════════════════════════════════════════════════════════════════════════ +// +// lock_escrow: balance' = balance + amount +// Invariant: balance' >= balance + +fn pure_escrow_lock(balance: i128, amount: i128) -> Option { + if amount <= 0 { + return None; + } + balance.checked_add(amount) +} + +#[kani::proof] +fn verify_escrow_lock_monotonic() { + let balance: i128 = kani::any(); + let amount: i128 = kani::any(); + + kani::assume(balance >= 0); + kani::assume(amount > 0); + kani::assume(balance.checked_add(amount).is_some()); + + let result = pure_escrow_lock(balance, amount); + assert!(result.is_some()); + let new_balance = result.unwrap(); + + assert!(new_balance >= balance); + assert_eq!(new_balance - balance, amount); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 6: Reputation Floor at Zero +// ═══════════════════════════════════════════════════════════════════════════ +// +// current = (current + points).max(0) +// Invariant: result >= 0 ALWAYS + +fn pure_reputation_update(current: i32, points: i32) -> i32 { + (current + points).max(0) +} + +#[kani::proof] +fn verify_reputation_never_negative() { + let current: i32 = kani::any(); + let points: i32 = kani::any(); + + kani::assume(current >= 0 && current <= 10_000); + kani::assume(points >= -1000 && points <= 1000); + + let new_points = pure_reputation_update(current, points); + + assert!(new_points >= 0); + if current + points >= 0 { + assert_eq!(new_points, current + points); + } else { + assert_eq!(new_points, 0); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 7: Reputation Saturating Arithmetic +// ═══════════════════════════════════════════════════════════════════════════ +// +// user_profile uses saturating_add / saturating_sub. +// Invariant: result never overflows or underflows i128. + +fn pure_saturating_add(a: i128, b: i128) -> i128 { + a.saturating_add(b) +} + +fn pure_saturating_sub(a: i128, b: i128) -> i128 { + a.saturating_sub(b) +} + +#[kani::proof] +fn verify_saturating_arithmetic_bounds() { + let a: i128 = kani::any(); + let b: i128 = kani::any(); + + let sum = pure_saturating_add(a, b); + let diff = pure_saturating_sub(a, b); + + if a.checked_add(b).is_some() { + assert_eq!(sum, a + b); + } else { + assert!((b > 0 && sum == i128::MAX) || (b < 0 && sum == i128::MIN)); + } + + if a.checked_sub(b).is_some() { + assert_eq!(diff, a - b); + } else { + assert!(diff == i128::MAX || diff == i128::MIN); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 8: Oracle Price Computation +// ═══════════════════════════════════════════════════════════════════════════ +// +// expected_out = amount_in * price_in / price_out +// min_out = expected_out * (10000 - slippage) / 10000 +// Invariant: min_out >= 0 AND min_out <= expected_out + +const ORACLE_DECIMALS: i128 = 10_000_000; +const BPS_DENOM: i128 = 10_000; + +fn pure_oracle_min_out( + amount_in: i128, + price_in: i128, + price_out: i128, + slippage_bps: u32, +) -> Option { + let expected = amount_in.checked_mul(price_in)? / price_out; + let min_out = expected.checked_mul(BPS_DENOM - slippage_bps as i128)? / BPS_DENOM; + Some(min_out) +} + +fn pure_oracle_min_out_i64( + amount_in: i64, + price_in: i64, + price_out: i64, + slippage_bps: u32, +) -> Option { + let expected = amount_in.checked_mul(price_in)? / price_out; + let min_out = expected.checked_mul(BPS_DENOM as i64 - slippage_bps as i64)? / BPS_DENOM as i64; + Some(min_out) +} + +#[kani::proof] +fn verify_oracle_min_out_no_overflow() { + let amount_in: i64 = kani::any(); + let price_in: i64 = kani::any(); + let price_out: i64 = kani::any(); + let slippage_bps: u32 = kani::any(); + + kani::assume(amount_in > 0 && amount_in <= 10_000_000_000); + kani::assume(price_in >= 1 && price_in <= 10_000_000); + kani::assume(price_out >= 1 && price_out <= 10_000_000); + kani::assume(slippage_bps <= 5000); + kani::assume(amount_in.checked_mul(price_in).is_some()); + + if let Some(min_out) = pure_oracle_min_out_i64(amount_in, price_in, price_out, slippage_bps) { + assert!(min_out >= 0); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 9: Swap Router Slippage Guard +// ═══════════════════════════════════════════════════════════════════════════ +// +// In convert_incoming_deposit: +// min_out = expected_out * (BPS_DENOM - slippage) / BPS_DENOM +// If pool output < min_out, the call traps (panic). +// Invariant: 0 <= slippage <= BPS_DENOM +// Invariant: min_out <= expected_out + +fn pure_slippage_guard(expected_out: i128, slippage_bps: u32) -> Option { + if slippage_bps > BPS_DENOM as u32 { + return None; + } + let min_out = expected_out.checked_mul(BPS_DENOM - slippage_bps as i128)? / BPS_DENOM; + Some(min_out) +} + +#[kani::proof] +fn verify_slippage_guard_bounds() { + let expected_out: i128 = kani::any(); + let slippage_bps: u32 = kani::any(); + + kani::assume(expected_out >= 0 && expected_out <= 10_000_000_000); + kani::assume(slippage_bps <= 10_000); + + if let Some(min_out) = pure_slippage_guard(expected_out, slippage_bps) { + assert!(min_out >= 0); + assert!(slippage_bps == 0 || expected_out >= min_out); + if slippage_bps == 0 { + assert_eq!(min_out, expected_out); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 10: Milestone State Machine +// ═══════════════════════════════════════════════════════════════════════════ +// +// Valid transitions: +// Pending → Submitted (submit) +// Submitted → Approved (approve) +// Submitted → Rejected (reject) +// Rejected → Submitted (re-submit) +// +// Invalid transitions must be rejected (return None). + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum MsState { + Pending, + Submitted, + Approved, + Rejected, +} + +fn ms_submit(state: MsState) -> Option { + match state { + MsState::Pending | MsState::Rejected => Some(MsState::Submitted), + _ => None, + } +} + +fn ms_approve(state: MsState) -> Option { + match state { + MsState::Submitted => Some(MsState::Approved), + _ => None, + } +} + +fn ms_reject(state: MsState) -> Option { + match state { + MsState::Submitted => Some(MsState::Rejected), + _ => None, + } +} + +#[kani::proof] +fn verify_milestone_transitions_exhaustive() { + let variant: u8 = kani::any(); + kani::assume(variant < 4); + let state = match variant { + 0 => MsState::Pending, + 1 => MsState::Submitted, + 2 => MsState::Approved, + _ => MsState::Rejected, + }; + + let after_submit = ms_submit(state); + let after_approve = ms_approve(state); + let after_reject = ms_reject(state); + + match state { + MsState::Pending | MsState::Rejected => { + assert!(after_submit.is_some()); + assert_eq!(after_submit.unwrap(), MsState::Submitted); + } + _ => { + assert!(after_submit.is_none()); + } + } + + match state { + MsState::Submitted => { + assert!(after_approve.is_some()); + assert_eq!(after_approve.unwrap(), MsState::Approved); + assert!(after_reject.is_some()); + assert_eq!(after_reject.unwrap(), MsState::Rejected); + } + _ => { + assert!(after_approve.is_none()); + assert!(after_reject.is_none()); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 11: Escrow Stats Tracking Invariant +// ═══════════════════════════════════════════════════════════════════════════ +// +// Invariant: total_locked >= total_released + total_refunded +// Invariant: all stats non-negative + +fn pure_escrow_stats_invariant( + total_locked: i128, + total_released: i128, + total_refunded: i128, +) -> bool { + total_locked >= 0 + && total_released >= 0 + && total_refunded >= 0 + && total_locked >= total_released + total_refunded +} + +#[kani::proof] +fn verify_escrow_stats_after_lock() { + let locked: i128 = kani::any(); + kani::assume(locked > 0 && locked < 1_000_000_000); + assert!(pure_escrow_stats_invariant(locked, 0, 0)); +} + +#[kani::proof] +fn verify_escrow_stats_after_release() { + let locked: i128 = kani::any(); + kani::assume(locked > 0 && locked < 1_000_000_000); + let released: i128 = kani::any(); + kani::assume(released >= 0 && released <= locked); + assert!(pure_escrow_stats_invariant(locked, released, 0)); +} + +#[kani::proof] +fn verify_escrow_stats_after_refund() { + let locked: i128 = kani::any(); + kani::assume(locked > 0 && locked < 1_000_000_000); + let released: i128 = kani::any(); + kani::assume(released >= 0 && released <= locked); + let refunded: i128 = kani::any(); + kani::assume(refunded >= 0 && refunded <= locked - released); + assert!(pure_escrow_stats_invariant(locked, released, refunded)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 12: Dispute Resolution Split +// ═══════════════════════════════════════════════════════════════════════════ +// +// resolve_dispute: creator_refund + assignee_payout == task.reward +// Invariant: no value creation or destruction + +fn pure_dispute_split(reward: i128, creator_refund: i128, assignee_payout: i128) -> bool { + creator_refund >= 0 && assignee_payout >= 0 && creator_refund + assignee_payout == reward +} + +#[kani::proof] +fn verify_dispute_split_invariant() { + let reward: i128 = kani::any(); + let creator_refund: i128 = kani::any(); + let assignee_payout: i128 = kani::any(); + + kani::assume(reward > 0 && reward < 1_000_000_000); + kani::assume(creator_refund >= 0); + kani::assume(assignee_payout >= 0); + kani::assume(creator_refund.checked_add(assignee_payout) == Some(reward)); + + assert!(pure_dispute_split(reward, creator_refund, assignee_payout)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 13: Governance Threshold Calculation +// ═══════════════════════════════════════════════════════════════════════════ +// +// for_percentage = (votes_for * 100) / non_abstain +// Passes when for_percentage >= threshold. +// Invariant: 0 <= for_percentage <= 100 when non_abstain > 0 + +fn pure_threshold(votes_for: u32, votes_against: u32) -> Option { + let non_abstain = votes_for + votes_against; + if non_abstain == 0 { + return None; + } + Some(votes_for * 100 / non_abstain) +} + +#[kani::proof] +fn verify_threshold_bounds() { + let votes_for: u32 = kani::any(); + let votes_against: u32 = kani::any(); + + kani::assume(votes_for <= 1_000_000); + kani::assume(votes_against <= 1_000_000); + + if let Some(pct) = pure_threshold(votes_for, votes_against) { + assert!(pct <= 100); + if votes_against == 0 { + assert_eq!(pct, 100); + } + if votes_for == 0 { + assert_eq!(pct, 0); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Proof 14: Swap Route Validation Bounds +// ═══════════════════════════════════════════════════════════════════════════ +// +// In validate_route: +// hops > 0 && path.len() == hops + 1 && hops <= max_hops +// Invariant: valid routes have matching path/pool lengths + +fn pure_validate_route(hops: usize, path_len: usize, max_hops: u32) -> bool { + if hops == 0 || hops > max_hops as usize { + return false; + } + hops.checked_add(1) + .map_or(false, |expected| path_len == expected) +} + +#[kani::proof] +fn verify_route_validation() { + let hops: usize = kani::any(); + let path_len: usize = kani::any(); + let max_hops: u32 = kani::any(); + + kani::assume(max_hops <= 10); + + let valid = pure_validate_route(hops, path_len, max_hops); + + if valid { + assert!(hops > 0); + assert_eq!(path_len, hops + 1); + assert!(hops <= max_hops as usize); + } +} diff --git a/src/lib.rs b/src/lib.rs index 92229bb7..59ef10e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,18 +4,21 @@ pub mod access_control; pub mod escrow; pub mod events; pub mod governance; +pub mod merkle; pub mod pausable; pub mod reputation; pub mod storage; pub mod swap_router; pub mod user_profile; pub mod vault; -pub mod merkle; -#[cfg(test)] -mod test; +#[cfg(kani)] +pub mod kani_proofs; + #[cfg(test)] mod swap_router_test; +#[cfg(test)] +mod test; use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; @@ -92,7 +95,7 @@ impl TaskManagerContract { // ======================================================================== // Initialization // ======================================================================== - + pub fn initialize( env: Env, admin: Address, @@ -103,22 +106,28 @@ impl TaskManagerContract { if env.storage().instance().has(&DataKey::Initialized) { panic!("already initialized"); } - + // Validate fee (max 10%) if platform_fee_bps > 1000 { panic!("platform fee cannot exceed 10%"); } - + env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::PlatformFeeBps, &platform_fee_bps); - env.storage().instance().set(&DataKey::TokenContract, &token_contract); - env.storage().instance().set(&DataKey::FeeRecipient, &fee_recipient); + env.storage() + .instance() + .set(&DataKey::PlatformFeeBps, &platform_fee_bps); + env.storage() + .instance() + .set(&DataKey::TokenContract, &token_contract); + env.storage() + .instance() + .set(&DataKey::FeeRecipient, &fee_recipient); env.storage().instance().set(&DataKey::Initialized, &true); env.storage().instance().set(&DataKey::TaskCount, &0u32); - + // Initialize reputation tiers reputation::init_reputation_tiers(env.clone()); - + // Initialize governance config env.storage().persistent().set( &governance::GovernanceKey::Config, @@ -135,7 +144,7 @@ impl TaskManagerContract { // ======================================================================== // Task Management // ======================================================================== - + pub fn create_task( env: Env, creator: Address, @@ -145,38 +154,40 @@ impl TaskManagerContract { tags: Vec, ) -> u32 { creator.require_auth(); - + // Check if paused pausable::require_not_paused( env.clone(), pausable::PauseAction::CreateTask, Some(creator.clone()), ); - + if reward <= 0 { panic!("reward must be positive"); } - + let token_contract: Address = env .storage() .instance() .get(&DataKey::TokenContract) .unwrap_or_else(|| panic!("not initialized")); - + // Transfer reward from creator to the contract let token_client = soroban_sdk::token::Client::new(&env, &token_contract); token_client.transfer(&creator, &env.current_contract_address(), &reward); - + let mut task_count: u32 = env .storage() .instance() .get(&DataKey::TaskCount) .unwrap_or(0); task_count += 1; - env.storage().instance().set(&DataKey::TaskCount, &task_count); - + env.storage() + .instance() + .set(&DataKey::TaskCount, &task_count); + let now = env.ledger().timestamp(); - + let task = Task { id: task_count, title: title.clone(), @@ -191,24 +202,26 @@ impl TaskManagerContract { created_at: now, updated_at: now, }; - - env.storage().instance().set(&DataKey::Task(task_count), &task); - + + env.storage() + .instance() + .set(&DataKey::Task(task_count), &task); + // Lock escrow escrow::lock_escrow(env.clone(), task_count, reward); - + // Emit event events::emit_task_created(&env, task_count, creator, title, reward); - + // Update statistics storage::update_statistics(&env, |stats| { stats.total_tasks_created += 1; stats.total_value_locked += reward; }); - + task_count } - + pub fn create_task_with_milestones( env: Env, creator: Address, @@ -218,18 +231,18 @@ impl TaskManagerContract { tags: Vec, ) -> u32 { creator.require_auth(); - + // Calculate total reward from milestones let mut total_reward: i128 = 0; for i in 0..milestones.len() { let milestone = milestones.get(i).unwrap(); total_reward += milestone.1; } - + if total_reward <= 0 { panic!("total milestone amount must be positive"); } - + // Create the task let task_id = Self::create_task( env.clone(), @@ -239,134 +252,139 @@ impl TaskManagerContract { total_reward, tags, ); - + // Create milestones for (milestone_title, amount) in milestones.iter() { - escrow::create_milestone( - env.clone(), - task_id, - milestone_title.clone(), - amount, - None, - ); + escrow::create_milestone(env.clone(), task_id, milestone_title.clone(), amount, None); } - + task_id } pub fn assign_task(env: Env, assignee: Address, task_id: u32) { assignee.require_auth(); - + pausable::require_not_paused( env.clone(), pausable::PauseAction::AssignTask, Some(assignee.clone()), ); - + let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if task.status != TaskStatus::Open { panic!("task is not open"); } - + task.assignee = Some(assignee.clone()); task.status = TaskStatus::InProgress; task.updated_at = env.ledger().timestamp(); - + env.storage().instance().set(&DataKey::Task(task_id), &task); - + events::emit_task_assigned(&env, task_id, assignee); } pub fn submit_work(env: Env, assignee: Address, task_id: u32, delivery_url: String) { assignee.require_auth(); - + pausable::require_not_paused( env.clone(), pausable::PauseAction::SubmitWork, Some(assignee.clone()), ); - + let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if task.assignee.as_ref() != Some(&assignee) { panic!("caller is not the assignee"); } - + if task.status != TaskStatus::InProgress { panic!("task is not in progress"); } - + task.status = TaskStatus::Completed; task.updated_at = env.ledger().timestamp(); - + env.storage().instance().set(&DataKey::Task(task_id), &task); - + events::emit_task_submitted(&env, task_id, assignee, delivery_url); } pub fn complete_task(env: Env, caller: Address, task_id: u32) { caller.require_auth(); - + pausable::require_not_paused( env.clone(), pausable::PauseAction::CompleteTask, Some(caller.clone()), ); - + let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if task.status != TaskStatus::Completed { panic!("task is not completed"); } - + // Verify caller is creator or admin let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); if caller != task.created_by && caller != admin { panic!("not authorized to complete task"); } - + let platform_fee_bps: u32 = env .storage() .instance() .get(&DataKey::PlatformFeeBps) .unwrap_or(0); - let fee_recipient: Address = env.storage().instance().get(&DataKey::FeeRecipient).unwrap(); - let token_contract: Address = env.storage().instance().get(&DataKey::TokenContract).unwrap(); - + let fee_recipient: Address = env + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .unwrap(); + let token_contract: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); + let fee = (task.reward * platform_fee_bps as i128) / 10000; let payout = task.reward - fee; - + let token_client = soroban_sdk::token::Client::new(&env, &token_contract); - - let assignee = task.assignee.clone().unwrap_or_else(|| panic!("no assignee")); - + + let assignee = task + .assignee + .clone() + .unwrap_or_else(|| panic!("no assignee")); + if fee > 0 { token_client.transfer(&env.current_contract_address(), &fee_recipient, &fee); } if payout > 0 { token_client.transfer(&env.current_contract_address(), &assignee, &payout); } - + // Release escrow escrow::release_escrow(env.clone(), task_id, task.reward); - + task.status = TaskStatus::Verified; task.updated_at = env.ledger().timestamp(); env.storage().instance().set(&DataKey::Task(task_id), &task); - + // Award reputation reputation::award_reputation( env.clone(), @@ -376,9 +394,9 @@ impl TaskManagerContract { Some(task_id), String::from_str(&env, "Task verified and completed"), ); - + events::emit_task_completed(&env, task_id, assignee, payout, fee); - + // Update statistics storage::update_statistics(&env, |stats| { stats.total_tasks_completed += 1; @@ -389,40 +407,44 @@ impl TaskManagerContract { pub fn cancel_task(env: Env, creator: Address, task_id: u32) { creator.require_auth(); - + pausable::require_not_paused( env.clone(), pausable::PauseAction::CancelTask, Some(creator.clone()), ); - + let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if task.created_by != creator { panic!("not task creator"); } - + if task.status != TaskStatus::Open { panic!("task is not open"); } - - let token_contract: Address = env.storage().instance().get(&DataKey::TokenContract).unwrap(); + + let token_contract: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); token_client.transfer(&env.current_contract_address(), &creator, &task.reward); - + // Release escrow (refund) escrow::release_escrow(env.clone(), task_id, task.reward); - + task.status = TaskStatus::Cancelled; task.updated_at = env.ledger().timestamp(); env.storage().instance().set(&DataKey::Task(task_id), &task); - + events::emit_task_cancelled(&env, task_id, creator, task.reward); - + // Update statistics storage::update_statistics(&env, |stats| { stats.total_tasks_cancelled += 1; @@ -431,33 +453,33 @@ impl TaskManagerContract { pub fn dispute_task(env: Env, caller: Address, task_id: u32) { caller.require_auth(); - + pausable::require_not_paused( env.clone(), pausable::PauseAction::DisputeTask, Some(caller.clone()), ); - + let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if caller != task.created_by && Some(&caller) != task.assignee.as_ref() { panic!("not authorized to dispute task"); } - + if task.status != TaskStatus::InProgress && task.status != TaskStatus::Completed { panic!("task status cannot be disputed"); } - + task.status = TaskStatus::Disputed; task.updated_at = env.ledger().timestamp(); env.storage().instance().set(&DataKey::Task(task_id), &task); - + events::emit_task_disputed(&env, task_id, caller); - + // Update statistics storage::update_statistics(&env, |stats| { stats.total_tasks_disputed += 1; @@ -472,36 +494,47 @@ impl TaskManagerContract { assignee_payout: i128, ) { admin.require_auth(); - + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); if admin != stored_admin { panic!("not admin"); } - + let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if task.status != TaskStatus::Disputed { panic!("task is not disputed"); } - + if creator_refund + assignee_payout != task.reward { panic!("invalid split totals"); } - - let token_contract: Address = env.storage().instance().get(&DataKey::TokenContract).unwrap(); + + let token_contract: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); - + if creator_refund > 0 { - token_client.transfer(&env.current_contract_address(), &task.created_by, &creator_refund); + token_client.transfer( + &env.current_contract_address(), + &task.created_by, + &creator_refund, + ); } if assignee_payout > 0 { - let assignee = task.assignee.clone().unwrap_or_else(|| panic!("no assignee")); + let assignee = task + .assignee + .clone() + .unwrap_or_else(|| panic!("no assignee")); token_client.transfer(&env.current_contract_address(), &assignee, &assignee_payout); - + // Award reputation for winning dispute reputation::award_reputation( env.clone(), @@ -512,21 +545,21 @@ impl TaskManagerContract { String::from_str(&env, "Won dispute"), ); } - + // Release escrow escrow::release_escrow(env.clone(), task_id, task.reward); - + task.status = TaskStatus::Resolved; task.updated_at = env.ledger().timestamp(); env.storage().instance().set(&DataKey::Task(task_id), &task); - + events::emit_dispute_resolved(&env, task_id, creator_refund, assignee_payout); } - + // ======================================================================== // Milestone Management // ======================================================================== - + pub fn submit_milestone( env: Env, assignee: Address, @@ -535,23 +568,23 @@ impl TaskManagerContract { submission_url: String, ) { assignee.require_auth(); - + // Verify assignee let task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + if task.assignee.as_ref() != Some(&assignee) { panic!("not the assignee"); } - + escrow::submit_milestone(env.clone(), task_id, milestone_id, submission_url); - + events::emit_milestone_submitted(&env, task_id, milestone_id, assignee); } - + pub fn approve_milestone( env: Env, caller: Address, @@ -560,30 +593,35 @@ impl TaskManagerContract { feedback: Option, ) { caller.require_auth(); - + let task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + // Only creator or admin can approve let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); if caller != task.created_by && caller != admin { panic!("not authorized"); } - - let amount = escrow::approve_milestone(env.clone(), task_id, milestone_id, feedback.clone()); - + + let amount = + escrow::approve_milestone(env.clone(), task_id, milestone_id, feedback.clone()); + // Transfer milestone payment to assignee - let token_contract: Address = env.storage().instance().get(&DataKey::TokenContract).unwrap(); + let token_contract: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); let assignee = task.assignee.clone().unwrap(); - + token_client.transfer(&env.current_contract_address(), &assignee, &amount); - + events::emit_milestone_approved(&env, task_id, milestone_id, amount); - + // Award reputation reputation::award_reputation( env.clone(), @@ -594,7 +632,7 @@ impl TaskManagerContract { String::from_str(&env, "Milestone approved"), ); } - + pub fn reject_milestone( env: Env, caller: Address, @@ -603,24 +641,24 @@ impl TaskManagerContract { feedback: String, ) { caller.require_auth(); - + let task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) .unwrap_or_else(|| panic!("task not found")); - + // Only creator or admin can reject let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); if caller != task.created_by && caller != admin { panic!("not authorized"); } - + escrow::reject_milestone(env.clone(), task_id, milestone_id, feedback.clone()); - + events::emit_milestone_rejected(&env, task_id, milestone_id, feedback); } - + pub fn get_milestones(env: Env, task_id: u32) -> Vec { escrow::get_milestones_for_task(env, task_id) } @@ -641,27 +679,27 @@ impl TaskManagerContract { pub fn reward_contribution(env: Env, admin: Address, user: Address, points: u32) { user_profile::reward_contribution(env.clone(), admin, user.clone(), points); - + let new_total = reputation::get_user_reputation(env.clone(), user.clone()); events::emit_reputation_awarded(&env, user, points, new_total); } - + pub fn get_profile(env: Env, user: Address) -> Option { user_profile::get_profile(env, user) } - + // ======================================================================== // Reputation System // ======================================================================== - + pub fn get_user_reputation(env: Env, user: Address) -> u32 { reputation::get_user_reputation(env, user) } - + pub fn get_user_tier(env: Env, user: Address) -> String { reputation::get_user_tier(env, user) } - + pub fn get_leaderboard(env: Env) -> Vec<(Address, u32)> { reputation::get_leaderboard(env) } @@ -669,23 +707,23 @@ impl TaskManagerContract { // ======================================================================== // Access Control // ======================================================================== - + pub fn grant_role(env: Env, admin: Address, user: Address, role: access_control::Role) { access_control::grant_role(env.clone(), admin.clone(), user.clone(), role.clone()); events::emit_role_granted(&env, user, format_role(&env, &role), admin); } - + pub fn revoke_role(env: Env, admin: Address, user: Address) { let role_data = access_control::get_role(env.clone(), user.clone()); let role_name = role_data .as_ref() .map(|r| format_role(&env, &r.role)) .unwrap_or_else(|| String::from_str(&env, "none")); - + access_control::revoke_role(env.clone(), admin.clone(), user.clone()); events::emit_role_revoked(&env, user, role_name, admin); } - + pub fn has_role(env: Env, user: Address, role: access_control::Role) -> bool { access_control::has_role(env, user, role) } @@ -693,15 +731,10 @@ impl TaskManagerContract { // ======================================================================== // Governance // ======================================================================== - - pub fn create_proposal( - env: Env, - proposer: Address, - title: String, - description: String, - ) -> u32 { + + pub fn create_proposal(env: Env, proposer: Address, title: String, description: String) -> u32 { let config = governance::get_config(env.clone()); - + let proposal_id = governance::create_proposal( env.clone(), proposer.clone(), @@ -711,46 +744,35 @@ impl TaskManagerContract { Some(config.threshold), config.min_reputation_to_propose, ); - + events::emit_proposal_created(&env, proposal_id, proposer, title); proposal_id } - - pub fn cast_vote( - env: Env, - voter: Address, - proposal_id: u32, - vote_type: governance::VoteType, - ) { + + pub fn cast_vote(env: Env, voter: Address, proposal_id: u32, vote_type: governance::VoteType) { let weight = reputation::get_user_reputation(env.clone(), voter.clone()); - - governance::cast_vote( - env.clone(), - voter.clone(), - proposal_id, - vote_type, - weight, - ); - + + governance::cast_vote(env.clone(), voter.clone(), proposal_id, vote_type, weight); + let vote_str = match vote_type { governance::VoteType::For => String::from_str(&env, "for"), governance::VoteType::Against => String::from_str(&env, "against"), governance::VoteType::Abstain => String::from_str(&env, "abstain"), }; - + events::emit_vote_cast(&env, proposal_id, voter, vote_str, weight); } - + pub fn execute_proposal(env: Env, caller: Address, proposal_id: u32) -> bool { let passed = governance::execute_proposal(env.clone(), caller, proposal_id); events::emit_proposal_executed(&env, proposal_id, passed); passed } - + pub fn get_proposal(env: Env, proposal_id: u32) -> Option { governance::get_proposal(env, proposal_id) } - + pub fn get_active_proposals(env: Env) -> Vec { governance::get_active_proposals(env) } @@ -758,22 +780,22 @@ impl TaskManagerContract { // ======================================================================== // Pause Control // ======================================================================== - + pub fn pause(env: Env, admin: Address, action: pausable::PauseAction) { pausable::pause(env.clone(), admin.clone(), action); events::emit_paused(&env, format_pause_action(&env, action), admin); } - + pub fn unpause(env: Env, admin: Address, action: pausable::PauseAction) { pausable::unpause(env.clone(), admin.clone(), action); events::emit_unpaused(&env, format_pause_action(&env, action), admin); } - + pub fn pause_all(env: Env, admin: Address) { pausable::pause_all(env.clone(), admin.clone()); events::emit_paused(&env, String::from_str(&env, "all"), admin); } - + pub fn unpause_all(env: Env, admin: Address) { pausable::unpause_all(env.clone(), admin.clone()); events::emit_unpaused(&env, String::from_str(&env, "all"), admin); @@ -790,7 +812,13 @@ impl TaskManagerContract { max_hops: u32, default_slippage_bps: u32, ) { - swap_router::configure(env.clone(), admin.clone(), oracle.clone(), max_hops, default_slippage_bps); + swap_router::configure( + env.clone(), + admin.clone(), + oracle.clone(), + max_hops, + default_slippage_bps, + ); events::emit_router_configured(&env, admin, oracle, max_hops, default_slippage_bps); } @@ -835,7 +863,14 @@ impl TaskManagerContract { match &outcome { swap_router::ConversionOutcome::Converted(token_out, amount_out) => { - events::emit_swap_executed(&env, sender, token_in, token_out.clone(), amount_in, *amount_out); + events::emit_swap_executed( + &env, + sender, + token_in, + token_out.clone(), + amount_in, + *amount_out, + ); } swap_router::ConversionOutcome::Refunded(reason) => { events::emit_swap_refunded(&env, sender, token_in, amount_in, reason.clone()); @@ -864,11 +899,11 @@ impl TaskManagerContract { pub fn get_task(env: Env, task_id: u32) -> Option { env.storage().instance().get(&DataKey::Task(task_id)) } - + pub fn get_escrow_stats(env: Env) -> escrow::EscrowStats { escrow::get_escrow_stats(env) } - + pub fn get_statistics(env: Env) -> storage::ContractStatistics { storage::get_statistics(&env) } @@ -946,19 +981,24 @@ impl TaskManagerContract { // Merkle Payroll (Vault) // ======================================================================== - pub fn set_payroll_root(env: Env, admin: Address, payroll_id: u32, root: soroban_sdk::BytesN<32>) { + pub fn set_payroll_root( + env: Env, + admin: Address, + payroll_id: u32, + root: soroban_sdk::BytesN<32>, + ) { admin.require_auth(); - + let stored_admin: Address = env .storage() .instance() .get(&DataKey::Admin) .unwrap_or_else(|| panic!("not initialized")); - + if admin != stored_admin { panic!("only admin can set payroll root"); } - + vault::set_payroll_root(&env, payroll_id, root); } diff --git a/src/pausable.rs b/src/pausable.rs index a827e7b1..c5a567ec 100644 --- a/src/pausable.rs +++ b/src/pausable.rs @@ -1,5 +1,5 @@ -use soroban_sdk::{contracttype, Address, Env}; use crate::DataKey; +use soroban_sdk::{contracttype, Address, Env}; /// Granular pause/unpause system for the LatterFix contract. /// diff --git a/src/reputation.rs b/src/reputation.rs index 8032b1c9..86287671 100644 --- a/src/reputation.rs +++ b/src/reputation.rs @@ -93,7 +93,7 @@ pub fn init_reputation_tiers(env: Env) { }); v }; - + env.storage() .persistent() .set(&ReputationKey::ReputationTiers, &tiers); @@ -108,19 +108,13 @@ pub fn award_reputation( description: String, ) { let key = ReputationKey::UserPoints(user.clone()); - let mut current: i32 = env - .storage() - .persistent() - .get(&key) - .unwrap_or(100i32); // Starting reputation - + let mut current: i32 = env.storage().persistent().get(&key).unwrap_or(100i32); // Starting reputation + current = (current + points).max(0); // Floor at 0 - env.storage() - .persistent() - .set(&key, ¤t); - + env.storage().persistent().set(&key, ¤t); + // Record the event - let event = ReputationEvent { + let _event = ReputationEvent { user: user.clone(), points, event_type, @@ -128,27 +122,30 @@ pub fn award_reputation( reference_id, description, }; - + let event_count_key = ReputationKey::EventCount(user.clone()); - let mut event_count: u32 = env.storage().persistent().get(&event_count_key).unwrap_or(0); + let mut event_count: u32 = env + .storage() + .persistent() + .get(&event_count_key) + .unwrap_or(0); event_count += 1; - env.storage().persistent().set(&event_count_key, &event_count); - + env.storage() + .persistent() + .set(&event_count_key, &event_count); + // Update leaderboard update_leaderboard(&env, user, current as u32); } pub fn get_user_reputation(env: Env, user: Address) -> u32 { let key = ReputationKey::UserPoints(user); - env.storage() - .persistent() - .get(&key) - .unwrap_or(100) as u32 + env.storage().persistent().get(&key).unwrap_or(100) as u32 } pub fn get_user_tier(env: Env, user: Address) -> String { let points = get_user_reputation(env.clone(), user); - + let tiers: Vec = env .storage() .persistent() @@ -161,13 +158,13 @@ pub fn get_user_tier(env: Env, user: Address) -> String { .get(&ReputationKey::ReputationTiers) .unwrap() }); - + for tier in tiers.iter() { if points >= tier.min_points && points <= tier.max_points { return tier.name; } } - + String::from_str(&env, "Unknown") } @@ -178,7 +175,7 @@ pub fn update_leaderboard(env: &Env, user: Address, points: u32) { .persistent() .get(&key) .unwrap_or_else(|| Map::new(env)); - + leaderboard.set(user, points); env.storage().persistent().set(&key, &leaderboard); } @@ -190,12 +187,12 @@ pub fn get_leaderboard(env: Env) -> Vec<(Address, u32)> { .persistent() .get(&key) .unwrap_or_else(|| Map::new(&env)); - + let mut result = Vec::new(&env); for (user, points) in leaderboard.iter() { result.push_back((user, points)); } - + result } diff --git a/src/storage.rs b/src/storage.rs index a8e98469..2f81dab4 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,17 +1,16 @@ -use soroban_sdk::{contracttype, Env, String, Vec}; +//! Storage helper module for the LatterFix TaskManager contract. +//! +//! Centralises all persistent storage keys, TTL management, and statistic +//! tracking so that every module reads/writes through a single typed interface. +//! +//! Storage tiers used in this contract: +//! - `persistent()` — survives ledger archival; requires TTL extension +//! - `temporary()` — cheap, auto-expires after TTL; used for nonces/sessions +//! - `instance()` — scoped to the contract instance; used for admin config -/// Storage helper module for the LatterFix TaskManager contract. -/// -/// Centralises all persistent storage keys, TTL management, and statistic -/// tracking so that every module reads/writes through a single typed interface. -/// -/// Storage tiers used in this contract: -/// - `persistent()` — survives ledger archival; requires TTL extension -/// - `temporary()` — cheap, auto-expires after TTL; used for nonces/sessions -/// - `instance()` — scoped to the contract instance; used for admin config +use soroban_sdk::{contracttype, Env, String, Vec}; // ── TTL Constants ────────────────────────────────────────────────────────── - /// Maximum persistent TTL: ~31 days at 5-second ledger close time. pub const MAX_PERSISTENT_TTL: u32 = 5_200_000; @@ -49,12 +48,8 @@ pub fn calculate_ttl(_env: &Env, is_permanent: bool) -> u32 { /// * `key` — the storage key to extend /// * `threshold` — minimum remaining ledgers before extension triggers /// * `extend_to` — target TTL to extend to (in ledgers) -pub fn extend_persistent_ttl( - env: &Env, - key: &K, - threshold: u32, - extend_to: u32, -) where +pub fn extend_persistent_ttl(env: &Env, key: &K, threshold: u32, extend_to: u32) +where K: soroban_sdk::IntoVal, { env.storage() @@ -106,7 +101,7 @@ pub fn add_category(env: &Env, name: String, description: String) -> u32 { .get(&StorageKey::Categories) .unwrap_or_else(|| Vec::new(env)); - let id = (categories.len() as u32) + 1; + let id = (categories.len()) + 1; categories.push_back(Category { id, diff --git a/src/swap_router.rs b/src/swap_router.rs index 058f95b8..b64f2124 100644 --- a/src/swap_router.rs +++ b/src/swap_router.rs @@ -1,32 +1,33 @@ +//! Multi-asset escrow swap router. +//! +//! Converts arbitrary incoming SAC tokens into an approved vault stablecoin +//! (e.g. USDC/ORGUSD) through one or more DEX pool hops, guarded by an +//! oracle-derived minimum-return check so the conversion cannot be pushed +//! through a manipulated pool price. +//! +//! Pool contracts are expected to expose the `PoolClient` interface — a +//! generic two-asset AMM pool that receives its input token via a direct +//! `transfer` (mirroring the Uniswap V2 / Soroswap pair pattern: the router +//! sends tokens to the pool, then calls `swap`, which pays the output out of +//! its own reserves). The oracle is expected to expose the `OracleClient` +//! interface — a single `price()` entry point returning the asset price +//! scaled by `ORACLE_PRICE_DECIMALS`, mirroring the Reflector oracle's +//! `lastprice`. +//! +//! Route resolution (path validity, approved destination, oracle pricing) is +//! fully checked *before* any tokens are pulled from the sender, so an +//! unresolved route never touches the sender's balance. If the pools +//! themselves fail to deliver the oracle-guarded minimum return, the whole +//! call traps and the host transaction reverts — including the initial pull +//! — which is the standard, atomic "refund" pattern used by production DEX +//! routers. + use soroban_sdk::{contractclient, contracttype, Address, Env, String, Vec}; use crate::DataKey; -/// Multi-asset escrow swap router. -/// -/// Converts arbitrary incoming SAC tokens into an approved vault stablecoin -/// (e.g. USDC/ORGUSD) through one or more DEX pool hops, guarded by an -/// oracle-derived minimum-return check so the conversion cannot be pushed -/// through a manipulated pool price. -/// -/// Pool contracts are expected to expose the `PoolClient` interface — a -/// generic two-asset AMM pool that receives its input token via a direct -/// `transfer` (mirroring the Uniswap V2 / Soroswap pair pattern: the router -/// sends tokens to the pool, then calls `swap`, which pays the output out of -/// its own reserves). The oracle is expected to expose the `OracleClient` -/// interface — a single `price()` entry point returning the asset price -/// scaled by `ORACLE_PRICE_DECIMALS`, mirroring the Reflector oracle's -/// `lastprice`. -/// -/// Route resolution (path validity, approved destination, oracle pricing) is -/// fully checked *before* any tokens are pulled from the sender, so an -/// unresolved route never touches the sender's balance. If the pools -/// themselves fail to deliver the oracle-guarded minimum return, the whole -/// call traps and the host transaction reverts — including the initial pull -/// — which is the standard, atomic "refund" pattern used by production DEX -/// routers. - pub const ORACLE_PRICE_DECIMALS: u32 = 7; + const BPS_DENOMINATOR: i128 = 10_000; // ============================================================================ @@ -139,7 +140,9 @@ pub fn configure( max_hops, default_slippage_bps, }; - env.storage().instance().set(&SwapRouterKey::Config, &config); + env.storage() + .instance() + .set(&SwapRouterKey::Config, &config); } pub fn get_config(env: Env) -> RouterConfig { @@ -224,14 +227,23 @@ fn execute_route(env: &Env, route: &SwapRoute, amount_in: i128, vault: &Address) let token_out = route.path.get(i + 1).unwrap(); let pool = route.pools.get(i).unwrap(); let is_last_hop = i + 1 == hops; - let hop_recipient = if is_last_hop { vault.clone() } else { this_contract.clone() }; + let hop_recipient = if is_last_hop { + vault.clone() + } else { + this_contract.clone() + }; // Uniswap-V2-style pattern: send the hop's input straight to the pool, // then invoke it — no approve/transferFrom dance required. - soroban_sdk::token::Client::new(env, &token_in).transfer(&this_contract, &pool, ¤t_amount); + soroban_sdk::token::Client::new(env, &token_in).transfer( + &this_contract, + &pool, + ¤t_amount, + ); let pool_client = PoolClient::new(env, &pool); - current_amount = pool_client.swap(¤t_amount, &0, &token_in, &token_out, &hop_recipient); + current_amount = + pool_client.swap(¤t_amount, &0, &token_in, &token_out, &hop_recipient); } current_amount @@ -291,7 +303,9 @@ fn update_stats(env: &Env, conversions_delta: u32, refunds_delta: u32, stablecoi stats.total_conversions += conversions_delta; stats.total_refunds += refunds_delta; stats.total_stablecoin_out += stablecoin_out_delta; - env.storage().persistent().set(&SwapRouterKey::Stats, &stats); + env.storage() + .persistent() + .set(&SwapRouterKey::Stats, &stats); } // ============================================================================ @@ -329,7 +343,10 @@ pub fn convert_incoming_deposit( if !validate_route(&env, &config, &token_in, &route) { update_stats(&env, 0, 1, 0); - return ConversionOutcome::Refunded(String::from_str(&env, "swap route could not be resolved")); + return ConversionOutcome::Refunded(String::from_str( + &env, + "swap route could not be resolved", + )); } let stablecoin_out = route.path.get(route.path.len() - 1).unwrap(); @@ -339,14 +356,20 @@ pub fn convert_incoming_deposit( Ok(Ok(Some(p))) if p > 0 => p, _ => { update_stats(&env, 0, 1, 0); - return ConversionOutcome::Refunded(String::from_str(&env, "no oracle price for input asset")); + return ConversionOutcome::Refunded(String::from_str( + &env, + "no oracle price for input asset", + )); } }; let price_out = match oracle.try_price(&stablecoin_out) { Ok(Ok(Some(p))) if p > 0 => p, _ => { update_stats(&env, 0, 1, 0); - return ConversionOutcome::Refunded(String::from_str(&env, "no oracle price for output asset")); + return ConversionOutcome::Refunded(String::from_str( + &env, + "no oracle price for output asset", + )); } }; diff --git a/src/swap_router_test.rs b/src/swap_router_test.rs index ae474a3f..35dcd91d 100644 --- a/src/swap_router_test.rs +++ b/src/swap_router_test.rs @@ -1,4 +1,5 @@ #![cfg(test)] +#![allow(deprecated)] use crate::swap_router::{ConversionOutcome, SwapRoute}; use crate::{TaskManagerContract, TaskManagerContractClient}; @@ -19,7 +20,9 @@ pub struct MockPool; #[contractimpl] impl MockPool { pub fn init(env: Env, rate_bps: i128) { - env.storage().instance().set(&symbol_short!("rate"), &rate_bps); + env.storage() + .instance() + .set(&symbol_short!("rate"), &rate_bps); } pub fn swap( @@ -30,7 +33,11 @@ impl MockPool { token_out: Address, to: Address, ) -> i128 { - let rate: i128 = env.storage().instance().get(&symbol_short!("rate")).unwrap_or(10_000); + let rate: i128 = env + .storage() + .instance() + .get(&symbol_short!("rate")) + .unwrap_or(10_000); let amount_out = amount_in * rate / 10_000; if amount_out < min_amount_out { @@ -60,7 +67,9 @@ pub struct MockOracle; #[contractimpl] impl MockOracle { pub fn set_price(env: Env, asset: Address, price: i128) { - env.storage().instance().set(&MockOracleKey::Price(asset), &price); + env.storage() + .instance() + .set(&MockOracleKey::Price(asset), &price); } pub fn price(env: Env, asset: Address) -> Option { @@ -72,7 +81,14 @@ impl MockOracle { const ONE: i128 = 10_000_000; // oracle price scale, 10^ORACLE_PRICE_DECIMALS -fn setup(env: &Env) -> (TaskManagerContractClient<'static>, Address, Address, Address) { +fn setup( + env: &Env, +) -> ( + TaskManagerContractClient<'static>, + Address, + Address, + Address, +) { let contract_id = env.register_contract(None, TaskManagerContract); let client = TaskManagerContractClient::new(env, &contract_id); @@ -189,7 +205,8 @@ fn test_multi_hop_swap_success() { let route = SwapRoute { path, pools }; // 1000 * 0.98 = 980; 980 * 0.98 = 960.4 -> 960 (integer division) - let outcome = client.convert_incoming_deposit(&sender, &token_in, &1_000, &route, &Some(500u32)); + let outcome = + client.convert_incoming_deposit(&sender, &token_in, &1_000, &route, &Some(500u32)); match outcome { ConversionOutcome::Converted(token_out, amount_out) => { @@ -235,7 +252,11 @@ fn test_refund_on_unresolved_route() { assert!(matches!(outcome, ConversionOutcome::Refunded(_))); let token_in_client = soroban_sdk::token::Client::new(&env, &token_in); - assert_eq!(token_in_client.balance(&sender), 1_000, "sender funds must never be pulled"); + assert_eq!( + token_in_client.balance(&sender), + 1_000, + "sender funds must never be pulled" + ); let stats = client.get_swap_router_stats(); assert_eq!(stats.total_refunds, 1); @@ -332,10 +353,16 @@ fn test_only_admin_can_configure_router() { let stablecoin = new_token(&env); let result = client.try_add_approved_stablecoin(¬_admin, &stablecoin); - assert!(result.is_err(), "non-admin must not be able to approve stablecoins"); + assert!( + result.is_err(), + "non-admin must not be able to approve stablecoins" + ); let result = client.try_configure_swap_router(¬_admin, &oracle_id, &4u32, &500u32); - assert!(result.is_err(), "non-admin must not be able to reconfigure the router"); + assert!( + result.is_err(), + "non-admin must not be able to reconfigure the router" + ); } // ── Test 7: withdrawing a converted vault balance pays out the stablecoin ── diff --git a/src/test.rs b/src/test.rs index 7a7ffbf1..2e58e76a 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,14 +1,18 @@ #![cfg(test)] +#![allow(deprecated)] -use crate::{TaskManagerContract, TaskManagerContractClient, TaskStatus}; +use crate::{TaskManagerContract, TaskManagerContractClient}; use soroban_sdk::testutils::Address as _; use soroban_sdk::token::StellarAssetClient; use soroban_sdk::{Address, Env, String, Vec}; // ── Shared setup helper ──────────────────────────────────────────────────── -fn setup_initialized_contract(env: &Env, fee_bps: u32) -> ( - TaskManagerContractClient, +fn setup_initialized_contract( + env: &Env, + fee_bps: u32, +) -> ( + TaskManagerContractClient<'_>, Address, // contract_id Address, // admin Address, // token_contract @@ -33,8 +37,7 @@ fn test_initialization() { let env = Env::default(); env.mock_all_auths(); - let (client, _, admin, token_contract, fee_recipient) = - setup_initialized_contract(&env, 100); + let (client, _, admin, token_contract, fee_recipient) = setup_initialized_contract(&env, 100); // Double-initialization must fail let res = client.try_initialize(&admin, &100, &token_contract, &fee_recipient); @@ -76,7 +79,10 @@ fn test_create_and_complete_task_flow() { client.submit_work( &assignee, &task_id, - &String::from_str(&env, "https://github.com/LatterFixxx/LatterFix-Smart-contract"), + &String::from_str( + &env, + "https://github.com/LatterFixxx/LatterFix-Smart-contract", + ), ); client.complete_task(&creator, &task_id); @@ -93,8 +99,7 @@ fn test_cancel_task_refund() { let env = Env::default(); env.mock_all_auths(); - let (client, contract_id, _, token_contract, _) = - setup_initialized_contract(&env, 100); + let (client, contract_id, _, token_contract, _) = setup_initialized_contract(&env, 100); let creator = Address::generate(&env); StellarAssetClient::new(&env, &token_contract).mint(&creator, &500); @@ -112,7 +117,11 @@ fn test_cancel_task_refund() { assert_eq!(token.balance(&contract_id), 500); client.cancel_task(&creator, &task_id); - assert_eq!(token.balance(&creator), 500, "creator must be fully refunded"); + assert_eq!( + token.balance(&creator), + 500, + "creator must be fully refunded" + ); assert_eq!(token.balance(&contract_id), 0); } @@ -123,8 +132,7 @@ fn test_dispute_and_resolution() { let env = Env::default(); env.mock_all_auths(); - let (client, contract_id, admin, token_contract, _) = - setup_initialized_contract(&env, 100); + let (client, contract_id, admin, token_contract, _) = setup_initialized_contract(&env, 100); let creator = Address::generate(&env); let assignee = Address::generate(&env); @@ -162,7 +170,9 @@ fn test_user_profile_lifecycle() { client.create_profile(&user, &username, &bio); - let profile = client.get_profile(&user).expect("profile must exist after creation"); + let profile = client + .get_profile(&user) + .expect("profile must exist after creation"); assert_eq!(profile.address, user); assert_eq!(profile.username, username); assert_eq!(profile.reputation, 100, "starting reputation is 100"); @@ -186,8 +196,7 @@ fn test_dispute_full_assignee_payout() { let env = Env::default(); env.mock_all_auths(); - let (client, contract_id, admin, token_contract, _) = - setup_initialized_contract(&env, 0); // 0% fee for clean assertions + let (client, contract_id, admin, token_contract, _) = setup_initialized_contract(&env, 0); // 0% fee for clean assertions let creator = Address::generate(&env); let assignee = Address::generate(&env); @@ -246,7 +255,11 @@ fn test_multiple_concurrent_tasks() { ); let token = soroban_sdk::token::Client::new(&env, &token_contract); - assert_eq!(token.balance(&contract_id), 3000, "both task rewards locked"); + assert_eq!( + token.balance(&contract_id), + 3000, + "both task rewards locked" + ); assert_eq!(token.balance(&creator), 0); // Complete task 1 → a1 @@ -292,7 +305,10 @@ fn test_cannot_double_assign() { client.assign_task(&a1, &task_id); // Second assignment to same task must fail let result = client.try_assign_task(&a2, &task_id); - assert!(result.is_err(), "double-assigning a task should be rejected"); + assert!( + result.is_err(), + "double-assigning a task should be rejected" + ); } // ── Test 9: Deposits into different tokens are tracked on separate ledgers ─ @@ -360,12 +376,20 @@ fn test_vault_claim_reduces_correct_token_only() { client.claim_from_vault(&worker, &usdc, &300); assert_eq!(client.get_depositor_vault_balance(&worker, &usdc), 0); - assert_eq!(client.get_depositor_vault_balance(&worker, &eurt), 200, "EURT balance must be untouched by a USDC claim"); + assert_eq!( + client.get_depositor_vault_balance(&worker, &eurt), + 200, + "EURT balance must be untouched by a USDC claim" + ); assert_eq!(client.get_token_vault_balance(&usdc), 0); assert_eq!(client.get_token_vault_balance(&eurt), 200); let usdc_token = soroban_sdk::token::Client::new(&env, &usdc); - assert_eq!(usdc_token.balance(&worker), 300, "worker received the claimed USDC back"); + assert_eq!( + usdc_token.balance(&worker), + 300, + "worker received the claimed USDC back" + ); } // ── Test 11: Deposit rejected for a token that isn't registered ────────── @@ -385,7 +409,10 @@ fn test_vault_deposit_rejects_unsupported_token() { // ORGUSD was never added via add_supported_token let result = client.try_deposit_to_vault(&depositor, &orgusd, &100); - assert!(result.is_err(), "depositing an unsupported token must be rejected"); + assert!( + result.is_err(), + "depositing an unsupported token must be rejected" + ); } // ── Test 12: Claim beyond depositor's balance is rejected ──────────────── @@ -406,7 +433,10 @@ fn test_vault_claim_rejects_insufficient_balance() { client.deposit_to_vault(&depositor, &usdc, &50); let result = client.try_claim_from_vault(&depositor, &usdc, &51); - assert!(result.is_err(), "claiming more than the deposited balance must be rejected"); + assert!( + result.is_err(), + "claiming more than the deposited balance must be rejected" + ); } // ── Test 13: Removing a supported token blocks further deposits ────────── @@ -429,7 +459,10 @@ fn test_vault_removed_token_blocks_new_deposits() { let depositor = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&depositor, &10); let result = client.try_deposit_to_vault(&depositor, &usdc, &10); - assert!(result.is_err(), "deposits must be rejected after a token is removed"); + assert!( + result.is_err(), + "deposits must be rejected after a token is removed" + ); } // ── Test: Merkle Payroll ─────────────────────────────────────────────────── @@ -442,12 +475,11 @@ fn test_merkle_payroll_claim() { let env = Env::default(); env.mock_all_auths(); - let (client, contract_id, admin, token_contract, _) = - setup_initialized_contract(&env, 100); + let (client, _contract_id, admin, token_contract, _) = setup_initialized_contract(&env, 100); let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_contract); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); - + let claimant1 = Address::generate(&env); let claimant2 = Address::generate(&env); let amount1: i128 = 1000; @@ -455,7 +487,7 @@ fn test_merkle_payroll_claim() { let depositor = Address::generate(&env); token_admin_client.mint(&depositor, &5000); - + client.add_supported_token(&admin, &token_contract); client.deposit_to_vault(&depositor, &token_contract, &5000); @@ -491,6 +523,12 @@ fn test_merkle_payroll_claim() { let forged_amount = 3000; let mut bogus_proof = Vec::new(&env); bogus_proof.push_back(leaf1.clone()); - let res2 = client.try_claim_payroll(&claimant2, &token_contract, &payroll_id, &forged_amount, &bogus_proof); + let res2 = client.try_claim_payroll( + &claimant2, + &token_contract, + &payroll_id, + &forged_amount, + &bogus_proof, + ); assert!(res2.is_err(), "forged claim should fail"); } diff --git a/src/user_profile.rs b/src/user_profile.rs index ad91ce8e..51e6046a 100644 --- a/src/user_profile.rs +++ b/src/user_profile.rs @@ -1,6 +1,6 @@ -use soroban_sdk::{contracttype, Address, Env, String}; +use crate::storage::DEFAULT_PERSISTENT_TTL; use crate::DataKey; -use crate::storage::{DEFAULT_PERSISTENT_TTL}; +use soroban_sdk::{contracttype, Address, Env, String}; /// On-chain developer profile stored in persistent ledger storage. /// diff --git a/src/vault.rs b/src/vault.rs index 75a5663f..b6094139 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -32,7 +32,9 @@ pub fn add_supported_token(env: &Env, token: Address) { let mut tokens = get_supported_tokens(env); if !tokens.contains(&token) { tokens.push_back(token); - env.storage().instance().set(&VaultKey::SupportedTokens, &tokens); + env.storage() + .instance() + .set(&VaultKey::SupportedTokens, &tokens); } } @@ -44,7 +46,9 @@ pub fn remove_supported_token(env: &Env, token: Address) { remaining.push_back(t); } } - env.storage().instance().set(&VaultKey::SupportedTokens, &remaining); + env.storage() + .instance() + .set(&VaultKey::SupportedTokens, &remaining); } // ── Balance Reads ─────────────────────────────────────────────────────────── @@ -80,11 +84,15 @@ pub fn deposit(env: &Env, depositor: Address, token: Address, amount: i128) { let vault_key = VaultKey::VaultBalance(token.clone()); let vault_total = get_vault_balance(env, token.clone()); - env.storage().persistent().set(&vault_key, &(vault_total + amount)); + env.storage() + .persistent() + .set(&vault_key, &(vault_total + amount)); let dep_key = VaultKey::DepositorBalance(depositor.clone(), token.clone()); let dep_balance = get_depositor_balance(env, depositor, token); - env.storage().persistent().set(&dep_key, &(dep_balance + amount)); + env.storage() + .persistent() + .set(&dep_key, &(dep_balance + amount)); } /// Claim `amount` of `token` out of the vault for `claimant`, drawing down @@ -103,8 +111,12 @@ pub fn claim(env: &Env, claimant: Address, token: Address, amount: i128) { let vault_key = VaultKey::VaultBalance(token.clone()); let vault_total = get_vault_balance(env, token.clone()); - env.storage().persistent().set(&dep_key, &(dep_balance - amount)); - env.storage().persistent().set(&vault_key, &(vault_total - amount)); + env.storage() + .persistent() + .set(&dep_key, &(dep_balance - amount)); + env.storage() + .persistent() + .set(&vault_key, &(vault_total - amount)); let token_client = soroban_sdk::token::Client::new(env, &token); token_client.transfer(&env.current_contract_address(), &claimant, &amount); @@ -160,7 +172,9 @@ pub fn claim_payroll( panic!("insufficient vault balance for payroll"); } - env.storage().persistent().set(&vault_key, &(vault_total - amount)); + env.storage() + .persistent() + .set(&vault_key, &(vault_total - amount)); let token_client = soroban_sdk::token::Client::new(env, &token); token_client.transfer(&env.current_contract_address(), &claimant, &amount); From 0ae353f41baa65dd7d67255e2862d91748b2234b Mon Sep 17 00:00:00 2001 From: Skinny001 Date: Thu, 23 Jul 2026 18:30:25 +0100 Subject: [PATCH 2/3] Fix TWAP oracle compilation errors and test failures - Fix type mismatch: Soroban Vec::len() returns u32, not usize - Remove unnecessary casts from ledger().timestamp() - Fix leading-zero decimal literals flagged by clippy - Add missing BytesN import and ToXdr trait - Prefix unused variables with underscore - Wrap TWAP tests in env.as_contract() for storage access (Soroban 21.x) - Fix pre-existing TWAP test bugs (cumulative values) --- src/test.rs | 948 ++++++++++++++++++++++----------------------- src/twap_oracle.rs | 14 +- 2 files changed, 470 insertions(+), 492 deletions(-) diff --git a/src/test.rs b/src/test.rs index a71d4d2e..14c36eb4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3,8 +3,10 @@ use crate::{TaskManagerContract, TaskManagerContractClient}; use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::Ledger as _; use soroban_sdk::token::StellarAssetClient; -use soroban_sdk::{Address, Env, String, Vec}; +use soroban_sdk::xdr::ToXdr; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; // ── Shared setup helper ──────────────────────────────────────────────────── @@ -322,27 +324,30 @@ use crate::twap_oracle::*; #[test] fn test_twap_config_initialization() { let env = Env::default(); - - let primary_pool = String::from_str(&env, "primary-pool"); - let secondary_oracle = Some(String::from_str(&env, "fallback-oracle")); - - initialize_twap_config( - env.clone(), - primary_pool.clone(), - secondary_oracle.clone(), - 3, // min_observation_count - 500, // max_deviation_bps (5%) - 3600, // observation_window_secs (1 hour) - 10_000_000, // min_liquidity_threshold - ); - - let config = get_twap_config(env); - assert_eq!(config.primary_pool, primary_pool); - assert_eq!(config.secondary_oracle, secondary_oracle); - assert_eq!(config.min_observation_count, 3); - assert_eq!(config.max_deviation_bps, 500); - assert_eq!(config.observation_window_secs, 3600); - assert_eq!(config.min_liquidity_threshold, 10_000_000); + let contract_id = env.register_contract(None, TaskManagerContract); + let e = env.clone(); + env.as_contract(&contract_id, move || { + let primary_pool = String::from_str(&e, "primary-pool"); + let secondary_oracle = Some(String::from_str(&e, "fallback-oracle")); + + initialize_twap_config( + e.clone(), + primary_pool.clone(), + secondary_oracle.clone(), + 3, + 500, + 3600, + 10_000_000, + ); + + let config = get_twap_config(e); + assert_eq!(config.primary_pool, primary_pool); + assert_eq!(config.secondary_oracle, secondary_oracle); + assert_eq!(config.min_observation_count, 3); + assert_eq!(config.max_deviation_bps, 500); + assert_eq!(config.observation_window_secs, 3600); + assert_eq!(config.min_liquidity_threshold, 10_000_000); + }); } // ── Test 2: Record and Retrieve Price Observations ─────────────────────── @@ -352,7 +357,7 @@ fn test_record_price_observations() { let env = Env::default(); env.mock_all_auths(); - let (client, _, admin, _, _) = setup_initialized_contract(&env, 100); + let (client, contract_id, admin, _, _) = setup_initialized_contract(&env, 100); let usdc_admin = Address::generate(&env); let usdc = env.register_stellar_asset_contract(usdc_admin); @@ -375,56 +380,51 @@ fn test_record_price_observations() { assert_eq!( client.get_depositor_vault_balance(&worker, &eurt), 200, - "EURT balance must be untouched by a USDC claim" ); assert_eq!(client.get_token_vault_balance(&usdc), 0); assert_eq!(client.get_token_vault_balance(&eurt), 200); let usdc_token = soroban_sdk::token::Client::new(&env, &usdc); - assert_eq!( - usdc_token.balance(&worker), - 300, - "worker received the claimed USDC back" - ); - - let asset_pair = String::from_str(&env, "USDC/EUR"); - - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 3, - 500, - 3600, - 10_000_000, - ); - - // Record first observation - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, // cumulative_price - 1_100_000_000, // raw_price (1.1 with 18 decimals) - 1_000, // timestamp - 100, // ledger_sequence - ); - - // Record second observation - record_price_observation( - env.clone(), - asset_pair.clone(), - 2_100_000_000, // cumulative_price - 1_100_000_000, // raw_price (same price) - 2_000, // timestamp - 101, // ledger_sequence - ); - - // Verify last observation is stored - let last_obs = get_last_twap(env.clone(), asset_pair.clone()); - assert!(last_obs.is_some(), "should have recorded last observation"); - let last = last_obs.unwrap(); - assert_eq!(last.timestamp, 2_000); - assert_eq!(last.price, 1_100_000_000); + assert_eq!(usdc_token.balance(&worker), 300); + + let e = env.clone(); + env.as_contract(&contract_id, move || { + let asset_pair = String::from_str(&e, "USDC/EUR"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 3, + 500, + 3600, + 10_000_000, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000, + 1_100_000_000, + 1_000, + 100, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 2_100_000_000, + 1_100_000_000, + 2_000, + 101, + ); + + let last_obs = get_last_twap(e.clone(), asset_pair.clone()); + assert!(last_obs.is_some()); + let last = last_obs.unwrap(); + assert_eq!(last.timestamp, 2_000); + assert_eq!(last.price, 1_100_000_000); + }); } // ── Test 3: Multi-Period TWAP Accumulation ───────────────────────────────── @@ -434,7 +434,7 @@ fn test_multi_period_twap_accumulation() { let env = Env::default(); env.mock_all_auths(); - let (client, _, _, _, _) = setup_initialized_contract(&env, 100); + let (client, contract_id, _, _, _) = setup_initialized_contract(&env, 100); let orgusd_admin = Address::generate(&env); let orgusd = env.register_stellar_asset_contract(orgusd_admin); @@ -442,71 +442,64 @@ fn test_multi_period_twap_accumulation() { let depositor = Address::generate(&env); StellarAssetClient::new(&env, &orgusd).mint(&depositor, &100); - // ORGUSD was never added via add_supported_token let result = client.try_deposit_to_vault(&depositor, &orgusd, &100); - assert!( - result.is_err(), - "depositing an unsupported token must be rejected" - ); - - let asset_pair = String::from_str(&env, "USDC/EUR"); - - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 2, - 500, - 36000, // 10 hour window - 10_000_000, - ); - - // Update liquidity - update_pool_liquidity(env.clone(), asset_pair.clone(), 50_000_000); - - // Set current ledger timestamp for observation window - let base_time = env.ledger().timestamp() as u64; - - // Record multiple observations over time - // Observation 1: price = 1.0 - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, // cumulative - 1_000_000_000, // price - base_time, - 100, - ); - - // Observation 2: price = 1.05 (slightly up) - record_price_observation( - env.clone(), - asset_pair.clone(), - 2_050_000_000, // cumulative - 1_050_000_000, // price - base_time + 1000, - 101, - ); - - // Observation 3: price = 1.02 (slight pullback) - record_price_observation( - env.clone(), - asset_pair.clone(), - 3_100_000_000, // cumulative - 1_020_000_000, // price - base_time + 2000, - 102, - ); - - // Calculate TWAP - let twap_result = calculate_twap(env.clone(), asset_pair.clone()); - - assert_eq!(twap_result.observation_count, 3); - assert!(!twap_result.used_fallback); - assert_eq!(twap_result.oldest_timestamp, base_time); - assert_eq!(twap_result.newest_timestamp, base_time + 2000); - // TWAP should be approximately around 1.03 - assert!(twap_result.price > 900_000_000 && twap_result.price < 1_200_000_000); + assert!(result.is_err(), "depositing an unsupported token must be rejected"); + + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(1_005_000); + + let asset_pair = String::from_str(&e, "USDC/EUR"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 2, + 500, + 36000, + 10_000_000, + ); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 50_000_000); + + let base_time = 1_000_000u64; + + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000_000, + 1_000_000_000, + base_time, + 100, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 2_050_000_000_000, + 1_050_000_000, + base_time + 1000, + 101, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 3_070_000_000_000, + 1_020_000_000, + base_time + 2000, + 102, + ); + + let twap_result = calculate_twap(e.clone(), asset_pair.clone()); + + assert_eq!(twap_result.observation_count, 3); + assert!(!twap_result.used_fallback); + assert_eq!(twap_result.oldest_timestamp, base_time); + assert_eq!(twap_result.newest_timestamp, base_time + 2000); + assert!(twap_result.price > 900_000_000 && twap_result.price < 1_200_000_000); + }); } // ── Test 4: Outlier Price Filter ─────────────────────────────────────────── @@ -516,7 +509,7 @@ fn test_outlier_price_filter() { let env = Env::default(); env.mock_all_auths(); - let (client, _, admin, _, _) = setup_initialized_contract(&env, 100); + let (client, contract_id, admin, _, _) = setup_initialized_contract(&env, 100); let usdc_admin = Address::generate(&env); let usdc = env.register_stellar_asset_contract(usdc_admin); @@ -527,75 +520,70 @@ fn test_outlier_price_filter() { client.deposit_to_vault(&depositor, &usdc, &50); let result = client.try_claim_from_vault(&depositor, &usdc, &51); - assert!( - result.is_err(), - "claiming more than the deposited balance must be rejected" - ); - - let asset_pair = String::from_str(&env, "USDC/USD"); - - // Configuration with 5% max deviation - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 3, - 500, // 5% max deviation - 36000, - 10_000_000, - ); - - update_pool_liquidity(env.clone(), asset_pair.clone(), 50_000_000); - - let base_time = env.ledger().timestamp() as u64; - - // Record normal prices - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, - 1_000_000_000, // 1.0 - base_time, - 100, - ); - - record_price_observation( - env.clone(), - asset_pair.clone(), - 2_010_000_000, - 1_010_000_000, // 1.01 - base_time + 1000, - 101, - ); - - // Record an outlier (20% spike - exceeds 5% tolerance) - record_price_observation( - env.clone(), - asset_pair.clone(), - 3_210_000_000, - 1_200_000_000, // 1.2 → 20% spike, should be filtered - base_time + 2000, - 102, - ); - - // Record another normal price - record_price_observation( - env.clone(), - asset_pair.clone(), - 4_220_000_000, - 1_020_000_000, // 1.02 - base_time + 3000, - 103, - ); - - // Calculate TWAP - should filter the outlier - let twap_result = calculate_twap(env.clone(), asset_pair.clone()); - - // Should have 3 observations (outlier filtered out) - assert_eq!(twap_result.observation_count, 3); - assert!(!twap_result.used_fallback); - // Average deviation should be low (< 2%) - assert!(twap_result.avg_deviation_bps < 200); + assert!(result.is_err(), "claiming more than the deposited balance must be rejected"); + + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(1_005_000); + + let asset_pair = String::from_str(&e, "USDC/USD"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 3, + 500, + 36000, + 10_000_000, + ); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 50_000_000); + + let base_time = 1_000_000u64; + + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000, + 1_000_000_000, + base_time, + 100, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 2_010_000_000, + 1_010_000_000, + base_time + 1000, + 101, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 3_210_000_000, + 1_200_000_000, + base_time + 2000, + 102, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 4_220_000_000, + 1_020_000_000, + base_time + 3000, + 103, + ); + + let twap_result = calculate_twap(e.clone(), asset_pair.clone()); + + assert_eq!(twap_result.observation_count, 3); + assert!(!twap_result.used_fallback); + assert!(twap_result.avg_deviation_bps < 200); + }); } // ── Test 5: Fallback Oracle on Low Liquidity ─────────────────────────────── @@ -605,7 +593,7 @@ fn test_fallback_oracle_low_liquidity() { let env = Env::default(); env.mock_all_auths(); - let (client, _, admin, _, _) = setup_initialized_contract(&env, 100); + let (client, contract_id, admin, _, _) = setup_initialized_contract(&env, 100); let usdc_admin = Address::generate(&env); let usdc = env.register_stellar_asset_contract(usdc_admin); @@ -618,54 +606,51 @@ fn test_fallback_oracle_low_liquidity() { let depositor = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&depositor, &10); let result = client.try_deposit_to_vault(&depositor, &usdc, &10); - assert!( - result.is_err(), - "deposits must be rejected after a token is removed" - ); - - let asset_pair = String::from_str(&env, "USDC/JPY"); - - let fallback_oracle = String::from_str(&env, "fallback"); - - // Configure with high liquidity requirement - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - Some(fallback_oracle), - 2, - 500, - 3600, - 1_000_000_000, // High threshold - ); - - // Set pool liquidity below threshold - update_pool_liquidity(env.clone(), asset_pair.clone(), 100_000_000); // Below 1 billion - - // Record insufficient observations - let base_time = env.ledger().timestamp() as u64; - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, - 0_980_000_000, - base_time, - 100, - ); - - // Set fallback price - set_fallback_price( - env.clone(), - asset_pair.clone(), - 0_975_000_000, // Fallback price - base_time, - ); - - // Should use fallback due to insufficient observations - let twap_result = calculate_twap(env.clone(), asset_pair.clone()); - - assert!(twap_result.used_fallback); - assert_eq!(twap_result.price, 0_975_000_000); - assert_eq!(twap_result.observation_count, 1); + assert!(result.is_err(), "deposits must be rejected after a token is removed"); + + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(1_005_000); + + let asset_pair = String::from_str(&e, "USDC/JPY"); + + let fallback_oracle = String::from_str(&e, "fallback"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + Some(fallback_oracle), + 2, + 500, + 3600, + 1_000_000_000, + ); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 100_000_000); + + let base_time = 1_000_000u64; + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000, + 980_000_000, + base_time, + 100, + ); + + set_fallback_price( + e.clone(), + asset_pair.clone(), + 975_000_000, + base_time, + ); + + let twap_result = calculate_twap(e.clone(), asset_pair.clone()); + + assert!(twap_result.used_fallback); + assert_eq!(twap_result.price, 975_000_000); + assert_eq!(twap_result.observation_count, 1); + }); } // ── Test 6: Fallback on Insufficient Observations ───────────────────────── @@ -675,15 +660,15 @@ fn test_fallback_on_insufficient_observations() { let env = Env::default(); env.mock_all_auths(); - let (client, _contract_id, admin, token_contract, _) = setup_initialized_contract(&env, 100); + let (client, contract_id, admin, token_contract, _) = setup_initialized_contract(&env, 100); let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_contract); - let token_client = soroban_sdk::token::Client::new(&env, &token_contract); + let _token_client = soroban_sdk::token::Client::new(&env, &token_contract); let claimant1 = Address::generate(&env); - let claimant2 = Address::generate(&env); + let _claimant2 = Address::generate(&env); let amount1: i128 = 1000; - let amount2: i128 = 2000; + let _amount2: i128 = 2000; let depositor = Address::generate(&env); token_admin_client.mint(&depositor, &5000); @@ -692,50 +677,51 @@ fn test_fallback_on_insufficient_observations() { client.deposit_to_vault(&depositor, &token_contract, &5000); let leaf1_data = (claimant1.clone(), token_contract.clone(), amount1).to_xdr(&env); - let leaf1: BytesN<32> = env.crypto().sha256(&leaf1_data).into(); - - let asset_pair = String::from_str(&env, "USDC/GBP"); - - let fallback_oracle = String::from_str(&env, "fallback"); - - // Configuration requires 3 observations, but has fallback - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - Some(fallback_oracle), // Has fallback - 3, - 500, - 3600, - 10_000_000, - ); - - update_pool_liquidity(env.clone(), asset_pair.clone(), 50_000_000); - - let base_time = env.ledger().timestamp() as u64; - - // Record only 1 observation (need 3) - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, - 1_000_000_000, - base_time, - 100, - ); - - // Set fallback price - set_fallback_price( - env.clone(), - asset_pair.clone(), - 0_950_000_000, - base_time, - ); - - // Should use fallback due to insufficient primary observations - let twap_result = calculate_twap(env.clone(), asset_pair.clone()); - - assert!(twap_result.used_fallback); - assert_eq!(twap_result.price, 0_950_000_000); + let _leaf1: BytesN<32> = env.crypto().sha256(&leaf1_data).into(); + + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(1_005_000); + + let asset_pair = String::from_str(&e, "USDC/GBP"); + + let fallback_oracle = String::from_str(&e, "fallback"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + Some(fallback_oracle), + 3, + 500, + 3600, + 10_000_000, + ); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 50_000_000); + + let base_time = 1_000_000u64; + + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000, + 1_000_000_000, + base_time, + 100, + ); + + set_fallback_price( + e.clone(), + asset_pair.clone(), + 950_000_000, + base_time, + ); + + let twap_result = calculate_twap(e.clone(), asset_pair.clone()); + + assert!(twap_result.used_fallback); + assert_eq!(twap_result.price, 950_000_000); + }); } // ── Test 7: Pool Liquidity Status Check ──────────────────────────────────── @@ -743,38 +729,37 @@ fn test_fallback_on_insufficient_observations() { #[test] fn test_pool_liquidity_checks() { let env = Env::default(); - - let asset_pair = String::from_str(&env, "USDC/CHF"); - - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 2, - 500, - 3600, - 20_000_000, // Min liquidity - ); - - // Initially should be below threshold - assert!(!is_pool_liquid_enough( - env.clone(), - asset_pair.clone() - )); - - // Update to above threshold - update_pool_liquidity(env.clone(), asset_pair.clone(), 25_000_000); - assert!(is_pool_liquid_enough( - env.clone(), - asset_pair.clone() - )); - - // Verify get_pool_liquidity - assert_eq!(get_pool_liquidity(env.clone(), asset_pair.clone()), 25_000_000); - - // Update below threshold - update_pool_liquidity(env.clone(), asset_pair.clone(), 15_000_000); - assert!(!is_pool_liquid_enough(env.clone(), asset_pair.clone())); + let contract_id = env.register_contract(None, TaskManagerContract); + let e = env.clone(); + env.as_contract(&contract_id, move || { + let asset_pair = String::from_str(&e, "USDC/CHF"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 2, + 500, + 3600, + 20_000_000, + ); + + assert!(!is_pool_liquid_enough( + e.clone(), + asset_pair.clone() + )); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 25_000_000); + assert!(is_pool_liquid_enough( + e.clone(), + asset_pair.clone() + )); + + assert_eq!(get_pool_liquidity(e.clone(), asset_pair.clone()), 25_000_000); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 15_000_000); + assert!(!is_pool_liquid_enough(e.clone(), asset_pair.clone())); + }); } // ── Test 8: Observation Pruning ──────────────────────────────────────────── @@ -782,54 +767,56 @@ fn test_pool_liquidity_checks() { #[test] fn test_prune_old_observations() { let env = Env::default(); - - let asset_pair = String::from_str(&env, "USDC/CAD"); - - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 2, - 500, - 3600, - 10_000_000, - ); - - let base_time = env.ledger().timestamp() as u64; - - // Record observations at different times - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, - 1_000_000_000, - base_time - 10_000, // 10k seconds old - 100, - ); - - record_price_observation( - env.clone(), - asset_pair.clone(), - 2_010_000_000, - 1_010_000_000, - base_time - 5_000, // 5k seconds old - 101, - ); - - record_price_observation( - env.clone(), - asset_pair.clone(), - 3_020_000_000, - 1_020_000_000, - base_time, // Current - 102, - ); - - // Prune observations older than 6000 seconds - let pruned_count = prune_old_observations(env.clone(), asset_pair.clone(), 6_000); - - // Should have pruned 1 observation (the one 10k seconds old) - assert_eq!(pruned_count, 1); + let contract_id = env.register_contract(None, TaskManagerContract); + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(100_000); + + let asset_pair = String::from_str(&e, "USDC/CAD"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 2, + 500, + 3600, + 10_000_000, + ); + + let base_time = 100_000u64; + + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000, + 1_000_000_000, + base_time - 10_000, + 100, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 2_010_000_000, + 1_010_000_000, + base_time - 5_000, + 101, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 3_020_000_000, + 1_020_000_000, + base_time, + 102, + ); + + let pruned_count = prune_old_observations(e.clone(), asset_pair.clone(), 6_000); + + assert_eq!(pruned_count, 1); + }); } // ── Test 9: Proper Window Filtering ─────────────────────────────────────── @@ -837,116 +824,107 @@ fn test_prune_old_observations() { #[test] fn test_twap_observation_window_filtering() { let env = Env::default(); - - let asset_pair = String::from_str(&env, "USDC/AUD"); - - // 1000 second observation window - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 2, - 500, - 1000, // 1000 second window - 10_000_000, - ); - - update_pool_liquidity(env.clone(), asset_pair.clone(), 50_000_000); - - let base_time = env.ledger().timestamp() as u64; - - // Record observation outside window (old) - record_price_observation( - env.clone(), - asset_pair.clone(), - 1_000_000_000, - 1_000_000_000, - base_time.saturating_sub(2000), // 2000 seconds old - outside window - 100, - ); - - // Record observations within window - record_price_observation( - env.clone(), - asset_pair.clone(), - 2_010_000_000, - 1_010_000_000, - base_time - 500, // Within window - 101, - ); - - record_price_observation( - env.clone(), - asset_pair.clone(), - 3_020_000_000, - 1_020_000_000, - base_time, // Current - 102, - ); - - // Calculate TWAP - should filter out old observation - let twap_result = calculate_twap(env.clone(), asset_pair.clone()); - - // Should have 2 observations (old one filtered by window) - assert_eq!(twap_result.observation_count, 2); - // Oldest should be 500 seconds before current - assert_eq!(twap_result.oldest_timestamp, base_time - 500); + let contract_id = env.register_contract(None, TaskManagerContract); + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(100_000); + + let asset_pair = String::from_str(&e, "USDC/AUD"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 2, + 500, + 1000, + 10_000_000, + ); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 50_000_000); + + let base_time = 100_000u64; + + record_price_observation( + e.clone(), + asset_pair.clone(), + 1_000_000_000, + 1_000_000_000, + base_time - 2000, + 100, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 2_010_000_000, + 1_010_000_000, + base_time - 500, + 101, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 3_020_000_000, + 1_020_000_000, + base_time, + 102, + ); + + let twap_result = calculate_twap(e.clone(), asset_pair.clone()); + + assert_eq!(twap_result.observation_count, 2); + assert_eq!(twap_result.oldest_timestamp, base_time - 500); + }); } // ── Test 10: TWAP with Edge Case Prices ──────────────────────────────────── - let forged_amount = 3000; - let mut bogus_proof = Vec::new(&env); - bogus_proof.push_back(leaf1.clone()); - let res2 = client.try_claim_payroll( - &claimant2, - &token_contract, - &payroll_id, - &forged_amount, - &bogus_proof, - ); - assert!(res2.is_err(), "forged claim should fail"); #[test] fn test_twap_edge_case_prices() { let env = Env::default(); - - let asset_pair = String::from_str(&env, "USDC/NZD"); - - initialize_twap_config( - env.clone(), - String::from_str(&env, "pool1"), - None, - 2, - 1000, // 10% tolerance for edge case - 36000, - 10_000_000, - ); - - update_pool_liquidity(env.clone(), asset_pair.clone(), 50_000_000); - - let base_time = env.ledger().timestamp() as u64; - - // Very small price - record_price_observation( - env.clone(), - asset_pair.clone(), - 100, - 100, // Very small - base_time, - 100, - ); - - // Normal price - record_price_observation( - env.clone(), - asset_pair.clone(), - 200, - 100, - base_time + 1000, - 101, - ); - - let twap_result = calculate_twap(env, asset_pair); - assert!(twap_result.price >= 0); - assert_eq!(twap_result.observation_count, 2); + let contract_id = env.register_contract(None, TaskManagerContract); + let e = env.clone(); + env.as_contract(&contract_id, move || { + e.ledger().set_timestamp(1_005_000); + + let asset_pair = String::from_str(&e, "USDC/NZD"); + + initialize_twap_config( + e.clone(), + String::from_str(&e, "pool1"), + None, + 2, + 1000, + 36000, + 10_000_000, + ); + + update_pool_liquidity(e.clone(), asset_pair.clone(), 50_000_000); + + let base_time = 1_000_000u64; + + record_price_observation( + e.clone(), + asset_pair.clone(), + 100, + 100, + base_time, + 100, + ); + + record_price_observation( + e.clone(), + asset_pair.clone(), + 200, + 100, + base_time + 1000, + 101, + ); + + let twap_result = calculate_twap(e.clone(), asset_pair.clone()); + assert!(twap_result.price >= 0); + assert_eq!(twap_result.observation_count, 2); + }); } diff --git a/src/twap_oracle.rs b/src/twap_oracle.rs index 7c722b33..649d9c93 100644 --- a/src/twap_oracle.rs +++ b/src/twap_oracle.rs @@ -181,7 +181,7 @@ pub fn set_fallback_price( // ────────────────────────────────────────────────────────────────────────── /// Calculate median price from observations -fn calculate_median(env: &Env, prices: &Vec) -> i128 { +fn calculate_median(_env: &Env, prices: &Vec) -> i128 { if prices.is_empty() { panic!("cannot calculate median of empty vector"); } @@ -289,7 +289,7 @@ pub fn calculate_twap( .unwrap_or_else(|| Vec::new(&env)); // Check if we have sufficient observations - if all_observations.len() < config.min_observation_count as usize { + if all_observations.len() < config.min_observation_count { // Fall back to secondary oracle if available if config.secondary_oracle.is_some() { return calculate_twap_fallback(env, asset_pair, config); @@ -299,7 +299,7 @@ pub fn calculate_twap( } // Filter observations within the observation window - let current_timestamp = env.ledger().timestamp() as u64; + let current_timestamp = env.ledger().timestamp(); let window_start = current_timestamp.saturating_sub(config.observation_window_secs); let mut window_observations = Vec::new(&env); @@ -311,7 +311,7 @@ pub fn calculate_twap( } // Verify we still have enough observations - if window_observations.len() < config.min_observation_count as usize { + if window_observations.len() < config.min_observation_count { // Fall back to secondary oracle if config.secondary_oracle.is_some() { return calculate_twap_fallback(env, asset_pair, config); @@ -355,7 +355,7 @@ pub fn calculate_twap( price: twap_price, oldest_timestamp: first_obs.timestamp, newest_timestamp: last_obs.timestamp, - observation_count: filtered_observations.len() as u32, + observation_count: filtered_observations.len(), used_fallback: false, avg_deviation_bps, } @@ -399,7 +399,7 @@ pub fn prune_old_observations( asset_pair: String, retention_secs: u64, ) -> u32 { - let config = get_twap_config(env.clone()); + let _config = get_twap_config(env.clone()); let obs_key = TwapStorageKey::Observations(asset_pair.clone()); let all_observations: Vec = env .storage() @@ -407,7 +407,7 @@ pub fn prune_old_observations( .get(&obs_key) .unwrap_or_else(|| Vec::new(&env)); - let cutoff_timestamp = (env.ledger().timestamp() as u64) + let cutoff_timestamp = env.ledger().timestamp() .saturating_sub(retention_secs); let mut kept_observations = Vec::new(&env); From 5827576c03b84a5a610880aa90db4a4e1250f327 Mon Sep 17 00:00:00 2001 From: Skinny001 Date: Fri, 24 Jul 2026 08:48:42 +0100 Subject: [PATCH 3/3] Fix duplicate mod merkle and pre-existing clippy issues from merge - Remove duplicate in lib.rs - Convert module-level doc comments to regular // comments - Remove duplicate BytesN import in test.rs - Add allow(deprecated) to multisig_test.rs - Add allow(dead_code) to Ctx struct - Add allow(clippy::too_many_arguments) to register_verification_key --- src/lib.rs | 1 - src/multisig.rs | 72 +++++++++++++++++++++--------------------- src/multisig_test.rs | 2 ++ src/test.rs | 2 +- src/zkp_attestation.rs | 33 +++++++++---------- 5 files changed, 56 insertions(+), 54 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d120a525..1e404ab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,6 @@ pub mod twap_oracle; pub mod swap_router; pub mod user_profile; pub mod vault; -pub mod merkle; pub mod zkp_attestation; #[cfg(kani)] diff --git a/src/multisig.rs b/src/multisig.rs index 52ce8853..5bc36b96 100644 --- a/src/multisig.rs +++ b/src/multisig.rs @@ -2,42 +2,42 @@ use soroban_sdk::{contracttype, Address, Env, String, Vec}; use crate::DataKey; -/// On-chain multisig proposal, approval-voting, and execution ledger. -/// -/// This module governs *admin* transactions — parameter changes and treasury -/// fund movements — and is deliberately separate from `governance`, which -/// implements reputation-weighted community voting over free-form proposals. -/// The two differ in every meaningful dimension: -/// -/// - `governance` : anyone with enough reputation may propose/vote, votes are -/// weighted, outcomes are advisory (no on-chain effect). -/// - `multisig` : only registered signers may propose/approve, each signer -/// counts once, and reaching the threshold *performs* the -/// encoded action against contract state or the treasury. -/// -/// Status workflow: -/// -/// ```text -/// Pending ──(threshold reached)──> Approved ──(execute)──> Executed -/// │ │ -/// └────────────(cancel)─────────────┴──> Cancelled -/// ``` -/// -/// Security properties: -/// - The approval threshold is **snapshotted at proposal creation**, so -/// rotating the signer set cannot retroactively make a live proposal -/// easier to pass. -/// - Approvals are **re-validated against the current signer set** at -/// execution time, so an approval from a since-removed signer stops -/// counting. -/// - Each signer may approve a given proposal at most once. -/// - Proposals expire after `proposal_ttl` seconds and can no longer be -/// approved or executed, bounding the window in which a stale approval -/// set stays actionable. -/// -/// Execution is atomic: if the encoded action traps (e.g. an underfunded -/// treasury transfer), the host transaction reverts, including the approval -/// that triggered it. The signer may re-approve once the cause is fixed. +// On-chain multisig proposal, approval-voting, and execution ledger. +// +// This module governs *admin* transactions — parameter changes and treasury +// fund movements — and is deliberately separate from `governance`, which +// implements reputation-weighted community voting over free-form proposals. +// The two differ in every meaningful dimension: +// +// - `governance` : anyone with enough reputation may propose/vote, votes are +// weighted, outcomes are advisory (no on-chain effect). +// - `multisig` : only registered signers may propose/approve, each signer +// counts once, and reaching the threshold *performs* the +// encoded action against contract state or the treasury. +// +// Status workflow: +// +// ```text +// Pending ──(threshold reached)──> Approved ──(execute)──> Executed +// │ │ +// └────────────(cancel)─────────────┴──> Cancelled +// ``` +// +// Security properties: +// - The approval threshold is **snapshotted at proposal creation**, so +// rotating the signer set cannot retroactively make a live proposal +// easier to pass. +// - Approvals are **re-validated against the current signer set** at +// execution time, so an approval from a since-removed signer stops +// counting. +// - Each signer may approve a given proposal at most once. +// - Proposals expire after `proposal_ttl` seconds and can no longer be +// approved or executed, bounding the window in which a stale approval +// set stays actionable. +// +// Execution is atomic: if the encoded action traps (e.g. an underfunded +// treasury transfer), the host transaction reverts, including the approval +// that triggered it. The signer may re-approve once the cause is fixed. // ============================================================================ // Types diff --git a/src/multisig_test.rs b/src/multisig_test.rs index d989f50e..920f41d2 100644 --- a/src/multisig_test.rs +++ b/src/multisig_test.rs @@ -1,4 +1,5 @@ #![cfg(test)] +#![allow(deprecated)] use crate::multisig::{MultisigAction, MultisigProposalStatus}; use crate::{TaskManagerContract, TaskManagerContractClient}; @@ -8,6 +9,7 @@ use soroban_sdk::{Address, Env, String, Vec}; // ── Shared setup ─────────────────────────────────────────────────────────── +#[allow(dead_code)] struct Ctx { client: TaskManagerContractClient<'static>, contract_id: Address, diff --git a/src/test.rs b/src/test.rs index 828e87d1..4f85becf 100644 --- a/src/test.rs +++ b/src/test.rs @@ -934,7 +934,7 @@ fn test_twap_edge_case_prices() { // ═══════════════════════════════════════════════════════════════════════════ use crate::zkp_attestation::*; -use soroban_sdk::{Bytes, BytesN}; +use soroban_sdk::Bytes; // ── Fixtures ─────────────────────────────────────────────────────────────── diff --git a/src/zkp_attestation.rs b/src/zkp_attestation.rs index 29d65411..2e82e0f4 100644 --- a/src/zkp_attestation.rs +++ b/src/zkp_attestation.rs @@ -1,21 +1,21 @@ use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec}; -/// Zero-Knowledge Proof (ZKP) Identity Attestation Module -/// -/// Lets an employee prove eligibility / KYC status during payroll processing -/// without revealing the underlying identity data on-chain. The flow is: -/// -/// 1. An admin registers the verification key (VK) of a proving circuit -/// (e.g. "kyc-tier-1") via [`register_verification_key`]. -/// 2. Off-chain, the employee produces a Groth16 zk-SNARK proof that they -/// satisfy the circuit, together with the public signals and a nullifier. -/// 3. On-chain, [`verify_attestation`] validates the proof against the VK, -/// binds the nullifier to the public signals, rejects replays, and records -/// the attestation — the raw identity inputs never touch the ledger. -/// -/// Replay protection is enforced with a spent-nullifier set: a nullifier is a -/// deterministic, per-identity/per-circuit tag derived off-chain, so a given -/// identity can be attested against a given circuit at most once. +// Zero-Knowledge Proof (ZKP) Identity Attestation Module +// +// Lets an employee prove eligibility / KYC status during payroll processing +// without revealing the underlying identity data on-chain. The flow is: +// +// 1. An admin registers the verification key (VK) of a proving circuit +// (e.g. "kyc-tier-1") via [`register_verification_key`]. +// 2. Off-chain, the employee produces a Groth16 zk-SNARK proof that they +// satisfy the circuit, together with the public signals and a nullifier. +// 3. On-chain, [`verify_attestation`] validates the proof against the VK, +// binds the nullifier to the public signals, rejects replays, and records +// the attestation — the raw identity inputs never touch the ledger. +// +// Replay protection is enforced with a spent-nullifier set: a nullifier is a +// deterministic, per-identity/per-circuit tag derived off-chain, so a given +// identity can be attested against a given circuit at most once. // ────────────────────────────────────────────────────────────────────────── // Encoding constants (BLS12-381, uncompressed) @@ -161,6 +161,7 @@ pub fn get_admin(env: Env) -> Address { } /// Register (or overwrite) the verification key for a circuit. Admin only. +#[allow(clippy::too_many_arguments)] pub fn register_verification_key( env: Env, admin: Address,