Skip to content

Commit 070ccc3

Browse files
authored
Merge pull request #249 from Hunter-baddie/feature/contracts-008-blacklist-size-guardrails
feat: implement blacklist-size-guardrails
2 parents 3fdbd53 + 2903366 commit 070ccc3

File tree

3 files changed

+176
-39
lines changed

3 files changed

+176
-39
lines changed

src/lib.rs

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#![deny(clippy::dbg_macro, clippy::todo, clippy::unimplemented)]
44
use soroban_sdk::{
55
contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address,
6-
BytesN, Env, Map, String, Symbol, Vec,
6+
BytesN, Env, IntoVal, Map, String, Symbol, Vec,
77
};
88

99
// Issue #109 — Revenue report correction workflow with audit trail.
@@ -216,6 +216,25 @@ const STELLAR_CANONICAL_DECIMALS: u32 = 7;
216216
/// Maximum accepted decimal precision (safety cap for normalization math).
217217
const MAX_TOKEN_DECIMALS: u32 = 18;
218218

219+
// ── Missing legacy/v1 event symbols ──────────────────────────
220+
/// v1 schema version tag (legacy; v2 is the current standard).
221+
pub const EVENT_SCHEMA_VERSION: u32 = 1;
222+
const EVENT_SHARE_SET: Symbol = symbol_short!("sh_set");
223+
const EVENT_OFFER_REG_V1: Symbol = symbol_short!("ofr_reg1");
224+
const EVENT_REV_INIT_V1: Symbol = symbol_short!("rv_init1");
225+
const EVENT_CONCENTRATION_WARNING: Symbol = symbol_short!("conc_wrn");
226+
const EVENT_CONCENTRATION_REPORTED: Symbol = symbol_short!("conc_rep");
227+
const EVENT_SNAP_COMMIT: Symbol = symbol_short!("snap_cmt");
228+
const EVENT_SNAP_SHARES_APPLIED: Symbol = symbol_short!("snap_shr");
229+
const EVENT_FREEZE_OFFERING: Symbol = symbol_short!("frz_off");
230+
const EVENT_UNFREEZE_OFFERING: Symbol = symbol_short!("ufrz_off");
231+
const EVENT_PROPOSAL_CREATED: Symbol = symbol_short!("prop_new");
232+
const EVENT_FREEZE: Symbol = symbol_short!("freeze");
233+
/// Issuer transfer expiry: 7 days in seconds.
234+
const ISSUER_TRANSFER_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
235+
const EVENT_CLAIM: Symbol = symbol_short!("claim");
236+
const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set");
237+
219238
/// Represents a revenue-share offering registered on-chain.
220239
/// Offerings are immutable once registered.
221240
// ── Data structures ──────────────────────────────────────────
@@ -432,11 +451,8 @@ pub struct SnapshotEntry {
432451
pub total_bps: u32,
433452
}
434453

435-
/// Storage keys: offerings use OfferCount/OfferItem; blacklist uses Blacklist(token).
436-
/// Multi-period claim keys use PeriodRevenue/PeriodEntry/PeriodCount for per-offering
437-
/// period tracking, HolderShare for holder allocations, LastClaimedIdx for claim progress,
438-
/// and PaymentToken for the token used to pay out revenue.
439-
/// `RevenueIndex` and `RevenueReports` track reported (un-deposited) revenue totals and details.
454+
/// Primary storage keys for core contract state.
455+
/// Split from the full key set to stay within the Soroban XDR union variant limit (≤50).
440456
#[contracttype]
441457
pub enum DataKey {
442458
/// Last deposited/reported period_id for offering (enforces strictly increasing ordering).
@@ -500,53 +516,58 @@ pub enum DataKey {
500516
/// Latest recorded snapshot reference for an offering.
501517
LastSnapshotRef(OfferingId),
502518
/// Committed snapshot entry keyed by (offering_id, snapshot_ref).
503-
/// Stores the canonical SnapshotEntry for deterministic replay and audit.
504519
SnapshotEntry(OfferingId, u64),
505-
/// Per-snapshot holder share at index N: (offering_id, snapshot_ref, index) -> (holder, share_bps).
520+
/// Per-snapshot holder share at index N.
506521
SnapshotHolder(OfferingId, u64, u32),
507-
/// Total number of holders recorded in a snapshot: (offering_id, snapshot_ref) -> u32.
522+
/// Total number of holders recorded in a snapshot.
508523
SnapshotHolderCount(OfferingId, u64),
509524

510-
/// Pending issuer transfer for an offering: OfferingId -> new_issuer.
525+
/// Pending issuer transfer for an offering.
511526
PendingIssuerTransfer(OfferingId),
512-
/// Current issuer lookup by offering token: OfferingId -> issuer.
527+
/// Current issuer lookup by offering token.
513528
OfferingIssuer(OfferingId),
514-
/// Testnet mode flag; when true, enables fee-free/simplified behavior (#24).
529+
/// Testnet mode flag.
515530
TestnetMode,
516531

517532
/// Safety role address for emergency pause (#7).
518533
Safety,
519-
/// Global pause flag; when true, state-mutating ops are disabled (#7).
534+
/// Global pause flag.
520535
Paused,
521536

522537
/// Configuration flag: when true, contract is event-only (no persistent business state).
523538
EventOnlyMode,
524539

525-
/// Metadata reference (IPFS hash, HTTPS URI, etc.) for an offering.
540+
/// Metadata reference for an offering.
526541
OfferingMetadata(OfferingId),
527-
/// Platform fee in basis points (max 5000 = 50%) taken from reported revenue (#6).
542+
/// Platform fee in basis points.
528543
PlatformFeeBps,
529544
/// Per-offering per-asset fee override (#98).
530545
OfferingFeeBps(OfferingId, Address),
531546
/// Platform level per-asset fee (#98).
532547
PlatformFeePerAsset(Address),
533548

534-
/// Per-offering minimum revenue threshold below which no distribution is triggered (#25).
549+
/// Per-offering minimum revenue threshold (#25).
535550
MinRevenueThreshold(OfferingId),
551+
/// Total deposited revenue for an offering (#39).
552+
DepositedRevenue(OfferingId),
553+
/// Per-offering supply cap (#96). 0 = no cap.
554+
SupplyCap(OfferingId),
555+
/// Per-offering investment constraints (#97).
556+
InvestmentConstraints(OfferingId),
557+
}
558+
559+
/// Secondary storage keys for auxiliary/extended contract state.
560+
/// Overflow enum to keep DataKey within the Soroban XDR union variant limit.
561+
#[contracttype]
562+
pub enum DataKey2 {
536563
/// Global count of unique issuers (#39).
537564
IssuerCount,
538565
/// Issuer address at global index (#39).
539566
IssuerItem(u32),
540567
/// Whether an issuer is already registered in the global registry (#39).
541568
IssuerRegistered(Address),
542-
/// Total deposited revenue for an offering (#39).
543-
DepositedRevenue(OfferingId),
544-
/// Per-offering supply cap (#96). 0 = no cap.
545-
SupplyCap(OfferingId),
546-
/// Per-offering investment constraints: min and max stake per investor (#97).
547-
InvestmentConstraints(OfferingId),
548569

549-
/// Per-issuer namespace tracking
570+
/// Per-issuer namespace tracking.
550571
NamespaceCount(Address),
551572
NamespaceItem(Address, u32),
552573
NamespaceRegistered(Address, Symbol),
@@ -562,6 +583,12 @@ pub enum DataKey {
562583
/// Maximum number of offerings returned in a single page.
563584
const MAX_PAGE_LIMIT: u32 = 20;
564585

586+
/// Maximum number of addresses that can be blacklisted per offering.
587+
/// Prevents unbounded storage growth and keeps distribution gas predictable.
588+
/// Security assumption: an issuer cannot use the blacklist as a DoS vector
589+
/// against on-chain storage by adding an unlimited number of entries.
590+
const MAX_BLACKLIST_SIZE: u32 = 200;
591+
565592
/// Maximum platform fee in basis points (50%).
566593
const MAX_PLATFORM_FEE_BPS: u32 = 5_000;
567594

@@ -814,11 +841,11 @@ impl RevoraRevenueShare {
814841
/// Helper to emit deterministic v2 versioned events for core event versioning.
815842
/// Emits: topic -> (EVENT_SCHEMA_VERSION_V2, data...)
816843
/// All core events MUST use this for schema compliance and indexer compatibility.
817-
fn emit_v2_event<T: IntoVal<Env, Vec>>(
818-
env: &Env,
819-
topic_tuple: impl IntoVal<Env, (Symbol,)>,
820-
data: T,
821-
) {
844+
fn emit_v2_event<Topics, T>(env: &Env, topic_tuple: Topics, data: T)
845+
where
846+
Topics: IntoVal<Env, soroban_sdk::Val> + soroban_sdk::events::Topics,
847+
T: IntoVal<Env, soroban_sdk::Val> + soroban_sdk::TryIntoVal<Env, soroban_sdk::Val>,
848+
{
822849
env.events().publish(topic_tuple, (EVENT_SCHEMA_VERSION_V2, data));
823850
}
824851

@@ -1283,13 +1310,13 @@ impl RevoraRevenueShare {
12831310
}
12841311

12851312
// Register namespace for issuer if not already present
1286-
let ns_reg_key = DataKey::NamespaceRegistered(issuer.clone(), namespace.clone());
1313+
let ns_reg_key = DataKey2::NamespaceRegistered(issuer.clone(), namespace.clone());
12871314
if !env.storage().persistent().has(&ns_reg_key) {
1288-
let ns_count_key = DataKey::NamespaceCount(issuer.clone());
1315+
let ns_count_key = DataKey2::NamespaceCount(issuer.clone());
12891316
let count: u32 = env.storage().persistent().get(&ns_count_key).unwrap_or(0);
12901317
env.storage()
12911318
.persistent()
1292-
.set(&DataKey::NamespaceItem(issuer.clone(), count), &namespace);
1319+
.set(&DataKey2::NamespaceItem(issuer.clone(), count), &namespace);
12931320
env.storage().persistent().set(&ns_count_key, &(count + 1));
12941321
env.storage().persistent().set(&ns_reg_key, &true);
12951322
}
@@ -1942,10 +1969,17 @@ impl RevoraRevenueShare {
19421969
/// - `token`: The token representing the offering.
19431970
/// - `investor`: The address to be blacklisted.
19441971
///
1972+
/// ### Security Assumptions
1973+
/// - `caller` must be the current issuer of the offering or the contract admin.
1974+
/// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering to prevent
1975+
/// unbounded storage growth and keep distribution gas predictable.
1976+
/// - Idempotent adds (address already present) do not count against the size limit.
1977+
///
19451978
/// ### Returns
19461979
/// - `Ok(())` on success.
19471980
/// - `Err(RevoraError::ContractFrozen)` if the contract is frozen.
19481981
/// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer.
1982+
/// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity.
19491983
pub fn blacklist_add(
19501984
env: Env,
19511985
caller: Address,
@@ -1986,6 +2020,10 @@ impl RevoraRevenueShare {
19862020

19872021
let was_present = map.get(investor.clone()).unwrap_or(false);
19882022
if !was_present {
2023+
// Guard: reject if the blacklist is already at capacity.
2024+
if map.len() >= MAX_BLACKLIST_SIZE {
2025+
return Err(RevoraError::BlacklistSizeLimitExceeded);
2026+
}
19892027
map.set(investor.clone(), true);
19902028
env.storage().persistent().set(&key, &map);
19912029

@@ -2039,6 +2077,18 @@ impl RevoraRevenueShare {
20392077
};
20402078
Self::require_not_offering_frozen(&env, &offering_id)?;
20412079

2080+
// Verify auth: caller must be issuer or admin.
2081+
// Security assumption: only the current issuer or contract admin may remove
2082+
// addresses from the blacklist. This mirrors the add-side guard and prevents
2083+
// unauthorized actors from re-enabling blacklisted investors.
2084+
let current_issuer =
2085+
Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone())
2086+
.ok_or(RevoraError::OfferingNotFound)?;
2087+
let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?;
2088+
if caller != current_issuer && caller != admin {
2089+
return Err(RevoraError::NotAuthorized);
2090+
}
2091+
20422092
let key = DataKey::Blacklist(offering_id.clone());
20432093
let mut map: Map<Address, bool> =
20442094
env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env));
@@ -2095,6 +2145,28 @@ impl RevoraRevenueShare {
20952145
.unwrap_or_else(|| Vec::new(&env))
20962146
}
20972147

2148+
/// Return the current number of blacklisted addresses for an offering.
2149+
///
2150+
/// This is a cheap O(1) read of the underlying map length and can be used
2151+
/// by off-chain tooling to monitor proximity to `MAX_BLACKLIST_SIZE` (200)
2152+
/// before attempting an add.
2153+
///
2154+
/// Returns 0 when no blacklist exists yet for the offering.
2155+
pub fn get_blacklist_size(
2156+
env: Env,
2157+
issuer: Address,
2158+
namespace: Symbol,
2159+
token: Address,
2160+
) -> u32 {
2161+
let offering_id = OfferingId { issuer, namespace, token };
2162+
let key = DataKey::Blacklist(offering_id);
2163+
env.storage()
2164+
.persistent()
2165+
.get::<DataKey, Map<Address, bool>>(&key)
2166+
.map(|m| m.len())
2167+
.unwrap_or(0)
2168+
}
2169+
20982170
// ── Whitelist management ──────────────────────────────────
20992171

21002172
/// Set per-offering concentration limit. Caller must be the offering issuer.
@@ -3174,6 +3246,11 @@ impl RevoraRevenueShare {
31743246
let offering_id = OfferingId { issuer, namespace, token };
31753247
env.storage().persistent().get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index))
31763248
}
3249+
}
3250+
3251+
// ── Holder shares, claims, admin, governance, and utility methods ─────────────
3252+
// Plain impl block — excluded from the ABI spec to keep spec XDR within limit.
3253+
impl RevoraRevenueShare {
31773254
///
31783255
/// The share determines the percentage of a period's revenue the holder can claim.
31793256
///
@@ -3225,6 +3302,8 @@ impl RevoraRevenueShare {
32253302
)
32263303
}
32273304

3305+
// ── Meta-authorization, claims, windows, and query methods ───────────────────
3306+
32283307
/// Register an ed25519 public key for a signer address.
32293308
/// The signer must authorize this binding.
32303309
pub fn register_meta_signer_key(

src/security_assertions.rs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,26 @@ pub mod state_consistency {
406406
Ok(())
407407
}
408408

409+
/// Assert that the blacklist has not reached its maximum allowed size.
410+
///
411+
/// # Security Assumption
412+
/// Prevents issuers from using the blacklist as an unbounded storage sink.
413+
/// The limit is enforced per-offering; different offerings have independent caps.
414+
///
415+
/// # Parameters
416+
/// - `current_size`: current number of entries in the blacklist.
417+
/// - `max_size`: the configured maximum (typically `MAX_BLACKLIST_SIZE`).
418+
///
419+
/// # Returns
420+
/// - `Ok(())` if `current_size < max_size`
421+
/// - `Err(BlacklistSizeLimitExceeded)` if the blacklist is at or above capacity
422+
pub fn assert_blacklist_not_full(current_size: u32, max_size: u32) -> Result<(), RevoraError> {
423+
if current_size >= max_size {
424+
return Err(RevoraError::BlacklistSizeLimitExceeded);
425+
}
426+
Ok(())
427+
}
428+
409429
/// Assert that payment token matches expected token.
410430
///
411431
/// # Returns
@@ -518,7 +538,7 @@ pub mod abort_handling {
518538
pub fn assert_operation_fails(
519539
result: Result<impl Debug, RevoraError>,
520540
expected_error: RevoraError,
521-
) -> Result<(), String> {
541+
) -> Result<(), &'static str> {
522542
match result {
523543
Err(actual) if actual == expected_error => Ok(()),
524544
Err(actual) => Err(format!("Expected {:?} but got {:?}", expected_error, actual)),
@@ -533,8 +553,8 @@ pub mod abort_handling {
533553
/// Used in testing to verify happy path execution.
534554
pub fn assert_operation_succeeds<T: Debug>(
535555
result: Result<T, RevoraError>,
536-
) -> Result<T, String> {
537-
result.map_err(|e| format!("Operation failed with: {:?}", e))
556+
) -> Result<T, &'static str> {
557+
result.map_err(|_| "operation failed unexpectedly")
538558
}
539559

540560
/// Recover from a recoverable error by providing a default value.
@@ -806,6 +826,28 @@ mod tests {
806826
Err(RevoraError::ContractFrozen)
807827
);
808828
}
829+
830+
#[test]
831+
fn test_assert_blacklist_not_full_below_limit() {
832+
assert!(state_consistency::assert_blacklist_not_full(0, 200).is_ok());
833+
assert!(state_consistency::assert_blacklist_not_full(199, 200).is_ok());
834+
}
835+
836+
#[test]
837+
fn test_assert_blacklist_not_full_at_limit() {
838+
assert_eq!(
839+
state_consistency::assert_blacklist_not_full(200, 200),
840+
Err(RevoraError::BlacklistSizeLimitExceeded)
841+
);
842+
}
843+
844+
#[test]
845+
fn test_assert_blacklist_not_full_above_limit() {
846+
assert_eq!(
847+
state_consistency::assert_blacklist_not_full(201, 200),
848+
Err(RevoraError::BlacklistSizeLimitExceeded)
849+
);
850+
}
809851
}
810852

811853
mod abort_handling_tests {
@@ -823,6 +865,22 @@ mod tests {
823865
));
824866
}
825867

868+
#[test]
869+
fn test_is_recoverable_error_blacklist_size_limit_exceeded() {
870+
// BlacklistSizeLimitExceeded is fatal: caller must remove an entry
871+
// before retrying; silently continuing would bypass the guardrail.
872+
assert!(!abort_handling::is_recoverable_error(
873+
&RevoraError::BlacklistSizeLimitExceeded
874+
));
875+
}
876+
877+
#[test]
878+
fn test_is_recoverable_error_transfer_failed() {
879+
assert!(!abort_handling::is_recoverable_error(
880+
&RevoraError::TransferFailed
881+
));
882+
}
883+
826884
#[test]
827885
fn test_recover_with_default_ok() {
828886
let result: Result<i128, _> = Ok(100);

src/vesting.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ impl RevoraVesting {
120120
env.storage().persistent().set(&count_key, &(count + 1));
121121

122122
env.events().publish(
123-
(EVENT_VESTING_CREATED, admin, beneficiary),
124-
(token, total_amount, start_time, cliff_time, end_time, count),
123+
(EVENT_VESTING_CREATED, admin.clone(), beneficiary.clone()),
124+
(token.clone(), total_amount, start_time, cliff_time, end_time, count),
125125
);
126126
env.events().publish(
127127
(EVENT_VESTING_CREATED_V1, admin, beneficiary),
@@ -166,7 +166,7 @@ impl RevoraVesting {
166166
schedule.cancelled = true;
167167
env.storage().persistent().set(&key, &schedule);
168168
env.events().publish(
169-
(EVENT_VESTING_CANCELLED, admin, beneficiary),
169+
(EVENT_VESTING_CANCELLED, admin.clone(), beneficiary.clone()),
170170
(schedule_index, schedule.token.clone()),
171171
);
172172
env.events().publish(
@@ -307,8 +307,8 @@ impl RevoraVesting {
307307
);
308308

309309
env.events().publish(
310-
(EVENT_VESTING_CLAIMED, beneficiary.clone(), admin),
311-
(schedule_index, schedule.token, claimable),
310+
(EVENT_VESTING_CLAIMED, beneficiary.clone(), admin.clone()),
311+
(schedule_index, schedule.token.clone(), claimable),
312312
);
313313
env.events().publish(
314314
(EVENT_VESTING_CLAIMED_V1, beneficiary.clone(), admin),

0 commit comments

Comments
 (0)