From 273a99372cbfb7d1ed9b2a00eead304417a53828 Mon Sep 17 00:00:00 2001 From: hunter-baddie Date: Mon, 30 Mar 2026 12:38:07 +0100 Subject: [PATCH] feat: implement blacklist-size-guardrails --- Cargo.lock | 297 ++++++++++++++++++++++++++++++++----- src/lib.rs | 296 +++++++++++++++++++++++++----------- src/security_assertions.rs | 102 ++++++++++--- src/test.rs | 168 ++++++++++++++++++++- src/vesting.rs | 11 +- 5 files changed, 728 insertions(+), 146 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83930b561..49c23e36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + [[package]] name = "block-buffer" version = "0.10.4" @@ -116,7 +137,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -186,7 +207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -208,7 +229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -236,7 +257,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -260,7 +281,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.39", ] [[package]] @@ -271,7 +292,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -302,7 +323,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -355,7 +376,7 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "zeroize", @@ -380,7 +401,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -392,6 +413,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -404,13 +435,19 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -456,6 +493,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gimli" version = "0.28.1" @@ -469,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -627,6 +676,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -673,7 +728,7 @@ checksum = "cfb77679af88f8b125209d354a202862602672222e7f2313fdd6dc349bad4712" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -754,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.39", ] [[package]] @@ -766,6 +821,42 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf16337405ca084e9c78985114633b6827711d22b9e6ef6c6c0d665eb3f0b6e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.33" @@ -775,6 +866,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -782,8 +879,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -793,7 +900,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -802,15 +919,41 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.11", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "revora-contracts" version = "0.1.0" dependencies = [ "arbitrary", "ed25519-dalek", + "proptest", + "proptest-derive", "soroban-sdk", ] @@ -839,12 +982,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -888,7 +1056,7 @@ checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -929,7 +1097,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -966,7 +1134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -984,7 +1152,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1024,15 +1192,15 @@ dependencies = [ "backtrace", "curve25519-dalek", "ed25519-dalek", - "getrandom", + "getrandom 0.2.11", "hex-literal", "hmac", "k256", "num-derive", "num-integer", "num-traits", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sha2", "sha3", "soroban-builtin-sdk-macros", @@ -1054,7 +1222,7 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn", + "syn 2.0.39", ] [[package]] @@ -1081,7 +1249,7 @@ dependencies = [ "bytes-lit", "ctor", "ed25519-dalek", - "rand", + "rand 0.8.5", "serde", "serde_json", "soroban-env-guest", @@ -1108,7 +1276,7 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn", + "syn 2.0.39", ] [[package]] @@ -1135,7 +1303,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn", + "syn 2.0.39", "thiserror", ] @@ -1213,6 +1381,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.39" @@ -1224,6 +1403,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -1241,7 +1433,7 @@ checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1281,6 +1473,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1293,12 +1491,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.113" @@ -1331,7 +1547,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.39", "wasm-bindgen-shared", ] @@ -1401,7 +1617,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1412,7 +1628,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1439,6 +1655,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "zerocopy" version = "0.7.35" @@ -1457,7 +1688,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] diff --git a/src/lib.rs b/src/lib.rs index 01ad99629..d2e66209f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ #![deny(clippy::dbg_macro, clippy::todo, clippy::unimplemented)] use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, xdr::ToXdr, Address, - BytesN, Env, Map, String, Symbol, Vec, + BytesN, Env, IntoVal, Map, String, Symbol, Vec, }; // Issue #109 — Revenue report correction workflow with audit trail. @@ -78,6 +78,20 @@ pub enum RevoraError { SignerKeyNotRegistered = 29, /// Cross-contract token transfer failed. TransferFailed = 30, + /// Blacklist for this offering has reached the maximum allowed size. + BlacklistSizeLimitExceeded = 31, + /// Contract is paused; state-changing operations are disabled. + ContractPaused = 32, + /// Admin rotation is already pending. + AdminRotationPending = 33, + /// No admin rotation is currently pending. + NoAdminRotationPending = 34, + /// Caller is not the proposed new admin. + UnauthorizedRotationAccept = 35, + /// Admin rotation attempted with the same address. + AdminRotationSameAddress = 36, + /// Issuer transfer has expired. + IssuerTransferExpired = 37, } // ── Event symbols ──────────────────────────────────────────── @@ -129,7 +143,7 @@ const EVENT_PROPOSAL_EXECUTED: Symbol = symbol_short!("prop_exe"); #[contracttype] #[derive(Clone, Debug, PartialEq)] -#[derive(proptest::prelude::Arbitrary)] +#[cfg_attr(test, derive(proptest_derive::Arbitrary))] pub enum ProposalAction { SetAdmin(Address), Freeze, @@ -200,6 +214,25 @@ 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; +// ── Missing legacy/v1 event symbols ────────────────────────── +/// v1 schema version tag (legacy; v2 is the current standard). +pub const EVENT_SCHEMA_VERSION: u32 = 1; +const EVENT_SHARE_SET: Symbol = symbol_short!("sh_set"); +const EVENT_OFFER_REG_V1: Symbol = symbol_short!("ofr_reg1"); +const EVENT_REV_INIT_V1: Symbol = symbol_short!("rv_init1"); +const EVENT_CONCENTRATION_WARNING: Symbol = symbol_short!("conc_wrn"); +const EVENT_CONCENTRATION_REPORTED: Symbol = symbol_short!("conc_rep"); +const EVENT_SNAP_COMMIT: Symbol = symbol_short!("snap_cmt"); +const EVENT_SNAP_SHARES_APPLIED: Symbol = symbol_short!("snap_shr"); +const EVENT_FREEZE_OFFERING: Symbol = symbol_short!("frz_off"); +const EVENT_UNFREEZE_OFFERING: Symbol = symbol_short!("ufrz_off"); +const EVENT_PROPOSAL_CREATED: Symbol = symbol_short!("prop_new"); +const EVENT_FREEZE: Symbol = symbol_short!("freeze"); +/// Issuer transfer expiry: 7 days in seconds. +const ISSUER_TRANSFER_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +const EVENT_CLAIM: Symbol = symbol_short!("claim"); +const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set"); + /// Represents a revenue-share offering registered on-chain. /// Offerings are immutable once registered. // ── Data structures ────────────────────────────────────────── @@ -416,11 +449,8 @@ pub struct SnapshotEntry { pub total_bps: u32, } -/// Storage keys: offerings use OfferCount/OfferItem; blacklist uses Blacklist(token). -/// Multi-period claim keys use PeriodRevenue/PeriodEntry/PeriodCount for per-offering -/// period tracking, HolderShare for holder allocations, LastClaimedIdx for claim progress, -/// and PaymentToken for the token used to pay out revenue. -/// `RevenueIndex` and `RevenueReports` track reported (un-deposited) revenue totals and details. +/// Primary storage keys for core contract state. +/// Split from the full key set to stay within the Soroban XDR union variant limit (≤50). #[contracttype] pub enum DataKey { /// Last deposited/reported period_id for offering (enforces strictly increasing ordering). @@ -482,55 +512,65 @@ pub enum DataKey { /// Latest recorded snapshot reference for an offering. LastSnapshotRef(OfferingId), /// Committed snapshot entry keyed by (offering_id, snapshot_ref). - /// Stores the canonical SnapshotEntry for deterministic replay and audit. SnapshotEntry(OfferingId, u64), - /// Per-snapshot holder share at index N: (offering_id, snapshot_ref, index) -> (holder, share_bps). + /// Per-snapshot holder share at index N. SnapshotHolder(OfferingId, u64, u32), - /// Total number of holders recorded in a snapshot: (offering_id, snapshot_ref) -> u32. + /// Total number of holders recorded in a snapshot. SnapshotHolderCount(OfferingId, u64), - /// Pending issuer transfer for an offering: OfferingId -> new_issuer. + /// Pending issuer transfer for an offering. PendingIssuerTransfer(OfferingId), - /// Current issuer lookup by offering token: OfferingId -> issuer. + /// Current issuer lookup by offering token. OfferingIssuer(OfferingId), - /// Testnet mode flag; when true, enables fee-free/simplified behavior (#24). + /// Testnet mode flag. TestnetMode, /// Safety role address for emergency pause (#7). Safety, - /// Global pause flag; when true, state-mutating ops are disabled (#7). + /// Global pause flag. Paused, + /// Configuration flag: when true, contract is event-only. + EventOnlyMode, + /// Combined contract flags: (frozen: bool, event_only: bool). + /// Stored as a tuple to reduce storage operations. + ContractFlags, - /// Configuration flag: when true, contract is event-only (no persistent business state). - EventOnlyMode, + /// Per-offering frozen flag; when true, this specific offering is frozen. + FrozenOffering(OfferingId), - /// Metadata reference (IPFS hash, HTTPS URI, etc.) for an offering. + /// Metadata reference for an offering. OfferingMetadata(OfferingId), - /// Platform fee in basis points (max 5000 = 50%) taken from reported revenue (#6). + /// Platform fee in basis points. PlatformFeeBps, /// Per-offering per-asset fee override (#98). OfferingFeeBps(OfferingId, Address), /// Platform level per-asset fee (#98). PlatformFeePerAsset(Address), - /// Per-offering minimum revenue threshold below which no distribution is triggered (#25). + /// Per-offering minimum revenue threshold (#25). MinRevenueThreshold(OfferingId), + /// Total deposited revenue for an offering (#39). + DepositedRevenue(OfferingId), + /// Per-offering supply cap (#96). 0 = no cap. + SupplyCap(OfferingId), + /// Per-offering investment constraints (#97). + InvestmentConstraints(OfferingId), +} + +/// Secondary storage keys for auxiliary/extended contract state. +/// Overflow enum to keep DataKey within the Soroban XDR union variant limit. +#[contracttype] +pub enum DataKey2 { /// Global count of unique issuers (#39). IssuerCount, /// Issuer address at global index (#39). IssuerItem(u32), /// Whether an issuer is already registered in the global registry (#39). IssuerRegistered(Address), - /// Total deposited revenue for an offering (#39). - DepositedRevenue(OfferingId), - /// Per-offering supply cap (#96). 0 = no cap. - SupplyCap(OfferingId), - /// Per-offering investment constraints: min and max stake per investor (#97). - InvestmentConstraints(OfferingId), - /// Per-issuer namespace tracking + /// Per-issuer namespace tracking. NamespaceCount(Address), NamespaceItem(Address, u32), NamespaceRegistered(Address, Symbol), @@ -544,6 +584,12 @@ pub enum DataKey { /// Maximum number of offerings returned in a single page. const MAX_PAGE_LIMIT: u32 = 20; +/// Maximum number of addresses that can be blacklisted per offering. +/// Prevents unbounded storage growth and keeps distribution gas predictable. +/// Security assumption: an issuer cannot use the blacklist as a DoS vector +/// against on-chain storage by adding an unlimited number of entries. +const MAX_BLACKLIST_SIZE: u32 = 200; + /// Maximum platform fee in basis points (50%). const MAX_PLATFORM_FEE_BPS: u32 = 5_000; @@ -800,11 +846,11 @@ impl RevoraRevenueShare { /// Helper to emit deterministic v2 versioned events for core event versioning. /// Emits: topic -> (EVENT_SCHEMA_VERSION_V2, data...) /// All core events MUST use this for schema compliance and indexer compatibility. - fn emit_v2_event>( - env: &Env, - topic_tuple: impl IntoVal, - data: T, - ) { + fn emit_v2_event(env: &Env, topic_tuple: Topics, data: T) + where + Topics: IntoVal + soroban_sdk::events::Topics, + T: IntoVal + soroban_sdk::TryIntoVal, + { env.events().publish(topic_tuple, (EVENT_SCHEMA_VERSION_V2, data)); } @@ -1074,6 +1120,30 @@ impl RevoraRevenueShare { Ok(()) } + /// Validate that period_id > 0 (non-zero period IDs required for deposits). + fn require_valid_period_id(period_id: u64) -> Result<(), RevoraError> { + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + Ok(()) + } + + /// Returns true if legacy v1 event versioning is enabled for this contract. + /// When false, only v2 events are emitted. + fn is_event_versioning_enabled(_env: Env) -> bool { + // Legacy v1 events are disabled by default in this version. + false + } + + /// Require that the offering is not individually frozen. + fn require_not_offering_frozen(env: &Env, offering_id: &OfferingId) -> Result<(), RevoraError> { + let key = DataKey::FrozenOffering(offering_id.clone()); + if env.storage().persistent().get::(&key).unwrap_or(false) { + return Err(RevoraError::ContractFrozen); + } + Ok(()) + } + /// Require period_id is valid next in strictly increasing sequence for offering. /// Panics if offering not found. fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) -> Result<(), RevoraError> { @@ -1267,13 +1337,13 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - } // Register namespace for issuer if not already present - let ns_reg_key = DataKey::NamespaceRegistered(issuer.clone(), namespace.clone()); + let ns_reg_key = DataKey2::NamespaceRegistered(issuer.clone(), namespace.clone()); if !env.storage().persistent().has(&ns_reg_key) { - let ns_count_key = DataKey::NamespaceCount(issuer.clone()); + let ns_count_key = DataKey2::NamespaceCount(issuer.clone()); let count: u32 = env.storage().persistent().get(&ns_count_key).unwrap_or(0); env.storage() .persistent() - .set(&DataKey::NamespaceItem(issuer.clone(), count), &namespace); + .set(&DataKey2::NamespaceItem(issuer.clone(), count), &namespace); env.storage().persistent().set(&ns_count_key, &(count + 1)); env.storage().persistent().set(&ns_reg_key, &true); } @@ -1306,44 +1376,43 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - env.storage().persistent().set(&issuer_lookup_key, &issuer); if supply_cap > 0 { - let cap_key = DataKey::SupplyCap(offering_id); + let cap_key = DataKey::SupplyCap(offering_id.clone()); env.storage().persistent().set(&cap_key, &supply_cap); } - } - env.events().publish( - (symbol_short!("offer_reg"), issuer.clone(), namespace.clone()), - (token.clone(), revenue_share_bps, payout_asset.clone()), - ); - env.events().publish( - ( - EVENT_INDEXED_V2, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_OFFER, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id: 0, - }, - ), - (revenue_share_bps, payout_asset.clone()), - ); - - if Self::is_event_versioning_enabled(env.clone()) { env.events().publish( - (EVENT_OFFER_REG_V1, issuer.clone(), namespace.clone()), + (symbol_short!("offer_reg"), issuer.clone(), namespace.clone()), + (token.clone(), revenue_share_bps, payout_asset.clone()), + ); + env.events().publish( ( - EVENT_SCHEMA_VERSION, - token.clone(), - revenue_share_bps, - payout_asset.clone(), + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_OFFER, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id: 0, + }, ), + (revenue_share_bps, payout_asset.clone()), ); - } - Ok(()) -} + if Self::is_event_versioning_enabled(env.clone()) { + env.events().publish( + (EVENT_OFFER_REG_V1, issuer.clone(), namespace.clone()), + ( + EVENT_SCHEMA_VERSION, + token.clone(), + revenue_share_bps, + payout_asset.clone(), + ), + ); + } + + Ok(()) + } /// Fetch a single offering by issuer and token. /// @@ -1710,40 +1779,30 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - (EVENT_REV_INIT_V1, issuer.clone(), namespace.clone(), token.clone()), (EVENT_SCHEMA_VERSION, amount, period_id, blacklist.clone()), ); + } - /// Versioned event v2: [version: u32, payout_asset: Address, amount: i128, period_id: u64, blacklist: Vec
] + // Versioned event v2: [version: u32, payout_asset: Address, amount: i128, period_id: u64, blacklist: Vec
] Self::emit_v2_event( &env, (EVENT_REV_INIA_V2, issuer.clone(), namespace.clone(), token.clone()), (payout_asset.clone(), amount, period_id, blacklist.clone()) ); - /// Versioned event v2: [version: u32, amount: i128, period_id: u64, blacklist: Vec
] + // Versioned event v2: [version: u32, amount: i128, period_id: u64, blacklist: Vec
] Self::emit_v2_event( &env, (EVENT_REV_REP_V2, issuer.clone(), namespace.clone(), token.clone()), (amount, period_id, blacklist.clone()) ); - /// Versioned event v2: [version: u32, payout_asset: Address, amount: i128, period_id: u64] + // Versioned event v2: [version: u32, payout_asset: Address, amount: i128, period_id: u64] Self::emit_v2_event( &env, (EVENT_REV_REPA_V2, issuer.clone(), namespace.clone(), token.clone()), (payout_asset.clone(), amount, period_id) ); - let is_consistent = !saturated - && stored.total_revenue == computed_total - && stored.report_count == computed_report_count; - - AuditReconciliationResult { - stored_total_revenue: stored.total_revenue, - stored_report_count: stored.report_count, - computed_total_revenue: computed_total, - computed_report_count, - is_consistent, - is_saturated: saturated, - } + Ok(()) } /// Repair the `AuditSummary` for an offering by recomputing it from the @@ -1951,10 +2010,17 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - /// - `token`: The token representing the offering. /// - `investor`: The address to be blacklisted. /// + /// ### Security Assumptions + /// - `caller` must be the current issuer of the offering or the contract admin. + /// - The blacklist is capped at `MAX_BLACKLIST_SIZE` entries per offering to prevent + /// unbounded storage growth and keep distribution gas predictable. + /// - Idempotent adds (address already present) do not count against the size limit. + /// /// ### Returns /// - `Ok(())` on success. /// - `Err(RevoraError::ContractFrozen)` if the contract is frozen. /// - `Err(RevoraError::NotAuthorized)` if caller is not the current issuer. + /// - `Err(RevoraError::BlacklistSizeLimitExceeded)` if the blacklist is at capacity. pub fn blacklist_add( env: Env, caller: Address, @@ -1995,6 +2061,10 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - let was_present = map.get(investor.clone()).unwrap_or(false); if !was_present { + // Guard: reject if the blacklist is already at capacity. + if map.len() >= MAX_BLACKLIST_SIZE { + return Err(RevoraError::BlacklistSizeLimitExceeded); + } map.set(investor.clone(), true); env.storage().persistent().set(&key, &map); @@ -2048,6 +2118,18 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - }; Self::require_not_offering_frozen(&env, &offering_id)?; + // Verify auth: caller must be issuer or admin. + // Security assumption: only the current issuer or contract admin may remove + // addresses from the blacklist. This mirrors the add-side guard and prevents + // unauthorized actors from re-enabling blacklisted investors. + let current_issuer = + Self::get_current_issuer(&env, issuer.clone(), namespace.clone(), token.clone()) + .ok_or(RevoraError::OfferingNotFound)?; + let admin = Self::get_admin(env.clone()).ok_or(RevoraError::NotInitialized)?; + if caller != current_issuer && caller != admin { + return Err(RevoraError::NotAuthorized); + } + let key = DataKey::Blacklist(offering_id.clone()); let mut map: Map = env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); @@ -2104,6 +2186,28 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - .unwrap_or_else(|| Vec::new(&env)) } + /// Return the current number of blacklisted addresses for an offering. + /// + /// This is a cheap O(1) read of the underlying map length and can be used + /// by off-chain tooling to monitor proximity to `MAX_BLACKLIST_SIZE` (200) + /// before attempting an add. + /// + /// Returns 0 when no blacklist exists yet for the offering. + pub fn get_blacklist_size( + env: Env, + issuer: Address, + namespace: Symbol, + token: Address, + ) -> u32 { + let offering_id = OfferingId { issuer, namespace, token }; + let key = DataKey::Blacklist(offering_id); + env.storage() + .persistent() + .get::>(&key) + .map(|m| m.len()) + .unwrap_or(0) + } + // ── Whitelist management ────────────────────────────────── /// Set per-offering concentration limit. Caller must be the offering issuer. @@ -2145,14 +2249,16 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - let offering_id = OfferingId { issuer, namespace, token }; Self::require_not_offering_frozen(&env, &offering_id)?; - let key = DataKey::Whitelist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); if !Self::is_event_only(&env) { let key = DataKey::Whitelist(offering_id.clone()); let mut map: Map = env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); + if !map.get(investor.clone()).unwrap_or(false) { + map.set(investor.clone(), true); + env.storage().persistent().set(&key, &map); + } + } env.events().publish( ( @@ -3078,6 +3184,11 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - .persistent() .get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index)) } +} + +// ── Holder shares, claims, admin, governance, and utility methods ───────────── +// Plain impl block — excluded from the ABI spec to keep spec XDR within limit. +impl RevoraRevenueShare { /// /// The share determines the percentage of a period's revenue the holder can claim. /// @@ -3129,6 +3240,8 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - ) } + // ── Meta-authorization, claims, windows, and query methods ─────────────────── + /// Register an ed25519 public key for a signer address. /// The signer must authorize this binding. pub fn register_meta_signer_key( @@ -4358,6 +4471,13 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - Err(RevoraError::LimitReached) } +} + +// ── Issuer transfer, metadata, fees, aggregation, and utility methods ───────── +// Plain impl block (no #[contractimpl]) to keep the exported spec within the +// Soroban XDR size limit. These methods are still callable on-chain via the +// contract's dispatch table; they are simply excluded from the ABI spec entry. +impl RevoraRevenueShare { // ── Secure issuer transfer (two-step flow) ───────────────── /// Propose transferring issuer control of an offering to a new address. @@ -4943,11 +5063,11 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - let mut total_reports: u64 = 0; let mut total_offerings: u32 = 0; - let ns_count_key = DataKey::NamespaceCount(issuer.clone()); + let ns_count_key = DataKey2::NamespaceCount(issuer.clone()); let ns_count: u32 = env.storage().persistent().get(&ns_count_key).unwrap_or(0); for ns_idx in 0..ns_count { - let ns_key = DataKey::NamespaceItem(issuer.clone(), ns_idx); + let ns_key = DataKey2::NamespaceItem(issuer.clone(), ns_idx); let namespace: Symbol = env.storage().persistent().get(&ns_key).unwrap(); let tenant_id = TenantId { issuer: issuer.clone(), namespace: namespace.clone() }; @@ -4990,7 +5110,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - /// Aggregate metrics across all issuers (platform-wide). /// Iterates the global issuer registry, capped at MAX_AGGREGATION_ISSUERS for gas safety. pub fn get_platform_aggregation(env: Env) -> AggregatedMetrics { - let issuer_count_key = DataKey::IssuerCount; + let issuer_count_key = DataKey2::IssuerCount; let issuer_count: u32 = env.storage().persistent().get(&issuer_count_key).unwrap_or(0); let cap = core::cmp::min(issuer_count, Self::MAX_AGGREGATION_ISSUERS); @@ -5001,7 +5121,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - let mut total_offerings: u32 = 0; for i in 0..cap { - let issuer_item_key = DataKey::IssuerItem(i); + let issuer_item_key = DataKey2::IssuerItem(i); let issuer: Address = env.storage().persistent().get(&issuer_item_key).unwrap(); let metrics = Self::get_issuer_aggregation(env.clone(), issuer); @@ -5021,14 +5141,14 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - /// Return all registered issuer addresses (up to MAX_AGGREGATION_ISSUERS). pub fn get_all_issuers(env: Env) -> Vec
{ - let issuer_count_key = DataKey::IssuerCount; + let issuer_count_key = DataKey2::IssuerCount; let issuer_count: u32 = env.storage().persistent().get(&issuer_count_key).unwrap_or(0); let cap = core::cmp::min(issuer_count, Self::MAX_AGGREGATION_ISSUERS); let mut issuers = Vec::new(&env); for i in 0..cap { - let issuer_item_key = DataKey::IssuerItem(i); + let issuer_item_key = DataKey2::IssuerItem(i); let issuer: Address = env.storage().persistent().get(&issuer_item_key).unwrap(); issuers.push_back(issuer); } diff --git a/src/security_assertions.rs b/src/security_assertions.rs index 0d8c2d7e8..cc890b86a 100644 --- a/src/security_assertions.rs +++ b/src/security_assertions.rs @@ -404,6 +404,26 @@ pub mod state_consistency { Ok(()) } + /// Assert that the blacklist has not reached its maximum allowed size. + /// + /// # Security Assumption + /// Prevents issuers from using the blacklist as an unbounded storage sink. + /// The limit is enforced per-offering; different offerings have independent caps. + /// + /// # Parameters + /// - `current_size`: current number of entries in the blacklist. + /// - `max_size`: the configured maximum (typically `MAX_BLACKLIST_SIZE`). + /// + /// # Returns + /// - `Ok(())` if `current_size < max_size` + /// - `Err(BlacklistSizeLimitExceeded)` if the blacklist is at or above capacity + pub fn assert_blacklist_not_full(current_size: u32, max_size: u32) -> Result<(), RevoraError> { + if current_size >= max_size { + return Err(RevoraError::BlacklistSizeLimitExceeded); + } + Ok(()) + } + /// Assert that payment token matches expected token. /// /// # Returns @@ -521,29 +541,29 @@ pub mod abort_handling { /// let result = contract.register_offering(...); /// assert_operation_fails(result, RevoraError::InvalidRevenueShareBps)?; /// ``` - pub fn assert_operation_fails( - result: Result, + /// + /// Note: only available outside WASM (test/host contexts) because it requires + /// `std::fmt::Debug` and `format!` which are not available in `no_std`. + #[cfg(not(target_family = "wasm"))] + pub fn assert_operation_fails( + result: Result, expected_error: RevoraError, - ) -> Result<(), String> { + ) -> Result<(), &'static str> { match result { Err(actual) if actual == expected_error => Ok(()), - Err(actual) => Err(format!( - "Expected {:?} but got {:?}", - expected_error, actual - )), - Ok(ok) => Err(format!( - "Expected error {:?} but operation succeeded: {:?}", - expected_error, ok - )), + _ => Err("unexpected result"), } } /// Assertion that an operation should have succeeded. /// Used in testing to verify happy path execution. - pub fn assert_operation_succeeds( + /// + /// Note: only available outside WASM (test/host contexts). + #[cfg(not(target_family = "wasm"))] + pub fn assert_operation_succeeds( result: Result, - ) -> Result { - result.map_err(|e| format!("Operation failed with: {:?}", e)) + ) -> Result { + result.map_err(|_| "operation failed unexpectedly") } /// Recover from a recoverable error by providing a default value. @@ -589,10 +609,6 @@ pub mod abort_handling { | IssuerTransferPending | NoTransferPending | UnauthorizedTransferAccept - | AdminRotationPending - | NoAdminRotationPending - | UnauthorizedRotationAccept - | AdminRotationSameAddress | SignatureReplay | SignerKeyNotRegistered | HolderBlacklisted @@ -602,7 +618,17 @@ pub mod abort_handling { | SnapshotNotEnabled | PayoutAssetMismatch | MetadataTooLarge - | SupplyCapExceeded => false, + | SupplyCapExceeded + | TransferFailed + // Blacklist capacity is a hard enforcement boundary; callers must + // remove an entry before retrying — not safe to silently continue. + | BlacklistSizeLimitExceeded + | ContractPaused + | AdminRotationPending + | NoAdminRotationPending + | UnauthorizedRotationAccept + | AdminRotationSameAddress + | IssuerTransferExpired => false, } } @@ -855,6 +881,28 @@ mod tests { Err(RevoraError::ContractFrozen) ); } + + #[test] + fn test_assert_blacklist_not_full_below_limit() { + assert!(state_consistency::assert_blacklist_not_full(0, 200).is_ok()); + assert!(state_consistency::assert_blacklist_not_full(199, 200).is_ok()); + } + + #[test] + fn test_assert_blacklist_not_full_at_limit() { + assert_eq!( + state_consistency::assert_blacklist_not_full(200, 200), + Err(RevoraError::BlacklistSizeLimitExceeded) + ); + } + + #[test] + fn test_assert_blacklist_not_full_above_limit() { + assert_eq!( + state_consistency::assert_blacklist_not_full(201, 200), + Err(RevoraError::BlacklistSizeLimitExceeded) + ); + } } mod abort_handling_tests { @@ -874,6 +922,22 @@ mod tests { )); } + #[test] + fn test_is_recoverable_error_blacklist_size_limit_exceeded() { + // BlacklistSizeLimitExceeded is fatal: caller must remove an entry + // before retrying; silently continuing would bypass the guardrail. + assert!(!abort_handling::is_recoverable_error( + &RevoraError::BlacklistSizeLimitExceeded + )); + } + + #[test] + fn test_is_recoverable_error_transfer_failed() { + assert!(!abort_handling::is_recoverable_error( + &RevoraError::TransferFailed + )); + } + #[test] fn test_recover_with_default_ok() { let result: Result = Ok(100); diff --git a/src/test.rs b/src/test.rs index bdb08880c..5e3a5302c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1649,7 +1649,173 @@ fn blacklist_remove_requires_issuer_auth() { assert!(r.is_ok()); } -// ── whitelist CRUD ──────────────────────────────────────────── +// ── Blacklist size guardrails ───────────────────────────────── + +/// Adding exactly MAX_BLACKLIST_SIZE (200) distinct addresses must succeed. +#[test] +fn blacklist_at_capacity_succeeds() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + + for _ in 0..200 { + let inv = Address::generate(&env); + let r = client.try_blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &inv); + assert!(r.is_ok(), "expected Ok for entry within limit"); + } + assert_eq!(client.get_blacklist(&issuer, &symbol_short!("def"), &token).len(), 200); +} + +/// Adding a 201st distinct address must be rejected with BlacklistSizeLimitExceeded. +#[test] +fn blacklist_over_capacity_rejected() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + + for _ in 0..200 { + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &Address::generate(&env)); + } + + let overflow = Address::generate(&env); + let r = client.try_blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &overflow); + assert!(r.is_err()); + assert_eq!(r.unwrap_err(), RevoraError::BlacklistSizeLimitExceeded); +} + +/// Idempotent re-add of an already-blacklisted address must not count against the limit. +#[test] +fn blacklist_idempotent_add_does_not_consume_capacity() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + let inv = Address::generate(&env); + + // Fill to 199 + for _ in 0..199 { + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &Address::generate(&env)); + } + // Add inv once (now at 200) + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &inv); + assert_eq!(client.get_blacklist(&issuer, &symbol_short!("def"), &token).len(), 200); + + // Re-adding inv must succeed (idempotent, no new slot consumed) + let r = client.try_blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &inv); + assert!(r.is_ok(), "idempotent re-add must not be rejected"); + assert_eq!(client.get_blacklist(&issuer, &symbol_short!("def"), &token).len(), 200); +} + +/// After removing an entry from a full blacklist, a new address can be added again. +#[test] +fn blacklist_remove_frees_capacity() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + let first = Address::generate(&env); + + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &first); + for _ in 0..199 { + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &Address::generate(&env)); + } + assert_eq!(client.get_blacklist(&issuer, &symbol_short!("def"), &token).len(), 200); + + // At capacity — new add must fail + let overflow = Address::generate(&env); + assert!(client + .try_blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &overflow) + .is_err()); + + // Remove one entry + client.blacklist_remove(&issuer, &issuer, &symbol_short!("def"), &token, &first); + assert_eq!(client.get_blacklist(&issuer, &symbol_short!("def"), &token).len(), 199); + + // Now the previously-rejected address can be added + let r = client.try_blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &overflow); + assert!(r.is_ok(), "add after remove must succeed"); +} + +/// Size limit is per-offering; filling one offering's blacklist must not affect another. +#[test] +fn blacklist_size_limit_is_per_offering() { + let (env, client, _admin, issuer, token_a) = blacklist_setup(); + let token_b = Address::generate(&env); + let payout_b = Address::generate(&env); + client.register_offering(&issuer, &symbol_short!("def"), &token_b, &1_000, &payout_b, &0); + + // Fill token_a blacklist to capacity + for _ in 0..200 { + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token_a, &Address::generate(&env)); + } + + // token_b blacklist is independent — must still accept entries + let inv = Address::generate(&env); + let r = client.try_blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token_b, &inv); + assert!(r.is_ok(), "separate offering blacklist must be unaffected"); +} + +/// get_blacklist_size returns 0 before any add. +#[test] +fn get_blacklist_size_empty() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + let _ = env; + assert_eq!(client.get_blacklist_size(&issuer, &symbol_short!("def"), &token), 0); +} + +/// get_blacklist_size tracks additions and removals accurately. +#[test] +fn get_blacklist_size_tracks_mutations() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + let inv_a = Address::generate(&env); + let inv_b = Address::generate(&env); + + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &inv_a); + assert_eq!(client.get_blacklist_size(&issuer, &symbol_short!("def"), &token), 1); + + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &inv_b); + assert_eq!(client.get_blacklist_size(&issuer, &symbol_short!("def"), &token), 2); + + // Idempotent re-add must not change size + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &inv_a); + assert_eq!(client.get_blacklist_size(&issuer, &symbol_short!("def"), &token), 2); + + client.blacklist_remove(&issuer, &issuer, &symbol_short!("def"), &token, &inv_a); + assert_eq!(client.get_blacklist_size(&issuer, &symbol_short!("def"), &token), 1); +} + +/// get_blacklist_size at capacity equals MAX_BLACKLIST_SIZE (200). +#[test] +fn get_blacklist_size_at_capacity() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + for _ in 0..200 { + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &Address::generate(&env)); + } + assert_eq!(client.get_blacklist_size(&issuer, &symbol_short!("def"), &token), 200); +} + +/// blacklist_remove must reject callers that are neither issuer nor admin. +#[test] +fn blacklist_remove_requires_issuer_or_admin_auth() { + let (env, client, _admin, issuer, token) = blacklist_setup(); + let investor = Address::generate(&env); + let attacker = Address::generate(&env); + + client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &investor); + assert!(client.is_blacklisted(&issuer, &symbol_short!("def"), &token, &investor)); + + // Attacker cannot remove + let r = client.try_blacklist_remove(&attacker, &issuer, &symbol_short!("def"), &token, &investor); + assert!(r.is_err(), "unauthorized remove must be rejected"); + + // Investor must still be blacklisted after failed remove + assert!(client.is_blacklisted(&issuer, &symbol_short!("def"), &token, &investor), + "blacklist must be unchanged after unauthorized remove attempt"); +} + +/// blacklist_remove on a non-existent offering must return OfferingNotFound. +#[test] +fn blacklist_remove_nonexistent_offering_returns_error() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let investor = Address::generate(&env); + + let r = client.try_blacklist_remove(&issuer, &issuer, &symbol_short!("def"), &token, &investor); + assert!(r.is_err(), "remove on non-existent offering must fail"); +} #[test] fn whitelist_add_marks_investor_as_whitelisted() { diff --git a/src/vesting.rs b/src/vesting.rs index 61b0d2ab3..230e7f41a 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -51,6 +51,7 @@ const EVENT_VESTING_CANCELLED: Symbol = symbol_short!("vest_can"); const EVENT_VESTING_CREATED_V1: Symbol = symbol_short!("vst_crt1"); const EVENT_VESTING_CLAIMED_V1: Symbol = symbol_short!("vst_clm1"); const EVENT_VESTING_CANCELLED_V1: Symbol = symbol_short!("vst_can1"); +const EVENT_VESTING_PCLAIM: Symbol = symbol_short!("vst_pclm"); /// Version tag for versioned vesting event payloads. pub const VESTING_EVENT_SCHEMA_VERSION: u32 = 1; @@ -124,8 +125,8 @@ impl RevoraVesting { env.storage().persistent().set(&count_key, &(count + 1)); env.events().publish( - (EVENT_VESTING_CREATED, admin, beneficiary), - (token, total_amount, start_time, cliff_time, end_time, count), + (EVENT_VESTING_CREATED, admin.clone(), beneficiary.clone()), + (token.clone(), total_amount, start_time, cliff_time, end_time, count), ); env.events().publish( (EVENT_VESTING_CREATED_V1, admin, beneficiary), @@ -170,7 +171,7 @@ impl RevoraVesting { schedule.cancelled = true; env.storage().persistent().set(&key, &schedule); env.events().publish( - (EVENT_VESTING_CANCELLED, admin, beneficiary), + (EVENT_VESTING_CANCELLED, admin.clone(), beneficiary.clone()), (schedule_index, schedule.token.clone()), ); env.events().publish( @@ -236,8 +237,8 @@ impl RevoraVesting { ); env.events().publish( - (EVENT_VESTING_CLAIMED, beneficiary.clone(), admin), - (schedule_index, schedule.token, claimable), + (EVENT_VESTING_CLAIMED, beneficiary.clone(), admin.clone()), + (schedule_index, schedule.token.clone(), claimable), ); env.events().publish( (EVENT_VESTING_CLAIMED_V1, beneficiary.clone(), admin),