diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a89630a..e80fd2f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ change, not backfilled later. ## [Unreleased] ### Added +- On-chain getters for grace periods, the basis-point denominator, the + maintenance-pool deposit cap, and its inactivity window (#450). +- A shared `MAX_RECIPIENTS` cap and shared instance-storage TTL extension + helper with named thresholds and ledger-time derivation (#447, #451). + +### Fixed +- Removed duplicate "Recent Changes" sections from the README; history is + maintained in this changelog (#452). + - `CODEOWNERS`, pinning `contracts/`, `docs/` and `scripts/` review to the maintainer for automatic reviewer assignment on the high volume of external bounty-program PRs this repo receives (#301, cross-ref #117). diff --git a/README.md b/README.md index fa343cc3..97acf73c 100644 --- a/README.md +++ b/README.md @@ -863,13 +863,4 @@ and the clearest place to catch accidental double-commitment or invalid recipient conditions before they hit the chain. ## Recent Changes -- Ongoing improvements and fixes as part of active development. -- See commit history and open issues for detailed change tracking. - -## Recent Changes -- Ongoing improvements and fixes as part of active development. -- See commit history and open issues for detailed change tracking. - -## Recent Changes -- Ongoing improvements and fixes as part of active development. -- See commit history and open issues for detailed change tracking. +- See [CHANGELOG.md](CHANGELOG.md) for notable behavior changes and release history. diff --git a/contracts/common/src/lib.rs b/contracts/common/src/lib.rs index 968a78cc..e1b43883 100644 --- a/contracts/common/src/lib.rs +++ b/contracts/common/src/lib.rs @@ -81,6 +81,9 @@ pub const BPS_DENOMINATOR: i128 = 10_000; /// Maximum number of distinct contributors allowed per escrow or milestone. pub const MAX_SPONSORS: u32 = 20; +/// Maximum number of recipients allowed in a payout split. +pub const MAX_RECIPIENTS: u32 = 20; + /// Maximum allowed single-step fee change (basis points) - Issue #20 /// Prevents accidental or malicious fee spikes (e.g., 2.5% to 99%) pub const MAX_FEE_CHANGE_BPS: u32 = 500; // 5% maximum change per call @@ -121,7 +124,9 @@ pub fn extend_ttl(env: &Env, key: &K) where K: IntoVal, { - env.storage().persistent().extend_ttl(key, 100_000, 500_000); + env.storage() + .persistent() + .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); } /// Stellar's approximate ledger close time, used to convert a duration into @@ -130,6 +135,24 @@ where /// here, not sub-day precision. See `extend_ttl_for_target`'s docs. const APPROX_SECONDS_PER_LEDGER: u64 = 5; +/// ~5.8 days of remaining lifetime at the approximate 5-second ledger close time. +const PERSISTENT_TTL_THRESHOLD: u32 = 100_000; +/// ~29 days of lifetime at the approximate 5-second ledger close time. +const PERSISTENT_TTL_EXTEND_TO: u32 = 500_000; +/// ~5.8 days of remaining instance-storage lifetime at ~5 seconds per ledger. +const INSTANCE_TTL_THRESHOLD: u32 = 100_000; +/// ~29 days of instance-storage lifetime at ~5 seconds per ledger. +const INSTANCE_TTL_EXTEND_TO: u32 = 500_000; + +/// Extends the contract instance-storage TTL using the same threshold and +/// target as persistent entries. At the approximate 5-second ledger close +/// time, these values represent ~5.8 days and ~29 days, respectively. +pub fn extend_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} + /// Measures the actual balance delta from a token transfer operation, /// protecting against fee-on-transfer tokens, rebasing tokens, and malicious /// token contracts (Issue #3). diff --git a/contracts/common/src/split.rs b/contracts/common/src/split.rs index 9220f728..92912d62 100644 --- a/contracts/common/src/split.rs +++ b/contracts/common/src/split.rs @@ -17,7 +17,7 @@ use soroban_sdk::{Address, Env, Vec}; -use crate::BPS_DENOMINATOR; +use crate::{BPS_DENOMINATOR, MAX_RECIPIENTS}; /// The computed protocol fee and each recipient's absolute payout amount. pub struct Payouts { @@ -38,6 +38,8 @@ pub enum SplitError { /// A recipient address equals the contract's own address — a self-payout /// that would strand funds in the contract with no recovery path. SelfPayout, + /// `recipients` exceeds [`MAX_RECIPIENTS`]. + TooManyRecipients, /// Checked i128 arithmetic overflowed. Returned instead of panicking /// (release profile sets `overflow-checks = true` + `panic = "abort"`, /// so an unchecked op would abort the whole transaction). @@ -55,6 +57,9 @@ pub fn compute_split( if recipients.is_empty() { return Err(SplitError::InvalidSplit); } + if recipients.len() > MAX_RECIPIENTS { + return Err(SplitError::TooManyRecipients); + } let mut bps_sum: i128 = 0; for (_, bps) in recipients.iter() { @@ -188,6 +193,39 @@ pub fn sort_remainders_desc(order: &mut Vec<(u32, i128, Address)>) { } } + #[cfg(test)] + mod tests { + use super::*; + use soroban_sdk::{contract, contractimpl}; + + #[contract] + struct SplitTestContract; + + #[contractimpl] + impl SplitTestContract {} + + #[test] + fn compute_split_enforces_recipient_limit() { + let env = Env::default(); + let contract_id = env.register(SplitTestContract, ()); + let mut recipients = Vec::new(&env); + for _ in 0..MAX_RECIPIENTS { + recipients.push_back((Address::generate(&env), 500u32)); + } + + let payouts = env.as_contract(&contract_id, || { + compute_split(&env, 1_000, 0, &recipients).unwrap() + }); + assert_eq!(payouts.shares.len(), MAX_RECIPIENTS); + + recipients.push_back((Address::generate(&env), 0)); + let result = env.as_contract(&contract_id, || { + compute_split(&env, 1_000, 0, &recipients) + }); + assert!(matches!(result, Err(SplitError::TooManyRecipients))); + } + } + // Repeatedly move the largest remaining element to the end of the array, // shrinking the heap until the array is sorted ascending by `less`. let mut end = n; diff --git a/contracts/escrow/src/error.rs b/contracts/escrow/src/error.rs index 16b73c69..47e3e999 100644 --- a/contracts/escrow/src/error.rs +++ b/contracts/escrow/src/error.rs @@ -37,4 +37,6 @@ pub enum Error { InvalidVersion = 22, /// `initialize` supplied a sponsor cap above `MAX_SPONSORS_LIMIT`. InvalidMaxSponsors = 23, + /// The payout recipient list exceeds `MAX_RECIPIENTS`. + TooManyRecipients = 24, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 7b66ff4e..e02fb340 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -15,7 +15,9 @@ mod types; mod test; use error::Error; -use mergefi_common::{BPS_DENOMINATOR, MAX_SPONSORS, MAX_SPONSORS_LIMIT}; +use mergefi_common::{ + extend_instance_ttl, BPS_DENOMINATOR, MAX_SPONSORS, MAX_SPONSORS_LIMIT, +}; use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env, Vec}; use types::{Contribution, DataKey, Escrow, EscrowStatus}; @@ -529,6 +531,7 @@ mod contract { .map_err(|e| match e { mergefi_common::SplitError::InvalidSplit => Error::InvalidSplit, mergefi_common::SplitError::SelfPayout => Error::SelfPayout, + mergefi_common::SplitError::TooManyRecipients => Error::TooManyRecipients, mergefi_common::SplitError::Overflow => Error::ArithmeticOverflow, })?; let treasury: Address = @@ -1035,6 +1038,16 @@ mod contract { .ok_or(Error::NotInitialized) } + /// Returns the grace period in seconds before permissionless refunds. + pub fn get_grace_period(_env: Env) -> u64 { + GRACE_PERIOD + } + + /// Returns the basis-point denominator used for fees and payouts. + pub fn get_bps_denominator(_env: Env) -> i128 { + BPS_DENOMINATOR + } + /// Admin-only: update the protocol fee for NEW escrows created after /// this call (Issue #20). Existing escrows are unaffected - they retain /// the fee that was active when they were funded, providing sponsor trust @@ -1099,9 +1112,6 @@ pub(crate) fn extend_ttl(env: &Env, key: &DataKey) { /// storage holds Admin, Treasury, FeeBps, and MaxSponsors — losing it /// takes down the entire contract for every issue. Uses the same /// threshold/extend_to as persistent records for consistency. -pub(crate) fn extend_instance_ttl(env: &Env) { - env.storage().instance().extend_ttl(100_000, 500_000); -} /// Extends the TTL of a persistent entry to (approximately) survive until /// `target_timestamp`, capped at Soroban's own persistent-entry TTL diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 22b9c1d6..4d521031 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1222,6 +1222,8 @@ fn test_get_max_sponsors_defaults_to_the_constant_when_not_specified() { let (_, _admin, _treasury, client) = setup(&env); assert_eq!(client.get_max_sponsors(), crate::MAX_SPONSORS); + assert_eq!(client.get_grace_period(), crate::GRACE_PERIOD); + assert_eq!(client.get_bps_denominator(), BPS_DENOMINATOR); } #[test] diff --git a/contracts/maintenance-pool/src/error.rs b/contracts/maintenance-pool/src/error.rs index e15dcad2..058c4dbb 100644 --- a/contracts/maintenance-pool/src/error.rs +++ b/contracts/maintenance-pool/src/error.rs @@ -40,4 +40,6 @@ pub enum Error { SelfPayout = 18, /// The stored contract version does not match the requested migration source. InvalidVersion = 19, + /// The payout recipient list exceeds `MAX_RECIPIENTS`. + TooManyRecipients = 20, } diff --git a/contracts/maintenance-pool/src/lib.rs b/contracts/maintenance-pool/src/lib.rs index 55ec3cfc..6933f0b9 100644 --- a/contracts/maintenance-pool/src/lib.rs +++ b/contracts/maintenance-pool/src/lib.rs @@ -19,7 +19,7 @@ use error::Error; use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env, Vec}; use types::{DataKey, Deposit, MaintenancePool}; -use mergefi_common::BPS_DENOMINATOR; +use mergefi_common::{extend_instance_ttl, BPS_DENOMINATOR}; /// Inactivity window (in seconds) after which a deposit becomes /// permissionlessly reclaimable by its original sponsor (#42). If no @@ -364,6 +364,7 @@ mod contract { .map_err(|e| match e { mergefi_common::SplitError::InvalidSplit => Error::InvalidSplit, mergefi_common::SplitError::SelfPayout => Error::SelfPayout, + mergefi_common::SplitError::TooManyRecipients => Error::TooManyRecipients, mergefi_common::SplitError::Overflow => Error::ArithmeticOverflow, })?; @@ -746,6 +747,21 @@ mod contract { env.storage().instance().get(&DataKey::Version).unwrap_or(0) } + /// Returns the basis-point denominator used for fees and payouts. + pub fn get_bps_denominator(_env: Env) -> i128 { + BPS_DENOMINATOR + } + + /// Returns the maximum number of deposits recorded per pool. + pub fn get_max_deposits(_env: Env) -> u32 { + MAX_DEPOSITS + } + + /// Returns the deposit inactivity window in seconds. + pub fn get_inactivity_window(_env: Env) -> u64 { + INACTIVITY_WINDOW + } + /// Admin-only: rotate the admin address. Requires both current admin /// and new admin authorization. pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> { @@ -842,6 +858,3 @@ fn extend_ttl(env: &Env, key: &DataKey) { /// Extends the TTL of the contract's instance storage (#38). Instance /// storage holds Admin, Treasury, and FeeBps — losing it takes down the /// entire contract for every pool. -fn extend_instance_ttl(env: &Env) { - env.storage().instance().extend_ttl(100_000, 500_000); -} diff --git a/contracts/maintenance-pool/src/test.rs b/contracts/maintenance-pool/src/test.rs index 6eb8b99b..5af3b1ec 100644 --- a/contracts/maintenance-pool/src/test.rs +++ b/contracts/maintenance-pool/src/test.rs @@ -220,6 +220,9 @@ fn test_get_admin_treasury_fee_bps() { assert_eq!(client.get_treasury(), treasury); assert_eq!(client.get_fee_bps(), 1_000u32); assert_eq!(client.get_version(), CONTRACT_VERSION); + assert_eq!(client.get_bps_denominator(), BPS_DENOMINATOR); + assert_eq!(client.get_max_deposits(), MAX_DEPOSITS); + assert_eq!(client.get_inactivity_window(), INACTIVITY_WINDOW); } #[test] diff --git a/contracts/milestones/src/error.rs b/contracts/milestones/src/error.rs index d1f34d8d..9e9f29b4 100644 --- a/contracts/milestones/src/error.rs +++ b/contracts/milestones/src/error.rs @@ -47,4 +47,6 @@ pub enum Error { InvalidVersion = 23, /// `initialize` supplied a sponsor cap above `MAX_SPONSORS_LIMIT`. InvalidMaxSponsors = 24, + /// The payout recipient list exceeds `MAX_RECIPIENTS`. + TooManyRecipients = 25, } diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 2decae25..161318d5 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -22,7 +22,9 @@ use error::Error; use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env, Map, Vec}; use types::{Contribution, DataKey, IssueStatus, Milestone}; -use mergefi_common::{BPS_DENOMINATOR, MAX_SPONSORS, MAX_SPONSORS_LIMIT}; +use mergefi_common::{ + extend_instance_ttl, BPS_DENOMINATOR, MAX_SPONSORS, MAX_SPONSORS_LIMIT, +}; /// Minimum grace period (in seconds) after the deadline before anyone can /// permissionlessly trigger a cancel_milestone. Mirrors escrow's @@ -645,6 +647,7 @@ mod contract { .map_err(|e| match e { mergefi_common::SplitError::InvalidSplit => Error::InvalidSplit, mergefi_common::SplitError::SelfPayout => Error::SelfPayout, + mergefi_common::SplitError::TooManyRecipients => Error::TooManyRecipients, mergefi_common::SplitError::Overflow => Error::ArithmeticOverflow, })?; let treasury: Address = @@ -1147,6 +1150,16 @@ mod contract { .ok_or(Error::NotInitialized) } + /// Returns the grace period in seconds before permissionless milestone cancellation. + pub fn get_grace_period(_env: Env) -> u64 { + GRACE_PERIOD + } + + /// Returns the basis-point denominator used for fees and payouts. + pub fn get_bps_denominator(_env: Env) -> i128 { + BPS_DENOMINATOR + } + /// Admin-only: update the protocol fee for NEW milestones created after /// this call (Issue #20). Existing milestones are unaffected - they retain /// the fee that was active when they were created, providing sponsor trust @@ -1290,6 +1303,3 @@ fn extend_ttl(env: &Env, key: &DataKey) { /// Extends the TTL of the contract's instance storage (#38). Instance /// storage holds Admin, Treasury, FeeBps, and MaxSponsors — losing it /// takes down the entire contract for every milestone. -fn extend_instance_ttl(env: &Env) { - env.storage().instance().extend_ttl(100_000, 500_000); -} diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 1b8716a6..d60b6546 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -986,6 +986,8 @@ fn test_get_max_sponsors_defaults_to_the_constant_when_not_specified() { let (_admin, _treasury, client) = setup(&env); assert_eq!(client.get_max_sponsors(), MAX_SPONSORS); + assert_eq!(client.get_grace_period(), crate::GRACE_PERIOD); + assert_eq!(client.get_bps_denominator(), BPS_DENOMINATOR); } #[test]