From 012b57623c13e44d20d4a6110f856fd22c2bd638 Mon Sep 17 00:00:00 2001 From: rampop01 Date: Fri, 21 Aug 2026 00:08:20 +0100 Subject: [PATCH 1/3] Optimize WASM size: Refactor for Soroban limits --- Cargo.toml | 1 - src/access_control.rs | 19 ++- src/escrow.rs | 35 ++--- src/events.rs | 47 +++---- src/governance.rs | 41 +++--- src/kani_proofs.rs | 17 +-- src/lib.rs | 282 ++++++++++++++++------------------------ src/merkle.rs | 6 - src/multisig.rs | 141 +++++++------------- src/multisig_test.rs | 34 +++-- src/pausable.rs | 57 +++----- src/reputation.rs | 37 +++--- src/storage.rs | 52 +++----- src/swap_router.rs | 116 +++++++---------- src/swap_router_test.rs | 6 +- src/test.rs | 144 ++++++++++---------- src/treasury.rs | 134 ++++++++----------- src/treasury_test.rs | 5 +- src/twap_oracle.rs | 124 ++++++------------ src/upgrade.rs | 165 +++++++++-------------- src/upgrade_test.rs | 14 +- src/user_profile.rs | 69 +++------- src/vault.rs | 28 ++-- src/zkp_attestation.rs | 124 +++--------------- 24 files changed, 632 insertions(+), 1066 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 71243375..7d7111cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ testutils = ["soroban-sdk/testutils"] [profile.release] opt-level = "z" -overflow-checks = true debug = 0 strip = "symbols" debug-assertions = false diff --git a/src/access_control.rs b/src/access_control.rs index 417f7cbf..69fc0aaa 100644 --- a/src/access_control.rs +++ b/src/access_control.rs @@ -1,21 +1,18 @@ +use soroban_sdk::unwrap::UnwrapOptimized; use soroban_sdk::{contracttype, Address, Env, Vec}; #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum Role { Admin, Manager, Moderator, Verifier, - /// Emergency guardian: authorized to veto a pending contract WASM - /// upgrade during its timelock window (see `upgrade.rs`). Deliberately - /// separate from `Admin` so upgrade proposals can be checked by a party - /// other than the one proposing them. Guardian, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct RoleData { pub role: Role, pub granted_at: u64, @@ -36,10 +33,10 @@ pub fn grant_role(env: Env, admin: Address, user: Address, role: Role) { .storage() .instance() .get(&AccessControlKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if admin != stored_admin { - panic!("only admin can grant roles"); + panic!(); } let key = AccessControlKey::Role(user.clone()); @@ -72,10 +69,10 @@ pub fn revoke_role(env: Env, admin: Address, user: Address) { .storage() .instance() .get(&AccessControlKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if admin != stored_admin { - panic!("only admin can revoke roles"); + panic!(); } let key = AccessControlKey::Role(user.clone()); @@ -117,6 +114,6 @@ pub fn get_role(env: Env, user: Address) -> Option { pub fn require_role(env: Env, user: Address, role: Role) { if !has_role(env.clone(), user, role) { - panic!("access denied: required role not found"); + panic!(); } } diff --git a/src/escrow.rs b/src/escrow.rs index f6629d54..9b13823b 100644 --- a/src/escrow.rs +++ b/src/escrow.rs @@ -1,7 +1,8 @@ +use soroban_sdk::unwrap::UnwrapOptimized; use soroban_sdk::{contracttype, Env, Vec}; #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum MilestoneStatus { Pending, Submitted, @@ -11,20 +12,20 @@ pub enum MilestoneStatus { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Milestone { pub id: u32, pub task_id: u32, - pub title: soroban_sdk::String, + pub title: soroban_sdk::Symbol, pub amount: i128, pub status: MilestoneStatus, pub due_date: Option, - pub submission_url: Option, - pub feedback: Option, + pub submission_url: Option, + pub feedback: Option, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct EscrowStats { pub total_locked: i128, pub total_released: i128, @@ -44,7 +45,7 @@ pub enum EscrowKey { pub fn create_milestone( env: Env, task_id: u32, - title: soroban_sdk::String, + title: soroban_sdk::Symbol, amount: i128, due_date: Option, ) -> u32 { @@ -74,18 +75,18 @@ pub fn submit_milestone( env: Env, task_id: u32, milestone_id: u32, - submission_url: soroban_sdk::String, + submission_url: soroban_sdk::Symbol, ) { let key = EscrowKey::Milestone(task_id, milestone_id); let mut milestone: Milestone = env .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("milestone not found")); + .unwrap_optimized(); if milestone.status != MilestoneStatus::Pending && milestone.status != MilestoneStatus::Rejected { - panic!("milestone cannot be submitted"); + panic!(); } milestone.status = MilestoneStatus::Submitted; @@ -98,17 +99,17 @@ pub fn approve_milestone( env: Env, task_id: u32, milestone_id: u32, - feedback: Option, + feedback: Option, ) -> i128 { let key = EscrowKey::Milestone(task_id, milestone_id); let mut milestone: Milestone = env .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("milestone not found")); + .unwrap_optimized(); if milestone.status != MilestoneStatus::Submitted { - panic!("milestone not submitted"); + panic!(); } milestone.status = MilestoneStatus::Approved; @@ -124,16 +125,16 @@ pub fn approve_milestone( 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::Symbol) { let key = EscrowKey::Milestone(task_id, milestone_id); let mut milestone: Milestone = env .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("milestone not found")); + .unwrap_optimized(); if milestone.status != MilestoneStatus::Submitted { - panic!("milestone not submitted"); + panic!(); } milestone.status = MilestoneStatus::Rejected; @@ -212,7 +213,7 @@ pub fn release_escrow(env: Env, task_id: u32, amount: i128) { let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); if current < amount { - panic!("insufficient escrow balance"); + panic!(); } let new_balance = current - amount; diff --git a/src/events.rs b/src/events.rs index d61c5310..1beb165f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,17 +1,7 @@ -use soroban_sdk::{symbol_short, Address, Env, String}; - -/// Event module for the LatterFix TaskManager Soroban contract. -/// -/// Every public state-changing action emits a structured event via -/// `env.events().publish()`. Events are indexed off-chain by: -/// - Stellar Expert contract event viewer -/// - Soroban RPC `getEvents` (filtered by contractId + topic) -/// - The LatterFix frontend via `fetchContractEvents()` in transactionHistory.ts -/// -/// Topic layout: (symbol, primary_id) -/// Data layout: tuple of relevant fields +use soroban_sdk::{symbol_short, Address, Env, Symbol}; + // ── 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: Symbol, reward: i128) { let ledger_ts = env.ledger().timestamp(); env.events().publish( (symbol_short!("task_cre"), task_id), @@ -26,7 +16,7 @@ pub fn emit_task_assigned(env: &Env, task_id: u32, assignee: Address) { ); } -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: Symbol) { env.events().publish( (symbol_short!("task_subm"), task_id), (assignee, delivery_url, env.ledger().timestamp()), @@ -70,14 +60,14 @@ pub fn emit_dispute_split_resolved(env: &Env, task_id: u32, platform_fee: i128, // ── Profile Events ───────────────────────────────────────────────────────── -pub fn emit_profile_created(env: &Env, user: Address, username: String) { +pub fn emit_profile_created(env: &Env, user: Address, username: Symbol) { 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: Symbol) { env.events().publish( (symbol_short!("prof_upd"), user), (field, env.ledger().timestamp()), @@ -114,7 +104,7 @@ pub fn emit_milestone_approved(env: &Env, task_id: u32, milestone_id: u32, amoun ); } -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: Symbol) { env.events().publish( (symbol_short!("mile_rej"), (task_id, milestone_id)), (feedback, env.ledger().timestamp()), @@ -123,14 +113,14 @@ pub fn emit_milestone_rejected(env: &Env, task_id: u32, milestone_id: u32, feedb // ── 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: Symbol) { 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: Symbol, weight: u32) { env.events().publish( (symbol_short!("vote_cast"), (proposal_id, voter)), (vote_type, weight, env.ledger().timestamp()), @@ -161,7 +151,7 @@ pub fn emit_multisig_proposed( env: &Env, proposal_id: u32, proposer: Address, - description: String, + description: Symbol, threshold: u32, ) { env.events().publish( @@ -199,14 +189,14 @@ pub fn emit_multisig_cancelled(env: &Env, proposal_id: u32, caller: Address) { // ── 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: Symbol, 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: Symbol, revoked_by: Address) { env.events().publish( (symbol_short!("role_rev"), user), (role, revoked_by, env.ledger().timestamp()), @@ -215,14 +205,14 @@ pub fn emit_role_revoked(env: &Env, user: Address, role: String, revoked_by: Add // ── Pause Events ─────────────────────────────────────────────────────────── -pub fn emit_paused(env: &Env, action: String, admin: Address) { +pub fn emit_paused(env: &Env, action: Symbol, 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: Symbol, admin: Address) { env.events().publish( (symbol_short!("unpaused"), action), (admin, env.ledger().timestamp()), @@ -247,7 +237,6 @@ pub fn emit_tokens_released(env: &Env, task_id: u32, to: Address, amount: i128) // ── 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) { env.events().publish( (symbol_short!("fee_upd"), updated_by), @@ -255,7 +244,6 @@ pub fn emit_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32, updated_b ); } -/// Emitted when the contract is first initialized. pub fn emit_contract_initialized(env: &Env, admin: Address, fee_bps: u32) { env.events().publish( (symbol_short!("init"), admin), @@ -265,7 +253,6 @@ pub fn emit_contract_initialized(env: &Env, admin: Address, fee_bps: u32) { // ── Swap Router Events ───────────────────────────────────────────────────── -/// Emitted when the multi-asset swap router config is set/updated. pub fn emit_router_configured( env: &Env, admin: Address, @@ -298,8 +285,6 @@ pub fn emit_stablecoin_removed(env: &Env, admin: Address, stablecoin: Address) { ); } -/// Emitted when an incoming non-standard token is successfully routed and -/// converted into an approved vault stablecoin. pub fn emit_swap_executed( env: &Env, sender: Address, @@ -320,14 +305,12 @@ pub fn emit_swap_executed( ); } -/// Emitted when a conversion is rejected before any funds are pulled from the -/// sender, e.g. because the route couldn't be resolved or has no oracle price. pub fn emit_swap_refunded( env: &Env, sender: Address, token_in: Address, amount: i128, - reason: String, + reason: Symbol, ) { env.events().publish( (symbol_short!("swap_ref"), sender), diff --git a/src/governance.rs b/src/governance.rs index 44c701c1..7b362cc8 100644 --- a/src/governance.rs +++ b/src/governance.rs @@ -1,7 +1,8 @@ -use soroban_sdk::{contracttype, Address, Env, String, Vec}; +use soroban_sdk::unwrap::UnwrapOptimized; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] pub enum ProposalStatus { Active, Executed, @@ -11,7 +12,7 @@ pub enum ProposalStatus { } #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] pub enum VoteType { For, Against, @@ -19,11 +20,11 @@ pub enum VoteType { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Proposal { pub id: u32, - pub title: String, - pub description: String, + pub title: Symbol, + pub description: Symbol, pub proposer: Address, pub status: ProposalStatus, pub created_at: u64, @@ -37,7 +38,7 @@ pub struct Proposal { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Vote { pub voter: Address, pub proposal_id: u32, @@ -56,7 +57,7 @@ pub enum GovernanceKey { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct GovernanceConfig { pub voting_period: u64, // Duration in seconds pub quorum: u32, // Minimum votes needed @@ -90,8 +91,8 @@ pub fn set_config(env: Env, admin: Address, config: GovernanceConfig) { pub fn create_proposal( env: Env, proposer: Address, - title: String, - description: String, + title: Symbol, + description: Symbol, quorum: Option, threshold: Option, _min_reputation: u32, @@ -140,21 +141,21 @@ pub fn cast_vote(env: Env, voter: Address, proposal_id: u32, vote_type: VoteType .storage() .persistent() .get(&proposal_key) - .unwrap_or_else(|| panic!("proposal not found")); + .unwrap_optimized(); if proposal.status != ProposalStatus::Active { - panic!("proposal not active"); + panic!(); } let now = env.ledger().timestamp(); if now > proposal.voting_ends_at { - panic!("voting period ended"); + panic!(); } // Check if already voted let vote_key = GovernanceKey::Vote(proposal_id, voter.clone()); if env.storage().persistent().has(&vote_key) { - panic!("already voted"); + panic!(); } // Record vote @@ -184,15 +185,15 @@ pub fn execute_proposal(env: Env, _caller: Address, proposal_id: u32) -> bool { .storage() .persistent() .get(&proposal_key) - .unwrap_or_else(|| panic!("proposal not found")); + .unwrap_optimized(); if proposal.status != ProposalStatus::Active { - panic!("proposal not active"); + panic!(); } let now = env.ledger().timestamp(); if now <= proposal.voting_ends_at { - panic!("voting period not ended"); + panic!(); } let total_votes = proposal.votes_for + proposal.votes_against + proposal.votes_abstain; @@ -234,14 +235,14 @@ pub fn cancel_proposal(env: Env, proposer: Address, proposal_id: u32) { .storage() .persistent() .get(&proposal_key) - .unwrap_or_else(|| panic!("proposal not found")); + .unwrap_optimized(); if proposal.proposer != proposer { - panic!("not proposer"); + panic!(); } if proposal.status != ProposalStatus::Active { - panic!("proposal not active"); + panic!(); } proposal.status = ProposalStatus::Cancelled; diff --git a/src/kani_proofs.rs b/src/kani_proofs.rs index 4ccc58eb..146f4da1 100644 --- a/src/kani_proofs.rs +++ b/src/kani_proofs.rs @@ -1,3 +1,4 @@ +use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(kani)] // ============================================================================ @@ -87,7 +88,7 @@ fn verify_vault_deposit_no_overflow() { let result = pure_vault_deposit(vault_total, dep_balance, amount); assert!(result.is_some()); - let (new_vault, new_dep) = result.unwrap(); + let (new_vault, new_dep) = result.unwrap_optimized(); assert!(new_vault >= 0); assert!(new_dep >= 0); @@ -134,7 +135,7 @@ fn verify_vault_claim_no_underflow() { let result = pure_vault_claim(vault_total, dep_balance, amount); assert!(result.is_some()); - let (new_vault, new_dep) = result.unwrap(); + let (new_vault, new_dep) = result.unwrap_optimized(); assert!(new_vault >= 0); assert!(new_dep >= 0); @@ -168,7 +169,7 @@ fn verify_escrow_release_no_underflow() { let result = pure_escrow_release(balance, amount); assert!(result.is_some()); - let new_balance = result.unwrap(); + let new_balance = result.unwrap_optimized(); assert!(new_balance >= 0); assert!(new_balance <= balance); @@ -213,7 +214,7 @@ fn verify_escrow_lock_monotonic() { let result = pure_escrow_lock(balance, amount); assert!(result.is_some()); - let new_balance = result.unwrap(); + let new_balance = result.unwrap_optimized(); assert!(new_balance >= balance); assert_eq!(new_balance - balance, amount); @@ -382,7 +383,7 @@ fn verify_slippage_guard_bounds() { // // Invalid transitions must be rejected (return None). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq)] enum MsState { Pending, Submitted, @@ -429,7 +430,7 @@ fn verify_milestone_transitions_exhaustive() { match state { MsState::Pending | MsState::Rejected => { assert!(after_submit.is_some()); - assert_eq!(after_submit.unwrap(), MsState::Submitted); + assert_eq!(after_submit.unwrap_optimized(), MsState::Submitted); } _ => { assert!(after_submit.is_none()); @@ -439,9 +440,9 @@ fn verify_milestone_transitions_exhaustive() { match state { MsState::Submitted => { assert!(after_approve.is_some()); - assert_eq!(after_approve.unwrap(), MsState::Approved); + assert_eq!(after_approve.unwrap_optimized(), MsState::Approved); assert!(after_reject.is_some()); - assert_eq!(after_reject.unwrap(), MsState::Rejected); + assert_eq!(after_reject.unwrap_optimized(), MsState::Rejected); } _ => { assert!(after_approve.is_none()); diff --git a/src/lib.rs b/src/lib.rs index 70e94186..8d8adaee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +use soroban_sdk::unwrap::UnwrapOptimized; pub mod access_control; pub mod escrow; @@ -31,14 +32,14 @@ mod treasury_test; #[cfg(test)] mod upgrade_test; -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec}; // ============================================================================ // Task Management Types // ============================================================================ #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] #[repr(u32)] pub enum TaskStatus { Open = 0, @@ -51,16 +52,16 @@ pub enum TaskStatus { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Task { pub id: u32, - pub title: String, - pub description: String, + pub title: Symbol, + pub description: Symbol, pub reward: i128, pub assignee: Option
, pub status: TaskStatus, pub created_by: Address, - pub tags: Vec, + pub tags: Vec, pub category_id: Option, pub deadline: Option, pub created_at: u64, @@ -68,7 +69,7 @@ pub struct Task { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct TaskWithMilestones { pub task: Task, pub milestones: Vec, @@ -80,7 +81,7 @@ pub struct TaskWithMilestones { // ============================================================================ #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum DataKey { Admin, PlatformFeeBps, @@ -98,20 +99,16 @@ pub enum DataKey { // Dispute Split Types // ============================================================================ -/// A single recipient share in a dispute split. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct DisputeSplitRecipient { pub address: Address, - /// Percentage in basis points (100 = 1%, 10000 = 100%). pub share_bps: u32, } -/// The full split instruction attached to a dispute resolution. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct DisputeSplit { - /// Ordered list of recipients and their basis-point shares. pub recipients: Vec, } @@ -136,12 +133,12 @@ impl TaskManagerContract { fee_recipient: Address, ) { if env.storage().instance().has(&DataKey::Initialized) { - panic!("already initialized"); + panic!(); } // Validate fee (max 10%) if platform_fee_bps > 1000 { - panic!("platform fee cannot exceed 10%"); + panic!(); } env.storage().instance().set(&DataKey::Admin, &admin); @@ -180,10 +177,10 @@ impl TaskManagerContract { pub fn create_task( env: Env, creator: Address, - title: String, - description: String, + title: Symbol, + description: Symbol, reward: i128, - tags: Vec, + tags: Vec, ) -> u32 { creator.require_auth(); @@ -195,14 +192,14 @@ impl TaskManagerContract { ); if reward <= 0 { - panic!("reward must be positive"); + panic!(); } let token_contract: Address = env .storage() .instance() .get(&DataKey::TokenContract) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); // Transfer reward from creator to the contract let token_client = soroban_sdk::token::Client::new(&env, &token_contract); @@ -257,22 +254,22 @@ impl TaskManagerContract { pub fn create_task_with_milestones( env: Env, creator: Address, - title: String, - description: String, - milestones: Vec<(String, i128)>, // (title, amount) - tags: Vec, + title: Symbol, + description: Symbol, + milestones: Vec<(Symbol, i128)>, // (title, amount) + 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(); + let milestone = milestones.get(i).unwrap_optimized(); total_reward += milestone.1; } if total_reward <= 0 { - panic!("total milestone amount must be positive"); + panic!(); } // Create the task @@ -306,10 +303,10 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.status != TaskStatus::Open { - panic!("task is not open"); + panic!(); } task.assignee = Some(assignee.clone()); @@ -321,7 +318,7 @@ impl TaskManagerContract { events::emit_task_assigned(&env, task_id, assignee); } - pub fn submit_work(env: Env, assignee: Address, task_id: u32, delivery_url: String) { + pub fn submit_work(env: Env, assignee: Address, task_id: u32, delivery_url: Symbol) { assignee.require_auth(); pausable::require_not_paused( @@ -334,14 +331,14 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.assignee.as_ref() != Some(&assignee) { - panic!("caller is not the assignee"); + panic!(); } if task.status != TaskStatus::InProgress { - panic!("task is not in progress"); + panic!(); } task.status = TaskStatus::Completed; @@ -365,16 +362,16 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.status != TaskStatus::Completed { - panic!("task is not completed"); + panic!(); } // Verify caller is creator or admin - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if caller != task.created_by && caller != admin { - panic!("not authorized to complete task"); + panic!(); } let platform_fee_bps: u32 = env @@ -386,12 +383,12 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::FeeRecipient) - .unwrap(); + .unwrap_optimized(); let token_contract: Address = env .storage() .instance() .get(&DataKey::TokenContract) - .unwrap(); + .unwrap_optimized(); let fee = (task.reward * platform_fee_bps as i128) / 10000; let payout = task.reward - fee; @@ -401,7 +398,7 @@ impl TaskManagerContract { let assignee = task .assignee .clone() - .unwrap_or_else(|| panic!("no assignee")); + .unwrap_optimized(); if fee > 0 { token_client.transfer(&env.current_contract_address(), &fee_recipient, &fee); @@ -424,7 +421,7 @@ impl TaskManagerContract { reputation::points_for_event(reputation::ReputationEventType::TaskVerified), reputation::ReputationEventType::TaskVerified, Some(task_id), - String::from_str(&env, "Task verified and completed"), + Symbol::new(&env, "Task verified and completed"), ); events::emit_task_completed(&env, task_id, assignee, payout, fee); @@ -450,21 +447,21 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.created_by != creator { - panic!("not task creator"); + panic!(); } if task.status != TaskStatus::Open { - panic!("task is not open"); + panic!(); } let token_contract: Address = env .storage() .instance() .get(&DataKey::TokenContract) - .unwrap(); + .unwrap_optimized(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); token_client.transfer(&env.current_contract_address(), &creator, &task.reward); @@ -496,14 +493,14 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if caller != task.created_by && Some(&caller) != task.assignee.as_ref() { - panic!("not authorized to dispute task"); + panic!(); } if task.status != TaskStatus::InProgress && task.status != TaskStatus::Completed { - panic!("task status cannot be disputed"); + panic!(); } task.status = TaskStatus::Disputed; @@ -527,30 +524,30 @@ impl TaskManagerContract { ) { admin.require_auth(); - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored_admin { - panic!("not admin"); + panic!(); } let mut task: Task = env .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.status != TaskStatus::Disputed { - panic!("task is not disputed"); + panic!(); } if creator_refund + assignee_payout != task.reward { - panic!("invalid split totals"); + panic!(); } let token_contract: Address = env .storage() .instance() .get(&DataKey::TokenContract) - .unwrap(); + .unwrap_optimized(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); if creator_refund > 0 { @@ -564,7 +561,7 @@ impl TaskManagerContract { let assignee = task .assignee .clone() - .unwrap_or_else(|| panic!("no assignee")); + .unwrap_optimized(); token_client.transfer(&env.current_contract_address(), &assignee, &assignee_payout); // Award reputation for winning dispute @@ -574,7 +571,7 @@ impl TaskManagerContract { reputation::points_for_event(reputation::ReputationEventType::DisputeWon), reputation::ReputationEventType::DisputeWon, Some(task_id), - String::from_str(&env, "Won dispute"), + Symbol::new(&env, "Won dispute"), ); } @@ -588,10 +585,6 @@ impl TaskManagerContract { events::emit_dispute_resolved(&env, task_id, creator_refund, assignee_payout); } - /// Resolve a disputed task by splitting the escrowed funds among multiple - /// recipients according to percentage shares. - /// - /// Delegates to the module-level `resolve_dispute_split` function. pub fn resolve_dispute_split( env: Env, task_id: u32, @@ -610,7 +603,7 @@ impl TaskManagerContract { assignee: Address, task_id: u32, milestone_id: u32, - submission_url: String, + submission_url: Symbol, ) { assignee.require_auth(); @@ -619,10 +612,10 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.assignee.as_ref() != Some(&assignee) { - panic!("not the assignee"); + panic!(); } escrow::submit_milestone(env.clone(), task_id, milestone_id, submission_url); @@ -635,7 +628,7 @@ impl TaskManagerContract { caller: Address, task_id: u32, milestone_id: u32, - feedback: Option, + feedback: Option, ) { caller.require_auth(); @@ -643,12 +636,12 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); // Only creator or admin can approve - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if caller != task.created_by && caller != admin { - panic!("not authorized"); + panic!(); } let amount = @@ -659,9 +652,9 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::TokenContract) - .unwrap(); + .unwrap_optimized(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); - let assignee = task.assignee.clone().unwrap(); + let assignee = task.assignee.clone().unwrap_optimized(); token_client.transfer(&env.current_contract_address(), &assignee, &amount); @@ -674,7 +667,7 @@ impl TaskManagerContract { reputation::points_for_event(reputation::ReputationEventType::MilestoneApproved), reputation::ReputationEventType::MilestoneApproved, Some(task_id), - String::from_str(&env, "Milestone approved"), + Symbol::new(&env, "Milestone approved"), ); } @@ -683,7 +676,7 @@ impl TaskManagerContract { caller: Address, task_id: u32, milestone_id: u32, - feedback: String, + feedback: Symbol, ) { caller.require_auth(); @@ -691,12 +684,12 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); // Only creator or admin can reject - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if caller != task.created_by && caller != admin { - panic!("not authorized"); + panic!(); } escrow::reject_milestone(env.clone(), task_id, milestone_id, feedback.clone()); @@ -712,14 +705,14 @@ impl TaskManagerContract { // User Profile Management // ======================================================================== - pub fn create_profile(env: Env, user: Address, username: String, bio: String) { + pub fn create_profile(env: Env, user: Address, username: Symbol, bio: Symbol) { user_profile::create_profile(env.clone(), user.clone(), username.clone(), bio.clone()); events::emit_profile_created(&env, user, username); } - pub fn update_bio(env: Env, user: Address, new_bio: String) { + pub fn update_bio(env: Env, user: Address, new_bio: Symbol) { user_profile::update_bio(env.clone(), user.clone(), new_bio.clone()); - events::emit_profile_updated(&env, user, String::from_str(&env, "bio")); + events::emit_profile_updated(&env, user, Symbol::new(&env, "bio")); } pub fn reward_contribution(env: Env, admin: Address, user: Address, points: u32) { @@ -741,7 +734,7 @@ impl TaskManagerContract { reputation::get_user_reputation(env, user) } - pub fn get_user_tier(env: Env, user: Address) -> String { + pub fn get_user_tier(env: Env, user: Address) -> Symbol { reputation::get_user_tier(env, user) } @@ -763,7 +756,7 @@ impl TaskManagerContract { let role_name = role_data .as_ref() .map(|r| format_role(&env, &r.role)) - .unwrap_or_else(|| String::from_str(&env, "none")); + .unwrap_or_else(|| Symbol::new(&env, "none")); access_control::revoke_role(env.clone(), admin.clone(), user.clone()); events::emit_role_revoked(&env, user, role_name, admin); @@ -777,7 +770,7 @@ 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: Symbol, description: Symbol) -> u32 { let config = governance::get_config(env.clone()); let proposal_id = governance::create_proposal( @@ -800,9 +793,9 @@ impl TaskManagerContract { 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"), + governance::VoteType::For => Symbol::new(&env, "for"), + governance::VoteType::Against => Symbol::new(&env, "against"), + governance::VoteType::Abstain => Symbol::new(&env, "abstain"), }; events::emit_vote_cast(&env, proposal_id, voter, vote_str, weight); @@ -832,7 +825,6 @@ impl TaskManagerContract { // approval-vote entry point keeps the unprefixed `vote_proposal` name, // which was free. - /// Install the multisig signer set and approval threshold. Admin-only. pub fn configure_multisig( env: Env, admin: Address, @@ -853,11 +845,10 @@ impl TaskManagerContract { events::emit_multisig_configured(&env, admin, signer_count, threshold); } - /// Propose an admin parameter change or treasury movement. Signer-only. pub fn multisig_propose( env: Env, proposer: Address, - description: String, + description: Symbol, action: multisig::MultisigAction, ) -> u32 { let proposal_id = @@ -869,8 +860,6 @@ impl TaskManagerContract { proposal_id } - /// Record an approval vote. Executes the proposal in the same call when - /// this vote reaches the threshold and `auto_execute` is enabled. pub fn vote_proposal( env: Env, signer: Address, @@ -889,7 +878,6 @@ impl TaskManagerContract { status } - /// Execute an already-approved proposal. Signer-only. pub fn multisig_execute_proposal( env: Env, caller: Address, @@ -900,7 +888,6 @@ impl TaskManagerContract { status } - /// Cancel a proposal before execution. Proposer or admin only. pub fn multisig_cancel_proposal(env: Env, caller: Address, proposal_id: u32) { multisig::cancel_proposal(env.clone(), caller.clone(), proposal_id); events::emit_multisig_cancelled(&env, proposal_id, caller); @@ -946,12 +933,12 @@ impl TaskManagerContract { 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); + events::emit_paused(&env, Symbol::new(&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); + events::emit_unpaused(&env, Symbol::new(&env, "all"), admin); } // ======================================================================== @@ -993,10 +980,6 @@ impl TaskManagerContract { swap_router::get_config(env) } - /// Convert an incoming non-standard SAC token into an approved vault - /// stablecoin via a (possibly multi-hop) DEX route, guarded by an - /// oracle-derived minimum-return check. Refunds (rejects without pulling - /// funds) if the route can't be resolved or has no oracle price. pub fn convert_incoming_deposit( env: Env, sender: Address, @@ -1065,7 +1048,6 @@ impl TaskManagerContract { // Multi-Stablecoin Vault // ======================================================================== - /// Register a SAC token address as an accepted vault currency. Admin only. pub fn add_supported_token(env: Env, admin: Address, token: Address) { admin.require_auth(); @@ -1073,16 +1055,15 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if admin != stored_admin { - panic!("only admin can add supported tokens"); + panic!(); } vault::add_supported_token(&env, token.clone()); events::emit_token_supported(&env, token, admin); } - /// Deregister a SAC token address from the accepted vault currencies. Admin only. pub fn remove_supported_token(env: Env, admin: Address, token: Address) { admin.require_auth(); @@ -1090,9 +1071,9 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if admin != stored_admin { - panic!("only admin can remove supported tokens"); + panic!(); } vault::remove_supported_token(&env, token.clone()); @@ -1107,15 +1088,12 @@ impl TaskManagerContract { vault::get_supported_tokens(&env) } - /// Deposit `amount` of `token` into the vault. Token must already be supported. pub fn deposit_to_vault(env: Env, depositor: Address, token: Address, amount: i128) { depositor.require_auth(); vault::deposit(&env, depositor.clone(), token.clone(), amount); events::emit_vault_deposit(&env, depositor, token, amount); } - /// Claim `amount` of `token` out of the vault, drawing down the caller's - /// depositor balance for that specific token. pub fn claim_from_vault(env: Env, claimant: Address, token: Address, amount: i128) { claimant.require_auth(); vault::claim(&env, claimant.clone(), token.clone(), amount); @@ -1146,10 +1124,10 @@ impl TaskManagerContract { .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if admin != stored_admin { - panic!("only admin can set payroll root"); + panic!(); } vault::set_payroll_root(&env, payroll_id, root); @@ -1171,22 +1149,16 @@ impl TaskManagerContract { // Upgrade Timelock & Rollback Guard // ======================================================================== - /// Read the currently configured upgrade timelock delay, in seconds. pub fn get_upgrade_timelock(env: Env) -> u64 { upgrade::get_timelock_seconds(&env) } - /// Reconfigure the upgrade timelock delay. Admin-only; rejects values - /// below `upgrade::MIN_TIMELOCK_SECONDS`. pub fn set_upgrade_timelock(env: Env, admin: Address, seconds: u64) { let old_seconds = upgrade::get_timelock_seconds(&env); let new_seconds = upgrade::set_timelock_seconds(env.clone(), admin.clone(), seconds); events::emit_upgrade_timelock_updated(&env, old_seconds, new_seconds, admin); } - /// Propose upgrading the contract to `new_wasm_hash`. Admin-only. Starts - /// the mandatory timelock window; `execute_upgrade` will reject any - /// attempt to apply the upgrade before `ready_at`. pub fn propose_upgrade( env: Env, admin: Address, @@ -1197,32 +1169,22 @@ impl TaskManagerContract { proposal } - /// Veto the currently pending upgrade proposal. Callable by the admin or - /// any address holding the `Guardian` role during the timelock window. pub fn veto_upgrade(env: Env, guardian: Address) -> upgrade::UpgradeProposal { let proposal = upgrade::veto_upgrade(env.clone(), guardian.clone()); events::emit_upgrade_vetoed(&env, proposal.wasm_hash.clone(), guardian); proposal } - /// Execute the pending upgrade proposal once its timelock has elapsed. - /// Admin-only. Reverts if called early, or if the proposal was already - /// executed or vetoed. pub fn execute_upgrade(env: Env, admin: Address) -> soroban_sdk::BytesN<32> { let wasm_hash = upgrade::execute_upgrade(env.clone(), admin.clone()); events::emit_upgrade_executed(&env, wasm_hash.clone(), admin); wasm_hash } - /// Current state of the (single) pending/most-recently-resolved upgrade - /// proposal, if any has ever been created. pub fn get_pending_upgrade(env: Env) -> Option { upgrade::get_pending_upgrade(&env) } - /// Append-only log of WASM hashes this contract has actually been - /// upgraded to. Used to identify a hash to roll back to via - /// `propose_upgrade`. pub fn get_upgrade_history(env: Env) -> Vec { upgrade::get_upgrade_history(&env) } @@ -1231,22 +1193,16 @@ impl TaskManagerContract { // Reward Treasury: Decay-Curve Vesting & Distribution // ======================================================================== - /// Set the SAC token the reward treasury holds and pays out. Admin-only. pub fn configure_treasury(env: Env, admin: Address, token: Address) { treasury::configure_treasury(env, admin, token); } - /// Deposit `amount` of the treasury token into the reward treasury. - /// Returns the new total treasury balance. pub fn fund_treasury(env: Env, funder: Address, amount: i128) -> i128 { let new_balance = treasury::fund_treasury(env.clone(), funder.clone(), amount); events::emit_treasury_funded(&env, funder, amount, new_balance); new_balance } - /// Create a decay-curve vesting schedule paying `total_amount` to - /// `beneficiary` over time. Admin-only; rejected if it would allocate - /// more than the treasury's currently funded, unallocated balance. pub fn create_vesting_schedule( env: Env, admin: Address, @@ -1277,11 +1233,9 @@ impl TaskManagerContract { schedule_id } - /// Claim everything currently vested-but-unclaimed on `schedule_id`. - /// Beneficiary-only. Returns the amount transferred. pub fn claim_vesting(env: Env, beneficiary: Address, schedule_id: u32) -> i128 { let amount = treasury::claim(env.clone(), beneficiary.clone(), schedule_id); - let schedule = treasury::get_vesting_schedule(&env, schedule_id).unwrap(); + let schedule = treasury::get_vesting_schedule(&env, schedule_id).unwrap_optimized(); events::emit_vesting_claimed( &env, schedule_id, @@ -1300,12 +1254,10 @@ impl TaskManagerContract { treasury::get_beneficiary_schedules(env, beneficiary) } - /// Amount vested so far on `schedule_id`, ignoring claims already made. pub fn get_vested_amount(env: Env, schedule_id: u32) -> i128 { treasury::vested_amount(&env, schedule_id) } - /// Amount currently claimable on `schedule_id`: vested minus claimed. pub fn get_claimable_amount(env: Env, schedule_id: u32) -> i128 { treasury::claimable_amount(&env, schedule_id) } @@ -1323,16 +1275,6 @@ impl TaskManagerContract { // Dispute Split Resolution (module-level for multisig reuse) // ============================================================================ -/// Resolve a disputed task by splitting the escrowed funds among multiple -/// recipients according to percentage shares. -/// -/// - `task_id` – must be in `Disputed` status. -/// - `recipients` – non-empty list of payment recipients. -/// - `shares_bps` – basis-point share per recipient (must sum to 10000). -/// -/// Platform fee is deducted from the total before distribution. Each -/// recipient's final payout is computed proportionally from the -/// fee-reduced balance. pub fn resolve_dispute_split( env: Env, task_id: u32, @@ -1341,21 +1283,21 @@ pub fn resolve_dispute_split( ) { // ── Basic invariants ──────────────────────────────────────────── if recipients.len() == 0 { - panic!("recipients list cannot be empty"); + panic!(); } if recipients.len() != shares_bps.len() { - panic!("recipients and shares must have same length"); + panic!(); } let mut total_bps: u32 = 0; for i in 0..shares_bps.len() { - let bps = shares_bps.get(i).unwrap(); + let bps = shares_bps.get(i).unwrap_optimized(); total_bps = total_bps .checked_add(bps) - .unwrap_or_else(|| panic!("share bps overflow")); + .unwrap_optimized(); } if total_bps != 10000 { - panic!("shares must sum to 10000 (100%)"); + panic!(); } // ── Task state checks ────────────────────────────────────────── @@ -1363,10 +1305,10 @@ pub fn resolve_dispute_split( .storage() .instance() .get(&DataKey::Task(task_id)) - .unwrap_or_else(|| panic!("task not found")); + .unwrap_optimized(); if task.status != TaskStatus::Disputed { - panic!("task is not disputed"); + panic!(); } // ── Fee calculation ──────────────────────────────────────────── @@ -1379,12 +1321,12 @@ pub fn resolve_dispute_split( .storage() .instance() .get(&DataKey::FeeRecipient) - .unwrap(); + .unwrap_optimized(); let token_contract: Address = env .storage() .instance() .get(&DataKey::TokenContract) - .unwrap(); + .unwrap_optimized(); let token_client = soroban_sdk::token::Client::new(&env, &token_contract); let fee = (task.reward * platform_fee_bps as i128) / 10000; @@ -1392,8 +1334,8 @@ pub fn resolve_dispute_split( // ── Distribute ───────────────────────────────────────────────── for i in 0..recipients.len() { - let recipient = recipients.get(i).unwrap(); - let bps = shares_bps.get(i).unwrap(); + let recipient = recipients.get(i).unwrap_optimized(); + let bps = shares_bps.get(i).unwrap_optimized(); let payout = (distributable * bps as i128) / 10000; if payout > 0 { token_client.transfer( @@ -1429,25 +1371,25 @@ pub fn resolve_dispute_split( // Helper Functions // ============================================================================ -fn format_role(env: &Env, role: &access_control::Role) -> String { +fn format_role(env: &Env, role: &access_control::Role) -> Symbol { match role { - access_control::Role::Admin => String::from_str(env, "Admin"), - access_control::Role::Manager => String::from_str(env, "Manager"), - access_control::Role::Moderator => String::from_str(env, "Moderator"), - access_control::Role::Verifier => String::from_str(env, "Verifier"), - access_control::Role::Guardian => String::from_str(env, "Guardian"), + access_control::Role::Admin => Symbol::new(&env, "Admin"), + access_control::Role::Manager => Symbol::new(&env, "Manager"), + access_control::Role::Moderator => Symbol::new(&env, "Moderator"), + access_control::Role::Verifier => Symbol::new(&env, "Verifier"), + access_control::Role::Guardian => Symbol::new(&env, "Guardian"), } } -fn format_pause_action(env: &Env, action: pausable::PauseAction) -> String { +fn format_pause_action(env: &Env, action: pausable::PauseAction) -> Symbol { match action { - pausable::PauseAction::CreateTask => String::from_str(env, "create_task"), - pausable::PauseAction::AssignTask => String::from_str(env, "assign_task"), - pausable::PauseAction::SubmitWork => String::from_str(env, "submit_work"), - pausable::PauseAction::CompleteTask => String::from_str(env, "complete_task"), - pausable::PauseAction::CancelTask => String::from_str(env, "cancel_task"), - pausable::PauseAction::DisputeTask => String::from_str(env, "dispute_task"), - pausable::PauseAction::Withdraw => String::from_str(env, "withdraw"), - pausable::PauseAction::All => String::from_str(env, "all"), + pausable::PauseAction::CreateTask => Symbol::new(&env, "create_task"), + pausable::PauseAction::AssignTask => Symbol::new(&env, "assign_task"), + pausable::PauseAction::SubmitWork => Symbol::new(&env, "submit_work"), + pausable::PauseAction::CompleteTask => Symbol::new(&env, "complete_task"), + pausable::PauseAction::CancelTask => Symbol::new(&env, "cancel_task"), + pausable::PauseAction::DisputeTask => Symbol::new(&env, "dispute_task"), + pausable::PauseAction::Withdraw => Symbol::new(&env, "withdraw"), + pausable::PauseAction::All => Symbol::new(&env, "all"), } } diff --git a/src/merkle.rs b/src/merkle.rs index c2578b27..3f92454e 100644 --- a/src/merkle.rs +++ b/src/merkle.rs @@ -1,11 +1,5 @@ use soroban_sdk::{Bytes, BytesN, Env, Vec}; -/// Verify a Merkle proof against a root hash. -/// -/// * `env` - The Soroban environment. -/// * `root` - The Merkle root. -/// * `leaf` - The leaf node hash to verify. -/// * `proof` - The array of sibling hashes making up the proof. pub fn verify_merkle_proof( env: &Env, root: &BytesN<32>, diff --git a/src/multisig.rs b/src/multisig.rs index 173db328..8c9fa2a8 100644 --- a/src/multisig.rs +++ b/src/multisig.rs @@ -1,4 +1,5 @@ -use soroban_sdk::{contracttype, Address, Env, String, Vec}; +use soroban_sdk::unwrap::UnwrapOptimized; +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; use crate::DataKey; @@ -43,60 +44,43 @@ use crate::DataKey; // Types // ============================================================================ -/// Lifecycle state of a multisig proposal. #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] #[repr(u32)] pub enum MultisigProposalStatus { - /// Awaiting further approvals. Pending = 0, - /// Threshold reached; the action may be executed. Approved = 1, - /// Action has been applied on-chain. Terminal. Executed = 2, - /// Withdrawn before execution. Terminal. Cancelled = 3, } -/// The state change a proposal will perform once executed. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum MultisigAction { - /// Update the platform fee, in basis points (max 1000 = 10%). SetPlatformFee(u32), - /// Update the address that receives platform fees. SetFeeRecipient(Address), - /// Update the payment token contract. SetTokenContract(Address), - /// Move funds out of the contract treasury: `(token, recipient, amount)`. TreasuryTransfer(Address, Address, i128), - /// Rotate the signer set and threshold: `(signers, threshold)`. SetSigners(Vec
, u32), - /// Resolve a disputed task with a multi-recipient split. - /// `(task_id, recipients, share_bps)`. ResolveDisputeSplit(u32, Vec
, Vec), } -/// A single signer's recorded approval — the on-chain ledger entry proving -/// who approved what, and when. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct MultisigApproval { pub signer: Address, pub approved_at: u64, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct MultisigProposal { pub id: u32, pub proposer: Address, - pub description: String, + pub description: Symbol, pub action: MultisigAction, pub status: MultisigProposalStatus, - /// Full approval ledger, in the order approvals were received. pub approvals: Vec, - /// Approvals required, snapshotted from config at creation time. pub threshold: u32, pub created_at: u64, pub expires_at: u64, @@ -104,14 +88,11 @@ pub struct MultisigProposal { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct MultisigConfig { pub signers: Vec
, - /// Number of distinct signer approvals required to execute. pub threshold: u32, - /// Seconds a proposal stays actionable after creation. pub proposal_ttl: u64, - /// Execute immediately when the approving vote reaches the threshold. pub auto_execute: bool, } @@ -122,11 +103,8 @@ pub enum MultisigKey { ProposalCount, } -/// Default proposal lifetime: 7 days. pub const DEFAULT_PROPOSAL_TTL: u64 = 604_800; -/// Upper bound on the signer set, keeping approval re-validation (a linear -/// scan per approval) cheaply bounded. pub const MAX_SIGNERS: u32 = 20; // ============================================================================ @@ -139,42 +117,39 @@ fn require_admin(env: &Env, caller: &Address) { .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if *caller != admin { - panic!("not admin"); + panic!(); } } -/// Validate a prospective signer set and threshold, panicking if unusable. fn validate_signer_set(signers: &Vec
, threshold: u32) { let count = signers.len(); if count == 0 { - panic!("signer set cannot be empty"); + panic!(); } if count > MAX_SIGNERS { - panic!("signer set exceeds maximum"); + panic!(); } if threshold == 0 { - panic!("threshold must be greater than zero"); + panic!(); } if threshold > count { - panic!("threshold exceeds signer count"); + panic!(); } // Reject duplicates: a repeated address would otherwise inflate the // effective signer count while contributing only one approval. for i in 0..count { - let signer = signers.get(i).unwrap(); + let signer = signers.get(i).unwrap_optimized(); for j in (i + 1)..count { - if signers.get(j).unwrap() == signer { - panic!("duplicate signer in set"); + if signers.get(j).unwrap_optimized() == signer { + panic!(); } } } } -/// Install the multisig signer set. Admin-only; this is the bootstrap step -/// that hands ongoing parameter/treasury authority to the signer group. pub fn configure( env: Env, admin: Address, @@ -196,12 +171,11 @@ pub fn configure( env.storage().instance().set(&MultisigKey::Config, &config); } -/// Read the multisig config, panicking if it was never installed. pub fn get_config(env: &Env) -> MultisigConfig { env.storage() .instance() .get(&MultisigKey::Config) - .unwrap_or_else(|| panic!("multisig not configured")) + .unwrap_optimized() } pub fn is_signer(env: &Env, who: &Address) -> bool { @@ -214,7 +188,7 @@ pub fn is_signer(env: &Env, who: &Address) -> bool { fn require_signer(env: &Env, who: &Address) { if !is_signer(env, who) { - panic!("not a multisig signer"); + panic!(); } } @@ -233,18 +207,16 @@ fn save_proposal(env: &Env, proposal: &MultisigProposal) { ); } -/// Validate that an action is well-formed before signers spend approvals on -/// it, so a proposal cannot reach threshold only to trap at execution. fn validate_action(env: &Env, action: &MultisigAction) { match action { MultisigAction::SetPlatformFee(bps) => { if *bps > 1000 { - panic!("platform fee cannot exceed 10%"); + panic!(); } } MultisigAction::TreasuryTransfer(_, _, amount) => { if *amount <= 0 { - panic!("transfer amount must be positive"); + panic!(); } } MultisigAction::SetSigners(signers, threshold) => { @@ -252,18 +224,18 @@ fn validate_action(env: &Env, action: &MultisigAction) { } MultisigAction::ResolveDisputeSplit(_task_id, recipients, shares_bps) => { if recipients.len() == 0 { - panic!("recipients list cannot be empty"); + panic!(); } if recipients.len() != shares_bps.len() { - panic!("recipients and shares must have same length"); + panic!(); } let mut total_bps: u32 = 0; for i in 0..shares_bps.len() { - let bps = shares_bps.get(i).unwrap(); - total_bps = total_bps.checked_add(bps).unwrap_or_else(|| panic!("share bps overflow")); + let bps = shares_bps.get(i).unwrap_optimized(); + total_bps = total_bps.checked_add(bps).unwrap_optimized(); } if total_bps != 10000 { - panic!("shares must sum to 10000 (100%)"); + panic!(); } } MultisigAction::SetFeeRecipient(_) | MultisigAction::SetTokenContract(_) => { @@ -272,16 +244,10 @@ fn validate_action(env: &Env, action: &MultisigAction) { } } -/// Create a proposal. Restricted to signers — an outsider should not be able -/// to fill the ledger with proposals the signer set has to triage. -/// -/// The proposer is *not* auto-approved: approval is an explicit, separately -/// authorized act, so a 1-of-N misconfiguration cannot silently execute on -/// creation alone. pub fn propose( env: Env, proposer: Address, - description: String, + description: Symbol, action: MultisigAction, ) -> u32 { proposer.require_auth(); @@ -320,30 +286,25 @@ pub fn propose( count } -/// Record `signer`'s approval of `proposal_id`. -/// -/// Returns the proposal's status after the vote. When the threshold is met the -/// proposal moves to `Approved`, and — if `auto_execute` is enabled — the -/// action is applied in the same call, returning `Executed`. pub fn vote_proposal(env: Env, signer: Address, proposal_id: u32) -> MultisigProposalStatus { signer.require_auth(); require_signer(&env, &signer); let mut proposal = get_proposal(&env, proposal_id) - .unwrap_or_else(|| panic!("proposal not found")); + .unwrap_optimized(); if proposal.status != MultisigProposalStatus::Pending { - panic!("proposal not pending"); + panic!(); } let now = env.ledger().timestamp(); if now > proposal.expires_at { - panic!("proposal expired"); + panic!(); } for approval in proposal.approvals.iter() { if approval.signer == signer { - panic!("signer already approved"); + panic!(); } } @@ -378,28 +339,23 @@ pub fn vote_proposal(env: Env, signer: Address, proposal_id: u32) -> MultisigPro proposal.status } -/// Execute an `Approved` proposal, applying its action on-chain. -/// -/// Any signer may trigger execution — the authority came from the approvals -/// already on the ledger, not from the caller. Used when `auto_execute` is -/// disabled, or to retry an execution whose earlier attempt trapped. pub fn execute_proposal(env: Env, caller: Address, proposal_id: u32) -> MultisigProposalStatus { caller.require_auth(); require_signer(&env, &caller); let mut proposal = get_proposal(&env, proposal_id) - .unwrap_or_else(|| panic!("proposal not found")); + .unwrap_optimized(); match proposal.status { MultisigProposalStatus::Approved => {} - MultisigProposalStatus::Pending => panic!("proposal not yet approved"), - MultisigProposalStatus::Executed => panic!("proposal already executed"), - MultisigProposalStatus::Cancelled => panic!("proposal cancelled"), + MultisigProposalStatus::Pending => panic!(), + MultisigProposalStatus::Executed => panic!(), + MultisigProposalStatus::Cancelled => panic!(), } let now = env.ledger().timestamp(); if now > proposal.expires_at { - panic!("proposal expired"); + panic!(); } // Re-check against the live signer set: approvals from signers removed @@ -412,7 +368,7 @@ pub fn execute_proposal(env: Env, caller: Address, proposal_id: u32) -> Multisig } } if valid < proposal.threshold { - panic!("approvals no longer meet threshold"); + panic!(); } apply_action(&env, &proposal.action); @@ -424,28 +380,26 @@ pub fn execute_proposal(env: Env, caller: Address, proposal_id: u32) -> Multisig proposal.status } -/// Cancel a proposal before execution. Allowed for the original proposer or -/// the contract admin. pub fn cancel_proposal(env: Env, caller: Address, proposal_id: u32) { caller.require_auth(); let mut proposal = get_proposal(&env, proposal_id) - .unwrap_or_else(|| panic!("proposal not found")); + .unwrap_optimized(); match proposal.status { MultisigProposalStatus::Pending | MultisigProposalStatus::Approved => {} - MultisigProposalStatus::Executed => panic!("proposal already executed"), - MultisigProposalStatus::Cancelled => panic!("proposal already cancelled"), + MultisigProposalStatus::Executed => panic!(), + MultisigProposalStatus::Cancelled => panic!(), } let admin: Address = env .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if caller != proposal.proposer && caller != admin { - panic!("only proposer or admin can cancel"); + panic!(); } proposal.status = MultisigProposalStatus::Cancelled; @@ -456,15 +410,11 @@ pub fn cancel_proposal(env: Env, caller: Address, proposal_id: u32) { // Execution // ============================================================================ -/// Apply a proposal's encoded action to contract state. -/// -/// Called only after threshold approval has been verified. Traps on failure, -/// reverting the enclosing transaction. fn apply_action(env: &Env, action: &MultisigAction) { match action { MultisigAction::SetPlatformFee(bps) => { if *bps > 1000 { - panic!("platform fee cannot exceed 10%"); + panic!(); } env.storage().instance().set(&DataKey::PlatformFeeBps, bps); } @@ -476,7 +426,7 @@ fn apply_action(env: &Env, action: &MultisigAction) { } MultisigAction::TreasuryTransfer(token, recipient, amount) => { if *amount <= 0 { - panic!("transfer amount must be positive"); + panic!(); } soroban_sdk::token::Client::new(env, token).transfer( &env.current_contract_address(), @@ -514,8 +464,6 @@ pub fn get_proposal_count(env: &Env) -> u32 { .unwrap_or(0) } -/// Number of approvals on `proposal_id` that are still backed by a current -/// signer — the figure compared against the threshold. pub fn get_approval_count(env: &Env, proposal_id: u32) -> u32 { let proposal = match get_proposal(env, proposal_id) { Some(p) => p, @@ -538,7 +486,6 @@ pub fn has_approved(env: &Env, proposal_id: u32, signer: &Address) -> bool { } } -/// All proposals still awaiting approvals and not yet expired. pub fn get_pending_proposals(env: &Env) -> Vec { let count = get_proposal_count(env); let now = env.ledger().timestamp(); diff --git a/src/multisig_test.rs b/src/multisig_test.rs index 920f41d2..edc5b930 100644 --- a/src/multisig_test.rs +++ b/src/multisig_test.rs @@ -1,3 +1,4 @@ +use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] @@ -5,7 +6,7 @@ use crate::multisig::{MultisigAction, MultisigProposalStatus}; use crate::{TaskManagerContract, TaskManagerContractClient}; use soroban_sdk::testutils::{Address as _, Ledger}; use soroban_sdk::token::StellarAssetClient; -use soroban_sdk::{Address, Env, String, Vec}; +use soroban_sdk::{Address, Env, Symbol, Vec}; // ── Shared setup ─────────────────────────────────────────────────────────── @@ -20,7 +21,6 @@ struct Ctx { signers: Vec
, } -/// Initialize the contract and install an N-signer multisig with `threshold`. fn setup(env: &Env, signer_count: u32, threshold: u32) -> Ctx { let contract_id = env.register_contract(None, TaskManagerContract); let client = TaskManagerContractClient::new(env, &contract_id); @@ -51,11 +51,11 @@ fn setup(env: &Env, signer_count: u32, threshold: u32) -> Ctx { } fn signer(ctx: &Ctx, i: u32) -> Address { - ctx.signers.get(i).unwrap() + ctx.signers.get(i).unwrap_optimized() } -fn desc(env: &Env) -> String { - String::from_str(env, "raise platform fee to 2.5%") +fn desc(env: &Env) -> Symbol { + Symbol::new(&env, "raise platform fee to 2.5%") } // ── Configuration ────────────────────────────────────────────────────────── @@ -142,7 +142,7 @@ fn test_propose_creates_pending_proposal() { ); assert_eq!(id, 1); - let proposal = ctx.client.get_multisig_proposal(&id).unwrap(); + let proposal = ctx.client.get_multisig_proposal(&id).unwrap_optimized(); assert_eq!(proposal.status, MultisigProposalStatus::Pending); assert_eq!(proposal.threshold, 2); assert_eq!(proposal.proposer, signer(&ctx, 0)); @@ -305,7 +305,7 @@ fn test_threshold_auto_executes_parameter_change() { MultisigProposalStatus::Executed ); - let proposal = ctx.client.get_multisig_proposal(&id).unwrap(); + let proposal = ctx.client.get_multisig_proposal(&id).unwrap_optimized(); assert_eq!(proposal.status, MultisigProposalStatus::Executed); assert!(proposal.executed_at.is_some()); assert_eq!(proposal.approvals.len(), 2); @@ -424,7 +424,7 @@ fn test_treasury_transfer_executes_on_threshold() { let id = ctx.client.multisig_propose( &signer(&ctx, 0), - &String::from_str(&env, "pay grant"), + &Symbol::new(&env, "pay grant"), &MultisigAction::TreasuryTransfer(ctx.token.clone(), recipient.clone(), 400), ); @@ -448,7 +448,7 @@ fn test_treasury_transfer_beyond_balance_reverts_whole_call() { let recipient = Address::generate(&env); let id = ctx.client.multisig_propose( &signer(&ctx, 0), - &String::from_str(&env, "overdraw"), + &Symbol::new(&env, "overdraw"), &MultisigAction::TreasuryTransfer(ctx.token.clone(), recipient.clone(), 5_000), ); @@ -459,7 +459,7 @@ fn test_treasury_transfer_beyond_balance_reverts_whole_call() { let res = ctx.client.try_vote_proposal(&signer(&ctx, 1), &id); assert!(res.is_err(), "underfunded treasury transfer must revert"); - let proposal = ctx.client.get_multisig_proposal(&id).unwrap(); + let proposal = ctx.client.get_multisig_proposal(&id).unwrap_optimized(); assert_eq!( proposal.status, MultisigProposalStatus::Pending, @@ -491,7 +491,7 @@ fn test_signer_rotation_via_proposal() { let id = ctx.client.multisig_propose( &signer(&ctx, 0), - &String::from_str(&env, "rotate signers"), + &Symbol::new(&env, "rotate signers"), &MultisigAction::SetSigners(new_set, 2), ); @@ -563,7 +563,7 @@ fn test_threshold_is_snapshotted_at_creation() { &desc(&env), &MultisigAction::SetPlatformFee(250), ); - assert_eq!(ctx.client.get_multisig_proposal(&id).unwrap().threshold, 4); + assert_eq!(ctx.client.get_multisig_proposal(&id).unwrap_optimized().threshold, 4); // Admin lowers the live threshold to 2 after the proposal was created. ctx.client @@ -601,7 +601,7 @@ fn test_proposer_can_cancel() { ); ctx.client.multisig_cancel_proposal(&signer(&ctx, 0), &id); - let proposal = ctx.client.get_multisig_proposal(&id).unwrap(); + let proposal = ctx.client.get_multisig_proposal(&id).unwrap_optimized(); assert_eq!(proposal.status, MultisigProposalStatus::Cancelled); let res = ctx.client.try_vote_proposal(&signer(&ctx, 1), &id); @@ -622,7 +622,7 @@ fn test_admin_can_cancel_any_proposal() { ctx.client.multisig_cancel_proposal(&ctx.admin, &id); assert_eq!( - ctx.client.get_multisig_proposal(&id).unwrap().status, + ctx.client.get_multisig_proposal(&id).unwrap_optimized().status, MultisigProposalStatus::Cancelled ); } @@ -768,18 +768,16 @@ fn test_pending_proposals_listing() { let pending = ctx.client.get_pending_multisig_proposals(); assert_eq!(pending.len(), 1, "only the untouched proposal stays pending"); - assert_eq!(pending.get(0).unwrap().id, keep); + assert_eq!(pending.get(0).unwrap_optimized().id, keep); } // ── Helpers ──────────────────────────────────────────────────────────────── -/// Read the live platform fee straight from contract storage, to confirm an -/// executed proposal actually mutated state rather than only its own record. fn fee_bps(env: &Env, ctx: &Ctx) -> u32 { env.as_contract(&ctx.contract_id, || { env.storage() .instance() .get(&crate::DataKey::PlatformFeeBps) - .unwrap() + .unwrap_optimized() }) } diff --git a/src/pausable.rs b/src/pausable.rs index c5a567ec..c6843f4a 100644 --- a/src/pausable.rs +++ b/src/pausable.rs @@ -1,31 +1,19 @@ +use soroban_sdk::unwrap::UnwrapOptimized; use crate::DataKey; use soroban_sdk::{contracttype, Address, Env}; -/// Granular pause/unpause system for the LatterFix contract. -/// -/// Two levels of pause are supported: -/// -/// 1. **Global action pause** — disables a specific `PauseAction` for all -/// users (e.g. pause `CreateTask` during a security review). -/// 2. **User-specific pause** — blocks a particular user from performing an -/// action (e.g. ban a bad actor from `SubmitWork`). -/// -/// Pause state is stored in `instance()` storage (fast, cheap, never archived). -/// Every state-changing function in `lib.rs` calls `require_not_paused()` before -/// performing its business logic. // ── Types ────────────────────────────────────────────────────────────────── #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] pub enum PauseState { NotPaused, Paused, } -/// Every public action that can be independently paused. #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] pub enum PauseAction { CreateTask, AssignTask, @@ -34,7 +22,6 @@ pub enum PauseAction { CancelTask, DisputeTask, Withdraw, - /// Synthetic sentinel used to check "is the whole contract paused?" All, } @@ -58,37 +45,33 @@ const ALL_ACTIONS: [PauseAction; 7] = [ // ── Global pause helpers ─────────────────────────────────────────────────── -/// Pause a specific action globally. Requires admin auth. pub fn pause(env: Env, admin: Address, action: PauseAction) { admin.require_auth(); - let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored { - panic!("not admin"); + panic!(); } env.storage() .instance() .set(&PauseKey::Action(action), &PauseState::Paused); } -/// Resume a specific action globally. Requires admin auth. pub fn unpause(env: Env, admin: Address, action: PauseAction) { admin.require_auth(); - let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored { - panic!("not admin"); + panic!(); } env.storage() .instance() .set(&PauseKey::Action(action), &PauseState::NotPaused); } -/// Pause ALL contract actions in a single call. Emergency circuit-breaker. -/// Requires admin auth. pub fn pause_all(env: Env, admin: Address) { admin.require_auth(); - let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored { - panic!("not admin"); + panic!(); } // Mark the synthetic All sentinel so is_globally_paused() is O(1) env.storage() @@ -101,12 +84,11 @@ pub fn pause_all(env: Env, admin: Address) { } } -/// Unpause ALL contract actions. Requires admin auth. pub fn unpause_all(env: Env, admin: Address) { admin.require_auth(); - let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored { - panic!("not admin"); + panic!(); } env.storage() .instance() @@ -120,24 +102,22 @@ pub fn unpause_all(env: Env, admin: Address) { // ── User-specific pause helpers ──────────────────────────────────────────── -/// Block a specific user from a specific action. Requires admin auth. pub fn pause_for_user(env: Env, admin: Address, user: Address, action: PauseAction) { admin.require_auth(); - let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored { - panic!("not admin"); + panic!(); } env.storage() .instance() .set(&PauseKey::UserPause(user, action), &true); } -/// Unblock a specific user from a specific action. Requires admin auth. pub fn unpause_for_user(env: Env, admin: Address, user: Address, action: PauseAction) { admin.require_auth(); - let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored { - panic!("not admin"); + panic!(); } env.storage() .instance() @@ -146,7 +126,6 @@ pub fn unpause_for_user(env: Env, admin: Address, user: Address, action: PauseAc // ── Query helpers ────────────────────────────────────────────────────────── -/// Returns true if the `All` sentinel is set — O(1) global pause check. pub fn is_globally_paused(env: &Env) -> bool { let state: PauseState = env .storage() @@ -156,8 +135,6 @@ pub fn is_globally_paused(env: &Env) -> bool { state == PauseState::Paused } -/// Returns true if `action` is paused globally OR if `user` is individually -/// blocked for that action. pub fn is_paused(env: Env, action: PauseAction, user: Option
) -> bool { // Fast path: whole contract suspended if is_globally_paused(&env) { @@ -185,10 +162,8 @@ pub fn is_paused(env: Env, action: PauseAction, user: Option
) -> bool { } } -/// Convenience guard: panics with a descriptive message if `action` is paused. -/// Call at the top of every public mutator in `lib.rs`. pub fn require_not_paused(env: Env, action: PauseAction, user: Option
) { if is_paused(env, action, user) { - panic!("action is currently paused by admin"); + panic!(); } } diff --git a/src/reputation.rs b/src/reputation.rs index 86287671..4d4bc440 100644 --- a/src/reputation.rs +++ b/src/reputation.rs @@ -1,18 +1,19 @@ -use soroban_sdk::{contracttype, Address, Env, Map, String, Vec}; +use soroban_sdk::unwrap::UnwrapOptimized; +use soroban_sdk::{contracttype, Address, Env, Map, Symbol, Vec}; #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct ReputationEvent { pub user: Address, pub points: i32, pub event_type: ReputationEventType, pub timestamp: u64, pub reference_id: Option, // Task ID or other reference - pub description: String, + pub description: Symbol, } #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] pub enum ReputationEventType { TaskCompleted, TaskVerified, @@ -26,20 +27,20 @@ pub enum ReputationEventType { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct ReputationTier { - pub name: String, + pub name: Symbol, pub min_points: u32, pub max_points: u32, - pub badge_url: Option, + pub badge_url: Option, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct UserReputationSummary { pub user: Address, pub total_points: u32, - pub tier: String, + pub tier: Symbol, pub tasks_completed: u32, pub tasks_verified: u32, pub disputes_won: u32, @@ -62,31 +63,31 @@ pub fn init_reputation_tiers(env: Env) { let tiers: Vec = { let mut v = Vec::new(&env); v.push_back(ReputationTier { - name: String::from_str(&env, "Newcomer"), + name: Symbol::new(&env, "Newcomer"), min_points: 0, max_points: 99, badge_url: None, }); v.push_back(ReputationTier { - name: String::from_str(&env, "Contributor"), + name: Symbol::new(&env, "Contributor"), min_points: 100, max_points: 499, badge_url: None, }); v.push_back(ReputationTier { - name: String::from_str(&env, "Expert"), + name: Symbol::new(&env, "Expert"), min_points: 500, max_points: 999, badge_url: None, }); v.push_back(ReputationTier { - name: String::from_str(&env, "Master"), + name: Symbol::new(&env, "Master"), min_points: 1000, max_points: 2499, badge_url: None, }); v.push_back(ReputationTier { - name: String::from_str(&env, "Legend"), + name: Symbol::new(&env, "Legend"), min_points: 2500, max_points: u32::MAX, badge_url: None, @@ -105,7 +106,7 @@ pub fn award_reputation( points: i32, event_type: ReputationEventType, reference_id: Option, - description: String, + description: Symbol, ) { let key = ReputationKey::UserPoints(user.clone()); let mut current: i32 = env.storage().persistent().get(&key).unwrap_or(100i32); // Starting reputation @@ -143,7 +144,7 @@ pub fn get_user_reputation(env: Env, user: Address) -> u32 { env.storage().persistent().get(&key).unwrap_or(100) as u32 } -pub fn get_user_tier(env: Env, user: Address) -> String { +pub fn get_user_tier(env: Env, user: Address) -> Symbol { let points = get_user_reputation(env.clone(), user); let tiers: Vec = env @@ -156,7 +157,7 @@ pub fn get_user_tier(env: Env, user: Address) -> String { env.storage() .persistent() .get(&ReputationKey::ReputationTiers) - .unwrap() + .unwrap_optimized() }); for tier in tiers.iter() { @@ -165,7 +166,7 @@ pub fn get_user_tier(env: Env, user: Address) -> String { } } - String::from_str(&env, "Unknown") + Symbol::new(&env, "Unknown") } pub fn update_leaderboard(env: &Env, user: Address, points: u32) { diff --git a/src/storage.rs b/src/storage.rs index 2f81dab4..f4796bed 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,23 +1,21 @@ -//! 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}; +use soroban_sdk::unwrap::UnwrapOptimized; +// 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, Symbol, Vec}; // ── TTL Constants ────────────────────────────────────────────────────────── -/// Maximum persistent TTL: ~31 days at 5-second ledger close time. pub const MAX_PERSISTENT_TTL: u32 = 5_200_000; -/// Default TTL for persistent user/task data: ~14 days. pub const DEFAULT_PERSISTENT_TTL: u32 = 2_073_600; -/// Short-lived TTL for temporary session data: ~7 days. pub const TEMP_SESSION_TTL: u32 = 120_960; // ── Storage Key Enum ─────────────────────────────────────────────────────── @@ -33,7 +31,6 @@ pub enum StorageKey { // ── TTL Helpers ──────────────────────────────────────────────────────────── -/// Calculate optimal TTL based on whether data is permanent. pub fn calculate_ttl(_env: &Env, is_permanent: bool) -> u32 { if is_permanent { MAX_PERSISTENT_TTL @@ -42,12 +39,6 @@ pub fn calculate_ttl(_env: &Env, is_permanent: bool) -> u32 { } } -/// Extend TTL for a persistent storage entry if it is below the threshold. -/// Call this after every write to prevent unexpected archival. -/// -/// * `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 K: soroban_sdk::IntoVal, @@ -57,15 +48,12 @@ where .extend_ttl(key, threshold, extend_to); } -/// Extend TTL for all core contract statistics on every state change. -/// Prevents the statistics storage entry from being archived mid-operation. pub fn refresh_statistics_ttl(env: &Env) { env.storage() .persistent() .extend_ttl(&StorageKey::Statistics, 100_000, DEFAULT_PERSISTENT_TTL); } -/// Extend TTL for the categories index after any write. pub fn refresh_categories_ttl(env: &Env) { env.storage() .persistent() @@ -75,7 +63,7 @@ pub fn refresh_categories_ttl(env: &Env) { // ── Storage Metadata ─────────────────────────────────────────────────────── #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct StorageStats { pub total_entries: u32, pub total_size_bytes: u64, @@ -85,16 +73,16 @@ pub struct StorageStats { // ── Category Management ──────────────────────────────────────────────────── #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Category { pub id: u32, - pub name: String, - pub description: String, + pub name: Symbol, + pub description: Symbol, pub task_count: u32, pub created_at: u64, } -pub fn add_category(env: &Env, name: String, description: String) -> u32 { +pub fn add_category(env: &Env, name: Symbol, description: Symbol) -> u32 { let mut categories: Vec = env .storage() .persistent() @@ -134,7 +122,7 @@ pub fn increment_category_task_count(env: &Env, category_id: u32) { .unwrap_or_else(|| Vec::new(env)); for i in 0..categories.len() { - let mut category = categories.get(i).unwrap(); + let mut category = categories.get(i).unwrap_optimized(); if category.id == category_id { category.task_count += 1; categories.set(i, category); @@ -151,10 +139,8 @@ pub fn increment_category_task_count(env: &Env, category_id: u32) { // ── Statistics Tracking ──────────────────────────────────────────────────── -/// Aggregate statistics stored in persistent storage. -/// Updated atomically on every contract state change. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq, Default)] +#[derive(Clone, Eq, PartialEq, Default)] pub struct ContractStatistics { pub total_tasks_created: u32, pub total_tasks_completed: u32, diff --git a/src/swap_router.rs b/src/swap_router.rs index b64f2124..c4254fa5 100644 --- a/src/swap_router.rs +++ b/src/swap_router.rs @@ -1,28 +1,29 @@ -//! 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 soroban_sdk::unwrap::UnwrapOptimized; +// 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, Symbol, Vec}; use crate::DataKey; @@ -36,10 +37,6 @@ const BPS_DENOMINATOR: i128 = 10_000; #[contractclient(name = "PoolClient")] pub trait PoolInterface { - /// Swap `amount_in` of `token_in` (already transferred to the pool by the - /// caller) for `token_out`, paying the result to `to`. Implementations - /// should reject if the computed output is below `min_amount_out`. - /// Returns the actual amount of `token_out` paid out. fn swap( env: Env, amount_in: i128, @@ -52,8 +49,6 @@ pub trait PoolInterface { #[contractclient(name = "OracleClient")] pub trait OracleInterface { - /// Latest price of `asset`, scaled by 10^ORACLE_PRICE_DECIMALS. - /// Returns `None` if no fresh price is available. fn price(env: Env, asset: Address) -> Option; } @@ -62,26 +57,22 @@ pub trait OracleInterface { // ============================================================================ #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct SwapRoute { - /// Token addresses along the route: `[token_in, hop_1, ..., stablecoin_out]`. pub path: Vec
, - /// Pool contract address for each hop; `pools.len() == path.len() - 1`. pub pools: Vec
, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct RouterConfig { pub oracle: Address, pub max_hops: u32, - /// Default slippage tolerance in basis points, applied when a caller - /// doesn't supply their own. pub default_slippage_bps: u32, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq, Default)] +#[derive(Clone, Eq, PartialEq, Default)] pub struct SwapRouterStats { pub total_conversions: u32, pub total_refunds: u32, @@ -89,10 +80,10 @@ pub struct SwapRouterStats { } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum ConversionOutcome { Converted(Address, i128), // (token_out, amount_out) - Refunded(String), // reason + Refunded(Symbol), // reason } #[contracttype] @@ -113,9 +104,9 @@ fn require_admin(env: &Env, caller: &Address) { .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if *caller != admin { - panic!("not admin"); + panic!(); } } @@ -129,10 +120,10 @@ pub fn configure( require_admin(&env, &admin); if max_hops == 0 { - panic!("max hops must be positive"); + panic!(); } if default_slippage_bps as i128 > BPS_DENOMINATOR { - panic!("slippage bps out of range"); + panic!(); } let config = RouterConfig { @@ -149,7 +140,7 @@ pub fn get_config(env: Env) -> RouterConfig { env.storage() .instance() .get(&SwapRouterKey::Config) - .unwrap_or_else(|| panic!("swap router not configured")) + .unwrap_optimized() } pub fn add_approved_stablecoin(env: Env, admin: Address, stablecoin: Address) { @@ -223,9 +214,9 @@ fn execute_route(env: &Env, route: &SwapRoute, amount_in: i128, vault: &Address) let this_contract = env.current_contract_address(); for i in 0..hops { - let token_in = route.path.get(i).unwrap(); - let token_out = route.path.get(i + 1).unwrap(); - let pool = route.pools.get(i).unwrap(); + let token_in = route.path.get(i).unwrap_optimized(); + let token_out = route.path.get(i + 1).unwrap_optimized(); + let pool = route.pools.get(i).unwrap_optimized(); let is_last_hop = i + 1 == hops; let hop_recipient = if is_last_hop { vault.clone() @@ -270,13 +261,13 @@ pub fn withdraw_stablecoin(env: Env, owner: Address, stablecoin: Address, amount owner.require_auth(); if amount <= 0 { - panic!("amount must be positive"); + panic!(); } let key = SwapRouterKey::VaultBalance(owner.clone(), stablecoin.clone()); let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); if amount > current { - panic!("insufficient vault balance"); + panic!(); } env.storage().persistent().set(&key, &(current - amount)); @@ -312,15 +303,6 @@ fn update_stats(env: &Env, conversions_delta: u32, refunds_delta: u32, stablecoi // Main entrypoint // ============================================================================ -/// Convert `amount_in` of `token_in` held by `sender` into one of the -/// approved vault stablecoins, following `route`. -/// -/// Route shape, destination-stablecoin approval, and oracle pricing are all -/// validated *before* any funds move — if any of those checks fail, nothing -/// is pulled from `sender` and `ConversionOutcome::Refunded` is returned. If -/// the swap itself fails to clear the oracle-derived minimum-return guard, -/// the whole call traps and the transaction (including the initial pull) is -/// reverted by the host, so the sender is refunded atomically. pub fn convert_incoming_deposit( env: Env, sender: Address, @@ -332,31 +314,31 @@ pub fn convert_incoming_deposit( sender.require_auth(); if amount_in <= 0 { - panic!("amount must be positive"); + panic!(); } let config = get_config(env.clone()); let slippage = slippage_bps.unwrap_or(config.default_slippage_bps); if slippage as i128 > BPS_DENOMINATOR { - panic!("slippage bps out of range"); + panic!(); } if !validate_route(&env, &config, &token_in, &route) { update_stats(&env, 0, 1, 0); - return ConversionOutcome::Refunded(String::from_str( + return ConversionOutcome::Refunded(Symbol::new( &env, "swap route could not be resolved", )); } - let stablecoin_out = route.path.get(route.path.len() - 1).unwrap(); + let stablecoin_out = route.path.get(route.path.len() - 1).unwrap_optimized(); let oracle = OracleClient::new(&env, &config.oracle); let price_in = match oracle.try_price(&token_in) { Ok(Ok(Some(p))) if p > 0 => p, _ => { update_stats(&env, 0, 1, 0); - return ConversionOutcome::Refunded(String::from_str( + return ConversionOutcome::Refunded(Symbol::new( &env, "no oracle price for input asset", )); @@ -366,7 +348,7 @@ 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( + return ConversionOutcome::Refunded(Symbol::new( &env, "no oracle price for output asset", )); @@ -377,7 +359,7 @@ pub fn convert_incoming_deposit( // conversion against a manipulated/thin DEX pool price. let expected_out = amount_in .checked_mul(price_in) - .unwrap_or_else(|| panic!("overflow computing expected output")) + .unwrap_optimized() / price_out; let min_out = expected_out * (BPS_DENOMINATOR - slippage as i128) / BPS_DENOMINATOR; @@ -392,7 +374,7 @@ pub fn convert_incoming_deposit( let amount_out = execute_route(&env, &route, amount_in, &vault); if amount_out < min_out { - panic!("swap output below minimum acceptable return"); + panic!(); } credit_vault_balance(&env, &sender, &stablecoin_out, amount_out); diff --git a/src/swap_router_test.rs b/src/swap_router_test.rs index 35dcd91d..c631764a 100644 --- a/src/swap_router_test.rs +++ b/src/swap_router_test.rs @@ -41,7 +41,7 @@ impl MockPool { let amount_out = amount_in * rate / 10_000; if amount_out < min_amount_out { - panic!("mock pool: insufficient output"); + panic!(); } soroban_sdk::token::Client::new(&env, &token_out).transfer( @@ -154,7 +154,7 @@ fn test_direct_swap_success() { assert_eq!(token_out, stablecoin); assert_eq!(amount_out, 1_000); } - ConversionOutcome::Refunded(_) => panic!("expected a successful conversion"), + ConversionOutcome::Refunded(_) => panic!(), } let token_in_client = soroban_sdk::token::Client::new(&env, &token_in); @@ -213,7 +213,7 @@ fn test_multi_hop_swap_success() { assert_eq!(token_out, stablecoin); assert_eq!(amount_out, 960); } - ConversionOutcome::Refunded(_) => panic!("expected a successful multi-hop conversion"), + ConversionOutcome::Refunded(_) => panic!(), } assert_eq!(client.get_vault_balance(&sender, &stablecoin), 960); diff --git a/src/test.rs b/src/test.rs index 8fc97b65..f8564a85 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,3 +1,4 @@ +use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] @@ -6,7 +7,7 @@ use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::Ledger as _; use soroban_sdk::token::StellarAssetClient; use soroban_sdk::xdr::ToXdr; -use soroban_sdk::{Address, BytesN, Env, String, Vec}; +use soroban_sdk::{Address, BytesN, Env, Symbol, Vec}; // ── Shared setup helper ──────────────────────────────────────────────────── @@ -64,12 +65,12 @@ fn test_create_and_complete_task_flow() { assert_eq!(token.balance(&creator), 1000); let mut tags = Vec::new(&env); - tags.push_back(String::from_str(&env, "rust")); + tags.push_back(Symbol::new(&env, "rust")); let task_id = client.create_task( &creator, - &String::from_str(&env, "Test Task"), - &String::from_str(&env, "Task Description"), + &Symbol::new(&env, "Test Task"), + &Symbol::new(&env, "Task Description"), &1000, &tags, ); @@ -81,7 +82,7 @@ fn test_create_and_complete_task_flow() { client.submit_work( &assignee, &task_id, - &String::from_str( + &Symbol::new( &env, "https://github.com/LatterFixxx/LatterFix-Smart-contract", ), @@ -108,8 +109,8 @@ fn test_cancel_task_refund() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Cancel Task"), - &String::from_str(&env, "Will cancel this"), + &Symbol::new(&env, "Cancel Task"), + &Symbol::new(&env, "Will cancel this"), &500, &Vec::new(&env), ); @@ -142,8 +143,8 @@ fn test_dispute_and_resolution() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Dispute Task"), - &String::from_str(&env, "Dispute test"), + &Symbol::new(&env, "Dispute Task"), + &Symbol::new(&env, "Dispute test"), &1000, &Vec::new(&env), ); @@ -167,8 +168,8 @@ fn test_user_profile_lifecycle() { let (client, _, admin, _, _) = setup_initialized_contract(&env, 100); let user = Address::generate(&env); - let username = String::from_str(&env, "john_doe"); - let bio = String::from_str(&env, "Rust developer"); + let username = Symbol::new(&env, "john_doe"); + let bio = Symbol::new(&env, "Rust developer"); client.create_profile(&user, &username, &bio); @@ -181,12 +182,12 @@ fn test_user_profile_lifecycle() { assert_eq!(profile.completed_tasks, 0); assert_eq!(profile.bio, bio); - let new_bio = String::from_str(&env, "Soroban developer"); + let new_bio = Symbol::new(&env, "Soroban developer"); client.update_bio(&user, &new_bio); - assert_eq!(client.get_profile(&user).unwrap().bio, new_bio); + assert_eq!(client.get_profile(&user).unwrap_optimized().bio, new_bio); client.reward_contribution(&admin, &user, &25); - let updated = client.get_profile(&user).unwrap(); + let updated = client.get_profile(&user).unwrap_optimized(); assert_eq!(updated.reputation, 125, "reputation must increase by 25"); assert_eq!(updated.completed_tasks, 1); } @@ -206,8 +207,8 @@ fn test_dispute_full_assignee_payout() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Full Assignee Payout"), - &String::from_str(&env, "Admin rules in contributor favour"), + &Symbol::new(&env, "Full Assignee Payout"), + &Symbol::new(&env, "Admin rules in contributor favour"), &800, &Vec::new(&env), ); @@ -239,19 +240,19 @@ fn test_multiple_concurrent_tasks() { StellarAssetClient::new(&env, &token_contract).mint(&creator, &3000); let mut tags = Vec::new(&env); - tags.push_back(String::from_str(&env, "frontend")); + tags.push_back(Symbol::new(&env, "frontend")); let t1 = client.create_task( &creator, - &String::from_str(&env, "Task Alpha"), - &String::from_str(&env, "First concurrent task"), + &Symbol::new(&env, "Task Alpha"), + &Symbol::new(&env, "First concurrent task"), &1000, &tags, ); let t2 = client.create_task( &creator, - &String::from_str(&env, "Task Beta"), - &String::from_str(&env, "Second concurrent task"), + &Symbol::new(&env, "Task Beta"), + &Symbol::new(&env, "Second concurrent task"), &2000, &tags, ); @@ -266,12 +267,12 @@ fn test_multiple_concurrent_tasks() { // Complete task 1 → a1 client.assign_task(&a1, &t1); - client.submit_work(&a1, &t1, &String::from_str(&env, "https://github.com/pr/1")); + client.submit_work(&a1, &t1, &Symbol::new(&env, "https://github.com/pr/1")); client.complete_task(&creator, &t1); // Complete task 2 → a2 client.assign_task(&a2, &t2); - client.submit_work(&a2, &t2, &String::from_str(&env, "https://github.com/pr/2")); + client.submit_work(&a2, &t2, &Symbol::new(&env, "https://github.com/pr/2")); client.complete_task(&creator, &t2); // 1% of 1000 = 10, 1% of 2000 = 20 → fee_recipient gets 30 @@ -298,8 +299,8 @@ fn test_cannot_double_assign() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Single Assign Task"), - &String::from_str(&env, "Only one assignee allowed"), + &Symbol::new(&env, "Single Assign Task"), + &Symbol::new(&env, "Only one assignee allowed"), &500, &Vec::new(&env), ); @@ -327,8 +328,8 @@ fn test_twap_config_initialization() { 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")); + let primary_pool = Symbol::new(&e, "primary-pool"); + let secondary_oracle = Some(Symbol::new(&e, "fallback-oracle")); initialize_twap_config( e.clone(), @@ -389,11 +390,11 @@ fn test_record_price_observations() { let e = env.clone(); env.as_contract(&contract_id, move || { - let asset_pair = String::from_str(&e, "USDC/EUR"); + let asset_pair = Symbol::new(&e, "USDC/EUR"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 3, 500, @@ -421,7 +422,7 @@ fn test_record_price_observations() { let last_obs = get_last_twap(e.clone(), asset_pair.clone()); assert!(last_obs.is_some()); - let last = last_obs.unwrap(); + let last = last_obs.unwrap_optimized(); assert_eq!(last.timestamp, 2_000); assert_eq!(last.price, 1_100_000_000); }); @@ -449,11 +450,11 @@ fn test_multi_period_twap_accumulation() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(1_005_000); - let asset_pair = String::from_str(&e, "USDC/EUR"); + let asset_pair = Symbol::new(&e, "USDC/EUR"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 2, 500, @@ -526,11 +527,11 @@ fn test_outlier_price_filter() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(1_005_000); - let asset_pair = String::from_str(&e, "USDC/USD"); + let asset_pair = Symbol::new(&e, "USDC/USD"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 3, 500, @@ -612,13 +613,13 @@ fn test_fallback_oracle_low_liquidity() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(1_005_000); - let asset_pair = String::from_str(&e, "USDC/JPY"); + let asset_pair = Symbol::new(&e, "USDC/JPY"); - let fallback_oracle = String::from_str(&e, "fallback"); + let fallback_oracle = Symbol::new(&e, "fallback"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), Some(fallback_oracle), 2, 500, @@ -683,13 +684,13 @@ fn test_fallback_on_insufficient_observations() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(1_005_000); - let asset_pair = String::from_str(&e, "USDC/GBP"); + let asset_pair = Symbol::new(&e, "USDC/GBP"); - let fallback_oracle = String::from_str(&e, "fallback"); + let fallback_oracle = Symbol::new(&e, "fallback"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), Some(fallback_oracle), 3, 500, @@ -732,11 +733,11 @@ fn test_pool_liquidity_checks() { 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"); + let asset_pair = Symbol::new(&e, "USDC/CHF"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 2, 500, @@ -772,11 +773,11 @@ fn test_prune_old_observations() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(100_000); - let asset_pair = String::from_str(&e, "USDC/CAD"); + let asset_pair = Symbol::new(&e, "USDC/CAD"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 2, 500, @@ -829,11 +830,11 @@ fn test_twap_observation_window_filtering() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(100_000); - let asset_pair = String::from_str(&e, "USDC/AUD"); + let asset_pair = Symbol::new(&e, "USDC/AUD"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 2, 500, @@ -889,11 +890,11 @@ fn test_twap_edge_case_prices() { env.as_contract(&contract_id, move || { e.ledger().set_timestamp(1_005_000); - let asset_pair = String::from_str(&e, "USDC/NZD"); + let asset_pair = Symbol::new(&e, "USDC/NZD"); initialize_twap_config( e.clone(), - String::from_str(&e, "pool1"), + Symbol::new(&e, "pool1"), None, 2, 1000, @@ -938,7 +939,6 @@ use soroban_sdk::Bytes; // ── Fixtures ─────────────────────────────────────────────────────────────── -/// A well-formed Groth16 proof payload (non-zero points of the expected size). fn zk_proof(env: &Env) -> Groth16Proof { Groth16Proof { a: Bytes::from_array(env, &[1u8; 96]), @@ -947,7 +947,6 @@ fn zk_proof(env: &Env) -> Groth16Proof { } } -/// `n + 1` IC points, as Groth16 requires for `n` public signals. fn zk_ic(env: &Env, n: u32) -> Vec { let mut ic = Vec::new(env); for i in 0..(n + 1) { @@ -956,7 +955,6 @@ fn zk_ic(env: &Env, n: u32) -> Vec { ic } -/// `n` public signals. fn zk_signals(env: &Env, n: u32) -> Vec> { let mut signals = Vec::new(env); for i in 0..n { @@ -965,16 +963,15 @@ fn zk_signals(env: &Env, n: u32) -> Vec> { signals } -/// Initialize the module and register a VK for a 2-signal circuit. -fn zk_setup(env: &Env) -> (Address, String) { +fn zk_setup(env: &Env) -> (Address, Symbol) { let admin = Address::generate(env); initialize(env.clone(), admin.clone()); - let circuit_id = String::from_str(env, "kyc-tier-1"); + let circuit_id = Symbol::new(&env, "kyc-tier-1"); register_verification_key( env.clone(), admin.clone(), circuit_id.clone(), - String::from_str(env, "BLS12-381"), + Symbol::new(&env, "BLS12-381"), Bytes::from_array(env, &[9u8; 32]), Bytes::from_array(env, &[8u8; 192]), Bytes::from_array(env, &[7u8; 192]), @@ -983,10 +980,9 @@ fn zk_setup(env: &Env) -> (Address, String) { (admin, circuit_id) } -/// Build a valid attestation (2 signals) with a correctly bound commitment. fn zk_attestation( env: &Env, - circuit_id: &String, + circuit_id: &Symbol, subject: &Address, nullifier: BytesN<32>, ) -> IdentityAttestation { @@ -1019,7 +1015,7 @@ fn test_zkp_valid_attestation() { assert!(!is_nullifier_used(env.clone(), nullifier.clone())); let attestation = zk_attestation(&env, &circuit_id, &subject, nullifier.clone()); - let receipt = verify_attestation(env.clone(), attestation).unwrap(); + let receipt = verify_attestation(env.clone(), attestation).unwrap_optimized(); assert_eq!(receipt.subject, subject); assert_eq!(receipt.circuit_id, circuit_id); @@ -1071,7 +1067,7 @@ fn test_zkp_rejects_unregistered_circuit() { let subject = Address::generate(&env); let nullifier = BytesN::from_array(&env, &[1u8; 32]); - let unknown = String::from_str(&env, "unknown-circuit"); + let unknown = Symbol::new(&env, "unknown-circuit"); let attestation = zk_attestation(&env, &unknown, &subject, nullifier); assert_eq!( verify_attestation(env.clone(), attestation), @@ -1208,8 +1204,8 @@ fn test_zkp_register_vk_requires_admin() { register_verification_key( env.clone(), impostor, - String::from_str(&env, "kyc-tier-2"), - String::from_str(&env, "BLS12-381"), + Symbol::new(&env, "kyc-tier-2"), + Symbol::new(&env, "BLS12-381"), Bytes::from_array(&env, &[9u8; 32]), Bytes::from_array(&env, &[8u8; 192]), Bytes::from_array(&env, &[7u8; 192]), @@ -1251,8 +1247,8 @@ fn test_dispute_split_three_way() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Split Task"), - &String::from_str(&env, "3-way split"), + &Symbol::new(&env, "Split Task"), + &Symbol::new(&env, "3-way split"), &1000, &Vec::new(&env), ); @@ -1303,8 +1299,8 @@ fn test_dispute_split_zero_fee() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Zero Fee Split"), - &String::from_str(&env, "Split with no fee"), + &Symbol::new(&env, "Zero Fee Split"), + &Symbol::new(&env, "Split with no fee"), &800, &Vec::new(&env), ); @@ -1347,8 +1343,8 @@ fn test_dispute_split_rejects_non_disputed() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Normal Task"), - &String::from_str(&env, "Not disputed"), + &Symbol::new(&env, "Normal Task"), + &Symbol::new(&env, "Not disputed"), &100, &Vec::new(&env), ); @@ -1382,8 +1378,8 @@ fn test_dispute_split_rejects_invalid_shares() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Bad Shares"), - &String::from_str(&env, "shares don't add up"), + &Symbol::new(&env, "Bad Shares"), + &Symbol::new(&env, "shares don't add up"), &500, &Vec::new(&env), ); @@ -1419,8 +1415,8 @@ fn test_dispute_split_rejects_mismatched_lengths() { let task_id = client.create_task( &creator, - &String::from_str(&env, "Mismatch"), - &String::from_str(&env, "lengths differ"), + &Symbol::new(&env, "Mismatch"), + &Symbol::new(&env, "lengths differ"), &500, &Vec::new(&env), ); @@ -1475,8 +1471,8 @@ fn test_dispute_split_via_multisig_proposal() { let task_id = client.create_task( &creator, - &String::from_str(&env, "MS Split Task"), - &String::from_str(&env, "multisig split"), + &Symbol::new(&env, "MS Split Task"), + &Symbol::new(&env, "multisig split"), &1000, &Vec::new(&env), ); @@ -1492,7 +1488,7 @@ fn test_dispute_split_via_multisig_proposal() { shares.push_back(6000u32); shares.push_back(4000u32); - let desc = String::from_str(&env, "resolve 60/40"); + let desc = Symbol::new(&env, "resolve 60/40"); let action = MultisigAction::ResolveDisputeSplit(task_id, recipients, shares); let proposal_id = client.multisig_propose(&s0, &desc, &action); assert_eq!(proposal_id, 1); diff --git a/src/treasury.rs b/src/treasury.rs index 17984695..ea46fcf3 100644 --- a/src/treasury.rs +++ b/src/treasury.rs @@ -1,38 +1,39 @@ -//! Decentralized reward treasury: decay-curve vesting & distribution manager. -//! -//! Holds a single SAC reward token on behalf of the protocol and releases it -//! to beneficiaries along a *decay curve* rather than a straight linear -//! ramp: each elapsed vesting period releases a fixed percentage of -//! whatever remains unvested, so the release rate is front-loaded and tapers -//! off the longer a schedule runs — mirroring how emission-style incentive -//! programs (liquidity mining, early-contributor rewards, etc.) usually want -//! to pay out. -//! -//! ### Decay model -//! -//! A schedule is defined by `total_amount`, a `start_time`, an optional -//! `cliff_seconds`, a `period_seconds` step size, and a `decay_rate_bps` -//! retention rate (basis points of what's *not yet* vested that stays -//! unvested after each period). After `n` full periods past the cliff, the -//! still-unvested fraction is `(decay_rate_bps / 10000)^n`, so: -//! -//! ```text -//! vested(n) = total_amount * (1 - (decay_rate_bps / 10000)^n) -//! ``` -//! -//! `decay_rate_bps` must be strictly less than 10000 so the curve actually -//! converges to `total_amount`; period count is capped at -//! `MAX_DECAY_PERIODS` to keep the exponentiation loop bounded regardless of -//! how long a schedule has been left unclaimed. -//! -//! ### Over-allocation guard -//! -//! `create_vesting_schedule` rejects any schedule whose `total_amount` would -//! push the sum of all schedules' `total_amount` past the treasury's funded -//! balance, so the treasury can never promise more than it holds. `claim` -//! additionally re-checks the live treasury balance and never lets a -//! schedule's `claimed_amount` exceed its `total_amount`, so rounding in the -//! decay curve can't be exploited to over-withdraw. +use soroban_sdk::unwrap::UnwrapOptimized; +// Decentralized reward treasury: decay-curve vesting & distribution manager. +// +// Holds a single SAC reward token on behalf of the protocol and releases it +// to beneficiaries along a *decay curve* rather than a straight linear +// ramp: each elapsed vesting period releases a fixed percentage of +// whatever remains unvested, so the release rate is front-loaded and tapers +// off the longer a schedule runs — mirroring how emission-style incentive +// programs (liquidity mining, early-contributor rewards, etc.) usually want +// to pay out. +// +// ### Decay model +// +// A schedule is defined by `total_amount`, a `start_time`, an optional +// `cliff_seconds`, a `period_seconds` step size, and a `decay_rate_bps` +// retention rate (basis points of what's *not yet* vested that stays +// unvested after each period). After `n` full periods past the cliff, the +// still-unvested fraction is `(decay_rate_bps / 10000)^n`, so: +// +// ```text +// vested(n) = total_amount * (1 - (decay_rate_bps / 10000)^n) +// ``` +// +// `decay_rate_bps` must be strictly less than 10000 so the curve actually +// converges to `total_amount`; period count is capped at +// `MAX_DECAY_PERIODS` to keep the exponentiation loop bounded regardless of +// how long a schedule has been left unclaimed. +// +// ### Over-allocation guard +// +// `create_vesting_schedule` rejects any schedule whose `total_amount` would +// push the sum of all schedules' `total_amount` past the treasury's funded +// balance, so the treasury can never promise more than it holds. `claim` +// additionally re-checks the live treasury balance and never lets a +// schedule's `claimed_amount` exceed its `total_amount`, so rounding in the +// decay curve can't be exploited to over-withdraw. use soroban_sdk::{contracttype, Address, Env, Vec}; @@ -44,11 +45,6 @@ use crate::DataKey; const BPS_DENOMINATOR: i128 = 10_000; -/// Upper bound on the number of decay periods applied when computing a -/// schedule's vested amount. Bounds the cost of `decay_retained_bps` -/// regardless of how long ago a schedule started; past this many periods the -/// curve has converged close enough to zero that the remainder rounds down -/// to fully vested anyway. pub const MAX_DECAY_PERIODS: u32 = 500; // ============================================================================ @@ -56,7 +52,7 @@ pub const MAX_DECAY_PERIODS: u32 = 500; // ============================================================================ #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct VestingSchedule { pub id: u32, pub beneficiary: Address, @@ -65,9 +61,6 @@ pub struct VestingSchedule { pub start_time: u64, pub cliff_seconds: u64, pub period_seconds: u64, - /// Basis points (0..10000) of the still-unvested balance that remains - /// unvested after each elapsed period. Lower = faster decay = more - /// released per period. pub decay_rate_bps: u32, pub created_at: u64, } @@ -92,9 +85,9 @@ fn require_admin(env: &Env, caller: &Address) { .storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")); + .unwrap_optimized(); if *caller != admin { - panic!("not admin"); + panic!(); } } @@ -102,7 +95,6 @@ fn require_admin(env: &Env, caller: &Address) { // Configuration // ============================================================================ -/// Set the SAC token this treasury holds and pays out. Admin-only. pub fn configure_treasury(env: Env, admin: Address, token: Address) { require_admin(&env, &admin); env.storage().instance().set(&TreasuryKey::Token, &token); @@ -112,7 +104,7 @@ fn get_treasury_token(env: &Env) -> Address { env.storage() .instance() .get(&TreasuryKey::Token) - .unwrap_or_else(|| panic!("treasury not configured")) + .unwrap_optimized() } // ============================================================================ @@ -132,8 +124,6 @@ fn set_treasury_balance(env: &Env, balance: i128) { .set(&TreasuryKey::Balance, &balance); } -/// Sum of `total_amount` across every vesting schedule ever created. Used as -/// the over-allocation ceiling against the funded balance. pub fn get_allocated_total(env: &Env) -> i128 { env.storage() .instance() @@ -147,13 +137,11 @@ fn set_allocated_total(env: &Env, allocated: i128) { .set(&TreasuryKey::AllocatedTotal, &allocated); } -/// Deposit `amount` of the treasury token from `funder` into the treasury. -/// Returns the new total treasury balance. pub fn fund_treasury(env: Env, funder: Address, amount: i128) -> i128 { funder.require_auth(); if amount <= 0 { - panic!("fund amount must be positive"); + panic!(); } let token = get_treasury_token(&env); @@ -169,12 +157,6 @@ pub fn fund_treasury(env: Env, funder: Address, amount: i128) -> i128 { // Decay curve // ============================================================================ -/// Fraction (in basis points, 0..=10000) of a schedule that is still -/// *unvested* after `periods` full decay steps at retention rate -/// `rate_bps` per step. `periods` is capped at `MAX_DECAY_PERIODS`. -/// -/// `retained(0) = 10000`; each step multiplies the running fraction by -/// `rate_bps / 10000`, so `retained` decreases monotonically toward zero. pub fn decay_retained_bps(rate_bps: u32, periods: u32) -> u32 { let steps = periods.min(MAX_DECAY_PERIODS); let rate = rate_bps as i128; @@ -209,7 +191,7 @@ fn vested_amount_at(schedule: &VestingSchedule, now: u64) -> i128 { let vested = schedule .total_amount .checked_mul(vested_fraction_bps) - .unwrap_or_else(|| panic!("vested amount overflow")) + .unwrap_optimized() / BPS_DENOMINATOR; if vested > schedule.total_amount { @@ -219,18 +201,15 @@ fn vested_amount_at(schedule: &VestingSchedule, now: u64) -> i128 { } } -/// Amount of `schedule_id`'s total that has vested as of the current ledger -/// timestamp, ignoring how much has already been claimed. pub fn vested_amount(env: &Env, schedule_id: u32) -> i128 { let schedule = get_vesting_schedule(env, schedule_id) - .unwrap_or_else(|| panic!("vesting schedule not found")); + .unwrap_optimized(); vested_amount_at(&schedule, env.ledger().timestamp()) } -/// Amount of `schedule_id` currently claimable: vested minus already claimed. pub fn claimable_amount(env: &Env, schedule_id: u32) -> i128 { let schedule = get_vesting_schedule(env, schedule_id) - .unwrap_or_else(|| panic!("vesting schedule not found")); + .unwrap_optimized(); let vested = vested_amount_at(&schedule, env.ledger().timestamp()); vested - schedule.claimed_amount } @@ -252,11 +231,6 @@ fn next_schedule_id(env: &Env) -> u32 { id } -/// Create a decay-curve vesting schedule for `beneficiary`. Admin-only. -/// -/// Rejected if `total_amount` would push the sum of all schedules' totals -/// past the treasury's currently funded balance (over-allocation guard), or -/// if `decay_rate_bps` is >= 10000 (the curve would never converge). pub fn create_vesting_schedule( env: Env, admin: Address, @@ -270,20 +244,20 @@ pub fn create_vesting_schedule( require_admin(&env, &admin); if total_amount <= 0 { - panic!("total amount must be positive"); + panic!(); } if period_seconds == 0 { - panic!("period seconds must be positive"); + panic!(); } if decay_rate_bps >= BPS_DENOMINATOR as u32 { - panic!("decay rate must be less than 10000 bps to converge"); + panic!(); } let balance = get_treasury_balance(&env); let allocated = get_allocated_total(&env); let available = balance - allocated; if total_amount > available { - panic!("insufficient unallocated treasury balance"); + panic!(); } let id = next_schedule_id(&env); @@ -320,34 +294,32 @@ pub fn create_vesting_schedule( id } -/// Claim everything currently vested-but-unclaimed on `schedule_id`. -/// Beneficiary-only. Returns the amount transferred. pub fn claim(env: Env, beneficiary: Address, schedule_id: u32) -> i128 { beneficiary.require_auth(); let mut schedule = get_vesting_schedule(&env, schedule_id) - .unwrap_or_else(|| panic!("vesting schedule not found")); + .unwrap_optimized(); if schedule.beneficiary != beneficiary { - panic!("caller is not the schedule beneficiary"); + panic!(); } let vested = vested_amount_at(&schedule, env.ledger().timestamp()); let claimable = vested - schedule.claimed_amount; if claimable <= 0 { - panic!("nothing to claim yet"); + panic!(); } // Over-allocation guard: a schedule can never pay out more than its // total_amount, no matter how the decay curve rounds. let new_claimed = schedule.claimed_amount + claimable; if new_claimed > schedule.total_amount { - panic!("claim would exceed total vested allocation"); + panic!(); } let balance = get_treasury_balance(&env); if claimable > balance { - panic!("insufficient treasury balance"); + panic!(); } // Effects before interactions. diff --git a/src/treasury_test.rs b/src/treasury_test.rs index b998b5ad..4bfd1a66 100644 --- a/src/treasury_test.rs +++ b/src/treasury_test.rs @@ -1,3 +1,4 @@ +use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] @@ -287,7 +288,7 @@ fn test_claim_pays_out_vested_amount_and_updates_balances() { assert_eq!(ctx.client.get_treasury_balance(), 500); assert_eq!(ctx.client.get_claimable_amount(&schedule_id), 0); - let schedule = ctx.client.get_vesting_schedule(&schedule_id).unwrap(); + let schedule = ctx.client.get_vesting_schedule(&schedule_id).unwrap_optimized(); assert_eq!(schedule.claimed_amount, 500); } @@ -396,7 +397,7 @@ fn test_claim_never_exceeds_total_allocation_even_far_past_full_vesting() { let claimed = ctx.client.claim_vesting(&beneficiary, &schedule_id); assert_eq!(claimed, 1000); - let schedule = ctx.client.get_vesting_schedule(&schedule_id).unwrap(); + let schedule = ctx.client.get_vesting_schedule(&schedule_id).unwrap_optimized(); assert_eq!(schedule.claimed_amount, 1000); // A further claim attempt has nothing left to release. diff --git a/src/twap_oracle.rs b/src/twap_oracle.rs index 649d9c93..ab598e9c 100644 --- a/src/twap_oracle.rs +++ b/src/twap_oracle.rs @@ -1,88 +1,60 @@ -use soroban_sdk::{contracttype, Env, String, Vec}; +use soroban_sdk::unwrap::UnwrapOptimized; +use soroban_sdk::{contracttype, Env, Symbol, Vec}; -/// Time-Weighted Average Price (TWAP) Oracle Module -/// -/// Provides on-chain price reading for cross-asset salary conversion with: -/// - Protection against short-term price manipulation and flash spikes -/// - Observation buffer for multi-period TWAP calculations -/// - Outlier filtering to reject sudden price deviations -/// - Graceful fallback to secondary oracle feeds if primary liquidity drops // ────────────────────────────────────────────────────────────────────────── // Data Types // ────────────────────────────────────────────────────────────────────────── #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct PriceObservation { - /// Unix timestamp of when this price observation was recorded pub timestamp: u64, - /// Cumulative price value (price * timestamp_delta for precision) pub cumulative_price: i128, - /// Raw price quote (scaled to 18 decimals for consistency) pub price: i128, - /// Block ledger sequence number for validation pub ledger_sequence: u32, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct TwapConfig { - /// Primary DEX pool contract address - pub primary_pool: String, - /// Secondary Oracle feed address for fallback - pub secondary_oracle: Option, - /// Minimum observation count required for valid TWAP + pub primary_pool: Symbol, + pub secondary_oracle: Option, pub min_observation_count: u32, - /// Maximum allowed price deviation (in basis points, e.g., 500 = 5%) pub max_deviation_bps: u32, - /// Observation window size in seconds pub observation_window_secs: u64, - /// Minimum liquidity threshold (in base token units) to use primary pool pub min_liquidity_threshold: i128, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct TwapResult { - /// The calculated TWAP value pub price: i128, - /// Timestamp of the oldest observation used pub oldest_timestamp: u64, - /// Timestamp of the newest observation used pub newest_timestamp: u64, - /// Number of observations included pub observation_count: u32, - /// Whether fallback oracle was used pub used_fallback: bool, - /// Average deviation from median (in basis points) pub avg_deviation_bps: u32, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum TwapStorageKey { - /// Store configuration: TwapStorageKey::Config Config, - /// Store observation history: TwapStorageKey::Observations(asset_pair) - Observations(String), - /// Store last recorded observation: TwapStorageKey::LastObservation(asset_pair) - LastObservation(String), - /// Store primary pool liquidity: TwapStorageKey::PoolLiquidity(asset_pair) - PoolLiquidity(String), - /// Store fallback pricing data: TwapStorageKey::FallbackPrice(asset_pair) - FallbackPrice(String), + Observations(Symbol), + LastObservation(Symbol), + PoolLiquidity(Symbol), + FallbackPrice(Symbol), } // ────────────────────────────────────────────────────────────────────────── // Configuration Management // ────────────────────────────────────────────────────────────────────────── -/// Initialize TWAP oracle configuration pub fn initialize_twap_config( env: Env, - primary_pool: String, - secondary_oracle: Option, + primary_pool: Symbol, + secondary_oracle: Option, min_observation_count: u32, max_deviation_bps: u32, observation_window_secs: u64, @@ -102,22 +74,20 @@ pub fn initialize_twap_config( .set(&TwapStorageKey::Config, &config); } -/// Retrieve TWAP configuration pub fn get_twap_config(env: Env) -> TwapConfig { env.storage() .persistent() .get(&TwapStorageKey::Config) - .unwrap_or_else(|| panic!("TWAP config not initialized")) + .unwrap_optimized() } // ────────────────────────────────────────────────────────────────────────── // Observation Recording // ────────────────────────────────────────────────────────────────────────── -/// Record a new price observation from the primary DEX pool pub fn record_price_observation( env: Env, - asset_pair: String, + asset_pair: Symbol, cumulative_price: i128, raw_price: i128, timestamp: u64, @@ -148,20 +118,18 @@ pub fn record_price_observation( env.storage().persistent().set(&key, &observations); } -/// Update pool liquidity status for fallback logic pub fn update_pool_liquidity( env: Env, - asset_pair: String, + asset_pair: Symbol, liquidity: i128, ) { let key = TwapStorageKey::PoolLiquidity(asset_pair); env.storage().persistent().set(&key, &liquidity); } -/// Set fallback price from secondary oracle pub fn set_fallback_price( env: Env, - asset_pair: String, + asset_pair: Symbol, price: i128, timestamp: u64, ) { @@ -180,10 +148,9 @@ pub fn set_fallback_price( // Outlier Detection & Filtering // ────────────────────────────────────────────────────────────────────────── -/// Calculate median price from observations fn calculate_median(_env: &Env, prices: &Vec) -> i128 { if prices.is_empty() { - panic!("cannot calculate median of empty vector"); + panic!(); } let len = prices.len(); @@ -195,25 +162,23 @@ fn calculate_median(_env: &Env, prices: &Vec) -> i128 { // Bubble sort (acceptable for small observation sets) for i in 0..len { for j in i + 1..len { - if sorted.get(j).unwrap() < sorted.get(i).unwrap() { - let temp = sorted.get(i).unwrap(); - sorted.set(i, sorted.get(j).unwrap()); + if sorted.get(j).unwrap_optimized() < sorted.get(i).unwrap_optimized() { + let temp = sorted.get(i).unwrap_optimized(); + sorted.set(i, sorted.get(j).unwrap_optimized()); sorted.set(j, temp); } } } if len % 2 == 1 { - sorted.get(len / 2).unwrap() + sorted.get(len / 2).unwrap_optimized() } else { - let mid1 = sorted.get(len / 2 - 1).unwrap(); - let mid2 = sorted.get(len / 2).unwrap(); + let mid1 = sorted.get(len / 2 - 1).unwrap_optimized(); + let mid2 = sorted.get(len / 2).unwrap_optimized(); (mid1 + mid2) / 2 } } -/// Filter observations by rejecting outliers -/// Returns filtered observations and average deviation in basis points fn filter_outliers( env: &Env, observations: &Vec, @@ -226,7 +191,7 @@ fn filter_outliers( // Extract prices for median calculation let mut prices = Vec::new(env); for i in 0..observations.len() { - prices.push_back(observations.get(i).unwrap().price); + prices.push_back(observations.get(i).unwrap_optimized().price); } let median = calculate_median(env, &prices); @@ -236,7 +201,7 @@ fn filter_outliers( let mut count: i128 = 0; for i in 0..observations.len() { - let obs = observations.get(i).unwrap(); + let obs = observations.get(i).unwrap_optimized(); let price = obs.price; // Calculate deviation in basis points (10000 bps = 100%) @@ -272,11 +237,9 @@ fn filter_outliers( // TWAP Calculation // ────────────────────────────────────────────────────────────────────────── -/// Calculate TWAP over a specified observation window -/// Returns TwapResult with calculated price and metadata pub fn calculate_twap( env: Env, - asset_pair: String, + asset_pair: Symbol, ) -> TwapResult { let config = get_twap_config(env.clone()); @@ -294,7 +257,7 @@ pub fn calculate_twap( if config.secondary_oracle.is_some() { return calculate_twap_fallback(env, asset_pair, config); } else { - panic!("insufficient observations for TWAP calculation"); + panic!(); } } @@ -304,7 +267,7 @@ pub fn calculate_twap( let mut window_observations = Vec::new(&env); for i in 0..all_observations.len() { - let obs = all_observations.get(i).unwrap(); + let obs = all_observations.get(i).unwrap_optimized(); if obs.timestamp >= window_start && obs.timestamp <= current_timestamp { window_observations.push_back(obs); } @@ -316,7 +279,7 @@ pub fn calculate_twap( if config.secondary_oracle.is_some() { return calculate_twap_fallback(env, asset_pair, config); } else { - panic!("insufficient observations within window"); + panic!(); } } @@ -329,13 +292,13 @@ pub fn calculate_twap( if config.secondary_oracle.is_some() { return calculate_twap_fallback(env, asset_pair, config); } else { - panic!("all observations filtered as outliers"); + panic!(); } } // Compute TWAP using cumulative prices - let first_obs = filtered_observations.get(0).unwrap(); - let last_obs = filtered_observations.get(filtered_observations.len() - 1).unwrap(); + let first_obs = filtered_observations.get(0).unwrap_optimized(); + let last_obs = filtered_observations.get(filtered_observations.len() - 1).unwrap_optimized(); let time_delta = if last_obs.timestamp > first_obs.timestamp { last_obs.timestamp - first_obs.timestamp @@ -361,10 +324,9 @@ pub fn calculate_twap( } } -/// Fallback TWAP calculation using secondary oracle feed fn calculate_twap_fallback( env: Env, - asset_pair: String, + asset_pair: Symbol, _config: TwapConfig, ) -> TwapResult { let fallback_key = TwapStorageKey::FallbackPrice(asset_pair.clone()); @@ -372,7 +334,7 @@ fn calculate_twap_fallback( .storage() .persistent() .get(&fallback_key) - .unwrap_or_else(|| panic!("no fallback price available")); + .unwrap_optimized(); TwapResult { price: fallback_obs.price, @@ -384,19 +346,17 @@ fn calculate_twap_fallback( } } -/// Get the most recent TWAP without recalculating (cached) pub fn get_last_twap( env: Env, - asset_pair: String, + asset_pair: Symbol, ) -> Option { let key = TwapStorageKey::LastObservation(asset_pair); env.storage().persistent().get(&key) } -/// Prune old observations to save storage (keep only recent data) pub fn prune_old_observations( env: Env, - asset_pair: String, + asset_pair: Symbol, retention_secs: u64, ) -> u32 { let _config = get_twap_config(env.clone()); @@ -414,7 +374,7 @@ pub fn prune_old_observations( let mut pruned_count = 0u32; for i in 0..all_observations.len() { - let obs = all_observations.get(i).unwrap(); + let obs = all_observations.get(i).unwrap_optimized(); if obs.timestamp >= cutoff_timestamp { kept_observations.push_back(obs); } else { @@ -435,10 +395,9 @@ pub fn prune_old_observations( // Liquidity & Health Checks // ────────────────────────────────────────────────────────────────────────── -/// Check if primary pool has sufficient liquidity pub fn is_pool_liquid_enough( env: Env, - asset_pair: String, + asset_pair: Symbol, ) -> bool { let config = get_twap_config(env.clone()); let liquidity_key = TwapStorageKey::PoolLiquidity(asset_pair); @@ -451,10 +410,9 @@ pub fn is_pool_liquid_enough( current_liquidity >= config.min_liquidity_threshold } -/// Get current pool liquidity pub fn get_pool_liquidity( env: Env, - asset_pair: String, + asset_pair: Symbol, ) -> i128 { let liquidity_key = TwapStorageKey::PoolLiquidity(asset_pair); env.storage() diff --git a/src/upgrade.rs b/src/upgrade.rs index 5c4b085a..0750266c 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -1,49 +1,50 @@ -//! Timelocked contract-WASM upgrade proposal, veto, and rollback ledger. -//! -//! Every upgrade of this contract's underlying WASM code must pass through a -//! single mandatory pending window before it can take effect, giving users -//! and the emergency guardian time to inspect the proposed code and react. -//! -//! Status workflow: -//! -//! ```text -//! Pending ──(timelock elapses, admin executes)──> Executed -//! │ -//! └──────────(guardian or admin vetoes)───────> Vetoed -//! ``` -//! -//! Only one upgrade proposal is tracked at a time — a second `propose_upgrade` -//! call is rejected while one is still `Pending`. This mirrors how the WASM -//! hash itself works: the contract only ever has one "next" version in -//! flight, so a single-slot design keeps the state machine (and its -//! authorization story) simple without losing any of the acceptance -//! criteria's requirements. -//! -//! ### Rollback semantics -//! -//! This module does not implement a separate "rollback" entry point. Instead, -//! every hash the contract is *actually* upgraded to is appended to an -//! append-only history log (`get_upgrade_history`). To roll back, the admin -//! simply calls `propose_upgrade` again with a previously recorded hash and -//! takes it through the exact same timelock + (optional) guardian-veto flow -//! as any other upgrade. This was chosen deliberately over a distinct -//! `rollback_upgrade` bypass: a rollback is just as capable of reintroducing -//! a bug or being abused as a forward upgrade, so it gets no less scrutiny — -//! the timelock and guardian veto apply equally. The history log's job is -//! purely to make past hashes discoverable so the admin/guardian know what to -//! propose back to; it grants no special authority of its own. -//! -//! ### Guardian role -//! -//! Rather than introducing a parallel role system, the emergency guardian is -//! modeled as a new `access_control::Role::Guardian` value on top of the -//! contract's existing role registry (`grant_role` / `revoke_role` / -//! `has_role` in `access_control.rs`). Guardians are installed the same way -//! any other role is: `grant_role(admin, guardian_address, Role::Guardian)`. -//! `veto_upgrade` accepts either the contract admin (so an operator can -//! self-correct a mistaken proposal without waiting on a third party) or any -//! address holding `Role::Guardian` (the actual emergency-stop path against a -//! malicious or compromised admin-issued proposal). +use soroban_sdk::unwrap::UnwrapOptimized; +// Timelocked contract-WASM upgrade proposal, veto, and rollback ledger. +// +// Every upgrade of this contract's underlying WASM code must pass through a +// single mandatory pending window before it can take effect, giving users +// and the emergency guardian time to inspect the proposed code and react. +// +// Status workflow: +// +// ```text +// Pending ──(timelock elapses, admin executes)──> Executed +// │ +// └──────────(guardian or admin vetoes)───────> Vetoed +// ``` +// +// Only one upgrade proposal is tracked at a time — a second `propose_upgrade` +// call is rejected while one is still `Pending`. This mirrors how the WASM +// hash itself works: the contract only ever has one "next" version in +// flight, so a single-slot design keeps the state machine (and its +// authorization story) simple without losing any of the acceptance +// criteria's requirements. +// +// ### Rollback semantics +// +// This module does not implement a separate "rollback" entry point. Instead, +// every hash the contract is *actually* upgraded to is appended to an +// append-only history log (`get_upgrade_history`). To roll back, the admin +// simply calls `propose_upgrade` again with a previously recorded hash and +// takes it through the exact same timelock + (optional) guardian-veto flow +// as any other upgrade. This was chosen deliberately over a distinct +// `rollback_upgrade` bypass: a rollback is just as capable of reintroducing +// a bug or being abused as a forward upgrade, so it gets no less scrutiny — +// the timelock and guardian veto apply equally. The history log's job is +// purely to make past hashes discoverable so the admin/guardian know what to +// propose back to; it grants no special authority of its own. +// +// ### Guardian role +// +// Rather than introducing a parallel role system, the emergency guardian is +// modeled as a new `access_control::Role::Guardian` value on top of the +// contract's existing role registry (`grant_role` / `revoke_role` / +// `has_role` in `access_control.rs`). Guardians are installed the same way +// any other role is: `grant_role(admin, guardian_address, Role::Guardian)`. +// `veto_upgrade` accepts either the contract admin (so an operator can +// self-correct a mistaken proposal without waiting on a third party) or any +// address holding `Role::Guardian` (the actual emergency-stop path against a +// malicious or compromised admin-issued proposal). use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; @@ -53,39 +54,29 @@ use crate::{access_control, DataKey}; // Types // ============================================================================ -/// Lifecycle state of the (single) pending upgrade proposal slot. #[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] #[repr(u32)] pub enum UpgradeStatus { - /// Proposed and timelocked; not yet executable. Pending = 0, - /// Timelock elapsed and the WASM swap was applied. Terminal. Executed = 1, - /// Cancelled by the admin or an emergency guardian before execution. Terminal. Vetoed = 2, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct UpgradeProposal { pub wasm_hash: BytesN<32>, pub proposed_by: Address, pub proposed_at: u64, - /// Ledger timestamp (seconds) at or after which `execute_upgrade` may - /// succeed. pub ready_at: u64, pub status: UpgradeStatus, pub executed_at: Option, pub vetoed_by: Option
, } -/// A single entry in the append-only log of WASM hashes this contract has -/// actually been upgraded to. Used to support emergency rollback: the admin -/// can look up a previous hash here and re-propose it through the normal -/// timelock flow. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct UpgradeHistoryEntry { pub wasm_hash: BytesN<32>, pub applied_at: u64, @@ -94,25 +85,15 @@ pub struct UpgradeHistoryEntry { #[contracttype] pub enum UpgradeKey { - /// The single in-flight (or most recently resolved) upgrade proposal. Pending, - /// Configured timelock delay, in seconds. Falls back to - /// `DEFAULT_TIMELOCK_SECONDS` when unset. TimelockSeconds, - /// Append-only `Vec` of applied upgrades. History, } -/// Default mandatory pending window: 48 hours. pub const DEFAULT_TIMELOCK_SECONDS: u64 = 172_800; -/// Safety floor for the configurable timelock: 1 hour. Prevents the admin -/// from configuring the delay down to (near) zero and defeating the whole -/// point of a mandatory inspection window. pub const MIN_TIMELOCK_SECONDS: u64 = 3_600; -/// Upper bound on retained history entries, keeping the log's storage -/// footprint bounded on a long-lived contract. pub const MAX_HISTORY_LEN: u32 = 50; // ============================================================================ @@ -123,25 +104,23 @@ fn stored_admin(env: &Env) -> Address { env.storage() .instance() .get(&DataKey::Admin) - .unwrap_or_else(|| panic!("not initialized")) + .unwrap_optimized() } fn require_admin(env: &Env, caller: &Address) { caller.require_auth(); if *caller != stored_admin(env) { - panic!("not admin"); + panic!(); } } -/// Gate for `veto_upgrade`: the contract admin, or any address holding -/// `access_control::Role::Guardian`. fn require_guardian_or_admin(env: &Env, caller: &Address) { caller.require_auth(); if *caller == stored_admin(env) { return; } if !access_control::has_role(env.clone(), caller.clone(), access_control::Role::Guardian) { - panic!("not an emergency guardian"); + panic!(); } } @@ -149,7 +128,6 @@ fn require_guardian_or_admin(env: &Env, caller: &Address) { // Timelock configuration // ============================================================================ -/// Currently configured timelock delay, in seconds. pub fn get_timelock_seconds(env: &Env) -> u64 { env.storage() .instance() @@ -157,12 +135,10 @@ pub fn get_timelock_seconds(env: &Env) -> u64 { .unwrap_or(DEFAULT_TIMELOCK_SECONDS) } -/// Reconfigure the timelock delay. Admin-only; rejects values below the -/// `MIN_TIMELOCK_SECONDS` safety floor. pub fn set_timelock_seconds(env: Env, admin: Address, seconds: u64) -> u64 { require_admin(&env, &admin); if seconds < MIN_TIMELOCK_SECONDS { - panic!("timelock below minimum safety floor"); + panic!(); } env.storage() .instance() @@ -174,17 +150,12 @@ pub fn set_timelock_seconds(env: Env, admin: Address, seconds: u64) -> u64 { // Proposal lifecycle // ============================================================================ -/// Propose upgrading the contract to `new_wasm_hash`. Admin-only. -/// -/// Stores the proposal in the `Pending` state with `ready_at` set to now plus -/// the configured timelock. Rejected while another proposal is still -/// `Pending` — resolve it (execute or veto) first. pub fn propose_upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) -> UpgradeProposal { require_admin(&env, &admin); if let Some(existing) = get_pending_upgrade(&env) { if existing.status == UpgradeStatus::Pending { - panic!("an upgrade proposal is already pending"); + panic!(); } } @@ -207,17 +178,13 @@ pub fn propose_upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) -> U proposal } -/// Cancel the currently pending upgrade proposal before its timelock elapses. -/// Callable by the contract admin (self-correction) or any address holding -/// `Role::Guardian` (emergency stop). No effect can be reversed once a -/// proposal is `Executed`. pub fn veto_upgrade(env: Env, guardian: Address) -> UpgradeProposal { require_guardian_or_admin(&env, &guardian); - let mut proposal = get_pending_upgrade(&env).unwrap_or_else(|| panic!("no pending upgrade")); + let mut proposal = get_pending_upgrade(&env).unwrap_optimized(); if proposal.status != UpgradeStatus::Pending { - panic!("upgrade proposal is not pending"); + panic!(); } proposal.status = UpgradeStatus::Vetoed; @@ -228,26 +195,20 @@ pub fn veto_upgrade(env: Env, guardian: Address) -> UpgradeProposal { proposal } -/// Execute the pending upgrade proposal, swapping the contract's live WASM. -/// Admin-only; reverts if the timelock has not yet elapsed, or if the -/// proposal was already executed or vetoed. -/// -/// Records the applied hash in the rollback history log before performing -/// the swap, then invokes `env.deployer().update_current_contract_wasm`. pub fn execute_upgrade(env: Env, caller: Address) -> BytesN<32> { require_admin(&env, &caller); - let mut proposal = get_pending_upgrade(&env).unwrap_or_else(|| panic!("no pending upgrade")); + let mut proposal = get_pending_upgrade(&env).unwrap_optimized(); match proposal.status { UpgradeStatus::Pending => {} - UpgradeStatus::Executed => panic!("upgrade proposal already executed"), - UpgradeStatus::Vetoed => panic!("upgrade proposal was vetoed"), + UpgradeStatus::Executed => panic!(), + UpgradeStatus::Vetoed => panic!(), } let now = env.ledger().timestamp(); if now < proposal.ready_at { - panic!("timelock has not elapsed"); + panic!(); } // Record the outgoing hash in history before swapping code, so the log @@ -288,7 +249,7 @@ fn push_history(env: &Env, wasm_hash: BytesN<32>, applied_by: Address, applied_a let drop = history.len() - MAX_HISTORY_LEN; let mut trimmed = Vec::new(env); for i in drop..history.len() { - trimmed.push_back(history.get(i).unwrap()); + trimmed.push_back(history.get(i).unwrap_optimized()); } history = trimmed; } @@ -312,8 +273,6 @@ pub fn get_pending_upgrade(env: &Env) -> Option { env.storage().instance().get(&UpgradeKey::Pending) } -/// Append-only log of WASM hashes this contract has actually been upgraded -/// to, oldest first (subject to `MAX_HISTORY_LEN` truncation). pub fn get_upgrade_history(env: &Env) -> Vec { env.storage() .persistent() diff --git a/src/upgrade_test.rs b/src/upgrade_test.rs index 7dde9472..208110ad 100644 --- a/src/upgrade_test.rs +++ b/src/upgrade_test.rs @@ -1,3 +1,4 @@ +use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] @@ -29,18 +30,11 @@ fn setup(env: &Env) -> Ctx { Ctx { client, admin } } -/// A WASM hash that has actually been uploaded to the test host, so -/// `execute_upgrade`'s call into `update_current_contract_wasm` succeeds -/// instead of tripping the host's "wasm does not exist" check. The Soroban -/// test host specifically allows a zero-length WASM blob for this purpose. fn uploaded_wasm_hash(env: &Env) -> BytesN<32> { let empty_wasm: &[u8] = &[]; env.deployer().upload_contract_wasm(empty_wasm) } -/// An arbitrary hash that was never uploaded. Fine for tests that only -/// exercise the proposal state machine and must panic before ever reaching -/// the actual WASM swap (premature execution, unauthorized callers, etc). fn placeholder_wasm_hash(env: &Env) -> BytesN<32> { BytesN::from_array(env, &[7u8; 32]) } @@ -64,7 +58,7 @@ fn test_propose_upgrade_sets_pending_state_with_timelock() { proposal.proposed_at + DEFAULT_TIMELOCK_SECONDS ); - let pending = ctx.client.get_pending_upgrade().unwrap(); + let pending = ctx.client.get_pending_upgrade().unwrap_optimized(); assert_eq!(pending, proposal); } @@ -137,14 +131,14 @@ fn test_execute_upgrade_succeeds_after_timelock_elapses() { let applied_hash = ctx.client.execute_upgrade(&ctx.admin); assert_eq!(applied_hash, hash); - let pending = ctx.client.get_pending_upgrade().unwrap(); + let pending = ctx.client.get_pending_upgrade().unwrap_optimized(); assert_eq!(pending.status, UpgradeStatus::Executed); assert!(pending.executed_at.is_some()); // Historical hash log records the applied upgrade for future rollback. let history = ctx.client.get_upgrade_history(); assert_eq!(history.len(), 1); - let entry = history.get(0).unwrap(); + let entry = history.get(0).unwrap_optimized(); assert_eq!(entry.wasm_hash, hash); assert_eq!(entry.applied_by, ctx.admin); } diff --git a/src/user_profile.rs b/src/user_profile.rs index 51e6046a..15e36e61 100644 --- a/src/user_profile.rs +++ b/src/user_profile.rs @@ -1,45 +1,30 @@ +use soroban_sdk::unwrap::UnwrapOptimized; use crate::storage::DEFAULT_PERSISTENT_TTL; use crate::DataKey; -use soroban_sdk::{contracttype, Address, Env, String}; - -/// On-chain developer profile stored in persistent ledger storage. -/// -/// Profiles are created via `create_profile()` and updated through dedicated -/// mutator functions, each requiring `require_auth()` on the owning address. -/// The admin can award or penalise reputation via `reward_contribution()` and -/// `slash_reputation()` respectively. +use soroban_sdk::{contracttype, Address, Env, Symbol}; + #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct UserProfile { - /// The Stellar G-address that owns this profile. pub address: Address, - /// Human-readable display name (stored on-chain). - pub username: String, - /// Reputation score; starts at 100 and changes with task outcomes. + pub username: Symbol, pub reputation: u32, - /// Total tasks verified and completed by this contributor. pub completed_tasks: u32, - /// Ledger timestamp of when the profile was first created. pub joined_at: u64, - /// Free-text bio/description (max enforced by SDK string limit). - pub bio: String, - /// Optional IPFS/HTTPS avatar URL. - pub avatar_url: Option, - /// Cumulative earnings in the contract's token (smallest denomination). + pub bio: Symbol, + pub avatar_url: Option, pub total_earnings: i128, - /// Ledger timestamp of the last profile mutation. pub last_updated: u64, } // ── Write helpers ────────────────────────────────────────────────────────── -/// Create a new on-chain profile. Panics if one already exists for `user`. -pub fn create_profile(env: Env, user: Address, username: String, bio: String) { +pub fn create_profile(env: Env, user: Address, username: Symbol, bio: Symbol) { user.require_auth(); let key = DataKey::UserProfile(user.clone()); if env.storage().persistent().has(&key) { - panic!("profile already exists"); + panic!(); } let profile = UserProfile { @@ -60,8 +45,7 @@ pub fn create_profile(env: Env, user: Address, username: String, bio: String) { .extend_ttl(&key, 100_000, DEFAULT_PERSISTENT_TTL); } -/// Update the bio of an existing profile. Requires auth from the profile owner. -pub fn update_bio(env: Env, user: Address, new_bio: String) { +pub fn update_bio(env: Env, user: Address, new_bio: Symbol) { user.require_auth(); let key = DataKey::UserProfile(user.clone()); @@ -69,7 +53,7 @@ pub fn update_bio(env: Env, user: Address, new_bio: String) { .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("profile not found")); + .unwrap_optimized(); profile.bio = new_bio; profile.last_updated = env.ledger().timestamp(); @@ -80,8 +64,7 @@ pub fn update_bio(env: Env, user: Address, new_bio: String) { .extend_ttl(&key, 100_000, DEFAULT_PERSISTENT_TTL); } -/// Update the display username of an existing profile. Requires owner auth. -pub fn update_username(env: Env, user: Address, new_username: String) { +pub fn update_username(env: Env, user: Address, new_username: Symbol) { user.require_auth(); let key = DataKey::UserProfile(user.clone()); @@ -89,7 +72,7 @@ pub fn update_username(env: Env, user: Address, new_username: String) { .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("profile not found")); + .unwrap_optimized(); profile.username = new_username; profile.last_updated = env.ledger().timestamp(); @@ -100,8 +83,7 @@ pub fn update_username(env: Env, user: Address, new_username: String) { .extend_ttl(&key, 100_000, DEFAULT_PERSISTENT_TTL); } -/// Set or update the avatar URL. Requires owner auth. -pub fn update_avatar(env: Env, user: Address, avatar_url: String) { +pub fn update_avatar(env: Env, user: Address, avatar_url: Symbol) { user.require_auth(); let key = DataKey::UserProfile(user.clone()); @@ -109,7 +91,7 @@ pub fn update_avatar(env: Env, user: Address, avatar_url: String) { .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("profile not found")); + .unwrap_optimized(); profile.avatar_url = Some(avatar_url); profile.last_updated = env.ledger().timestamp(); @@ -120,15 +102,12 @@ pub fn update_avatar(env: Env, user: Address, avatar_url: String) { .extend_ttl(&key, 100_000, DEFAULT_PERSISTENT_TTL); } -/// Award reputation points and increment completed_tasks counter. -/// Also records cumulative earnings for the contributor. -/// Requires admin auth. pub fn reward_contribution(env: Env, admin: Address, user: Address, points: u32) { admin.require_auth(); - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored_admin { - panic!("not admin"); + panic!(); } let key = DataKey::UserProfile(user.clone()); @@ -136,7 +115,7 @@ pub fn reward_contribution(env: Env, admin: Address, user: Address, points: u32) .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("profile not found")); + .unwrap_optimized(); profile.reputation = profile.reputation.saturating_add(points); profile.completed_tasks += 1; @@ -148,8 +127,6 @@ pub fn reward_contribution(env: Env, admin: Address, user: Address, points: u32) .extend_ttl(&key, 100_000, DEFAULT_PERSISTENT_TTL); } -/// Record token earnings for a contributor after a successful task payout. -/// Called internally by the escrow module — no auth required (internal only). pub fn record_earnings(env: &Env, user: &Address, amount: i128) { let key = DataKey::UserProfile(user.clone()); if let Some(mut profile) = env.storage().persistent().get::(&key) { @@ -162,14 +139,12 @@ pub fn record_earnings(env: &Env, user: &Address, amount: i128) { } } -/// Slash reputation as a penalty (e.g. unjustified dispute or deadline miss). -/// Reputation is floored at 0 using `saturating_sub`. Requires admin auth. pub fn slash_reputation(env: Env, admin: Address, user: Address, penalty: u32) { admin.require_auth(); - let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap_optimized(); if admin != stored_admin { - panic!("not admin"); + panic!(); } let key = DataKey::UserProfile(user.clone()); @@ -177,7 +152,7 @@ pub fn slash_reputation(env: Env, admin: Address, user: Address, penalty: u32) { .storage() .persistent() .get(&key) - .unwrap_or_else(|| panic!("profile not found")); + .unwrap_optimized(); profile.reputation = profile.reputation.saturating_sub(penalty); profile.last_updated = env.ledger().timestamp(); @@ -190,8 +165,6 @@ pub fn slash_reputation(env: Env, admin: Address, user: Address, penalty: u32) { // ── Read helpers ─────────────────────────────────────────────────────────── -/// Fetch the full on-chain profile for a given address. Returns `None` if -/// no profile has been created yet. pub fn get_profile(env: Env, user: Address) -> Option { let key = DataKey::UserProfile(user); env.storage().persistent().get(&key) diff --git a/src/vault.rs b/src/vault.rs index b6094139..04385d19 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -1,10 +1,6 @@ +use soroban_sdk::unwrap::UnwrapOptimized; use soroban_sdk::{contracttype, Address, Env, Vec}; -/// Multi-stablecoin vault module. -/// -/// Lets the contract hold balances in more than one SAC token (USDC, ORGUSD, -/// EURT, etc.) within the same instance, with separate ledgers per token and -/// per depositor so funds never mix across asset types. #[contracttype] pub enum VaultKey { @@ -69,14 +65,12 @@ pub fn get_depositor_balance(env: &Env, depositor: Address, token: Address) -> i // ── Deposit / Claim ───────────────────────────────────────────────────────── -/// Deposit `amount` of `token` into the vault on behalf of `depositor`. -/// Requires `token` to already be registered as supported. pub fn deposit(env: &Env, depositor: Address, token: Address, amount: i128) { if amount <= 0 { - panic!("deposit amount must be positive"); + panic!(); } if !is_supported_token(env, &token) { - panic!("token not supported"); + panic!(); } let token_client = soroban_sdk::token::Client::new(env, &token); @@ -95,17 +89,15 @@ pub fn deposit(env: &Env, depositor: Address, token: Address, amount: i128) { .set(&dep_key, &(dep_balance + amount)); } -/// Claim `amount` of `token` out of the vault for `claimant`, drawing down -/// their depositor balance for that specific token. pub fn claim(env: &Env, claimant: Address, token: Address, amount: i128) { if amount <= 0 { - panic!("claim amount must be positive"); + panic!(); } let dep_key = VaultKey::DepositorBalance(claimant.clone(), token.clone()); let dep_balance = get_depositor_balance(env, claimant.clone(), token.clone()); if dep_balance < amount { - panic!("insufficient vault balance for this token"); + panic!(); } let vault_key = VaultKey::VaultBalance(token.clone()); @@ -141,12 +133,12 @@ pub fn claim_payroll( proof: Vec>, ) { if amount <= 0 { - panic!("claim amount must be positive"); + panic!(); } let claim_key = VaultKey::PayrollClaimed(payroll_id, claimant.clone()); if env.storage().persistent().has(&claim_key) { - panic!("payroll already claimed"); + panic!(); } let root_key = VaultKey::PayrollRoot(payroll_id); @@ -154,13 +146,13 @@ pub fn claim_payroll( .storage() .persistent() .get(&root_key) - .unwrap_or_else(|| panic!("payroll root not found")); + .unwrap_optimized(); let leaf_data = (claimant.clone(), token.clone(), amount).to_xdr(env); let leaf = env.crypto().sha256(&leaf_data).into(); if !verify_merkle_proof(env, &root, &leaf, &proof) { - panic!("invalid merkle proof"); + panic!(); } env.storage().persistent().set(&claim_key, &true); @@ -169,7 +161,7 @@ pub fn claim_payroll( let vault_total = get_vault_balance(env, token.clone()); if vault_total < amount { - panic!("insufficient vault balance for payroll"); + panic!(); } env.storage() diff --git a/src/zkp_attestation.rs b/src/zkp_attestation.rs index 2e82e0f4..04d4b051 100644 --- a/src/zkp_attestation.rs +++ b/src/zkp_attestation.rs @@ -1,4 +1,5 @@ -use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec}; +use soroban_sdk::unwrap::UnwrapOptimized; +use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, Symbol, Vec}; // Zero-Knowledge Proof (ZKP) Identity Attestation Module // @@ -21,122 +22,73 @@ use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, Strin // Encoding constants (BLS12-381, uncompressed) // ────────────────────────────────────────────────────────────────────────── -/// Byte length of an uncompressed BLS12-381 G1 point (`A`, `C`, and every IC). const G1_POINT_LEN: u32 = 96; -/// Byte length of an uncompressed BLS12-381 G2 point (`B`). const G2_POINT_LEN: u32 = 192; -/// Byte length of a field element used as a public signal / nullifier. const FIELD_ELEMENT_LEN: u32 = 32; -/// Upper bound on public signals accepted per attestation (DoS guard). const MAX_PUBLIC_SIGNALS: u32 = 32; // ────────────────────────────────────────────────────────────────────────── // Data Types // ────────────────────────────────────────────────────────────────────────── -/// A Groth16 zk-SNARK proof payload. -/// -/// Points are carried as opaque serialized blobs (`A`, `C` are G1; `B` is G2) -/// so the wrapper stays agnostic to the exact host binding; structural checks -/// enforce the expected encoding lengths. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Groth16Proof { - /// G1 point `A`. pub a: Bytes, - /// G2 point `B`. pub b: Bytes, - /// G1 point `C`. pub c: Bytes, } -/// Verification key for a single proving circuit. -/// -/// `ic` holds the `IC` vector of the Groth16 verification key: for a circuit -/// with `n` public signals it MUST contain exactly `n + 1` G1 points. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct VerificationKey { - /// Human-readable circuit identifier, e.g. "kyc-tier-1". - pub circuit_id: String, - /// Curve label, e.g. "BLS12-381". Informational metadata for indexers. - pub curve: String, - /// Serialized `alpha_g1` / `beta_g2` pairing term of the VK. + pub circuit_id: Symbol, + pub curve: Symbol, pub alpha_beta: Bytes, - /// Serialized `gamma_g2` term of the VK. pub gamma: Bytes, - /// Serialized `delta_g2` term of the VK. pub delta: Bytes, - /// `IC` vector: one G1 point per public signal, plus a constant term. pub ic: Vec, - /// Ledger timestamp at which the key was registered. pub registered_at: u64, - /// Admin address that registered the key. pub registered_by: Address, } -/// A private identity attestation presented by an employee. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct IdentityAttestation { - /// Circuit whose VK the proof should be checked against. - pub circuit_id: String, - /// The employee presenting the attestation (authorizes the call). + pub circuit_id: Symbol, pub subject: Address, - /// The Groth16 proof. pub proof: Groth16Proof, - /// Public signals (field elements) fed to the verifier. pub public_signals: Vec>, - /// Per-identity/per-circuit replay tag. pub nullifier: BytesN<32>, - /// `H(nullifier || public_signals)` — binds the nullifier to the signals. pub attestation_commitment: BytesN<32>, } -/// Record written on a successful attestation. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct AttestationReceipt { - /// Spent nullifier this receipt is keyed by. pub nullifier: BytesN<32>, - /// Attested subject. pub subject: Address, - /// Circuit the proof was verified against. - pub circuit_id: String, - /// Ledger timestamp of verification. + pub circuit_id: Symbol, pub verified_at: u64, } -/// Reasons an attestation can be rejected. #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub enum AttestationError { - /// No VK registered for the referenced circuit. CircuitNotRegistered, - /// Proof points have the wrong encoding length or are the zero blob. MalformedProof, - /// `public_signals` count does not match the VK's `IC` arity. PublicSignalMismatch, - /// `attestation_commitment` does not equal `H(nullifier || signals)`. CommitmentMismatch, - /// The nullifier has already been attested (replay). NullifierAlreadyUsed, - /// The Groth16 pairing equation did not hold. PairingCheckFailed, } -/// Storage keys for the ZKP attestation module. #[contracttype] pub enum ZkStorageKey { - /// Module admin (may register verification keys). Admin, - /// Verification key by circuit id. - Vk(String), - /// Spent-nullifier flag. + Vk(Symbol), Nullifier(BytesN<32>), - /// Attestation receipt by nullifier. Attestation(BytesN<32>), - /// Running count of successful attestations. AttestationCount, } @@ -144,29 +96,26 @@ pub enum ZkStorageKey { // Administration // ────────────────────────────────────────────────────────────────────────── -/// Initialize the module, recording the admin allowed to register circuits. pub fn initialize(env: Env, admin: Address) { if env.storage().instance().has(&ZkStorageKey::Admin) { - panic!("zkp module already initialized"); + panic!(); } env.storage().instance().set(&ZkStorageKey::Admin, &admin); } -/// Return the configured admin address. pub fn get_admin(env: Env) -> Address { env.storage() .instance() .get(&ZkStorageKey::Admin) - .unwrap_or_else(|| panic!("zkp module not initialized")) + .unwrap_optimized() } -/// 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, - circuit_id: String, - curve: String, + circuit_id: Symbol, + curve: Symbol, alpha_beta: Bytes, gamma: Bytes, delta: Bytes, @@ -176,12 +125,12 @@ pub fn register_verification_key( let stored_admin = get_admin(env.clone()); if admin != stored_admin { - panic!("only admin can register verification keys"); + panic!(); } // A well-formed VK needs at least the constant IC term. if ic.is_empty() { - panic!("verification key must contain at least one IC element"); + panic!(); } let vk = VerificationKey { @@ -205,8 +154,7 @@ pub fn register_verification_key( ); } -/// Fetch the verification key registered for a circuit, if any. -pub fn get_verification_key(env: Env, circuit_id: String) -> Option { +pub fn get_verification_key(env: Env, circuit_id: Symbol) -> Option { env.storage() .persistent() .get(&ZkStorageKey::Vk(circuit_id)) @@ -216,7 +164,6 @@ pub fn get_verification_key(env: Env, circuit_id: String) -> Option) -> bool { env.storage() .persistent() @@ -230,11 +177,6 @@ fn spend_nullifier(env: &Env, nullifier: &BytesN<32>) { .set(&ZkStorageKey::Nullifier(nullifier.clone()), &true); } -/// Deterministically derive `H(nullifier || public_signals)`. -/// -/// Binding the nullifier to the exact public signals prevents an attacker from -/// lifting a valid proof onto a different nullifier (or swapping signals under a -/// fixed nullifier) to mint a fresh, "unspent" attestation. pub fn compute_attestation_commitment( env: Env, nullifier: BytesN<32>, @@ -252,11 +194,6 @@ pub fn compute_attestation_commitment( // Proof verification // ────────────────────────────────────────────────────────────────────────── -/// Verify a private identity attestation and, on success, consume its -/// nullifier and persist a receipt. -/// -/// Rejections are returned as [`AttestationError`] rather than panicking so -/// callers (and payroll flows) can branch on the specific failure. pub fn verify_attestation( env: Env, attestation: IdentityAttestation, @@ -317,14 +254,12 @@ pub fn verify_attestation( Ok(receipt) } -/// Fetch the receipt for a previously verified attestation, if any. pub fn get_attestation(env: Env, nullifier: BytesN<32>) -> Option { env.storage() .persistent() .get(&ZkStorageKey::Attestation(nullifier)) } -/// Total number of successful attestations recorded. pub fn attestation_count(env: Env) -> u64 { env.storage() .instance() @@ -357,7 +292,6 @@ fn record_attestation(env: &Env, attestation: &IdentityAttestation) -> Attestati receipt } -/// Validate that the proof and VK encodings are structurally well-formed. fn validate_proof_structure(vk: &VerificationKey, proof: &Groth16Proof) -> bool { // Point encoding lengths. if proof.a.len() != G1_POINT_LEN @@ -392,26 +326,6 @@ fn is_zero_bytes(bytes: &Bytes) -> bool { true } -/// Groth16 pairing-equation verifier — the host-binding wrapper. -/// -/// The Groth16 check is the pairing equation -/// -/// ```text -/// e(A, B) == e(alpha, beta) · e(vk_x, gamma) · e(C, delta) -/// ``` -/// -/// where `vk_x = IC[0] + Σ public_signals[i] · IC[i+1]`. -/// -/// Evaluating it requires BLS12-381 pairing arithmetic, exposed as Soroban host -/// functions (CAP-0059) starting at Protocol 22. This crate targets -/// soroban-sdk 21.x, where that host binding is not yet available, so this -/// function isolates the seam: every predicate that *is* verifiable without the -/// pairing host — encoding well-formedness, public-input arity, and the -/// nullifier/signal commitment binding — is enforced by [`verify_attestation`] -/// before we get here. Replace the body with -/// `env.crypto().bls12_381().pairing_check(...)` once the deployment target -/// moves to Protocol 22; the module's public API and storage layout are -/// unaffected. fn verify_groth16_pairing( _env: &Env, vk: &VerificationKey, From 306c9b20d755e3647dffc21d8c03e20232cf5fa0 Mon Sep 17 00:00:00 2001 From: rampop01 Date: Fri, 21 Aug 2026 00:25:23 +0100 Subject: [PATCH 2/3] Restore test-only Debug derives for CI --- src/kani_proofs.rs | 4 ++-- src/multisig.rs | 1 + src/upgrade.rs | 2 ++ src/zkp_attestation.rs | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/kani_proofs.rs b/src/kani_proofs.rs index 146f4da1..767d4a58 100644 --- a/src/kani_proofs.rs +++ b/src/kani_proofs.rs @@ -1,5 +1,5 @@ -use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(kani)] +use soroban_sdk::unwrap::UnwrapOptimized; // ============================================================================ // Formal Verification Harnesses — Kani Rust Model Checker @@ -383,7 +383,7 @@ fn verify_slippage_guard_bounds() { // // Invalid transitions must be rejected (return None). -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] enum MsState { Pending, Submitted, diff --git a/src/multisig.rs b/src/multisig.rs index 8c9fa2a8..3341c525 100644 --- a/src/multisig.rs +++ b/src/multisig.rs @@ -47,6 +47,7 @@ use crate::DataKey; #[contracttype] #[derive(Clone, Copy, Eq, PartialEq)] #[repr(u32)] +#[cfg_attr(any(test, kani), derive(Debug))] pub enum MultisigProposalStatus { Pending = 0, Approved = 1, diff --git a/src/upgrade.rs b/src/upgrade.rs index 0750266c..39e7ad47 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -57,6 +57,7 @@ use crate::{access_control, DataKey}; #[contracttype] #[derive(Clone, Copy, Eq, PartialEq)] #[repr(u32)] +#[cfg_attr(any(test, kani), derive(Debug))] pub enum UpgradeStatus { Pending = 0, Executed = 1, @@ -65,6 +66,7 @@ pub enum UpgradeStatus { #[contracttype] #[derive(Clone, Eq, PartialEq)] +#[cfg_attr(any(test, kani), derive(Debug))] pub struct UpgradeProposal { pub wasm_hash: BytesN<32>, pub proposed_by: Address, diff --git a/src/zkp_attestation.rs b/src/zkp_attestation.rs index 04d4b051..03067492 100644 --- a/src/zkp_attestation.rs +++ b/src/zkp_attestation.rs @@ -65,6 +65,7 @@ pub struct IdentityAttestation { #[contracttype] #[derive(Clone, Eq, PartialEq)] +#[cfg_attr(any(test, kani), derive(Debug))] pub struct AttestationReceipt { pub nullifier: BytesN<32>, pub subject: Address, @@ -74,6 +75,7 @@ pub struct AttestationReceipt { #[contracttype] #[derive(Clone, Eq, PartialEq)] +#[cfg_attr(any(test, kani), derive(Debug))] pub enum AttestationError { CircuitNotRegistered, MalformedProof, From 0c10eb82763acd7899f6939febc8efa5906e6773 Mon Sep 17 00:00:00 2001 From: rampop01 Date: Fri, 21 Aug 2026 00:26:38 +0100 Subject: [PATCH 3/3] Fix inner attribute positioning in tests --- src/multisig_test.rs | 2 +- src/test.rs | 2 +- src/treasury_test.rs | 2 +- src/upgrade_test.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/multisig_test.rs b/src/multisig_test.rs index edc5b930..804af1d7 100644 --- a/src/multisig_test.rs +++ b/src/multisig_test.rs @@ -1,7 +1,7 @@ -use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] +use soroban_sdk::unwrap::UnwrapOptimized; use crate::multisig::{MultisigAction, MultisigProposalStatus}; use crate::{TaskManagerContract, TaskManagerContractClient}; use soroban_sdk::testutils::{Address as _, Ledger}; diff --git a/src/test.rs b/src/test.rs index f8564a85..ec5ffca7 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,7 +1,7 @@ -use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] +use soroban_sdk::unwrap::UnwrapOptimized; use crate::{TaskManagerContract, TaskManagerContractClient}; use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::Ledger as _; diff --git a/src/treasury_test.rs b/src/treasury_test.rs index 4bfd1a66..5dabdcb5 100644 --- a/src/treasury_test.rs +++ b/src/treasury_test.rs @@ -1,7 +1,7 @@ -use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] +use soroban_sdk::unwrap::UnwrapOptimized; use crate::treasury::{decay_retained_bps, MAX_DECAY_PERIODS}; use crate::{TaskManagerContract, TaskManagerContractClient}; use soroban_sdk::testutils::{Address as _, Ledger}; diff --git a/src/upgrade_test.rs b/src/upgrade_test.rs index 208110ad..fcf44764 100644 --- a/src/upgrade_test.rs +++ b/src/upgrade_test.rs @@ -1,7 +1,7 @@ -use soroban_sdk::unwrap::UnwrapOptimized; #![cfg(test)] #![allow(deprecated)] +use soroban_sdk::unwrap::UnwrapOptimized; use crate::access_control::Role; use crate::upgrade::{UpgradeStatus, DEFAULT_TIMELOCK_SECONDS, MIN_TIMELOCK_SECONDS}; use crate::{TaskManagerContract, TaskManagerContractClient};