From d34ca7975872ebc18184117899589c3e2334b934 Mon Sep 17 00:00:00 2001 From: Jagadeesh Date: Wed, 29 Jul 2026 02:43:09 +0530 Subject: [PATCH 1/2] fix(test): correct event-count assertion in genuine-transfer test env.events().all() reflects only the most recent contract invocation, not a running total across separate calls (consistent with every other event test in this file). The test compared post-transfer event count against a pre-transfer count captured after a *different* call (set_service_metadata), expecting accumulation that never happens, so it failed on every run regardless of contract behavior. Fixed to assert directly on this call's own event count. --- contracts/escrow/src/test.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index acc83e0..9bd774d 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1101,16 +1101,21 @@ fn test_transfer_service_ownership_genuine_transfer_emits_event() { let new_owner = Address::generate(&env); let desc = String::from_str(&env, "inference service"); client.set_service_metadata(&svc, &desc, &owner); - // Capture event count before transfer. - let events_before = env.events().all(); - let count_before = events_before.len(); // Perform genuine transfer. client.transfer_service_ownership(&owner, &svc, &new_owner); - // Exactly one new event (owner_chg). + // env.events().all() reflects only the most recent contract invocation + // (confirmed by every other event test in this file, e.g. + // assert_usage_event_count re-checking a count of 1 after each of + // several sequential record_usage calls), not a running total since + // the start of the test. The prior `set_service_metadata` call's + // `meta_set` event is therefore not present here — only this + // transfer's own event is. Comparing against a pre-call count of + // events from a *different* invocation was the bug; checking this + // call's own event count directly is the fix. let events_after = env.events().all(); assert_eq!( events_after.len(), - count_before + 1, + 1, "genuine transfer must emit exactly one event" ); let (_addr, topics, data) = events_after.last().unwrap(); From a344813c37d93011e56b9ccc5ba297d6bdd563c2 Mon Sep 17 00:00:00 2001 From: Jagadeesh Date: Wed, 29 Jul 2026 02:23:33 +0530 Subject: [PATCH 2/2] docs(events): document the event model and invariants --- README.md | 1 + docs/escrow/events.md | 113 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 docs/escrow/events.md diff --git a/README.md b/README.md index f2ad5cc..e97a6da 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,7 @@ agentpay-contracts/ - [Escrow: Build, Test, and Deploy Guide](docs/escrow/build-deploy.md) — build the release WASM, run the test suite, and deploy to testnet with the Stellar/Soroban CLI. - [Escrow: Schema Versioning & Migration](docs/escrow/migrations.md) — the difference between `version()` and `SchemaVersion`, the double-run guard, and the migration runbook. - [Escrow: Storage DataKey Reference](docs/escrow/storage.md) — complete map of every `DataKey` variant: stored value type, default when absent, which entrypoints write it, and whether it is drained by `settle`. Explains why everything uses `persistent()` and the per-pair vs per-agent vs singleton key cardinality. +- [Escrow: Events Reference](docs/escrow/events.md) — every event topic, its payload shape, and which entrypoint emits it, including the shared `cfg_set`/`dispute` topics and the four setters that currently emit no event at all. ## CI/CD diff --git a/docs/escrow/events.md b/docs/escrow/events.md new file mode 100644 index 0000000..3915662 --- /dev/null +++ b/docs/escrow/events.md @@ -0,0 +1,113 @@ +# Escrow Contract — Events Reference + +This document catalogs every event topic published by the escrow contract +(`contracts/escrow/src/lib.rs`), its payload shape, and when it fires. All +topics are `symbol_short!` values, which are limited to 9 ASCII characters; +the macro fails to compile on any longer literal, so the limit is enforced +at build time rather than by a runtime test. + +--- + +## Event catalog + +| Topic | Payload | Emitted by | Fires when | +|---|---|---|---| +| `cred_deb` | `(agent, debit, new_balance)` | `debit_agent_credit` (internal, called from `record_usage` and `settle`) | An agent's prepaid credit balance is drawn down to cover a bill | +| `usage` | `(agent, service_id, requests, total)` | `record_usage` | Every successful usage record, one event per call | +| `usage_hi` | `(agent, service_id, total)` | `record_usage` | The per-pair usage total crosses `UsageAlertThreshold` from below to at/above, edge-triggered (see Gaps below — currently unreachable) | +| `usage_dec` | `(agent, service_id, amount, new_total)` | `decrement_usage` | Admin manually decrements a usage counter | +| `price_set` | `(service_id, price_stroops)` | `set_service_price` | Flat per-request price is set for a service | +| `price_rmv` | `service_id` | `remove_service_price` | Flat price is removed for a service | +| `tiers_set` | `service_id` | `set_price_tiers` | A volume-discount tier schedule is set for a service | +| `tiers_rm` | `service_id` | `remove_price_tiers` | A tier schedule is removed, reverting to flat pricing | +| `settled` | `(agent, service_id, requests, billed)` | `settle`, `settle_all` (once per service settled) | A `(agent, service_id)` pair is drained and billed | +| `settl_all` | `(agent, count, total_billed)` | `settle_all` | Batch summary after a full sweep — `count` includes zero-billed services | +| `bnd_set` | `(min_stroops, max_stroops)` | `set_price_bounds` | Global min/max service price bounds are changed | +| `cfg_set` | `(tag, value)` — see [Config tags](#config-tags) | `set_allowlist_enabled`, `set_min_requests_per_call`, `set_max_requests_per_call`, `set_max_requests_per_window`, `set_rate_window_seconds`, `set_require_service_registration` | A scalar admin config value is changed | +| `rate_rst` | `agent` | `reset_rate_window` | An agent's rate-limit window is manually cleared | +| `svc_add` | `service_id` | `register_service` | A service is registered (no metadata) | +| `svc_reg` | `(service_id, owner)` | `register_service_with_metadata` | A service is registered with metadata in one call | +| `svc_rm` | `service_id` | `unregister_service` | A service is unregistered | +| `svc_dis` | `(service_id, disabled)` | `set_service_disabled` | A service's disabled flag is toggled either way | +| `meta_set` | `(service_id, owner)` | `set_service_metadata` | Service metadata is set or overwritten | +| `meta_clr` | `service_id` | `clear_service_metadata` | Service metadata is cleared | +| `owner_chg` | `(service_id, old_owner, new_owner)` | `transfer_service_ownership` | A service's metadata owner changes | +| `admin_chg` | `(old_admin, new_admin)` | `accept_admin_transfer` | Step 2 of admin handover completes | +| `paused` | `bool` | `pause` (`true`), `unpause` (`false`) | The pause switch is toggled either way | +| `dispute` | `(action, agent, service_id[, refund_requests])` — see [Dispute actions](#dispute-actions) | `open_dispute`, `resolve_dispute`, `refund_batch` | A dispute is opened or resolved | + +### Config tags + +`cfg_set` is a shared topic for single-scalar admin config changes. The first +payload element is a fixed `symbol_short!` tag identifying which config value +changed; the second is the new value: + +| Tag | Set by | Value type | +|---|---|---| +| `allowlist` | `set_allowlist_enabled` | `bool` | +| `min_call` | `set_min_requests_per_call` | `u32` | +| `max_call` | `set_max_requests_per_call` | `u32` | +| `max_win` | `set_max_requests_per_window` | `u32` | +| `win_sec` | `set_rate_window_seconds` | `u64` | +| `req_reg` | `set_require_service_registration` | `bool` | + +### Dispute actions + +The `dispute` topic is shared by the open and resolve paths; the action is +carried in the payload rather than the topic, keeping the topic count small +and giving indexers a single subscription for the whole dispute lifecycle: + +| Action | Payload after `action` | Emitted by | +|---|---|---| +| `open` | `(agent, service_id)` | `open_dispute` | +| `resolve` | `(agent, service_id, refund_requests)` | `resolve_dispute`, `refund_batch` (once per resolved service) | + +`refund_requests` is the amount subtracted from the usage counter — `0` for +`resolve_dispute` when the admin declines a refund, or the full prior usage +for `refund_batch`, which always zeroes the counter. + +--- + +## Invariants + +- **No topic collisions.** Every entrypoint that mutates admin-visible state + publishes under a distinct topic, except the intentionally-shared `cfg_set` + (disambiguated by its `tag` field) and `dispute` (disambiguated by its + `action` field). No two *unrelated* state changes share a topic. +- **9-character limit.** `symbol_short!` topics must be ≤ 9 ASCII characters, + enforced by a compile error on any longer literal — every topic in the + table above already builds, so all satisfy the limit. +- **Events follow the storage write, not precede it.** Every `publish` call + in `lib.rs` occurs after the corresponding `env.storage()` write, so a + listener that reacts to an event by reading contract state will always see + the state the event describes. +- **Events do not gate control flow.** Publishing is a side effect with no + return value consulted by the contract; a hypothetical event-delivery + failure cannot change whether a call succeeds or panics. + +## Gaps (storage writes with no matching event) + +Cross-referencing every `env.storage().persistent().set` / `.remove` call +against the table above surfaces four admin-facing state changes that do +**not** publish a dedicated event, unlike every other setter in the +contract: + +- `set_agent_allowed` — writes `AgentAllowed(agent)`, no event. +- `set_agent_blocked` — writes `AgentBlocked(agent)`, no event. +- `propose_admin_transfer` — writes `PendingAdmin`, no event. +- `cancel_admin_transfer` — removes `PendingAdmin`, no event. + +Additionally, `usage_hi` (documented above) can never fire on the current +contract because `UsageAlertThreshold` has no setter — see +[`docs/escrow/storage.md`](storage.md) for that gap. + +These are documented here as known gaps for indexer authors, not fixed by +this PR — closing them is separate, scoped work. + +## Testing + +Events are asserted with `env.events().all()` in `test.rs`, typically via +helpers like `assert_latest_usage_event` and `assert_latest_pause_event` +that decode the most recent event and check topic + payload. Boundary and +payload-correctness tests capture events immediately after the mutating +call, per the project's contribution guidelines.