Tokenized warehouse receipts and commodity financing for smallholder farmers — built on Stellar Soroban.
A farmer deposits stored crops in a certified warehouse, gets a tokenized receipt, and instantly borrows USDC against it — instead of panic-selling at the bottom of the market.
Smallholder farmers are cash-poor right after harvest. They dump crops when prices are lowest because they need liquidity, then can't access credit because they have no collateral a bank recognizes. Billions in stored grain sit as "dead" capital. A warehouse receipt — proof you own X tonnes in a certified store — is a classic real-world asset, but today it is paper, unverifiable, and untradeable.
AgriVault turns warehouse receipts into programmable on-chain collateral.
┌─────────────────────────────────────────────────────────────────────┐
│ Warehouse Operator │
│ (approved attestor) │
│ │ mint(receipt_meta) │
│ ▼ │
│ receipt_token ──── KYC-gated transfer ────► Farmer wallet │
│ │ │ │
│ │ lock(receipt_id) │
│ │ ▼ │
│ └──────────────────────────────────► lending_pool │
│ │ │
│ Lender │ borrow(amount) │
│ deposit(USDC) ──────────────────────────────────►│ │
│ │ │
│ USDC (≤60% LTV) │
│ ▼ │
│ Farmer wallet │
│ │
│ On repay: receipt unlocked, interest accrues to lenders │
│ On default: anyone calls liquidate() after 90 days │
└─────────────────────────────────────────────────────────────────────┘
agrivault/
│
├── contracts/ # Soroban smart contracts (Rust)
│ ├── attestor_registry/ # Admin-controlled list of approved warehouse operators
│ ├── receipt_token/ # Permissioned receipt token with KYC gate + lock/unlock
│ └── lending_pool/ # USDC pool — borrow, repay, liquidate
│
├── app/ # Next.js 14 frontend
│ └── src/
│ ├── app/ # App Router pages
│ │ ├── operator/ # Issue receipts
│ │ ├── farmer/ # Lock, borrow, repay
│ │ └── lender/ # Deposit, earn, withdraw
│ ├── components/ # Shared UI components
│ ├── context/ # WalletContext (Freighter v2)
│ ├── lib/ # contracts.ts, constants.ts, demo.ts
│ └── types/ # Canonical TypeScript interfaces
│
├── indexer/ # Node.js event indexer → Postgres → REST API
│ └── src/
│ ├── db.ts # Schema + migrations
│ ├── poller.ts # Soroban event polling
│ ├── processor.ts # Event → database
│ └── api.ts # Express REST endpoints
│
├── sdk/ # TypeScript SDK wrapping all three contracts
│ └── src/
│ ├── client.ts # SorobanClient (build, simulate, submit)
│ └── contracts/ # AttestorRegistry, ReceiptToken, LendingPool clients
│
├── scripts/ # Testnet deploy + seed scripts
│ └── src/
│ ├── deploy.ts # Deploy contracts, initialize, wire together
│ └── seed.ts # Fund accounts, mint demo receipt, borrow USDC
│
├── Cargo.toml # Rust workspace
└── README.md
Controls which warehouse operators are allowed to mint receipt tokens.
| Function | Auth | Description |
|---|---|---|
initialize(admin) |
— | One-time setup |
approve_attestor(attestor) |
admin | Whitelist a warehouse operator |
revoke_attestor(attestor) |
admin | Remove from whitelist |
is_attestor(attestor) → bool |
— | Read-only check |
set_admin(new_admin) |
admin | Transfer admin role |
A permissioned token representing a physical warehouse deposit.
| Function | Auth | Description |
|---|---|---|
mint(attestor, owner, commodity, grade, tonnes, valuation_usd, warehouse_id, ipfs_hash) → u64 |
approved attestor | Mint receipt for KYC'd owner |
kyc_add(wallet) / kyc_remove(wallet) |
admin | Manage KYC allowlist |
transfer(from, to, receipt_id) |
owner | Both parties must be KYC'd; receipt must be unlocked |
lock(caller, receipt_id) |
lending_pool only | Lock as collateral |
unlock(caller, receipt_id) |
lending_pool only | Release after repayment |
mark_redeemable(caller, receipt_id) |
lending_pool only | Mark after liquidation |
get_receipt(id) → ReceiptMeta |
— | Read metadata |
Encoding:
tonnesis stored astonnes × 100(e.g.2000= 20.00 t)valuation_usdis stored as USD cents (e.g.400000= $4,000.00)
USDC lending pool with LTV-enforced borrowing and pro-rata interest distribution.
| Function | Auth | Description |
|---|---|---|
initialize(admin, usdc_token, receipt_token, ltv_bps, annual_rate_bps, loan_duration) |
— | One-time setup |
deposit(lender, amount) |
lender | Add USDC liquidity |
withdraw(lender, amount) |
lender | Remove USDC proportional to pool balance |
borrow(farmer, receipt_id, amount) |
receipt owner | Lock receipt, receive USDC (≤ LTV) |
repay(farmer, receipt_id) |
borrower | Pay principal + interest, unlock receipt |
liquidate(caller, receipt_id) |
anyone | Trigger after loan duration expires |
pool_info() → PoolInfo |
— | Pool stats snapshot |
amount_due(receipt_id) → i128 |
— | Current repayment obligation |
Economics (defaults — all configurable at initialization):
| Parameter | Default | Meaning |
|---|---|---|
| LTV | 60% | Maximum borrow = valuation × 0.60 |
| Annual rate | 8% | Simple (non-compounding) linear interest |
| Loan duration | 90 days | Loan becomes liquidatable after this |
| USDC precision | 6 dp | 1 USDC = 1,000,000 stroops |
Interest formula:
interest = principal × (rate_bps / 10_000) × (elapsed_seconds / 31_536_000)
Warehouse operators fill a form specifying commodity, grade, quantity, valuation, and IPFS document hash. Clicking "Issue Receipt" calls mint() via Freighter. The page previews the exact amount the farmer will be able to borrow before submission.
Farmers look up a receipt by ID. If they own it and it's unlocked, they can enter a borrow amount (or hit Max for 60% LTV), which calls borrow() and locks the receipt. The page shows the active loan details and a Repay button to call repay() and reclaim the receipt.
Lenders see pool stats (total deposits, balance, utilisation, interest earned), their own deposit, and estimated earnings. One-click quick-fill buttons for $1k / $5k / $10k deposits. Withdraw button with Max.
| Layer | Technology |
|---|---|
| Smart contracts | Rust · Soroban SDK 21 · wasm32-unknown-unknown |
| Frontend | Next.js 14 (App Router) · TypeScript · Tailwind CSS |
| Wallet | Freighter v2 (@stellar/freighter-api) |
| Stellar SDK | @stellar/stellar-sdk 12 |
| Indexer | Node.js · Express · PostgreSQL · pg |
| Deploy scripts | ts-node · Stellar CLI |
| Tool | Install |
|---|---|
| Rust 1.75+ | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| wasm target | rustup target add wasm32-unknown-unknown |
| Stellar CLI | cargo install --locked stellar-cli --features opt |
| Node.js 20+ | nodejs.org |
| PostgreSQL 14+ | postgresql.org |
| Freighter wallet | freighter.app |
git clone <repo-url>
cd agrivault
# Install all JS packages
cd app && npm install && cd ..
cd indexer && npm install && cd ..
cd scripts && npm install && cd ..
cd sdk && npm install && cd ..cd app
cp .env.local.example .env.local
# NEXT_PUBLIC_DEMO_MODE=true is already set in .env.local.example
npm run dev
# → http://localhost:3000Demo mode simulates all transactions with 1-second fake round-trips so you can explore the full UI without deploying contracts.
# From the repo root
cargo build --release --target wasm32-unknown-unknowncd scripts
cp .env.example .env
# Edit .env — add ADMIN_SECRET (a funded Testnet keypair secret key)
npm run deploy
# Deploys all three contracts, initializes them, and writes deployed.envnpm run seed
# Creates keypairs, funds via Friendbot, mints a 20t Maize receipt,
# lender deposits $10k, farmer borrows $2,400, demonstrates KYC revertcd indexer
cp .env.example .env
# Fill DATABASE_URL, contract IDs from deployed.env
npm run dev
# REST API → http://localhost:3001cd app
cp deployed.env .env.local # or copy the relevant vars
# Make sure NEXT_PUBLIC_DEMO_MODE is NOT set (or set to false)
npm run dev
# → http://localhost:3000Base URL: http://localhost:3001
| Method | Endpoint | Query params | Description |
|---|---|---|---|
| GET | /receipts |
owner, locked, redeemable, limit, offset |
List receipts |
| GET | /receipts/:id |
— | Single receipt |
| GET | /loans |
farmer, repaid, liquidated, limit, offset |
List loans |
| GET | /loans/:receiptId |
— | Single loan |
| GET | /pool |
— | Pool aggregate stats |
| GET | /pool/lenders/:address |
— | Lender's deposit balance |
| GET | /events |
type, contract, limit, offset |
Raw event log |
| GET | /health |
— | Service health check |
# From the repo root
cargo testTest coverage:
| Contract | What's tested |
|---|---|
attestor_registry |
Initialize, double-init guard, approve, revoke, admin-auth enforcement, admin transfer |
receipt_token |
Mint by approved attestor, unapproved attestor rejected, non-KYC owner rejected, KYC transfer gate, transfer to KYC wallet, lock/unlock, lock-only-by-pool guard, locked receipt not transferable, mark redeemable, counter increments |
lending_pool |
Deposit + balance, borrow within LTV, borrow exceeding LTV rejected, repay + unlock, liquidate before due date rejected, liquidate overdue, lender balance grows after repay, interest calculation, double-borrow on same receipt rejected |
- KYC gate: Receipt transfers are gated by an admin-managed allowlist. Sending to a non-KYC address reverts.
- Attestor gate: Only addresses approved in
attestor_registrycan mint receipts. Unapproved attestors revert. - Pool-only lock/unlock: The
receipt_tokencontract will only acceptlock/unlock/mark_redeemablecalls from the registeredlending_poolcontract address. - LTV enforcement: The pool enforces
amount ≤ (valuation_usd_cents × LTV_bps / 10_000) × 10_000at borrow time. - Permissionless liquidation: Anyone can call
liquidate()after the loan duration expires — no oracle or keeper bot required. - Interest model: Simple (non-compounding) linear interest — no runaway debt spirals.
- Secret management:
deployed.envandseed-keys.jsonare in.gitignore— never commit private keys.
| Feature | Status |
|---|---|
| Real warehouse IoT / physical inspection | Out of scope |
| Price oracles (valuation is admin-set at mint) | Out of scope |
| Secondary receipt trading on a DEX | Out of scope |
| Multi-collateral loans | Out of scope |
| Compounding interest | Out of scope |
| Mobile wallet support | Out of scope |
1. Operator → issue receipt: "20 t Maize · Grade A · WH001 · $4,000"
Receipt #0 minted to farmer wallet
2. Farmer → load receipt #0 → click Max → Lock Receipt & Borrow USDC
$2,400 USDC lands in farmer wallet instantly
Receipt is now locked (not transferable)
3. [Time passes — 90 days elapse on-chain]
4. Anyone → liquidate(receipt_id=0)
Receipt marked redeemable
Lenders' total_deposits grows with accrued interest
5. Farmer → try transfer(receipt=0, non-KYC wallet)
Transaction REVERTS ✓ ("recipient not KYC'd")
Built for the Stellar Community Fund / CV Labs Accelerator — RWA Financial Inclusion track.