From da25c3ef04348dd7b4cfb1b20752bfac44e834b7 Mon Sep 17 00:00:00 2001 From: Mustapha3415 Date: Sat, 17 Oct 2026 00:18:07 +0100 Subject: [PATCH] feat(escrow): emit indexer lifecycle events Closes #33 Closes #9 --- ARCHITECTURE_ESSENTIALS.md | 1 + README.md | 7 ++ ROADMAP.md | 6 +- contracts/escrow/src/lib.rs | 83 +++++++++++++++++++- contracts/escrow/tests/escrow_events.rs | 90 ++++++++++++++++++++++ docs/adr/0006-escrow-indexing-off-chain.md | 59 ++++++++++++++ docs/threat-model.md | 17 ++-- 7 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 contracts/escrow/tests/escrow_events.rs create mode 100644 docs/adr/0006-escrow-indexing-off-chain.md diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 3d5b571c..a9e3c667 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -15,6 +15,7 @@ get_status(escrow_id) -> EscrowStatus // enum: Funded | Released | Refunded — get_escrow(escrow_id) -> EscrowData // read-only: payer, payee, token, amount, condition_ref, status, releaser, refund_after — IMPLEMENTED + LIVE ON TESTNET (Phase E4) release(escrow_id, caller) -> result // IMPLEMENTED + LIVE ON TESTNET (Phase E2) — only escrow_id's `releaser` may call this, only while Funded refund(escrow_id, caller) -> result // IMPLEMENTED + LIVE ON TESTNET (Phase E2 + E4) — releaser always; anyone too, once refund_after has passed, and then still only back to payer +EscrowCreated / EscrowReleased / EscrowRefunded events // additive lifecycle events for off-chain payer/payee indexing; see ADR 0006 ``` `create_escrow`'s param order in code is `(payer, payee, token, amount, condition_ref, releaser, refund_after)` — `token` before `amount`, `releaser` added by Phase E2 and `refund_after` diff --git a/README.md b/README.md index 80c2a243..70d2ec32 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ projects (e.g. Trustless Work). Anything domain-specific — what condition triggers a release, who the parties are — lives in the consuming application, not here. +The contract emits `EscrowCreated`, `EscrowReleased`, and `EscrowRefunded` +events. The creation event contains the initial record and topics for the +escrow id, payer, and payee; terminal events carry the id and parties with +the token and amount. Integrators can build payer/payee listings from these +events without growing on-chain indexes. See [ADR 0006](docs/adr/0006-escrow-indexing-off-chain.md) +for indexer recovery, pagination, and trade-offs. + ## Table of contents - [Why this repo exists](#why-this-repo-exists-honestly) diff --git a/ROADMAP.md b/ROADMAP.md index 3894e468..553b0a6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,13 +115,16 @@ deployment predates it. Closes issue #12. - [ ] Rest of the `docs/threat-model.md` §6 pre-mainnet checklist, scoped into issues: [#8](https://github.com/DocForum/docforum-escrow/issues/8) write status before transfer + reject - contract-self addresses (Trivial), [#9](https://github.com/DocForum/docforum-escrow/issues/9) events (Medium), + contract-self addresses (Trivial), [x] [#9](https://github.com/DocForum/docforum-escrow/issues/9) events (Medium), [#10](https://github.com/DocForum/docforum-escrow/issues/10) storage TTL (Medium), [#15](https://github.com/DocForum/docforum-escrow/issues/15) integrator trust-model docs (Trivial). - [ ] Related, not in the checklist: [#13](https://github.com/DocForum/docforum-escrow/issues/13) `get_escrow` full-record read (Medium), [#14](https://github.com/DocForum/docforum-escrow/issues/14) offline SDK unit tests in CI (Medium). +## Phase E5 — Contract features +- [ ] [#33](https://github.com/DocForum/docforum-escrow/issues/33) Decide how integrators list escrows by payer/payee. ADR 0006 recommends an off-chain index over unbounded on-chain vectors; lifecycle events are implemented in this contribution and must be merged before this item is complete. + ## Explicitly out of scope for this repo - Any healthcare-specific logic (see README). - Fiat on/off-ramp, KYC — that's `docforum-core`'s concern if/when it @@ -129,6 +132,7 @@ repo's. ## Changelog +- 2026-10-16 — Proposed off-chain payer/payee indexing in ADR 0006 and added lifecycle events carrying escrow identity, parties, and reconstruction data. Added contract event regression coverage. Closes issues #33 and #9. - 2026-09-09 — Repo created as part of the DocForum 3-repo org split. - 2026-09-14 — Phase E1 implemented: `create_escrow`/`get_status` on `soroban-sdk` 27.0.6, 4 tests passing, wasm build verified. Testnet diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index a900b94c..63c8a438 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -14,7 +14,9 @@ // consuming application (docforum-core), never here. #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env, String}; +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, String, +}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -42,6 +44,50 @@ pub struct EscrowData { pub refund_after: Option, } +/// Full creation data plus address topics, so indexers can build stable +/// payer/payee views without scanning contract storage. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowCreated { + #[topic] + pub escrow_id: u64, + #[topic] + pub payer: Address, + #[topic] + pub payee: Address, + pub token: Address, + pub amount: i128, + pub condition_ref: String, + pub releaser: Address, + pub refund_after: Option, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowReleased { + #[topic] + pub escrow_id: u64, + #[topic] + pub payer: Address, + #[topic] + pub payee: Address, + pub token: Address, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowRefunded { + #[topic] + pub escrow_id: u64, + #[topic] + pub payer: Address, + #[topic] + pub payee: Address, + pub token: Address, + pub amount: i128, +} + #[contracttype] enum DataKey { Escrow(u64), @@ -120,6 +166,17 @@ impl EscrowContract { refund_after, }; env.storage().persistent().set(&DataKey::Escrow(id), &data); + EscrowCreated { + escrow_id: id, + payer: data.payer.clone(), + payee: data.payee.clone(), + token: data.token.clone(), + amount: data.amount, + condition_ref: data.condition_ref.clone(), + releaser: data.releaser.clone(), + refund_after: data.refund_after, + } + .publish(&env); Ok(id) } @@ -175,7 +232,17 @@ impl EscrowContract { token_client.transfer(&env.current_contract_address(), &data.payee, &data.amount); data.status = EscrowStatus::Released; - env.storage().persistent().set(&DataKey::Escrow(escrow_id), &data); + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &data); + EscrowReleased { + escrow_id, + payer: data.payer, + payee: data.payee, + token: data.token, + amount: data.amount, + } + .publish(&env); Ok(()) } @@ -213,7 +280,17 @@ impl EscrowContract { token_client.transfer(&env.current_contract_address(), &data.payer, &data.amount); data.status = EscrowStatus::Refunded; - env.storage().persistent().set(&DataKey::Escrow(escrow_id), &data); + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &data); + EscrowRefunded { + escrow_id, + payer: data.payer, + payee: data.payee, + token: data.token, + amount: data.amount, + } + .publish(&env); Ok(()) } diff --git a/contracts/escrow/tests/escrow_events.rs b/contracts/escrow/tests/escrow_events.rs new file mode 100644 index 00000000..e54a322e --- /dev/null +++ b/contracts/escrow/tests/escrow_events.rs @@ -0,0 +1,90 @@ +use docforum_escrow::{ + EscrowContract, EscrowContractClient, EscrowCreated, EscrowRefunded, EscrowReleased, + EscrowStatus, +}; +use soroban_sdk::{ + testutils::{Address as _, Events as _}, + token::{StellarAssetClient, TokenClient}, + Address, Env, Event, String, +}; + +#[test] +fn lifecycle_events_include_address_index_and_reconstruction_data() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(admin).address(); + let _token = TokenClient::new(&env, &token_id); + let asset = StellarAssetClient::new(&env, &token_id); + let payer = Address::generate(&env); + let payee = Address::generate(&env); + let releaser = Address::generate(&env); + asset.mint(&payer, &500); + + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + let condition_ref = String::from_str(&env, "invoice-123"); + let escrow_id = client.create_escrow( + &payer, + &payee, + &token_id, + &300, + &condition_ref, + &releaser, + &None, + ); + let created = EscrowCreated { + escrow_id, + payer: payer.clone(), + payee: payee.clone(), + token: token_id.clone(), + amount: 300, + condition_ref, + releaser: releaser.clone(), + refund_after: None, + }; + let events = env.events().all().filter_by_contract(&contract_id); + assert_eq!(events, std::vec![created.to_xdr(&env, &contract_id)]); + + client.release(&escrow_id, &releaser); + let released = EscrowReleased { + escrow_id, + payer: payer.clone(), + payee: payee.clone(), + token: token_id.clone(), + amount: 300, + }; + let events = env.events().all().filter_by_contract(&contract_id); + assert_eq!(events, std::vec![released.to_xdr(&env, &contract_id)]); + assert_eq!(client.get_status(&escrow_id), EscrowStatus::Released); + + // A second escrow exercises the refund event and proves index consumers + // receive the same payer/payee/token/amount shape for both terminal paths. + let second_id = client.create_escrow( + &payer, + &payee, + &token_id, + &200, + &String::from_str(&env, "invoice-456"), + &releaser, + &None, + ); + assert_eq!( + env.events() + .all() + .filter_by_contract(&contract_id) + .events() + .len(), + 1 + ); + client.refund(&second_id, &releaser); + let events = env.events().all().filter_by_contract(&contract_id); + let refunded = EscrowRefunded { + escrow_id: second_id, + payer, + payee, + token: token_id, + amount: 200, + }; + assert_eq!(events, std::vec![refunded.to_xdr(&env, &contract_id)]); +} diff --git a/docs/adr/0006-escrow-indexing-off-chain.md b/docs/adr/0006-escrow-indexing-off-chain.md new file mode 100644 index 00000000..9f3eaf20 --- /dev/null +++ b/docs/adr/0006-escrow-indexing-off-chain.md @@ -0,0 +1,59 @@ +# ADR 0006: Index escrows from lifecycle events off-chain + +**Status:** Accepted + +## Context + +Consumers need to list escrows by payer or payee. The contract currently +supports lookup by escrow id. Adding an on-chain address-to-escrow vector +would make each `create_escrow` pay for a growing write set, require rent +management for every index entry, and need cursor semantics for mutable +lists. A busy address would make creation increasingly expensive and could +eventually hit storage limits. + +## Decision + +Keep the contract lookup keyed by escrow id and build payer/payee views from +the `created`, `released`, and `refunded` contract events. The `created` +event carries the full initial record and indexes the escrow id, payer, and +payee as topics. Terminal events carry the same address and id topics plus +token and amount. An indexer can create the address indexes from `created` +and update status from terminal events without repeatedly reading contract +storage. + +Consumers should treat `(network, contract id, escrow id)` as the stable +record key, process events idempotently, and reconcile a record with +`get_escrow` when recovering an index. Pagination and ordering belong in the +indexer's database; new escrows do not mutate earlier cursors. The indexer +must follow the network's finalized ledger stream and persist its last +processed ledger so it can recover after restarts. + +## Alternatives considered + +### On-chain per-address vectors + +This allows direct contract reads, but payer and payee indexes need separate +entries. Every escrow creation would write several persistent values and +extend their TTL. The vectors grow without a natural bound, require +compaction or pagination rules, and increase the cost of the core escrow +operation. This trade-off is rejected for the current contract. + +### Keep only escrow-id lookup + +This avoids extra storage but leaves every consumer to scan all ids or rely +on an undocumented event format. The new lifecycle events make the off-chain +indexer path explicit while preserving bounded on-chain writes. + +## Consequences + +- `create_escrow`, `release`, and `refund` emit additive public events. +- Existing contract calls and storage layout do not change. +- A payer/payee listing depends on an indexer; the contract remains the + authoritative record and `get_escrow` remains the reconciliation path. +- Events and their topics are public and permanent. Integrators must not put + sensitive material in `condition_ref`. + +## References + +- Issue [#33](https://github.com/DocForum/docforum-escrow/issues/33) +- Event format follow-up [#9](https://github.com/DocForum/docforum-escrow/issues/9) diff --git a/docs/threat-model.md b/docs/threat-model.md index 04c559cb..ed724d98 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -203,14 +203,13 @@ behave like SEP-41. the instance TTL on each call), sized to the longest expected escrow duration. -### T7 — No events emitted · **Low** -- The contract publishes no events of its own. The only on-chain trace - of create/release/refund is the token's `transfer` event plus storage - changes. That makes it harder to monitor for T1 (an unexpected burst - of releases from a leaked key). -- **Mitigation:** emit `created`/`released`/`refunded` events with the - `escrow_id`. Alert in the consumer on any release/refund it didn't - initiate. +### T7 — Event-based indexer lag or gaps · **Low** +- Payer/payee listings are maintained off-chain from lifecycle events. An + indexer can lag or miss events during an outage. +- **Mitigation:** `EscrowCreated` carries the complete initial record and + address topics; terminal events carry the escrow id, parties, token, and + amount. Indexers should checkpoint finalized ledgers, process events + idempotently, and reconcile records with `get_escrow`. ### T8 — Payee has no protection against the releaser · **By design, document it** - The `payer` chooses the `releaser`. Nothing stops `releaser == payer`, @@ -297,7 +296,7 @@ needs tests in `contracts/escrow/tests/` in the same PR (hard rule 2). payee/releaser, key backup/multisig) are still open above. - [ ] ⚙ Balance-delta check on `create_escrow` (T5) - [ ] ⚙ TTL extension for escrow entries and instance (T6) -- [ ] ⚙ Emit create/release/refund events (T7) +- [x] ⚙ Emit create/release/refund events (T7) — see ADR 0006 and issue #9. - [x] Tests with a malicious mock token: lying transfer, short delivery, re-entry attempt (T4, T5) — issue #12, `tests/malicious_token.rs`