Four Soroban smart contracts powering the TrusTrove trade finance protocol on Stellar.
Live App · App Repo · Stellar Explorer
TrusTrove is a decentralized trade finance protocol on Stellar. SMEs tokenize unpaid invoices and receive immediate USDC funding from a shared liquidity pool. Liquidity providers deposit USDC and earn yield from discount fees when invoices repay. No banks, no brokers — four Soroban smart contracts handle everything.
| Name | Role | GitHub | Telegram | |
|---|---|---|---|---|
| Fuhad (K1NGD4VID) | Founder & Lead Developer | @k1ngd4vid | @k1ngd4vid |
Join the contributor community: t.me/trusttrove
Seed-issue generator scripts live in scripts/maintainer/, which is the only supported location for this tooling:
create_issues.py— generate issues from a templatecreate-contract-issues.sh/create-contract-issues.ps1— shell/PowerShell helpers
Run any script from the repo root, e.g. bash scripts/maintainer/create-contract-issues.sh.
Any
create_issues.*files found at the repo root are stale duplicates left over from before this tooling was consolidated underscripts/maintainer/. Do not use them — they lack the rate-limit/dedup guards thescripts/maintainer/versions have.
Tracks verified SME issuers and buyers.
initialize(admin)
register_issuer(address, metadata) → bool
register_buyer(address, metadata) → bool
is_verified(address) → bool
get_profile(address) → Profile
revoke(address) → bool
Revocation is prospective, not retroactive. is_verified() is re-checked
at every point where new business gets committed — invoice.create(),
invoice.list_for_financing(), and pool.fund_invoice() — so a revoked
issuer or buyer can't originate, list, or get funded on a new invoice. It is
not re-checked at any later lifecycle step (mark_shipped,
confirm_delivery, repay, repay_early, trigger_default): once an
invoice is Funded, pool capital is already committed and the repayment
terms are already fixed, so a later revoke() does not unwind, freeze, or
force-default an in-flight invoice. This is a deliberate choice — unwinding
committed capital on revocation would be disruptive to LPs and gameable
(e.g. an issuer could grief the pool by getting itself revoked mid-term to
force a default). Admins who need to stop a specific in-flight invoice have
invoice.trigger_default() (past due date) as the existing mechanism; there
is no separate "freeze this invoice" primitive.
Manages the full invoice lifecycle. Enforces valid state transitions. Emits events consumed by the Go indexer.
Created → Listed → Funded → Active → Confirmed → Repaid
↘ Defaulted
create(issuer, buyer, face_value, due_date, funding_asset) → invoice_id
submit_attestation(invoice_id, payload, signature) → bool
list_for_financing(invoice_id, discount_bps) → bool
mark_funded(invoice_id, funded_amount) → bool ← pool_contract only
mark_shipped(invoice_id) → bool
confirm_delivery(invoice_id, confirmer) → bool ← dual confirmation required
repay(invoice_id) → bool
trigger_default(invoice_id) → bool
get(invoice_id) → Invoice
get_attestation(invoice_id) → Option<Attestation>
get_by_status(status) → Vec<Invoice>
get_by_issuer(address) → Vec<Invoice>
set_agent_registry_contract(agent_registry_contract) → bool
Holds USDC between pool funding and issuer payout. Only callable by pool_contract.
lock(invoice_id, amount) → bool
release_to_issuer(invoice_id, issuer) → bool
release_to_pool(invoice_id, repayment_amount) → bool
handle_default(invoice_id, caller) → bool ← admin or pool_contract
get_locked(invoice_id) → u128
USDC liquidity pool with share-based LP accounting. Share price grows as invoices repay.
deposit(lp, usdc_amount) → shares
withdraw(lp, shares) → usdc_amount
fund_invoice(invoice_id) → bool ← re-verifies issuer & buyer against registry_contract
receive_repayment(invoice_id, amount) → bool ← invoice_contract only
handle_default(invoice_id) → bool
get_stats() → PoolStats
get_lp_position(address) → LPPosition
┌─────────────────┐
│ registry_contract │
│ (identity oracle) │
└────────┬────────┘
│ is_verified()
┌──────────────────▼──────────────────┐
│ invoice_contract │
│ (lifecycle state machine & indexer) │
└──────┬────────────────────┬──────────┘
│ mark_funded() │ receive_repayment()
│ trigger_default() │ handle_default()
┌──────▼───────┐ ┌───────▼──────────┐
│ pool_contract │ │ pool_contract │
│ fund_invoice │ │ (repayment in) │
└──────┬────────┘ └──────────────────┘
│ lock()
┌──────▼────────────┐
│ escrow_contract │
│ (USDC custody) │
└───────────────────┘
pool_contract also calls registry_contract.is_verified() directly
(not shown above) as part of fund_invoice, re-checking the issuer and
buyer before committing capital. See "Revocation is prospective, not
retroactive" above.
Each step below documents what happens to USDC and which contracts are called.
LPs deposit USDC into the pool and receive shares proportional to their contribution. Share price grows as invoices repay.
LP ──[USDC]──► Pool
Pool ──[shares]──► LP
The issuer creates an invoice (recording face_value, due_date, buyer, funding_asset), then lists it with a discount_bps expressing the yield they will give up in exchange for immediate liquidity.
Before listing, an Underwrite agent must sign an AttestationPayload (containing domain_separator, invoice_id, risk_score, evidence_hash, agent_id, nonce) off-chain with a secp256k1 key. Anyone can relay this signature via submit_attestation, which recovers the signer and verifies it against the agent-registry contract (deployed separately from the underwrite-contract repo). The agent-registry address is configured via set_agent_registry_contract (admin-only). list_for_financing panics with VerificationRequired until a valid attestation exists for the invoice.
No fund movement. Invoice status: Created → Listed.
Anyone can call pool.fund_invoice(invoice_id). The pool computes the funded amount, locks it in escrow, and marks the invoice as funded.
funded_amount = face_value × (10000 − discount_bps) / 10000
Pool ──[funded_amount USDC]──► Escrow (locked per invoice_id)
Invoice status: Listed → Funded
The pool retains face_value − funded_amount (the discount) as accrued yield, collectible when the buyer repays.
The pool contract is expected to call escrow.release_to_issuer(invoice_id, issuer) so that the locked USDC reaches the issuer who can then ship goods.
Escrow ──[funded_amount USDC]──► Issuer
fund_invoice (see Issue #56). In the current deployment, issuers do not automatically receive USDC after an invoice is funded. This is the highest-priority gap before mainnet.
The issuer calls mark_shipped. Then both the issuer and the buyer must independently call confirm_delivery. Only when both confirmations are recorded does the invoice advance to Confirmed.
No fund movement. Invoice status: Funded → Active → Confirmed.
The buyer calls invoice.repay(invoice_id), which transfers face_value USDC directly from the buyer to the pool, then calls pool.receive_repayment to account for the yield.
Buyer ──[face_value USDC]──► Pool
Pool books yield: face_value − funded_amount = discount earned
TotalDeposits += yield_amount (share price rises for all LPs)
Invoice status: Confirmed → Repaid
Repayment does not flow through escrow. The escrow contract is only involved in funding (Step 3), the missing issuer release (Step 4), and default recovery (Step 7).
If the invoice passes its due_date without reaching Repaid, any caller triggers invoice.trigger_default. The invoice contract calls pool.handle_default, which in turn calls escrow.handle_default — returning the still-locked funded_amount to the pool.
invoice.trigger_default()
└─► pool.handle_default()
└─► escrow.handle_default()
└─► Escrow ──[funded_amount USDC]──► Pool
Invoice status: → Defaulted
TotalFunded -= funded_amount (liquidity freed)
| Event | Source | Destination | Amount | Escrow involved? |
|---|---|---|---|---|
| LP deposit | LP wallet | Pool | usdc_amount |
No |
| LP withdraw | Pool | LP wallet | shares × price |
No |
| Fund invoice | Pool | Escrow | face_value × (1 − discount) |
Yes — locks |
| Release to issuer (gap) | Escrow | Issuer | funded_amount |
Yes — releases |
| Repay | Buyer wallet | Pool | face_value |
No |
| Default recovery | Escrow | Pool | funded_amount |
Yes — releases |
- The escrow contract only accepts
lock()calls from the registeredpool_contract. release_to_issuerandrelease_to_poolare callable only bypool_contract.handle_defaultin escrow accepts the pool or the admin (emergency recovery path).receive_repaymentin the pool is callable only by the registeredinvoice_contract.- Every state transition in
invoice_contractis guarded by an explicit status check; no skipping steps.
Detailed references: Threat Model · Storage Schema · Limitations
| Contract | Address |
|---|---|
| registry_contract | CABGWVIZFF62FG67ZGFEP67NEEY4WYTMFURDMFTKKNRDAFPKPOJDTN4C |
| invoice_contract | CA4O3MR7LWHRSUDBNU6FY6UDFFYBN7TGBZXBDZB4OYYXFYXIFJ6RJF6B |
| escrow_contract | CAJWGUKDTTC3SKN4RAAY72J4DVIIYSCFHX6GIMNTT22ABMISJK4GBCEH |
| pool_contract | CAKEWH7SJCXGV2MH2WZYIX3QDPTSSBQFXYVYBOWAGLNBBZMPLE2US6CS |
Note: Testnet addresses are subject to rotation. See DEPLOYMENT.md for our redeployment and lifecycle policy.
Verify on Stellar Expert Testnet
- Rust 1.85.0 (required — other versions either have WASM bugs or are blocked by Stellar CLI)
- Stellar CLI (latest)
- jq (latest) — required by
scripts/maintainer/update-readme-addresses.sh, which runs automatically at the end ofdeploy.sh
The repo ships a rust-toolchain.toml at the root pinning channel 1.85.0 and target wasm32v1-none. rustup picks it up automatically when you run any cargo/rustc command in this directory — you do not have to set a default. If the toolchain isn't installed yet, run:
rustup toolchain install 1.85.0
rustup target add wasm32v1-none --toolchain 1.85.0git clone https://github.com/TrusTrove/TrusTrove-contract.git
cd TrusTrove-contract
rustup run 1.85.0 stellar contract buildcargo test --workspaceSee DEPLOYMENT.md for the full deployment guide including prerequisites, contract wiring order, and rollback.
# Create and fund a deployer account
bash scripts/setup-testnet.sh
# Fund via browser: https://friendbot.stellar.org/?addr=YOUR_ADDRESS
# Deploy all four contracts
bash scripts/deploy.shOr on Windows (PowerShell):
./scripts/setup-testnet.ps1
./scripts/deploy.ps1The deploy script prints all four contract IDs at the end. Paste them into TrusTrove-app/.env.local.
The deploy script requires the Stellar CLI. Choose one:
- Linux/macOS: Install globally per Stellar docs — the script will find it on
PATH. - Windows (native): Use the PowerShell scripts (
scripts/setup-testnet.ps1andscripts/deploy.ps1). Install the Stellar CLI toProgram Files (x86)\Stellar CLI\, or setSTELLAR_BINenvironment variable. Run from PowerShell:powershell ./scripts/setup-testnet.ps1 powershell ./scripts/deploy.ps1
- Windows (WSL): Install Stellar CLI in your WSL environment, or install on Windows host and set
STELLAR_BINto the Windows path (e.g.,/mnt/c/Program Files (x86)/Stellar CLI/stellar.exe).
TrusTrove is in active development on Stellar testnet. Several centralization trade-offs were made deliberately to ship a working protocol quickly. They are documented here so contributors and users understand the current trust model and can help drive the path to a more decentralized design.
The deployer wallet that calls initialize() on each contract becomes its admin. That single key currently controls:
- Registering and revoking verified issuers/buyers (
registry_contract) - Emergency pausing (not yet implemented — see roadmap below)
- Triggering
handle_defaultas a fallback recovery path (escrow_contract)
Risk: Loss or compromise of the admin key has a high blast radius. A single actor also introduces censorship risk for issuer onboarding.
Roadmap: Migrate admin to a multi-sig (e.g., 3-of-5 Stellar signers) before any mainnet deployment.
Prior to this change, pool::fund_invoice required admin.require_auth(), meaning capital allocation was entirely at the admin's discretion. This created censorship risk — the admin could favour certain issuers, block competitors, or halt funding entirely with no on-chain accountability.
Current state (this release): fund_invoice is now permissionless. Any caller can trigger funding for any invoice that passes the on-chain eligibility checks:
- Invoice status must be
Listed(status 1) - Invoice funding asset must match the pool's asset
- Pool must have sufficient available liquidity
No off-chain approval or admin signature is required.
Longer-term governance design (not yet implemented):
The goal is LP-governed capital allocation:
- LPs stake their LP tokens to signal approval for specific invoices ("LP voting")
- An invoice becomes eligible once a quorum of LP-weighted votes approves it
- Admin retains only an emergency pause capability (circuit breaker), not funding control
- Governance parameters (quorum threshold, voting window) are upgradeable by LP vote
If you want to contribute to governance design, open an issue tagged complexity:high and link your proposal.
invoice::trigger_default requires admin.require_auth(). Although the on-chain eligibility check enforces now >= due_date, the function is not an automatic time-based trigger — an admin must explicitly call it. This creates a single point of control over declaring defaults.
Risk: Delays or failure to call trigger_default in time prevents the pool from recovering funds via escrow::handle_default, which could harm LP returns. A compromised admin could also misuse this power.
Current design rationale: A human-in-the-loop check before declaring a default prevents accidental defaults from clock drift, chain reorgs, or misconfigured automation. It also allows for off-chain negotiations (grace periods, extensions) before a default is formally recorded.
Roadmap: Introduce a permissionless time-based default mechanism where any caller can trigger a default for an invoice past its due_date + grace_period, without requiring admin authorization. The admin would retain only an override capability (e.g., to halt a false default).
There is currently no circuit breaker. If a critical bug is found post-deployment the only recourse is to stop directing traffic to the affected contracts via the frontend.
Roadmap: Add an admin_pause() / admin_unpause() function pair to each contract, guarded behind multi-sig, that blocks state-changing calls while reads remain live.
We welcome contributions from Rust and Soroban developers. Read CONTRIBUTING.md before opening a PR.
Issues are labeled by contract and complexity:
complexity:low— isolated function or test, good entry pointcomplexity:medium— touches contract logic and storagecomplexity:high— cross-contract interactions or new mechanics
Detailed references for contributors and integrators:
- Threat Model — trust assumptions, auth gates, attack vectors
- Storage Schema — on-chain data layout, TTL patterns, gas estimates
- Limitations — testnet constraints, known gaps, unhandled edge cases
- Event Catalog — every emitted event, topics, data schema, and emitting contract
- All amounts use
u128in stroops (1 USDC = 10,000,000) - All timestamps use
u64Unix seconds - Every
persistent().set()must be followed byextend_ttl() - Use
panic_with_error!with typed errors — no barepanic!orunwrap()in production paths
feat(registry): add batch issuer registration function
fix(pool): guard against division by zero when total_shares is 0
test(invoice): add full lifecycle integration test
If you have questions, reach us on Telegram: t.me/trusttrove
MIT — see CHANGELOG.md for version history.
