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."