From acc8658d722ee7f698fe74a835b2236bd04df20c Mon Sep 17 00:00:00 2001 From: ryzen-xp Date: Mon, 29 Jun 2026 06:52:56 +0530 Subject: [PATCH] feat: add jurisdiction tagging and dividend-accrual -ledgger --- README.md | 14 +- docs/dividend-accrual-ledger.md | 60 +++ docs/jurisdiction-tagging.md | 46 ++ src/lib.rs | 670 ++++++++++++++++++++++++--- src/structured_error_tests.rs | 6 +- src/test_accrual_ledger.rs | 88 ++++ src/test_claim_transfer_fail.rs | 14 +- src/test_compute_share_invariants.rs | 10 +- src/test_event_indexed_v2.rs | 4 +- src/test_jurisdiction.rs | 195 ++++++++ src/test_utils.rs | 2 +- 11 files changed, 1022 insertions(+), 87 deletions(-) create mode 100644 docs/dividend-accrual-ledger.md create mode 100644 docs/jurisdiction-tagging.md create mode 100644 src/test_accrual_ledger.rs create mode 100644 src/test_jurisdiction.rs diff --git a/README.md b/README.md index b302e204..2cbabffb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ 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
` | — | All blacklisted addresses for token. | +| `set_holder_jurisdiction` | `issuer: Address`, `namespace: Symbol`, `token: Address`, `holder: Address`, `jurisdiction: Symbol` | `Result<(), RevoraError>` | issuer | Tag a holder record with a mutable issuer-controlled jurisdiction code for one offering. Emits `jur_set`. | +| `get_holder_jurisdiction` | `issuer: Address`, `namespace: Symbol`, `token: Address`, `holder: Address` | `Option` | — | Read the holder's stored jurisdiction tag for one offering. | +| `set_allowed_jurisdictions` | `issuer: Address`, `namespace: Symbol`, `token: Address`, `jurisdictions: Vec` | `Result<(), RevoraError>` | issuer | Replace the offering's jurisdiction allowlist for future share writes and snapshot inclusion. Empty list disables jurisdiction gating. Emits `jur_set`. | +| `get_allowed_jurisdictions` | `issuer: Address`, `namespace: Symbol`, `token: Address` | `Vec` | — | Return the offering's configured jurisdiction allowlist. | | `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` | — | Get concentration limit config for offering. | @@ -76,7 +80,8 @@ 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. | -| 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. | +| 31 | `JurisdictionDisallowed` | Holder jurisdiction is not currently allowed for a new `set_holder_share` write or snapshot inclusion batch. Existing persisted shares remain claimable. | +| 51 | `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`. @@ -92,6 +97,9 @@ Auth failures (e.g. wrong signer) are signaled by host/panic, not `RevoraError`. | `rev_rep` | `(issuer, token), (amount, period_id, blacklist_vec)` | Receipt for an accepted persisted report call (initial or override). Use `rev_init` plus `rev_ovrd` to reconstruct audit totals. | | `bl_add` | `(token, caller), investor` | After `blacklist_add`. | | `bl_rem` | `(token, caller), investor` | After `blacklist_remove`. | +| `jur_set` | `(issuer, namespace, token), (holder/allow, payload...)` | After `set_holder_jurisdiction` or `set_allowed_jurisdictions`. | +| `jur_reject` | `(issuer, namespace, token), (holder, jurisdiction, action)` | When a share write or snapshot batch is rejected because the holder's jurisdiction is not allowed. | +| `acc_upd` | `(issuer, namespace, token), (period_id, period_index, delta_e18, global_acc_e18)` | After `deposit_revenue`, recording the cumulative dividend-accrual index update for indexers. | | `min_rev` | `(issuer, token), (previous_amount, new_amount)` | When `set_min_revenue_threshold` is set or changed. | | `rev_below` | `(issuer, token), (amount, period_id, threshold)` | When a new `report_revenue` call is below the offering's minimum threshold; no report/audit update and the period remains available for a later accepted report. | | `conc_warn` | `(issuer, token), (concentration_bps, limit_bps)` | When `report_concentration` is called and reported concentration exceeds configured limit (warning only; enforce blocks at `report_revenue`). | @@ -121,6 +129,8 @@ Auth failures (e.g. wrong signer) are signaled by host/panic, not `RevoraError`. - **Payment token decimals:** Different Stellar assets use different decimal precisions (e.g., USDC=6, XLM=7, WBTC=8). Use `set_payment_token_decimals` to configure the offering's asset precision; the contract normalizes raw amounts to 7-decimal canonical units before computing holder shares. See [docs/payment-token-decimal-compatibility.md](./docs/payment-token-decimal-compatibility.md) for details and examples. - **Testnet mode:** Admin can enable testnet mode via `set_testnet_mode(true)` to relax certain validations for non-production deployments. When enabled: (1) `register_offering` allows `revenue_share_bps > 10000`, (2) `report_revenue` skips concentration enforcement. Use only for testnet/development environments. Check mode with `is_testnet_mode()`. - **Reporting and claiming windows:** Issuers can optionally restrict when `report_revenue` and `claim` are permitted using time-based access windows. See [Time Windows](#time-based-access-windows-reporting--claiming) below. +- **Jurisdiction gating:** Issuers can tag holder records and maintain a per-offering jurisdiction allowlist. The guard only applies to new `set_holder_share` writes and `apply_snapshot_shares` batches, so removing a jurisdiction does not retroactively block already-persisted holders from claiming. See [docs/jurisdiction-tagging.md](./docs/jurisdiction-tagging.md). +- **Dividend accrual ledger:** `deposit_revenue` now advances a cumulative per-offering accrual index, and holder share updates are frozen with per-holder checkpoints. This means changing a holder's share only affects future deposits; already-deposited revenue remains claimable at the historical share in effect when it accrued. See [docs/dividend-accrual-ledger.md](./docs/dividend-accrual-ledger.md). ### Distribution Proofs @@ -301,7 +311,7 @@ Comprehensive tests verify these invariants: - `claim_partial_sequence_with_delay_advances_index_correctly`: Partial sequences advance index correctly -- **Version:** Call `get_version()` to read the current contract version (a constant, e.g., `23`). This value is bumped when storage layout or semantics change in a way that affects compatibility. +- **Version:** Call `get_version()` to read the current contract version (a constant, e.g., `24`). This value is bumped when storage layout or semantics change in a way that affects compatibility. - **Upgrade strategy:** This codebase deploys a single WASM contract; Soroban has no EVM-style proxy upgrade, so upgrades require deploying a new contract instance. Future upgrades follow this process: 1. Deploy a new contract (new WASM) with a higher `CONTRACT_VERSION`. 2. Optionally run a one-time migration (e.g., admin or migration script) that reads state from the old contract and writes into the new one, or that emits migration-milestone events for indexers. diff --git a/docs/dividend-accrual-ledger.md b/docs/dividend-accrual-ledger.md new file mode 100644 index 00000000..1705bed2 --- /dev/null +++ b/docs/dividend-accrual-ledger.md @@ -0,0 +1,60 @@ +# Dividend Accrual Ledger + +This document describes the per-offering dividend accrual ledger added for issue `#449`. + +## What Changed + +- `deposit_revenue` now updates a cumulative per-offering accrual index: + - `GlobalAccPerShareE18(offering_id)` + - `AccPerShareAtIndex(offering_id, period_index)` +- Holder share changes are frozen with per-holder checkpoints: + - `HolderShareSchedule(offering_id, holder)` +- Claims no longer use the holder's current share for all unclaimed periods. + Instead, each unclaimed deposited period is priced against the share checkpoint + that was active when that period accrued. +- `acc_upd` is emitted on every successful `deposit_revenue` so indexers can + reconcile the on-chain cumulative index. + +## Important Repo-Specific Note + +The issue description referenced `report_revenue`, but this contract's actual +claim funding path is `deposit_revenue`. `report_revenue` is informational and +audit-oriented here; it does not create holder claimable balances. + +For that reason, the accrual index is updated on `deposit_revenue`. + +## Security Properties + +- Share changes are forward-only: + - Updating a holder from `50%` to `25%` affects future deposits only. + - Already-deposited periods retain the historical share that was active when + the revenue accrued. +- Zeroing a holder does not burn already-accrued entitlement: + - If revenue was deposited while the holder had a non-zero share, a later + `set_holder_share(..., 0)` does not erase that historical claim. +- Claim delay remains authoritative: + - The per-offering `ClaimDelaySecs` barrier is still enforced period-by-period. + - A share change before the delay elapses does not rewrite the older period's + eventual payout. +- Jurisdiction gating remains non-retroactive: + - The new accrual path does not change the `#451` rule that removing a + jurisdiction should not block already-persisted holder claims. + +## Indexer Notes + +`acc_upd` carries: + +- `period_id` +- `period_index` +- `delta_e18` +- `global_acc_e18` + +Indexers can reconstruct the cumulative dividend index directly from these +events and pair it with holder share checkpoint history for off-chain reviews. + +## Test Coverage Added + +- historical share preserved across unclaimed deposits +- zeroing a holder after deposit does not erase accrued value +- `get_claimable` matches the historical share schedule +- claim delay continues to compose correctly with share changes diff --git a/docs/jurisdiction-tagging.md b/docs/jurisdiction-tagging.md new file mode 100644 index 00000000..2fb5863e --- /dev/null +++ b/docs/jurisdiction-tagging.md @@ -0,0 +1,46 @@ +# Jurisdiction Tagging and Compliance Gating + +Issue: #451 + +## Summary + +This change adds issuer-controlled jurisdiction metadata to holder records and a per-offering jurisdiction allowlist that gates new share writes and snapshot inclusion. + +Implemented in: +- `src/lib.rs` +- `src/test_jurisdiction.rs` +- `src/structured_error_tests.rs` + +## New API + +- `set_holder_jurisdiction(issuer, namespace, token, holder, jurisdiction)` +- `get_holder_jurisdiction(issuer, namespace, token, holder) -> Option` +- `set_allowed_jurisdictions(issuer, namespace, token, jurisdictions)` +- `get_allowed_jurisdictions(issuer, namespace, token) -> Vec` + +## Enforcement Boundary + +- `set_holder_share` rejects with `JurisdictionDisallowed` when the offering allowlist is non-empty and the holder's stored jurisdiction is missing or not allowed. +- `meta_set_holder_share` inherits the same guard because it routes through the shared internal share writer. +- `apply_snapshot_shares` rejects the entire batch before any writes when any holder in the batch is disallowed. +- `claim` does not re-check the allowlist. This is intentional so that tightening or removing jurisdictions does not retroactively block already-persisted holder records. + +## Events + +- `jur_set`: emitted when the issuer updates a holder jurisdiction or replaces the offering allowlist. +- `jur_reject`: emitted when a share write or snapshot batch is rejected for jurisdiction mismatch. + +## Security Notes + +- Holder jurisdictions and allowlists are mutable only by the current issuer for the offering. +- The allowlist is checked before share state or snapshot slots are written, preserving atomicity on rejection. +- Empty allowlist means jurisdiction gating is disabled for future writes. +- Issuer transfer migrates the offering-level allowlist to the new issuer-scoped offering record. + +## Tests + +- Holder tagging and allowlist persistence with audit event coverage. +- Direct `set_holder_share` rejection path with `JurisdictionDisallowed`. +- Snapshot batch rejection without partial state writes. +- Non-retroactive behavior: previously recorded holders remain claimable after the issuer removes their jurisdiction from the allowlist. +- Structured error discriminant coverage for the new error code. diff --git a/src/lib.rs b/src/lib.rs index afd6a619..dfbd0e05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,6 +96,8 @@ pub enum RevoraError { SnapshotNotFinalized = 49, /// The recomputed snapshot digest does not match the committed `content_hash`. SnapshotHashMismatch = 50, + /// Concentration data is missing or stale for an enforced offering. + StaleConcentrationData = 51, /// Payout asset mismatch. PayoutAssetMismatch = 14, /// A transfer is already pending for this offering. @@ -131,6 +133,8 @@ pub enum RevoraError { /// Multisig proposal has expired. /// Wire value: 30. Stable since v1. ProposalExpired = 30, + /// Holder jurisdiction is not permitted by the offering's compliance allowlist. + JurisdictionDisallowed = 31, /// Cross-contract token transfer failed. TransferFailed = 39, /// Contract is already at the target version; no migration needed. @@ -150,7 +154,7 @@ pub enum RevoraError { /// Issuer transfer has expired. IssuerTransferExpired = 43, /// Transfer blocked because the offering has pre-cliff vesting schedules. - VestingTransferBlocked = 48, + VestingTransferBlocked = 38, /// Contract is paused. ContractPaused = 44, /// Blacklist size limit exceeded. @@ -185,7 +189,15 @@ mod test_duplicates; #[cfg(test)] mod test_event_indexed_v2; #[cfg(test)] +mod test_jurisdiction; +#[cfg(test)] +mod test_accrual_ledger; +#[cfg(test)] +mod structured_error_tests; +#[cfg(test)] mod test_min_revenue_threshold_boundary; +#[cfg(test)] +mod test_utils; // #[cfg(test)] // mod test_claim_transfer_fail; #[cfg(test)] @@ -232,6 +244,7 @@ const EVENT_REV_DEPOSIT_V2: Symbol = symbol_short!("rev_dep2"); const EVENT_REV_DEP_SNAP_V2: Symbol = symbol_short!("rev_snp2"); const EVENT_CLAIM_V2: Symbol = symbol_short!("claim2"); const EVENT_SHARE_SET_V2: Symbol = symbol_short!("sh_set2"); +const EVENT_ACC_UPD: Symbol = symbol_short!("acc_upd"); const EVENT_FREEZE_V2: Symbol = symbol_short!("frz2"); const EVENT_CLAIM_DELAY_SET_V2: Symbol = symbol_short!("dly_set2"); const EVENT_CONCENTRATION_WARNING_V2: Symbol = symbol_short!("conc2"); @@ -255,6 +268,14 @@ pub enum ProposalAction { SetProposalDuration(u64), } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PauseState { + NotPaused, + SoftPaused, + HardPaused, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Proposal { @@ -315,6 +336,11 @@ const EVENT_MULTISIG_INIT: Symbol = symbol_short!("ms_init"); const EVENT_META_REV_APPROVE: Symbol = symbol_short!("meta_rev"); /// Emitted when `repair_audit_summary` writes a corrected `AuditSummary` to storage. const EVENT_AUDIT_REPAIRED: Symbol = symbol_short!("aud_rep"); +const EVENT_JUR_SCOPE_HOLDER: Symbol = symbol_short!("holder"); +const EVENT_JUR_SCOPE_ALLOW: Symbol = symbol_short!("allow"); +const EVENT_JUR_ACTION_SHARE: Symbol = symbol_short!("share"); +const EVENT_JUR_ACTION_SNAPSHOT: Symbol = symbol_short!("snap"); +const EVENT_JUR_UNSET: Symbol = symbol_short!("unset"); /// Missing v1 event symbols (referenced by report_revenue versioned path). /// Emitted when payment token decimals are set for an offering. @@ -327,6 +353,7 @@ const EVENT_ROUNDING_MODE_SET: Symbol = symbol_short!("rnd_mode"); const EVENT_ADMIN_SET: Symbol = symbol_short!("admin_set"); const EVENT_PLATFORM_FEE_SET: Symbol = symbol_short!("fee_set"); const BPS_DENOMINATOR: i128 = 10_000; +const ACCRUAL_SCALE_E18: i128 = 1_000_000_000_000_000_000; /// Stellar network canonical decimal precision (7 decimal places, i.e., stroops). const STELLAR_CANONICAL_DECIMALS: u32 = 7; /// Maximum accepted decimal precision (safety cap for normalization math). @@ -362,9 +389,9 @@ const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set"); /// Offerings are immutable once registered. // ── Data structures ────────────────────────────────────────── /// Contract version identifier (#23). Bumped when storage or semantics change; used for migration and compatibility. -pub const CONTRACT_VERSION: u32 = 23; +pub const CONTRACT_VERSION: u32 = 25; /// Persistent storage layout version. Bump when adding/renaming DataKey variants. -pub const STORAGE_LAYOUT_VERSION: u32 = 1; +pub const STORAGE_LAYOUT_VERSION: u32 = 2; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -493,6 +520,21 @@ pub struct SimulateDistributionResult { pub payouts: Vec<(Address, i128)>, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct HolderShareCheckpoint { + pub start_index: u32, + pub share_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct HolderAccrualState { + pub last_settled_idx: u32, + pub last_acc_per_share_e18: i128, + pub accrued_owed: i128, +} + /// Versioned structured topic payload for indexers. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -667,8 +709,6 @@ pub enum DataKey { Admin, /// Contract frozen flag; when true, state-changing ops are disabled (#32). Frozen, - /// Offering-level frozen flag; when true, offering mutations are disabled. - FrozenOffering(OfferingId), /// Proposed new admin address (pending two-step rotation). PendingAdmin, @@ -736,6 +776,14 @@ pub enum DataKey2 { LastReportedPeriodId(OfferingId), /// Last deposited period_id for an offering. LastDepositedPeriodId(OfferingId), + /// Supply cap for an offering's cumulative deposited revenue. + SupplyCap(OfferingId), + /// Cumulative deposited revenue tracked against the supply cap. + DepositedRevenue(OfferingId), + /// Per-offering investment constraints. + InvestmentConstraints(OfferingId), + /// Per-offering minimum revenue threshold. + MinRevenueThreshold(OfferingId), /// Payment token decimals configured for an offering. PaymentTokenDecimals(OfferingId), /// Offering-scoped freeze flag. @@ -756,6 +804,18 @@ pub enum DataKey2 { StressDataEntry(Address, u32), /// Tracks total amount of dummy data allocated per admin. StressDataCount(Address), + /// Holder's configured jurisdiction tag for (offering_id, holder). + HolderJurisdiction(OfferingId, Address), + /// Per-offering jurisdiction allowlist. Empty means compliance gating is disabled. + AllowedJurisdictions(OfferingId), + /// Global cumulative normalized accrual per 1 bps share, scaled by 1e18. + GlobalAccPerShareE18(OfferingId), + /// Snapshot of cumulative accrual after `index` deposited periods. + AccPerShareAtIndex(OfferingId, u32), + /// Cached holder accrual state used to freeze matured entitlements across share changes. + HolderAccrualState(OfferingId, Address), + /// Piecewise-constant share schedule keyed by deposited-period index. + HolderShareSchedule(OfferingId, Address), /// Packed flags: (event_versioning_enabled: bool, event_only_mode: bool). ContractFlags, @@ -1070,7 +1130,7 @@ impl RevoraRevenueShare { if env .storage() .persistent() - .get::(&DataKey::FrozenOffering(offering_id.clone())) + .get::(&DataKey2::FrozenOffering(offering_id.clone())) .unwrap_or(false) { return Err(RevoraError::OfferingFrozen); @@ -1137,6 +1197,14 @@ impl RevoraRevenueShare { env.events().publish(topic_tuple, (EVENT_SCHEMA_VERSION_V2, data)); } + fn jurisdiction_set_event(env: &Env) -> Symbol { + Symbol::new(env, "jur_set") + } + + fn jurisdiction_reject_event(env: &Env) -> Symbol { + Symbol::new(env, "jur_reject") + } + fn is_event_versioning_enabled(_env: Env) -> bool { true } @@ -1236,6 +1304,13 @@ impl RevoraRevenueShare { token: token.clone(), }; + Self::require_holder_jurisdiction_allowed( + env, + &offering_id, + &holder, + EVENT_JUR_ACTION_SHARE, + )?; + // Maintain a running total of persisted holder shares for this offering. let total_key = DataKey::HolderShareTotal(offering_id.clone()); let mut current_total: u32 = env.storage().persistent().get(&total_key).unwrap_or(0); @@ -1251,11 +1326,14 @@ impl RevoraRevenueShare { return Err(RevoraError::InvalidShareBps); } + Self::cache_holder_accrual_through_matured(env, &offering_id, &holder); + // Persist updated holder share and running total. env.storage() .persistent() .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); env.storage().persistent().set(&total_key, &new_total); + Self::record_holder_share_transition(env, &offering_id, &holder, old_share, share_bps); env.events().publish( (EVENT_SHARE_SET, issuer.clone(), namespace.clone(), token.clone()), @@ -1270,6 +1348,318 @@ impl RevoraRevenueShare { Ok(()) } + fn get_period_count_internal(env: &Env, offering_id: &OfferingId) -> u32 { + env.storage() + .persistent() + .get::<_, u32>(&DataKey::PeriodCount(offering_id.clone())) + .unwrap_or(0) + } + + fn accrual_delta_e18(amount: i128) -> i128 { + amount + .checked_mul(ACCRUAL_SCALE_E18) + .unwrap_or(i128::MAX) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0) + } + + fn get_acc_per_share_at_index(env: &Env, offering_id: &OfferingId, index: u32) -> i128 { + if index == 0 { + return 0; + } + env.storage() + .persistent() + .get::<_, i128>(&DataKey2::AccPerShareAtIndex(offering_id.clone(), index)) + .unwrap_or(0) + } + + fn get_holder_share_schedule( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> Vec { + if let Some(schedule) = env + .storage() + .persistent() + .get::<_, Vec>(&DataKey2::HolderShareSchedule( + offering_id.clone(), + holder.clone(), + )) + { + return schedule; + } + + let current_share: u32 = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::HolderShare(offering_id.clone(), holder.clone())) + .unwrap_or(0); + let mut schedule = Vec::new(env); + if current_share > 0 { + schedule.push_back(HolderShareCheckpoint { start_index: 0, share_bps: current_share }); + } + schedule + } + + fn record_holder_share_transition( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + old_share: u32, + new_share: u32, + ) { + if old_share == new_share { + return; + } + + let period_count = Self::get_period_count_internal(env, offering_id); + let existing = Self::get_holder_share_schedule(env, offering_id, holder); + let mut updated = Vec::new(env); + + for i in 0..existing.len() { + let checkpoint = existing.get(i).unwrap(); + if checkpoint.start_index == period_count { + continue; + } + updated.push_back(checkpoint); + } + + updated.push_back(HolderShareCheckpoint { start_index: period_count, share_bps: new_share }); + env.storage().persistent().set( + &DataKey2::HolderShareSchedule(offering_id.clone(), holder.clone()), + &updated, + ); + } + + fn get_holder_accrual_state( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> HolderAccrualState { + let last_claimed_idx: u32 = env + .storage() + .persistent() + .get::<_, u32>(&DataKey::LastClaimedIdx(offering_id.clone(), holder.clone())) + .unwrap_or(0); + + let mut state = env + .storage() + .persistent() + .get::<_, HolderAccrualState>(&DataKey2::HolderAccrualState( + offering_id.clone(), + holder.clone(), + )) + .unwrap_or(HolderAccrualState { + last_settled_idx: last_claimed_idx, + last_acc_per_share_e18: Self::get_acc_per_share_at_index( + env, + offering_id, + last_claimed_idx, + ), + accrued_owed: 0, + }); + + if state.last_settled_idx < last_claimed_idx { + state.last_settled_idx = last_claimed_idx; + state.last_acc_per_share_e18 = + Self::get_acc_per_share_at_index(env, offering_id, last_claimed_idx); + state.accrued_owed = 0; + } + + state + } + + fn compute_holder_payout_for_range( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + start_idx: u32, + end_idx: u32, + ) -> i128 { + if start_idx >= end_idx { + return 0; + } + + let schedule = Self::get_holder_share_schedule(env, offering_id, holder); + if schedule.is_empty() { + return 0; + } + + let mut total = 0_i128; + let mut current_index = start_idx; + let mut current_share = 0_u32; + let mut schedule_idx = 0_u32; + + while schedule_idx < schedule.len() { + let checkpoint = schedule.get(schedule_idx).unwrap(); + if checkpoint.start_index > start_idx { + break; + } + current_share = checkpoint.share_bps; + schedule_idx = schedule_idx.saturating_add(1); + } + + while current_index < end_idx { + while schedule_idx < schedule.len() { + let checkpoint = schedule.get(schedule_idx).unwrap(); + if checkpoint.start_index > current_index { + break; + } + current_share = checkpoint.share_bps; + schedule_idx = schedule_idx.saturating_add(1); + } + + if current_share > 0 { + let acc_end = + Self::get_acc_per_share_at_index(env, offering_id, current_index.saturating_add(1)); + let acc_start = Self::get_acc_per_share_at_index(env, offering_id, current_index); + let delta = acc_end.saturating_sub(acc_start); + total = total.saturating_add( + delta.saturating_mul(current_share as i128) / ACCRUAL_SCALE_E18, + ); + } + + current_index = current_index.saturating_add(1); + } + + total + } + + fn find_matured_claim_end_idx(env: &Env, offering_id: &OfferingId, start_idx: u32) -> u32 { + let period_count = Self::get_period_count_internal(env, offering_id); + if start_idx >= period_count { + return start_idx; + } + + let delay_secs: u64 = env + .storage() + .persistent() + .get::<_, u64>(&DataKey::ClaimDelaySecs(offering_id.clone())) + .unwrap_or(0); + let now = env.ledger().timestamp(); + let mut end_idx = start_idx; + + while end_idx < period_count { + let entry_key = DataKey::PeriodEntry(offering_id.clone(), end_idx); + let period_id: u64 = env.storage().persistent().get(&entry_key).unwrap_or(0); + if period_id == 0 { + end_idx = end_idx.saturating_add(1); + continue; + } + + let time_key = DataKey::PeriodDepositTime(offering_id.clone(), period_id); + let deposit_time: u64 = env.storage().persistent().get(&time_key).unwrap_or(0); + if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { + break; + } + + end_idx = end_idx.saturating_add(1); + } + + end_idx + } + + fn cache_holder_accrual_through_matured(env: &Env, offering_id: &OfferingId, holder: &Address) { + let mut state = Self::get_holder_accrual_state(env, offering_id, holder); + let matured_end = Self::find_matured_claim_end_idx(env, offering_id, state.last_settled_idx); + if matured_end <= state.last_settled_idx { + return; + } + + let delta = Self::compute_holder_payout_for_range( + env, + offering_id, + holder, + state.last_settled_idx, + matured_end, + ); + state.accrued_owed = state.accrued_owed.saturating_add(delta); + state.last_settled_idx = matured_end; + state.last_acc_per_share_e18 = Self::get_acc_per_share_at_index(env, offering_id, matured_end); + + env.storage().persistent().set( + &DataKey2::HolderAccrualState(offering_id.clone(), holder.clone()), + &state, + ); + } + + fn normalize_jurisdictions(env: &Env, jurisdictions: Vec) -> Vec { + let mut normalized = Vec::new(env); + for i in 0..jurisdictions.len() { + let jurisdiction = jurisdictions.get(i).unwrap(); + if !Self::vec_contains_symbol(&normalized, &jurisdiction) { + normalized.push_back(jurisdiction); + } + } + normalized + } + + fn vec_contains_symbol(values: &Vec, target: &Symbol) -> bool { + for i in 0..values.len() { + if values.get(i).unwrap() == *target { + return true; + } + } + false + } + + fn get_allowed_jurisdictions_internal(env: &Env, offering_id: &OfferingId) -> Vec { + env.storage() + .persistent() + .get(&DataKey2::AllowedJurisdictions(offering_id.clone())) + .unwrap_or_else(|| Vec::new(env)) + } + + fn get_holder_jurisdiction_internal( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + ) -> Option { + env.storage() + .persistent() + .get(&DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone())) + } + + fn emit_jurisdiction_reject( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + jurisdiction: Symbol, + action: Symbol, + ) { + env.events().publish( + ( + Self::jurisdiction_reject_event(env), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ), + (holder.clone(), jurisdiction, action), + ); + } + + fn require_holder_jurisdiction_allowed( + env: &Env, + offering_id: &OfferingId, + holder: &Address, + action: Symbol, + ) -> Result<(), RevoraError> { + let allowed = Self::get_allowed_jurisdictions_internal(env, offering_id); + if allowed.is_empty() { + return Ok(()); + } + + let jurisdiction = Self::get_holder_jurisdiction_internal(env, offering_id, holder) + .unwrap_or(EVENT_JUR_UNSET); + + if Self::vec_contains_symbol(&allowed, &jurisdiction) { + return Ok(()); + } + + Self::emit_jurisdiction_reject(env, offering_id, holder, jurisdiction, action); + Err(RevoraError::JurisdictionDisallowed) + } + /// Return the explicitly persisted payment token lock for an offering, if any. /// /// The `PaymentToken` key is written only after the first successful deposit. @@ -1385,6 +1775,23 @@ impl RevoraRevenueShare { env.storage().persistent().set(&count_key, &(count + 1)); Self::commit_period_id(env, last_period_key, period_id); + let decimals = Self::get_payment_token_decimals( + env.clone(), + offering_id.issuer.clone(), + offering_id.namespace.clone(), + offering_id.token.clone(), + ); + let normalized = Self::normalize_amount(amount, decimals); + let acc_delta_e18 = Self::accrual_delta_e18(normalized); + let global_acc_key = DataKey2::GlobalAccPerShareE18(offering_id.clone()); + let current_acc: i128 = env.storage().persistent().get(&global_acc_key).unwrap_or(0); + let next_acc = current_acc.saturating_add(acc_delta_e18); + env.storage().persistent().set(&global_acc_key, &next_acc); + env.storage().persistent().set( + &DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), + &next_acc, + ); + // Update cumulative deposited revenue and emit cap-reached event if applicable (#96) let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); let deposited: i128 = env.storage().persistent().get(&deposited_key).unwrap_or(0); @@ -1405,6 +1812,10 @@ impl RevoraRevenueShare { (EVENT_REV_DEPOSIT_V2, issuer.clone(), namespace.clone(), token.clone()), (payment_token, amount, period_id), ); + env.events().publish( + (EVENT_ACC_UPD, issuer, namespace, token), + (period_id, count + 1, acc_delta_e18, next_acc), + ); Ok(()) } @@ -2036,6 +2447,17 @@ impl RevoraRevenueShare { env.storage().persistent().set(&DataKey::ClaimDelaySecs(new_offering_id.clone()), &delay); env.storage().persistent().remove(&DataKey::ClaimDelaySecs(offering_id.clone())); } + if let Some(allowed_jurisdictions) = env + .storage() + .persistent() + .get::<_, Vec>(&DataKey2::AllowedJurisdictions(offering_id.clone())) + { + env.storage().persistent().set( + &DataKey2::AllowedJurisdictions(new_offering_id.clone()), + &allowed_jurisdictions, + ); + env.storage().persistent().remove(&DataKey2::AllowedJurisdictions(offering_id.clone())); + } if let Some(snap_config) = env.storage().persistent().get::<_, bool>(&DataKey::SnapshotConfig(offering_id.clone())) { env.storage().persistent().set(&DataKey::SnapshotConfig(new_offering_id.clone()), &snap_config); env.storage().persistent().remove(&DataKey::SnapshotConfig(offering_id.clone())); @@ -4933,12 +5355,18 @@ impl RevoraRevenueShare { return Err(RevoraError::LimitReached); } - // Validate all share_bps before writing anything (fail-fast). + // Validate all share_bps and jurisdiction rules before writing anything (fail-fast). for i in 0..batch_len { - let (_, share_bps) = holders.get(i).unwrap(); + let (holder, share_bps) = holders.get(i).unwrap(); if share_bps > 10_000 { return Err(RevoraError::InvalidShareBps); } + Self::require_holder_jurisdiction_allowed( + &env, + &offering_id, + &holder, + EVENT_JUR_ACTION_SNAPSHOT, + )?; } let mut added_bps: u32 = 0; @@ -4978,10 +5406,13 @@ impl RevoraRevenueShare { return Err(RevoraError::InvalidShareBps); } + Self::cache_holder_accrual_through_matured(&env, &offering_id, &holder); + // Update live holder share so claim() works immediately. env.storage() .persistent() .set(&DataKey::HolderShare(offering_id.clone(), holder.clone()), &share_bps); + Self::record_holder_share_transition(&env, &offering_id, &holder, old_share, share_bps); current_total = new_total; added_bps = added_bps.saturating_add(share_bps); @@ -5190,6 +5621,112 @@ impl RevoraRevenueShare { env.storage().persistent().get(&DataKey::HolderShare(offering_id, holder)).unwrap_or(0) } + /// Set or update a holder's jurisdiction tag for an offering. + pub fn set_holder_jurisdiction( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + jurisdiction: Symbol, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let current_issuer = + Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + env.storage().persistent().set( + &DataKey2::HolderJurisdiction(offering_id.clone(), holder.clone()), + &jurisdiction, + ); + env.events().publish( + ( + Self::jurisdiction_set_event(&env), + issuer, + namespace, + token, + ), + (EVENT_JUR_SCOPE_HOLDER, holder, jurisdiction), + ); + Ok(()) + } + + /// Read a holder's configured jurisdiction tag for an offering. + pub fn get_holder_jurisdiction( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + holder: Address, + ) -> Option { + let offering_id = OfferingId { issuer, namespace, token }; + Self::get_holder_jurisdiction_internal(&env, &offering_id, &holder) + } + + /// Replace the offering's allowed jurisdiction set. + /// + /// An empty list disables jurisdiction gating for future share writes and snapshot inclusion. + pub fn set_allowed_jurisdictions( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + jurisdictions: Vec, + ) -> Result<(), RevoraError> { + Self::require_not_frozen(&env)?; + Self::require_not_paused(&env)?; + issuer.require_auth(); + + let current_issuer = + Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + if current_issuer != issuer { + return Err(RevoraError::OfferingNotFound); + } + + let offering_id = OfferingId { + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + }; + let normalized = Self::normalize_jurisdictions(&env, jurisdictions); + env.storage() + .persistent() + .set(&DataKey2::AllowedJurisdictions(offering_id), &normalized); + env.events().publish( + ( + Self::jurisdiction_set_event(&env), + issuer, + namespace, + token, + ), + (EVENT_JUR_SCOPE_ALLOW, normalized), + ); + Ok(()) + } + + /// Return the offering's allowed jurisdiction list in stored order. + pub fn get_allowed_jurisdictions( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> Vec { + let offering_id = OfferingId { issuer, namespace, token }; + Self::get_allowed_jurisdictions_internal(&env, &offering_id) + } + /// Set the claim delay in seconds for an offering. pub fn set_claim_delay( env: Env, @@ -5338,17 +5875,6 @@ impl RevoraRevenueShare { return Err(RevoraError::HolderBlacklisted); } - let share_bps = Self::get_holder_share( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - holder.clone(), - ); - if share_bps == 0 { - return Err(RevoraError::NoPendingClaims); - } - Self::require_claim_window_open(&env, &offering_id)?; let count_key = DataKey::PeriodCount(offering_id.clone()); @@ -5372,7 +5898,6 @@ impl RevoraRevenueShare { let delay_secs: u64 = env.storage().persistent().get(&delay_key).unwrap_or(0); let now = env.ledger().timestamp(); - let mut total_payout: i128 = 0; let mut claimed_periods = Vec::new(&env); let mut last_claimed_idx = start_idx; let mut previous_period_id: Option = None; @@ -5410,17 +5935,6 @@ impl RevoraRevenueShare { if delay_secs > 0 && now < deposit_time.saturating_add(delay_secs) { break; } - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap(); - let decimals = Self::get_payment_token_decimals( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - let normalized = Self::normalize_amount(revenue, decimals); - let payout = normalized * (share_bps as i128) / 10_000; - total_payout += payout; claimed_periods.push_back(period_id); last_claimed_idx = i + 1; } @@ -5429,6 +5943,30 @@ impl RevoraRevenueShare { return Err(RevoraError::ClaimDelayNotElapsed); } + let mut accrual_state = Self::get_holder_accrual_state(&env, &offering_id, &holder); + if accrual_state.last_settled_idx < start_idx { + accrual_state.last_settled_idx = start_idx; + accrual_state.last_acc_per_share_e18 = + Self::get_acc_per_share_at_index(&env, &offering_id, start_idx); + accrual_state.accrued_owed = 0; + } + + if last_claimed_idx > accrual_state.last_settled_idx { + let delta = Self::compute_holder_payout_for_range( + &env, + &offering_id, + &holder, + accrual_state.last_settled_idx, + last_claimed_idx, + ); + accrual_state.accrued_owed = accrual_state.accrued_owed.saturating_add(delta); + accrual_state.last_settled_idx = last_claimed_idx; + accrual_state.last_acc_per_share_e18 = + Self::get_acc_per_share_at_index(&env, &offering_id, last_claimed_idx); + } + + let total_payout = accrual_state.accrued_owed; + // Transfer only if there is a positive payout if total_payout > 0 { let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id) @@ -5444,6 +5982,18 @@ impl RevoraRevenueShare { // Advance claim index only for periods actually claimed (respecting delay) env.storage().persistent().set(&idx_key, &last_claimed_idx); + env.storage().persistent().set( + &DataKey2::HolderAccrualState(offering_id.clone(), holder.clone()), + &HolderAccrualState { + last_settled_idx: last_claimed_idx, + last_acc_per_share_e18: Self::get_acc_per_share_at_index( + &env, + &offering_id, + last_claimed_idx, + ), + accrued_owed: 0, + }, + ); // Versioned v2 event: [2, holder, total_payout, periods] ΓÇö always emitted (#RC26Q2-C31) Self::emit_v2_event( @@ -5911,7 +6461,6 @@ impl RevoraRevenueShare { env: &Env, offering_id: &OfferingId, holder: &Address, - share_bps: u32, requested_start_idx: u32, count: Option, ) -> (i128, Option) { @@ -5962,20 +6511,12 @@ impl RevoraRevenueShare { return (total, Some(idx)); } - let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); - let revenue: i128 = env.storage().persistent().get(&rev_key).unwrap_or(0); - let decimals = Self::get_payment_token_decimals( - env.clone(), - offering_id.issuer.clone(), - offering_id.namespace.clone(), - offering_id.token.clone(), - ); - let normalized = Self::normalize_amount(revenue, decimals); - total = total.saturating_add(Self::compute_share( - env.clone(), - normalized, - share_bps, - RoundingMode::Truncation, + total = total.saturating_add(Self::compute_holder_payout_for_range( + env, + offering_id, + holder, + idx, + idx.saturating_add(1), )); processed = processed.saturating_add(1); idx = idx.saturating_add(1); @@ -5995,17 +6536,6 @@ impl RevoraRevenueShare { token: Address, holder: Address, ) -> i128 { - let share_bps = Self::get_holder_share( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), - ); - if share_bps == 0 { - return 0; - } - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), @@ -6018,8 +6548,7 @@ impl RevoraRevenueShare { return 0; } - let (total, _) = - Self::compute_claimable_preview(&env, &offering_id, &holder, share_bps, 0, None); + let (total, _) = Self::compute_claimable_preview(&env, &offering_id, &holder, 0, None); total } @@ -6075,17 +6604,6 @@ impl RevoraRevenueShare { start_idx: u32, count: u32, ) -> (i128, Option) { - let share_bps = Self::get_holder_share( - env.clone(), - issuer.clone(), - namespace.clone(), - token.clone(), - holder.clone(), - ); - if share_bps == 0 { - return (0, None); - } - let offering_id = OfferingId { issuer: issuer.clone(), namespace: namespace.clone(), @@ -6102,7 +6620,6 @@ impl RevoraRevenueShare { &env, &offering_id, &holder, - share_bps, start_idx, Some(count), ) @@ -6194,6 +6711,17 @@ impl RevoraRevenueShare { let deposit_time = env.ledger().timestamp(); env.storage().persistent().set(&time_key, &deposit_time); + let normalized = Self::normalize_amount(amount, STELLAR_CANONICAL_DECIMALS); + let acc_delta_e18 = Self::accrual_delta_e18(normalized); + let global_acc_key = DataKey2::GlobalAccPerShareE18(offering_id.clone()); + let current_acc: i128 = env.storage().persistent().get(&global_acc_key).unwrap_or(0); + let next_acc = current_acc.saturating_add(acc_delta_e18); + env.storage().persistent().set(&global_acc_key, &next_acc); + env.storage().persistent().set( + &DataKey2::AccPerShareAtIndex(offering_id.clone(), count + 1), + &next_acc, + ); + // Update cumulative deposited revenue let deposited_key = DataKey2::DepositedRevenue(offering_id.clone()); let deposited: i128 = env.storage().persistent().get(&deposited_key).unwrap_or(0); diff --git a/src/structured_error_tests.rs b/src/structured_error_tests.rs index 7a619481..4c228048 100644 --- a/src/structured_error_tests.rs +++ b/src/structured_error_tests.rs @@ -76,6 +76,7 @@ mod tests { ("SignatureReplay", RevoraError::SignatureReplay as u32), ("SignerKeyNotRegistered", RevoraError::SignerKeyNotRegistered as u32), ("ProposalExpired", RevoraError::ProposalExpired as u32), + ("JurisdictionDisallowed", RevoraError::JurisdictionDisallowed as u32), ("TransferFailed", RevoraError::TransferFailed as u32), ("AlreadyAtTargetVersion", RevoraError::AlreadyAtTargetVersion as u32), ("MigrationDowngradeNotAllowed", RevoraError::MigrationDowngradeNotAllowed as u32), @@ -91,7 +92,7 @@ mod tests { ("MissingReportForOverride", RevoraError::MissingReportForOverride as u32), ]; - // O(n²) uniqueness check — n=42, negligible cost. + // O(n²) uniqueness check — n is small, negligible cost. for i in 0..codes.len() { for j in (i + 1)..codes.len() { assert_ne!( @@ -148,7 +149,7 @@ mod tests { assert_eq!(RevoraError::SignerKeyNotRegistered as u32, 29); // 30: ProposalExpired — stable since v1 assert_eq!(RevoraError::ProposalExpired as u32, 30); - // 31: gap (reserved for future use) + assert_eq!(RevoraError::JurisdictionDisallowed as u32, 31); assert_eq!(RevoraError::AlreadyAtTargetVersion as u32, 32); assert_eq!(RevoraError::MigrationDowngradeNotAllowed as u32, 33); // 34: gap (reserved for future use) @@ -233,6 +234,7 @@ mod tests { RevoraError::SignatureReplay as u32, RevoraError::SignerKeyNotRegistered as u32, RevoraError::ProposalExpired as u32, + RevoraError::JurisdictionDisallowed as u32, RevoraError::TransferFailed as u32, RevoraError::AlreadyAtTargetVersion as u32, RevoraError::MigrationDowngradeNotAllowed as u32, diff --git a/src/test_accrual_ledger.rs b/src/test_accrual_ledger.rs new file mode 100644 index 00000000..b36900ef --- /dev/null +++ b/src/test_accrual_ledger.rs @@ -0,0 +1,88 @@ +#![cfg(test)] + +use crate::{RevoraRevenueShare, RevoraRevenueShareClient}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Ledger}, + Address, Env, +}; + +fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset_admin = Address::generate(&env); + let payout_asset = crate::test_utils::create_token(&env, &payout_asset_admin); + crate::test_utils::mint_tokens(&env, &payout_asset, &issuer, 1_000_000); + + client.register_offering(&issuer, &symbol_short!("def"), &token, &5_000, &payout_asset, &0); + + (env, client, issuer, token, payout_asset) +} + +#[test] +fn claim_uses_historical_share_for_unclaimed_periods() { + let (_env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&_env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &2); + + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(payout, 75_000); +} + +#[test] +fn zeroing_share_does_not_burn_already_accrued_claims() { + let (_env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&_env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &0); + + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + assert_eq!(payout, 50_000); +} + +#[test] +fn get_claimable_uses_historical_share_schedule() { + let (_env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&_env); + + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &4_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &1_000); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &2); + + assert_eq!(client.get_claimable(&issuer, &symbol_short!("def"), &token, &holder), 50_000); +} + +#[test] +fn delay_barrier_preserves_pre_change_accrual() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + env.ledger().with_mut(|li| li.timestamp = 1_000); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &5_000); + client.set_claim_delay(&issuer, &symbol_short!("def"), &token, &100); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + + env.ledger().with_mut(|li| li.timestamp = 1_050); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500); + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &2); + + env.ledger().with_mut(|li| li.timestamp = 1_100); + assert_eq!(client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0), 50_000); + + env.ledger().with_mut(|li| li.timestamp = 1_150); + assert_eq!(client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0), 25_000); +} diff --git a/src/test_claim_transfer_fail.rs b/src/test_claim_transfer_fail.rs index 0087d0f1..2b79aa7c 100644 --- a/src/test_claim_transfer_fail.rs +++ b/src/test_claim_transfer_fail.rs @@ -181,13 +181,16 @@ fn pending_periods( holder: &Address, ) -> soroban_sdk::Vec { env.as_contract(revora_id, || { - RevoraRevenueShare::get_pending_periods( + let (periods, _) = RevoraRevenueShare::get_pending_periods_page( env.clone(), issuer.clone(), symbol_short!("def"), offering_token.clone(), holder.clone(), - ) + 0, + 20, + ); + periods }) } @@ -438,14 +441,15 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { // Register a second offering with a normal Stellar asset token let offering_token_b = Address::generate(&env); let admin_b = Address::generate(&env); - + let payout_asset_b = crate::test_utils::create_token(&env, &admin_b); + crate::test_utils::mint_tokens(&env, &payout_asset_b, &issuer, 100_000); revora.register_offering( &issuer, &symbol_short!("def"), &offering_token_b, &10_000, - + &payout_asset_b, &0, ); revora.set_holder_share(&issuer, &symbol_short!("def"), &offering_token_b, &holder, &10_000); @@ -453,7 +457,7 @@ fn claim_transfer_fail_does_not_affect_sibling_offering() { &issuer, &symbol_short!("def"), &offering_token_b, - + &payout_asset_b, &100_000, &1, ); diff --git a/src/test_compute_share_invariants.rs b/src/test_compute_share_invariants.rs index f9452e6d..faf6d893 100644 --- a/src/test_compute_share_invariants.rs +++ b/src/test_compute_share_invariants.rs @@ -48,6 +48,9 @@ #![cfg(test)] +extern crate alloc; + +use alloc::format; use crate::{RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode}; use soroban_sdk::{testutils::Address as _, Address, Env}; @@ -589,8 +592,8 @@ fn remainder_product_bound_holds_for_all_bps() { 20_000, 100_000, 1_000_000, - i128::MAX / 10_000 * 10_000 + 9_999, // Max remainder - i128::MIN / 10_000 * 10_000 - 9_999, // Min remainder + i128::MAX - 9_999, // Large positive with high remainder + i128::MIN + 9_999, // Large negative with high remainder ]; let bps_values = [1_u32, 100, 1_000, 5_000, 9_999, 10_000]; @@ -636,11 +639,10 @@ fn checked_mul_defense_in_depth_prevents_overflow() { ]; for &amount in &extreme_amounts { - for &bps in [1_u32, 5_000, 10_000] { + for bps in [1_u32, 5_000, 10_000] { let result = c.compute_share(&amount, &bps, &RoundingMode::Truncation); // Should never panic and should always satisfy bounds assert_bounds(result, amount, &format!("Extreme amount={amount} bps={bps}")); } } } - diff --git a/src/test_event_indexed_v2.rs b/src/test_event_indexed_v2.rs index 1488b855..8252f552 100644 --- a/src/test_event_indexed_v2.rs +++ b/src/test_event_indexed_v2.rs @@ -208,8 +208,8 @@ fn event_indexed_v2_claim_topic_and_data_shape() { client.register_offering(&issuer, &ns, &token, &2500, &payout, &0); let holder = Address::generate(&env); - client.deposit_revenue(&issuer, &ns, &token, &payout, &100_000, &1); client.set_holder_share(&issuer, &ns, &token, &holder, &5_000); // 50% + client.deposit_revenue(&issuer, &ns, &token, &payout, &100_000, &1); let before = env.events().all().len(); client.claim(&holder, &issuer, &ns, &token, &10); @@ -246,9 +246,9 @@ fn event_indexed_v2_claim_period_id_always_zero() { client.register_offering(&issuer, &ns, &token, &2500, &payout, &0); let holder = Address::generate(&env); + client.set_holder_share(&issuer, &ns, &token, &holder, &5_000); client.deposit_revenue(&issuer, &ns, &token, &payout, &100_000, &1); client.deposit_revenue(&issuer, &ns, &token, &payout, &200_000, &2); - client.set_holder_share(&issuer, &ns, &token, &holder, &5_000); let before = env.events().all().len(); client.claim(&holder, &issuer, &ns, &token, &10); diff --git a/src/test_jurisdiction.rs b/src/test_jurisdiction.rs new file mode 100644 index 00000000..73380e20 --- /dev/null +++ b/src/test_jurisdiction.rs @@ -0,0 +1,195 @@ +#![cfg(test)] + +use crate::{DataKey2, RevoraError, RevoraRevenueShare, RevoraRevenueShareClient}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events}, + Address, BytesN, Env, +}; + +fn setup_offering() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let payout_asset_admin = Address::generate(&env); + let payout_asset = crate::test_utils::create_token(&env, &payout_asset_admin); + crate::test_utils::mint_tokens(&env, &payout_asset, &issuer, 1_000_000); + + client.register_offering(&issuer, &symbol_short!("def"), &token, &5_000, &payout_asset, &0); + + (env, client, issuer, token, payout_asset) +} + +#[test] +fn holder_jurisdiction_and_allowlist_are_stored_with_audit_event() { + let (env, client, issuer, token, _payout_asset) = setup_offering(); + let holder = Address::generate(&env); + let events_before = env.events().all().len(); + + client.set_holder_jurisdiction( + &issuer, + &symbol_short!("def"), + &token, + &holder, + &symbol_short!("us"), + ); + client.set_allowed_jurisdictions( + &issuer, + &symbol_short!("def"), + &token, + &soroban_sdk::vec![&env, symbol_short!("us"), symbol_short!("ca")], + ); + + assert_eq!( + client.get_holder_jurisdiction(&issuer, &symbol_short!("def"), &token, &holder), + Some(symbol_short!("us")) + ); + assert_eq!( + client.get_allowed_jurisdictions(&issuer, &symbol_short!("def"), &token), + soroban_sdk::vec![&env, symbol_short!("us"), symbol_short!("ca")] + ); + assert!(env.events().all().len() >= events_before + 2); +} + +#[test] +fn set_holder_share_rejects_disallowed_jurisdiction_and_emits_audit_event() { + let (env, client, issuer, token, _payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_jurisdiction( + &issuer, + &symbol_short!("def"), + &token, + &holder, + &symbol_short!("uk"), + ); + client.set_allowed_jurisdictions( + &issuer, + &symbol_short!("def"), + &token, + &soroban_sdk::vec![&env, symbol_short!("us")], + ); + let events_before = env.events().all().len(); + + let result = + client.try_set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &2_500u32); + assert_eq!(result, Err(Ok(RevoraError::JurisdictionDisallowed))); + assert_eq!(client.get_holder_share(&issuer, &symbol_short!("def"), &token, &holder), 0); + assert!(env.events().all().len() > events_before); +} + +#[test] +fn removing_a_jurisdiction_does_not_break_existing_holder_claims() { + let (env, client, issuer, token, payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_holder_jurisdiction( + &issuer, + &symbol_short!("def"), + &token, + &holder, + &symbol_short!("us"), + ); + client.set_allowed_jurisdictions( + &issuer, + &symbol_short!("def"), + &token, + &soroban_sdk::vec![&env, symbol_short!("us")], + ); + client.set_holder_share(&issuer, &symbol_short!("def"), &token, &holder, &4_000); + + client.set_allowed_jurisdictions( + &issuer, + &symbol_short!("def"), + &token, + &soroban_sdk::vec![&env, symbol_short!("ca")], + ); + + client.deposit_revenue(&issuer, &symbol_short!("def"), &token, &payout_asset, &100_000, &1); + let payout = client.claim(&holder, &issuer, &symbol_short!("def"), &token, &0); + + assert_eq!(client.get_holder_share(&issuer, &symbol_short!("def"), &token, &holder), 4_000); + assert_eq!(payout, 40_000); + assert_eq!(crate::test_utils::get_balance(&env, &payout_asset, &holder), 40_000); +} + +#[test] +fn apply_snapshot_shares_rejects_disallowed_jurisdiction_without_partial_state() { + let (env, client, issuer, token, _payout_asset) = setup_offering(); + let holder = Address::generate(&env); + + client.set_snapshot_config(&issuer, &symbol_short!("def"), &token, &true); + client.set_holder_jurisdiction( + &issuer, + &symbol_short!("def"), + &token, + &holder, + &symbol_short!("uk"), + ); + client.set_allowed_jurisdictions( + &issuer, + &symbol_short!("def"), + &token, + &soroban_sdk::vec![&env, symbol_short!("us")], + ); + + let hash = BytesN::from_array(&env, &[7; 32]); + client.commit_snapshot(&issuer, &symbol_short!("def"), &token, &1, &hash); + let events_before = env.events().all().len(); + + let holders = soroban_sdk::vec![&env, (holder.clone(), 2_500u32)]; + let result = + client.try_apply_snapshot_shares(&issuer, &symbol_short!("def"), &token, &1, &0, &holders); + + assert_eq!(result, Err(Ok(RevoraError::JurisdictionDisallowed))); + assert_eq!(client.get_holder_share(&issuer, &symbol_short!("def"), &token, &holder), 0); + assert_eq!(client.get_snapshot_holder_count(&issuer, &symbol_short!("def"), &token, &1), 0); + assert!(env.events().all().len() > events_before); +} + +#[test] +fn issuer_transfer_migrates_allowed_jurisdictions() { + let (env, client, old_issuer, token, _payout_asset) = setup_offering(); + let new_issuer = Address::generate(&env); + let contract_id = client.address.clone(); + + env.as_contract(&contract_id, || { + env.storage().persistent().set(&DataKey2::IssuerCount, &1_u32); + env.storage().persistent().set(&DataKey2::IssuerItem(0), &old_issuer); + env.storage() + .persistent() + .set(&DataKey2::IssuerRegistered(old_issuer.clone()), &true); + env.storage() + .persistent() + .set(&DataKey2::NamespaceCount(old_issuer.clone()), &1_u32); + env.storage() + .persistent() + .set(&DataKey2::NamespaceItem(old_issuer.clone(), 0), &symbol_short!("def")); + env.storage() + .persistent() + .set(&DataKey2::NamespaceRegistered(old_issuer.clone(), symbol_short!("def")), &true); + }); + + client.set_allowed_jurisdictions( + &old_issuer, + &symbol_short!("def"), + &token, + &soroban_sdk::vec![&env, symbol_short!("us"), symbol_short!("sg")], + ); + client.propose_issuer_transfer(&old_issuer, &symbol_short!("def"), &token, &new_issuer); + client.accept_issuer_transfer(&new_issuer, &symbol_short!("def"), &token); + + assert_eq!( + client.get_allowed_jurisdictions(&new_issuer, &symbol_short!("def"), &token), + soroban_sdk::vec![&env, symbol_short!("us"), symbol_short!("sg")] + ); + assert_eq!( + client.get_allowed_jurisdictions(&old_issuer, &symbol_short!("def"), &token).len(), + 0 + ); +} diff --git a/src/test_utils.rs b/src/test_utils.rs index 41864db2..ca26a52f 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -11,7 +11,7 @@ use soroban_sdk::{ }; /// Core test utilities avoiding self-referential struct lifetime errors. -pub fn setup_context() -> (Env, RevoraRevenueShareClient, Address, Address, Address, Address) { +pub fn setup_context() -> (Env, RevoraRevenueShareClient<'static>, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register_contract(None, RevoraRevenueShare);