Soroban smart contracts for LiquiFact on Stellar. This repository contains the escrow crate: a single-instance invoice escrow with funding, optional SME collateral records, compliance legal hold, SME withdrawal, settlement, and per-investor accounting.
| Method | Purpose |
|---|---|
init |
Create escrow (admin auth). Sets funding_target = amount. Binds funding_token, treasury, optional registry, optional yield_tiers (YieldTier Soroban [Vec]); validates invoice_id string (length ≤ 32, charset [A-Za-z0-9_]). |
get_escrow / get_version / get_legal_hold |
Read state. |
get_funding_token / get_treasury / get_registry_ref |
Immutable funding asset, treasury for dust recovery, optional registry hint (None if unset at init). |
get_contribution |
Per-investor funded principal. |
update_funding_target |
Admin, open state only; target ≥ funded_amount. |
fund |
Investor auth; blocked while legal hold is active. First deposit fixes per-investor effective yield (base yield_bps) and clears claim lock unless set by fund_with_commitment. |
fund_with_commitment |
First deposit only for that investor when using a commitment window: sets effective yield from optional tier table and InvestorClaimNotBefore when committed_lock_secs > 0. Further amounts use fund. |
get_funding_close_snapshot |
[Option] of immutable close record when status first became funded (pro-rata denominator). |
get_investor_yield_bps / get_investor_claim_not_before |
Read per-investor tier outcome and claim lock. |
withdraw |
SME auth; funded → withdrawn; blocked under legal hold. |
settle |
SME auth; funded → settled (maturity gate if set); blocked under legal hold. |
claim_investor_payout |
Investor auth; after settle; blocked under legal hold. |
sweep_terminal_dust |
Treasury auth only; transfers capped [MAX_DUST_SWEEP_AMOUNT] of the bound token after terminal status (settled or withdrawn); blocked under legal hold. |
record_sme_collateral_commitment |
SME auth; record-only pledge (asset + amount + timestamp). |
get_sme_collateral_commitment / is_investor_claimed |
Reads. |
set_legal_hold / clear_legal_hold |
Admin governance (hold blocks risk-bearing transitions). |
update_maturity |
Admin, open state only. |
transfer_admin |
Admin rotation. |
migrate |
Version guardrails (see upgrade policy below). |
funding_tokenandtreasuryare stored underDataKey::FundingToken/DataKey::Treasuryand are immutable afterinit(no setter).registryis optional: when provided, it is stored underDataKey::RegistryRef; if omitted, that key is absent andget_registry_refreturnsNone. The registry id is a read-only hint for indexers — it does not grant this escrow any privilege and must not be treated as an on-chain source of truth without calling the registry contract directly (avoid static “call loops” that assume mutual authority).
Off-chain invoice slugs should match the same rules enforced in init: non-empty, length ≤ 32 (Soroban Symbol maximum), characters in [A-Za-z0-9_] only (SEP-style slugs; no spaces, punctuation, or Unicode).
sweep_terminal_dust delegates the SEP-41 transfer to escrow/src/external_calls.rs, which records sender and recipient balances before/after and requires exact amount deltas. Only [DataKey::FundingToken] is used for this path (trust list in module rustdoc). Soroban does not exhibit classic EVM-style synchronous reentrancy into this contract mid-transfer; token implementations are still treated as adversarial for balance correctness. Unsupported token economics (fee-on-transfer) should trip assertions.
There is no separate on-chain funding deadline or grace-period field beyond maturity on [InvoiceEscrow] and optional per-investor claim locks from fund_with_commitment. Tests exercise off-by-one behavior around maturity (now >= maturity) and exact funded transitions (funded_amount >= funding_target). Integrators should assume ledger timestamp skew across validators matches Stellar norms and test boundary predicates accordingly.
When init receives a non-empty yield_tiers vector, tiers must have strictly increasing min_lock_secs, non-decreasing yield_bps, and every tier yield_bps >= base yield_bps. fund_with_commitment(investor, amount, committed_lock_secs) selects the best matching tier on the first deposit only; tier selection is immutable after that investor’s first leg (additional principal uses fund at the stored effective yield). Rounding: yields are integer basis points only; currency rounding for coupon cash flows belongs off-chain.
On the first transition to funded, the contract persists FundingCloseSnapshot: total_principal equals funded_amount at that instant (so overfunding is absorbed in the snapshot total), funding_target, and ledger timestamp/sequence. The snapshot is immutable; deterministic pro-rata uses get_contribution(investor) / total_principal with rational math off-chain.
sweep_terminal_dust(amount) moves min(amount, balance, MAX_DUST_SWEEP_AMOUNT) of the bound funding token from the escrow contract to treasury, using the safety wrapper above. It is only callable in status 2 (settled) or status 3 (withdrawn) so open or funded escrows cannot be drained as “dust.” Legal hold blocks sweeps. fund does not move tokens in this version; if custodial flows are added later, token balances must stay reconciled with ledger fields so sweeps cannot pull user principal. Tokens sent to this contract in other assets are not touched by this hook.
record_sme_collateral_commitment stores a SmeCollateralCommitment under DataKey::SmeCollateralPledge. It does not lock tokens on-chain or trigger liquidation. Indexers should treat it as a disclosure field for future enforcement hooks; no false liquidation is possible from this field alone because no asset movement or status transition depends on it.
When DataKey::LegalHold is true, the contract rejects new fund, settle, SME withdraw, and claim_investor_payout. Only the stored admin may set or clear the hold. Emergency policy: there is no separate break-glass entrypoint; recovery is via governed admin (multisig / DAO). Document operational playbooks off-chain so holds cannot strand funds without governance.
Public enum in escrow/src/lib.rs: Escrow, Version, InvestorContribution(Address), LegalHold, SmeCollateralPledge, InvestorClaimed(Address), FundingToken, Treasury, RegistryRef (present only when set at init), optional YieldTierTable, FundingCloseSnapshot, InvestorEffectiveYield(Address), InvestorClaimNotBefore(Address). New optional keys should keep additive names and avoid reusing or repurposing existing variants.
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 layout or meaning of an existing stored type (e.g. new required field on
InvoiceEscrowwithout a migration that rewritesDataKey::Escrow). - Rename or change the XDR shape of an existing
DataKeyvariant used in production.
Compatibility test plan (short):
- 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.
migrate today validates from_version against stored DataKey::Version and errors if no path is implemented.
Use PascalCase variant names matching persisted role (LegalHold, SmeCollateralPledge). Per-address maps use wrapper variants: InvestorContribution(Address), InvestorClaimed(Address).
Who may deploy production: only addresses and keys owned by LiquiFact governance (multisig / custody). Treat contract admin and deployer secrets as highly sensitive.
| Variable | Purpose |
|---|---|
STELLAR_NETWORK |
e.g. TESTNET / PUBLIC / custom Horizon 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 Stellar Soroban docs for your installed stellar / soroban CLI version.
rustup target add wasm32v1-none
cargo build --target wasm32v1-none --release
# Artifact (typical):
# target/wasm32v1-none/release/liquifact_escrow.wasmstellar contract deploy \
--wasm target/wasm32v1-none/release/liquifact_escrow.wasm \
--source-account "$SOURCE_SECRET" \
--network "$STELLAR_NETWORK" \
--rpc-url "$SOROBAN_RPC_URL"
# Record emitted contract id as LIQUIFACT_ESCROW_CONTRACT_IDInitialize on-chain with init via stellar contract invoke (pass admin, invoice_id as string, sme_address, amounts, yield_bps, maturity, funding_token, registry as optional address, treasury, yield_tiers as optional vector per your product).
shasum -a 256 target/wasm32v1-none/release/liquifact_escrow.wasmStore the digest in release notes and inject the same WASM into verification tooling (block explorer, internal registry). After deployment, confirm the on-chain contract code hash matches the audited artifact for that release tag.
- Persist
LIQUIFACT_ESCROW_CONTRACT_ID(and network passphrase) in the backend’s secure config. - Rollback: cannot undeploy a contract; rollback is forward-only: deploy a new contract id, point new traffic to it, and sunset the old id. Document state replication needs if invoices were already bound to the old id.
| Step | Command |
|---|---|
| Format | cargo fmt --all -- --check |
| Build | cargo build |
| Test | cargo test |
| Coverage (≥ 95% lines in CI) | cargo llvm-cov --features testutils --fail-under-lines 95 --summary-only |
Install coverage tools:
cargo install cargo-llvm-cov
rustup component add llvm-tools-previewSee docs/ESCROW_TOKEN_INTEGRATION_CHECKLIST.md for the supported token assumptions, explicit unsupported token warnings, and the integration-layer responsibilities required when this escrow contract interacts with external token contracts.
- Auth: state-changing entrypoints use
require_auth()for the appropriate role (admin, SME, investor, treasury for dust sweep). - Legal hold: is governance-controlled; misuse risk is mitigated by using a multisig
adminand operational policy. - Collateral record: is not proof of encumbrance until a future version explicitly enforces token transfers.
- Token integration: external token transfers and token safety validation must live in the integration layer; this contract stores only numeric amount state and collateral metadata.
- 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 inexternal_calls. Wrong-asset or oversized balances still require operational discipline — the hook is not a general-purpose withdrawal for live liabilities. - Tiered yield / claim locks: first-deposit discipline (
fundvsfund_with_commitment) 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; it must not be used as an authority without verifying behavior of the registry contract off-chain or in a dedicated integration.
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.
- Branch from
main. - Run
cargo fmt,cargo test, and the coverage command above before pushing. - Keep README and rustdoc aligned with
escrow/src/lib.rsbehavior.