Scope: Local / standalone validator simulation only. These recipes are not production deployment instructions. Production deployments require maintainer-controlled secrets and governance procedures described in the repository README.
CLI version: Stellar CLI v22+. Flag names and XDR encoding may differ on older releases. Always cross-check the Stellar Soroban CLI docs for your installed version.
- Prerequisites
- Start a Local Standalone Validator
- Create Identities
- Build the WASM
- Deploy the Contract
- Deploy a Test Token
- init — Initialize an Escrow
- fund — Investor Funding
- fund_with_commitment — Tiered Yield Funding
- settle — SME Settlement
- withdraw — SME Liquidity Pull
- claim_investor_payout — Investor Claim
- Read-Only Getters
- Admin Operations
- Auth Flags Reference
- Security Notes
- Interactive REPL CLI (FEATURE_220)
| Requirement | Version / Notes |
|---|---|
| Stellar CLI | v22+ (stellar --version) |
| Rust toolchain | stable, 1.70+ |
wasm32v1-none target |
rustup target add wasm32v1-none |
| Docker (optional) | for stellar container start standalone node |
Install the Stellar CLI:
cargo install --locked stellar-cli --features optVerify:
stellar --version
# stellar 22.x.xThe local standalone node runs in a Docker container via the stellar container subcommand
(not stellar network start):
stellar container start localThis starts a local Soroban-enabled validator accessible at http://localhost:8000. The network
passphrase for the local standalone is Standalone Network ; February 2017.
Add it as a named network for convenience:
stellar network add \
--rpc-url http://localhost:8000/soroban/rpc \
--network-passphrase "Standalone Network ; February 2017" \
localConfirm connectivity:
stellar network lsStop the container when done:
stellar container stop localCreate four named identities for the simulation. Each maps to a Stellar keypair stored locally.
# Admin — governs holds, maturity updates, admin transfer
stellar keys generate admin --network local --fund
# SME (invoice issuer) — calls settle() and withdraw()
stellar keys generate sme --network local --fund
# Two investors
stellar keys generate investor1 --network local --fund
stellar keys generate investor2 --network local --fund
# Treasury — receives terminal dust sweeps
stellar keys generate treasury --network local --fundRetrieve addresses for use in later commands:
ADMIN=$(stellar keys address admin)
SME=$(stellar keys address sme)
INVESTOR1=$(stellar keys address investor1)
INVESTOR2=$(stellar keys address investor2)
TREASURY=$(stellar keys address treasury)
echo "ADMIN: $ADMIN"
echo "SME: $SME"
echo "INVESTOR1: $INVESTOR1"
echo "INVESTOR2: $INVESTOR2"
echo "TREASURY: $TREASURY"⚠ Maintainer secret note: In production,
adminandtreasurycorrespond toLIQUIFACT_ADMIN_ADDRESSand the treasury multisig. Their secret keys (SOURCE_SECRET) must never be committed to version control or logged. The local identities above are simulation-only throwaway keys.
rustup target add wasm32v1-none
cargo build --target wasm32v1-none --releaseThe compiled artifact is at:
target/wasm32v1-none/release/karis-ky_escrow.wasm
Optionally lint before deploying:
cargo clippy -p escrow -- -D warningsCONTRACT_ID=$(stellar contract deploy \
--wasm target/wasm32v1-none/release/karis-ky_escrow.wasm \
--source admin \
--network local)
echo "CONTRACT_ID: $CONTRACT_ID"Save CONTRACT_ID — every subsequent invoke command references it.
Sample output:
CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4
The escrow binds one SEP-41 funding token at init. For local simulation, deploy the Stellar
native asset wrapper or a test token contract. The simplest approach uses the built-in
stellar contract asset deploy for the native XLM asset:
TOKEN_ID=$(stellar contract asset deploy \
--asset native \
--source admin \
--network local)
echo "TOKEN_ID: $TOKEN_ID"For a custom test token (e.g. USDC simulation), deploy a Soroban token contract separately and
mint to investor addresses before calling fund. The escrow only requires a standard SEP-41
interface (transfer, balance).
Token safety: Only standard SEP-41 tokens are supported. Fee-on-transfer, rebasing, or hook tokens are out of scope and will cause
external_calls::transfer_funding_token_with_balance_checksto panic on balance assertion. Seeescrow/src/external_calls.rs.
init is the first call on a freshly deployed contract. It is one-time — calling it again
panics with "Escrow already initialized".
Auth required: admin (admin.require_auth())
| Argument | Type | Description |
|---|---|---|
admin |
Address |
Governance address; controls holds, maturity, admin transfer |
invoice_id |
String |
ASCII alphanumeric + _, max 32 chars (e.g. INV001) |
sme_address |
Address |
Invoice issuer; authorized to call settle and withdraw |
amount |
i128 |
Funding target in token base units (7 decimals → 10_000_0000000 = 10,000 units) |
yield_bps |
i64 |
Base annualized yield in basis points (800 = 8%); range 0–10,000 |
maturity |
u64 |
Unix timestamp after which settle is allowed; 0 = no maturity gate |
funding_token |
Address |
SEP-41 token contract address bound to this escrow (immutable) |
registry |
Option<Address> |
Optional registry hint for indexers; not an on-chain authority |
treasury |
Address |
Receives terminal dust sweeps; immutable after init |
yield_tiers |
Option<Vec<YieldTier>> |
Optional tiered yield ladder; null to omit |
min_contribution |
Option<i128> |
Minimum per-call funding floor; null for no floor |
max_unique_investors |
Option<u32> |
Cap on distinct investor addresses; null for default 128 |
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- init \
--admin "$ADMIN" \
--invoice_id "INV001" \
--sme_address "$SME" \
--amount 10000_0000000 \
--yield_bps 800 \
--maturity 0 \
--funding_token "$TOKEN_ID" \
--registry null \
--treasury "$TREASURY" \
--yield_tiers null \
--min_contribution null \
--max_unique_investors null# Set maturity to a future timestamp (e.g. 2026-12-31 00:00:00 UTC = 1767139200)
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- init \
--admin "$ADMIN" \
--invoice_id "INV002" \
--sme_address "$SME" \
--amount 50000_0000000 \
--yield_bps 1000 \
--maturity 1767139200 \
--funding_token "$TOKEN_ID" \
--registry null \
--treasury "$TREASURY" \
--yield_tiers null \
--min_contribution null \
--max_unique_investors nullstellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- init \
--admin "$ADMIN" \
--invoice_id "INV003" \
--sme_address "$SME" \
--amount 10000_0000000 \
--yield_bps 800 \
--maturity 0 \
--funding_token "$TOKEN_ID" \
--registry null \
--treasury "$TREASURY" \
--yield_tiers '[{"min_lock_secs":2592000,"yield_bps":1000},{"min_lock_secs":7776000,"yield_bps":1200}]' \
--min_contribution null \
--max_unique_investors nullTiers must have strictly increasing
min_lock_secsand non-decreasingyield_bps≥ baseyield_bps. The table is immutable afterinit.
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- init \
--admin "$ADMIN" \
--invoice_id "INV004" \
--sme_address "$SME" \
--amount 10000_0000000 \
--yield_bps 800 \
--maturity 0 \
--funding_token "$TOKEN_ID" \
--registry null \
--treasury "$TREASURY" \
--yield_tiers null \
--min_contribution 100_0000000 \
--max_unique_investors 10Sample output:
{
"invoice_id": "INV001",
"admin": "GADMIN...",
"sme_address": "GSME...",
"amount": "10000_0000000",
"funding_target": "10000_0000000",
"funded_amount": "0",
"yield_bps": "800",
"maturity": "0",
"status": "0"
}Records investor principal. Moves status from 0 (open) → 1 (funded) when funded_amount
reaches funding_target. Captures a FundingCloseSnapshot on the first transition to funded.
Auth required: investor (investor.require_auth())
fundis the correct method for all deposits after the first from a given investor address. The first deposit may also usefund(base yield) orfund_with_commitment(tiered yield).
stellar contract invoke \
--id "$CONTRACT_ID" \
--source investor1 \
--network local \
-- fund \
--investor "$INVESTOR1" \
--amount 10000_0000000Sample output:
{
"invoice_id": "INV001",
"funded_amount": "10000_0000000",
"status": "1"
}# Investor 1 funds half
stellar contract invoke \
--id "$CONTRACT_ID" \
--source investor1 \
--network local \
-- fund \
--investor "$INVESTOR1" \
--amount 5000_0000000
# Investor 2 funds the remaining half — triggers status → 1 (funded)
stellar contract invoke \
--id "$CONTRACT_ID" \
--source investor2 \
--network local \
-- fund \
--investor "$INVESTOR2" \
--amount 5000_0000000# Must use fund(), not fund_with_commitment(), for subsequent deposits
stellar contract invoke \
--id "$CONTRACT_ID" \
--source investor1 \
--network local \
-- fund \
--investor "$INVESTOR1" \
--amount 1000_0000000Investor cap: The contract enforces a hard cap of 128 distinct investor addresses per escrow (or the
max_unique_investorsvalue set atinit). The 129th distinct address is rejected. Re-funding an existing investor at the cap is still allowed.
First deposit only per investor. Selects an effective yield from the tier ladder based on
committed_lock_secs. Sets InvestorClaimNotBefore when committed_lock_secs > 0.
Auth required: investor (investor.require_auth())
# Commit to 30-day lock (2592000 seconds) — selects matching tier yield
stellar contract invoke \
--id "$CONTRACT_ID" \
--source investor1 \
--network local \
-- fund_with_commitment \
--investor "$INVESTOR1" \
--amount 5000_0000000 \
--committed_lock_secs 2592000After calling
fund_with_commitment, any additional principal from the same investor address must usefund(). Callingfund_with_commitmentagain for the same investor panics with"Additional principal after a tiered first deposit must use fund()".
Marks a funded escrow as settled (status 1 → 2). Blocked by legal hold and by maturity gate
if maturity > 0 and ledger.timestamp < maturity.
Auth required: sme_address (sme_address.require_auth())
stellar contract invoke \
--id "$CONTRACT_ID" \
--source sme \
--network local \
-- settleSample output:
{
"invoice_id": "INV001",
"funded_amount": "10000_0000000",
"yield_bps": "800",
"maturity": "0",
"status": "2"
}Maturity gate: If
maturitywas set atinitand the ledger timestamp has not yet reached it,settlepanics with"Escrow has not yet reached maturity". On a local standalone validator you can advance ledger time by submitting transactions or usingstellar ledger bump(check your CLI version for availability).
Alternative terminal path: SME pulls funded liquidity (status 1 → 3 / withdrawn) without
going through settle. Blocked by legal hold.
Auth required: sme_address (sme_address.require_auth())
stellar contract invoke \
--id "$CONTRACT_ID" \
--source sme \
--network local \
-- withdrawSample output:
{
"invoice_id": "INV001",
"funded_amount": "10000_0000000",
"status": "3"
}
settleandwithdraware mutually exclusive paths from status1. Once either is called, the escrow is in a terminal state.
Records that an investor has claimed their payout after settlement (status must be 2). This is
an accounting marker — actual token transfer is handled by the integration layer, not this
contract. Blocked by legal hold and by InvestorClaimNotBefore if a commitment lock is active.
Auth required: investor (investor.require_auth())
stellar contract invoke \
--id "$CONTRACT_ID" \
--source investor1 \
--network local \
-- claim_investor_payout \
--investor "$INVESTOR1"Sample output:
null
The call emits an
InvestorPayoutClaimedevent. Verify with:stellar events --id "$CONTRACT_ID" --network local
These calls do not require --source auth and do not modify state.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_escrowSample output:
{
"invoice_id": "INV001",
"admin": "GADMIN...",
"sme_address": "GSME...",
"amount": "10000_0000000",
"funding_target": "10000_0000000",
"funded_amount": "10000_0000000",
"yield_bps": "800",
"maturity": "0",
"status": "2"
}Status values: 0 = open, 1 = funded, 2 = settled, 3 = withdrawn.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_unique_funder_countSample output: "2"
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_contribution \
--investor "$INVESTOR1"Sample output: "5000_0000000"
The hard cap is 128 distinct investors per escrow (or the max_unique_investors value set at
init). Read the configured cap:
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_max_unique_investors_capReturns null when no explicit cap was set at init (default 128 applies in contract logic).
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_funding_close_snapshotSample output (after funding closes):
{
"total_principal": "10000_0000000",
"funding_target": "10000_0000000",
"closed_at_ledger_timestamp": "1745539200",
"closed_at_ledger_sequence": "42"
}Returns null before the escrow reaches funded status.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_investor_yield_bps \
--investor "$INVESTOR1"Sample output: "800" (base yield) or a higher tier value if fund_with_commitment was used.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_investor_claim_not_before \
--investor "$INVESTOR1"Returns "0" if no lock was set.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- is_investor_claimed \
--investor "$INVESTOR1"Returns "true" or "false".
stellar contract invoke --id "$CONTRACT_ID" --network local -- get_funding_token
stellar contract invoke --id "$CONTRACT_ID" --network local -- get_treasury
stellar contract invoke --id "$CONTRACT_ID" --network local -- get_registry_ref
stellar contract invoke --id "$CONTRACT_ID" --network local -- get_version
stellar contract invoke --id "$CONTRACT_ID" --network local -- get_legal_hold
stellar contract invoke --id "$CONTRACT_ID" --network local -- get_min_contribution_floorget_registry_ref returns null when no registry was provided at init. The registry address
is a discoverability hint only — not an on-chain authority.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_sme_collateral_commitmentReturns null until record_sme_collateral_commitment has been called. Sample output:
{
"asset": "USDC",
"amount": "5000_0000000",
"recorded_at": "1745539200"
}stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_primary_attestation_hashReturns null until bind_primary_attestation_hash has been called.
stellar contract invoke \
--id "$CONTRACT_ID" \
--network local \
-- get_attestation_append_logReturns an array of 32-byte digests (up to MAX_ATTESTATION_APPEND_ENTRIES = 32 entries).
Returns an empty array [] if no digests have been appended.
Auth required: admin
# Enable hold — blocks settle, withdraw, claim_investor_payout, fund, sweep_terminal_dust
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- set_legal_hold \
--active true
# Clear hold
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- clear_legal_holdAuth required: admin. Only allowed in status 0 (open).
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- update_maturity \
--new_maturity 1767139200Auth required: current admin.
NEW_ADMIN="GNEWADMIN..."
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- propose_admin \
--new_admin "$NEW_ADMIN"Auth required: pending admin from propose_admin.
stellar contract invoke \
--id "$CONTRACT_ID" \
--source new_admin \
--network local \
-- accept_adminAuth required: treasury. Only in terminal states (status 2 or 3). Capped at
MAX_DUST_SWEEP_AMOUNT (100,000,000 base units) per call.
stellar contract invoke \
--id "$CONTRACT_ID" \
--source treasury \
--network local \
-- sweep_terminal_dust \
--amount 1000000Auth required: admin. Single-set — panics if already bound.
# 32-byte hex digest (e.g. SHA-256 of a legal document bundle)
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- bind_primary_attestation_hash \
--digest "aabbccdd...32bytes...hex"Auth required: admin. Append-only audit log; bounded at 32 entries
(MAX_ATTESTATION_APPEND_ENTRIES). Does not replace the primary hash.
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- append_attestation_digest \
--digest "aabbccdd...32bytes...hex"Auth required: admin. Only allowed in status 0 (open). New target must be positive and
≥ funded_amount at the time of the call.
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- update_funding_target \
--new_target 20000_0000000Auth required: none (panics immediately unless a migration path is implemented). Used to
advance the stored schema version. The current contract panics for all from_version values
because no migration path is implemented — this entrypoint is a forward-compatibility hook for
future schema upgrades.
The current SCHEMA_VERSION is 6. A fresh deployment stores version 6. Two panic cases:
# Panics: "Already at current schema version" (from_version matches stored AND equals SCHEMA_VERSION)
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- migrate \
--from_version 6
# Panics: "from_version does not match stored version" (mismatch with what is stored)
stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- migrate \
--from_version 4Do not call
migratein simulation unless you have extended the implementation with a concrete migration path. It is documented here for completeness.
Auth required: sme_address. Record-only — does not move tokens.
stellar contract invoke \
--id "$CONTRACT_ID" \
--source sme \
--network local \
-- record_sme_collateral_commitment \
--asset "USDC" \
--amount 5000_0000000In Stellar CLI v22+, the --source flag specifies the keypair that signs the transaction. When
a contract entrypoint calls addr.require_auth(), the CLI must present a valid authorization
for that address. For the common case where --source is the authorizing address, no extra
--auth flag is needed — the CLI automatically includes the source account's authorization.
| Entrypoint | require_auth() caller |
--source to use |
|---|---|---|
init |
admin |
admin |
fund |
investor |
investor1 / investor2 |
fund_with_commitment |
investor |
investor1 / investor2 |
settle |
sme_address |
sme |
withdraw |
sme_address |
sme |
claim_investor_payout |
investor |
investor1 / investor2 |
sweep_terminal_dust |
treasury |
treasury |
set_legal_hold / clear_legal_hold |
admin |
admin |
update_maturity |
admin |
admin |
propose_admin |
admin (current) |
admin |
accept_admin |
pending admin | new admin |
bind_primary_attestation_hash |
admin |
admin |
append_attestation_digest |
admin |
admin |
update_funding_target |
admin |
admin |
migrate |
none (panics; no path implemented) | admin (by convention) |
record_sme_collateral_commitment |
sme_address |
sme |
When the transaction source differs from the authorizing address (e.g. a relayer pays fees but
the investor authorizes the fund call), use --auth to attach a pre-signed authorization
entry. Refer to the Stellar CLI auth docs
for the --auth flag format, which encodes a SorobanAuthorizationEntry in XDR.
For local simulation, using --source <identity> where the identity matches the required
require_auth() address is the simplest approach and covers all recipes in this document.
| Role | Address | Capabilities |
|---|---|---|
| Admin | LIQUIFACT_ADMIN_ADDRESS |
init, legal hold, maturity update, admin transfer, attestation binding |
| SME | set at init |
settle, withdraw, record_sme_collateral_commitment |
| Investor | any funded address | fund, fund_with_commitment, claim_investor_payout |
| Treasury | set at init (immutable) |
sweep_terminal_dust |
The following environment variables are required for production deployments and must never be committed to version control, logged, or shared:
| Variable | Purpose | Required for |
|---|---|---|
SOURCE_SECRET |
Deployer / admin Stellar secret key (S...) |
stellar contract deploy, init, all admin calls |
LIQUIFACT_ADMIN_ADDRESS |
Initial admin address (G...) |
--admin arg in init |
In production, admin should be a multisig or governed contract address, not a single hot key.
See the repository README for the release runbook.
Token transfers (principal custody, payout disbursement, yield payment) are handled by the integration layer, not by this escrow contract. The escrow stores numeric state only.
The external_calls::transfer_funding_token_with_balance_checks function in
escrow/src/external_calls.rs enforces pre/post balance
equality for SEP-41 tokens used in sweep_terminal_dust. Fee-on-transfer, rebasing, and hook
tokens are explicitly out of scope and will cause a panic on balance assertion.
- All addresses above are placeholder simulation identities.
- Do not use
stellar container start localcredentials against Testnet or Mainnet. - The local standalone validator has no real economic value; keys generated with
--fundare funded from a local friendbot and have no Mainnet equivalent. - For Testnet simulation, replace
--network localwith--network testnetand fund accounts via the Stellar Testnet Friendbot.
| Status | Value | Allowed next transitions |
|---|---|---|
| Open | 0 |
→ 1 (funded, via fund) |
| Funded | 1 |
→ 2 (settled, via settle) or 3 (withdrawn, via withdraw) |
| Settled | 2 |
terminal — claim_investor_payout, sweep_terminal_dust only |
| Withdrawn | 3 |
terminal — sweep_terminal_dust only |
Transitions are forward-only. Legal hold blocks settle, withdraw, claim_investor_payout,
fund (new funding), and sweep_terminal_dust.
The contract enforces a maximum of 128 distinct investor addresses per escrow instance (or
the max_unique_investors value set at init). This is a storage-growth guard against
denial-of-storage attacks. Invoices requiring more than 128 backers should be split across
multiple escrow instances or handled via a higher-level allocation flow.
settle and claim_investor_payout compare against Env::ledger().timestamp() (validator-
observed ledger time), not wall-clock time. Maturity and commitment lock boundaries are
>= / < integer second comparisons. Simulated and live network timestamps may differ.
The optional registry address stored at init is a discoverability hint for indexers only.
It is not verified or called by this contract and must not be used as proof of registry state
without independent verification.
The escrow-repl tool provides an interactive CLI for inspecting contract state without writing test code.
cd repl-cli
cargo build --release
./target/release/escrow-repl --network local --contract <ESCROW_CONTRACT_ID>./target/release/escrow-replRuns with mock data, useful for testing command syntax.
Fetch current escrow state:
escrow> get_escrow
{
"invoice_id": "INV_001",
"status": 1,
"funded_amount": 95000000,
"funding_target": 100000000,
...
}
Fetch contract schema version:
escrow> get_version
{
"schema_version": 7,
"contract_version": "0.1.0",
"build_timestamp": "2026-08-29T09:15:05Z"
}
Check if dispute pause is active:
escrow> is_dispute_paused
{
"is_paused": false,
"pause_ticket_id": null,
"resumes_at": null
}
Export complete state snapshot for backup or migration:
escrow> export_state | jq . | less
{
"schema_version": 7,
"escrow": { ... },
"funding_token": "TTOKEN...",
"treasury": "GTREASURY...",
"legal_hold": false,
"unique_funder_count": 42,
...
}
All output is pretty-printed JSON, suitable for piping to tools like jq:
# Filter escrow summary
escrow> get_escrow | jq '{status, funded_amount, funding_target}'
# Save snapshot to file
escrow> export_state > backup.json
# Extract legal hold state
escrow> export_state | jq '.legal_hold'escrow> help
escrow> help export_state
escrow> quit
- Read-only: Inspection only; no state mutations (use
stellar contract invokefor write operations) - Demo mode: Real RPC integration planned for future releases
- Single network: Use
--network local|testnet|mainnetor--rpc-urlto switch
- repl-cli/README.md — Full REPL documentation
- FEATURE_220_REPL_DESIGN.md — Design specification