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
1 change: 1 addition & 0 deletions contracts/finchippay-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
110 changes: 103 additions & 7 deletions contracts/finchippay-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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()
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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<Address> = 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()
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions contracts/finchippay-contract/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading