Skip to content
Merged
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
10 changes: 9 additions & 1 deletion contracts/milestone-escrow/src/admin_override_cancel_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
//! - Terminal milestones are correctly skipped in every scenario.

use super::*;
use crate::test::setup_funded_escrow;
use crate::{DataKey, Error, MilestoneEscrowClient, MilestoneStatus};
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{token, vec, Address, Env};

// ────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -263,13 +265,19 @@ fn test_cancel_refund_all_terminal_returns_invalid_amount() {
let env = Env::default();
env.mock_all_auths();

let (client_addr, freelancer_addr, _, admin_addr, _, _, client) =
let (client_addr, freelancer_addr, _, admin_addr, token_id, contract_id, client) =
setup_funded_escrow(&env, vec![&env, 2_000_i128]);

// Fully release the single milestone via normal path.
client.mark_delivered(&freelancer_addr, &0u32);
client.approve_milestone(&client_addr, &0u32);

// Top up the contract so the zero-balance boundary guard in
// `cancel_escrow` still lets a cancel go through even though every
// milestone is now terminal and fully released.
let token_admin = token::StellarAssetClient::new(&env, &token_id);
token_admin.mint(&contract_id, &1_000_i128);

client.cancel_escrow(&client_addr);

let result = client.try_admin_override_cancel_refund(&admin_addr);
Expand Down
26 changes: 23 additions & 3 deletions contracts/milestone-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ pub enum Error {
/// empty, exceeded the party cap, contained a negative weight, or summed
/// to zero.
InvalidAllocationWeights = 31,
/// An emergency refund / pause-gated endpoint was called while the
/// contract holds zero token balance, so there is nothing to settle.
EmptyBalance = 32,
}

const BPS_SCALE: u32 = 10_000;
Expand Down Expand Up @@ -1032,6 +1035,17 @@ impl MilestoneEscrow {
Ok(())
}

/// Reject a pause-gated settlement while the contract token balance is
/// zero, so an emergency refund never attempts an empty transfer.
fn assert_nonzero_balance(env: &Env, meta: &JobMeta) -> Result<(), Error> {
let token_client = token::Client::new(&env, &meta.token);
let contract_balance = token_client.balance(&env.current_contract_address());
if contract_balance <= 0 {
return Err(Error::EmptyBalance);
}
Ok(())
}

fn validate_fee_allocation(
client_bps: u32,
freelancer_bps: u32,
Expand Down Expand Up @@ -3077,7 +3091,6 @@ impl MilestoneEscrow {
/// * `InvalidStatus` – `CancelLock` is not active.
/// * `InvalidAmount` – Total remaining balance is zero (nothing to refund).
pub fn admin_override_cancel_refund(env: Env, admin: Address) -> Result<(), Error> {
admin.require_auth();
Self::require_admin(&env, &admin)?;

// Only valid when a cancel lock is active.
Expand Down Expand Up @@ -4307,12 +4320,14 @@ impl MilestoneEscrow {
}
}

#[cfg(test)]
mod test;
#[cfg(test)]
mod test_emergency_pause;
#[cfg(test)]
mod test_payment_streaming_milestones;
#[cfg(test)]
mod admin_override_cancel_tests;

// ── escrow_interest_yield: admin emergency override endpoints ─────────────────
//
// Design rationale
// ─────────────────
Expand Down Expand Up @@ -5637,6 +5652,8 @@ impl MilestoneEscrow {
/// * `Unauthorized` – `admin` is not the stored admin.
/// * `EmergencyPauseInProgress` – A pause transition is already running.
/// * `NotPaused` – The contract is not frozen.
/// * `EmptyBalance` – The contract token balance is zero, so
/// there is nothing to settle.
/// * `InvalidAmount` – `total_amount` ≤ 0, or overflow.
/// * `InvalidRatio` – Shares do not sum to 10 000 bps.
pub fn emergency_pause_claim_refund(
Expand All @@ -5653,6 +5670,9 @@ impl MilestoneEscrow {
return Err(Error::NotPaused);
}

let meta = Self::load_job_meta(&env)?;
Self::assert_nonzero_balance(&env, &meta)?;

Self::emergency_pause_split_refund(
env,
total_amount,
Expand Down
2 changes: 1 addition & 1 deletion contracts/milestone-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ impl ReentrantToken {
}
}

fn setup_funded_escrow(
pub(crate) fn setup_funded_escrow(
env: &Env,
milestone_amounts: soroban_sdk::Vec<i128>,
) -> (
Expand Down
42 changes: 41 additions & 1 deletion contracts/milestone-escrow/src/test_emergency_pause.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use super::*;
use soroban_sdk::{
testutils::Address as _, testutils::EnvTestConfig, testutils::Events, vec, Address, Env,
testutils::Address as _, testutils::EnvTestConfig, testutils::Events, token, vec, Address, Env,
FromVal, IntoVal, Val,
};

Expand Down Expand Up @@ -57,6 +57,11 @@ fn initialised_escrow(env: &Env) -> (MilestoneEscrowClient<'_>, Address) {
&amounts,
);

// Fund the contract so pause-gated refund settlements (which reject an
// empty balance) can proceed in the tests that exercise the split math.
let token_admin = token::StellarAssetClient::new(env, &token_contract_id);
token_admin.mint(&contract_id, &100_000_i128);

(escrow, admin_addr)
}

Expand Down Expand Up @@ -397,6 +402,41 @@ fn test_claim_refund_conserves_odd_totals() {
}
}

#[test]
fn test_claim_refund_rejects_an_empty_contract_balance() {
let env = test_env();
env.mock_all_auths();

let admin_addr = Address::generate(&env);
let client_addr = Address::generate(&env);
let freelancer_addr = Address::generate(&env);
let arbiter_addr = Address::generate(&env);

let token_contract_id = env
.register_stellar_asset_contract_v2(admin_addr.clone())
.address();

let contract_id = env.register(MilestoneEscrow, ());
let escrow = MilestoneEscrowClient::new(&env, &contract_id);

// Initialised and paused, but never funded: nothing to settle.
escrow.initialize(
&admin_addr,
&client_addr,
&freelancer_addr,
&arbiter_addr,
&token_contract_id,
&604_800u64,
&vec![&env, 1_000_i128],
);
escrow.emergency_pause(&admin_addr);

assert_eq!(
escrow.try_emergency_pause_claim_refund(&admin_addr, &1_000_i128, &5_000_u32, &5_000_u32),
Err(Ok(Error::EmptyBalance))
);
}

// ============================================================================
// emergency_pause_allocation — high-precision division
// ============================================================================
Expand Down
Loading
Loading