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
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ build:
echo "wasm32v1-none not installed; run: rustup target add wasm32v1-none"; \
exit 1; \
fi
@command -v stellar >/dev/null 2>&1 && \
for c in $(CONTRACTS); do \
stellar contract optimize --wasm $(WASM_DIR)/$$(echo $$c | tr - _).wasm || true; \
done || echo "stellar-cli not found; skipping wasm optimize step (optional)"
@command -v stellar >/dev/null 2>&1 || { echo "stellar-cli required for WASM optimization; install via: cargo install --locked stellar-cli"; exit 1; }
@for c in $(CONTRACTS); do \
stellar contract optimize --wasm $(WASM_DIR)/$$(echo $$c | tr - _).wasm; \
done

## Build all contracts with the release-with-logs profile (release optimizations
## with debug-assertions enabled, useful for contract debugging and diagnostic logs).
Expand Down
17 changes: 9 additions & 8 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ impl EscrowContract {
let token_client = token::Client::new(&env, &escrow.token);
let contract_address = env.current_contract_address();

escrow.status = EscrowStatus::Paid;
env.storage().persistent().set(&key, &escrow);
extend_ttl(&env, &key);
extend_instance_ttl(&env);

if payouts.fee > 0 {
token_client.transfer(&contract_address, &treasury, &payouts.fee);
}
Expand All @@ -285,11 +290,6 @@ impl EscrowContract {
}
}

escrow.status = EscrowStatus::Paid;
env.storage().persistent().set(&key, &escrow);
extend_ttl(&env, &key);
extend_instance_ttl(&env);

// Keep contribution sub-records alive alongside the parent so the
// full ledger remains queryable after a release event.
for i in 0..escrow.contributor_count {
Expand Down Expand Up @@ -328,6 +328,10 @@ impl EscrowContract {
admin.require_auth();
}

escrow.status = EscrowStatus::Refunded;
env.storage().persistent().set(&key, &escrow);
extend_ttl(&env, &key);

let token_client = token::Client::new(&env, &escrow.token);
let contract_address = env.current_contract_address();
for i in 0..escrow.contributor_count {
Expand All @@ -347,9 +351,6 @@ impl EscrowContract {
extend_ttl(&env, &contribution_key);
}

escrow.status = EscrowStatus::Refunded;
env.storage().persistent().set(&key, &escrow);
extend_ttl(&env, &key);
extend_instance_ttl(&env);

Ok(())
Expand Down
150 changes: 150 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1605,3 +1605,153 @@ fn test_release_all_or_nothing_revert_with_blocked_recipient() {
let escrow = client.get_escrow(&700u64);
assert_eq!(escrow.status, EscrowStatus::Funded);
}

// ---------------------------------------------------------------------------
// State-machine model: EscrowStatus transitions (#26)
// ---------------------------------------------------------------------------
//
// Valid transitions (all enforced by match/if guards in lib.rs):
//
// Funded ──release──> Paid
// Funded ──refund───> Refunded
// Paid ──fund()───> Funded (re-funding after terminal state)
// Refunded──fund()───> Funded (re-funding after terminal state)
//
// Invalid transitions (must be rejected):
// Funded ──release──> Refunded (impossible)
// Funded ──refund───> Paid (impossible)
// Paid ──release──> * (AlreadyPaid)
// Paid ──refund───> * (AlreadyPaid)
// Refunded──release──> * (AlreadyRefunded)
// Refunded──refund───> * (AlreadyRefunded)

/// Exhaustive enumeration of all valid EscrowStatus transitions.
/// Each entry: (initial_status, operation, expected_result).
/// This serves as the formal state-machine model for EscrowStatus.

#[test]
fn test_state_machine_funded_to_paid_via_release() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

let token_admin = Address::generate(&env);
let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin);
let sponsor = Address::generate(&env);
asset_client.mint(&sponsor, &10_000i128);

client.fund(&800u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
assert_eq!(client.get_escrow(&800u64).status, EscrowStatus::Funded);

let maintainer = Address::generate(&env);
client.release(&800u64, &vec![&env, (maintainer, 10_000u32)]);
assert_eq!(client.get_escrow(&800u64).status, EscrowStatus::Paid);
}

#[test]
fn test_state_machine_funded_to_refunded_via_refund() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

let token_admin = Address::generate(&env);
let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin);
let sponsor = Address::generate(&env);
asset_client.mint(&sponsor, &10_000i128);

client.fund(&801u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
assert_eq!(client.get_escrow(&801u64).status, EscrowStatus::Funded);

client.refund(&801u64);
assert_eq!(client.get_escrow(&801u64).status, EscrowStatus::Refunded);
}

#[test]
fn test_state_machine_paid_allows_refund_rejection() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

let token_admin = Address::generate(&env);
let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin);
let sponsor = Address::generate(&env);
asset_client.mint(&sponsor, &10_000i128);

client.fund(&810u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
let maintainer = Address::generate(&env);
client.release(&810u64, &vec![&env, (maintainer, 10_000u32)]);
assert_eq!(client.get_escrow(&810u64).status, EscrowStatus::Paid);

// Paid -> refund must be rejected
let err = client.try_refund(&810u64);
assert_eq!(err, Err(Ok(Error::AlreadyPaid)));
}

#[test]
fn test_state_machine_refunded_allows_release_rejection() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

let token_admin = Address::generate(&env);
let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin);
let sponsor = Address::generate(&env);
asset_client.mint(&sponsor, &10_000i128);

client.fund(&804u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
client.refund(&804u64);
assert_eq!(client.get_escrow(&804u64).status, EscrowStatus::Refunded);

// Refunded -> release must be rejected
let maintainer = Address::generate(&env);
let err = client.try_release(&804u64, &vec![&env, (maintainer, 10_000u32)]);
assert_eq!(err, Err(Ok(Error::AlreadyRefunded)));
}

#[test]
fn test_state_machine_refunded_allows_double_refund_rejection() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

let token_admin = Address::generate(&env);
let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin);
let sponsor = Address::generate(&env);
asset_client.mint(&sponsor, &10_000i128);

client.fund(&805u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
client.refund(&805u64);

// Refunded -> refund must be rejected
let err = client.try_refund(&805u64);
assert_eq!(err, Err(Ok(Error::AlreadyRefunded)));
}

#[test]
fn test_state_machine_paid_refunded_allow_refund_via_fund() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

let token_admin = Address::generate(&env);
let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin);
let sponsor = Address::generate(&env);
asset_client.mint(&sponsor, &30_000i128);

// Paid -> fund (re-fund) -> Funded
client.fund(&806u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
let maintainer = Address::generate(&env);
client.release(&806u64, &vec![&env, (maintainer, 10_000u32)]);
assert_eq!(client.get_escrow(&806u64).status, EscrowStatus::Paid);

client.fund(&806u64, &sponsor, &token_addr, &10_000i128, &2_000u64, &None);
assert_eq!(client.get_escrow(&806u64).status, EscrowStatus::Funded);

// Refunded -> fund (re-fund) -> Funded
client.fund(&807u64, &sponsor, &token_addr, &10_000i128, &1_000u64, &None);
client.refund(&807u64);
assert_eq!(client.get_escrow(&807u64).status, EscrowStatus::Refunded);

client.fund(&807u64, &sponsor, &token_addr, &10_000i128, &2_000u64, &None);
assert_eq!(client.get_escrow(&807u64).status, EscrowStatus::Funded);
}
10 changes: 5 additions & 5 deletions contracts/maintenance-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,17 @@ impl MaintenancePoolContract {
let token_client = token::Client::new(&env, &pool.token);
let contract_address = env.current_contract_address();

if fee > 0 {
token_client.transfer(&contract_address, &treasury, &fee);
}
token_client.transfer(&contract_address, &recipient, &payout);

pool.balance -= amount;
pool.total_withdrawn += amount;
pool.last_withdraw_at = env.ledger().timestamp();
env.storage().persistent().set(&pkey, &pool);
extend_ttl(&env, &pkey);

if fee > 0 {
token_client.transfer(&contract_address, &treasury, &fee);
}
token_client.transfer(&contract_address, &recipient, &payout);

// Refresh all deposit sub-records on every withdrawal so historical
// deposit records stay queryable across a long-running pool's lifetime.
for i in 0..pool.deposit_count {
Expand Down
10 changes: 5 additions & 5 deletions contracts/milestones/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ impl MilestonesContract {
let token_client = token::Client::new(&env, &milestone.token);
let contract_address = env.current_contract_address();

env.storage()
.persistent()
.set(&skey, &IssueStatus::Released);
extend_ttl(&env, &skey);

if payouts.fee > 0 {
token_client.transfer(&contract_address, &treasury, &payouts.fee);
}
Expand All @@ -343,11 +348,6 @@ impl MilestonesContract {
}
}

env.storage()
.persistent()
.set(&skey, &IssueStatus::Released);
extend_ttl(&env, &skey);

// Refresh the parent milestone's Contribution sub-records so they
// stay alive as individual issues are released over the milestone's
// lifetime — release_issue doesn't touch contributions directly but
Expand Down
Loading