From 25242a68ca0645ae1c923f80eb5f99f1e2c9a0e6 Mon Sep 17 00:00:00 2001 From: okekefrancis112 Date: Fri, 24 Apr 2026 11:47:41 +0100 Subject: [PATCH 1/2] chore(contracts): make coverage.sh fail with actionable hints when tarpaulin missing --- scripts/coverage.sh | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 8c140c28..420ed90d 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -14,22 +14,55 @@ set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' +YELLOW='\033[0;33m' CYAN='\033[0;36m' NC='\033[0m' +tarpaulin_missing_hint() { + local reason="$1" + echo -e "${RED}ERROR:${NC} ${reason}" >&2 + echo "" >&2 + echo -e "${YELLOW}How to fix:${NC}" >&2 + echo " 1. Install cargo-tarpaulin (Linux/macOS x86_64 + Linux aarch64 supported):" >&2 + echo " cargo install cargo-tarpaulin --locked" >&2 + echo "" >&2 + echo " 2. Verify the install resolves on your PATH:" >&2 + echo " command -v cargo-tarpaulin # should print a path under ~/.cargo/bin" >&2 + echo " cargo tarpaulin --version # should print a version banner" >&2 + echo "" >&2 + echo " 3. If 'cargo install' fails on macOS with linker errors, make sure the" >&2 + echo " Xcode command-line tools are installed:" >&2 + echo " xcode-select --install" >&2 + echo "" >&2 + echo " 4. On unsupported platforms (e.g. Apple Silicon pre-0.27, Windows)," >&2 + echo " run coverage in the project's Linux CI instead:" >&2 + echo " gh workflow run coverage.yml" >&2 + echo "" >&2 + echo -e "${CYAN}Docs:${NC} https://github.com/xd009642/tarpaulin#installation" >&2 + echo -e "${CYAN}Config:${NC} see tarpaulin.toml at the repo root for the coverage profile." >&2 + exit 127 +} + if ! command -v cargo-tarpaulin &>/dev/null; then - echo -e "${RED}ERROR:${NC} cargo-tarpaulin is not installed." - echo " Install it with: cargo install cargo-tarpaulin" - exit 1 + tarpaulin_missing_hint "cargo-tarpaulin binary not found on PATH." +fi + +# The binary may exist but fail to execute as a cargo subcommand (e.g. built +# against an incompatible rustc). Probe it so the hints trigger there too. +if ! cargo tarpaulin --version &>/dev/null; then + tarpaulin_missing_hint "cargo-tarpaulin is installed but 'cargo tarpaulin --version' failed." fi TARPAULIN_VERSION=$(cargo tarpaulin --version 2>&1 || true) echo -e " ${CYAN}[INFO]${NC} Using ${TARPAULIN_VERSION}" echo -e " ${CYAN}[INFO]${NC} Running tests with coverage instrumentation..." +# Disable errexit around tarpaulin so the threshold-failure branch below is +# reachable; tarpaulin exits non-zero when coverage < fail-under. +set +e cargo tarpaulin - STATUS=$? +set -e # Upload HTML report as a CI artifact when running in GitHub Actions. if [ -n "${GITHUB_ACTIONS:-}" ] && [ -f "coverage/tarpaulin-report.html" ]; then From 79c3b2a8e99b6fc971e8e5440508b28377264861 Mon Sep 17 00:00:00 2001 From: okekefrancis112 Date: Fri, 24 Apr 2026 12:00:47 +0100 Subject: [PATCH 2/2] chore(contracts): add event schema docs for and --- EVENT_SCHEMA.md | 104 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index a5cf398d..1ef61efa 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -406,8 +406,6 @@ Emitted when the nominee accepts the admin role. | topic 2 | topics | Address| new admin | | data | data | () | empty | -> **Note:** `balance_credited` is never emitted when `to_pool = true`. Indexers tracking developer earnings should subscribe to this event; indexers tracking total protocol revenue should subscribe to `payment_received` with `to_pool = true`. - --- ## Contract: `callora-revenue-pool` (v0.0.1) @@ -574,20 +572,50 @@ three payments, three `batch_distribute` events are emitted in order. ## Contract: `callora-settlement` (v0.1.0) +Source: [`contracts/settlement/src/lib.rs`](contracts/settlement/src/lib.rs). + +**Amount units.** All `amount` / `new_balance` fields are `i128` in USDC +micro-units (7-decimal scaled integers), matching the Stellar USDC contract. +Legacy text elsewhere in this document calls this "stroops" — same scalar type, +same integer semantics; the settlement contract never handles native XLM. + +**Data payload encoding.** The `data` column describes the Soroban +`contracttype` struct published by `env.events().publish(...)`. On the wire +each struct is a single XDR value whose field names match the Rust struct; +the JSON examples below are the logical field view an indexer sees after +decoding, not a raw array. The struct layouts live in `lib.rs`: +`PaymentReceivedEvent` and `BalanceCreditedEvent`. + +**Emit atomicity and ordering.** Both events originate inside one +`receive_payment()` call, so they share the same transaction and ledger +sequence. When `to_pool = false`, `payment_received` is always emitted +**before** `balance_credited`. If any guard panics (see "Panic modes" below) +no events are emitted and state is rolled back. + +**Panic modes (no events emitted).** +- Caller is not the registered vault or admin (`require_authorized_caller`). +- `amount <= 0` — `"amount must be positive"`. +- `to_pool = true` with `developer = Some(_)` — `"developer address must be None when to_pool=true"`. +- `to_pool = false` with `developer = None` — `"developer address required when to_pool=false"`. +- Arithmetic overflow on pool or developer balance — `"pool balance overflow"` / `"developer balance overflow"`. + +--- + ### `payment_received` -Emitted by `receive_payment()` for every inbound payment regardless of routing. +Emitted by `receive_payment()` for every successful inbound payment, +regardless of routing. -| Index | Location | Type | Description | -|--------------|----------|------------------|--------------------------------------------------------------------------| -| topic 0 | topics | Symbol | `"payment_received"` | -| topic 1 | topics | Address | `caller` — vault or admin address | -| `from_vault` | data | Address | same as topic 1 | -| `amount` | data | i128 | payment amount in stroops; always > 0 | -| `to_pool` | data | bool | `true` → credited to global pool; `false` → credited to a developer | -| `developer` | data | Option\| `None` when `to_pool=true`; developer address when `to_pool=false` | +| Index | Location | Type | Description | +|--------------|----------|-------------------|-----------------------------------------------------------------------------------| +| topic 0 | topics | Symbol | `"payment_received"` | +| topic 1 | topics | Address | `caller` — authorized vault or admin address (same as `from_vault` field) | +| `from_vault` | data | Address | originator of the payment; duplicates topic 1 for indexers that key by data only | +| `amount` | data | i128 | payment amount in USDC micro-units; invariant `amount > 0` | +| `to_pool` | data | bool | `true` → credited to global pool; `false` → credited to an individual developer | +| `developer` | data | Option\ | `None` when `to_pool = true`; `Some(address)` when `to_pool = false` | -**Example — global pool credit:** +**Example — global pool credit (`to_pool = true`):** ```json { @@ -601,7 +629,10 @@ Emitted by `receive_payment()` for every inbound payment regardless of routing. } ``` -**Example — developer credit:** +Side effect: `GlobalPool.total_balance += amount` and +`GlobalPool.last_updated = env.ledger().timestamp()`. + +**Example — developer credit (`to_pool = false`):** ```json { @@ -615,19 +646,32 @@ Emitted by `receive_payment()` for every inbound payment regardless of routing. } ``` +Side effect: developer balance map entry for `GDEV...` is incremented by +`amount`. `GlobalPool.last_updated` is **not** touched on developer credits. + +**Indexer guidance.** +- `topic 1` is always the caller; filter on it to isolate payments from a + specific vault or admin. +- `developer` is the only field that distinguishes pool vs. developer credits + in the data payload; the `to_pool` boolean is redundant but stable and + cheaper to filter on. +- A `payment_received` with `to_pool = false` is always paired with exactly + one `balance_credited` event in the same transaction. + --- ### `balance_credited` -Emitted by `receive_payment()` **only** when `to_pool = false`. +Emitted by `receive_payment()` **only** when `to_pool = false`, immediately +after the matching `payment_received` event. -| Index | Location | Type | Description | -|---------------|----------|---------|--------------------------------------------------| -| topic 0 | topics | Symbol | `"balance_credited"` | -| topic 1 | topics | Address | `developer` — address whose balance was updated | -| `developer` | data | Address | same as topic 1 | -| `amount` | data | i128 | amount credited (stroops) | -| `new_balance` | data | i128 | developer's cumulative balance after this credit | +| Index | Location | Type | Description | +|---------------|----------|---------|-----------------------------------------------------------------| +| topic 0 | topics | Symbol | `"balance_credited"` | +| topic 1 | topics | Address | `developer` — address whose balance was updated | +| `developer` | data | Address | same as topic 1; duplicated for data-only indexers | +| `amount` | data | i128 | amount credited to the developer in USDC micro-units | +| `new_balance` | data | i128 | developer's cumulative balance after this credit (post-state) | ```json { @@ -640,9 +684,21 @@ Emitted by `receive_payment()` **only** when `to_pool = false`. } ``` -> `balance_credited` is never emitted when `to_pool = true`. Indexers tracking -> developer earnings should subscribe to this event; indexers tracking total -> protocol revenue should subscribe to `payment_received` with `to_pool = true`. +**Invariants.** +- `new_balance = prior_balance + amount`, checked for `i128` overflow; overflow + panics and rolls back both events. +- `new_balance` equals `CalloraSettlement::get_developer_balance(developer)` + immediately after the emitting transaction. +- `amount` in `balance_credited` equals `amount` in the paired + `payment_received`. + +**Indexer guidance.** +- Track developer earnings by subscribing to `balance_credited` — it already + carries the post-credit balance, so no separate read is required. +- Track total protocol inflow by summing `payment_received.amount` across + both routing modes, or filter `to_pool = true` for pool-only inflow. +- `balance_credited` is **never** emitted when `to_pool = true`; do not wait + for one on pool credits. ---