Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 98 additions & 149 deletions contracts/escrow/src/approvals.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
use crate::ttl::{PENDING_APPROVAL_BUMP_THRESHOLD, PENDING_APPROVAL_TTL_LEDGERS};
use crate::types::{Contract, ContractStatus, DataKey, Error, MilestoneApprovals, Milestone, ReleaseAuthorization};
use crate::types::{
Contract, ContractStatus, DataKey, Error, Milestone, MilestoneApprovals, ReleaseAuthorization,
};
use soroban_sdk::{Address, Env, Symbol, Vec};

/// Approves a milestone for release by the caller.
///
///
/// Records the approval in temporary storage with TTL expiry.
/// The approval will automatically expire after PENDING_APPROVAL_TTL_LEDGERS.
///
///
/// # Arguments
/// * `env` - The contract environment
/// * `contract_id` - The contract ID
/// * `milestone_index` - The index of the milestone to approve
/// * `caller` - The address of the caller (must be client, freelancer, or arbiter)
///
///
/// # Returns
/// `true` if approval was recorded successfully
///
///
/// # Errors
/// * `ContractNotFound` - If contract doesn't exist
/// * `InvalidState` - If contract is not in Funded state
/// * `IndexOutOfBounds` - If milestone index is invalid
/// * `MilestoneAlreadyReleased` - If milestone was already released
/// * `UnauthorizedRole` - If caller is not authorized to approve
/// * `AlreadyApproved` - If caller has already approved this milestone
///
///
/// # Security
/// - Caller must be authenticated via require_auth()
/// - Only authorized parties (client/freelancer/arbiter) can approve
Expand Down Expand Up @@ -73,7 +75,7 @@ pub fn approve_milestone(
// Determine caller role and check authorization
let is_client = caller == &contract.client;
let is_freelancer = caller == &contract.freelancer;
let is_arbiter = contract.arbiter.as_ref().map_or(false, |a| caller == a);
let is_arbiter = contract.arbiter.as_ref() == Some(caller);

// Verify caller is a valid participant
if !is_client && !is_freelancer && !is_arbiter {
Expand Down Expand Up @@ -106,15 +108,15 @@ pub fn approve_milestone(

// Load or create approval record
let approval_key = DataKey::MilestoneApprovals(contract_id, milestone_index);
let mut approvals: MilestoneApprovals = env
.storage()
.temporary()
.get(&approval_key)
.unwrap_or(MilestoneApprovals {
client_approved: false,
freelancer_approved: false,
arbiter_approved: false,
});
let mut approvals: MilestoneApprovals =
env.storage()
.temporary()
.get(&approval_key)
.unwrap_or(MilestoneApprovals {
client_approved: false,
freelancer_approved: false,
arbiter_approved: false,
});

// Check for duplicate approval and update
if is_client {
Expand All @@ -135,32 +137,32 @@ pub fn approve_milestone(
}

// Store approval with TTL
env.storage()
.temporary()
.set(&approval_key, &approvals);

env.storage()
.temporary()
.extend_ttl(&approval_key, PENDING_APPROVAL_BUMP_THRESHOLD, PENDING_APPROVAL_TTL_LEDGERS);
env.storage().temporary().set(&approval_key, &approvals);

env.storage().temporary().extend_ttl(
&approval_key,
PENDING_APPROVAL_BUMP_THRESHOLD,
PENDING_APPROVAL_TTL_LEDGERS,
);

Ok(true)
}

/// Checks if a milestone has sufficient approvals for release.
///
///
/// Expired approvals (TTL elapsed) are treated as absent and return None.
///
///
/// # Arguments
/// * `env` - The contract environment
/// * `contract` - The contract data
/// * `contract_id` - The contract ID
/// * `milestone_index` - The milestone index
///
///
/// # Returns
/// * `Ok(true)` - If sufficient approvals exist and are valid
/// * `Err(InsufficientApprovals)` - If approvals are missing or insufficient
/// * `Err(ApprovalExpired)` - If approvals existed but have expired
///
///
/// # Security
/// - Fail-closed: missing or expired approvals prevent release
/// - TTL expiry is enforced by Soroban's temporary storage
Expand All @@ -171,13 +173,10 @@ pub fn check_approvals(
milestone_index: u32,
) -> Result<bool, Error> {
let approval_key = DataKey::MilestoneApprovals(contract_id, milestone_index);

// Try to load approvals from temporary storage
// If TTL has expired, this will return None
let approvals: Option<MilestoneApprovals> = env
.storage()
.temporary()
.get(&approval_key);
let approvals: Option<MilestoneApprovals> = env.storage().temporary().get(&approval_key);

// If no approvals exist (or they expired), fail
let approvals = approvals.ok_or(Error::InsufficientApprovals)?;
Expand All @@ -202,9 +201,9 @@ pub fn check_approvals(
}

/// Clears approval records for a milestone after successful release.
///
///
/// This prevents approval reuse and cleans up temporary storage.
///
///
/// # Arguments
/// * `env` - The contract environment
/// * `contract_id` - The contract ID
Expand All @@ -217,16 +216,18 @@ pub fn clear_approvals(env: &Env, contract_id: u32, milestone_index: u32) {
#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::{testutils::Address as _, Env};

#[test]
fn test_approve_milestone_client_only() {
let env = Env::default();
env.mock_all_auths();

let client = Address::generate(&env);
let freelancer = Address::generate(&env);

use crate::Escrow;
use soroban_sdk::{testutils::Address as _, Address, Env};

const CONTRACT_ID: u32 = 1;

fn seed_funded_contract(
env: &Env,
escrow: &Address,
release_authorization: ReleaseAuthorization,
) -> (Address, Address, Contract) {
let client = Address::generate(env);
let freelancer = Address::generate(env);
let contract = Contract {
client: client.clone(),
freelancer: freelancer.clone(),
Expand All @@ -235,134 +236,82 @@ mod tests {
funded_amount: 1000,
released_amount: 0,
refunded_amount: 0,
release_authorization: ReleaseAuthorization::ClientOnly,
release_authorization,
};

let contract_id = 1u32;
env.storage()
.persistent()
.set(&DataKey::Contract(contract_id), &contract);

let milestones = Vec::from_array(
&env,
env,
[Milestone {
amount: 1000,
funded_amount: 0,
released: false,
refunded: false,
work_evidence: None,
refunded_amount: 0,
}],
);
let milestone_key = Symbol::new(&env, "milestones");
env.storage()
.persistent()
.set(&(DataKey::Contract(contract_id), milestone_key), &milestones);

// Client approves
let result = approve_milestone(&env, contract_id, 0, &client);
assert!(result.is_ok());
let milestone_key = Symbol::new(env, "milestones");

env.as_contract(escrow, || {
env.storage()
.persistent()
.set(&DataKey::Contract(CONTRACT_ID), &contract);
env.storage().persistent().set(
&(DataKey::Contract(CONTRACT_ID), milestone_key),
&milestones,
);
});

// Check approvals
let check = check_approvals(&env, &contract, contract_id, 0);
assert!(check.is_ok());
(client, freelancer, contract)
}

#[test]
fn test_approve_milestone_multisig() {
fn test_approve_milestone_client_only() {
let env = Env::default();
env.mock_all_auths();
let escrow = env.register(Escrow, ());
let (client, _, contract) =
seed_funded_contract(&env, &escrow, ReleaseAuthorization::ClientOnly);

let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let contract = Contract {
client: client.clone(),
freelancer: freelancer.clone(),
arbiter: None,
status: ContractStatus::Funded,
funded_amount: 1000,
released_amount: 0,
refunded_amount: 0,
release_authorization: ReleaseAuthorization::MultiSig,
};

let contract_id = 1u32;
env.storage()
.persistent()
.set(&DataKey::Contract(contract_id), &contract);

let milestones = Vec::from_array(
&env,
[Milestone {
amount: 1000,
released: false,
refunded: false,
work_evidence: None,
}],
);
let milestone_key = Symbol::new(&env, "milestones");
env.storage()
.persistent()
.set(&(DataKey::Contract(contract_id), milestone_key), &milestones);

// Only client approves - insufficient
let result = approve_milestone(&env, contract_id, 0, &client);
assert!(result.is_ok());

let check = check_approvals(&env, &contract, contract_id, 0);
assert_eq!(check, Err(Error::InsufficientApprovals));

// Freelancer also approves - now sufficient
let result = approve_milestone(&env, contract_id, 0, &freelancer);
assert!(result.is_ok());
env.as_contract(&escrow, || {
assert!(approve_milestone(&env, CONTRACT_ID, 0, &client).is_ok());
assert!(check_approvals(&env, &contract, CONTRACT_ID, 0).is_ok());
});
}

let check = check_approvals(&env, &contract, contract_id, 0);
assert!(check.is_ok());
#[test]
fn test_approve_milestone_multisig() {
let env = Env::default();
env.mock_all_auths();
let escrow = env.register(Escrow, ());
let (client, freelancer, contract) =
seed_funded_contract(&env, &escrow, ReleaseAuthorization::MultiSig);

env.as_contract(&escrow, || {
assert!(approve_milestone(&env, CONTRACT_ID, 0, &client).is_ok());
assert_eq!(
check_approvals(&env, &contract, CONTRACT_ID, 0),
Err(Error::InsufficientApprovals)
);
assert!(approve_milestone(&env, CONTRACT_ID, 0, &freelancer).is_ok());
assert!(check_approvals(&env, &contract, CONTRACT_ID, 0).is_ok());
});
}

#[test]
fn test_duplicate_approval_rejected() {
let env = Env::default();
env.mock_all_auths();
let escrow = env.register(Escrow, ());
let (client, _, _) = seed_funded_contract(&env, &escrow, ReleaseAuthorization::ClientOnly);

let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let contract = Contract {
client: client.clone(),
freelancer: freelancer.clone(),
arbiter: None,
status: ContractStatus::Funded,
funded_amount: 1000,
released_amount: 0,
refunded_amount: 0,
release_authorization: ReleaseAuthorization::ClientOnly,
};

let contract_id = 1u32;
env.storage()
.persistent()
.set(&DataKey::Contract(contract_id), &contract);

let milestones = Vec::from_array(
&env,
[Milestone {
amount: 1000,
released: false,
refunded: false,
work_evidence: None,
}],
);
let milestone_key = Symbol::new(&env, "milestones");
env.storage()
.persistent()
.set(&(DataKey::Contract(contract_id), milestone_key), &milestones);

// First approval succeeds
let result = approve_milestone(&env, contract_id, 0, &client);
assert!(result.is_ok());

// Second approval fails
let result = approve_milestone(&env, contract_id, 0, &client);
assert_eq!(result, Err(Error::AlreadyApproved));
env.as_contract(&escrow, || {
assert!(approve_milestone(&env, CONTRACT_ID, 0, &client).is_ok());
});
env.as_contract(&escrow, || {
assert_eq!(
approve_milestone(&env, CONTRACT_ID, 0, &client),
Err(Error::AlreadyApproved)
);
});
}
}
Loading
Loading