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 |
Current:
SCHEMA_VERSION = 5
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) panics in all current cases.
There is no silent migration path from any prior version to version 5.
Callers must not assume it will do bookkeeping work:
| Condition | Panic message |
|---|---|
stored != from_version |
"from_version does not match stored version" |
from_version >= SCHEMA_VERSION |
"Already at current schema version" |
Any from_version < SCHEMA_VERSION |
"No migration path from version {N} — extend migrate or redeploy" |
To add a real migration path (e.g. rewrite DataKey::Escrow after a struct
field change), implement the transformation inside migrate before the final
panic! 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.wasm# Escrow crate only (mirrors CI)
cargo clippy -p liquifact_escrow -- -D warnings
# Entire workspace
cargo clippy --all-targets -- -D warnings| Entrypoint | Description |
|---|---|
init |
Create an invoice escrow; binds funding token, treasury, optional registry. |
fund |
Record investor principal; marks escrow funded when target is met. |
fund_with_commitment |
First deposit with optional lock period; selects tiered yield. |
settle |
Mark a funded escrow as settled (SME auth required; maturity enforced). |
withdraw |
SME pulls funded liquidity (accounting record). |
claim_investor_payout |
Investor records a payout claim after settlement. |
sweep_terminal_dust |
Treasury sweeps rounding residue from a terminal escrow. |
migrate |
Schema version gate — panics on all paths in the current release. |
set_legal_hold |
Admin activates/clears compliance hold. |
bind_primary_attestation_hash |
Admin sets a single-write 32-byte digest. |
append_attestation_digest |
Admin appends to bounded audit log. |
record_sme_collateral_commitment |
SME records collateral pledge (metadata only). |
get_escrow |
Read current escrow state. |
get_version |
Read stored DataKey::Version. |
The escrow stores per-investor contribution entries inside the contract instance. That map is intentionally bounded.
- Supported investor cardinality: configured via
max_unique_investorsatinit(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_000base units per call.
Escrow tests are organized by feature area under
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 |
properties.rs |
Proptest-based invariants |
Shared helpers live in escrow/src/test.rs. Each test
creates its own fresh Env so feature modules do not rely on hidden
cross-test state.
Core design decisions are captured in docs/adr/:
| ADR | Decision |
|---|---|
| ADR-001 | Escrow state model (status 0–3, forward-only transitions) |
| ADR-002 | Authorization boundaries per role (admin, SME, investor, treasury) |
| ADR-003 | Two-phase settlement flow and funding-close snapshot |
| ADR-004 | Legal / compliance hold mechanism |
| ADR-005 | Optional tiered yield and per-investor commitment locks |
| ADR-006 | Treasury dust sweep and SEP-41 token safety wrapper |
See 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.
See 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.
- 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
adminand operational policy (seedocs/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_callswillpanic!(safe failure) on non-compliant tokens. - Overflow:
funduseschecked_addonfunded_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.
- Registry ref: stored for discoverability only; must not be used as authority without verifying the registry contract independently.
- migrate: panics on all paths in the current release — no silent migration work is performed.
DataKeykeepsClonebecause key wrappers are reused for storage get/set paths.InvoiceEscrowandSmeCollateralCommitmentintentionally do not deriveClone; this prevents accidental full-state duplication in hot paths.InvoiceEscrowandSmeCollateralCommitmentderivePartialEqfor deterministic state assertions in tests andDebugfor failure diagnostics.initpublishesEscrowInitializedfrom stored state instead of cloning the in-memory escrow snapshot, reducing avoidable copy overhead.
Run these before opening a PR:
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