diff --git a/contracts/finchippay-contract/README.md b/contracts/finchippay-contract/README.md index 28373fdd..6929bee8 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 4baf8605..552244b3 100644 --- a/contracts/finchippay-contract/src/lib.rs +++ b/contracts/finchippay-contract/src/lib.rs @@ -312,6 +312,7 @@ pub struct BatchClaimCursor { /// 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 ───────────────────────────────────────────────── @@ -556,6 +557,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 ────────────────────────────────────────────────────── @@ -624,6 +629,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 ───────────────────────────────────────────────────────── @@ -1406,6 +1413,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() @@ -1424,6 +1432,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); @@ -1432,7 +1441,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 @@ -1484,16 +1493,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() @@ -1502,6 +1514,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() @@ -1511,7 +1603,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); @@ -2371,6 +2463,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/tests/integration.rs b/contracts/finchippay-contract/tests/integration.rs index db0f2736..464488a7 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();