Trustless, milestone-based escrow for freelance and remote work β built on Stellar's Soroban smart contract platform.
Remote work and freelance contracting run on trust that often doesn't exist between strangers across borders. Clients hesitate to pay upfront. Workers hesitate to deliver without payment guarantees. The usual fix β a centralized escrow middleman β adds fees, delays, and a single point of failure.
Trellis removes the middleman. Funds are locked on-chain, released milestone by milestone as work is verified, with a built-in dispute process if either party disagrees. No platform holds your money. The contract does.
This matters everywhere, but especially for contributors in emerging markets β where access to reliable, low-fee, borderless payment infrastructure can be the difference between taking on international work or not.
π‘ Why Soroban?
Contracts are written in real Rust, compiled to WASM, and run on the Stellar network β which has fast finality, low fees (~fractions of a cent), and an established USDC presence via Circle's Stellar Asset Contract. This makes it well-suited for cross-border payment use cases.
A live test agreement exists on-chain and is queryable right now:
trellis status --agreement-id 0101010101010101010101010101010101010101010101010101010101010101| Attribute | Value |
|---|---|
| Contract ID | CAUAO7CYKULE2K4EJMQ6LLRUHP7Y7JYOH6G2VBXKYG7PTETE3UZ3DU7Q |
| Network | Stellar Testnet |
| Explorer | View on Stellar Lab |
Full deployment details, every verified command, and step-by-step deployment instructions are in DEPLOYMENT.md.
Trellis models a freelance engagement as an agreement made up of one or more milestones, each with its own funding, work submission, and release lifecycle.
PENDING FUNDED WORK SUBMITTED COMPLETED DISPUTED REFUNDED CANCELLED lock_funds submit_work approve & release cancel_unfunded raise_dispute raise_dispute resolve (refund) resolve (release) Payer action Payee action Either party Resolver action| Role | Responsibility |
|---|---|
| Payer π§βπΌ | Funds milestones and approves completed work |
| Payee π¨βπ» | Submits proof of completed work and receives payment on approval |
| Dispute Resolver βοΈ | A neutral third party who can rule on disputes, releasing funds to either side |
- β Funds are held by the contract β not by either party or a platform
- β Either party can raise a dispute β neither can unilaterally freeze funds by going silent
- β Unfunded milestones can be cancelled β walk away cleanly with no cost
- β Every state transition emits an on-chain event β off-chain clients track progress in real time without polling
Trellis is a monorepo with three layers:
LAYER 1 Β· ON-CHAIN contracts/trellis_core/ Soroban Smart Contract (Rust β WASM) lib.rs types.rs storage.rs events.rs LAYER 2 Β· OFF-CHAIN cli/trellis_cli/ Command-Line Interface (Rust + clap + reqwest) main.rs config.rs rpc.rs LAYER 3 Β· WEB frontend/ Web Dashboard (React + Vite + TypeScript + Tailwind) App.tsx components/ lib/ Stellar Network| Function | Caller | Effect |
|---|---|---|
init |
Payer | Creates a new agreement with one or more milestones (each amount must be strictly positive) |
lock_funds |
Payer | Deposits funds for a milestone into the contract |
submit_work |
Payee | Submits proof of completed work for a funded milestone |
approve_and_release |
Payer | Approves submitted work, releases the remaining escrowed funds to payee |
release_partial |
Payer | Releases part of a Funded/WorkSubmitted milestone's escrowed funds as a progress payment; the milestone completes once fully released |
raise_dispute |
Payer or Payee | Flags a milestone for resolver review |
resolve_dispute |
Dispute Resolver | Rules on a dispute β refunds payer or pays payee |
cancel_unfunded_milestone |
Payer | Cancels a milestone that was never funded β status becomes Cancelled, never Refunded (reserved for dispute refunds) |
get_agreement |
Anyone | Returns the full current state of an agreement (read-only) |
get_total_amount |
Anyone | Returns the agreement's total value β sum of all milestone amounts (read-only) |
batch_lock_funds |
Payer | Funds multiple milestones atomically in one transaction |
get_milestone |
Anyone | Returns a single milestone's state, or none if the agreement or milestone does not exist (read-only) |
extend_agreement_ttl |
Anyone | Renews an agreement's ledger TTL to avoid archival |
set_milestone_deadline |
Payer | Sets an optional deadline (ledger timestamp) on a still-Pending milestone |
get_milestone_deadline |
Anyone | Returns a milestone's deadline, if one is set (read-only) |
expire_milestone |
Anyone | After the deadline, closes a stalled Pending or Funded milestone as Refunded, returning any locked funds to the payer |
𧬠Native ScVal Encoding
The CLI builds Soroban
ScVal arguments natively in Rust instead of relying on the stellar CLI's JSON-to-XDR conversion. Scalar arguments (addresses, i128 amounts, symbols) are encoded by the scalar encoder, and the milestone vector passed to init is encoded by a dedicated builder that mirrors the contract's exact #[contracttype] layout: each Milestone is a struct-of-fields map, amount is an ScVal::I128, and status is encoded as the EscrowStatus::Pending enum tag. The resulting Vec<Milestone> ScVal is cross-checked against what the stellar CLI produces for the same input.
π¦ Storage Lifetime
Soroban archives persistent ledger entries once their TTL expires, so an agreement that is never touched would eventually be lost. Every state-mutating entrypoint renews the agreement's TTL to ~30 days automatically, and the view functions (
get_agreement, get_milestone, get_total_amount) renew it as well whenever a read finds the remaining TTL below the threshold β so a read is not strictly side-effect free, and the caller pays for the extension. Reading keeps a watched agreement alive between transitions rather than leaving it to expire. Agreements that stay idle longer than that β a long delivery window, a stalled dispute β need extend_agreement_ttl called before the TTL runs out; any address may call it, and the caller pays the rent.
β±οΈ Milestone Deadlines
A milestone can carry an optional deadline so an unresponsive counterparty cannot stall it forever. The payer sets it with
set_milestone_deadline while the milestone is still Pending, so the payee sees it before any funds are locked or work starts. It is a ledger timestamp (Unix seconds) and must be in the future. Once env.ledger().timestamp() is past the deadline, anyone (payer, payee or a keeper) may call expire_milestone:
Pending(never funded) βRefunded; no tokens move.Funded(payee never submitted work) βRefunded; the locked amount returns to the payer.WorkSubmittedandDisputedare never expired: once the payee has delivered, a silent payer must not win by default, and a timeout must not cut arbitration short. Useraise_dispute/resolve_disputeinstead.
Tradeoffs: timestamps are used instead of ledger sequences because deadlines are agreed in wall-clock terms and ledger close times vary; validators bound timestamp drift, so this is precise to within seconds, not to the ledger. Expiry is an explicit call rather than automatic, because Soroban has no scheduler. Deadlines live in a separate storage entry (not a Milestone field), so the init argument layout and every existing caller are unchanged. That entry has its TTL renewed alongside the agreement's.
| Category | Technologies |
|---|---|
| Smart Contract | Soroban Β· soroban-sdk 22.x Β· Rust (#![no_std] β WASM) |
| CLI | clap 4 Β· clap_complete Β· serde + serde_json Β· dotenvy |
| Frontend | React 19 Β· Vite Β· TypeScript Β· Tailwind CSS Β· React Router |
| Stellar SDK | @stellar/stellar-sdk Β· @stellar/freighter-api Β· Soroban RPC |
- Rust (stable toolchain)
wasm32-unknown-unknowntarget:rustup target add wasm32-unknown-unknownstellarCLI 26.x+- Node.js 20+ (for the frontend)
cd frontend
cp .env.example .env
npm install
npm run devOpen http://localhost:5173 to see the animated landing page with the particle network background, typewriter effects, live contract activity stats (agreements created / milestones locked, within the RPC event retention window), and full agreement management UI.
cd contracts/trellis_core
cargo testThe suite has 51 tests, all run in the Soroban sandbox: 31 example-based tests in test.rs (happy path, double-init protection, dispute resolution, milestone cancellation, role checks, batch operations, TTL extension, and the get_agreement view function), 11 property-based proptest tests in test_properties.rs (balance conservation, invalid amounts, milestone isolation), and 9 panic-boundary tests in test_panic_boundaries.rs.
make buildRuns the contract WASM build, CLI binary build, and frontend bundle in sequence. See make help for all available targets.
The workspace's crate graph is scanned for known vulnerabilities, disallowed
licences, and unexpected source registries. This runs automatically in CI
(the supply-chain job in .github/workflows/contract-ci.yml) and can be run
locally:
cargo install --locked cargo-deny cargo-audit
cargo deny check # enforces deny.toml: advisories, licences, duplicate versions, sources
cargo audit # RustSec advisory database checkNote: Keep Cargo.lock in sync with Cargo.toml. CI now verifies the
lockfile with cargo check --locked --workspace and will fail if it's out
of date. To update the lockfile locally after changing dependencies run:
cargo update -p <pkg>
# or to generate/update the lockfile explicitly:
cargo generate-lockfilePolicy lives in deny.toml at the repository root. A new
RustSec advisory against any dependency (direct or transitive) fails the
build.
Frontend npm dependencies are scanned by npm audit in CI
(.github/workflows/npm-audit.yml) β run it locally with:
cd frontend
npm audit --audit-level=highKnown false positives are documented in frontend/.audit-allowlist.json.
cd contracts/trellis_core
cargo rustc --manifest-path=Cargo.toml --crate-type=cdylib --target=wasm32-unknown-unknown --releaseSee DEPLOYMENT.md for the full deployment walkthrough and why this command is used instead of
stellar contract build.
cd cli/trellis_cli
cargo build --release
export STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
export STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
export TRELLIS_CONTRACT_ID="CAUAO7CYKULE2K4EJMQ6LLRUHP7Y7JYOH6G2VBXKYG7PTETE3UZ3DU7Q"
export TRELLIS_SOURCE_KEY="<your stellar identity name>"
# Create a new agreement
trellis init \
--agreement-id <hex-id> \
--payer <payer-address> \
--payee <payee-address> \
--token <token-contract-address> \
--resolver <resolver-address> \
--milestones "1000,2000"
# Check status
trellis status --agreement-id <hex-id>
# Check a single milestone's status
trellis milestone-status --agreement-id <hex-id> --milestone-id 0
# Fund the first milestone
trellis lock-funds --agreement-id <hex-id> --milestone-id 0All 9 escrow commands are implemented β init, lock-funds, submit-work, approve-release, raise-dispute, resolve-dispute, cancel-milestone, status, and milestone-status. The CLI also provides two utility commands: completion (see Shell Completions) and keys (manage secret keys in the OS keychain). See DEPLOYMENT.md for the full command reference.
trellis health checks the configured RPC endpoint natively (getHealth + getLatestLedger over HTTP). It needs only an RPC URL, with no stellar binary, contract ID or source key, and it supports --json / --human-readable / --quiet / --dry-run.
These flags work with every command and can be combined with .env/env-var configuration:
# Preview without submitting
trellis lock-funds --agreement-id <hex-id> --milestone-id 0 --dry-run
# Machine-parseable JSON
trellis status --agreement-id <hex-id> --json
# Suppress everything except final JSON (implies --json)
trellis status --agreement-id <hex-id> --quiet
# Colorized summary
trellis status --agreement-id <hex-id> --human-readable # or -HRead-only queries (status, milestone-status) decode the raw XDR ScVal
returned by simulateTransaction natively in the CLI β no stellar binary is
required for these commands. The decoded result is rendered through the same
render_json/render_human paths as every other command, so --json,
--human-readable, and --quiet all behave identically whether or not the
Stellar CLI is installed.
Native transaction simulation is implemented in
cli/trellis_cli/src/rpc.rs (RpcClient::simulate_transaction) against the
Soroban JSON-RPC simulateTransaction method. For a base64
TransactionEnvelope XDR it returns a typed result containing the recommended
minimum resource fee (minResourceFee), the resource fee and instruction /
I/O-byte budgets embedded in the transactionData, and the fully parsed
ledger footprint (its read-only and read-write LedgerKeys). For a read-only
invocation the returned results[0].xdr ScVal is decoded directly, so a
query value can be fetched without any signing key. A reverted host function
call is surfaced as a typed contract error, kept distinct from network-level
and JSON-RPC-level failures so callers can tell "the contract said no" apart
from "the network was unreachable".
--dry-run prints the stellar contract invoke command that would be executed
without actually running it or submitting anything on-chain. Because it never
spawns the stellar binary, it works on machines where the Stellar CLI is not
installed β useful for previewing command construction in CI or on a fresh
checkout.
Milestone arguments passed to init (e.g. --milestones "1000,2000") are
encoded to Soroban ScVal natively by the CLI, matching the contract's
Vec<Milestone> layout β no stellar CLI conversion step is involved.
--json takes priority over --human-readable when both are passed.
Before any command runs, the CLI calls the configured RPC endpoint's getNetwork
method and compares the returned passphrase against --network-passphrase
(or STELLAR_NETWORK_PASSPHRASE). If they differ, the command fails early with
an error naming both values, so a mismatched --rpc-url (e.g. mainnet RPC with a
testnet passphrase) is caught immediately instead of surfacing as a confusing
downstream failure. This check is skipped under --dry-run.
# bash
trellis completion bash > /etc/bash_completion.d/trellis
# zsh
trellis completion zsh > "${fpath[1]}/_trellis"
# fish
trellis completion fish > ~/.config/fish/completions/trellis.fishSupported shells: bash, zsh, fish, elvish, powershell.
- Core Soroban escrow contract β all 12 entrypoints implemented and tested
- Full state machine β happy path, dispute resolution, and cancellation paths
- Contract test suite β 51 tests in the Soroban sandbox
- Full CLI β all 8 commands wired end-to-end with JSON, dry-run, and human-readable output modes
- Native ScVal encoding β scalar arguments and the
Vec<Milestone>argument toinitare built directly in Rust - Deployed live on Stellar testnet β
initandstatusverified against the live contract - Frontend dashboard β 5 pages, 28 components, 12 custom hooks, animated particle network background
- Wallet connect β Freighter wallet integration with connection states
- Event feed β real-time on-chain event history per agreement (limited to the last ~100k ledgers, ~6 days, that RPC providers retain; full history awaits an event-indexing service, #496)
- Shell completions β bash, zsh, fish, elvish, powershell
- Native strkey codec β
G.../S.../C...Stellar address encode/decode with CRC16 checksum validation (cli/trellis_cli/src/strkey.rs)
| Area | Description | Difficulty |
|---|---|---|
| Frontend β Agreement Status page | Polish and edge cases for live agreement state display | Intermediate |
| Frontend β Create Agreement form | Validation UX, milestone builder refinements | Intermediate |
| Frontend β Milestone actions | lock, submit, approve, dispute button workflows | Intermediate |
| Frontend β Event feed enhancements | Real-time updates, filtering, pagination | Intermediate |
| Native RPC client | Replace stellar CLI shell-out with native Rust HTTP client | Advanced |
| Documentation | CONTRIBUTING.md and contributor onboarding guide | Beginner |
See Issues for the full task list β each issue has exact requirements, acceptance criteria, a suggested branch name, and a timeframe.
Trellis is built in the open and welcomes contributors of all experience levels β from documentation and frontend components to contract enhancements and tooling.
- Fork the repo and clone it locally
- Browse open issues tagged
good first issueorhelp wanted - Comment on the issue to claim it β wait for maintainer confirmation before starting
- Branch:
git checkout -b feat/your-feature-name - Verify: run
cargo testincontracts/trellis_core - PR: open a PR referencing the issue with
Closes #X
New to Soroban? Start with the official Soroban docs.
New to React + Stellar? Read throughfrontend/src/lib/config.tsand the Stellar SDK docs.
MIT β see LICENSE for the full text.