Design rationale: The major design choices behind this architecture are documented as Architecture Decision Records in
docs/adr/.
- System Overview
- Component Descriptions
- Data Flows
- Technology Decisions
- Deployment Topology
- Smart Contracts (detailed)
Invoice Liquidity Network is a two-sided protocol on Stellar that connects invoice holders (freelancers, SMEs) with liquidity providers (DeFi users). The system consists of multiple components that work together:
┌──────────────────────────────────────────────────┐
│ Frontend │
│ (Web dApp / Mobile App / Dashboard) │
└──────────────┬───────────────────────────────────┘
│
│
┌─────────────────────────────┼─────────────────────────────┐
│ ┌─────────────▼─────────────┐ │
│ │ @iln/sdk │ │
│ │ TypeScript SDK │ │
│ │ (typed contract client) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ @iln/cli │ │ Soroban │ │ @iln/indexer│ │
│ │ Terminal │ │ Smart │ │ REST API │ │
│ │ Interface │◄──►│ Contracts │◄──►│ Event │ │
│ │ │ │ (WASM) │ │ Indexer │ │
│ └────────────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
└──────────────────────────┼────────────────────┼──────────┘
│ │
┌────────▼────────┐ ┌───────▼────────┐
│ Stellar Network │ │ @iln/notify │
│ (Horizon + RPC) │ │ Webhook & │
│ │ │ Email Service│
└──────────────────┘ └───────────────┘
| Component | Location | Role |
|---|---|---|
| Smart Contracts | contracts/ |
On-chain escrow, governance, distribution, reputation |
| TypeScript SDK | sdk/ |
Typed client library for contract interaction |
| CLI | cli/ |
Terminal-based wallet and invoice management |
| Indexer | indexer/ |
REST API indexing Horizon events into a queryable database |
| Notifications | notifications/ |
Webhook and email delivery for on-chain events |
| Frontend | External | Web/mobile dApps consuming SDK and indexer APIs |
Four Soroban contracts deployed to each Stellar network. See Contracts for full details.
- invoice_liquidity — Core escrow: submit, fund, settle, cancel, default invoices; multi-token support; reputation scoring.
- iln_governance — On-chain governance: proposals, voting, delegation, quorum, admin veto.
- iln_distribution — Yield and incentive distribution for LPs, freelancers, and payers.
- reputation_bonus — Reputation-based discount bonuses and invoice hooks.
A typed client library that wraps raw Soroban RPC calls. It provides:
- Typed functions for every contract operation (
submitInvoice,fundInvoice,markPaid,claimDefault, etc.) - Query helpers (
getInvoice,listInvoicesBySubmitter,listInvoicesByLP,getReputation) - Error type hierarchy (
ILNErrorsubclasses for every contract error code) - Works in both browser (Freighter wallet) and Node.js (keypair) environments
- Full TypeScript types exported for
Invoice,ReputationProfile,ContractStats
Why a dedicated SDK? The Soroban RPC uses raw XDR ScVal encoding which is error-prone. The SDK provides a type-safe layer that converts between native JS types and ScVal, so integrators never touch XDR directly.
A terminal application that wraps the SDK for direct interaction:
- Wallet management (generate, import, show balances, fund via Friendbot)
- Invoice operations (submit, list, cancel)
- Reputation queries
- JSON output mode for CI/scripting
- Shell completion (bash, zsh, fish)
A REST API service that listens to Stellar Horizon for contract events and indexes them into a SQLite database. It exists because:
- Soroban contract storage is not directly queryable by field — you must know the exact key
- The indexer provides SQL-powered queries: list all invoices by status, aggregate stats, leaderboards, reputation scores
- Reduces RPC load on Soroban endpoints
Endpoints: GET /invoices, GET /stats, GET /leaderboard, GET /reputation, GET /health.
A webhook and email notification service that delivers real-time alerts for on-chain events. Features:
- Webhook delivery with HMAC-SHA256 payload signing
- Per-endpoint circuit breaker (5 failures → 10 min cooldown → half-open probe)
- Per-endpoint sliding-window rate limiter (1000 deliveries/hour default)
- Subscription CRUD (create, list, delete webhooks)
- Email delivery via Resend SDK adapter
Web and mobile applications that consume the SDK and indexer APIs. Not part of this repository but referenced to show the full architecture.
Freelancer @iln/sdk Soroban Contract Indexer
│ │ │ │
│ submitInvoice(payer, │ │ │
│ amount, dueDate, │ │ │
│ discountRate, token) │ │ │
│───────────────────────►│ │ │
│ │ submit_invoice() │ │
│ │───────────────────────►│ │
│ │ │─ validate inputs │
│ │ │─ assign ID │
│ │ │─ save (Pending) │
│ │ │─ emit "submitted" │
│ │ │ │
│ │ return InvoiceId │ │
│ │◄───────────────────────│ │
│ return InvoiceId │ │ │
│◄───────────────────────│ │ │
│ │ │ poll/subscribe │
│ │ │◄────────────────────│
│ │ │ "submitted" event │
│ │ │────────────────────►│
│ │ │ │─ persist to DB
LP @iln/sdk Soroban Contract Indexer
│ │ │ │
│ fundInvoice(invoiceId, │ │ │
│ amount) │ │ │
│────────────────────────►│ │ │
│ │ fund_invoice() │ │
│ │───────────────────────►│ │
│ │ │─ assert Pending │
│ │ │─ transfer LP→contract│
│ │ │─ transfer→freelancer │
│ │ │─ save (Funded) │
│ │ │─ emit "funded" │
│ │ │ │
│ │ return confirmed │ │
│ │◄───────────────────────│ │
│ return txHash + yield │ │ │
│◄────────────────────────│ │ │
│ │ │ poll/subscribe │
│ │ │◄─────────────────────│
│ │ │ "funded" event │
│ │ │─────────────────────►│
│ │ │ │─ persist to DB
Soroban Contract Stellar Network Indexer Client App
│ │ │ │
│─ emit "funded" │ │ │
│───────────────────────►│ │ │
│ │─ Horizon ingest │ │
│ │───────────────────►│ │
│ │ │─ parse event XDR │
│ │ │─ update SQLite │
│ │ │ │
│ │ │ GET /invoices │
│ │ │◄─────────────────────│
│ │ │─────────────────────►│
│ │ │ return JSON │
│ │ │◄─────────────────────│
Soroban Contract Stellar Network Indexer Notifications
│ │ │ │
│─ emit "paid" │ │ │
│───────────────────────►│ │ │
│ │─ Horizon ingest │ │
│ │───────────────────►│ │
│ │ │─ detect new event │
│ │ │─ push to SNS/queue │
│ │ │────────────────────►│
│ │ │ │─ look up webhooks
│ │ │ │─ sign payload
│ │ │ │─ POST to endpoint
│ │ │ │─ send email (if configured)
- Low fees — Soroban transactions cost fractions of a cent, making micro-invoice financing viable.
- Fast finality — 3–5 second confirmation, versus minutes on other chains.
- Built-in asset support — USDC, EURC, and other Stellar assets are natively supported via the Stellar Asset Contract (SAC) interface.
- No MEV — Stellar's consensus protocol does not allow frontrunning or transaction reordering.
- Rust-based contracts — Soroban contracts are written in Rust, providing memory safety and the WASM compilation target.
See ADR-001: Soroban Chain Choice.
- Developer experience — The Soroban RPC communicates in raw XDR ScVal; the SDK provides a typed, documented API that handles encoding/decoding automatically.
- Universal runtime — Works in browsers (via Freighter wallet) and Node.js, enabling both dApps and backend integrations.
- Error type hierarchy — Maps every contract error code to a typed
ILNErrorsubclass for precise error handling.
- Query flexibility — Horizon only exposes raw operations and effects; the indexer provides SQL-level queries (list invoices by status, aggregate stats, leaderboards).
- Reduced RPC cost — Soroban RPC has rate limits and per-request costs; the indexer caches contract state and serves reads without RPC calls.
- Off-chain aggregation — Reputation scoring, leaderboards, and historical analytics require data that is expensive to compute from raw on-chain state.
- Zero infrastructure — No database server to manage; the indexer is a single binary with a file-based database.
- Good enough performance — For a protocol at the scale of ILN, SQLite handles millions of invoice records easily.
- Easy deployment — Works with Docker volumes, no separate Postgres setup required for most deployments.
┌──────────────────────┐
│ Cloudflare / DNS │
└──────────┬───────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Indexer │ │ Notifications│ │ Frontend │
│ (Container) │ │ (Container) │ │ (CDN/SPA) │
└──────┬───────┘ └──────┬───────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SQLite │ │ PostgreSQL │
│ (Volume) │ │ (Volume) │
└──────────────┘ └──────────────┘
│ │
└────────────────┘
│
▼
┌─────────────────────┐
│ Stellar Network │
│ (Horizon + RPC) │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Soroban Contracts │
│ (WASM instances) │
└─────────────────────┘
| Component | Hosting | Scaling Strategy |
|---|---|---|
| Indexer | Container (Docker/K8s) | Horizontal scaling behind load balancer |
| Notifications | Container (Docker/K8s) | Horizontal scaling, requires shared queue |
| Frontend | Static CDN (Cloudflare Pages, Vercel) | Automatic CDN scaling |
| SQLite | Volume mount | Vertical scaling, or migrate to Postgres |
| PostgreSQL | Managed DB (RDS, Cloud SQL) | Read replicas for notifications |
- Testnet — All components deployed in a single staging environment for integration testing.
- Mainnet — Production deployment with separate database instances, stricter access controls, and monitoring.
| Actor | Role | What they do |
|---|---|---|
| Freelancer | Invoice holder | Submits unpaid invoices, receives immediate liquidity |
| Payer | Invoice debtor | The client who owes the money, settles the invoice on-chain |
| Liquidity provider (LP) | Funder | Funds invoices at a discount, earns yield when payer settles |
| ILN Contract | Trustless escrow | Holds funds, enforces rules, routes payments |
The contract uses Soroban's persistent storage with two key types:
StorageKey::InvoiceCount → u64 (auto-incrementing ID counter)
StorageKey::Invoice(id: u64) → Invoice struct
Each Invoice struct holds the full state of one invoice:
Invoice {
id: u64,
freelancer: Address,
payer: Address,
amount: i128, // full invoice value in stroops
due_date: u64, // Unix timestamp
discount_rate: u32, // basis points (e.g. 300 = 3%)
status: InvoiceStatus,
funder: Option<Address>,
funded_at: Option<u64>,
}Invoice status follows a strict one-way state machine:
Pending → Funded → Paid
→ Defaulted
No status transition can go backwards. A Paid or Defaulted invoice is terminal.
Called by the freelancer to register an unpaid invoice.
Freelancer
│
├─ require_auth() freelancer must sign
├─ validate amount > 0
├─ validate discount_rate 1–5000 bps
├─ validate due_date > now
├─ assign next invoice ID
├─ save Invoice { status: Pending }
└─ emit "submitted" event
No funds move at this step. The invoice sits in Pending state until an LP funds it.
Called by a liquidity provider to fund a Pending invoice.
LP
│
├─ require_auth()
├─ load invoice, assert status == Pending
├─ calculate:
│ discount_amount = amount × discount_rate / 10_000
│ freelancer_payout = amount − discount_amount
│
├─ token.transfer(LP → contract, amount)
│ LP sends the full invoice value to the contract
│
├─ token.transfer(contract → freelancer, freelancer_payout)
│ Contract immediately pays out (amount − discount) to freelancer
│ Freelancer has their liquidity. Invoice is now funded.
│
├─ contract holds: discount_amount in escrow
├─ update Invoice { status: Funded, funder, funded_at }
└─ emit "funded" event
After this step:
- The freelancer has received their money (minus the discount)
- The LP has committed funds and is waiting for the payer to settle
- The contract holds the discount amount in escrow
Called by the payer to settle the invoice in full.
Payer
│
├─ require_auth() only the registered payer can call this
├─ load invoice, assert status == Funded
├─ assert due_date has not passed (optional: allow late payment)
│
├─ token.transfer(payer → contract, amount)
│ Payer sends the full invoice value to the contract
│
├─ token.transfer(contract → funder, amount)
│ Contract releases the full amount to the LP
│ LP receives: their principal back + the escrowed discount = yield
│
├─ update Invoice { status: Paid }
└─ emit "paid" event
After this step the LP has earned the discount spread as yield:
LP sent: 1,000 USDC (at fund_invoice time)
LP received: 1,000 USDC (from payer settlement)
Escrowed yield: 30 USDC (3% discount, returned alongside principal)
Net yield: 3.00%
Called by the LP after the due date passes without payment.
LP (original funder)
│
├─ require_auth()
├─ load invoice, assert status == Funded
├─ assert env.ledger().timestamp() > due_date
│
├─ token.transfer(contract → funder, discount_amount)
│ LP recovers only the escrowed discount
│ The freelancer's payout cannot be reversed
│
├─ update Invoice { status: Defaulted }
└─ emit "defaulted" event
In a default the LP loses amount − discount_amount (the freelancer's payout). The discount amount is returned as partial recourse. This is the core risk LPs accept — it is why discount rates exist.
fund_invoice()
┌─────────────────────────────────────────────┐
│ │
▼ │
LP ──── 1,000 USDC ──▶ CONTRACT ──── 970 USDC ──▶ FREELANCER │
│ │
│ holds 30 USDC (discount escrow) │
│ │
mark_paid() │
│ │
PAYER ── 1,000 USDC ──▶ CONTRACT ──── 1,000 USDC ──▶ LP │
(principal + yield) │
│
claim_default() (if payer misses due_date) │
│ │
CONTRACT ──── 30 USDC ──▶ LP │
(partial recourse only) │
└─
ILN uses USDC on Stellar, accessed via Soroban's native token interface:
use soroban_sdk::token::Client as TokenClient;
fn usdc_client(env: &Env) -> TokenClient {
let address = Address::from_str(env, USDC_TOKEN);
TokenClient::new(env, &address)
}All amounts are stored and transferred in stroops (Stellar's base unit). 1 USDC = 10,000,000 stroops. The contract never converts — all arithmetic is in stroops to avoid rounding errors.
Discount calculation uses integer basis points to avoid floating point:
let discount_amount = invoice.amount
.checked_mul(invoice.discount_rate as i128)
.unwrap_or(0)
/ 10_000;The contract emits events at each state transition so indexers, frontends, and analytics tools can track activity without polling storage:
| Event | Emitted by | Payload |
|---|---|---|
submitted |
submit_invoice() |
invoice ID |
funded |
fund_invoice() |
invoice ID |
paid |
mark_paid() |
invoice ID |
defaulted |
claim_default() |
invoice ID |
No admin key. There is no privileged address that can pause the contract, drain funds, or alter invoice state. Once an invoice is funded, only the registered payer can trigger mark_paid() and only the original funder can trigger claim_default().
Auth on every state transition. Every function that moves funds calls require_auth() on the relevant party before doing anything else. Unsigned calls revert immediately.
Integer-only arithmetic. No floating point anywhere in the contract. All amounts are i128 in stroops. Discount calculations use basis points and integer division. Overflow is caught with checked_mul.
Immutable invoice terms. Once submitted, the amount, payer, due date, and discount rate cannot be changed. The LP knows exactly what they are funding when they call fund_invoice().
One funder per invoice. An invoice can only be funded once. The second call to fund_invoice() on a Funded invoice returns AlreadyFunded immediately.
contracts/invoice_liquidity/src/
├── lib.rs — contract entry point, all public functions
├── invoice.rs — Invoice struct, InvoiceStatus enum, storage helpers
├── errors.rs — ContractError enum
└── tests.rs — unit tests (native target only)
The contract is compiled to .wasm and deployed to Stellar's network via the Stellar CLI:
# Build
cargo build --target wasm32v1-none --release
# Deploy to testnet
stellar contract deploy \
--wasm target/wasm32v1-none/release/invoice_liquidity.wasm \
--source alice \
--network testnetTestnet contract ID: CD3TE3IAHM737P236XZL2OYU275ZKD6MN7YH7PYYAXYIGEH55OPEWYJC
There is one contract instance per network. All invoices across all users are stored within that single contract's persistent storage, keyed by invoice ID.
Default risk is unmitigated. If a payer defaults, the LP loses the principal they advanced to the freelancer. There is no insurance pool, no credit scoring, and no collateral requirement. LPs should assess payer quality manually.
Payer verification is on-chain only. mark_paid() requires the registered payer address to sign the transaction. There is no mechanism to verify that the on-chain payer address corresponds to the real-world client. Invoice submission is currently trust-based between freelancer and payer.
No dispute mechanism. If a freelancer submits a fraudulent invoice, the LP has no on-chain recourse beyond claim_default() after the due date. Dispute resolution is out of scope for v1.
Single asset. Only USDC is supported. Multi-asset support is on the roadmap.