Soroban smart contracts powering the TrusTrove trade finance protocol on Stellar. The protocol uses a pool_factory + per-asset pool instance model, allowing independent liquidity pools per asset.
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
batch_register_issuers(entries) → Vec<Address> ← admin only, max 50, returns skipped
batch_register_buyers(entries) → Vec<Address> ← admin only, max 50, returns skipped
is_verified(address) → bool
get_profile(address) → Profile
get_profile_count(role) → u32 ← issuers/buyers registered, O(1)
list_profiles(role, start, limit) → Vec<Address> ← registration-ordered page, limit ≤ 50
revoke(address) → bool
The registry is enumerable without replaying events. Every registration
path — single and batch, issuer and buyer — appends the new address to a
per-role index (ProfileIndex(role, n)) and bumps a per-role counter
(ProfileCount(role)), both TTL-extended like a profile entry. So an indexer
or admin dashboard can call get_profile_count(role) to size its last page and
then page through list_profiles(role, start, limit) with limit ≤ 50
(PageSizeExceeded above the cap). Skipped/already-registered batch entries
are never indexed twice, pages are contiguous and non-overlapping, and revoked
profiles remain enumerated (revocation flips verification, it does not
deregister the profile).
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
batch_create(issuer, entries) → Vec<invoice_id> ← atomic, max 50 entries
submit_attestation(invoice_id, payload, signature) → bool
list_for_financing(invoice_id, discount_bps) → bool
batch_list_for_financing(entries) → Vec<invoice_id> ← tolerant, max 50 entries
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
repay_partial(invoice_id, amount) → bool
repay_early(invoice_id) → bool
trigger_default(invoice_id) → bool
get(invoice_id) → Invoice
get_attestation(invoice_id) → Option<Attestation>
get_by_status(status, page, page_size) → Vec<Invoice>
get_by_issuer(address, page, page_size) → Vec<Invoice>
get_by_buyer(address, page, page_size) → Vec<Invoice>
get_invoice_count_by_issuer(address) → u32
get_invoice_count_by_buyer(address) → u32
get_counts() → Map<String, u64>
get_remaining_balance(invoice_id) → u128
set_agent_registry_contract(agent_registry_contract) → bool
- Marks a
Createdinvoice as available for financing (Listedstatus). discount_bps: Annualized discount rate in basis points (valid range:0..=5000, where 5,000 = 50% max). Values above 5,000 panic withDiscountTooHigh(#9).- Only the invoice
issuercan call this. - Requires invoice to be in
Createdstatus. - Requires an active risk attestation from an authorized Underwrite agent.
entries: up toMAX_BATCH_SIZE(50) tuples of(buyer, face_value, due_date, funding_asset).- Atomic: one invalid entry reverts the entire batch, including invoices already created by earlier entries. Callers fix the bad entry and resubmit the whole batch.
issuerauthorizes once for the whole batch, not once per entry.- Panics with
BatchSizeExceeded(#28) above 50 entries.
entries: up toMAX_BATCH_SIZE(50) tuples of(invoice_id, discount_bps).- Tolerant: entries that fail validation (already listed, no attestation, bad discount) are skipped, not reverted. Returns the IDs that failed, in entry order; an empty result means every entry listed.
- Each listed invoice's own
issuerauthorizes, but only once per distinct issuer per batch.
Mediates USDC between the pool, issuer, and buyer. pool.fund_invoice locks the funded amount here and immediately releases it to the issuer in the same call (issue #819); repayment settles through release_to_pool, which consumes either a live lock or the issuer-release marker. 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
pause() / unpause() ← admin only (issue #716)
get_locked(invoice_id) → u128
When the escrow is paused, initialize, lock, release_to_issuer,
release_to_pool, and handle_default all revert with ContractPaused.
Read-only views (get_locked) stay callable.
USDC liquidity pool where LP shares are a SEP-41 transferable token. 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
batch_fund_invoice(invoice_ids) → Vec<invoice_id> ← permissionless, max 50 entries
receive_repayment(invoice_id, amount) → bool ← invoice_contract only
receive_repayment_with_refund(invoice_id, amount, refund, buyer) → bool ← invoice_contract only
handle_default(invoice_id) → bool
set_protocol_fee(fee_bps, treasury) → bool ← admin only
get_protocol_fee_bps() → u32
get_treasury() → Address
get_stats() → PoolStats
get_lp_position(address) → LPPosition
transfer(from, to, amount) → () ← SEP-41 share transfer (generic token::Client compatible)
transfer_shares(from, to, amount) → () ← non-standard legacy alias of transfer
approve(from, spender, amount, expiration_ledger) → () ← SEP-41 grant
allowance(from, spender) → i128 ← 0 when spent/expired
transfer_from(spender, from, to, amount) → () ← spender auth, spends grant
decimals() → u32 ← per-instance, set at initialize
name() → String ← set at initialize()
symbol() → String ← set at initialize()
The protocol employs a pool_factory + per-asset pool instance model. The pool_factory handles deploying and tracking an independent pool_contract instance for each supported funding asset (e.g., USDC, XLM). This design isolates risk, ensuring that a shortfall in one asset pool cannot cross-contaminate another.
Note: This architecture explicitly supersedes closed issue #17's Option A/B question, resolved as Option B (parallel single-asset pools instead of a commingled multi-asset pool). The full rationale, storage layout, deploy flow and the remaining single-asset checklist are in Pool Factory Design.
┌─────────────────┐
│ registry_contract │
│ (identity oracle) │
└────────┬────────┘
│ is_verified()
┌──────────────────▼──────────────────┐
│ invoice_contract │
│ (lifecycle state machine & indexer) │
└──────┬────────────────────┬──────────┘
│ mark_funded() │ receive_repayment()
│ trigger_default() │ handle_default()
┌──────▼───────┐ ┌───────▼──────────┐
│ pool_contract │ │ pool_contract │◄──(deployed by)── ┌───────────────┐
│ fund_invoice │ │ (repayment in) │ │ pool_factory │
└──────┬────────┘ └──────────────────┘ └───────────────┘
│ lock()
┌──────▼────────────┐
│ escrow_contract │
│ (Asset custody) │
└───────────────────┘
The pool_contract instances also call 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 (between 0 and 5,000, representing 0% to 50% max yield) expressing the yield they will give up in exchange for immediate liquidity. If discount_bps > 5000, the transaction fails with InvoiceError::DiscountTooHigh (#9).
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. The exact interface the agent-registry must implement, and the payload/signature scheme agents must sign, are specified in Agent-Registry Interface.
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, marks the invoice as funded, and releases the principal to the issuer — all inside this single call (issue #819, restoring #56).
funded_amount = face_value × (10000 − discount_bps) / 10000
Pool ──[funded_amount USDC]──► Escrow (locked per invoice_id)
Escrow ──[funded_amount USDC]──► Issuer (released in the same call)
Invoice status: Listed → Funded
The pool retains face_value − funded_amount (the discount) as accrued yield, collectible when the buyer repays.
The payout is not a separate step anymore: fund_invoice calls escrow.release_to_issuer(invoice_id, issuer) immediately after lock, so the issuer holds the USDC in the same transaction and can ship goods.
Escrow ──[funded_amount USDC]──► Issuer
The earlier gap (issue #56) was fixed in 5f6dc7c, silently regressed by a later refactor, and re-wired by #819. Because the escrow Locked record is consumed at funding, escrow keeps a durable IssuerReleased(invoice_id) marker instead: release_to_pool accepts that marker so repayment still settles (each marker or lock is consumed exactly once), and escrow.handle_default reports false for already-funded invoices — see Step 7 for how the pool books that.
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) (or repay_partial / repay_early), which transfers USDC from the buyer into escrow; escrow releases it to the pool via release_to_pool, and the pool records the yield split.
Buyer ──[face_value USDC]──► Escrow ──[face_value USDC]──► Pool
earned_by_pool = discount × elapsed / term (capped at the full discount
at/after due_date)
refund_to_buyer = discount − earned_by_pool ──► Buyer
Protocol fee cut: protocol_cut = earned_by_pool × fee_bps / 10000 ──► Treasury
LP yield: lp_yield = earned_by_pool − protocol_cut
TotalDeposits += lp_yield (share price rises for all LPs)
TotalYieldDistributed += lp_yield
Invoice status: → Repaid
repay_early is restricted to Confirmed invoices strictly before due_date; repay accepts Funded, Active, or Confirmed at any time and also refunds the unearned discount before maturity. Partial installments accumulate in escrow and settle at the pool only when the final one clears the balance. See Repayment Timing and Refund Math for the allowed statuses, formulas, and worked examples.
Protocol Fee: Defaults to 0 bps at deployment, preserving 100% yield distribution to LPs. The contract admin can configure a fee up to 2000 bps (20%) and set the treasury destination address via pool.set_protocol_fee(fee_bps, treasury).
Repayment does not rest on the funding lock: the buyer's USDC settles through escrow (escrow.release_to_pool consumes either a live lock or the Step 4 issuer-release marker) and lands at the pool. The escrow contract is otherwise involved in funding and issuer payout (Steps 3–4) and default handling (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 calls escrow.handle_default. For a normally funded invoice the principal was already paid to the issuer at funding (Step 3), so escrow has nothing to hand back; the pool verifies the IssuerReleased marker via escrow.was_released_to_issuer and books the unrecoverable principal as a real LP capital loss. If escrow can still return a live lock (out-of-band funding), the legacy path runs and deposits are preserved. A false from escrow with no marker means the release was skipped and reverts with EscrowDefaultNotReleased (#21).
invoice.trigger_default()
└─► pool.handle_default()
├─► escrow.handle_default() → false for funded invoices (already paid out)
├─► escrow.was_released_to_issuer → true (marker present, #819)
└─► invoice.mark_defaulted()
Invoice status: → Defaulted
TotalFunded -= funded_amount
TotalLossRealised += funded_amount
TotalDeposits -= funded_amount (LP capital loss; shares are never burned)
| 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 → Issuer | Issuer | face_value × (1 − discount) |
Yes — locks then releases in one call |
| Repay | Buyer wallet → Escrow → Pool | Pool | face_value (or the outstanding balance for partial flow) |
Yes — release_to_pool settles |
| Default | Pool books LP loss (principal already with issuer) | Pool | funded_amount written off |
Only if escrow still holds a live lock; otherwise marker-verified write-off (#819) |
- 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 of all four contracts (the escrow circuit breaker landed in issue #716)
- 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.
Mitigation: Configure the admin as a Stellar account with signer thresholds (for example, 3-of-5) before mainnet deployment. No contract change is needed; see DEPLOYMENT.md for setup instructions.
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 is permissionless and requires no authorization (admin.require_auth() was removed). Anyone can trigger default processing once now >= due_date. This removes the single point of failure of an admin-gated default mechanism and ensures timely loss recognition and escrow fund recovery for liquidity pools without relying on admin intervention.
All four contracts — pool, invoice, registry, and escrow — expose admin-gated pause() / unpause() entry points backed by the shared trusttrove-pause crate (issues #713, #714, #715, #716). While paused, every state-changing call reverts with ContractPaused while read-only views remain live.
The escrow covers initialize, lock, release_to_issuer, release_to_pool, and handle_default, so a compromised escrow admin or a stuck release path can be frozen without touching funds already locked — get_locked stays readable throughout.
Roadmap: Back the admin key with a multi-sig.
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
- Pool Factory Design — per-asset pool factory (Option B), storage layout, deploy flow, single-asset checklist
- Agent-Registry Interface —
get_agent/Agentspec and attestation signature scheme consumed bysubmit_attestation
- 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
| Code | Variant | Description |
|---|---|---|
| 1 | AlreadyInitialized |
Contract has already been initialized |
| 2 | NotFound |
Invoice record or configuration key was not found |
| 3 | NotAuthorized |
Caller lacks required authorization |
| 4 | IssuerNotVerified |
Issuer is not verified in the registry contract |
| 5 | BuyerNotVerified |
Buyer is not verified in the registry contract |
| 6 | InvalidFaceValue |
Face value is zero |
| 7 | InvalidDueDate |
Due date is in the past or exceeds maximum invoice lifetime |
| 8 | InvalidStatusTransition |
Status does not allow the requested operation |
| 9 | DiscountTooHigh |
discount_bps exceeds maximum permitted 5,000 (50%) |
| 10 | AlreadyConfirmed |
Caller has already confirmed delivery |
| 11 | DueDateNotPassed |
Invoice due date has not passed yet |
| 12 | InvalidDiscount |
Discount computation resulted in an invalid state |
| 13 | UnsupportedAsset |
Token asset is not registered as supported |
| 14 | ListingNotExpired |
Invoice listing has not reached expiry |
| 15 | MathOverflow |
Arithmetic operation overflowed or underflowed |
| 16 | InvalidAmount |
Face value, repayment, or fee amount is invalid or zero |
| 17 | CounterOverflow |
Internal invoice ID counter exceeded limit |
| 18 | InvalidExpiryWindow |
Expiry window parameter is invalid |
| 19 | InvalidParticipants |
Issuer and buyer cannot be the same address |
| 20 | NotInitialized |
Contract has not been initialized |
| 21 | UntrustedSigner |
Attestation signer does not match a verified agent |
| 22 | AlreadyAttested |
Invoice already has an attestation recorded |
| 23 | VerificationRequired |
Listing requires a valid underwriting attestation |
| 24 | CrossContractCallFailed |
Inter-contract call to Escrow or Pool failed |
| 25 | RepaymentExceedsBalance |
Repayment amount exceeds remaining invoice balance |
| 26 | InvalidConfiguration |
Configuration value is invalid |
| 27 | InvalidPageSize |
page_size exceeds MAX_PAGE_SIZE on a paginated get_by_* query |
| 28 | BatchSizeExceeded |
Batch entry point was given more than MAX_BATCH_SIZE (50) entries |
| Code | Variant | Description |
|---|---|---|
| 5 | InsufficientLiquidity |
Pool does not have enough available liquidity to fund the invoice |
| 8 | InvoiceNotListed |
Invoice is not in Listed status |
| 11 | AssetMismatch |
Invoice funding asset does not match the pool's asset |
| 12 | UtilizationCapExceeded |
Funding would breach the pool's utilization cap |
| 26 | BatchSizeExceeded |
batch_fund_invoice was given more than MAX_BATCH_SIZE (50) entries |
See contracts/pool/src/errors.rs for the full list.
MIT — see CHANGELOG.md for version history.
