diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index bc23fa19..a9e3c667 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -10,43 +10,20 @@ ## Contract surface ``` -__constructor(allowed_tokens: Vec
) // immutable token allowlist; empty means allow all -create_escrow(payer, payee, token, amount, condition_ref, releaser, refund_after, notify_payee, refund_to) -> escrow_id // Phase E5 callback + sponsored refund destination (ADR 0009); deployed on testnet -create_escrow_with_acceptance(..., requires_acceptance, refund_to) -> escrow_id // optional payee acceptance gate; deployed on testnet -accept(escrow_id, payee) -> () // payee-authorized, one-time acceptance before release; deployed on testnet -create_escrow_with_releasers(payer, payee, token, amount, condition_ref, releasers, threshold, refund_after, notify_payee, refund_to) -> escrow_id // IMPLEMENTED (Phase E5, issue #119, ADR 0007) — M-of-N, deployed on testnet +create_escrow(payer, payee, token, amount, condition_ref, releaser, refund_after) -> escrow_id // IMPLEMENTED + LIVE ON TESTNET (Phase E1 + E2 + E4) get_status(escrow_id) -> EscrowStatus // enum: Funded | Released | Refunded — IMPLEMENTED + LIVE ON TESTNET (Phase E1) -get_escrow(escrow_id) -> EscrowData // read-only: payer, payee, token, amount, condition_ref, status, releasers, threshold, refund_after, refund_to — IMPLEMENTED (Phase E4 + E5; `releaser` became `releasers`+`threshold` in #119) -release(escrow_id, caller) -> result // IMPLEMENTED (Phase E2) — a releaser, threshold must be 1, only while Funded -release_with_approvals(escrow_id, approvers) -> result // IMPLEMENTED (Phase E5, issue #119, ADR 0007) — >= threshold distinct releasers, each authorizing this invocation -refund(escrow_id, caller) -> result // IMPLEMENTED (Phase E2 + E4) — a releaser when threshold is 1; anyone too, once refund_after has passed, and then still only to the stored `refund_to` (= payer unless set at creation, ADR 0009) -refund_with_approvals(escrow_id, approvers) -> result // IMPLEMENTED (Phase E5, issue #119) — same approval rules as release_with_approvals -top_up(escrow_id, payer, amount) -> result // IMPLEMENTED (issue #26) — original payer only, funded state only; emits `top_up` +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` +by Phase E4, both last; no behavioral difference beyond the added +params, just noting it so this doc and `src/lib.rs` don't drift. -No new contract-level role came in with the M-of-N work: `releasers` is -escrow data chosen by the payer at creation and immutable afterwards. There is -still no admin, no owner, no pause and no upgrade path — OpenZeppelin's -pausable/upgradeable/access modules were evaluated and rejected in -`docs/adr/0006` precisely because they would add one. -The allowlist is fixed when the contract is deployed. `create_escrow` -rejects any token outside a non-empty list with `DisallowedToken` (error -code 8); an empty list preserves the prior open-token behavior. The -current testnet deployment includes the native XLM SAC and the -BalanceMismatch test token in its immutable allowlist; see -`docs/testnet-deployments.md`. - -`create_escrow`'s param order in code is `(payer, payee, token, amount, condition_ref, releaser, refund_after, notify_payee, refund_to)`. -`notify_payee` opts a contract payee into `on_escrow_released(escrow_id, -amount)`; when true, the payee must authorize creation as well as the payer. -The quorum creation entry point has the same final callback flag. The SDK -option defaults to false. Callback failure rolls release back atomically; -see ADR 0005 for failure, gas, and stored-record compatibility. - -The current contract surface (`create_escrow`, `get_status`, `get_escrow`, -`release`, `refund`, payee acceptance, M-of-N quorum, callbacks, and -sponsored refund destinations) is deployed on testnet as of 2026-10-20 -(`docs/testnet-deployments.md`, latest entry) — including +All five functions are proven live on testnet as of the Phase E4 +redeployment (`docs/testnet-deployments.md`, 2026-10-08 entry) — including a real timeout refund triggered by a non-releaser after its deadline, a real `release` after the deadline, and real rejections of a pre-deadline stranger refund (`Error(Contract, #3)`), a deadline already @@ -58,89 +35,36 @@ historical record. `refund_after` is an optional unix timestamp in seconds set once at creation (`docs/adr/0004`): after it passes **anyone** may call -`refund`, but the destination is unchanged — always the stored -`refund_to`, which is the `payer` unless set otherwise at creation — +`refund`, but the destination is unchanged — always the stored `payer` — and `release` is never gated by it, so the releaser can still release after the deadline (whoever lands first wins). No `refund_after` means behaviour is exactly what it was before: releaser-only refunds, forever. -The timeout is the answer to "what if the releaser's key is lost?" (threat -model T2): instead of locking funds permanently, a deadline lets anyone -return them. - -`refund_to` (Phase E5, `docs/adr/0009`) is an optional address, set at -creation under the funder's (`payer`'s) auth, that every refund path pays -instead of `payer`. It is stored, immutable, never supplied at payout, and -may not be the contract itself (`InvalidRefundTo`); omitted, it equals -`payer` and behaviour is exactly as before. Invariant I1 is therefore -"release → stored `payee`, refund → stored `refund_to`". Make sure the -destination can hold the token: if the transfer to it fails, so does the -refund. - -`release_with_approvals`/`refund_with_approvals` check that at least -`threshold` distinct releasers each authorized this exact invocation. -Approvals are never stored, so nothing can be replayed, and both work -only while the escrow is `Funded`. The payee-acceptance gate -(`docs/adr/0008`) only blocks release: refunds never need acceptance. - -The contract decides **who** may call `release`/`refund`, never **why**: -`condition_ref` is opaque to it (`docs/adr/0001`). ## TypeScript SDK surface (`sdk/`) ```ts import { EscrowClient, Keypair } from "@docforum/escrow-sdk"; const client = new EscrowClient({ contractId }); // defaults: testnet RPC + passphrase -const { escrowId, txHash } = await client.createEscrow({ payer, payee, token, amount, conditionRef, releaser, refundAfter, notifyPayee: true, refundTo }); -await client.createQuorumEscrow({ payer, payee, token, amount, conditionRef, releasers, threshold, refundAfter, notifyPayee: true, refundTo }); // M-of-N, ADR 0007 +const { escrowId, txHash } = await client.createEscrow({ payer, payee, token, amount, conditionRef, releaser, refundAfter }); await client.getStatus(escrowId); // "Funded" | "Released" | "Refunded" -await client.getEscrow(escrowId); // full stored record — incl. releasers + threshold + refundAfter, refundTo, so a payee can check the deal (docs/adr/0004/0007/0009, threat model T8) -await client.release({ escrowId, caller }); // { txHash } — caller: Keypair or SEP-43 Signer, must be a releaser and threshold must be 1 -await client.releaseWithApprovals({ escrowId, approvers }); // { txHash } — one Keypair/Signer per approver (>= threshold, distinct) -await client.refund({ escrowId, caller }); // { txHash } — releaser when threshold is 1; anyone after refundAfter has passed -await client.refundWithApprovals({ escrowId, approvers }); // { txHash } — M-of-N refund -await client.getEvents({ fromLedger }); // { events, nextCursor } — decoded lifecycle event page -const stop = client.subscribe(handler, { cursor, pollIntervalMs }); // serial polling; await handler for back-pressure -await client.estimate({ source, method, args }); // { resourceFee, inclusionFee, total, minResourceFee } — simulation only, 20% resource buffer -client.guardSponsorship(signedXdr); // { ok, reasons, invokedContracts } — every op + contract auth entry must target this escrow -client.feeBump(signedXdr, sponsor, { maxFee, baseFee? }); // { xdr, fee, innerTxHash } — throws MaxFeeExceededError, never overshoots the cap +await client.getEscrow(escrowId); // full stored record — incl. releaser + refundAfter, so a payee can check the deal (docs/adr/0004, threat model T8) +await client.release({ escrowId, caller }); // { txHash } — caller: Keypair, must equal the escrow's releaser +await client.refund({ escrowId, caller }); // { txHash } — releaser always; anyone after refundAfter has passed ``` - -`refundTo` on `createEscrow` and `createQuorumEscrow` is optional too (default: the payer) and -`getEscrow` returns it as `refundTo`. - -`estimate` never signs or submits; it simulates against the client's RPC -and reports stroops. `guardSponsorship` is the pre-sponsorship check for -transactions that come from someone else: it rejects foreign contract -invocations, non-invocation operations, contract-address auth entries -pointing elsewhere, and already-bumped envelopes, while allowing the -account auth entries (payer/releaser) a legitimate escrow call carries. -`feeBump` charges `baseFee × (inner operations + 1)` and validates the -cap before building the envelope, so a fee spike fails loudly instead of -silently costing more than the sponsor agreed to. -Event results are a discriminated union: known lifecycle events expose -their event type and escrow id; unrecognized topics are returned as -`type: "unknown"` with decoded topic values and payload. Persist the -last handled event's cursor to resume; an expired RPC history cursor throws -`EventCursorExpiredError`. These helpers require the contract lifecycle -events tracked by issue #9; the current deployment does not emit them. `refundAfter` on `createEscrow` is optional (unix seconds, must be strictly in the future or the contract rejects with `InvalidRefundAfter`); omit it for no timeout, which behaves exactly as this contract did before the param existed. -`MAX_RELEASERS` (10) and `MAX_CONDITION_REF_LENGTH` (64) mirror the contract's -bounds client-side so callers fail fast. `getEscrow()` returns -`releasers: string[]` and `threshold: number` (`releaser: string` was replaced -in SDK 0.5.0 when the contract's `EscrowData` changed). `sdk/src/generated/contract-client.ts` is machine-generated (`stellar contract bindings typescript --wasm ...`) — never hand-edit it; -regenerate it whenever the contract's public signatures/types change. CI now -enforces that: `.github/workflows/ci.yml`'s `sdk-bindings` job rebuilds the -WASM, regenerates with the CLI version pinned in `scripts/stellar-cli-version`, -and diffs the result (`scripts/check-generated-bindings.sh`, issue #121). See -`sdk/src/generated/README.md` for the regeneration steps. +regenerate it whenever the contract's public signatures/types change. `sdk/src/index.ts` is the actual public surface (hand-written, ergonomic wrapper) — consumers import from the package root, never from -`src/generated/` directly.`condition_ref` is an **opaque string/id** the contract does not interpret, bounded to 64 bytes at creation (error `ConditionRefTooLong = 7`; SDK constant `MAX_CONDITION_REF_LENGTH`) — it's the caller's job (e.g. `docforum-core`) to decide when release is warranted and call `release()`. `releaser` is the one address permitted +`src/generated/` directly. + +`condition_ref` is an **opaque string/id** the contract does not interpret +— it's the caller's job (e.g. `docforum-core`) to decide when release is +warranted and call `release()`. `releaser` is the one address permitted to call `release`/`refund` for that specific escrow, set once at `create_escrow` time — see `docs/adr/0002` for why a per-escrow releaser was chosen over a single contract-level admin. The one exception is the @@ -152,58 +76,6 @@ way. Baking healthcare-specific logic into the contract is exactly the scope creep this repo exists to avoid (see README "why this repo exists, honestly"). -### Signer interface and browser wallets (issue #127) - -`EscrowClient` methods accept either a local `Keypair` or a SEP-43 `Signer` -(`address` + `signTransaction`), so the same `createEscrow`/`release`/`refund` -surface works in Node and in a browser wallet. `sdk/src/signers/wallets-kit.ts` -ships `WalletsKitSigner`, which adapts Stellar Wallets Kit to that interface: - -- It is exported on the `@docforum/escrow-sdk/signers/wallets-kit` subpath and - has **no runtime import** of `@creit.tech/stellar-wallets-kit` (optional peer - dependency, injected kit). Server imports of the package root therefore never - pull wallet code; `sideEffects: false` keeps it tree-shakeable. -- A wallet on the wrong network throws `WalletNetworkMismatchError` **before** - signing. -- A user rejection is a normal SEP-43 outcome (`error.code === -4`), not an - unhandled throw. -- 12 offline tests in `sdk/tests/signers.test.ts` with a fake kit. - -### React hooks (issue #126) - -`packages/escrow-react` (`@docforum/escrow-react`) is framework glue only: no -UI components or styling. It depends on TanStack Query v5 as a peer and -requires `EscrowProvider` (client) inside the app's `QueryClientProvider`. - -- `useEscrow(id)` polls `getEscrow` and stops at terminal statuses - (`Released`/`Refunded`). -- `useEscrowActions(signer)` wraps creation/settlement, invalidates the - matching query, and returns user rejection as `{ status: "rejected" }`. -- `useEscrowTimeline(escrow)` counts down to `refundAfter`. Documented caveat: - `refund_after` is a ledger timestamp, so a local countdown is advisory; - pass `ledgerTimeOffsetMs` to align it. The contract, not the countdown, - authorizes a timeout refund. - -### Telemetry (issue #129) - -`EscrowClientConfig.instrumentation` / `onEvent` add optional tracing for the -`simulate`, `sign`, `submit` and `confirm` phases. `@opentelemetry/api` is an -optional peer dependency; with no tracer and no `onEvent` the helpers return -before reading a clock or allocating a span. Attributes are contract id, -function, escrow id, transaction hash and retry count. Account addresses are -never included unless `includeAddresses: true` (threat model T9). Tests in -`sdk/tests/instrumentation.test.ts` assert every phase is produced and the -disabled path does no timing work. - -### Go client (issue #128) - -`sdk-go/` is a separate Go module (`github.com/DocForum/docforum-escrow/sdk-go`) -with `CreateEscrow`, `GetStatus`, `GetEscrow`, `Release` and `Refund`, typed -errors matching the contract's codes, and `big.Int` for i128 amounts. It is an -additive client — the contract and TypeScript SDK are unchanged by it. Offline -unit tests run in CI; integration tests are opt-in and run against a local -quickstart deployment. - ## Hard rules 1. **No PHI, ever, anywhere in this repo.** Not in tests, not in example fixtures, not in comments. This repo should be safely public and diff --git a/README.md b/README.md index 8912a61e..11e10a4e 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 278a8118..553b0a6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,11 +3,7 @@ > Update this file on every contribution that starts/completes/blocks an > item below. -**Status: Phase E1 done. Phase E2 done and live on testnet. Phase E3's `create_escrow`/`get_status`/`release`/`refund` wrapping done; SDK distribution decided (issue #5 closed — GitHub Release tarball, ADR 0003). Phase E4 (security review, issue #6) in progress: threat model drafted (`docs/threat-model.md`) and the timeout-refund item closed (issue #11 — ADR 0004 + implementation, live on testnet); independent review not yet done — blocking for any mainnet use. Phase E5 (contract features) now includes the OZ-library evaluation (ADR 0006, no library adopted) and the optional in-contract M-of-N releaser quorum (ADR 0007, SDK 0.5.0) — implemented, tested and pending a testnet redeploy. Phase E6 (npm publishing) in progress: automated `sdk-v*` release workflow with provenance, changesets, and a superseding ADR landed; the one-time human npm org/trusted-publishing setup is still pending, so `sdk-v0.3.0` is not yet on npm. Fee estimation and fee-bump sponsorship helpers (issue #70) are implemented in the SDK, `0.5.0`. Phase E7 (verification/release engineering) started: committed Soroban test snapshots with a CI drift check (issue #120) and a CI check that generated SDK bindings match the contract WASM (issue #121).** - -## Payee acceptance (issue #25) - -An opt-in creation method can require the payee to authorize `accept` before release. Existing escrows keep the default behavior, and refunds remain available without acceptance. Acceptance is recorded in the escrow data. +**Status: Phase E1 done. Phase E2 done and live on testnet. Phase E3's `create_escrow`/`get_status`/`release`/`refund` wrapping done; SDK distribution decided (issue #5 closed — GitHub Release tarball, ADR 0003). Phase E4 (security review, issue #6) in progress: threat model drafted (`docs/threat-model.md`) and the timeout-refund item closed (issue #11 — ADR 0004 + implementation, live on testnet); independent review not yet done — blocking for any mainnet use.** ## Phase E1 — Contract skeleton - [x] Soroban project scaffold (`contracts/escrow`), builds to wasm @@ -115,206 +111,19 @@ An opt-in creation method can require the payee to authorize `accept` before rel Soroban rejects re-entry as an unrecoverable context error ("Contract re-entry is not allowed"), aborting the whole invocation — fail-closed. Post-creation clawback remains possible by design and is documented in - T5 as accepted residual risk. Deployed and verified with a real - fee-on-transfer rejection on testnet; see `docs/testnet-deployments.md`. - Closes issue #12. -- [x] Redeploy current upstream `main` to testnet with live create, release, - refund, timeout-refund, and `BalanceMismatch` verification (issue #109). - The current contract ID and transaction links are in - `docs/testnet-deployments.md`; prior deployment records remain as history. + T5 as accepted residual risk. **Not yet on testnet** — the current + 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 — Bound condition_ref length and validate it at creation -- [x] Pick a maximum length (64 bytes) based on observed integrator usage - (UUIDs, short labels; existing test refs far shorter), document the - rationale, and reject longer refs with a new error code - (`ConditionRefTooLong = 7`) at creation — closes issue #57. -- [x] Update the SDK to validate the same bound client-side with a shared - constant (`MAX_CONDITION_REF_LENGTH`), so callers fail fast instead of - paying network fees for a rejected transaction. -- [x] Update T10 in `docs/threat-model.md` with the bound and rationale, - and update `README.md` + `ARCHITECTURE_ESSENTIALS.md` to surface the - new contract/SDK surface. Contract tests added in - `contracts/escrow/tests/create_escrow.rs` (hard rule 2). -- [x] Redeployed to testnet with `ConditionRefTooLong` on 2026-10-20; - lifecycle and `BalanceMismatch` verification are recorded in - `docs/testnet-deployments.md`. ## Phase E5 — Contract features - -- [x] Optional escrow-wide earliest release time (issue #43, ADR 0009); refund path remains independent. - -- [x] **OpenZeppelin Stellar library evaluation** (issue #118) — - `docs/adr/0006`: all six candidate modules checked against ADR 0001/0002 - and the "no hidden admin" rule. Pausable, Upgradeable and the - access/role modules are **rejected** (each adds a contract-level role, - and a pause switch would let its holder block every release and timeout - refund); smart-account/multisig-account modules stay out of scope; no - stable OZ release supports this contract's `soroban-sdk` major (0.7.2 - requires `^26.1.0`, this contract pins 27.0.6). **Adopted:** the audited - checks-effects-interactions ordering (T4) implemented directly in - `settle()` — status written before the outbound transfer, with a - rollback test (`tests/malicious_token.rs::failed_payout_leaves_the_escrow_funded`). - Library pinning/update policy and the wasm/instruction-cost comparison - recorded in the ADR. **Not yet on testnet.** -- [x] **In-contract M-of-N releaser quorum** (issue #119) — - `docs/adr/0007`: optional `create_escrow_with_releasers(releasers, - threshold)` (1–10 addresses, threshold 1..=len, no duplicates) plus - `release_with_approvals`/`refund_with_approvals`. Approvals are collected - **in one call** (each approver authorizes that exact invocation) rather - than accumulated, so there is no approval state to clear and nothing to - replay; duplicates are rejected and never count twice. `EscrowData` now - stores `releasers` + `threshold` (single-releaser escrows are stored as - 1-of-1, entry points unchanged); `release`/`refund` on a quorum escrow - return `ThresholdNotMet`. Timeout refunds stay open to anyone. New - append-only errors 13–17. 19 tests in `tests/quorum.rs`; single-releaser - suites unchanged and green. Breaking read shape → SDK 0.5.0, bindings - regenerated. **Not yet on testnet** — the live deployment predates it. -- [x] Sponsored funding: a funder distinct from the refund recipient — - issue #28. **ADR first:** `docs/adr/0009` answers the two questions the - issue poses — a separate refund destination **is** compatible with - invariant I1 (it is a second address stored at creation and read, never - supplied, at payout), and it is authorised by the **funder** in their - existing `create_escrow` signature (Soroban auth covers all arguments; - the recipient only receives and need not sign). **Implementation:** - optional `refund_to` on `create_escrow`, `create_escrow_with_acceptance` and `create_escrow_with_releasers` (default `payer`, so omitted = - unchanged), stored in `EscrowData`, used by every refund path (releaser - and timeout share one transfer; there is no payee-initiated refund in - this contract yet — any future one must use `refund_to`), rejected with - new `Error::InvalidRefundTo = 21` if it is the contract itself. - **Tests:** 16 in `contracts/escrow/tests/sponsored_refund.rs` — releaser - refund, timeout refund (stranger, funder, exactly at the deadline), no - authority from being funder/recipient, release unaffected, default - unchanged, contract-self rejected, funder auth required, recipient never - signs. **I1 wording** updated in `docs/threat-model.md` (plus actors, T1, - T2, T8, new T11 and §6). **Breaking change:** `create_escrow` gains a - parameter and `EscrowData` a field; `@docforum/escrow-sdk` 0.5.0 → - 0.6.0 (bindings regenerated, never hand-edited; adds `refundTo` on - `createEscrow` and `getEscrow`). The current contract was deployed on - testnet 2026-10-20 and the SDK tests target it. Publishing the - `sdk-v0.6.0` release tarball per ADR 0003 remains pending. -- [x] Immutable deploy-time token allowlist (issue #36): - `__constructor(allowed_tokens)` stores approved addresses; - `create_escrow` rejects unlisted tokens with append-only error code 7. - An empty list preserves allow-all behavior. Tests cover listed, - unlisted, and empty-list cases. Threat model, deployment guidance, and - contract API docs updated. Deployed and verified with the immutable - testnet allowlist on 2026-10-20. Closes issue #36. -- [x] Optional release callback (issue #53): both single-releaser and - quorum creation require payee authorization when `notify_payee` is true; - release calls `on_escrow_released` after transfer and rolls back atomically - on failure. Re-entry tests cover release/refund/create attempts. ADR 0005 - and threat-model T11 document the decision. Deployed on testnet - 2026-10-20. -## Phase E6 — SDK & integrator tooling -- [x] Escrow lifecycle snapshots and a configurable SubQuery indexer with a typed, paginated SDK query client (issue #87). Operators provide the deployed contract and starting ledger; no hosted indexer is assumed. -- [x] Automated npm release workflow (`.github/workflows/release-sdk.yml`), - triggered by an `sdk-v*` tag: builds, typechecks, tests, runs - `npm publish --provenance`, and attaches the tarball to the GitHub - Release (kept for existing consumers per ADR 0003). Uses npm trusted - publishing (OIDC, `id-token: write`) so no long-lived token sits in - secrets; refuses to publish if the tag doesn't match `package.json`'s - version. Closes issue #16. -- [x] Adopted changesets (`.changeset/`, `@changesets/cli`) for version - bumps and changelogs; `sdk/package.json` gained `changeset`/ - `version`/`release` scripts and `publishConfig` with provenance. -- [x] Superseding ADR written: `docs/adr/0005` supersedes ADR 0003's - interim tarball-only decision now that npm publishing is going live. -- [ ] One-time human steps (npm org creation, trusted-publisher setup for - the release workflow) — documented in `CONTRIBUTING.md`; not yet done, - so `sdk-v0.3.0` is not yet published to npm. -- [x] Fee estimation and fee-bump sponsorship helpers (issue #70). - `estimate({ source, method, args })` simulates a call and returns - `resourceFee` (with a 20% buffer over the simulated minimum, rounded - up), `inclusionFee`, `total`, and the raw `minResourceFee`, in stroops. - `feeBump(signedXdr, sponsor, { maxFee, baseFee? })` wraps a signed - envelope and charges `baseFee × (inner operations + 1)`; it throws - `MaxFeeExceededError` before building anything when that exceeds - `maxFee`, so a fee spike can never silently overshoot the cap. - `guardSponsorship` / `assertSponsorable` inspect **every** operation - and every contract-address auth entry, rejecting foreign contract - calls, non-invocation operations, foreign auth targets, and - already-bumped envelopes while allowing the account auth entries a - legitimate escrow call carries. 17 offline unit tests in - `sdk/tests/fees.test.ts` (wired into CI as `npm run test:unit`) plus a - live-testnet estimate/fee-bump case in `testnet-integration.test.ts`; - additive SDK surface, `0.4.0 → 0.5.0`. Closes issue #70. -- [x] React hooks package (issue #126) — `packages/escrow-react`, - published name `@docforum/escrow-react`. TanStack Query v5 peer. - `useEscrow(id)` polls `getEscrow` and stops at `Released`/`Refunded`; - `useEscrowActions(signer)` invalidates the query after a settlement and - resolves wallet rejection as `{ status: "rejected" }`; `useEscrowTimeline` - counts down to `refundAfter` with a documented ledger-vs-local clock - caveat. Hooks only — no UI. 14 React Testing Library tests with a fake - client and a dedicated CI job. Demo in `examples/web-demo/`. -- [x] Stellar Wallets Kit adapter (issue #127) — - `WalletsKitSigner` on the `@docforum/escrow-sdk/signers/wallets-kit` - subpath, implementing the SDK's SEP-43 `Signer` surface. The kit is an - optional peer dependency and is injected, so there is no runtime import and - server bundles stay wallet-free. Typed `WalletNetworkMismatchError` before - signing; user rejection returned as SEP-43 `-4` rather than thrown. 12 - offline fake-kit tests. -- [x] Go client SDK (issue #128) — `sdk-go/`, module - `github.com/DocForum/docforum-escrow/sdk-go`. `CreateEscrow`, `GetStatus`, - `GetEscrow`, `Release`, `Refund` (+ `TopUp` and approval paths), typed - contract errors, `big.Int` i128 amounts, offline unit tests and an opt-in - quickstart integration suite with its own CI workflow. -- [x] OpenTelemetry instrumentation hooks (issue #129) — optional - `instrumentation`/`onEvent` on `EscrowClient`, spans for `simulate`, - `sign`, `submit` and `confirm`. No tracer/observer means no clock read and - no span allocation. Attributes carry contract id, function, escrow id, - transaction hash and retry count; addresses are opt-in only - (`includeAddresses: true`). 7 offline tests assert span production and the - disabled path. - -## Phase E7 — Verification, testing & release engineering -- [x] **Reproducible WASM builds and deployment verification (issue #91)** ? - Rust 1.96.0 and Stellar CLI 28.1.0 are pinned; the pinned - `rust:1.96.0-bookworm` container compares release WASM hashes from two - checkout paths after remapping repository paths. `contract-v*` releases - publish the WASM and SHA-256 checksum. `scripts/verify-deployment` fetches - deployed WASM from testnet and compares it with a local build; README - documents third-party verification. No `stellar contract optimize` step is - used. The current testnet deployment predates this source, so the verifier - reports the expected hash mismatch. Contract tests/build and the two-path - comparison passed locally. -- [x] **PR malware scan** — `.github/workflows/pr-malware-scan.yml` + `.github/scripts/pr-malware-scan.cjs`, on every PR push. Flags editor/devcontainer autorun tasks, npm install hooks, typosquatted packages and Actions, non-registry dependency sources, payloads disguised as fonts/images, base64/whitespace obfuscation, exfiltration endpoints, reverse shells and miners. HIGH findings fail the `Malware scan` check, label the PR `security-review` and @mention the author; one comment per PR, updated in place. Reads the PR as data only (never checks out or runs it). Added 2026-10-20 after a VS Code autorun payload (escrow #163) and a typosquatted action (escrow #176). Manual backfill (Actions → PR malware scan → Run workflow, `prs: all`) scans PRs opened before the scanner and records a `Malware scan` commit status, which satisfies the required check. -- [x] **Nightly testnet escrow lifecycle soak** (issue #108) — - `.github/workflows/nightly-testnet-soak.yml` runs the SDK live integration - suite nightly with generated identities, transient Friendbot retries, - release/refund paths, and a short timeout refund. Quorum coverage remains - skipped until the documented testnet contract supports it. Failures - update one tracking issue in place and classify contract-missing resets, - infrastructure failures, and SDK test failures. Manual dispatch includes - a forced-failure reporting check. -- [x] **Commit Soroban test snapshots, review event/storage diffs in PRs** - (issue #120) — `test_snapshots/` is no longer git-ignored; every - `cargo test` writes one snapshot per test (events, auth, ledger/storage) - and CI fails if the committed files change without being committed. The - snapshots were reviewed once on the initial commit. Documented - event-capture caveat: the SDK clears the event buffer at the start of - every top-level invocation, so a snapshot only shows the events of a - test's *last* contract call — `tests/top_up.rs::top_up_emits_an_event` - deliberately ends on the emitting call so the mechanism is visible in a - diff. Determinism verified by running the suite twice against the - committed files (no diff). -- [x] **CI check that generated SDK bindings match the contract WASM** - (issue #121) — `scripts/check-generated-bindings.sh` plus the `sdk-bindings` - CI job: builds the WASM, regenerates with the Stellar CLI version pinned in - `scripts/stellar-cli-version` (28.1.0, release tarball checksum pinned - too), and fails with regeneration instructions if - `sdk/src/generated/contract-client.ts` differs. Detects both a stale - binding after a contract change and a hand edit; CI never commits the - regeneration. Instructions in `sdk/src/generated/README.md`; the - previously stale committed bindings (missing `top_up`, errors 6–8) were - regenerated here. -- [x] **PR conflict notifier** — `.github/workflows/pr-conflict-notifier.yml` + `.github/scripts/pr-conflict-notifier.cjs`. On every push to `main` (and every 3h) it labels open PRs that conflict with `main` as `needs-rebase` and @mentions the author once per push with rebase steps; the label clears itself when the conflict is resolved. Runs from `main` only and never checks out PR code. +- [ ] [#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). @@ -323,96 +132,8 @@ An opt-in creation method can require the payee to authorize `accept` before rel repo's. ## Changelog - -> New entries go in `changelog.d/`, one file per PR — see -> `changelog.d/README.md`. Maintainers fold them in here with -> `node scripts/fold-changelog.mjs`. Please don't append to this section -> directly. - -- 2026-10-19 — Issue #70 (Phase E6): added `estimate`, `feeBump`, - `guardSponsorship` and `assertSponsorable` to the SDK. Estimation runs - through the client's RPC and reports the resource fee with a buffered - 20% headroom, the inclusion fee, the total, and the unbuffered minimum; - the buffered figure rounds up so an estimate is never a strop short. - Fee-bumping refused to guess like the original branch did: it builds the - fee-bump with `TransactionBuilder.buildFeeBumpTransaction`, charges - `baseFee × (inner operations + 1)` (the inner fee is refunded), and - checks the cap **before** building, throwing `MaxFeeExceededError` - instead of overshooting. The sponsorship guard inspects every operation - and every contract-address auth entry — an early draft compared *account* - auth addresses to the contract and would have rejected legitimate - payer/releaser entries — and unwraps (then rejects) an already-bumped - envelope. 17 offline unit tests in `sdk/tests/fees.test.ts`, wired into - CI as `npm run test:unit`, plus an estimate/fee-bump case against the - live testnet deployment; additive surface, `0.4.0 → 0.5.0`. README, - `ARCHITECTURE_ESSENTIALS.md` and this roadmap updated. Closes issue #70. -- 2026-10-20 — Added the Phase E7 nightly testnet soak workflow for issue - #108, with transient Friendbot retries, fresh test identities, timeout - refund coverage, failure classification, de-duplicated issue reporting, - and a manual forced-failure path. -- 2026-10-19 — Required payee authorization when `notify_payee` is enabled, - and extended the callback to quorum escrows after #203 introduced M-of-N - releasers. Updated ADR 0005, T11, API docs and generated bindings. -- 2026-10-20 — Phase E5 + E7 batch (issues #118, #119, #120, #121). - **#118:** ADR 0006 evaluates OpenZeppelin's Stellar contracts library; - pause/upgradeable/access are rejected (contract-level roles, and a pause - switch would gate releases and timeout refunds), no stable release - supports `soroban-sdk` 27, so no crate is adopted — the audited - checks-effects-interactions ordering is applied directly instead - (`settle()` writes status before transferring; rollback test added). - Dependency pinning/update policy + measured wasm/CPU comparison in the - ADR (wasm 24,516 → 29,338 bytes; settlement paths +1.3–1.6%). - **#119:** ADR 0007 adds an optional in-contract M-of-N releaser set: - `create_escrow_with_releasers`, `release_with_approvals`, - `refund_with_approvals`; one-call approvals (no stored state, nothing to - replay), duplicates rejected, `MAX_RELEASERS = 10`, errors 13–17; - `EscrowData` now carries `releasers` + `threshold` (single-releaser - escrows store 1-of-1 and keep using `release`/`refund`). 19 new tests; - 57 contract tests green. SDK 0.4.0 → **0.5.0** (breaking read shape), with - `createQuorumEscrow`/`releaseWithApprovals`/`refundWithApprovals`, - offline validation tests, and regenerated bindings. **Not yet on testnet.** - **#120:** committed `test_snapshots/` for every test (57 files) and a CI - drift check; documented how to update them intentionally and the - last-invocation-only event-capture caveat. - **#121:** pinned the Stellar CLI (`scripts/stellar-cli-version`, 28.1.0 + - tarball checksum), added `scripts/check-generated-bindings.sh` and the - `sdk-bindings` CI job; regenerating against the current WASM revealed the - committed bindings were stale (missing `top_up`, `create_*` docs and - errors 6–8) — now fixed. CI's offline SDK step also runs the new quorum - validation tests (`npm run test:offline`). Note on numbering: Phase E6 - above references `docs/adr/0005` (npm publishing), but that file is not in - this tree — 0006 and 0007 are used by this batch to avoid overwriting it if - it lands later. -- 2026-10-17 — Phase E5 started: bounded `condition_ref` to 64 bytes at - creation (new error `ConditionRefTooLong = 7`), added contract test, - added SDK client-side validation (`MAX_CONDITION_REF_LENGTH`), updated - T10, README and ARCHITECTURE_ESSENTIALS.md. Not yet redeployed to - testnet. Closes issue #57. -- 2026-10-18 — Fixed PR #183's contract CI after merging newer `main`: - restored the `IntoVal` import, assigned `CallbackFailed` error code 8 - after `DisallowedToken` code 7, supplied the callback opt-in and - allowlist constructor arguments in affected tests, and regenerated the - SDK binding from the merged contract spec. `cargo test` and the WASM - release build pass locally. -- 2026-10-16 — Implemented the immutable deploy-time token allowlist for - issue #36. Contract tests/docs updated; awaiting a new testnet deployment - before claiming the protection is live. -- 2026-10-17 — Issue #63 (Phase E6): added SDK `getEvents` pagination and - typed lifecycle-event decoding, preserving unknown topic variants for - forward compatibility. Added cursor-resumable serial polling with - handler back-pressure and typed RPC-retention errors. Committed Soroban - XDR event fixtures cover decoding, filters, unknown topics, cursor - resume, retention errors, and stopping. Bumped the additive SDK surface - from 0.3.0 to 0.4.0. SDK typecheck/build and contract tests/build pass; - lifecycle events remain unavailable until issue #9 changes the contract. - +- 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-10-09 — Phase E6 started: added `.github/workflows/release-sdk.yml` - (tag-triggered `sdk-v*` build/test/`npm publish --provenance` + GitHub - Release tarball, OIDC trusted publishing, tag/version guard), adopted - changesets, wrote `docs/adr/0005` superseding ADR 0003, and documented - the one-time npm org/trusted-publishing human steps in `CONTRIBUTING.md`. - npm publish itself still pending those human steps. Closes issue #16. - 2026-09-14 — Phase E1 implemented: `create_escrow`/`get_status` on `soroban-sdk` 27.0.6, 4 tests passing, wasm build verified. Testnet deploy intentionally left undone (needs a funded identity + CLI this @@ -588,16 +309,3 @@ An opt-in creation method can require the payee to authorize `accept` before rel #12 were opened and closed in between). #16 merged with an empty diff because its branch's merge from `main` kept `main`'s ROADMAP.md wholesale. -- 2026-10-20 — Added the PR conflict notifier workflow (see the CI item above): maintainers no longer need to manually ask authors to rebase after each merge. -- 2026-10-20 — Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally. -- 2026-10-16 — Phase E5, issue #28: sponsored funding. ADR 0009 first - (separate refund destination is compatible with I1; the funder - authorises it in the creation signature). Then optional `refund_to` on - `create_escrow` (default `payer`), used by every refund path, with - `Error::InvalidRefundTo = 21` for the contract's own address. 16 new - tests in `tests/sponsored_refund.rs`; 47/47 contract tests pass - repo-wide, wasm release build succeeds. SDK bindings regenerated; - `@docforum/escrow-sdk` 0.5.0 → 0.6.0 (breaking). I1 wording, actors, T1, - T2, T8, new T11 and §6 in the threat model, `README.md` and - `ARCHITECTURE_ESSENTIALS.md` updated. **Not done:** testnet redeploy and - the `sdk-v0.6.0` release tarball. diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index cd2579cf..63c8a438 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,19 +1,12 @@ // Soroban escrow contract. // // Phase E1 (see ROADMAP.md): create_escrow, get_status. -// Phase E2: release, refund. Both restricted to the escrow's releaser +// Phase E2: release, refund. Both restricted to the escrow's `releaser` // address, set at create_escrow time — see docs/adr/0002 for why a // per-escrow releaser was chosen over a single contract-level admin. // Phase E4 (issue #11): optional `refund_after` timeout refund — see // docs/adr/0004. After that deadline anyone may refund, but only back to // the payer, so a lost `releaser` key can no longer lock funds forever. -// Phase E5 (issue #28): optional `refund_to` — a refund destination that -// differs from the funder (`payer`), fixed at creation with the funder's -// auth and used by every refund path; see docs/adr/0009. -// Phase E5 (issue #119): an escrow can instead be created with an M-of-N -// releaser set (`create_escrow_with_releasers`) — see docs/adr/0007. -// Single-releaser escrows are stored as a one-address set with threshold 1 -// and behave exactly as before. // // Hard rule (see ARCHITECTURE_ESSENTIALS.md / docs/adr/0001): the contract // enforces WHO may act, not WHY. `condition_ref` is opaque — the contract @@ -22,20 +15,9 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, - IntoVal, String, Vec, + contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, String, }; -/// Largest allowed releaser set for one escrow. Ten covers every -/// real-world multisig shape this primitive is meant to replace -/// (2-of-3, 3-of-5, ...) while keeping `create_escrow`'s validation, -/// `get_escrow`'s payload and the settlement path's auth checks cheap and -/// bounded. See docs/adr/0007. -pub const MAX_RELEASERS: u32 = 10; - -/// Maximum `condition_ref` length in bytes (threat model T10, Phase E5). -pub const MAX_CONDITION_REF_LEN: u32 = 64; - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum EscrowStatus { @@ -47,47 +29,69 @@ pub enum EscrowStatus { #[contracttype] #[derive(Clone)] pub struct EscrowData { - /// The funder: authorises and pays for the escrow at creation. Also - /// the refund destination unless `refund_to` says otherwise. pub payer: Address, pub payee: Address, pub token: Address, pub amount: i128, pub condition_ref: String, pub status: EscrowStatus, - /// Addresses permitted to settle this escrow — see docs/adr/0002 for the - /// per-escrow releaser, and docs/adr/0007 for M-of-N sets. A plain - /// single-releaser escrow stores exactly one entry here. - pub releasers: Vec
, - /// How many *distinct* addresses from `releasers` must authorize one - /// settlement. Always `>= 1` and `<= releasers.len()`; `1` means any - /// single releaser can settle (the pre-quorum behaviour). - pub threshold: u32, + /// The only address permitted to call `release`/`refund` on this + /// escrow. Set once at `create_escrow` time — see docs/adr/0002. + pub releaser: Address, /// Optional deadline (unix seconds) after which `refund` opens to - /// anyone — still only to `refund_to`. `None` means no timeout, + /// anyone — still only back to `payer`. `None` means no timeout, /// behaviour identical to before this field existed — see docs/adr/0004. pub refund_after: Option, - /// If true, invoke the contract payee's `on_escrow_released` hook after - /// the payout transfer. A failed callback reverts the whole release. - pub notify_payee: bool, - /// Whether release requires the payee's explicit acceptance. - pub requires_acceptance: bool, - /// Ledger timestamp at which the payee accepted, if any. - pub accepted_at: Option, - /// Where **every** refund path (releaser, timeout) sends the funds. - /// Fixed at creation under the funder's auth and never chosen at - /// payout, so invariant I1 holds; defaults to `payer` when omitted at - /// creation — see docs/adr/0009. - pub refund_to: Address, - /// Earliest ledger timestamp at which release is allowed. - pub release_not_before: 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), NextId, - AllowedTokens, } #[contracterror] @@ -106,41 +110,6 @@ pub enum Error { /// across `create_escrow`'s transfer (a lying or short-delivering /// token) — see docs/threat-model.md T5. BalanceMismatch = 6, - /// `condition_ref` exceeded the maximum allowed length (currently - /// 64 bytes) — see docs/threat-model.md T10 and ROADMAP.md Phase E5. - /// Bounded to keep storage and event costs predictable; reject at - /// creation so no on-chain storage is wasted on an oversized ref. - ConditionRefTooLong = 7, - /// `token` is not in the immutable deploy-time allowlist. - DisallowedToken = 8, - /// The opted-in payee callback returned an error or could not be - /// invoked. The release transaction (including its transfer) rolls back. - CallbackFailed = 9, - Overflow = 12, - /// The releaser set is empty, or `threshold` is 0 or larger than the - /// number of releasers — see docs/adr/0007. - InvalidThreshold = 13, - /// More than `MAX_RELEASERS` (10) addresses were supplied at creation. - TooManyReleasers = 14, - /// Fewer distinct approvals were supplied than the escrow's threshold, - /// or a single releaser tried to settle a quorum escrow through the - /// single-caller entry point. - ThresholdNotMet = 15, - /// The same address appeared twice in one approval list. Duplicates - /// never count twice toward the threshold. - DuplicateApprover = 16, - /// The same address appeared twice in the releaser set at creation. - DuplicateReleaser = 17, - AcceptanceRequired = 18, - AcceptanceNotConfigured = 19, - AlreadyAccepted = 20, - /// `refund_to` was the escrow contract's own address — funds sent - /// there on refund could never be moved again (docs/adr/0009). - InvalidRefundTo = 21, - /// The configured release time is in the past or after the refund time. - InvalidReleaseNotBefore = 22, - /// Release was attempted before the configured time. - ReleaseTooEarly = 23, } #[contract] @@ -148,27 +117,14 @@ pub struct EscrowContract; #[contractimpl] impl EscrowContract { - /// Sets the immutable token allowlist at deployment. An empty list - /// preserves the legacy behavior and allows any token address. - pub fn __constructor(env: Env, allowed_tokens: Vec
) { - env.storage() - .instance() - .set(&DataKey::AllowedTokens, &allowed_tokens); - } - /// Locks `amount` of `token` pulled from `payer`, held for `payee`, - /// docs/threat-model.md T10 and ROADMAP.md Phase E5). `releaser` is the - /// address permitted to call `release`/`refund` on this escrow - /// (docs/adr/0002). `refund_after` is optional (docs/adr/0004). - /// `notify_payee` opts an implementing contract payee into - /// `on_escrow_released(escrow_id, amount)` (docs/adr/0005). The payer - /// must authorize creation, and the payee must also authorize if - /// `notify_payee` is true. `refund_to` is the optional refund - /// destination for a sponsored escrow where the funder (`payer`) is - /// not who should get the money back (docs/adr/0009): `None` means - /// `payer`; it may not be this contract's own address, and the - /// `payer`'s auth on this call covers it. For M-of-N releasers use - /// `create_escrow_with_releasers`. + /// tagged with an opaque `condition_ref`. `releaser` is the only + /// address that will later be permitted to call `release`/`refund` + /// on this escrow (see docs/adr/0002). `refund_after` is an optional + /// unix timestamp after which `refund` opens to any caller, always + /// back to `payer` (docs/adr/0004); `None` means no timeout. + /// Requires `payer` auth (the contract moves their funds). Returns + /// the new escrow's id. pub fn create_escrow( env: Env, payer: Address, @@ -178,160 +134,50 @@ impl EscrowContract { condition_ref: String, releaser: Address, refund_after: Option, - notify_payee: bool, - refund_to: Option
, - ) -> Result { - let mut releasers = Vec::new(&env); - releasers.push_back(releaser); - Self::create_escrow_inner( - &env, - payer, - payee, - token, - amount, - condition_ref, - releasers, - 1, - refund_after, - notify_payee, - false, - refund_to, - None, - ) - } - - /// Creates a single-releaser escrow with an optional earliest release time. - /// Release is allowed at the configured timestamp. - pub fn create_escrow_release_time( - env: Env, - payer: Address, - payee: Address, - token: Address, - amount: i128, - condition_ref: String, - releaser: Address, - refund_after: Option, - notify_payee: bool, - refund_to: Option
, - release_not_before: Option, - ) -> Result { - let mut releasers = Vec::new(&env); - releasers.push_back(releaser); - Self::create_escrow_inner( - &env, - payer, - payee, - token, - amount, - condition_ref, - releasers, - 1, - refund_after, - notify_payee, - false, - refund_to, - release_not_before, - ) - } - - /// Creates an escrow with an opt-in payee acceptance gate. Existing - /// integrations can keep using `create_escrow`, which defaults this off. - pub fn create_escrow_with_acceptance( - env: Env, - payer: Address, - payee: Address, - token: Address, - amount: i128, - condition_ref: String, - releaser: Address, - refund_after: Option, - requires_acceptance: bool, - refund_to: Option
, ) -> Result { - let mut releasers = Vec::new(&env); - releasers.push_back(releaser); - Self::create_escrow_inner( - &env, - payer, - payee, - token, - amount, - condition_ref, - releasers, - 1, - refund_after, - false, - requires_acceptance, - refund_to, - None, - ) - } - - /// Records the payee's acceptance for an opted-in funded escrow. - pub fn accept(env: Env, escrow_id: u64, payee: Address) -> Result<(), Error> { - payee.require_auth(); - let mut data: EscrowData = env - .storage() - .persistent() - .get(&DataKey::Escrow(escrow_id)) - .ok_or(Error::NotFound)?; - if data.payee != payee { - return Err(Error::Unauthorized); - } - if data.status != EscrowStatus::Funded { - return Err(Error::NotFunded); + if amount <= 0 { + return Err(Error::InvalidAmount); } - if !data.requires_acceptance { - return Err(Error::AcceptanceNotConfigured); + if let Some(deadline) = refund_after { + if deadline <= env.ledger().timestamp() { + return Err(Error::InvalidRefundAfter); + } } - if data.accepted_at.is_some() { - return Err(Error::AlreadyAccepted); + payer.require_auth(); + + let token_client = token::Client::new(&env, &token); + let contract_address = env.current_contract_address(); + let balance_before = token_client.balance(&contract_address); + token_client.transfer(&payer, &contract_address, &amount); + let balance_after = token_client.balance(&contract_address); + if balance_after.checked_sub(balance_before) != Some(amount) { + return Err(Error::BalanceMismatch); } - let accepted_at = env.ledger().timestamp(); - data.accepted_at = Some(accepted_at); - env.storage() - .persistent() - .set(&DataKey::Escrow(escrow_id), &data); - env.events() - .publish((symbol_short!("accepted"), escrow_id), (payee, accepted_at)); - Ok(()) - } - /// Same as `create_escrow`, but the escrow is settled by M-of-N - /// releasers instead of one address (docs/adr/0007): `threshold` - /// distinct addresses from `releasers` must authorize a settlement. - /// The set is stored at creation and cannot be changed afterwards — - /// there is no admin and no setter. Rejects an empty set, more than - /// 10 releasers, duplicate addresses, and a threshold of 0 or one - /// larger than the set. - pub fn create_escrow_with_releasers( - env: Env, - payer: Address, - payee: Address, - token: Address, - amount: i128, - condition_ref: String, - releasers: Vec
, - threshold: u32, - refund_after: Option, - notify_payee: bool, - refund_to: Option
, - ) -> Result { - Self::create_escrow_inner( - &env, + let id = Self::next_id(&env); + let data = EscrowData { payer, payee, token, amount, condition_ref, - releasers, - threshold, + status: EscrowStatus::Funded, + releaser, refund_after, - notify_payee, - false, - refund_to, - None, - ) + }; + 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) } /// Read-only status query. Errors if `escrow_id` doesn't exist. @@ -344,12 +190,11 @@ impl EscrowContract { Ok(data.status) } - /// Read-only accessor for everything stored about `escrow_id` — payer, - /// payee, token, amount, `condition_ref`, status, the releaser set with - /// its threshold, `refund_after` and `refund_to`. Exists so payees and - /// integrators can check who can settle, when, and where a refund goes - /// before relying on a `Funded` escrow (docs/adr/0004, threat model T8). - /// Errors on an unknown id. + /// Read-only accessor for everything stored about `escrow_id` — + /// payer, payee, token, amount, `condition_ref`, status, `releaser` + /// and `refund_after`. Exists so payees and integrators can check + /// the deadline before relying on a `Funded` escrow (docs/adr/0004, + /// threat model T8). Errors on an unknown id. pub fn get_escrow(env: Env, escrow_id: u64) -> Result { env.storage() .persistent() @@ -357,396 +202,101 @@ impl EscrowContract { .ok_or(Error::NotFound) } - /// Adds funds to a funded escrow without changing its parties or id. - /// Only its original payer may authorize the transfer. - pub fn top_up(env: Env, escrow_id: u64, payer: Address, amount: i128) -> Result<(), Error> { - if amount <= 0 { - return Err(Error::InvalidAmount); - } - payer.require_auth(); + /// Moves the escrowed `amount` to `payee`. Only the escrow's + /// `releaser` (set at `create_escrow` time) may call this — the + /// contract enforces WHO, never WHY (ADR 0001). Errors if the + /// escrow doesn't exist, `caller` isn't the releaser, or the escrow + /// isn't currently `Funded` (no double-release, no releasing a + /// refunded escrow). + /// + /// Deliberately **not** gated by `refund_after` (docs/adr/0004): after + /// the deadline the releaser can still release, racing any timeout + /// refund — whoever lands first wins and the other fails `NotFunded`. + pub fn release(env: Env, escrow_id: u64, caller: Address) -> Result<(), Error> { + caller.require_auth(); + let mut data: EscrowData = env .storage() .persistent() .get(&DataKey::Escrow(escrow_id)) .ok_or(Error::NotFound)?; - if data.payer != payer { + + if caller != data.releaser { return Err(Error::Unauthorized); } if data.status != EscrowStatus::Funded { return Err(Error::NotFunded); } - let updated_amount = data.amount.checked_add(amount).ok_or(Error::Overflow)?; - Self::transfer_in_exact(&env, &data.token, &payer, amount)?; - data.amount = updated_amount; + + let token_client = token::Client::new(&env, &data.token); + 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.events().publish( - (symbol_short!("top_up"), escrow_id), - (amount, updated_amount), - ); + EscrowReleased { + escrow_id, + payer: data.payer, + payee: data.payee, + token: data.token, + amount: data.amount, + } + .publish(&env); Ok(()) } - /// Moves the escrowed `amount` to `payee`. Only the escrow's releaser - /// may call this — the contract enforces WHO, never WHY (ADR 0001). On - /// an M-of-N escrow this single-caller path is only allowed when the - /// threshold is 1; otherwise use `release_with_approvals`. - /// - /// Deliberately **not** gated by `refund_after` (docs/adr/0004): after - /// the deadline the releaser can still release, racing any timeout - /// refund — whoever lands first wins and the other fails `NotFunded`. - pub fn release(env: Env, escrow_id: u64, caller: Address) -> Result<(), Error> { - caller.require_auth(); - - let mut data = Self::load(&env, escrow_id)?; - Self::check_single_caller(&data, &caller)?; - Self::check_funded(&data)?; - - Self::settle(&env, escrow_id, &mut data, EscrowStatus::Released) - } - - /// Moves the escrowed `amount` to `payee` on behalf of an M-of-N - /// releaser set (docs/adr/0007). Every address in `approvers` must be a - /// releaser, appear at most once, and authorize this exact invocation; - /// at least `threshold` distinct approvers are required. Approvals are - /// not stored between calls, so there is nothing to clear on settlement - /// and no approval to replay — a settled escrow fails `NotFunded`. - pub fn release_with_approvals( - env: Env, - escrow_id: u64, - approvers: Vec
, - ) -> Result<(), Error> { - let mut data = Self::load(&env, escrow_id)?; - Self::check_approvals(&env, &data, &approvers)?; - Self::check_funded(&data)?; - - Self::settle(&env, escrow_id, &mut data, EscrowStatus::Released) - } - - /// Returns the escrowed `amount` to the escrow's `refund_to` (the - /// `payer` unless a different destination was fixed at creation — - /// docs/adr/0006). Same state rule as + /// Returns the escrowed `amount` to `payer`. Same state rule as /// `release` (only while `Funded`), but two callers are authorised: /// - /// - the escrow's releaser (docs/adr/0002), always; or + /// - the escrow's `releaser` (docs/adr/0002), always; or /// - **anyone**, once `refund_after` has been reached — the timeout - /// refund path for a lost/unsignable releaser key (docs/adr/0004). + /// refund path for a lost/unsignable `releaser` key (docs/adr/0004). /// /// The destination never changes: timeout or not, funds only ever go - /// to the stored `refund_to`, which no caller can influence. With no - /// `refund_after` set, behaviour is identical to before the timeout - /// existed — releaser only. On an M-of-N escrow the releaser path needs - /// a threshold of 1; otherwise use `refund_with_approvals` (the timeout - /// path stays open to anyone). + /// back to the stored `payer`. With no `refund_after` set, behaviour + /// is identical to before the timeout existed — releaser only. pub fn refund(env: Env, escrow_id: u64, caller: Address) -> Result<(), Error> { caller.require_auth(); - let mut data = Self::load(&env, escrow_id)?; + let mut data: EscrowData = env + .storage() + .persistent() + .get(&DataKey::Escrow(escrow_id)) + .ok_or(Error::NotFound)?; let timeout_passed = match data.refund_after { Some(deadline) => env.ledger().timestamp() >= deadline, None => false, }; - if !timeout_passed { - Self::check_single_caller(&data, &caller)?; - } - Self::check_funded(&data)?; - - Self::settle(&env, escrow_id, &mut data, EscrowStatus::Refunded) - } - - /// Returns the escrowed `amount` to `refund_to` on behalf of an M-of-N - /// releaser set (docs/adr/0007). Same approval rules as - /// `release_with_approvals`. The timeout refund path in `refund` stays - /// available as well: once `refund_after` passes, anyone can return the - /// funds to the payer without approvals. - pub fn refund_with_approvals( - env: Env, - escrow_id: u64, - approvers: Vec
, - ) -> Result<(), Error> { - let mut data = Self::load(&env, escrow_id)?; - Self::check_approvals(&env, &data, &approvers)?; - Self::check_funded(&data)?; - - Self::settle(&env, escrow_id, &mut data, EscrowStatus::Refunded) - } - - fn create_escrow_inner( - env: &Env, - payer: Address, - payee: Address, - token: Address, - amount: i128, - condition_ref: String, - releasers: Vec
, - threshold: u32, - refund_after: Option, - notify_payee: bool, - requires_acceptance: bool, - refund_to: Option
, - release_not_before: Option, - ) -> Result { - let allowed_tokens: Vec
= env - .storage() - .instance() - .get(&DataKey::AllowedTokens) - .unwrap_or(Vec::new(env)); - if !allowed_tokens.is_empty() && !Self::contains(&allowed_tokens, &token) { - return Err(Error::DisallowedToken); - } - if amount <= 0 { - return Err(Error::InvalidAmount); - } - if let Some(deadline) = refund_after { - if deadline <= env.ledger().timestamp() { - return Err(Error::InvalidRefundAfter); - } - } - if let Some(release_time) = release_not_before { - if release_time < env.ledger().timestamp() - || refund_after.is_some_and(|refund_time| release_time > refund_time) - { - return Err(Error::InvalidReleaseNotBefore); - } - } - // Bound condition_ref to keep per-escrow storage and event costs - // predictable (threat model T10, Phase E5). 64 bytes is generous - // for a UUID or short label while capping spam risk. - if condition_ref.len() > MAX_CONDITION_REF_LEN { - return Err(Error::ConditionRefTooLong); - } - Self::check_releaser_set(env, &releasers, threshold)?; - let refund_to = refund_to.unwrap_or_else(|| payer.clone()); - if refund_to == env.current_contract_address() { - return Err(Error::InvalidRefundTo); - } - - payer.require_auth(); - if notify_payee { - payee.require_auth(); - } - - Self::transfer_in_exact(env, &token, &payer, amount)?; - - let id = Self::next_id(env); - let data = EscrowData { - payer, - payee, - token, - amount, - condition_ref, - status: EscrowStatus::Funded, - releasers, - threshold, - refund_after, - notify_payee, - requires_acceptance, - accepted_at: None, - refund_to, - release_not_before, - }; - env.storage().persistent().set(&DataKey::Escrow(id), &data); - Self::publish_state(env, symbol_short!("created"), id, &data); - Ok(id) - } - - /// Validates the creation-time releaser set (docs/adr/0007). Run before - /// any auth prompt or transfer so a malformed request costs the caller - /// nothing on-chain. - fn check_releaser_set( - env: &Env, - releasers: &Vec
, - threshold: u32, - ) -> Result<(), Error> { - if releasers.len() > MAX_RELEASERS { - return Err(Error::TooManyReleasers); - } - if threshold == 0 || threshold > releasers.len() { - return Err(Error::InvalidThreshold); - } - let mut seen: Vec
= Vec::new(env); - for releaser in releasers.iter() { - if Self::contains(&seen, &releaser) { - return Err(Error::DuplicateReleaser); - } - seen.push_back(releaser); - } - Ok(()) - } - - /// Authorizes the single-caller `release`/`refund` entry points: the - /// caller must be a releaser, and the escrow must settle on one - /// signature (threshold 1). A member of a larger quorum gets - /// `ThresholdNotMet` rather than `Unauthorized`, so integrators can tell - /// "wrong address" from "use the approvals entry point". - fn check_single_caller(data: &EscrowData, caller: &Address) -> Result<(), Error> { - if !Self::contains(&data.releasers, caller) { + if caller != data.releaser && !timeout_passed { return Err(Error::Unauthorized); } - if data.threshold != 1 { - return Err(Error::ThresholdNotMet); - } - Ok(()) - } - - /// Authorizes an M-of-N settlement (docs/adr/0007): at least `threshold` - /// distinct releasers, each of whom authorizes this exact invocation. - /// Duplicates are rejected rather than deduplicated so a caller cannot - /// mistake a 2-of-3 with one repeated address for a satisfied threshold. - fn check_approvals( - env: &Env, - data: &EscrowData, - approvers: &Vec
, - ) -> Result<(), Error> { - if approvers.len() < data.threshold { - return Err(Error::ThresholdNotMet); - } - let mut seen: Vec
= Vec::new(env); - for approver in approvers.iter() { - if !Self::contains(&data.releasers, &approver) { - return Err(Error::Unauthorized); - } - if Self::contains(&seen, &approver) { - return Err(Error::DuplicateApprover); - } - seen.push_back(approver.clone()); - approver.require_auth(); - } - Ok(()) - } - - /// Writes the settled state, then transfers the funds out. - /// - /// Checks-effects-interactions (docs/adr/0006): the status write comes - /// before the token call, so safety doesn't rest on the host's re-entry - /// rule alone (threat model T4). A failing transfer panics inside the - /// token contract, which aborts and rolls back this whole invocation — - /// including the status write — so a failed payout can never leave an - /// escrow marked `Released`/`Refunded`. - fn settle( - env: &Env, - escrow_id: u64, - data: &mut EscrowData, - settled: EscrowStatus, - ) -> Result<(), Error> { - if settled == EscrowStatus::Released - && data - .release_not_before - .is_some_and(|release_time| env.ledger().timestamp() < release_time) - { - return Err(Error::ReleaseTooEarly); - } - let notify_payee = data.notify_payee && settled == EscrowStatus::Released; - let destination = match settled { - EscrowStatus::Released => data.payee.clone(), - _ => data.refund_to.clone(), - }; - if settled == EscrowStatus::Released - && data.requires_acceptance - && data.accepted_at.is_none() - { - return Err(Error::AcceptanceRequired); - } - data.status = settled; - env.storage() - .persistent() - .set(&DataKey::Escrow(escrow_id), data); - - let token_client = token::Client::new(env, &data.token); - token_client.transfer(&env.current_contract_address(), &destination, &data.amount); - if notify_payee { - let args = (escrow_id, data.amount).into_val(env); - let callback = env.try_invoke_contract::<(), soroban_sdk::Error>( - &data.payee, - &soroban_sdk::Symbol::new(env, "on_escrow_released"), - args, - ); - match callback { - Ok(Ok(())) => {} - _ => return Err(Error::CallbackFailed), - } + if data.status != EscrowStatus::Funded { + return Err(Error::NotFunded); } - let action = match &data.status { - EscrowStatus::Funded => symbol_short!("created"), - EscrowStatus::Released => symbol_short!("released"), - EscrowStatus::Refunded => symbol_short!("refunded"), - }; - Self::publish_state(env, action, escrow_id, data); - Ok(()) - } - /// Emits a complete state snapshot for indexers, with releaser sets - /// preserved for M-of-N escrows. - fn publish_state(env: &Env, action: soroban_sdk::Symbol, id: u64, data: &EscrowData) { - let status = match &data.status { - EscrowStatus::Funded => symbol_short!("Funded"), - EscrowStatus::Released => symbol_short!("Released"), - EscrowStatus::Refunded => symbol_short!("Refunded"), - }; - env.events().publish( - (symbol_short!("ESCROW"), action, id), - ( - data.payer.clone(), - data.payee.clone(), - data.token.clone(), - data.amount, - data.condition_ref.clone(), - data.releasers.clone(), - data.threshold, - data.refund_after, - data.notify_payee, - data.requires_acceptance, - data.accepted_at, - status, - ), - ); - } + let token_client = token::Client::new(&env, &data.token); + token_client.transfer(&env.current_contract_address(), &data.payer, &data.amount); - fn load(env: &Env, escrow_id: u64) -> Result { + data.status = EscrowStatus::Refunded; env.storage() .persistent() - .get(&DataKey::Escrow(escrow_id)) - .ok_or(Error::NotFound) - } - - fn check_funded(data: &EscrowData) -> Result<(), Error> { - if data.status != EscrowStatus::Funded { - return Err(Error::NotFunded); + .set(&DataKey::Escrow(escrow_id), &data); + EscrowRefunded { + escrow_id, + payer: data.payer, + payee: data.payee, + token: data.token, + amount: data.amount, } + .publish(&env); Ok(()) } - fn contains(list: &Vec
, candidate: &Address) -> bool { - for item in list.iter() { - if &item == candidate { - return true; - } - } - false - } - fn next_id(env: &Env) -> u64 { let id: u64 = env.storage().instance().get(&DataKey::NextId).unwrap_or(0); env.storage().instance().set(&DataKey::NextId, &(id + 1)); id } - - fn transfer_in_exact( - env: &Env, - token: &Address, - payer: &Address, - amount: i128, - ) -> Result<(), Error> { - let token_client = token::Client::new(env, token); - let contract_address = env.current_contract_address(); - let balance_before = token_client.balance(&contract_address); - token_client.transfer(payer, &contract_address, &amount); - let balance_after = token_client.balance(&contract_address); - if balance_after.checked_sub(balance_before) != Some(amount) { - return Err(Error::BalanceMismatch); - } - 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 945ac1a9..2049460f 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -283,14 +283,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`, @@ -436,7 +435,7 @@ needs tests in `contracts/escrow/tests/` in the same PR (hard rule 2). Also rejects the contract itself as `refund_to` (T11). Not yet deployed to testnet. - [ ] ⚙ 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`