From f3de7de8c588045fe737480beebed234bc0447db Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Thu, 27 Aug 2026 14:35:25 +0100 Subject: [PATCH 1/2] Fix unbounded receipt minting by adding MAX_USER_RECEIPTS cap --- contracts/finchippay-contract/README.md | 1 + contracts/finchippay-contract/src/lib.rs | 5 + contracts/finchippay-contract/src/storage.rs | 5 +- .../finchippay-contract/tests/integration.rs | 41 +++- .../finchippay-contract/tests/ttl_sweep.rs | 34 ++- .../tests/yield_escrow_invariants.rs | 202 +++++++++++++----- 6 files changed, 217 insertions(+), 71 deletions(-) diff --git a/contracts/finchippay-contract/README.md b/contracts/finchippay-contract/README.md index 4b9e20b8..5a8461f0 100644 --- a/contracts/finchippay-contract/README.md +++ b/contracts/finchippay-contract/README.md @@ -49,6 +49,7 @@ Recipients can call `claim_stream` at any time to drain accrued tokens. Payers c - Stream deposits are capped at `MAX_STREAM_DEPOSIT` with cumulative top-up enforcement. - Stream rates are capped at `MAX_STREAM_RATE` to prevent overflow. - Multi-sig proposals are capped at `MAX_MULTISIG_AMOUNT` and `MAX_MULTISIG_SIGNERS` (20). + - Receipts are capped at `MAX_USER_RECEIPTS` (1,000) per user to prevent storage bloat. - Escrow amounts are capped at `MAX_ESCROW_AMOUNT` and have a minimum of `MIN_ESCROW_AMOUNT` to prevent dust attacks. - Multi-sig proposals have a minimum of `MIN_MULTISIG_AMOUNT` and can include an `expiration_ledger` to auto-expire abandoned proposals. - Multi-sig signer lists are checked for duplicates at creation time. diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index b5b5c4f0..fbed5527 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -257,6 +257,7 @@ pub struct EscrowSummary { /// Maximum number of escrows tracked per recipient index (prevents state bloat). const MAX_USER_ESCROWS: u32 = 100; const MAX_USER_STREAMS: u32 = 100; +const MAX_USER_RECEIPTS: u32 = 1_000; const MAX_PAGE_SIZE: u32 = 50; // ─── Batch swap helper types ───────────────────────────────────────────────── @@ -2269,6 +2270,10 @@ impl FinchippayContract { .get(&DataKey::ReceiptCount(from.clone())) .unwrap_or(0); + if count >= MAX_USER_RECEIPTS { + panic!("User receipt limit reached"); + } + let receipt = ReceiptMetadata { from: from.clone(), to, diff --git a/contracts/finchippay-contract/src/storage.rs b/contracts/finchippay-contract/src/storage.rs index 4e05cfb2..3ac8969a 100644 --- a/contracts/finchippay-contract/src/storage.rs +++ b/contracts/finchippay-contract/src/storage.rs @@ -274,10 +274,7 @@ pub fn ttl_class_item_key_cost(class: &TtlClass, index: u32) -> u32 { 2 } } - TtlClass::MultiSig - | TtlClass::Vesting - | TtlClass::Emergency - | TtlClass::YieldEscrow => 1, + TtlClass::MultiSig | TtlClass::Vesting | TtlClass::Emergency | TtlClass::YieldEscrow => 1, } } diff --git a/contracts/finchippay-contract/tests/integration.rs b/contracts/finchippay-contract/tests/integration.rs index ddebc66b..95c05abc 100644 --- a/contracts/finchippay-contract/tests/integration.rs +++ b/contracts/finchippay-contract/tests/integration.rs @@ -251,6 +251,38 @@ fn test_mint_receipt() { assert_eq!(receipt.to, payee); } +#[test] +fn test_mint_receipt_cap() { + let env = Env::default(); + let (_, client) = deploy(&env); + let payer = Address::generate(&env); + let payee = Address::generate(&env); + env.mock_all_auths(); + let memo = Symbol::new(&env, "Rent"); + + for _ in 0..1000 { + client.mint_receipt(&payer, &payee, &1_500, &memo); + } + + assert_eq!(client.get_receipt_count(&payer), 1000); +} + +#[test] +#[should_panic(expected = "User receipt limit reached")] +fn test_mint_receipt_cap_exceeded() { + let env = Env::default(); + let (_, client) = deploy(&env); + let payer = Address::generate(&env); + let payee = Address::generate(&env); + env.mock_all_auths(); + let memo = Symbol::new(&env, "Rent"); + + for _ in 0..1000 { + client.mint_receipt(&payer, &payee, &1_500, &memo); + } + client.mint_receipt(&payer, &payee, &1_500, &memo); // panics here +} + #[test] fn test_get_receipt_not_found() { let env = Env::default(); @@ -1276,14 +1308,7 @@ fn test_create_vesting_rejects_current_or_past_end_ledger() { ) .is_err()); assert!(client - .try_create_vesting( - &token_id, - &funder, - &beneficiary, - &5_000, - &cliff, - &past_end, - ) + .try_create_vesting(&token_id, &funder, &beneficiary, &5_000, &cliff, &past_end,) .is_err()); } diff --git a/contracts/finchippay-contract/tests/ttl_sweep.rs b/contracts/finchippay-contract/tests/ttl_sweep.rs index d0d54189..dba215c0 100644 --- a/contracts/finchippay-contract/tests/ttl_sweep.rs +++ b/contracts/finchippay-contract/tests/ttl_sweep.rs @@ -18,11 +18,11 @@ use finchippay_contract::{ storage::{ttl_class_at, MAX_KEYS_PER_SWEEP, MIN_TTL_LEDGERS, TTL_CLASS_COUNT}, DataKey, FinchippayContract, FinchippayContractClient, TtlClass, }; +use soroban_sdk::testutils::storage::Persistent as _; use soroban_sdk::{ testutils::{Address as _, Ledger}, token, Address, Env, Symbol, Vec, }; -use soroban_sdk::testutils::storage::Persistent as _; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -62,7 +62,8 @@ fn ttl_env() -> Env { } fn shorten_entry_lifetimes(env: &Env) { - env.ledger().with_mut(|li| li.min_persistent_entry_ttl = TTL_TEST_ENTRY_BIRTH_TTL); + env.ledger() + .with_mut(|li| li.min_persistent_entry_ttl = TTL_TEST_ENTRY_BIRTH_TTL); } fn ttl_of(env: &Env, contract: &Address, key: &DataKey) -> u32 { @@ -78,8 +79,10 @@ fn read_cursor(env: &Env, contract: &Address) -> (u32, u32) { fn read_watermark(env: &Env, contract: &Address, class: &TtlClass) -> u32 { env.as_contract(contract, || { - let watermark: Option = - env.storage().persistent().get(&DataKey::TtlWatermark(class.clone())); + let watermark: Option = env + .storage() + .persistent() + .get(&DataKey::TtlWatermark(class.clone())); watermark.unwrap_or(0) }) } @@ -133,10 +136,14 @@ fn add_state(env: &Env, infra: &Infra<'_>) -> State { } for _ in 0..2 { let recipient = Address::generate(env); - let _ = infra.client.open_stream(&infra.token, &infra.payer, &recipient, &10, &50_000); + let _ = infra + .client + .open_stream(&infra.token, &infra.payer, &recipient, &10, &50_000); } let receipt_to = Address::generate(env); - let _ = infra.client.mint_receipt(&infra.payer, &receipt_to, &1_000, &Symbol::new(env, "memo")); + let _ = infra + .client + .mint_receipt(&infra.payer, &receipt_to, &1_000, &Symbol::new(env, "memo")); State { escrow_recipients } } @@ -231,7 +238,10 @@ fn chunked_sweep_resumes_monotonically_and_covers_every_key_once() { let bumped = a.client.bump_all_ttls(&a.admin, &budget); let after = read_cursor(&env, &a.id); - assert!(bumped <= MAX_KEYS_PER_SWEEP, "chunk overshot the hard key cap"); + assert!( + bumped <= MAX_KEYS_PER_SWEEP, + "chunk overshot the hard key cap" + ); total += bumped; if after == (0, 0) && before != (0, 0) { @@ -249,7 +259,10 @@ fn chunked_sweep_resumes_monotonically_and_covers_every_key_once() { // No key skipped or double-bumped: the chunked total equals the uninterrupted // total, and every key is back at the floor. - assert_eq!(total, reference_total, "chunked sweep skipped or double-bumped keys"); + assert_eq!( + total, reference_total, + "chunked sweep skipped or double-bumped keys" + ); assert_swept_keys_at_floor(&env, &a.id, &a.payer, &a_state.escrow_recipients); // After a completed sweep, get_min_ttl is a sound lower bound. @@ -378,7 +391,10 @@ fn single_sweep_step_stays_within_instruction_budget() { let after = env.budget().cpu_instruction_cost(); let used = after.saturating_sub(before); - assert!(bumped <= MAX_KEYS_PER_SWEEP, "step overshot the hard key cap"); + assert!( + bumped <= MAX_KEYS_PER_SWEEP, + "step overshot the hard key cap" + ); // Soroban's per-transaction CPU budget is 100M instructions on mainnet; a // single full-size sweep step must consume only a fraction of it. assert!(used < 100_000_000, "sweep step used {used} instructions"); diff --git a/contracts/finchippay-contract/tests/yield_escrow_invariants.rs b/contracts/finchippay-contract/tests/yield_escrow_invariants.rs index f6dcead3..8ccf33ec 100644 --- a/contracts/finchippay-contract/tests/yield_escrow_invariants.rs +++ b/contracts/finchippay-contract/tests/yield_escrow_invariants.rs @@ -27,8 +27,8 @@ //! Contract-based tests (with real token transfers) use lower case counts //! to stay within CI time budgets, while arithmetic-only tests use higher counts. -use finchippay_contract::{FinchippayContract, FinchippayContractClient}; use finchippay_contract::{Escrow, EscrowStatus}; +use finchippay_contract::{FinchippayContract, FinchippayContractClient}; use proptest::prelude::*; use proptest::test_runner::{Config, TestRunner}; use soroban_sdk::{ @@ -62,7 +62,10 @@ fn deploy<'a>(env: &'a Env, payer: &Address) -> (Address, FinchippayContractClie let sac = env.register_stellar_asset_contract_v2(admin.clone()); let token = sac.address(); let token_admin = token::StellarAssetClient::new(env, &token); - token_admin.mint(payer, &(MAX_TEST_DEPOSIT.saturating_mul(CASES_CONTRACT as i128 + 10))); + token_admin.mint( + payer, + &(MAX_TEST_DEPOSIT.saturating_mul(CASES_CONTRACT as i128 + 10)), + ); (id, client, token) } @@ -125,14 +128,21 @@ fn invariant_payout_never_exceeds_deposit() { let release_ledger = current_ledger + ledger_offset; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); let escrow = get_escrow_data(&client, escrow_id); assert!( escrow.amount <= amount, "Invariant violated: payout ({}) > deposit ({})", - escrow.amount, amount + escrow.amount, + amount ); Ok(()) @@ -163,7 +173,13 @@ fn invariant_payout_never_exceeds_deposit_plus_yield() { let release_ledger = current_ledger + ledger_offset; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); let escrow = get_escrow_data(&client, escrow_id); @@ -173,7 +189,8 @@ fn invariant_payout_never_exceeds_deposit_plus_yield() { assert!( escrow.amount <= max_payout, "Invariant violated: payout ({}) > deposit + yield ({})", - escrow.amount, max_payout + escrow.amount, + max_payout ); Ok(()) @@ -200,36 +217,51 @@ fn invariant_no_double_pay() { let mut runner = TestRunner::new(config(CASES_MULTI)); runner - .run(&strategy, |(amount1, amount2, ledger_offset1, ledger_offset2)| { - let current_ledger = env.ledger().sequence(); - let release_ledger1 = current_ledger + ledger_offset1; - let release_ledger2 = current_ledger + ledger_offset2; - - let escrow_id1 = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount1, release_ledger1, - ); - - let escrow_id2 = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount2, release_ledger2, - ); - - advance_ledger(&env, release_ledger1); - - client.claim_escrow(&escrow_id1); - - let result = client.try_claim_escrow(&escrow_id1); - assert!(result.is_err(), "Double-claim should panic"); - - client.cancel_escrow(&escrow_id2); - - let result = client.try_cancel_escrow(&escrow_id2); - assert!(result.is_err(), "Double-cancel should panic"); - - let result = client.try_claim_escrow(&escrow_id2); - assert!(result.is_err(), "Claiming cancelled escrow should panic"); - - Ok(()) - }) + .run( + &strategy, + |(amount1, amount2, ledger_offset1, ledger_offset2)| { + let current_ledger = env.ledger().sequence(); + let release_ledger1 = current_ledger + ledger_offset1; + let release_ledger2 = current_ledger + ledger_offset2; + + let escrow_id1 = create_test_escrow( + &env, + &client, + &token, + &funder, + &beneficiary, + amount1, + release_ledger1, + ); + + let escrow_id2 = create_test_escrow( + &env, + &client, + &token, + &funder, + &beneficiary, + amount2, + release_ledger2, + ); + + advance_ledger(&env, release_ledger1); + + client.claim_escrow(&escrow_id1); + + let result = client.try_claim_escrow(&escrow_id1); + assert!(result.is_err(), "Double-claim should panic"); + + client.cancel_escrow(&escrow_id2); + + let result = client.try_cancel_escrow(&escrow_id2); + assert!(result.is_err(), "Double-cancel should panic"); + + let result = client.try_claim_escrow(&escrow_id2); + assert!(result.is_err(), "Claiming cancelled escrow should panic"); + + Ok(()) + }, + ) .unwrap(); } @@ -257,7 +289,13 @@ fn invariant_amount_never_negative() { let release_ledger = current_ledger + ledger_offset; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); let escrow = get_escrow_data(&client, escrow_id); @@ -297,7 +335,13 @@ fn invariant_yield_never_negative() { let release_ledger = current_ledger + ledger_offset; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); let escrow = get_escrow_data(&client, escrow_id); @@ -338,7 +382,13 @@ fn invariant_valid_state_transitions() { let release_ledger = current_ledger + ledger_offset; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); let escrow = get_escrow_data(&client, escrow_id); @@ -391,7 +441,8 @@ fn invariant_deposit_yield_no_overflow() { assert!( payout <= total, "Payout ({}) > deposit + yield ({})", - payout, total + payout, + total ); Ok(()) @@ -402,7 +453,10 @@ fn invariant_deposit_yield_no_overflow() { /// Pure arithmetic invariant: shares ratio calculation. #[test] fn invariant_shares_proportional_to_deposit() { - let strategy = (MIN_DEPOSIT..=MAX_TEST_DEPOSIT, MIN_DEPOSIT..=MAX_TEST_DEPOSIT); + let strategy = ( + MIN_DEPOSIT..=MAX_TEST_DEPOSIT, + MIN_DEPOSIT..=MAX_TEST_DEPOSIT, + ); let mut runner = TestRunner::new(config(CASES_ARITHMETIC)); runner @@ -411,9 +465,15 @@ fn invariant_shares_proportional_to_deposit() { let shares2 = deposit2; if deposit1 > deposit2 { - assert!(shares1 > shares2, "Shares should be proportional to deposit"); + assert!( + shares1 > shares2, + "Shares should be proportional to deposit" + ); } else if deposit1 < deposit2 { - assert!(shares1 < shares2, "Shares should be proportional to deposit"); + assert!( + shares1 < shares2, + "Shares should be proportional to deposit" + ); } else { assert_eq!(shares1, shares2, "Equal deposits should yield equal shares"); } @@ -440,7 +500,13 @@ fn edge_case_minimum_deposit() { let release_ledger = current_ledger + 1000; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); let escrow = get_escrow_data(&client, escrow_id); @@ -466,7 +532,13 @@ fn edge_case_minimum_release_ledger() { let release_ledger = current_ledger + 1; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); advance_ledger(&env, release_ledger); @@ -491,7 +563,13 @@ fn edge_case_multiple_escrows_same_release() { let mut escrow_ids = soroban_sdk::Vec::new(&env); for _ in 0..5 { let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); escrow_ids.push_back(escrow_id); } @@ -519,7 +597,13 @@ fn edge_case_cancel_immediately() { let release_ledger = current_ledger + 1000; let escrow_id = create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); client.cancel_escrow(&escrow_id); @@ -542,7 +626,13 @@ fn edge_case_zero_amount_panics() { let release_ledger = current_ledger + 1000; create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); } @@ -560,7 +650,13 @@ fn edge_case_negative_amount_panics() { let release_ledger = current_ledger + 1000; create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); } @@ -578,7 +674,13 @@ fn edge_case_past_release_ledger_panics() { let release_ledger = current_ledger - 1; create_test_escrow( - &env, &client, &token, &funder, &beneficiary, amount, release_ledger, + &env, + &client, + &token, + &funder, + &beneficiary, + amount, + release_ledger, ); } From 3ab8b71737e1121f36b882580b8c656a6c2eff18 Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Thu, 27 Aug 2026 21:32:33 +0100 Subject: [PATCH 2/2] feat: implement admin-action timelock and veto window --- contracts/finchippay-contract/src/lib.rs | 105 +++++++++++++- .../finchippay-contract/tests/integration.rs | 135 ++++++++++++++++++ 2 files changed, 233 insertions(+), 7 deletions(-) diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs index fbed5527..fd48e6c9 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -497,6 +497,10 @@ const MAX_ADMIN_SIGNERS: u32 = 20; /// `validate_storage_compatibility` before upgrading to ensure the new WASM /// declares a layout version >= this value, preventing bricked storage. const STORAGE_LAYOUT_VERSION: u32 = 3; +/// Mandatory delay in ledgers before an admin action can be executed +/// (≈24 hours at 5 s/ledger). +const ADMIN_ACTION_DELAY: u32 = 17_280; + // ─── Storage TTL classes ────────────────────────────────────────────────────── @@ -548,6 +552,8 @@ pub struct AdminActionProposal { pub executed: bool, /// Ledger after which the proposal expires and can no longer be approved. pub expiration_ledger: u32, + /// Ledger at which this action becomes executable (0 means not yet activated). + pub activation_ledger: u32, } // ─── Storage key enum ───────────────────────────────────────────────────────── @@ -1326,6 +1332,7 @@ impl FinchippayContract { executed: false, // Expire after 7 days (~120,960 ledgers at 5s/ledger). expiration_ledger: current_ledger + 120_960, + activation_ledger: 0, }; env.storage() @@ -1344,6 +1351,7 @@ impl FinchippayContract { // Threshold 1: the proposer's recorded approval already meets it. if threshold == 1 { proposal.executed = true; + proposal.activation_ledger = current_ledger; env.storage() .persistent() .set(&DataKey::AdminActionProposal(counter), &proposal); @@ -1352,7 +1360,7 @@ impl FinchippayContract { (Symbol::new(&env, "admin_action_approved"),), (counter, proposer, 1u32, threshold), ); - Self::execute_admin_action(&env, &proposal); + Self::do_execute_admin_action(&env, &proposal); } counter @@ -1404,16 +1412,19 @@ impl FinchippayContract { (proposal_id, approver, approval_count, proposal.threshold), ); - // Auto-execute when threshold met + // Queue for execution when threshold met if approval_count >= proposal.threshold { - proposal.executed = true; + if proposal.activation_ledger == 0 { + proposal.activation_ledger = current_ledger + ADMIN_ACTION_DELAY; + env.events().publish( + (Symbol::new(&env, "admin_action_queued"),), + (proposal_id, proposal.activation_ledger), + ); + } env.storage() .persistent() .set(&DataKey::AdminActionProposal(proposal_id), &proposal); bump(&env, &DataKey::AdminActionProposal(proposal_id)); - - // Dispatch the action - Self::execute_admin_action(&env, &proposal); } else { env.storage() .persistent() @@ -1422,6 +1433,86 @@ impl FinchippayContract { } } + /// Veto a queued admin action proposal, resetting its approvals and timelock. + pub fn veto_admin_action(env: Env, proposal_id: u64, vetoer: Address) { + let _guard = ReentrancyGuard::acquire(&env); + vetoer.require_auth(); + + // Validate signer + let signers: Vec
= env + .storage() + .persistent() + .get(&DataKey::AdminSigners) + .unwrap_or_else(|| panic!("Admin signers not configured")); + if !signers.contains(&vetoer) { + panic!("not an admin signer"); + } + + let mut proposal: AdminActionProposal = env + .storage() + .persistent() + .get(&DataKey::AdminActionProposal(proposal_id)) + .unwrap_or_else(|| panic!("{:?}", ContractError::ProposalNotFound)); + + if proposal.executed { + panic!("{:?}", ContractError::ProposalAlreadyExecuted); + } + + if proposal.activation_ledger == 0 { + panic!("{:?}", ContractError::InvalidState); // Not yet queued + } + + let current_ledger = env.ledger().sequence(); + if current_ledger >= proposal.activation_ledger { + panic!("{:?}", ContractError::InvalidState); // Time window expired + } + + proposal.approvals = Vec::new(&env); + proposal.activation_ledger = 0; + + env.storage() + .persistent() + .set(&DataKey::AdminActionProposal(proposal_id), &proposal); + bump(&env, &DataKey::AdminActionProposal(proposal_id)); + + env.events().publish( + (Symbol::new(&env, "admin_action_vetoed"),), + (proposal_id, vetoer), + ); + } + + /// Execute an admin action whose timelock delay has passed. + pub fn execute_admin_action(env: Env, proposal_id: u64) { + let _guard = ReentrancyGuard::acquire(&env); + + let mut proposal: AdminActionProposal = env + .storage() + .persistent() + .get(&DataKey::AdminActionProposal(proposal_id)) + .unwrap_or_else(|| panic!("{:?}", ContractError::ProposalNotFound)); + + if proposal.executed { + panic!("{:?}", ContractError::ProposalAlreadyExecuted); + } + + if proposal.activation_ledger == 0 { + panic!("{:?}", ContractError::InvalidState); // Not yet queued + } + + let current_ledger = env.ledger().sequence(); + if current_ledger < proposal.activation_ledger { + panic!("{:?}", ContractError::ReleaseLedgerNotReached); + } + + proposal.executed = true; + env.storage() + .persistent() + .set(&DataKey::AdminActionProposal(proposal_id), &proposal); + bump(&env, &DataKey::AdminActionProposal(proposal_id)); + + Self::do_execute_admin_action(&env, &proposal); + } + /// Return an admin action proposal by id. pub fn get_admin_action_proposal(env: Env, proposal_id: u64) -> AdminActionProposal { env.storage() @@ -1431,7 +1522,7 @@ impl FinchippayContract { } /// Internal: execute the concrete admin action after threshold is met. - fn execute_admin_action(env: &Env, proposal: &AdminActionProposal) { + fn do_execute_admin_action(env: &Env, proposal: &AdminActionProposal) { let action = &proposal.action_type; if action == &Symbol::new(env, "pause") { Self::do_pause(env); diff --git a/contracts/finchippay-contract/tests/integration.rs b/contracts/finchippay-contract/tests/integration.rs index 95c05abc..05a6ed0f 100644 --- a/contracts/finchippay-contract/tests/integration.rs +++ b/contracts/finchippay-contract/tests/integration.rs @@ -1533,3 +1533,138 @@ fn test_resolve_dispute_state_and_token_checks() { let contract_bal_after = sac_client.balance(&contract_id); assert_eq!(contract_bal_after, contract_bal_mid); } + +// ─── Admin Action Timelock & Veto Tests ────────────────────────────────────── + +#[test] +fn test_admin_timelock_happy_path() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(FinchippayContract, ()); + let client = FinchippayContractClient::new(&env, &id); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let signers = Vec::from_array(&env, [signer1.clone(), signer2.clone()]); + client.initialize(&signers, &2); + + let data = Vec::new(&env); + let proposal_id = client.propose_admin_action(&signer1, &Symbol::new(&env, "pause"), &data); + + // Approving reaches threshold + client.approve_admin_action(&proposal_id, &signer2); + + let proposal = client.get_admin_action_proposal(&proposal_id); + assert_eq!(proposal.executed, false); + assert!(proposal.activation_ledger > 0); + + // Fast-forward ledger to pass the timelock + env.ledger().set_sequence(proposal.activation_ledger); + + client.execute_admin_action(&proposal_id); + + let proposal_after = client.get_admin_action_proposal(&proposal_id); + assert_eq!(proposal_after.executed, true); +} + +#[test] +fn test_admin_timelock_veto() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(FinchippayContract, ()); + let client = FinchippayContractClient::new(&env, &id); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let signers = Vec::from_array(&env, [signer1.clone(), signer2.clone()]); + client.initialize(&signers, &2); + + let data = Vec::new(&env); + let proposal_id = client.propose_admin_action(&signer1, &Symbol::new(&env, "pause"), &data); + + client.approve_admin_action(&proposal_id, &signer2); + + // Veto before timelock expires + client.veto_admin_action(&proposal_id, &signer1); + + let proposal = client.get_admin_action_proposal(&proposal_id); + assert_eq!(proposal.approvals.len(), 0); + assert_eq!(proposal.activation_ledger, 0); +} + +#[test] +fn test_admin_timelock_expired_veto_window() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(FinchippayContract, ()); + let client = FinchippayContractClient::new(&env, &id); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let signers = Vec::from_array(&env, [signer1.clone(), signer2.clone()]); + client.initialize(&signers, &2); + + let data = Vec::new(&env); + let proposal_id = client.propose_admin_action(&signer1, &Symbol::new(&env, "pause"), &data); + + client.approve_admin_action(&proposal_id, &signer2); + + let proposal = client.get_admin_action_proposal(&proposal_id); + + // Advance beyond activation_ledger + env.ledger().set_sequence(proposal.activation_ledger); + + let result = client.try_veto_admin_action(&proposal_id, &signer1); + assert_eq!(result.is_err(), true); +} + +#[test] +fn test_admin_timelock_multi_veto() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(FinchippayContract, ()); + let client = FinchippayContractClient::new(&env, &id); + + let signer1 = Address::generate(&env); + let signer2 = Address::generate(&env); + let signers = Vec::from_array(&env, [signer1.clone(), signer2.clone()]); + client.initialize(&signers, &2); + + let data = Vec::new(&env); + let proposal_id = client.propose_admin_action(&signer1, &Symbol::new(&env, "pause"), &data); + + client.approve_admin_action(&proposal_id, &signer2); + + // First veto + client.veto_admin_action(&proposal_id, &signer1); + + // Re-approve + client.approve_admin_action(&proposal_id, &signer1); + client.approve_admin_action(&proposal_id, &signer2); + + // Second veto + client.veto_admin_action(&proposal_id, &signer2); + + let proposal = client.get_admin_action_proposal(&proposal_id); + assert_eq!(proposal.approvals.len(), 0); + assert_eq!(proposal.activation_ledger, 0); +} + +#[test] +fn test_admin_timelock_fast_path() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(FinchippayContract, ()); + let client = FinchippayContractClient::new(&env, &id); + + let admin = Address::generate(&env); + let signers = Vec::from_array(&env, [admin.clone()]); + client.initialize(&signers, &1); + + let data = Vec::new(&env); + let proposal_id = client.propose_admin_action(&admin, &Symbol::new(&env, "pause"), &data); + + // With threshold 1, it executes immediately on propose + let proposal = client.get_admin_action_proposal(&proposal_id); + assert_eq!(proposal.executed, true); +}