AI-driven mandi price intelligence and a farmer-first liquidity pool, delivered as an offline-capable Progressive Web App backed by a Rust API with an embedded JavaScript rule engine.
- Overview
- The Liquidity Pool (USP)
- System Architecture
- Backend
- Frontend
- Authentication
- Data Model
- API Reference
- Getting Started
- Project Structure
- Roadmap
Small and mid-size Indian farmers sell at the mandi (wholesale market) on the day of harvest. Prices are volatile — the onion modal price in Lasalgaon can swing 30–50% over two weeks — and most farmers cannot afford to store and wait. They sell at the bottom of the cycle. Aggregators and middlemen capture the price recovery that follows.
MandiMind is a multilingual mobile-first web app (Hindi, Marathi, English) that gives a farmer three things in one flow:
- Price intelligence — a 30-day modal-price forecast per crop and mandi, produced by a sandboxed JavaScript rule engine on the server.
- Direct buyer matching — ranked introductions to verified buyers (exporters, wholesalers, restaurants, processors) scored by a transparent algorithm.
- The Liquidity Pool — deposit produce into a partner cold storage, receive 70–80% of current market value as cash immediately, and have the pool auto-sell the stock when the price hits a pre-computed trigger. This is the product's defining differentiator.
- Offline-first. Rural connectivity is lossy. Every user-visible action writes locally, then reconciles.
- Small binary, small bundle. The Rust backend is a single statically-linkable binary. The PWA ships under 400 kB gzipped in the critical path.
- Auditable formulas. Pricing and advance logic lives in readable JavaScript files, not buried inside handlers.
- No new auth stack. Supabase handles Google OAuth and token issuance; the backend verifies the resulting ES256 JWTs against Supabase's JWKS endpoint.
Traditional farm credit is collateralised against land, not inventory. Commodity futures are inaccessible — contract sizes, broker fees, and margin calls price out anyone under roughly five lakh rupees of exposure. Warehouse receipt financing exists on paper but moves at bank-loan speeds.
The Liquidity Pool closes that gap through one guided flow inside the app. It turns a storable harvest into working capital in hours, not weeks, and removes the "when should I sell" timing risk by committing to an algorithmically-determined trigger price up front.
The formula is embedded JavaScript, executed in a sandboxed Boa runtime with strict caps (50,000 loop iterations, 100-deep recursion, no I/O). The script is versioned alongside the Rust binary and can be moved to a database-backed rule store in a later iteration without touching handlers.
Inputs:
| Field | Source |
|---|---|
listing.quantity_kg |
Farmer's crop listing |
listing.quality_grade |
A / B / C — gates base advance percentage |
current_market_price |
Latest modal_price from mandi_prices |
predicted_peak |
current_market_price × 1.15 today; future: output of price_prediction.js |
cold_storage_cost_per_kg_per_month |
Partner facility rate card |
requested_advance_pct |
Farmer's slider, capped by grade |
Rule:
base_pct = { A: 80, B: 75, C: 70 }[grade]
advance_pct = min(requested_pct, base_pct)
advance_amount = round(qty * market_price * advance_pct / 100)
trigger_price = round(predicted_peak * 0.95, 2) # capture 95% of forecast upside
storage_est = storage_cost * qty * 2 # two months of storage
final_payout = round(trigger_price * qty - advance_amount - storage_est)
# Safety net: if final_payout < 0, clamp to 0 and solve for break-even trigger
Grade controls LTV — Grade A stock is most liquid and gets the highest advance. The 95% cut on predicted peak is deliberate: the pool sells slightly before the forecast peak rather than chasing a local maximum.
stateDiagram-v2
[*] --> advance_paid: Farmer deposits listing
advance_paid --> in_storage: Stock received at cold storage
in_storage --> auto_sold: market_price >= trigger_price
in_storage --> settled: Farmer opts to sell manually
auto_sold --> settled: Final payout released
settled --> [*]
| Live, end-to-end tested | Designed, not yet wired |
|---|---|
| Advance computation through Boa | Background price watcher for auto_sold transitions |
Pool position creation and listing status transition to in_storage |
Settlement integration (UPI / bank rails) |
| Per-farmer position read API | Storage rent ledger billed per day (currently estimated at deposit time) |
sequenceDiagram
participant PWA
participant API as Axum
participant Mid as auth_middleware
participant Cache as JWKS cache
participant Sup as Supabase JWKS
participant H as Route handler
participant DB as SQLite
PWA->>API: GET /api/listings?farmer_id=... (Bearer ES256 JWT)
API->>Mid: extract Authorization header
Mid->>Mid: decode_header -> alg=ES256, kid=...
alt kid in cache
Cache-->>Mid: JWK (cached)
else kid not cached
Mid->>Sup: GET /auth/v1/.well-known/jwks.json
Sup-->>Mid: { keys: [...] }
Mid->>Cache: store
end
Mid->>Mid: verify signature + exp with EC P-256 public key
Mid->>H: inject Claims { sub, email, role, ... } into request extensions
H->>DB: SELECT ...
DB-->>H: rows
H-->>PWA: 200 JSON (snake_case)
PWA->>PWA: service-layer normalizer -> camelCase domain types
Every mutation writes to IndexedDB first. If the network call fails or the device is offline, the mutation lands in a pending_sync_queue and the UI surfaces a pending count. On reconnect, syncService.processPendingQueue() drains the queue with exponential-style retries (drop after 5 failures to avoid an unbounded queue).
flowchart TD
A[User action<br/>e.g. Create listing] --> B{Online?}
B -- Yes --> C[POST /api/listings]
C -- 200 --> D[Normalise response<br/>Dexie.put]
C -- Error --> E[Dexie: add to<br/>pending_sync_queue]
B -- No --> F[Generate local UUID<br/>Dexie.add]
F --> E
E --> G[syncStore.incrementPending]
G --> H[UI badge: N pending sync]
I[window.onOnline] --> J[syncService.processPendingQueue]
J --> K{retry <= 5}
K -- success --> L[delete queue item<br/>decrement pending]
K -- fail --> M[increment retries<br/>or drop after 5]
Queue item types:
create_listing— POST /api/listingsupdate_listing— PATCH /api/listings/:idupdate_profile— PATCH /api/farmers/:id (camelCase is normalised to snake_case at send time)
| Concern | Choice | Why |
|---|---|---|
| Language / runtime | Rust stable, Tokio | Memory safety for financial arithmetic; low-footprint single binary. |
| Web framework | Axum 0.7 | Lean type-safe extractors; composes with tower middleware. |
| Database | SQLite via sqlx (WAL mode) | Single-file deployability. sqlx is compiled with both sqlite and postgres features, so a Postgres swap needs no query rewrites. |
| Rule engine | Boa 0.21 (ECMAScript 2024) | Moves pricing, matching, and advance logic out of Rust and into versionable JS snippets. Sandboxed with strict loop and recursion caps. |
| Auth | JSON Web Tokens verified against Supabase JWKS | No in-house user management. |
| Observability | tracing + tracing-subscriber |
Structured logs. |
Pricing logic, matching heuristics, and advance formulas change more frequently than core server code. Encoding them as JavaScript snippets with explicit input and output contracts gives three properties the equivalent Rust code would not:
- Hot-swappable rules. Today the scripts are embedded with
include_str!. Moving them into a database table in a later iteration lets operators tune formulas without a rebuild or restart. - Auditability. Every formula lives in a small reviewable file (
pool_advance.js,buyer_matching.js,price_prediction.js) that a non-Rust engineer can read and reason about. - Safe execution. Boa runs with no filesystem, network, or process access, with hard limits of 50,000 loop iterations and 100-deep recursion. A bad rule can fail the evaluation; it cannot crash the server.
backend/
├── Cargo.toml
├── migrations/
│ ├── 001_initial.sql # Full schema
│ └── 002_relax_phone.sql # OAuth users may have no phone - drop UNIQUE
├── src/
│ ├── main.rs # Router assembly, CORS, migrations, seed
│ ├── auth.rs # JWKS ES256 + HS256 + dev fallback verifier
│ ├── db/mod.rs # Pool init, migration runner with _migrations tracker
│ ├── engine/
│ │ ├── mod.rs # RuleEngine::evaluate(script, input) - Boa wrapper
│ │ └── scripts/
│ │ ├── mod.rs # include_str!-embedded scripts
│ │ ├── price_prediction.js
│ │ ├── buyer_matching.js
│ │ └── pool_advance.js
│ ├── error.rs # AppError to HTTP mapping
│ ├── models.rs # Serde DTOs and request types
│ ├── routes/
│ │ ├── health.rs # Public liveness probe
│ │ ├── farmers.rs # Create/upsert (by id), get, patch
│ │ ├── listings.rs # CRUD with status and assigned_batch persistence
│ │ ├── mandi.rs # prices, latest, prediction/:crop (Boa)
│ │ ├── buyers.rs # offers, match/:listing_id (Boa), accept, decline
│ │ ├── pool.rs # positions, deposit (Boa) - the USP
│ │ ├── storage.rs # Cold storage directory
│ │ ├── sync.rs # Batch offline-queue endpoint
│ │ └── engine.rs # Public engine showcase endpoints
│ └── seed.rs # Deterministic seed for farmers, mandis, offers, storages
Migrations are embedded with include_str! and tracked in a _migrations table (id, applied_at). Each migration runs at most once. Destructive table-rebuild migrations (like 002_relax_phone.sql, which relaxes phone NOT NULL UNIQUE to phone NOT NULL DEFAULT '') run with PRAGMA foreign_keys = OFF for the duration and re-enable afterwards — necessary because SQLite enforces FKs across the crop_listings table during DROP TABLE farmers.
| Concern | Choice |
|---|---|
| Framework | React 18 + TypeScript + Vite 5 |
| Styling | Tailwind CSS 3 + Radix primitives |
| Forms and validation | react-hook-form + zod |
| Server state | TanStack Query 5 |
| Client state | Zustand 4, persisted to IndexedDB via idb-keyval |
| Offline storage | Dexie 4 |
| Service worker | vite-plugin-pwa + Workbox |
| Charts | Recharts |
| Animation | Framer Motion |
| i18n | i18next (Hindi, Marathi, English) |
| Auth SDK | @supabase/supabase-js |
mandimind-pwa/
├── index.html
├── vite.config.ts
├── tailwind.config.ts
└── src/
├── main.tsx
├── app/
│ ├── App.tsx
│ ├── providers.tsx # QueryClient, i18n, theme providers
│ └── router.tsx # Protected routes gated by farmerStore.isAuthenticated
├── pages/ # One component per route
│ ├── OnboardingPage.tsx # 4 steps: language - Google OAuth - profile - crops
│ ├── HomePage.tsx # Greeting, active listings, price recommendation
│ ├── ListCropPage.tsx # 5-step crop listing wizard
│ ├── MarketIntelligencePage.tsx
│ ├── SellWindowPage.tsx # Per-listing forecast curve and sell-window band
│ ├── BuyerMatchesPage.tsx
│ ├── LiquidityPoolPage.tsx
│ ├── ColdStoragePage.tsx # Distance-sorted via geolocation
│ ├── ProfilePage.tsx
│ └── OfflinePage.tsx
├── features/ # Feature-scoped components (pool/, buyers/, voice/)
├── services/ # One per domain; every HTTP call flows through here
│ ├── farmerService.ts
│ ├── listingService.ts
│ ├── mandiService.ts
│ ├── buyerService.ts
│ ├── poolService.ts
│ ├── storageService.ts
│ ├── syncService.ts # Drains the offline queue
│ └── voiceService.ts # Web Speech API wrapper (no HTTP)
├── stores/
│ ├── farmerStore.ts # Persisted; drives route guards
│ ├── listingStore.ts # In-memory with optimistic updates
│ └── syncStore.ts # online flag, pending count, last-synced timestamp
├── lib/
│ ├── api.ts # fetch wrapper; attaches localStorage.supabase_token
│ ├── supabase.ts # Supabase client + onAuthStateChange to localStorage
│ ├── db.ts # Dexie schema
│ ├── queryClient.ts
│ ├── i18n.ts
│ └── utils.ts
├── locales/ # hi.json, mr.json, en.json
├── types/domain.ts # Shared TS domain types (camelCase)
├── mocks/ # Fixtures used as final offline fallback
└── sw.ts # Workbox runtime strategies
The backend speaks snake_case; the frontend domain types are camelCase. Every service file carries an explicit normalize* function that maps between them and tolerates either shape, so cached IndexedDB rows and fresh API responses traverse the same path.
Services encode offline fallback in three layers:
backend -> IndexedDB cache -> bundled mock fixtures
A GET /api/prices call returns live data when online, falls back to Dexie-cached rows when the request fails, and finally to src/mocks/mandi-prices.json on a cold-boot offline session. The user never sees an empty screen.
All routes except /onboarding and /offline are gated on useFarmerStore(s => s.isAuthenticated). The farmer object is persisted to IndexedDB, so a reopened PWA goes straight to / without re-auth — unless Supabase's token has expired, in which case any API call returns 401 and the user re-authenticates via Google.
Target users are on low-end Android devices with under 1 GB RAM. A PWA installs from the browser with no Play Store listing, updates over the air, and works offline through a pre-cached shell plus IndexedDB. The same codebase scales to desktop without a second build pipeline.
Supabase migrated its default JWT signing from HS256 to ES256 (asymmetric, EC P-256) in 2024. The private key never leaves Supabase; servers verify tokens against the public JWKS endpoint at https://<project>.supabase.co/auth/v1/.well-known/jwks.json.
auth_middleware in src/auth.rs handles three cases:
- Asymmetric path (ES256 / RS256). Decodes the JWT header, reads
algandkid, pulls JWKS (cached process-wide in aOnceLock<RwLock<Option<Jwks>>>), finds the matching key, builds aDecodingKey::from_ec_components(x, y), and verifies signature plusexp. - Legacy HS256. If
SUPABASE_JWT_SECRETis a real JWT secret, verifies with it directly. - Dev fallback. If the secret is empty or has the new
sb_API-key shape (a publishable key, not a JWT secret), the middleware falls back to structure-only decode. On missing header plus empty secret it injects a syntheticdev-farmer-001claim set so the app works offline against the locally-seeded database.
This keeps the dev ergonomics of "clone and run" without compromising production verification.
erDiagram
farmers ||--o{ crop_listings : owns
farmers ||--o{ pool_positions : has
crop_listings ||--o| pool_positions : financed_by
cold_storages ||--o{ pool_positions : hosts
buyer_offers }o..o{ crop_listings : matched_by_rule_engine
mandi_prices }o..o{ crop_listings : informs_pricing
farmers {
TEXT id PK
TEXT name
TEXT phone
TEXT village
TEXT district
TEXT state
TEXT preferred_language "hi | mr | en"
TEXT primary_crops "JSON array"
REAL land_size_acres
}
crop_listings {
TEXT id PK
TEXT farmer_id FK
TEXT crop
REAL quantity_kg
TEXT expected_harvest_date
TEXT quality_grade "A | B | C"
TEXT status "draft | listed | matched | in_storage | sold | cancelled"
TEXT assigned_batch "monday | wednesday | friday"
}
pool_positions {
TEXT id PK
TEXT listing_id FK
TEXT farmer_id FK
TEXT cold_storage_id FK
REAL advance_paid
REAL advance_pct
REAL current_market_price
REAL trigger_price
REAL estimated_final_payout
TEXT status "advance_paid | in_storage | auto_sold | settled"
}
cold_storages {
TEXT id PK
TEXT name
TEXT district
REAL lat
REAL lng
REAL capacity_tonnes
REAL available_tonnes
REAL price_per_kg_per_month
}
mandi_prices {
INTEGER id PK
TEXT mandi_id
TEXT crop
REAL min_price
REAL max_price
REAL modal_price
TEXT date
REAL arrivals_tonnes
}
buyer_offers {
TEXT id PK
TEXT buyer_name
TEXT buyer_type "restaurant | exporter | wholesaler | processor"
TEXT crop
REAL quantity_needed_kg
REAL offered_price_per_kg
INTEGER verified
}
All request and response bodies are snake_case JSON. Errors return { "error": "..." } with an appropriate HTTP status.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health |
public | Liveness probe |
| POST | /api/farmers |
Bearer | Create or upsert a farmer by id |
| GET | /api/farmers/:id |
Bearer | Fetch by id |
| GET | /api/farmers/by-phone/:phone |
Bearer | Fetch by phone |
| PATCH | /api/farmers/:id |
Bearer | Partial update |
| POST | /api/listings |
Bearer | Create crop listing |
| GET | /api/listings |
Bearer | List; filters: farmer_id, crop, status |
| GET | /api/listings/:id |
Bearer | Fetch by id |
| PATCH | /api/listings/:id |
Bearer | Partial update |
| GET | /api/prices |
Bearer | Mandi prices; filters crop, district |
| GET | /api/prices/latest |
Bearer | Deduplicated latest per mandi+crop |
| GET | /api/prices/prediction/:crop |
Bearer | 30-day forecast via Boa |
| GET | /api/buyers/offers |
Bearer | All offers; filter crop |
| GET | /api/buyers/match/:listing_id |
Bearer | Ranked buyer matches via Boa |
| POST | /api/buyers/offers/:id/accept |
Bearer | Record an acceptance |
| POST | /api/buyers/offers/:id/decline |
Bearer | Record a decline |
| GET | /api/pool/positions |
Bearer | Positions; filter farmer_id |
| POST | /api/pool/deposit |
Bearer | Advance calc via Boa + create pool position |
| GET | /api/storage |
Bearer | Cold-storage directory; filter district |
| POST | /api/sync |
Bearer | Batch offline-queue endpoint |
| POST | /api/engine/eval |
public (demo) | Evaluate arbitrary script against an input |
| GET | /api/engine/scripts |
public (demo) | Describe built-in scripts |
| POST | /api/engine/run/:name |
public (demo) | Run a built-in script |
| GET | /api/engine/demo |
public (demo) | Eight JS snippets showcasing engine capabilities |
- Rust 1.80 or newer (
rustuprecommended) - Node 20 or newer with npm
- A Supabase project for Google OAuth. Leaving secrets empty runs the backend in dev-bypass mode; the app still works against the seeded SQLite database.
backend/.env:
DATABASE_URL=sqlite:mandimind.db
BIND_ADDR=0.0.0.0:3000
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_ANON_KEY=...
SUPABASE_JWT_SECRET=... # optional real HS256 secret; leave empty for dev bypass
RUST_LOG=info
mandimind-pwa/.env:
VITE_API_URL=http://localhost:3000
VITE_SUPABASE_URL=https://<project>.supabase.co
VITE_SUPABASE_ANON_KEY=...
# Terminal 1 — backend
cd backend
cargo run # binds :3000, runs migrations and seed on first start
# Terminal 2 — frontend
cd mandimind-pwa
npm install
npm run dev # Vite on :5173On first run the backend creates mandimind.db and seeds three farmers, roughly twenty mandi price rows, buyer offers, and cold storages. The seed is idempotent — it runs only when the farmers table is empty.
cd backend && cargo build --release
cd mandimind-pwa && npm run buildThe PWA output in dist/ is a static bundle, servable behind any CDN. The service worker precaches the application shell for offline use.
TinkerQuest/
├── backend/ # Rust / Axum / Boa / SQLite
└── mandimind-pwa/ # React / Vite / PWA
The two halves are independently buildable and deployable; they communicate only over HTTP.
- Background price watcher that fires
auto_soldtransitions on pool positions - Replace the
market_price × 1.15heuristic inpool_advancewith the output ofprice_prediction - UPI and bank-rail payout on settlement
- Aadhaar-OTP onboarding for farmers without Google accounts
- Push notifications for price triggers and batch days
- Postgres backend for multi-tenant deployment (query-level change is zero; sqlx already compiles both drivers)
- Migrate embedded Boa scripts to a database-backed rule table for runtime tuning