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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Soroban contract for revenue-share offerings and blacklist management.
| `blacklist_remove` | `caller: Address`, `token: Address`, `investor: Address` | — | issuer | Remove investor from blacklist. Only the current issuer can perform this action. Idempotent. |
| `is_blacklisted` | `token: Address`, `investor: Address` | `bool` | — | Whether investor is blacklisted for token. |
| `get_blacklist` | `token: Address` | `Vec<Address>` | — | All blacklisted addresses for token. |
| `set_concentration_limit` | `issuer: Address`, `token: Address`, `max_bps: u32`, `enforce: bool` | `Result<(), RevoraError>` | issuer | Set per-offering max single-holder concentration (bps). 0 = disabled. If `enforce` is true, `report_revenue` fails when reported concentration > `max_bps`. Offering must exist. |
| `set_concentration_limit` | `issuer: Address`, `token: Address`, `max_bps: u32`, `enforce: bool`, `max_staleness_secs: u64` | `Result<(), RevoraError>` | issuer | Set per-offering max single-holder concentration (bps). 0 = disabled. If `enforce` is true, `report_revenue` fails when reported concentration > `max_bps`. When `max_staleness_secs > 0` and `enforce` is true, `report_revenue` also fails if no concentration has been reported or the last report is older than `max_staleness_secs` seconds. Offering must exist. |
| `report_concentration` | `issuer: Address`, `token: Address`, `concentration_bps: u32` | `Result<(), RevoraError>` | issuer | Report current top-holder concentration (bps). Emits `conc_warn` if over configured limit. |
| `get_concentration_limit` | `issuer: Address`, `token: Address` | `Option<ConcentrationLimitConfig>` | — | Get concentration limit config for offering. |
| `get_current_concentration` | `issuer: Address`, `token: Address` | `Option<u32>` | — | Last reported concentration (bps) for offering. |
Expand All @@ -57,7 +57,7 @@ Soroban contract for revenue-share offerings and blacklist management.
### Types

- **Offering:** `{ issuer: Address, token: Address, revenue_share_bps: u32 }`
- **ConcentrationLimitConfig:** `{ max_bps: u32, enforce: bool }` — per-offering concentration guardrail.
- **ConcentrationLimitConfig:** `{ max_bps: u32, enforce: bool, max_staleness_secs: u64 }` — per-offering concentration guardrail. When `enforce` is true and `max_staleness_secs > 0`, `report_revenue` rejects with `StaleConcentrationData` if no concentration has been reported or the last report is older than `max_staleness_secs` seconds.
- **AuditSummary:** `{ total_revenue: i128, report_count: u64 }` — per-offering audit log summary.
- **RoundingMode:** `Truncation` (0) or `RoundHalfUp` (1) — used by `compute_share` and per-offering default.

Expand All @@ -76,7 +76,7 @@ Soroban contract for revenue-share offerings and blacklist management.
| 18 | `InvalidPeriodId` | period_id is 0 where a positive value is required (#35). |
| 25 | `ReportingWindowClosed` | Current ledger timestamp is outside the configured reporting window; `report_revenue` rejected. |
| 26 | `ClaimWindowClosed` | Current ledger timestamp is outside the configured claiming window; `claim` rejected. |
| 47 | `MissingReportForOverride` | `report_revenue` rejected when `override_existing=true` is requested for a period that has no existing persisted report. Emits `rev_omiss`. |
| 49 | `StaleConcentrationData` | `report_concentration` has never been called, or the last call is older than `max_staleness_secs`; `report_revenue` rejected when enforcement is on. |

Auth failures (e.g. wrong signer) are signaled by host/panic, not `RevoraError`. Use `try_register_offering`, `try_report_revenue`, and similar `try_*` client methods to receive contract errors as `Result`.

Expand Down
39 changes: 38 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ pub enum RevoraError {
///
/// Wire value: next available stable discriminant.
MissingReportForOverride = 47,
/// Concentration data is missing or older than `max_staleness_secs` and enforcement is on.
/// Wire value: 49. Stable since v1.
StaleConcentrationData = 49,
}

pub mod vesting;
Expand Down Expand Up @@ -386,6 +389,11 @@ pub struct ConcentrationLimitConfig {
pub max_bps: u32,
/// If true, `report_revenue` will fail if current concentration exceeds `max_bps`.
pub enforce: bool,
/// Maximum age (in seconds) of a `report_concentration` call before it is considered stale.
/// When `enforce` is true and this is > 0, `report_revenue` rejects if no concentration has
/// been reported or the last report is older than this many seconds. 0 = disabled (no staleness
/// check).
pub max_staleness_secs: u64,
}

/// Per-offering investment constraints (#97). Min/max stake per investor; off-chain enforced.
Expand Down Expand Up @@ -608,6 +616,8 @@ pub enum DataKey {
ConcentrationLimit(OfferingId),
/// Per-offering: last reported concentration in bps.
CurrentConcentration(OfferingId),
/// Per-offering: ledger timestamp of the last report_concentration call.
ConcentrationReportedAt(OfferingId),
/// Per-offering: audit summary.
AuditSummary(OfferingId),
/// Per-offering: rounding mode for share math.
Expand Down Expand Up @@ -2575,6 +2585,23 @@ impl RevoraRevenueShare {
// reject report if current concentration exceeds the limit.
// Allowed: current <= max_bps. Rejected: current > max_bps.
if config.enforce && config.max_bps > 0 {
// Staleness guard: if max_staleness_secs > 0, require a fresh report.
if config.max_staleness_secs > 0 {
let reported_at: Option<u64> = env
.storage()
.persistent()
.get(&DataKey::ConcentrationReportedAt(offering_id.clone()));
match reported_at {
None => return Err(RevoraError::StaleConcentrationData),
Some(ts) => {
if current_timestamp.saturating_sub(ts)
> config.max_staleness_secs
{
return Err(RevoraError::StaleConcentrationData);
}
}
}
}
let curr_key = DataKey::CurrentConcentration(offering_id.clone());
let current: u32 = env.storage().persistent().get(&curr_key).unwrap_or(0);
if current > config.max_bps {
Expand Down Expand Up @@ -3937,6 +3964,9 @@ impl RevoraRevenueShare {
/// ### Parameters
/// - `max_bps`: The maximum allowed share for a single holder in basis points.
/// - `enforce`: If true, `report_revenue` will fail if current concentration > `max_bps`.
/// - `max_staleness_secs`: When > 0 and `enforce` is true, `report_revenue` rejects if no
/// concentration has been reported or the last report is older than this many seconds.
/// Set to 0 to disable the staleness check.
///
/// ### Constraints
/// - `max_bps` must be <= 10,000.
Expand All @@ -3947,6 +3977,7 @@ impl RevoraRevenueShare {
token: Address,
max_bps: u32,
enforce: bool,
max_staleness_secs: u64,
) -> Result<(), RevoraError> {
Self::require_not_frozen(&env)?;
Self::require_not_paused(&env)?;
Expand Down Expand Up @@ -3976,7 +4007,9 @@ impl RevoraRevenueShare {

if !Self::is_event_only(&env) {
let key = DataKey::ConcentrationLimit(offering_id);
env.storage().persistent().set(&key, &ConcentrationLimitConfig { max_bps, enforce });
env.storage()
.persistent()
.set(&key, &ConcentrationLimitConfig { max_bps, enforce, max_staleness_secs });
}

Self::emit_v2_event(
Expand Down Expand Up @@ -4057,6 +4090,10 @@ impl RevoraRevenueShare {
env.storage()
.persistent()
.set(&DataKey::CurrentConcentration(offering_id.clone()), &concentration_bps);
env.storage().persistent().set(
&DataKey::ConcentrationReportedAt(offering_id.clone()),
&env.ledger().timestamp(),
);
env.events().publish(
(EVENT_CONCENTRATION_REPORTED, issuer, namespace, token),
concentration_bps,
Expand Down
12 changes: 6 additions & 6 deletions src/milestone_signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ fn milestone_concentration_enforcement_blocks_revenue_report() {
let (issuer, token, payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true);
client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64);
client.report_concentration(&issuer, &ns, &token, &6_000u32);

assert!(
Expand All @@ -267,7 +267,7 @@ fn milestone_concentration_at_limit_allows_revenue_report() {
let (issuer, token, payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true);
client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64);
client.report_concentration(&issuer, &ns, &token, &5_000u32);

client.report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &1u64, &false);
Expand All @@ -283,7 +283,7 @@ fn milestone_concentration_warning_does_not_block_report() {
let (issuer, token, payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false);
client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false, &0u64);
client.report_concentration(&issuer, &ns, &token, &8_000u32);

client.report_revenue(&issuer, &ns, &token, &payout, &500i128, &1u64, &false);
Expand All @@ -303,7 +303,7 @@ fn milestone_concentration_warning_event_emitted() {
let (issuer, token, _payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false);
client.set_concentration_limit(&issuer, &ns, &token, &3_000u32, &false, &0u64);
client.report_concentration(&issuer, &ns, &token, &8_000u32);

assert!(
Expand All @@ -320,7 +320,7 @@ fn milestone_concentration_one_bps_over_limit_rejected() {
let (issuer, token, payout) = setup_offering(&env, &client);
let ns = symbol_short!("def");

client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true);
client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64);
client.report_concentration(&issuer, &ns, &token, &5_001u32);

let result = client.try_report_revenue(&issuer, &ns, &token, &payout, &1_000i128, &1u64, &false);
Expand All @@ -338,7 +338,7 @@ fn milestone_concentration_testnet_mode_bypasses_enforcement() {
// Enable testnet mode (requires admin auth)
client.set_testnet_mode(&true);

client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true);
client.set_concentration_limit(&issuer, &ns, &token, &5_000u32, &true, &0u64);
client.report_concentration(&issuer, &ns, &token, &6_000u32);

// Should succeed despite being over limit
Expand Down
9 changes: 5 additions & 4 deletions src/proptest_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ pub enum TestOperation {
BlacklistAdd { caller: Address, issuer: Address, namespace: Symbol, token: Address, investor: Address },
/// `blacklist_remove(caller, issuer, namespace, token, investor)`
BlacklistRemove { caller: Address, issuer: Address, namespace: Symbol, token: Address, investor: Address },
/// `set_concentration_limit(issuer, namespace, token, max_bps, enforce)`
SetConcentrationLimit { max_bps: u32, enforce: bool },
/// `set_concentration_limit(issuer, namespace, token, max_bps, enforce, max_staleness_secs)`
SetConcentrationLimit { max_bps: u32, enforce: bool, max_staleness_secs: u64 },
/// `report_concentration(issuer, namespace, token, concentration_bps)`
ReportConcentration { concentration_bps: u32 },
/// `freeze()` — admin-only global freeze
Expand Down Expand Up @@ -198,8 +198,9 @@ pub fn arb_blacklist_remove() -> impl Strategy<Value = TestOperation> {

/// Strategy for a single `SetConcentrationLimit` operation.
pub fn arb_set_concentration_limit() -> impl Strategy<Value = TestOperation> {
(arb_valid_bps(), any::<bool>())
.prop_map(|(max_bps, enforce)| TestOperation::SetConcentrationLimit { max_bps, enforce })
(arb_valid_bps(), any::<bool>()).prop_map(|(max_bps, enforce)| {
TestOperation::SetConcentrationLimit { max_bps, enforce, max_staleness_secs: 0 }
})
}

/// Strategy for any single valid operation (uniform distribution across all variants).
Expand Down
Loading
Loading