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