From da9e0a737fcd5d91700f5d50303d1716ac62ab31 Mon Sep 17 00:00:00 2001 From: temycodes Date: Thu, 25 Jun 2026 15:00:47 +0100 Subject: [PATCH 1/3] ci: gate event schema coverage against publish-site count --- .github/workflows/ci.yml | 10 ++ EVENT_SCHEMA.md | 227 +++++++++++++++++++++++++ scripts/check_event_schema_coverage.sh | 117 +++++++++++++ 3 files changed, 354 insertions(+) create mode 100755 scripts/check_event_schema_coverage.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2610ac5..25f39f43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,13 @@ 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/scripts/check_event_schema_coverage.sh b/scripts/check_event_schema_coverage.sh new file mode 100755 index 00000000..e67a82ea --- /dev/null +++ b/scripts/check_event_schema_coverage.sh @@ -0,0 +1,117 @@ +#!/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 " Crates : ${CONTRACTS_DIR}/*/src/lib.rs" +echo "" + +# Strip #[cfg(test)] blocks from a lib.rs file, then extract all Symbol::new +# topic strings that appear in publish calls. Test blocks are excluded because +# they may reference topic names that are not real contract events. +collect_topics() { + local lib="$1" + + awk ' + /^[[:space:]]*#\[cfg\(test\)\]/ { inside_test = 1; depth = 0; next } + inside_test { + n = split($0, chars, "") + for (i = 1; i <= n; i++) { + if (chars[i] == "{") depth++ + if (chars[i] == "}") { + depth-- + if (depth <= 0) { inside_test = 0; next } + } + } + next + } + { print } + ' "${lib}" \ + | grep -oP 'Symbol::new\(&env,\s*"\K[^"]+' \ + | sort -u +} + +declare -A ALL_TOPICS + +for lib in "${CONTRACTS_DIR}"/*/src/lib.rs; do + [[ -f "${lib}" ]] || continue + crate=$(basename "$(dirname "$(dirname "${lib}")")") + + while IFS= read -r topic; do + [[ -z "${topic}" ]] && continue + ALL_TOPICS["${topic}"]="${crate}" + done < <(collect_topics "${lib}") +done + +if [[ ${#ALL_TOPICS[@]} -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." \ No newline at end of file From 78f635c5e1ee9ccab094cb159b3a03c0ed456313 Mon Sep 17 00:00:00 2001 From: temycodes Date: Thu, 25 Jun 2026 15:16:17 +0100 Subject: [PATCH 2/3] fix(ci): correct event-schema gate job wiring and publish extraction The coverage job was nested at the wrong YAML level and would never run. Tighten the script to extract topics from publish sites across contract src. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 +- scripts/check_event_schema_coverage.sh | 76 ++++++++++++++++++-------- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f39f43..043d5894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,8 @@ jobs: run: | chmod +x scripts/check-wasm-size.sh ./scripts/check-wasm-size.sh -event-schema-coverage: + + event-schema-coverage: name: Event schema coverage gate runs-on: ubuntu-latest steps: diff --git a/scripts/check_event_schema_coverage.sh b/scripts/check_event_schema_coverage.sh index e67a82ea..b6885a08 100755 --- a/scripts/check_event_schema_coverage.sh +++ b/scripts/check_event_schema_coverage.sh @@ -35,47 +35,79 @@ fi echo "Checking EVENT_SCHEMA.md coverage" echo " Schema : ${SCHEMA_FILE}" -echo " Crates : ${CONTRACTS_DIR}/*/src/lib.rs" +echo " Scope : ${CONTRACTS_DIR}/*/src/*.rs (excluding #[cfg(test)] blocks)" echo "" -# Strip #[cfg(test)] blocks from a lib.rs file, then extract all Symbol::new -# topic strings that appear in publish calls. Test blocks are excluded because -# they may reference topic names that are not real contract events. -collect_topics() { - local lib="$1" - +# Remove #[cfg(test)] inline blocks and one-line test module imports. +strip_test_blocks() { awk ' - /^[[:space:]]*#\[cfg\(test\)\]/ { inside_test = 1; depth = 0; next } - inside_test { - n = split($0, chars, "") + 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; next } + 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 } - ' "${lib}" \ - | grep -oP 'Symbol::new\(&env,\s*"\K[^"]+' \ - | sort -u + ' "$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 +declare -A ALL_TOPICS=() +topic_count=0 -for lib in "${CONTRACTS_DIR}"/*/src/lib.rs; do - [[ -f "${lib}" ]] || continue - crate=$(basename "$(dirname "$(dirname "${lib}")")") +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}" - done < <(collect_topics "${lib}") -done + topic_count=$((topic_count + 1)) + done < <(collect_topics "${rs_file}") +done < <(find "${CONTRACTS_DIR}" -path '*/src/*.rs' -print0 | sort -z) -if [[ ${#ALL_TOPICS[@]} -eq 0 ]]; then +if [[ ${topic_count} -eq 0 ]]; then echo -e "${YELLOW}WARN:${NC} no publish topics found under ${CONTRACTS_DIR}" >&2 exit 0 fi @@ -114,4 +146,4 @@ if [[ ${#missing[@]} -gt 0 ]]; then exit 1 fi -echo -e "${GREEN}OK:${NC} all ${#ALL_TOPICS[@]} topic(s) are documented in EVENT_SCHEMA.md." \ No newline at end of file +echo -e "${GREEN}OK:${NC} all ${#ALL_TOPICS[@]} topic(s) are documented in EVENT_SCHEMA.md." From 7824dbe5b6c9131a83e4e8b077d3040e5d891558 Mon Sep 17 00:00:00 2001 From: temycodes Date: Thu, 25 Jun 2026 15:26:34 +0100 Subject: [PATCH 3/3] fix: restore revenue-pool pause constants and apply rustfmt CI was failing on missing PAUSED_KEY/ERR_PAUSED in revenue_pool and cargo fmt --check drift across the three contract crates. Co-authored-by: Cursor --- contracts/revenue_pool/src/lib.rs | 6 +- contracts/settlement/src/lib.rs | 56 ++-- contracts/settlement/src/test.rs | 39 ++- contracts/vault/src/lib.rs | 77 +++-- contracts/vault/src/test.rs | 43 ++- contracts/vault/src/test_idempotency.rs | 25 +- contracts/vault/src/test_reentrancy.rs | 284 ++++++++++++------ contracts/vault/src/test_setter_validation.rs | 20 +- 8 files changed, 371 insertions(+), 179 deletions(-) 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")));