Skip to content
Open
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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>` | — | 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<Symbol>` | — | Read the holder's stored jurisdiction tag for one offering. |
| `set_allowed_jurisdictions` | `issuer: Address`, `namespace: Symbol`, `token: Address`, `jurisdictions: Vec<Symbol>` | `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<Symbol>` | — | 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<ConcentrationLimitConfig>` | — | Get concentration limit config for offering. |
Expand Down Expand Up @@ -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`.

Expand All @@ -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`). |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions docs/dividend-accrual-ledger.md
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions docs/jurisdiction-tagging.md
Original file line number Diff line number Diff line change
@@ -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<Symbol>`
- `set_allowed_jurisdictions(issuer, namespace, token, jurisdictions)`
- `get_allowed_jurisdictions(issuer, namespace, token) -> Vec<Symbol>`

## 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.
Loading
Loading