Skip to content

Latest commit

 

History

History
424 lines (329 loc) · 22.6 KB

File metadata and controls

424 lines (329 loc) · 22.6 KB

LiquiFact Escrow Contracts

Soroban smart contracts for LiquiFact, the invoice liquidity network on Stellar. This repository contains the escrow contract that holds investor funds for tokenized invoices until settlement.


Prerequisites

  • Rust 1.70+ (stable)
  • wasm32v1-none target for WASM builds: rustup target add wasm32v1-none
  • Soroban / Stellar CLI (optional — for deployment and contract interaction)

For local development and CI, Rust alone is sufficient.


Quick start

cargo build
cargo test

Schema version changelog (DataKey::Version)

The SCHEMA_VERSION constant in escrow/src/lib.rs is stored on-chain under DataKey::Version at [init] and is the authoritative version for upgrade decisions. All production instances should have this value match the deployed WASM.

Version Description Upgrade path
1 Initial schema (InvoiceEscrow v1, basic funding / settle) N/A
2 Added per-investor yield keys (InvestorEffectiveYield, InvestorClaimNotBefore) Additive keys — no migrate call required for read compatibility
3 Added FundingCloseSnapshot, MinContributionFloor, MaxUniqueInvestorsCap, UniqueFunderCount Additive keys — old instances return None / 0 defaults
4 Added attestation API (PrimaryAttestationHash, AttestationAppendLog) Additive keys — no migrate call required
5 Added YieldTierTable (fund_with_commitment), RegistryRef, Treasury; tightened InvoiceEscrow layout Redeploy required if InvoiceEscrow struct layout differs from stored XDR

| 6 | Moved per-investor keys to persistent storage to bound instance footprint and decouple per-address TTL | Redeploy required — prior instances must be redeployed to pick up new storage locations |

Current: SCHEMA_VERSION = 6 See the detailed schema version contract in Escrow schema versioning.


Storage-only upgrade policy (additive fields)

Compatible without redeploy when you only:

  • Add new DataKey variants and/or new #[contracttype] structs stored under new keys.
  • Read new keys with .get(...).unwrap_or(default) so missing keys behave as "unset" on old deployments.

Requires new deployment or explicit migration when you:

  • Change the layout or XDR shape of an existing stored type (e.g. add a required field to InvoiceEscrow without a migration that rewrites DataKey::Escrow).
  • Rename or change the XDR shape of an existing DataKey variant used in production.

migrate entrypoint — typed error semantics

LiquifactEscrow::migrate(from_version) emits typed EscrowError codes in all current cases. There is no silent migration path from any prior version to version 6. Callers must not assume it will do bookkeeping work:

Condition Typed error (code)
stored != from_version MigrationVersionMismatch (90)
from_version >= SCHEMA_VERSION AlreadyCurrentSchemaVersion (91)
Any from_version < SCHEMA_VERSION NoMigrationPath (92)

See docs/escrow-error-messages.md for the full reference.

To add a real migration path (e.g. rewrite DataKey::Escrow after a struct field change), implement the transformation inside migrate before the final typed error and update DataKey::Version.

DataKey naming convention

Rule Example
PascalCase enum variant DataKey::FundingToken
Per-address variants use tuple form DataKey::InvestorContribution(Address)
New variants must be additive (no rename of existing)

Compatibility test plan (short)

  1. Deploy version N; exercise init, fund, settle.
  2. Deploy version N+1 with only new optional keys; repeat flows; assert old instances still readable.
  3. If InvoiceEscrow changes, add a migration test or document mandatory redeploy.

See docs/OPERATOR_RUNBOOK.md for the full redeploy-vs-upgrade decision tree and Stellar/Soroban CLI examples.


Release runbook: build, deploy, verify

Who may deploy production: only addresses and keys owned by LiquiFact governance (multisig / custody). Treat contract admin and deployer secrets as highly sensitive.

See docs/OPERATOR_RUNBOOK.md for the step-by-step runbook including pre-flight checklists, rollback protocol, and legal hold coordination.

Environment variables (example)

Variable Purpose
STELLAR_NETWORK e.g. testnet / mainnet / custom network passphrase
SOROBAN_RPC_URL Soroban RPC endpoint
SOURCE_SECRET Funding / deployer Stellar secret key (S...)
LIQUIFACT_ADMIN_ADDRESS Initial admin intended to control holds and funding target

Exact CLI flags change between Soroban releases; always cross-check the Stellar Soroban docs for your installed stellar CLI version.

Build WASM

rustup target add wasm32v1-none
cargo build --target wasm32v1-none --release -p liquifact_escrow
# Artifact (typical):
# target/wasm32v1-none/release/liquifact_escrow.wasm

liquifact-contracts/ ├── Cargo.toml # Workspace definition ├── docs/ │ └── escrow-sme-collateral.md # Collateral flow spec ├── escrow/ │ ├── Cargo.toml # Escrow contract crate │ └── src/ │ ├── lib.rs # LiquiFact escrow contract │ ├── test.rs # Legacy unit tests │ └── tests/ │ ├── mod.rs │ └── coverage.rs # Collateral flow coverage tests └── .github/workflows/ └── ci.yml # CI: fmt, build, test


### Escrow contract entrypoints

| Entrypoint | Auth Role | Description |
|---|---|---|
| `init` | Admin (implicit) | Create an invoice escrow (invoice id, SME, amount, yield bps, maturity). |
| `fund` | Investor | Record investor principal and atomically pull the funding token from the investor; marks escrow funded when target is met. |
| `fund_with_commitment` | Investor | First deposit with optional lock period (atomically pulling the funding token); selects tiered yield. |
| `fund_batch` | Investor | Batch-record up to `MAX_FUND_BATCH` investor contributions in a single call. Rejects the entire batch atomically on any duplicate investor address (`FundingBatchDuplicateInvestor`, code 84), non-positive amount, or other per-entry invariant violation. |
| `settle` | SME | Mark a funded escrow as settled (SME auth required; maturity enforced). |
| `partial_settle` | SME | SME marks a portion of the escrow as settled before full settlement. |
| `withdraw` | SME | SME pulls funded liquidity (accounting record). |
| `cancel_funding` | Admin | Admin cancels an open escrow (transitions status 0 → 4). |
| `refund` | Investor | Investor pulls contributed liquidity from a cancelled escrow. Increments `DistributedPrincipal` liability. |
| `extend_funding_deadline` | Admin | Extend-only push of the funding deadline while the escrow is open. |
| `claim_investor_payout` | Investor | Investor records a payout claim after settlement. |
| `claim_payouts_batch` | Investor / Any | Batch-record payout claims for up to `MAX_CLAIM_BATCH` investors in one transaction. |
| `sweep_terminal_dust` | Treasury | Treasury sweeps rounding residue from a terminal escrow. |
| `migrate` | Admin | Schema version gate — **typed errors on all paths** in the current release (codes 90–92). |
| `set_legal_hold` | Admin | Admin activates/clears compliance hold. |
| `set_paused` | Admin | Admin toggles a lightweight operational pause (incident response) that blocks `fund`, `settle`, `withdraw`, and `claim_investor_payout`. Orthogonal to legal hold; single-call toggle with no clear delay. |
| `is_paused` | — | Read the current operational pause flag (defaults to `false`). Reflects auto-expiry once a pause max duration is configured. |
| `set_pause_max_duration` | Admin | Configure how long (seconds) an operational pause may stay active before it auto-expires. `0` disables the limit (legacy behavior: pause blocks indefinitely until explicitly cleared). Nonzero values must fall within `[MIN_PAUSE_MAX_DURATION_SECS, MAX_PAUSE_MAX_DURATION_SECS]` or the call fails with `PauseMaxDurationOutOfRange` (code 223). Emits `PauseMaxDurationUpdated`. |
| `get_pause_max_duration` | — | Read the configured pause auto-expiry duration (`0` = unlimited). |
| `set_pause_rate_limit` | Admin | Configure a cap (`max_toggles`) on `set_paused` calls allowed within a rolling `window_secs` window. `(0, 0)` disables rate limiting (legacy behavior). A nonzero `max_toggles` must pair with a nonzero `window_secs` (`PauseRateLimitInvalidCombination`, code 226) and both must fall within their configured bounds (`PauseToggleLimitOutOfRange` code 224, `PauseToggleWindowOutOfRange` code 225). Reconfiguring resets the current window. Emits `PauseRateLimitUpdated`. |
| `get_pause_rate_limit` | — | Read the configured pause-toggle rate limit as `(max_toggles, window_secs)`; `(0, 0)` = unlimited. |
| `set_allowlist_active` | Admin | Admin enables/disables the investor allowlist gate. |
| `set_investor_allowlisted` | Admin | Admin sets per-address allowlist status. |
| `set_investors_allowlisted` | Admin | Admin batch-sets allowlist status for multiple addresses. |
| `bind_primary_attestation_hash` | Admin | Admin sets a single-write 32-byte digest (single-set guarantee). |
| `append_attestation_digest` | Admin | Admin appends to bounded audit log. |
| `record_sme_collateral_commitment` | SME | SME records collateral pledge (metadata only). |
| `batch_record_collateral` | SME | Batch-record collateral commitments atomically (all-or-nothing); bounded by `MAX_COLLATERAL_BATCH`. |
| `get_sme_collateral_commitment` | — | Return current pledge record, or `None`. |
| `clear_sme_collateral_commitment` | SME | Retire a recorded pledge; emits `CollateralClearedEvt`. Returns `NoCollateralToClear` if none exists. |
| `propose_admin` | Admin | Step 1 of admin handover — sets `PendingAdmin` and proposal expiry. |
| `accept_admin` | Pending Admin | Step 2 of admin handover — pending address accepts proposal before expiry. |
| `cancel_pending_admin` | Admin | Admin withdraws an unaccepted proposal. |
| `get_escrow` | — | Read current escrow state. |
| `get_version` | — | Read stored `DataKey::Version`. |
| `get_remaining_investor_slots` | — | Read remaining unique investor capacity before reaching the cap. |
| `get_reconciliation` | — | Read solvency position: live token balance, outstanding liability, and surplus/deficit. See [`docs/escrow-read-api.md`](docs/escrow-read-api.md). |

| `rebind_registry_ref` | Admin | Set or update the off-chain registry hint (`DataKey::RegistryRef`). Emits `RegistryRefRebound`. |
| `clear_registry_ref` | Admin | Convenience alias for `rebind_registry_ref(None)`. Clears the registry pointer. |
| `get_registry_ref` | — | Return the current registry hint, or `None` when unbound. |

---

## Off-chain registry-reference pointer

The escrow stores an optional `Option<Address>` under `DataKey::RegistryRef` as a
**discoverability hint** for off-chain indexers. It is set at `init` (optional) and
can be updated or cleared at any time by the admin via `rebind_registry_ref` /
`clear_registry_ref`.

### Non-authority guarantee

**The registry pointer confers no control over on-chain funds, settlement, or
authorization.** No entrypoint that moves tokens or changes escrow status reads
`DataKey::RegistryRef`. Integrators must not treat its presence as proof of registry
membership or as a security boundary.

### Pointer states

| State | `get_registry_ref` returns | Meaning |
|-------|---------------------------|---------|
| Unbound | `None` | No off-chain registry is associated with this escrow. |
| Bound | `Some(addr)` | `addr` is a hint to an off-chain registry contract. Verify membership directly with that contract if authoritative state is needed. |

### Mutation path

Only the current escrow admin may call `rebind_registry_ref` or `clear_registry_ref`.
Each call emits a `RegistryRefRebound` event (topic: `reg_rebind`) carrying the new
`Option<Address>` value. Off-chain indexers should subscribe to this event to re-sync
their cached pointer without polling.

### Lifecycle summary

1. **Init (optional bind):** `init` accepts an optional `registry: Option<Address>`.
   Passing `Some(addr)` stores the hint immediately; `None` leaves the pointer unset.
2. **Rebind:** Admin calls `rebind_registry_ref(Some(new_addr))` to change the hint.
   The `RegistryRefRebound` event fires with the new address.
3. **Clear:** Admin calls `clear_registry_ref()` (or `rebind_registry_ref(None)`) to
   delete the key. The `RegistryRefRebound` event fires with `registry = None`.

See [`docs/escrow-registry-ref.md`](docs/escrow-registry-ref.md) for the full lifecycle
specification and integrator guidance, and [`docs/escrow-events.md`](docs/escrow-events.md)
for the `RegistryRefRebound` event schema.

---

## Storage guardrails

The escrow stores per-investor contribution entries inside the contract
instance. That map is intentionally bounded.

- Supported investor cardinality: configured via `max_unique_investors` at
  `init` (optional cap); no hard-coded global max since investor cardinality
  is escrow-specific.
- Attestation append log: bounded at `MAX_ATTESTATION_APPEND_ENTRIES = 32`.
- Dust sweep: capped at `MAX_DUST_SWEEP_AMOUNT = 100_000_000` base units per
  call.

---

## Test organization

Escrow tests are organized by feature area under
[`escrow/src/test/`](escrow/src/test):

| File | Coverage area |
|------|--------------|
| `init.rs` | Initialization, invoice-id validation, getters, init-shaped baselines |
| `funding.rs` | Funding, contribution accounting, snapshots, tier selection |
| `settlement.rs` | Settlement, withdrawal, investor claims, maturity boundaries, dust sweep |
| `admin.rs` | Admin-governed state changes, legal hold, migration guards, collateral metadata |
| `integration.rs` | External token-wrapper assumptions, metadata-only integration checks, `cancel_funding` transition matrix |
| `properties.rs` | Proptest-based invariants |

Shared helpers live in [`escrow/src/test.rs`](escrow/src/test.rs). Each test
creates its own fresh `Env` so feature modules do not rely on hidden
cross-test state.

---

## Architecture Decision Records

Core design decisions are captured in [`docs/adr/`](docs/adr/):

| ADR | Decision |
|-----|---------|
| [ADR-001](docs/adr/ADR-001-state-model.md) | Escrow state model (`status` 0–4, forward-only transitions) |
| [ADR-002](docs/adr/ADR-002-auth-boundaries.md) | Authorization boundaries per role (admin, SME, investor, treasury) |
| [ADR-003](docs/adr/ADR-003-settlement-flow.md) | Two-phase settlement flow and funding-close snapshot |
| [ADR-004](docs/adr/ADR-004-legal-hold.md) | Legal / compliance hold mechanism |
| [ADR-005](docs/adr/ADR-005-tiered-yield.md) | Optional tiered yield and per-investor commitment locks |
| [ADR-006](docs/adr/ADR-006-dust-sweep-and-token-safety.md) | Treasury dust sweep and SEP-41 token safety wrapper |

---

## Token integration security checklist

See [`docs/ESCROW_TOKEN_INTEGRATION_CHECKLIST.md`](docs/ESCROW_TOKEN_INTEGRATION_CHECKLIST.md)
for supported token assumptions, explicit unsupported token warnings, and the
integration-layer responsibilities required when this contract interacts with
external token contracts.

---

## SEP-41 token-safety wrappers

See [`docs/escrow-token-safety.md`](docs/escrow-token-safety.md) for the threat model,
invariants, and error codes (`EscrowError` codes 36–41) for the funding-token
transfer wrapper `transfer_funding_token_with_balance_checks`. The wrapper detects
fee-on-transfer, rebasing, hook, and lying token behaviors at the host-call boundary.

---

## SME collateral metadata

See [`docs/escrow-sme-collateral.md`](docs/escrow-sme-collateral.md) for the risk-team handling rules for `record_sme_collateral_commitment` and `CollateralRecordedEvt`. The record is SME-reported metadata only; it is not proof of custody, token movement, or an enforceable on-chain claim.

## Investor allowlist

The escrow supports an optional investor allowlist gate that controls which addresses may fund. See [`docs/escrow-allowlist.md`](docs/escrow-allowlist.md) for the complete allowlist model documentation, including:

- Active/inactive toggle behavior and interaction with per-address entries
- Persistent storage model and TTL/archival implications
- Fund-gate enforcement rules and default-to-deny semantics
- Batch operations and equivalence to single calls
- Security considerations for TTL management and admin key security

## Escrow cancellation and refund lifecycle

The escrow supports cancellation by the admin under specific criteria, unlocking investor refunds and a residual dust sweep with liability-floor protection. See [`docs/escrow-cancellation-refunds.md`](docs/escrow-cancellation-refunds.md) for the end-to-end documentation, including:

- Transitioning into status 4 via `cancel_funding`
- Refund mechanisms (auth, idempotency, and `DistributedPrincipal` tracking)
- Residual dust sweeping and the liability floor protecting un-refunded investors
- A worked execution sequence with multiple investors

## Security notes

- **Typed errors:** stable numeric [`EscrowError`](docs/escrow-error-messages.md) codes are
  append-only; SDKs must branch on `ContractError(code)`, not panic strings. See
  [`docs/escrow-error-messages.md`](docs/escrow-error-messages.md) for the full reference.
- **Auth:** state-changing entrypoints use `require_auth()` for the
  appropriate role (admin, SME, investor, **treasury** for dust sweep).
- **Legal hold:** governance-controlled; misuse risk is mitigated by using a
  multisig `admin` and operational policy (see
  [`docs/OPERATOR_RUNBOOK.md`](docs/OPERATOR_RUNBOOK.md)).
- **Operational pause:** admin-only `set_paused` is a lightweight incident-response
  circuit breaker, **orthogonal to legal hold** — no compliance semantics and no
  clear delay. It gates `fund`, `settle`, `withdraw`, and `claim_investor_payout`
  as a read-only precondition before `require_auth` (typed errors 201–204). Either
  flag blocks independently; clearing one never clears the other.
- **Pause limit configuration:** two independent, admin-configurable bounds guard the
  pause circuit breaker itself, both defaulting to **disabled** so pre-existing
  deployments behave identically until an admin opts in:
  - `set_pause_max_duration` bounds how long a pause may block gated entrypoints before
    it auto-expires (`is_paused` and the gate checks compute expiry from `DataKey::PausedAt`;
    the stored `Paused` flag itself is left untouched until an explicit `set_paused(false)`).
  - `set_pause_rate_limit` bounds how many times `set_paused` may be called within a
    rolling window, to blunt a compromised-admin-key toggle-spam scenario.
  Both setters require admin auth and reject out-of-range values with typed errors
  (`PauseMaxDurationOutOfRange` 223, `PauseToggleLimitOutOfRange` 224,
  `PauseToggleWindowOutOfRange` 225, `PauseRateLimitInvalidCombination` 226,
  `PauseToggleRateLimitExceeded` 227).
- **Collateral record:** SME-reported metadata only; not proof of custody,
  token movement, reserved balance, or an enforceable on-chain claim.
- **Token integration:** fee-on-transfer, rebasing, and hook tokens are
  **explicitly out of scope**. Post-transfer balance-equality checks in
  [`external_calls`](escrow/src/external_calls.rs) emit typed `EscrowError` codes
  36–41 on non-compliant tokens.
- **Overflow:** `fund` uses `checked_add` on `funded_amount`.
- **Dust sweep:** gated on terminal escrow status, per-call cap
  (`MAX_DUST_SWEEP_AMOUNT`), actual balance, legal hold, and treasury auth;
  only the configured SEP-41 token is transferred with post-transfer balance
  equality checks.
- **Tiered yield / claim locks:** first-deposit discipline prevents changing
  an investor's tier after their initial leg; claim timestamps are ledger-based.
- **Funding snapshot:** single-write immutability avoids shifting pro-rata
  denominators after close.
- **Target-lowering promotion:** `update_funding_target` re-evaluates the funded threshold after
  every target change. If `funded_amount >= new_target > 0`, the escrow is immediately promoted to
  funded (`status = 1`) and `FundingCloseSnapshot` is written exactly once — identical semantics
  to the promotion that occurs inside `fund`/`fund_with_commitment`. The snapshot denominator is
  captured atomically with the status transition and cannot be overwritten.
- **Registry ref:** stored for discoverability only; must not be used as
  authority without verifying the registry contract independently.
- **migrate:** emits typed errors on all paths in the current release — no silent
  migration work is performed. See [`docs/escrow-error-messages.md`](docs/escrow-error-messages.md).

### Contract type clone/derive safety

- `DataKey` keeps `Clone` because key wrappers are reused for storage
  get/set paths.
- `InvoiceEscrow` and `SmeCollateralCommitment` intentionally do **not**
  derive `Clone`; this prevents accidental full-state duplication in hot paths.
- `InvoiceEscrow` and `SmeCollateralCommitment` derive `PartialEq` for
  deterministic state assertions in tests and `Debug` for failure diagnostics.
- `init` publishes `EscrowInitialized` from stored state instead of cloning
  the in-memory escrow snapshot, reducing avoidable copy overhead.

---

## CI

Run these before opening a PR:

```bash
cargo fmt --all -- --check
cargo clippy -p liquifact_escrow -- -D warnings
cargo build
cargo test
cargo llvm-cov --features testutils --fail-under-lines 95 --summary-only -p liquifact_escrow

Cargo.lock process notes

  • Keep Cargo.lock committed and reviewed for every dependency change.
  • For routine updates, use a dedicated dependency branch and include lockfile diff context in PR.
  • For emergency advisory bumps, prioritize minimal version movement and full regression checks.
  • After any lockfile update, re-run the full CI command set above before merge.
  • Dependency policy, cadence, and emergency workflow are documented in docs/escrow-dependency-policy.md.

Contributing

MIT