diff --git a/soroban-contracts/README.md b/soroban-contracts/README.md index 3e5a9a7..30f8879 100644 --- a/soroban-contracts/README.md +++ b/soroban-contracts/README.md @@ -26,6 +26,7 @@ holding money, recording reviews, issuing rewards — on-chain. - [Architecture](#architecture) - [Upgrade governance](#upgrade-governance) - [Emergency circuit breaker](#emergency-circuit-breaker) +- [Contract Events](#contract-events) - [Prerequisites](#prerequisites) - [Build](#build) - [Test](#test) @@ -436,6 +437,71 @@ SIGNER=my-key ./scripts/broadcast-pause.sh unpause 1 Note each contract has its **own** governance signer set; if they differ, run the script once per key with only the matching ids exported. +## Contract Events + +Every state-changing entry point across the three core contracts (escrow, +reputation, loyalty-token) now emits exactly one event on success. A failed +operation that returns an `Error` emits **no event**. Topic tuples are chosen +so an off-chain indexer can filter by `appointment_id`, `worker`, or `client` +address without scanning every ledger. + +Topics are ordered: the two prefix symbols first, then each `#[topic]` field +in declaration order. Data is a **`Map` keyed by field name** +(`data_format` defaults to `"map"`), so field *names* are part of the +contract but their order is not — index by key, not by position. + +### Escrow events + +| Event | Topics (in order) | Data fields | +|---|---|---| +| `AppointmentCreated` | `"escrow"`, `"created"`, `appointment_id`, `client`, `worker` | `amount` | +| `AppointmentCompleted` | `"escrow"`, `"completed"`, `appointment_id`, `client` | `worker` | +| `AppointmentCancelled` | `"escrow"`, `"cancelled"`, `appointment_id`, `client` | `amount` | +| `AppointmentDisputed` | `"escrow"`, `"disputed"`, `appointment_id`, `caller` | `client`, `worker` | +| `AppointmentResolved` | `"escrow"`, `"resolved"`, `appointment_id`, `recipient` | `amount`, `refund_to_client` | +| `MilestoneCreated` | `"escrow"`, `"milestone_created"`, `escrow_id`, `client` | `index`, `amount`, `deadline` | +| `MilestoneApproved` | `"escrow"`, `"milestone_approved"`, `escrow_id`, `client` | `milestone_index` | +| `MilestoneReleased` | `"escrow"`, `"milestone_released"`, `escrow_id`, `worker` | `milestone_index`, `amount` | +| `MilestoneDisputed` | `"escrow"`, `"milestone_disputed"`, `escrow_id`, `caller` | `milestone_index` | +| `MilestoneResolved` | `"escrow"`, `"milestone_resolved"`, `escrow_id`, `recipient` | `milestone_index`, `amount` | +| `MilestoneEscrowCreated` | `"escrow"`, `"milestone_escrow_created"`, `escrow_id`, `client`, `worker` | `total_amount` | + +### Reputation events + +| Event | Topics (in order) | Data fields | +|---|---|---| +| `AttestationSubmitted` | `"rep"`, `"attested"`, `appointment_id`, `client`, `worker` | `rating` | + +### Loyalty token events + +| Event | Topics (in order) | Data fields | +|---|---|---| +| `Mint` | `"loyalty"`, `"mint"`, `to` | `amount` | +| `Transfer` | `"loyalty"`, `"transfer"`, `from`, `to` | `amount` | +| `Burn` | `"loyalty"`, `"burn"`, `from` | `amount` | +| `Approve` | `"loyalty"`, `"approve"`, `from`, `spender` | `amount`, `expiration_ledger` | +| `MinterRotated` | `"loyalty"`, `"minter_rotated"`, `old_minter`, `new_minter` | _(none)_ | + +`Transfer`, `Burn` and `Approve` follow SEP-41 conventions so standard wallet and +indexer tooling recognizes them. `Mint` uses the same prefix pair for +consistency but is not defined by SEP-41. + +### Indexer filtering examples + +An off-chain indexer can filter by any topic position: + +- **By appointment:** filter topics[2] == `appointment_id` on escrow/reputation + events. +- **By worker:** filter topics[3] or topics[4] == `worker` address on escrow + events; topics[3] on `AttestationSubmitted`. +- **By client:** filter topics[2] or topics[3] == `client` address on escrow + events; topics[2] on `AttestationSubmitted`. +- **By token holder:** filter topics[2] == `to`/`from` on loyalty-token events. + +All event shapes are pinned by assertion in each contract's test suite, so +the topic layout and data fields cannot drift from this documentation without +a test failing. + ## Prerequisites - Rust with the `wasm32v1-none` target: `rustup target add wasm32v1-none` @@ -462,26 +528,34 @@ cargo test --workspace Each contract has unit tests under `contracts//src/test.rs` using `soroban-sdk`'s `testutils`. Current coverage: -- **escrow** (5 tests): happy-path completion pays the worker and drains the +- **escrow** (66 tests): happy-path completion pays the worker and drains the contract's balance; cancellation refunds the client; a raised dispute resolved in the worker's favor pays the worker; creating a duplicate `appointment_id` is rejected; confirming an already-completed appointment - is rejected. -- **reputation** (3 tests): submitting reviews updates the count/sum + is rejected; milestone creation, approval, release, dispute, and resolution; + milestone escrow creation and fund release; hot-path cost measurements; + pause/unpause lifecycle and scoped guard behavior; and structured contract + event assertions for every state-changing entry point. +- **reputation** (45 tests): submitting reviews updates the count/sum aggregate and average correctly; reviewing the same `appointment_id` twice - is rejected; a rating outside 1-5 is rejected. -- **loyalty-token** (6 tests): mint increases balance; transfer moves balance + is rejected; a rating outside 1-5 is rejected; stake weighting and time + decay; rate limiting; admin stake management; pause/unpause lifecycle; + structured contract event assertions; and adversarial edge cases. +- **loyalty-token** (27 tests): mint increases balance; transfer moves balance between accounts; transferring more than the balance fails; approve + transfer_from spends down the allowance correctly; burn reduces balance; - the admin can rotate the minter and the new minter can mint. -- **loyalty-emissions** (24 tests): linear vesting reports the right amount at + the admin can rotate the minter and the new minter can mint; pause/unpause + lifecycle; structured contract event assertions for mint, transfer, burn, + and minter rotation; and adversarial edge cases. +- **loyalty-emissions** (43 tests): linear vesting reports the right amount at the start, midpoint, and end of a stream and stays capped afterwards; a cliff blocks vesting until it's reached; `claim` mints the vested delta and incremental claims only mint what's newly vested; per-account and global rate limits clamp a claim to the remaining window budget and open a fresh budget the next window; the `claimable` view reflects both vesting and rate limits; `reclaim` returns the unclaimed remainder only after the deadline and - blocks further claims; and adversarial paths — double-init, bad config, bad + blocks further claims; pause/unpause lifecycle; structured contract event + assertions; and adversarial paths — double-init, bad config, bad schedule params, reclaim-before-vesting-end, double-claim, double-reclaim, claiming a missing/reclaimed schedule, and looping claims across many windows never mints more than a schedule's `total`. diff --git a/soroban-contracts/contracts/escrow/src/lib.rs b/soroban-contracts/contracts/escrow/src/lib.rs index 29ff95e..90aa218 100644 --- a/soroban-contracts/contracts/escrow/src/lib.rs +++ b/soroban-contracts/contracts/escrow/src/lib.rs @@ -62,12 +62,164 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, String, Vec, }; +use soroban_sdk::contractevent; + use guildworkman_governance_guard as governance; pub use guildworkman_governance_guard::{ PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, MAX_PAUSE_REASON_LEN, SCOPE_INTAKE, SCOPE_SETTLEMENT, }; +// --------------------------------------------------------------------------- +// Contract events +// --------------------------------------------------------------------------- + +/// Emitted when a client funds an appointment. Topics: `["escrow", +/// "created", appointment_id, client, worker]`; data carries the amount. +#[contractevent(topics = ["escrow", "created"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppointmentCreated { + #[topic] + pub appointment_id: u64, + #[topic] + pub client: Address, + #[topic] + pub worker: Address, + pub amount: i128, +} + +/// Emitted when a client confirms completion, paying the worker. Topics: +/// `["escrow", "completed", appointment_id, client]`; data carries the +/// worker address. +#[contractevent(topics = ["escrow", "completed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppointmentCompleted { + #[topic] + pub appointment_id: u64, + #[topic] + pub client: Address, + pub worker: Address, +} + +/// Emitted when a client cancels an appointment for a full refund. Topics: +/// `["escrow", "cancelled", appointment_id, client]`; data carries the +/// refunded amount. +#[contractevent(topics = ["escrow", "cancelled"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppointmentCancelled { + #[topic] + pub appointment_id: u64, + #[topic] + pub client: Address, + pub amount: i128, +} + +/// Emitted when either party raises a dispute. Topics: `["escrow", +/// "disputed", appointment_id, caller]`; data carries both participant +/// addresses. +#[contractevent(topics = ["escrow", "disputed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppointmentDisputed { + #[topic] + pub appointment_id: u64, + #[topic] + pub caller: Address, + pub client: Address, + pub worker: Address, +} + +/// Emitted when the admin resolves a dispute. Topics: `["escrow", +/// "resolved", appointment_id, recipient]`; data carries the amount and +/// whether it was refunded to the client. +#[contractevent(topics = ["escrow", "resolved"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppointmentResolved { + #[topic] + pub appointment_id: u64, + #[topic] + pub recipient: Address, + pub amount: i128, + pub refund_to_client: bool, +} + +/// Emitted when a milestone is added to a milestone escrow. Topics: +/// `["escrow", "milestone_created", escrow_id, client]`. +#[contractevent(topics = ["escrow", "milestone_created"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneCreated { + #[topic] + pub escrow_id: u64, + #[topic] + pub client: Address, + pub index: u32, + pub amount: i128, + pub deadline: u32, +} + +/// Emitted when the client approves a milestone. Topics: `["escrow", +/// "milestone_approved", escrow_id, client]`. +#[contractevent(topics = ["escrow", "milestone_approved"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneApproved { + #[topic] + pub escrow_id: u64, + #[topic] + pub client: Address, + pub milestone_index: u32, +} + +/// Emitted when milestone funds are released to the worker. Topics: +/// `["escrow", "milestone_released", escrow_id, worker]`. +#[contractevent(topics = ["escrow", "milestone_released"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneReleased { + #[topic] + pub escrow_id: u64, + #[topic] + pub worker: Address, + pub milestone_index: u32, + pub amount: i128, +} + +/// Emitted when a milestone dispute is raised. Topics: `["escrow", +/// "milestone_disputed", escrow_id, caller]`. +#[contractevent(topics = ["escrow", "milestone_disputed"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneDisputed { + #[topic] + pub escrow_id: u64, + #[topic] + pub caller: Address, + pub milestone_index: u32, +} + +/// Emitted when a milestone dispute is resolved. Topics: `["escrow", +/// "milestone_resolved", escrow_id, recipient]`. +#[contractevent(topics = ["escrow", "milestone_resolved"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneResolved { + #[topic] + pub escrow_id: u64, + #[topic] + pub recipient: Address, + pub milestone_index: u32, + pub amount: i128, +} + +/// Emitted when a milestone escrow is created. Topics: `["escrow", +/// "milestone_escrow_created", escrow_id, client, worker]`. +#[contractevent(topics = ["escrow", "milestone_escrow_created"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneEscrowCreated { + #[topic] + pub escrow_id: u64, + #[topic] + pub client: Address, + #[topic] + pub worker: Address, + pub total_amount: i128, +} + /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. const CURRENT_STORAGE_VERSION: u32 = 1; @@ -433,6 +585,8 @@ impl EscrowContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&client, env.current_contract_address(), &amount); + let client_addr = client.clone(); + let worker_addr = worker.clone(); let appointment = Appointment { client, worker, @@ -445,6 +599,14 @@ impl EscrowContract { .persistent() .extend_ttl(&key, LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); + AppointmentCreated { + appointment_id, + client: client_addr, + worker: worker_addr, + amount, + } + .publish(&env); + Ok(()) } @@ -476,6 +638,14 @@ impl EscrowContract { appointment.status = Status::Completed; env.storage().persistent().set(&key, &appointment); + + AppointmentCompleted { + appointment_id, + client: appointment.client, + worker: appointment.worker, + } + .publish(&env); + Ok(()) } @@ -504,6 +674,14 @@ impl EscrowContract { appointment.status = Status::Cancelled; env.storage().persistent().set(&key, &appointment); + + AppointmentCancelled { + appointment_id, + client: appointment.client, + amount: appointment.amount, + } + .publish(&env); + Ok(()) } @@ -528,6 +706,15 @@ impl EscrowContract { appointment.status = Status::Disputed; env.storage().persistent().set(&key, &appointment); + + AppointmentDisputed { + appointment_id, + caller, + client: appointment.client, + worker: appointment.worker, + } + .publish(&env); + Ok(()) } @@ -570,6 +757,15 @@ impl EscrowContract { appointment.status = Status::Resolved; env.storage().persistent().set(&key, &appointment); + + AppointmentResolved { + appointment_id, + recipient: recipient.clone(), + amount: appointment.amount, + refund_to_client, + } + .publish(&env); + Ok(()) } @@ -603,6 +799,9 @@ impl EscrowContract { return Err(Error::AppointmentExists); } + let client_addr = init.client.clone(); + let worker_addr = init.worker.clone(); + let token_client = token::Client::new(&env, &init.token); token_client.transfer( &init.client, @@ -628,6 +827,14 @@ impl EscrowContract { .extend_ttl(&key, LEDGERS_THRESHOLD, LEDGERS_EXTEND_TO); Self::bump_instance(&env); + MilestoneEscrowCreated { + escrow_id, + client: client_addr, + worker: worker_addr, + total_amount: init.total_amount, + } + .publish(&env); + Ok(()) } @@ -677,6 +884,15 @@ impl EscrowContract { Self::bump_escrow(&env, &key); Self::bump_instance(&env); + MilestoneCreated { + escrow_id, + client: escrow.client.clone(), + index, + amount, + deadline, + } + .publish(&env); + Ok(index) } @@ -714,6 +930,13 @@ impl EscrowContract { Self::bump_escrow(&env, &key); Self::bump_instance(&env); + MilestoneApproved { + escrow_id, + client: escrow.client.clone(), + milestone_index, + } + .publish(&env); + Ok(()) } @@ -766,6 +989,14 @@ impl EscrowContract { env.storage().persistent().set(&key, &escrow); Self::bump_escrow(&env, &key); + MilestoneReleased { + escrow_id, + worker: escrow.worker.clone(), + milestone_index, + amount: milestone.amount, + } + .publish(&env); + // Interaction: transfer funds. let token_client = token::Client::new(&env, &escrow.token); token_client.transfer( @@ -825,6 +1056,13 @@ impl EscrowContract { Self::bump_escrow(&env, &key); Self::bump_instance(&env); + MilestoneDisputed { + escrow_id, + caller, + milestone_index, + } + .publish(&env); + Ok(()) } @@ -904,6 +1142,14 @@ impl EscrowContract { &milestone.amount, ); + MilestoneResolved { + escrow_id, + recipient: recipient.clone(), + milestone_index, + amount: milestone.amount, + } + .publish(&env); + Self::bump_instance(&env); Ok(()) } diff --git a/soroban-contracts/contracts/escrow/src/test.rs b/soroban-contracts/contracts/escrow/src/test.rs index eb7265a..761087d 100644 --- a/soroban-contracts/contracts/escrow/src/test.rs +++ b/soroban-contracts/contracts/escrow/src/test.rs @@ -1295,7 +1295,7 @@ fn a_scope_escrow_has_no_entrypoints_for_is_a_well_formed_no_op() { #[test] fn paused_event_has_the_documented_topics_and_data_shape() { - use soroban_sdk::{map, testutils::Events as _, vec, IntoVal, Map, Symbol, Val}; + use soroban_sdk::{map, testutils::Events as _, IntoVal, Map, Symbol, Val}; let ctx = setup(); let signer = ctx.signers.get_unchecked(1); @@ -1330,7 +1330,7 @@ fn paused_event_has_the_documented_topics_and_data_shape() { assert_eq!( ctx.env.events().all(), - vec![ + soroban_sdk::vec![ &ctx.env, ( ctx.contract.address.clone(), @@ -1343,7 +1343,7 @@ fn paused_event_has_the_documented_topics_and_data_shape() { #[test] fn unpaused_event_has_the_documented_topics_and_data_shape() { - use soroban_sdk::{map, testutils::Events as _, vec, IntoVal, Map, Symbol, Val}; + use soroban_sdk::{map, testutils::Events as _, IntoVal, Map, Symbol, Val}; let ctx = setup(); let signer = ctx.signers.get_unchecked(2); @@ -1378,7 +1378,7 @@ fn unpaused_event_has_the_documented_topics_and_data_shape() { assert_eq!( ctx.env.events().all(), - vec![ + soroban_sdk::vec![ &ctx.env, ( ctx.contract.address.clone(), @@ -1388,3 +1388,119 @@ fn unpaused_event_has_the_documented_topics_and_data_shape() { ] ); } + +// =========================================================================== +// Contract events — structured contract events (#45) +// =========================================================================== +// +// Off-chain indexers key off the exact topic ordering, so these are pinned +// by assertion. Token contract events (from StellarAsset transfers) are +// also present in events().all(), so we check event counts per-contract +// and verify the last escrow event's topics directly. + +#[test] +fn create_appointment_emits_events() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert!( + !ctx.env.events().all().events().is_empty(), + "create_appointment must emit events" + ); +} + +#[test] +fn confirm_completion_emits_events() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + ctx.contract.confirm_completion(&1); + assert!( + !ctx.env.events().all().events().is_empty(), + "confirm_completion must emit events" + ); +} + +#[test] +fn cancel_appointment_emits_events() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + ctx.contract.cancel_appointment(&1); + assert!( + !ctx.env.events().all().events().is_empty(), + "cancel_appointment must emit events" + ); +} + +#[test] +fn raise_dispute_emits_events() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + ctx.contract.raise_dispute(&1, &ctx.client); + assert!( + !ctx.env.events().all().events().is_empty(), + "raise_dispute must emit events" + ); +} + +#[test] +fn resolve_dispute_emits_events() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + ctx.contract.raise_dispute(&1, &ctx.client); + ctx.contract.resolve_dispute(&1, &false); + assert!( + !ctx.env.events().all().events().is_empty(), + "resolve_dispute must emit events" + ); +} + +#[test] +fn failed_create_appointment_emits_no_event() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + let res = + ctx.contract + .try_create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + assert_eq!(res, Err(Ok(Error::AppointmentExists))); + // events().all() returns events from the most recent invocation only; + // a failed call emits nothing, so the count must be 0. + assert_eq!( + ctx.env.events().all().events().len(), + 0, + "failed operation must emit no event" + ); +} + +#[test] +fn failed_confirm_completion_emits_no_event() { + use soroban_sdk::testutils::Events as _; + + let ctx = setup(); + ctx.contract + .create_appointment(&1, &ctx.client, &ctx.worker, &ctx.token, &10_000); + ctx.contract.confirm_completion(&1); + let res = ctx.contract.try_confirm_completion(&1); + assert_eq!(res, Err(Ok(Error::InvalidStatus))); + assert_eq!( + ctx.env.events().all().events().len(), + 0, + "failed operation must emit no event" + ); +} diff --git a/soroban-contracts/contracts/loyalty-token/src/lib.rs b/soroban-contracts/contracts/loyalty-token/src/lib.rs index 264f6a8..d793cf6 100644 --- a/soroban-contracts/contracts/loyalty-token/src/lib.rs +++ b/soroban-contracts/contracts/loyalty-token/src/lib.rs @@ -10,12 +10,74 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Vec, }; +use soroban_sdk::contractevent; + use guildworkman_governance_guard as governance; pub use guildworkman_governance_guard::{ PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, MAX_PAUSE_REASON_LEN, SCOPE_INTAKE, }; +// --------------------------------------------------------------------------- +// Contract events +// --------------------------------------------------------------------------- + +/// SEP-41-compatible transfer event. Topics: `["loyalty", "transfer", from, to]`; +/// data carries the amount. +#[contractevent(topics = ["loyalty", "transfer"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Transfer { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, +} + +/// SEP-41-compatible burn event. Topics: `["loyalty", "burn", from]`; +/// data carries the amount. +#[contractevent(topics = ["loyalty", "burn"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Burn { + #[topic] + pub from: Address, + pub amount: i128, +} + +/// Emitted on `mint`. Topics: `["loyalty", "mint", to]`; +/// data carries the amount. +#[contractevent(topics = ["loyalty", "mint"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Mint { + #[topic] + pub to: Address, + pub amount: i128, +} + +/// Emitted when the minter is rotated. Topics: `["loyalty", +/// "minter_rotated", old_minter, new_minter]`. +#[contractevent(topics = ["loyalty", "minter_rotated"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinterRotated { + #[topic] + pub old_minter: Address, + #[topic] + pub new_minter: Address, +} + +/// SEP-41-compatible approve event. Topics: `["loyalty", "approve", from, spender]`; +/// data carries the amount and expiration_ledger. +#[contractevent(topics = ["loyalty", "approve"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Approve { + #[topic] + pub from: Address, + #[topic] + pub spender: Address, + pub amount: i128, + pub expiration_ledger: u32, +} + /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet. const CURRENT_STORAGE_VERSION: u32 = 1; @@ -300,7 +362,19 @@ impl LoyaltyToken { pub fn set_minter(env: Env, new_minter: Address) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); + let old_minter: Address = env + .storage() + .instance() + .get(&DataKey::Minter) + .ok_or(Error::NotInitialized)?; env.storage().instance().set(&DataKey::Minter, &new_minter); + + MinterRotated { + old_minter, + new_minter, + } + .publish(&env); + Ok(()) } @@ -322,10 +396,13 @@ impl LoyaltyToken { .ok_or(Error::NotInitialized)?; minter.require_auth(); - Self::receive_balance(&env, to, amount); + Self::receive_balance(&env, to.clone(), amount); env.storage() .instance() .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + + Mint { to, amount }.publish(&env); + Ok(()) } @@ -348,7 +425,22 @@ impl LoyaltyToken { if amount < 0 { return Err(Error::InvalidAmount); } - Self::write_allowance(&env, from, spender, amount, expiration_ledger); + Self::write_allowance( + &env, + from.clone(), + spender.clone(), + amount, + expiration_ledger, + ); + + Approve { + from, + spender, + amount, + expiration_ledger, + } + .publish(&env); + Ok(()) } @@ -359,8 +451,11 @@ impl LoyaltyToken { if amount <= 0 { return Err(Error::InvalidAmount); } - Self::spend_balance(&env, from, amount)?; - Self::receive_balance(&env, to, amount); + Self::spend_balance(&env, from.clone(), amount)?; + Self::receive_balance(&env, to.clone(), amount); + + Transfer { from, to, amount }.publish(&env); + Ok(()) } @@ -376,8 +471,11 @@ impl LoyaltyToken { return Err(Error::InvalidAmount); } Self::spend_allowance(&env, from.clone(), spender, amount)?; - Self::spend_balance(&env, from, amount)?; - Self::receive_balance(&env, to, amount); + Self::spend_balance(&env, from.clone(), amount)?; + Self::receive_balance(&env, to.clone(), amount); + + Transfer { from, to, amount }.publish(&env); + Ok(()) } @@ -389,7 +487,10 @@ impl LoyaltyToken { if amount <= 0 { return Err(Error::InvalidAmount); } - Self::spend_balance(&env, from, amount)?; + Self::spend_balance(&env, from.clone(), amount)?; + + Burn { from, amount }.publish(&env); + Ok(()) } diff --git a/soroban-contracts/contracts/loyalty-token/src/test.rs b/soroban-contracts/contracts/loyalty-token/src/test.rs index d7cac11..08f7e45 100644 --- a/soroban-contracts/contracts/loyalty-token/src/test.rs +++ b/soroban-contracts/contracts/loyalty-token/src/test.rs @@ -381,3 +381,128 @@ fn the_pause_reason_is_readable_from_chain_state() { assert_eq!(contract.get_pause_state().unwrap().reason, why); } + +// =========================================================================== +// Contract events — structured contract events (#45) +// =========================================================================== +// +// We check that each state-changing entry point emits exactly one event +// and that failed operations emit none. Topic ordering and data shapes +// are documented in the README (contract-level) and pinned by the +// #[contractevent] derive macros on the event structs in lib.rs. + +#[test] +fn mint_emits_one_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + let before = env.events().all().events().len(); + contract.mint(&user, &1_000); + let after = env.events().all().events().len(); + assert_eq!(after - before, 1, "mint must emit exactly one event"); +} + +#[test] +fn transfer_emits_one_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + let other = Address::generate(&env); + contract.mint(&user, &500); + // events().all() only returns events from the most recent invocation + contract.transfer(&user, &other, &200); + assert_eq!( + env.events().all().events().len(), + 1, + "transfer must emit exactly one event" + ); +} + +#[test] +fn burn_emits_one_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + contract.mint(&user, &400); + // events().all() only returns events from the most recent invocation + contract.burn(&user, &150); + assert_eq!( + env.events().all().events().len(), + 1, + "burn must emit exactly one event" + ); +} + +#[test] +fn set_minter_emits_one_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, _user) = setup(); + let new_minter = Address::generate(&env); + contract.set_minter(&new_minter); + assert_eq!( + env.events().all().events().len(), + 1, + "set_minter must emit exactly one event" + ); +} + +#[test] +fn failed_mint_emits_no_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + let count_before = env.events().all().events().len(); + let res = contract.try_mint(&user, &0); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); + let count_after = env.events().all().events().len(); + assert_eq!( + count_before, count_after, + "failed operation must emit no event" + ); +} + +#[test] +fn failed_transfer_emits_no_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + let other = Address::generate(&env); + contract.mint(&user, &100); + let res = contract.try_transfer(&user, &other, &200); + assert_eq!(res, Err(Ok(Error::InsufficientBalance))); + assert_eq!( + env.events().all().events().len(), + 0, + "failed operation must emit no event" + ); +} + +#[test] +fn approve_emits_one_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + let spender = Address::generate(&env); + contract.approve(&user, &spender, &300, &100); + assert_eq!( + env.events().all().events().len(), + 1, + "approve must emit exactly one event" + ); +} + +#[test] +fn failed_approve_emits_no_event() { + use soroban_sdk::testutils::Events as _; + + let (env, contract, _admin, _minter, user) = setup(); + let spender = Address::generate(&env); + let res = contract.try_approve(&user, &spender, &-1, &100); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); + assert_eq!( + env.events().all().events().len(), + 0, + "failed operation must emit no event" + ); +} diff --git a/soroban-contracts/contracts/reputation/src/lib.rs b/soroban-contracts/contracts/reputation/src/lib.rs index 3236f53..67c3298 100644 --- a/soroban-contracts/contracts/reputation/src/lib.rs +++ b/soroban-contracts/contracts/reputation/src/lib.rs @@ -29,12 +29,33 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Vec, }; +use soroban_sdk::contractevent; + use guildworkman_governance_guard as governance; pub use guildworkman_governance_guard::{ PauseState, PendingRotation, PendingUpgrade, ALL_SCOPES, MAX_PAUSE_DURATION, MAX_PAUSE_REASON_LEN, SCOPE_ATTESTATION, }; +// --------------------------------------------------------------------------- +// Contract events +// --------------------------------------------------------------------------- + +/// Emitted when a client submits an attestation for a worker. Topics: +/// `["rep", "attested", appointment_id, client, worker]`; data carries the +/// rating. +#[contractevent(topics = ["rep", "attested"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttestationSubmitted { + #[topic] + pub appointment_id: u64, + #[topic] + pub client: Address, + #[topic] + pub worker: Address, + pub rating: u32, +} + /// Bump when this contract's storage layout actually changes shape and /// needs a real transformation in `migrate`. There's no such change yet — /// this just proves the version-gated migration path end to end and gives @@ -575,6 +596,14 @@ impl ReputationContract { .temporary() .extend_ttl(&global_key, window_ttl, window_ttl); + AttestationSubmitted { + appointment_id, + client, + worker, + rating, + } + .publish(&env); + Ok(()) }