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.
- Rust 1.70+ (stable)
wasm32v1-nonetarget 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.
cargo build
cargo testThe 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 = 6See the detailed schema version contract in Escrow schema versioning.
Compatible without redeploy when you only:
- Add new
DataKeyvariants 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
InvoiceEscrowwithout a migration that rewritesDataKey::Escrow). - Rename or change the XDR shape of an existing
DataKeyvariant used in production.
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.
| 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) | — |
- Deploy version N; exercise
init,fund,settle. - Deploy version N+1 with only new optional keys; repeat flows; assert old instances still readable.
- If
InvoiceEscrowchanges, 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.
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.
| 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.
rustup target add wasm32v1-none
cargo build --target wasm32v1-none --release -p liquifact_escrow
# Artifact (typical):
# target/wasm32v1-none/release/liquifact_escrow.wasmliquifact-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 | Description |
|---|---|---|
| `init` | — | Create an invoice escrow (invoice id, SME, amount, yield bps, maturity). |
| `get_escrow` | — | Read current escrow state. |
| `fund` | — | Record investor funding; status → funded when target is met. |
| `settle` | — | Mark escrow as settled; investors receive principal + yield. |
| `record_sme_collateral_commitment` | SME | Record off-chain collateral pledge (metadata only, no token movement). |
| `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. |
| Entrypoint | Description |
|------------|-------------|
| `init` | Create an invoice escrow; binds funding token, treasury, optional registry. |
| `fund` | Record investor principal and atomically pull the funding token from the investor; marks escrow funded when target is met. |
| `fund_with_commitment` | First deposit with optional lock period (atomically pulling the funding token); selects tiered yield. |
| `settle` | Mark a funded escrow as settled (SME auth required; maturity enforced). |
| `withdraw` | SME pulls funded liquidity (accounting record). |
| `refund` | Investor pulls contributed liquidity from a cancelled escrow. Increments `DistributedPrincipal` liability. |
| `claim_investor_payout` | Investor records a payout claim after settlement. |
| `claim_payouts_batch` | Batch-record payout claims for up to `MAX_CLAIM_BATCH` investors in one transaction. |
| `sweep_terminal_dust` | Treasury sweeps rounding residue from a terminal escrow. |
| `migrate` | Schema version gate — **typed errors on all paths** in the current release (codes 90–92). |
| `set_legal_hold` | Admin activates/clears compliance hold. |
| `set_allowlist_active` | Admin enables/disables the investor allowlist gate. |
| `set_investor_allowlisted` | Admin sets per-address allowlist status. |
| `set_investors_allowlisted` | Admin batch-sets allowlist status for multiple addresses. |
| `bind_primary_attestation_hash` | Admin sets a single-write 32-byte digest (single-set guarantee). |
| `append_attestation_digest` | Admin appends to bounded audit log. |
| `record_sme_collateral_commitment` | SME records collateral pledge (metadata only). |
| `propose_admin` | Step 1 of admin handover — sets `DataKey::PendingAdmin` and `DataKey::PendingAdminExpiry` (admin auth). Optional validity window; defaults to 7 days. |
| `accept_admin` | Step 2 of admin handover — pending address accepts before expiry and becomes admin. |
| `cancel_pending_admin` | Admin withdraws an unaccepted proposal; clears `DataKey::PendingAdmin` and `DataKey::PendingAdminExpiry`. |
| `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. |
---
## 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–3, 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)).
- **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
- Keep
Cargo.lockcommitted 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.
MIT