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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 1 addition & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 24 additions & 1 deletion contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,7 +124,9 @@ pub fn extend_ttl<K>(env: &Env, key: &K)
where
K: IntoVal<Env, Val>,
{
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
Expand All @@ -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).
Expand Down
40 changes: 39 additions & 1 deletion contracts/common/src/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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).
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions contracts/escrow/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
18 changes: 14 additions & 4 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions contracts/maintenance-pool/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
21 changes: 17 additions & 4 deletions contracts/maintenance-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})?;

Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions contracts/maintenance-pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions contracts/milestones/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
18 changes: 14 additions & 4 deletions contracts/milestones/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
2 changes: 2 additions & 0 deletions contracts/milestones/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading