Soroban smart contracts for LiquiFact — the global invoice liquidity network on Stellar. This repo contains the escrow contract that holds investor funds for tokenized invoices until settlement.
Risk:
- Anyone can call
fundorsettle
Impact:
- Malicious settlement
- Fake funding events
Mitigation (Current):
- None (mock auth used in tests)
Recommended Controls:
- Require auth:
fund: investor must authorizesettle: only trusted role (e.g. admin/oracle)
Risk:
funded_amount += amountmay overflowi128
git clone <this-repo-url>
cd liquifact-contracts
cargo build
cargo testRisks:
- Negative funding
- Zero funding
- Invalid maturity
| Command | Description |
|---|---|
cargo build |
Build all contracts |
cargo test |
Run unit tests and property-based tests (using proptest) |
cargo fmt |
Format code |
cargo fmt -- --check |
Check formatting (used in CI) |
liquifact-contracts/
├── Cargo.toml # Workspace definition
├── docs/
│ └── EVENT_SCHEMA.md # Indexer-friendly event schema reference
├── escrow/
│ ├── Cargo.toml # Escrow contract crate
│ └── src/
│ ├── lib.rs # LiquiFact escrow contract (init, fund, settle, migrate)
│ └── test.rs # Unit tests
├── docs/
│ ├── openapi.yaml # OpenAPI 3.1 specification
│ ├── package.json # Test runner deps (AJV, js-yaml)
│ └── tests/
│ └── openapi.test.js # Schema conformance tests (51 cases)
└── .github/workflows/
└── ci.yml # CI: fmt, build, test
Records an investor contribution. Transitions to status = 1 when
funded_amount >= funding_target.
Production note: Must be called atomically with a SEP-41 token
transferfrominvestorto the contract address. This version records accounting only.
Parameters
| Parameter | Constraints |
|---|---|
_investor |
Investor's Stellar address (for audit trail) |
amount |
> 0 recommended; partial funding is allowed |
Returns — Updated InvoiceEscrow.
Failure conditions
| Condition | Behaviour |
|---|---|
status != 0 |
Panics: "Escrow not open for funding" |
init not called |
Panics: "Escrow not initialized" |
funded_amount overflows |
Rust panics (debug) / wraps (release) |
State transitions
- init — Create an invoice escrow (invoice id, SME address, admin address, amount, yield bps, maturity).
- get_escrow — Read current escrow state.
- get_version — Return the stored schema version number.
- fund — Record investor funding; status becomes "funded" when target is met.
- settle — Mark escrow as settled (buyer paid; investors receive principal + yield).
- migrate — Upgrade storage from an older schema version to the current one (see below).
Tests are tagged by risk category in inline comments:
| Category | Tag | What is covered |
|---|---|---|
| Happy path | [HAPPY] |
Full lifecycle, field persistence, get_escrow consistency |
| Auth | [AUTH] |
require_auth recorded for admin / investor / SME; panics without auth |
| State | [STATE] |
Double-init, fund-after-funded, fund-after-settled, settle-when-open, double-settle |
| Uninitialized | [UNINIT] |
get_escrow, fund, settle all panic before init |
| Boundary | [BOUND] |
amount=1, amount=i128::MAX, yield_bps=i64::MAX, maturity=0, maturity=u64::MAX, overshoot funding, exact-boundary funding |
| Repeated calls | [REPEAT] |
Multiple investors accumulate correctly; get_escrow is idempotent |
The escrow contract stores its state as a single InvoiceEscrow struct under the instance storage key "escrow", alongside a "version" key that holds the current schema version (u32).
Any change to the struct layout (adding, removing, or retyping a field) is a breaking schema change and requires a version bump and a migration path.
| Version | Description |
|---|---|
| 1 | Initial schema — invoice_id, sme_address, amount, funding_target, funded_amount, yield_bps, maturity, status, version |
SCHEMA_VERSIONinlib.rsis the source of truth for the current schema.- Every
initcall writesSCHEMA_VERSIONinto both the struct'sversionfield and the"version"storage key. get_version()lets off-chain tooling (indexers, upgrade scripts) read the stored version before deciding whether to callmigrate.
- Bump
SCHEMA_VERSIONinlib.rs(e.g.1to2). - Keep the old struct — add a
legacymodule (or a type alias likeInvoiceEscrowV1) so the old bytes can still be deserialized. - Add a migration arm in
LiquifactEscrow::migrate:if from_version == 1 { let old: InvoiceEscrowV1 = env.storage().instance() .get(&symbol_short!("escrow")).unwrap(); let new = InvoiceEscrow { // spread old fields, default new ones new_field: default_value, version: 2, ..old.into() }; env.storage().instance().set(&symbol_short!("escrow"), &new); env.storage().instance().set(&symbol_short!("version"), &2u32); }
- Write a test in
test.rsthat manually writes the old struct bytes into storage and asserts the migrated state is correct. - Gate
migratebehind admin auth before deploying to production (see security notes below).
1. Deploy new WASM (bump SCHEMA_VERSION, add migration arm)
2. Call get_version() -> confirm stored version == N
3. Call migrate(N) -> storage upgraded to N+1
4. Call get_version() -> confirm stored version == N+1
5. Resume normal operations
The contract rejects migrate calls that:
- Pass a
from_versionthat does not match the stored version (prevents accidental double-migration). - Pass a
from_version >= SCHEMA_VERSION(already up to date).
- Re-initialization guard —
initpanics if the escrow is already initialized, preventing state overwrite. migratemust be admin-gated in production — the current implementation is open for testability. Before mainnet deployment, addadmin_address.require_auth()at the top ofmigrateso only the contract deployer can trigger upgrades.- No silent data loss — migration arms must explicitly handle every field. Defaulting a field to zero/false is intentional and must be documented in the version history table above.
- Immutable history — old migration arms should never be removed; they ensure any instance at any historical version can be brought forward step-by-step.
Currently, the contract methods (init, fund, settle) do not enforce authorization via require_auth(). They rely solely on state-machine guards (e.g. checking if status == 0 before funding).
Warning: This represents an authentication gap. Any caller can trigger these functions. Negative tests have been added to track this gap and ensure proper exceptions are thrown when the contract is in an invalid state.
-
Minimum Funding: All funding amounts must be strictly greater than zero (
$> 0$ ). - Initialization: Escrow creation will fail if the target amount is not positive.
-
Integer Safety: Uses
checked_addto prevent overflow during funded amount accounting.
- Soroban runtime guarantees:
- Deterministic execution
- Storage integrity
- Token transfers handled externally
- Off-chain systems validate invoice authenticity
funded_amount <= funding_target(soft enforced)status transitions: 0 → 1 → 2- Cannot settle before funded
| Step | Command | Fails if… |
|------|---------|-----------|
| Format |
cargo fmt --all -- --check| any file is not formatted | | Build |cargo build| compilation error | | Tests |cargo test| any test fails | | Coverage |cargo llvm-cov --features testutils --fail-under-lines 95| line coverage < 95 % |
The pipeline uses cargo-llvm-cov (installed via taiki-e/install-action) to measure line coverage and hard-fail the job when it drops below 95 %.
To run the coverage check locally:
# Install once
cargo install cargo-llvm-cov
# Run (requires llvm-tools-preview component)
rustup component add llvm-tools-preview
cargo llvm-cov --features testutils --fail-under-lines 95 --summary-onlyKeep formatting, tests, and coverage passing before opening a PR.
- Fork the repo and clone your fork.
- Create a branch from
main:git checkout -b feature/your-featureorfix/your-fix. - Setup: ensure Rust stable is installed; run
cargo buildandcargo test. - Make changes:
- Follow existing patterns in
escrow/src/lib.rs. - Add or update tests in
escrow/src/test.rs. - Format with
cargo fmt.
- Follow existing patterns in
- Verify locally:
cargo fmt --all -- --checkcargo buildcargo test --features testutils
- Commit with clear messages (e.g.
feat(escrow): X,test(escrow): Y). - Push to your fork and open a Pull Request to
main. - Wait for CI and address review feedback.
We welcome new contracts (e.g. settlement, tokenization helpers), tests, and docs that align with LiquiFact's invoice financing flow.
- Multi-escrow support
- Role-based access control
- Token integration
- Event emission
- Formal verification