diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2610ac5..043d5894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,14 @@ jobs: run: | chmod +x scripts/check-wasm-size.sh ./scripts/check-wasm-size.sh + + event-schema-coverage: + name: Event schema coverage gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check event schema coverage + run: | + chmod +x scripts/check_event_schema_coverage.sh + ./scripts/check_event_schema_coverage.sh \ No newline at end of file diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index 7c22605f..87286580 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -858,3 +858,230 @@ Emitted by `set_vault()` when the admin updates the registered vault address. | 0.0.1 | revenue-pool | Full revenue pool event suite with JSON examples | | 0.0.1 | revenue-pool | Added `admin_changed` event on `set_admin` for explicit old/new admin intent | | 0.1.0 | settlement | `payment_received`, `balance_credited` | + +--- + +### `allowlist_add` + +Emitted when the owner adds an address to the depositor allowlist. + +| Index | Location | Type | Description | +|---------|----------|---------|--------------------------| +| topic 0 | topics | Symbol | `"allowlist_add"` | +| topic 1 | topics | Address | caller (owner) | +| topic 2 | topics | Address | depositor being added | +| data | data | () | empty | + +```json +{ + "topics": ["allowlist_add", "GOWNER...", "GDEPOSITOR..."], + "data": null +} +``` + +**Indexer note:** If the address was already in the list, the event is still emitted +but the list is unchanged (no duplicates are stored). + +--- + +### `allowlist_clear` + +Emitted when the owner wipes the entire depositor allowlist. + +| Index | Location | Type | Description | +|---------|----------|---------|------------------| +| topic 0 | topics | Symbol | `"allowlist_clear"` | +| topic 1 | topics | Address | caller (owner) | +| data | data | () | empty | + +```json +{ + "topics": ["allowlist_clear", "GOWNER..."], + "data": null +} +``` + +**Indexer note:** After this event, `get_allowlist()` returns an empty list. +All previously allowed depositor addresses must be re-added via `add_address()`. + +--- + +### `price_set` + +Emitted when the owner sets or updates the price for an offering. + +| Index | Location | Type | Description | +|---------|-----------|---------|------------------------------------| +| topic 0 | topics | Symbol | `"price_set"` | +| topic 1 | topics | Address | caller (owner) | +| topic 2 | topics | String | `offering_id` | +| data | data | String | price as a decimal string (i128) | + +```json +{ + "topics": ["price_set", "GOWNER...", "offering-001"], + "data": "5000000" +} +``` + +**Encoding note:** The price is stored and emitted as a UTF-8 decimal string +representing a positive `i128` integer in USDC micro-units. A value of `"0"` or +negative is rejected before the event is emitted. + +--- + +### `upgraded` + +Emitted when the admin upgrades the contract WASM via `upgrade()`. +Emitted by both the vault and revenue pool contracts. + +| Index | Location | Type | Description | +|---------|----------|-------------|------------------------------------| +| topic 0 | topics | Symbol | `"upgraded"` | +| topic 1 | topics | Address | `admin` — address that triggered the upgrade | +| data | data | BytesN<32> | new WASM hash installed on-chain | + +```json +{ + "topics": ["upgraded", "GADMIN..."], + "data": "<32-byte WASM hash as hex>" +} +``` + +**Indexer note:** The emitted hash matches what `version()` returns immediately +after the upgrade. Indexers can use this event to track contract version history +without querying ledger state. + +--- + +## Contract: `callora-revenue-pool` — additional events + +### `pause_set` + +Emitted by both `pause()` and `unpause()`. The boolean data field distinguishes +the two cases, making this a single unified event for pause state changes. + +| Index | Location | Type | Description | +|---------|----------|---------|----------------------------------------| +| topic 0 | topics | Symbol | `"pause_set"` | +| topic 1 | topics | Address | `caller` — admin who changed the state | +| data | data | bool | `true` = paused, `false` = unpaused | + +```json +{ "topics": ["pause_set", "GADMIN..."], "data": true } +``` + +```json +{ "topics": ["pause_set", "GADMIN..."], "data": false } +``` + +**Indexer note:** After `data = true`, `distribute()` and `batch_distribute()` +are blocked. After `data = false`, all operations are restored. +Calling `pause()` when already paused, or `unpause()` when not paused, panics +before the event is emitted. + +--- + +## Contract: `callora-settlement` — additional events + +### `developer_withdraw` + +Emitted when a developer withdraws their accrued balance from the settlement contract. + +| Index | Location | Type | Description | +|--------------------|----------|---------|------------------------------------------------| +| topic 0 | topics | Symbol | `"developer_withdraw"` | +| topic 1 | topics | Address | `developer` — address initiating the withdrawal | +| `developer` | data | Address | same as topic 1; duplicated for data-only indexers | +| `amount` | data | i128 | amount withdrawn in USDC micro-units | +| `remaining_balance`| data | i128 | developer's balance after the withdrawal | + +```json +{ + "topics": ["developer_withdraw", "GDEV..."], + "data": { + "developer": "GDEV...", + "amount": 2500000, + "remaining_balance": 1000000 + } +} +``` + +**Invariant:** `remaining_balance = prior_balance − amount`. The USDC transfer +to `developer` has already succeeded by the time this event is emitted — the +funds have left the contract. + +--- + +### `vault_proposed` + +Emitted when the admin proposes a new vault address via `propose_vault()`. +This begins a two-step vault rotation; the vault is not active until +`vault_accepted` is emitted. + +| Index | Location | Type | Description | +|------------------|----------|---------|------------------------------------------| +| topic 0 | topics | Symbol | `"vault_proposed"` | +| topic 1 | topics | Address | `caller` — admin who initiated the proposal | +| `current_vault` | data | Address | the vault address currently registered | +| `proposed_vault` | data | Address | the new vault address pending acceptance | + +```json +{ + "topics": ["vault_proposed", "GADMIN..."], + "data": { + "current_vault": "GOLDVAULT...", + "proposed_vault": "GNEWVAULT..." + } +} +``` + +**Indexer note:** Until `vault_accepted` is observed, `current_vault` remains +the active vault for `receive_payment()` authorization. Index this event to +monitor pending rotations. + +--- + +### `vault_accepted` + +Emitted when the pending vault (or admin) accepts the proposed vault rotation +via `accept_vault()`. After this event the new vault is the only address +authorized to call `receive_payment()`. + +| Index | Location | Type | Description | +|--------------|----------|---------|-----------------------------------------------------| +| topic 0 | topics | Symbol | `"vault_accepted"` | +| topic 1 | topics | Address | `caller` — pending vault or admin who accepted | +| `old_vault` | data | Address | vault address that was previously active | +| `new_vault` | data | Address | vault address now active | +| `accepted_by`| data | Address | same as topic 1; duplicated for data-only indexers | + +```json +{ + "topics": ["vault_accepted", "GNEWVAULT..."], + "data": { + "old_vault": "GOLDVAULT...", + "new_vault": "GNEWVAULT...", + "accepted_by": "GNEWVAULT..." + } +} +``` + +**Indexer note:** After this event, update any cached vault address used for +payment-source filtering. `vault_proposed` + `vault_accepted` always appear as +a pair across two separate transactions. + +| `allowlist_add` | vault | `add_address()` | +| `allowlist_clear` | vault | `clear_all()` | +| `price_set` | vault | `set_price()` | +| `upgraded` | vault, revenue-pool | `upgrade()` | +| `pause_set` | revenue-pool | `pause()` / `unpause()` | +| `developer_withdraw` | settlement | `developer_withdraw()` | +| `vault_proposed` | settlement | `propose_vault()` | +| `vault_accepted` | settlement | `accept_vault()` | + +| 0.0.1 | vault | Added `allowlist_add`, `allowlist_clear` events for depositor allowlist management | +| 0.0.1 | vault | Added `price_set` event on `set_price()` for offering price tracking | +| 0.0.1 | vault | Added `upgraded` event on `upgrade()` for WASM version audit trail | +| 0.0.1 | revenue-pool | Added `pause_set` event (unified pause/unpause) with boolean data field | +| 0.1.0 | settlement | Added `developer_withdraw`, `vault_proposed`, `vault_accepted` events | \ No newline at end of file diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/revenue_pool/src/lib.rs index a833c858..85507f94 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/revenue_pool/src/lib.rs @@ -17,11 +17,13 @@ const ADMIN_KEY: &str = "admin"; const PENDING_ADMIN_KEY: &str = "pending_admin"; const USDC_KEY: &str = "usdc"; const MAX_DISTRIBUTE_KEY: &str = "max_distribute"; +const PAUSED_KEY: &str = "paused"; const ERR_AMOUNT_NOT_POSITIVE: &str = "amount must be positive"; const ERR_AMOUNT_EXCEEDS_MAX_DISTRIBUTE: &str = "amount exceeds max_distribute"; const ERR_UNAUTHORIZED: &str = "unauthorized: caller is not admin"; const ERR_INSUFFICIENT_BALANCE: &str = "insufficient USDC balance"; const ERR_NOT_INITIALIZED: &str = "revenue pool not initialized"; +const ERR_PAUSED: &str = "revenue pool is paused"; const ERR_DUPLICATE_RECIPIENT: &str = "duplicate recipient in batch"; const VERSION_KEY: &str = "version"; @@ -517,7 +519,9 @@ impl RevenuePool { } // Extend TTL before executing transfers. - env.storage().instance().extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT); + env.storage() + .instance() + .extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT); // Phase 3: Execution — all validation passed, perform transfers. // Soroban's transaction model guarantees that if any transfer fails, diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 9376db40..22f462b3 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec, +}; /// Maximum number of items allowed in a single `batch_receive_payment` call. pub const MAX_BATCH_SIZE: u32 = 50; @@ -32,18 +34,18 @@ pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; #[derive(Clone, Copy, Debug, PartialEq)] #[repr(u32)] pub enum SettlementError { - NotInitialized = 1, - AlreadyInitialized = 2, - Unauthorized = 3, - AmountNotPositive = 4, - DeveloperRequired = 5, - DeveloperMustBeNone = 6, - PoolOverflow = 7, - DeveloperOverflow = 8, - UsdcTokenNotConfigured = 9, + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + AmountNotPositive = 4, + DeveloperRequired = 5, + DeveloperMustBeNone = 6, + PoolOverflow = 7, + DeveloperOverflow = 8, + UsdcTokenNotConfigured = 9, InsufficientDeveloperBalance = 10, - DeveloperBalanceUnderflow = 11, - InsufficientContractBalance = 12, + DeveloperBalanceUnderflow = 11, + InsufficientContractBalance = 12, } /// Persistent storage keys for settlement contract @@ -128,7 +130,6 @@ pub struct DeveloperWithdrawEvent { pub remaining_balance: i128, } - #[contract] pub struct CalloraSettlement; @@ -245,7 +246,7 @@ impl CalloraSettlement { let new_balance = current_balance .checked_add(amount) .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); - + // Write to persistent storage with TTL extension env.storage().persistent().set( &StorageKey::DeveloperBalance(dev_address.clone()), @@ -341,9 +342,11 @@ impl CalloraSettlement { env.storage() .persistent() .set(&StorageKey::DeveloperBalance(dev.clone()), &new_balance); - env.storage() - .persistent() - .extend_ttl(&StorageKey::DeveloperBalance(dev.clone()), 50000, 50000); + env.storage().persistent().extend_ttl( + &StorageKey::DeveloperBalance(dev.clone()), + 50000, + 50000, + ); // Add to index if not already present let mut index: Vec
= inst .get(&StorageKey::DeveloperIndex) @@ -472,12 +475,15 @@ impl CalloraSettlement { usdc.transfer(&contract_address, &developer, &amount); - env.storage() - .persistent() - .set(&StorageKey::DeveloperBalance(developer.clone()), &new_balance); - env.storage() - .persistent() - .extend_ttl(&StorageKey::DeveloperBalance(developer.clone()), 50000, 50000); + env.storage().persistent().set( + &StorageKey::DeveloperBalance(developer.clone()), + &new_balance, + ); + env.storage().persistent().extend_ttl( + &StorageKey::DeveloperBalance(developer.clone()), + 50000, + 50000, + ); env.events().publish( (Symbol::new(&env, "developer_withdraw"), developer.clone()), @@ -615,9 +621,7 @@ impl CalloraSettlement { /// # Returns /// `Some(Address)` of the nominated admin, or `None` when no transfer is pending. pub fn get_pending_admin(env: Env) -> Option
{ - env.storage() - .instance() - .get(&StorageKey::PendingAdmin) + env.storage().instance().get(&StorageKey::PendingAdmin) } /// Nominate a new admin (admin only). diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs index 51661258..a78b2820 100644 --- a/contracts/settlement/src/test.rs +++ b/contracts/settlement/src/test.rs @@ -270,8 +270,14 @@ mod settlement_tests { let result = client.try_withdraw_developer_balance(&developer, &100i128); assert!(result.is_ok()); assert_eq!(client.get_developer_balance(&developer), 0i128); - assert_eq!(token::Client::new(&env, &usdc_address).balance(&addr), 0i128); - assert_eq!(token::Client::new(&env, &usdc_address).balance(&developer), 100i128); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&addr), + 0i128 + ); + assert_eq!( + token::Client::new(&env, &usdc_address).balance(&developer), + 100i128 + ); } #[test] @@ -955,9 +961,10 @@ mod settlement_tests { client.init(&admin, &vault); env.as_contract(&addr, || { - env.storage() - .persistent() - .set(&crate::StorageKey::DeveloperBalance(developer.clone()), &i128::MAX); + env.storage().persistent().set( + &crate::StorageKey::DeveloperBalance(developer.clone()), + &i128::MAX, + ); }); let result = client.try_receive_payment(&vault, &1i128, &false, &Some(developer)); @@ -1008,17 +1015,29 @@ mod settlement_tests { } let cases = [ - Case { name: "vault address succeeds", role: CallerRole::Vault, should_succeed: true }, - Case { name: "admin address succeeds", role: CallerRole::Admin, should_succeed: true }, - Case { name: "third party fails", role: CallerRole::ThirdParty, should_succeed: false }, + Case { + name: "vault address succeeds", + role: CallerRole::Vault, + should_succeed: true, + }, + Case { + name: "admin address succeeds", + role: CallerRole::Admin, + should_succeed: true, + }, + Case { + name: "third party fails", + role: CallerRole::ThirdParty, + should_succeed: false, + }, ]; for case in cases { let (env, addr, admin, vault, third_party) = setup_contract(); let client = CalloraSettlementClient::new(&env, &addr); let caller = match case.role { - CallerRole::Vault => vault, - CallerRole::Admin => admin, + CallerRole::Vault => vault, + CallerRole::Admin => admin, CallerRole::ThirdParty => third_party, }; diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 43d2363d..203ddd60 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -440,10 +440,7 @@ impl CalloraVault { } /// Set or clear the authorized caller for `deduct`/`batch_deduct` (owner only). - pub fn set_authorized_caller( - env: Env, - new_caller: Option
, - ) -> Result<(), VaultError> { + pub fn set_authorized_caller(env: Env, new_caller: Option
) -> Result<(), VaultError> { let mut meta = Self::get_meta(env.clone())?; meta.owner.require_auth(); let old = meta.authorized_caller.clone(); @@ -594,8 +591,11 @@ impl CalloraVault { // Transfer USDC from caller to vault. If this panics, the Soroban host // reverts the entire transaction — the Effects above are atomically rolled // back, leaving no inconsistent state. - token::Client::new(&env, &usdc_addr) - .transfer(&caller, &env.current_contract_address(), &amount); + token::Client::new(&env, &usdc_addr).transfer( + &caller, + &env.current_contract_address(), + &amount, + ); Ok(meta.balance) } @@ -651,14 +651,14 @@ impl CalloraVault { .instance() .get(&StorageKey::UsdcToken) .ok_or(VaultError::NotInitialized)?; - - // SECURITY: Perform all external operations FIRST. + + // SECURITY: Perform all external operations FIRST. // Although this is a CEI violation (Check-Effect-Interaction), re-entry is // blocked by Soroban's authorization model. Each call to `deduct` requires - // `caller.require_auth()`, which prevents recursive calls from stealing + // `caller.require_auth()`, which prevents recursive calls from stealing // authorization unless the user explicitly signs a nested call. Self::transfer_funds(&env, &ut, &settlement, amount); - + // Create a settlement client and call receive_payment to credit the global pool let settlement_client = SettlementClient::new(&env, &settlement); settlement_client.receive_payment( @@ -667,7 +667,7 @@ impl CalloraVault { &true, // to_pool = true: credit global pool &None, // no specific developer ); - + // Now that external operations succeeded, update internal state let mut meta = Self::get_meta(env.clone())?; meta.balance = meta @@ -682,7 +682,7 @@ impl CalloraVault { if let Some(ref rid) = request_id { Self::mark_request_processed(&env, rid); } - + let rid = request_id.unwrap_or(Symbol::new(&env, "")); env.events().publish( (Symbol::new(&env, "deduct"), caller, rid), @@ -750,7 +750,9 @@ impl CalloraVault { } seen_in_batch.push_back(rid.clone()); } - running = running.checked_sub(item.amount).ok_or(VaultError::Overflow)?; + running = running + .checked_sub(item.amount) + .ok_or(VaultError::Overflow)?; total = total.checked_add(item.amount).ok_or(VaultError::Overflow)?; } let settlement = Self::require_settlement(&env)?; @@ -759,11 +761,11 @@ impl CalloraVault { .instance() .get(&StorageKey::UsdcToken) .ok_or(VaultError::NotInitialized)?; - + // SECURITY: External operations performed before internal state update. // Protected by `require_auth` and Soroban invocation semantics. Self::transfer_funds(&env, &ut, &settlement, total); - + // Create a settlement client and call receive_payment to credit the global pool let settlement_client = SettlementClient::new(&env, &settlement); settlement_client.receive_payment( @@ -772,7 +774,7 @@ impl CalloraVault { &true, // to_pool = true: credit global pool &None, // no specific developer ); - + // Now that external operations succeeded, update internal state let mut meta = Self::get_meta(env.clone())?; meta.balance = running; @@ -786,7 +788,7 @@ impl CalloraVault { Self::mark_request_processed(&env, rid); } } - + for item in items.iter() { let rid = item.request_id.unwrap_or(Symbol::new(&env, "")); env.events().publish( @@ -856,7 +858,10 @@ impl CalloraVault { &meta.owner, &amount, ); - meta.balance = meta.balance.checked_sub(amount).ok_or(VaultError::Overflow)?; + meta.balance = meta + .balance + .checked_sub(amount) + .ok_or(VaultError::Overflow)?; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() .instance() @@ -883,13 +888,20 @@ impl CalloraVault { .get(&StorageKey::UsdcToken) .ok_or(VaultError::NotInitialized)?; token::Client::new(&env, &ua).transfer(&env.current_contract_address(), &to, &amount); - meta.balance = meta.balance.checked_sub(amount).ok_or(VaultError::Overflow)?; + meta.balance = meta + .balance + .checked_sub(amount) + .ok_or(VaultError::Overflow)?; env.storage().instance().set(&StorageKey::MetaKey, &meta); env.storage() .instance() .extend_ttl(INSTANCE_BUMP_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.events().publish( - (Symbol::new(&env, "withdraw_to"), meta.owner.clone(), to.clone()), + ( + Symbol::new(&env, "withdraw_to"), + meta.owner.clone(), + to.clone(), + ), (amount, meta.balance), ); Ok(meta.balance) @@ -1019,7 +1031,12 @@ impl CalloraVault { /// # Errors /// - `VaultError::OfferingIdTooLong` when `offering_id` exceeds maximum length. /// - `VaultError::PriceParseError` when `price` cannot be parsed to a positive i128. - pub fn set_price(env: Env, caller: Address, offering_id: String, price: String) -> Result<(), VaultError> { + pub fn set_price( + env: Env, + caller: Address, + offering_id: String, + price: String, + ) -> Result<(), VaultError> { caller.require_auth(); Self::require_owner(env.clone(), caller.clone())?; if offering_id.len() > MAX_OFFERING_ID_LEN { @@ -1128,12 +1145,12 @@ impl CalloraVault { /// See UPGRADE.md for the complete operational flow. pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) { caller.require_auth(); - let admin = Self::get_admin(env.clone()) - .expect("vault must be initialized before upgrade"); + let admin = Self::get_admin(env.clone()).expect("vault must be initialized before upgrade"); // Perform the on-chain upgrade via the deployer interface. // This is a host operation and may only succeed in the live environment. - env.deployer().update_current_contract_wasm(new_wasm_hash.clone()); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); // Persist the version marker for on-chain queries. env.storage() @@ -1149,9 +1166,7 @@ impl CalloraVault { /// /// Returns `None` if no upgrade has been performed yet (initial deployment). pub fn version(env: Env) -> Option> { - env.storage() - .instance() - .get(&StorageKey::ContractVersion) + env.storage().instance().get(&StorageKey::ContractVersion) } // ----------------------------------------------------------------------- @@ -1195,9 +1210,11 @@ impl CalloraVault { fn mark_request_processed(env: &Env, request_id: &Symbol) { let key = StorageKey::ProcessedRequest(request_id.clone()); env.storage().temporary().set(&key, &true); - env.storage() - .temporary() - .extend_ttl(&key, REQUEST_ID_BUMP_THRESHOLD, REQUEST_ID_BUMP_AMOUNT); + env.storage().temporary().extend_ttl( + &key, + REQUEST_ID_BUMP_THRESHOLD, + REQUEST_ID_BUMP_AMOUNT, + ); } fn transfer_funds(env: &Env, usdc_token: &Address, to: &Address, amount: i128) { diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 57c88236..201fe900 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -31,7 +31,8 @@ fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { /// Register and initialize the settlement contract. fn create_settlement(env: &Env, admin: &Address, vault_address: &Address) -> Address { let settlement_address = env.register(CalloraSettlement, ()); - let settlement_client = callora_settlement::CalloraSettlementClient::new(env, &settlement_address); + let settlement_client = + callora_settlement::CalloraSettlementClient::new(env, &settlement_address); env.mock_all_auths(); settlement_client.init(admin, vault_address); settlement_address @@ -5844,7 +5845,6 @@ fn test_reentry_repeated_attempts() { assert_eq!(vault_client.balance(), 400); } - // --------------------------------------------------------------------------- // Upgrade tests (Issue #331) // --------------------------------------------------------------------------- @@ -5894,13 +5894,13 @@ fn upgrade_sets_version_and_emits_event() { let events = env.events().all(); let ev = events.last().unwrap(); assert_eq!(ev.0, vault_address); - + let name: Symbol = ev.1.get(0).unwrap().into_val(&env); assert_eq!(name, Symbol::new(&env, "upgraded")); - + let admin_topic: Address = ev.1.get(1).unwrap().into_val(&env); assert_eq!(admin_topic, owner); - + let data: BytesN<32> = ev.2.into_val(&env); assert_eq!(data, new_hash); } @@ -5952,7 +5952,10 @@ fn upgrade_owner_not_admin_fails() { // owner (no longer admin) should fail let res = client.try_upgrade(&owner, &new_hash); - assert!(res.is_err(), "owner without admin role should not be able to upgrade"); + assert!( + res.is_err(), + "owner without admin role should not be able to upgrade" + ); } #[test] @@ -6022,10 +6025,16 @@ impl BudgetSnapshot { /// Calculate delta between two snapshots (after - before). fn delta(&self, before: &BudgetSnapshot) -> BudgetSnapshot { BudgetSnapshot { - cpu_instructions: self.cpu_instructions.saturating_sub(before.cpu_instructions), + cpu_instructions: self + .cpu_instructions + .saturating_sub(before.cpu_instructions), memory_bytes: self.memory_bytes.saturating_sub(before.memory_bytes), - ledger_read_bytes: self.ledger_read_bytes.saturating_sub(before.ledger_read_bytes), - ledger_write_bytes: self.ledger_write_bytes.saturating_sub(before.ledger_write_bytes), + ledger_read_bytes: self + .ledger_read_bytes + .saturating_sub(before.ledger_read_bytes), + ledger_write_bytes: self + .ledger_write_bytes + .saturating_sub(before.ledger_write_bytes), } } } @@ -6038,7 +6047,15 @@ fn setup_vault_for_deduct(env: &Env, initial_balance: i128) -> (Address, Callora env.mock_all_auths(); fund_vault(&usdc_admin, &vault_address, initial_balance); - client.init(&owner, &usdc, &Some(initial_balance), &None, &None, &None, &None); + client.init( + &owner, + &usdc, + &Some(initial_balance), + &None, + &None, + &None, + &None, + ); let settlement = create_settlement(env, &owner, &vault_address); client.set_settlement(&owner, &settlement); @@ -6186,15 +6203,15 @@ fn budget_measure_batch_deduct_size_50() { #[ignore] fn budget_measure_all() { std::println!("\n=== VAULT BUDGET MEASUREMENT SUITE ===\n"); - + // Single deduct baseline budget_measure_single_deduct(); - + // Batch deduct at various sizes budget_measure_batch_deduct_size_1(); budget_measure_batch_deduct_size_10(); budget_measure_batch_deduct_size_25(); budget_measure_batch_deduct_size_50(); - + std::println!("\n=== END VAULT BUDGET MEASUREMENTS ===\n"); } diff --git a/contracts/vault/src/test_idempotency.rs b/contracts/vault/src/test_idempotency.rs index 09eb5d2d..3b9d1ef3 100644 --- a/contracts/vault/src/test_idempotency.rs +++ b/contracts/vault/src/test_idempotency.rs @@ -43,7 +43,8 @@ fn create_vault(env: &Env) -> (Address, CalloraVaultClient<'_>) { /// Register and initialize the settlement contract. fn create_settlement(env: &Env, admin: &Address, vault_address: &Address) -> Address { let settlement_address = env.register(CalloraSettlement, ()); - let settlement_client = callora_settlement::CalloraSettlementClient::new(env, &settlement_address); + let settlement_client = + callora_settlement::CalloraSettlementClient::new(env, &settlement_address); settlement_client.init(admin, vault_address); settlement_address } @@ -86,13 +87,14 @@ fn deduct_duplicate_request_id_rejected() { // Second call with same request_id — must be rejected. let result = client.try_deduct(&owner, &100, &Some(rid.clone())); - assert!( - result.is_err(), - "duplicate request_id must be rejected" - ); + assert!(result.is_err(), "duplicate request_id must be rejected"); // Balance must be unchanged after the rejected retry. - assert_eq!(client.balance(), 900, "balance must not change on duplicate"); + assert_eq!( + client.balance(), + 900, + "balance must not change on duplicate" + ); } /// Two distinct `request_id` values each succeed independently. @@ -241,7 +243,11 @@ fn batch_deduct_duplicate_request_id_rejected_atomically() { assert!(result.is_err(), "batch with duplicate id must be rejected"); // Balance must be unchanged — full atomicity. - assert_eq!(client.balance(), 900, "balance must not change on duplicate batch"); + assert_eq!( + client.balance(), + 900, + "balance must not change on duplicate batch" + ); } /// A batch where two items share the same new `request_id` is rejected. @@ -381,7 +387,10 @@ fn deduct_retry_with_different_amount_still_rejected() { // Retry with a different amount — still rejected. let result = client.try_deduct(&owner, &50, &Some(rid.clone())); - assert!(result.is_err(), "retry with different amount must be rejected"); + assert!( + result.is_err(), + "retry with different amount must be rejected" + ); assert_eq!(client.balance(), 900); } diff --git a/contracts/vault/src/test_reentrancy.rs b/contracts/vault/src/test_reentrancy.rs index bc0e3504..163a6696 100644 --- a/contracts/vault/src/test_reentrancy.rs +++ b/contracts/vault/src/test_reentrancy.rs @@ -1,8 +1,8 @@ extern crate std; +use crate::{CalloraVault, CalloraVaultClient, DeductItem}; use soroban_sdk::testutils::{Address as _, Events as _}; use soroban_sdk::{contract, contractimpl, Address, Env, IntoVal, Symbol, Vec}; -use crate::{CalloraVault, CalloraVaultClient, DeductItem}; // --------------------------------------------------------------------------- // Malicious Token Mock @@ -15,32 +15,51 @@ pub struct MaliciousToken; impl MaliciousToken { pub fn transfer(env: Env, from: Address, _to: Address, _amount: i128) { from.require_auth(); - - let vault_addr: Option
= env.storage().instance().get(&Symbol::new(&env, "vault_addr")); - let attack_active: bool = env.storage().instance().get(&Symbol::new(&env, "attack_active")).unwrap_or(false); - + + let vault_addr: Option
= env + .storage() + .instance() + .get(&Symbol::new(&env, "vault_addr")); + let attack_active: bool = env + .storage() + .instance() + .get(&Symbol::new(&env, "attack_active")) + .unwrap_or(false); + if attack_active { if let Some(vault) = vault_addr { // Prevent infinite recursion in the mock - env.storage().instance().set(&Symbol::new(&env, "attack_active"), &false); - - let caller: Address = env.storage().instance().get(&Symbol::new(&env, "attack_caller")).unwrap(); + env.storage() + .instance() + .set(&Symbol::new(&env, "attack_active"), &false); + + let caller: Address = env + .storage() + .instance() + .get(&Symbol::new(&env, "attack_caller")) + .unwrap(); let client = CalloraVaultClient::new(&env, &vault); - + // Attempt re-entry into deduct let _ = client.try_deduct(&caller, &1, &Some(Symbol::new(&env, "reentry_token"))); } } } - pub fn balance(_env: Env, _id: Address) -> i128 { - 1_000_000_000 + pub fn balance(_env: Env, _id: Address) -> i128 { + 1_000_000_000 } pub fn set_token_attack_config(env: Env, vault: Address, caller: Address, active: bool) { - env.storage().instance().set(&Symbol::new(&env, "vault_addr"), &vault); - env.storage().instance().set(&Symbol::new(&env, "attack_caller"), &caller); - env.storage().instance().set(&Symbol::new(&env, "attack_active"), &active); + env.storage() + .instance() + .set(&Symbol::new(&env, "vault_addr"), &vault); + env.storage() + .instance() + .set(&Symbol::new(&env, "attack_caller"), &caller); + env.storage() + .instance() + .set(&Symbol::new(&env, "attack_active"), &active); } } @@ -53,26 +72,51 @@ pub struct MaliciousSettlement; #[contractimpl] impl MaliciousSettlement { - pub fn receive_payment(env: Env, _caller: Address, _amount: i128, _to_pool: bool, _developer: Option
) { - let vault_addr: Option
= env.storage().instance().get(&Symbol::new(&env, "vault_addr")); - let attack_active: bool = env.storage().instance().get(&Symbol::new(&env, "attack_active")).unwrap_or(false); - + pub fn receive_payment( + env: Env, + _caller: Address, + _amount: i128, + _to_pool: bool, + _developer: Option
, + ) { + let vault_addr: Option
= env + .storage() + .instance() + .get(&Symbol::new(&env, "vault_addr")); + let attack_active: bool = env + .storage() + .instance() + .get(&Symbol::new(&env, "attack_active")) + .unwrap_or(false); + if attack_active { if let Some(vault) = vault_addr { - env.storage().instance().set(&Symbol::new(&env, "attack_active"), &false); - let caller: Address = env.storage().instance().get(&Symbol::new(&env, "attack_caller")).unwrap(); + env.storage() + .instance() + .set(&Symbol::new(&env, "attack_active"), &false); + let caller: Address = env + .storage() + .instance() + .get(&Symbol::new(&env, "attack_caller")) + .unwrap(); let client = CalloraVaultClient::new(&env, &vault); - + // Attempt re-entry into deduct let _ = client.try_deduct(&caller, &1, &Some(Symbol::new(&env, "reentry_settle"))); } } } - + pub fn set_settle_attack_config(env: Env, vault: Address, caller: Address, active: bool) { - env.storage().instance().set(&Symbol::new(&env, "vault_addr"), &vault); - env.storage().instance().set(&Symbol::new(&env, "attack_caller"), &caller); - env.storage().instance().set(&Symbol::new(&env, "attack_active"), &active); + env.storage() + .instance() + .set(&Symbol::new(&env, "vault_addr"), &vault); + env.storage() + .instance() + .set(&Symbol::new(&env, "attack_caller"), &caller); + env.storage() + .instance() + .set(&Symbol::new(&env, "attack_active"), &active); } } @@ -84,177 +128,243 @@ fn setup_reentrancy_test(env: &Env) -> (Address, CalloraVaultClient, Address, Ad let owner = Address::generate(env); let vault_addr = env.register(CalloraVault, ()); let vault_client = CalloraVaultClient::new(env, &vault_addr); - + let token_addr = env.register(MaliciousToken, ()); let settlement_addr = env.register(MaliciousSettlement, ()); - + env.mock_all_auths(); - + // Init vault with the malicious token vault_client.init(&owner, &token_addr, &Some(1000), &None, &None, &None, &None); vault_client.set_settlement(&owner, &settlement_addr); - + (vault_addr, vault_client, token_addr, settlement_addr, owner) } #[test] fn test_reentrancy_via_token_transfer_is_blocked_by_auth() { let env = Env::default(); - let (vault_addr, vault_client, token_addr, _settlement_addr, owner) = setup_reentrancy_test(&env); - + let (vault_addr, vault_client, token_addr, _settlement_addr, owner) = + setup_reentrancy_test(&env); + let token_mock = MaliciousTokenClient::new(&env, &token_addr); token_mock.set_token_attack_config(&vault_addr, &owner, &true); - + let initial_balance = vault_client.balance(); assert_eq!(initial_balance, 1000); - + // Trigger deduct -> calls token.transfer -> calls vault.deduct (re-entry) let result = vault_client.try_deduct(&owner, &100, &Some(Symbol::new(&env, "first_call"))); - + assert!(result.is_ok(), "First deduct should succeed"); - assert_eq!(vault_client.balance(), 900, "Balance should only be deducted once"); - + assert_eq!( + vault_client.balance(), + 900, + "Balance should only be deducted once" + ); + // Check if the re-entry event was published (it shouldn't be if it failed) let events = env.events().all(); let mut reentry_count = 0; for e in events.iter() { - if e.0 != vault_addr { continue; } + if e.0 != vault_addr { + continue; + } let topics = &e.1; - if topics.len() < 3 { continue; } + if topics.len() < 3 { + continue; + } let rid: Symbol = topics.get(2).unwrap().into_val(&env); if rid == Symbol::new(&env, "reentry_token") { reentry_count += 1; } } - + assert_eq!(reentry_count, 0, "Re-entry should not have succeeded"); } #[test] fn test_reentrancy_via_settlement_callback_is_blocked() { let env = Env::default(); - let (vault_addr, vault_client, _token_addr, settlement_addr, owner) = setup_reentrancy_test(&env); - + let (vault_addr, vault_client, _token_addr, settlement_addr, owner) = + setup_reentrancy_test(&env); + let settlement_mock = MaliciousSettlementClient::new(&env, &settlement_addr); settlement_mock.set_settle_attack_config(&vault_addr, &owner, &true); - + let initial_balance = vault_client.balance(); assert_eq!(initial_balance, 1000); - + // Trigger deduct -> calls settlement.receive_payment -> calls vault.deduct (re-entry) let result = vault_client.try_deduct(&owner, &100, &Some(Symbol::new(&env, "first_call"))); - + assert!(result.is_ok(), "First deduct should succeed"); - assert_eq!(vault_client.balance(), 900, "Balance should only be deducted once"); - + assert_eq!( + vault_client.balance(), + 900, + "Balance should only be deducted once" + ); + let events = env.events().all(); let mut reentry_count = 0; for e in events.iter() { - if e.0 != vault_addr { continue; } + if e.0 != vault_addr { + continue; + } let topics = &e.1; - if topics.len() < 3 { continue; } + if topics.len() < 3 { + continue; + } let rid: Symbol = topics.get(2).unwrap().into_val(&env); if rid == Symbol::new(&env, "reentry_settle") { reentry_count += 1; } } - - assert_eq!(reentry_count, 0, "Re-entry via settlement should not have succeeded"); + + assert_eq!( + reentry_count, 0, + "Re-entry via settlement should not have succeeded" + ); } #[test] fn test_batch_deduct_reentrancy_via_token() { let env = Env::default(); - let (vault_addr, vault_client, token_addr, _settlement_addr, owner) = setup_reentrancy_test(&env); - + let (vault_addr, vault_client, token_addr, _settlement_addr, owner) = + setup_reentrancy_test(&env); + let token_mock = MaliciousTokenClient::new(&env, &token_addr); token_mock.set_token_attack_config(&vault_addr, &owner, &true); - - let items = Vec::from_array(&env, [ - DeductItem { amount: 50, request_id: Some(Symbol::new(&env, "item1")) }, - DeductItem { amount: 50, request_id: Some(Symbol::new(&env, "item2")) }, - ]); - + + let items = Vec::from_array( + &env, + [ + DeductItem { + amount: 50, + request_id: Some(Symbol::new(&env, "item1")), + }, + DeductItem { + amount: 50, + request_id: Some(Symbol::new(&env, "item2")), + }, + ], + ); + let result = vault_client.try_batch_deduct(&owner, &items); - + assert!(result.is_ok(), "Batch deduct should succeed"); - assert_eq!(vault_client.balance(), 900, "Balance should only be deducted by batch amount"); - + assert_eq!( + vault_client.balance(), + 900, + "Balance should only be deducted by batch amount" + ); + let events = env.events().all(); let mut reentry_count = 0; for e in events.iter() { - if e.0 != vault_addr { continue; } + if e.0 != vault_addr { + continue; + } let topics = &e.1; - if topics.len() < 3 { continue; } + if topics.len() < 3 { + continue; + } let rid: Symbol = topics.get(2).unwrap().into_val(&env); if rid == Symbol::new(&env, "reentry_token") { reentry_count += 1; } } - - assert_eq!(reentry_count, 0, "Re-entry during batch should not have succeeded"); + + assert_eq!( + reentry_count, 0, + "Re-entry during batch should not have succeeded" + ); } #[test] fn test_reentrancy_by_authorized_attacker() { let env = Env::default(); - let (vault_addr, vault_client, token_addr, _settlement_addr, _owner) = setup_reentrancy_test(&env); - + let (vault_addr, vault_client, token_addr, _settlement_addr, _owner) = + setup_reentrancy_test(&env); + let attacker = Address::generate(&env); vault_client.set_authorized_caller(&Some(attacker.clone())); - + let token_mock = MaliciousTokenClient::new(&env, &token_addr); token_mock.set_token_attack_config(&vault_addr, &attacker, &true); - + let initial_balance = vault_client.balance(); assert_eq!(initial_balance, 1000); - + // Attacker calls deduct -> token.transfer -> attacker calls vault.deduct (re-entry) let result = vault_client.try_deduct(&attacker, &100, &Some(Symbol::new(&env, "first_call"))); - + assert!(result.is_ok(), "First deduct should succeed"); - assert_eq!(vault_client.balance(), 900, "Balance should only be deducted once"); - + assert_eq!( + vault_client.balance(), + 900, + "Balance should only be deducted once" + ); + let events = env.events().all(); let mut reentry_count = 0; for e in events.iter() { - if e.0 != vault_addr { continue; } + if e.0 != vault_addr { + continue; + } let topics = &e.1; - if topics.len() < 3 { continue; } + if topics.len() < 3 { + continue; + } let rid: Symbol = topics.get(2).unwrap().into_val(&env); if rid == Symbol::new(&env, "reentry_token") { reentry_count += 1; } } - - assert_eq!(reentry_count, 0, "Re-entry by authorized attacker should still fail or be blocked"); + + assert_eq!( + reentry_count, 0, + "Re-entry by authorized attacker should still fail or be blocked" + ); } #[test] fn test_withdraw_reentrancy_via_token() { let env = Env::default(); - let (vault_addr, vault_client, token_addr, _settlement_addr, owner) = setup_reentrancy_test(&env); - + let (vault_addr, vault_client, token_addr, _settlement_addr, owner) = + setup_reentrancy_test(&env); + let token_mock = MaliciousTokenClient::new(&env, &token_addr); // Withdraw calls token.transfer. We attempt to call deduct() during withdraw's transfer. token_mock.set_token_attack_config(&vault_addr, &owner, &true); - + let result = vault_client.try_withdraw(&100); - + assert!(result.is_ok(), "Withdraw should succeed"); - assert_eq!(vault_client.balance(), 900, "Balance should only be deducted by withdraw amount"); - + assert_eq!( + vault_client.balance(), + 900, + "Balance should only be deducted by withdraw amount" + ); + let events = env.events().all(); let mut reentry_count = 0; for e in events.iter() { - if e.0 != vault_addr { continue; } + if e.0 != vault_addr { + continue; + } let topics = &e.1; - if topics.len() < 3 { continue; } + if topics.len() < 3 { + continue; + } let rid: Symbol = topics.get(2).unwrap().into_val(&env); if rid == Symbol::new(&env, "reentry_token") { reentry_count += 1; } } - - assert_eq!(reentry_count, 0, "Re-entry during withdraw should not have succeeded"); + + assert_eq!( + reentry_count, 0, + "Re-entry during withdraw should not have succeeded" + ); } diff --git a/contracts/vault/src/test_setter_validation.rs b/contracts/vault/src/test_setter_validation.rs index 8984b9cb..41795d69 100644 --- a/contracts/vault/src/test_setter_validation.rs +++ b/contracts/vault/src/test_setter_validation.rs @@ -1,7 +1,7 @@ extern crate std; +use super::*; use soroban_sdk::testutils::{Address as _, Events as _}; use soroban_sdk::{token, Address, Env, IntoVal, String, Symbol}; -use super::*; fn create_usdc<'a>(env: &'a Env, admin: &'a Address) -> (Address, token::StellarAssetClient<'a>) { let ca = env.register_stellar_asset_contract_v2(admin.clone()); @@ -28,21 +28,33 @@ fn set_price_offering_id_too_long() { let env = Env::default(); let (_, client, _, admin) = setup(&env); let long_id = "a".repeat((MAX_OFFERING_ID_LEN + 1) as usize); - client.set_price(&admin, &String::from_str(&env, &long_id), &String::from_str(&env, "100")); + client.set_price( + &admin, + &String::from_str(&env, &long_id), + &String::from_str(&env, "100"), + ); } #[test] fn set_price_zero_price() { let env = Env::default(); let (_, client, _, admin) = setup(&env); - client.set_price(&admin, &String::from_str(&env, "off1"), &String::from_str(&env, "0")); + client.set_price( + &admin, + &String::from_str(&env, "off1"), + &String::from_str(&env, "0"), + ); } #[test] fn set_price_successful() { let env = Env::default(); let (_, client, _, admin) = setup(&env); - client.set_price(&admin, &String::from_str(&env, "off1"), &String::from_str(&env, "1000")); + client.set_price( + &admin, + &String::from_str(&env, "off1"), + &String::from_str(&env, "1000"), + ); // Verify readback let stored = client.get_price(&String::from_str(&env, "off1")); assert_eq!(stored, Some(String::from_str(&env, "1000"))); diff --git a/scripts/check_event_schema_coverage.sh b/scripts/check_event_schema_coverage.sh new file mode 100755 index 00000000..b6885a08 --- /dev/null +++ b/scripts/check_event_schema_coverage.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Verify that every env.events().publish( site in the three contract crates +# has a matching topic entry in EVENT_SCHEMA.md. +# +# Usage: +# ./scripts/check_event_schema_coverage.sh +# SCHEMA_FILE=docs/MY_SCHEMA.md ./scripts/check_event_schema_coverage.sh +# +# Exit codes: +# 0 all topics are documented +# 1 one or more topics are missing from EVENT_SCHEMA.md + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCHEMA_FILE="${SCHEMA_FILE:-"${REPO_ROOT}/EVENT_SCHEMA.md"}" +CONTRACTS_DIR="${REPO_ROOT}/contracts" + +if [[ ! -t 1 ]]; then + RED='' + GREEN='' + YELLOW='' + NC='' +fi + +if [[ ! -f "${SCHEMA_FILE}" ]]; then + echo -e "${RED}ERROR:${NC} schema file not found: ${SCHEMA_FILE}" >&2 + exit 1 +fi + +echo "Checking EVENT_SCHEMA.md coverage" +echo " Schema : ${SCHEMA_FILE}" +echo " Scope : ${CONTRACTS_DIR}/*/src/*.rs (excluding #[cfg(test)] blocks)" +echo "" + +# Remove #[cfg(test)] inline blocks and one-line test module imports. +strip_test_blocks() { + awk ' + BEGIN { inside_test = 0; depth = 0 } + + function handle_test_content(content) { + n = split(content, chars, "") + for (i = 1; i <= n; i++) { + if (chars[i] == "{") depth++ + if (chars[i] == "}") { + depth-- + if (depth <= 0) { inside_test = 0; return } + } + } + } + + /^[[:space:]]*#\[cfg\(test\)\]/ { + if ((getline nextline) <= 0) next + if (nextline ~ /^[[:space:]]*mod[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]*;/) next + inside_test = 1 + depth = 0 + handle_test_content(nextline) + next + } + + inside_test { + handle_test_content($0) + next + } + + { print } + ' "$1" +} + +# Extract the event topic (first Symbol::new string) from each publish site. +# Handles both env.events().publish( and env.events()\n .publish( forms. +collect_topics() { + local lib="$1" + + strip_test_blocks "${lib}" \ + | perl -0777 -pe 's/env\.events\(\)\s*\n\s*\.publish\(/env.events().publish(/g' \ + | perl -0777 -ne ' + while (/\.publish\s*\(/g) { + my $chunk = substr($_, pos(), 500); + if ($chunk =~ /Symbol::new\(&env,\s*"([^"]+)"/) { + print "$1\n"; + } + } + ' \ + | sort -u +} + +declare -A ALL_TOPICS=() +topic_count=0 + +while IFS= read -r -d '' rs_file; do + case "$(basename "${rs_file}")" in + test.rs | test_*.rs) continue ;; + esac + + crate=$(basename "$(dirname "$(dirname "${rs_file}")")") + + while IFS= read -r topic; do + [[ -z "${topic}" ]] && continue + ALL_TOPICS["${topic}"]="${crate}" + topic_count=$((topic_count + 1)) + done < <(collect_topics "${rs_file}") +done < <(find "${CONTRACTS_DIR}" -path '*/src/*.rs' -print0 | sort -z) + +if [[ ${topic_count} -eq 0 ]]; then + echo -e "${YELLOW}WARN:${NC} no publish topics found under ${CONTRACTS_DIR}" >&2 + exit 0 +fi + +echo "Found ${#ALL_TOPICS[@]} unique topic(s) across all crates:" +for t in $(printf '%s\n' "${!ALL_TOPICS[@]}" | sort); do + echo " [${ALL_TOPICS[$t]}] ${t}" +done +echo "" + +# A topic is considered documented if the schema file contains any of: +# ### `topic_name` (section header) +# `topic_name` (inline backtick reference) +# "topic_name" (double-quoted, e.g. in JSON examples) + +missing=() + +for topic in $(printf '%s\n' "${!ALL_TOPICS[@]}" | sort); do + if grep -qE "(###[[:space:]]+\`${topic}\`|\`${topic}\`|\"${topic}\")" "${SCHEMA_FILE}"; then + echo -e " ${GREEN}OK${NC} ${topic}" + else + echo -e " ${RED}MISSING${NC} ${topic} (crate: ${ALL_TOPICS[$topic]})" + missing+=("${topic}") + fi +done + +echo "" + +if [[ ${#missing[@]} -gt 0 ]]; then + echo -e "${RED}FAIL:${NC} ${#missing[@]} topic(s) not documented in EVENT_SCHEMA.md:" + for t in "${missing[@]}"; do + echo " - ${t} (crate: ${ALL_TOPICS[$t]})" + done + echo "" + echo " Add a section to EVENT_SCHEMA.md for each missing topic, then re-run." + exit 1 +fi + +echo -e "${GREEN}OK:${NC} all ${#ALL_TOPICS[@]} topic(s) are documented in EVENT_SCHEMA.md."