A Flask backend that demonstrates offline UPI payments routed through a Bluetooth-style mesh network. You're in a basement with zero connectivity. You send your friend ₹500. Your phone encrypts the payment, broadcasts it to nearby phones, and the packet hops device-to-device until some phone walks outside, gets 4G, and silently uploads it to this backend. The backend decrypts, deduplicates, and settles.
This repo is the server side of that system, plus a software simulator of the mesh so you can demo the whole flow on a single laptop without any real Bluetooth hardware.
- What this demo proves
- How to run it
- The demo flow (step by step)
- Architecture
- The three hard problems and how they're solved
- File-by-file walkthrough
- API reference
- Tests
- What's NOT real (and what would change for production)
- Honest limitations of the concept
The system shows three things working end to end:
- A payment can travel from sender to backend through untrusted intermediaries without any of them being able to read or tamper with it. (Hybrid RSA + AES-GCM encryption.)
- Even if the same payment reaches the backend simultaneously through multiple bridge nodes, it settles exactly once. (Idempotency via atomic compare-and-set on the ciphertext hash.)
- A tampered or replayed packet is rejected before it touches the ledger.
You'll see all three in the dashboard.
- Python 3.10 or newer installed and on PATH. Check with
python --version. - Install dependencies:
pip install -r requirements.txtThat's it. No database, no Redis. Just Python.
python app.pyThe first run creates a local SQLite database (upimesh.db) and seeds 4 demo accounts.
Once you see Running on http://10.66.134.52:8080, open:
http://10.66.134.52:8080
You'll get a dark dashboard with everything you need to drive the demo.
Ctrl+C in the terminal.
pytest tests/ -vThe interesting one is test_idempotency.py — it fires three threads delivering the same packet simultaneously and asserts that exactly one settles.
The dashboard has four buttons that walk through the full pipeline. The intended sequence:
Choose sender, receiver, amount, PIN. Click "📤 Inject into Mesh".
What actually happens on the backend:
- The server pretends to be the sender's phone.
- It builds a
PaymentInstructionwith a unique nonce and current timestamp. - It encrypts that with the server's RSA public key (using hybrid encryption — see below).
- It wraps the ciphertext in a
MeshPacketwith a TTL of 5. - It hands the packet to
phone-alice, an offline virtual device.
You'll see phone-alice now holds 1 packet.
Click "🔄 Run Gossip Round". Then click it again.
Each round, every device that holds a packet broadcasts it to every other device within "Bluetooth range" (which, in our simulator, means everyone). TTL decrements per hop.
After 1 round: every device holds the packet. After 2 rounds: still every device — TTL is just lower.
In the real system this would happen organically as people walk past each other in the basement.
Click "📡 Bridges Upload to Backend".
phone-bridge is the only device with hasInternet=true. The dashboard simulates that phone walking outside and getting 4G. It POSTs every packet it holds to /api/bridge/ingest.
The backend pipeline runs:
- Hash the ciphertext (SHA-256).
- Try to claim the hash in the idempotency cache.
- If claimed: decrypt with the server's RSA private key.
- Verify freshness (
signedAtwithin 24 hours). - Run the debit/credit in a single DB transaction.
Watch the Account Balances table — money has moved. Watch the Transaction Ledger — a new row appears.
Reset the mesh. Inject a single packet. Run gossip 2 times. Now all 5 devices hold the same packet, including multiple bridges in a more complex setup.
To really see idempotency in action, modify services.py to seed multiple bridge devices, or just:
- Click "Inject" once.
- Click "Gossip" twice.
- Click "Flush Bridges" — only
phone-bridgeis a bridge in the default seed, so just one upload happens.
To exercise the concurrent duplicate case properly, run the test:
pytest tests/test_idempotency.py::test_single_packet_delivered_by_three_bridges_settles_exactly_once -vThis test creates one packet, fires 3 threads at BridgeIngestionService.ingest() simultaneously, and verifies that exactly one settles, two are dropped as duplicates, and the sender is debited exactly once.
┌─────────────────────────────────────────────────────────────────────────┐
│ SENDER PHONE (offline) │
│ PaymentInstruction { sender, receiver, amount, pinHash, nonce, time } │
│ │ │
│ ▼ encrypt with server's RSA public key │
│ MeshPacket { packetId, ttl, createdAt, ciphertext } │
└──────────────────────────────────────┬──────────────────────────────────┘
│ Bluetooth gossip
▼
┌─────────┐ hop ┌─────────┐ hop ┌─────────┐
│stranger1│ ─────▶ │stranger2│ ─────▶ │ bridge │ ◀── walks outside
└─────────┘ └─────────┘ └────┬────┘ gets 4G
│
▼ HTTPS POST
┌─────────────────────────────────────────────────────────────────────────┐
│ FLASK BACKEND (this project) │
│ │
│ /api/bridge/ingest │
│ │ │
│ ▼ │
│ [1] hash ciphertext (SHA-256) │
│ │ │
│ ▼ │
│ [2] IdempotencyService.claim(hash) ◀── atomic dict set (≈ Redis │
│ │ SETNX). Duplicates rejected │
│ │ here, before any work. │
│ ▼ │
│ [3] HybridCryptoService.decrypt(ciphertext) │
│ │ (RSA-OAEP unwraps AES key, AES-GCM decrypts payload │
│ │ AND verifies the auth tag — tampering = exception) │
│ ▼ │
│ [4] Freshness check: signedAt within last 24h │
│ │ │
│ ▼ │
│ [5] SettlementService.settle() │
│ DB transaction: debit sender, credit receiver, write ledger │
│ Optimistic locking on Account (defense in depth) │
└─────────────────────────────────────────────────────────────────────────┘
A random stranger's phone is carrying your transaction. How do you stop them from reading the amount or changing it?
Solution: Hybrid encryption (RSA-OAEP + AES-GCM).
The sender encrypts the payload with the server's public key. Only the server holds the private key, so intermediates see opaque ciphertext.
But RSA can only encrypt small data (~245 bytes for a 2048-bit key), and our payload is JSON that could exceed that. So we use the standard hybrid pattern:
- Generate a fresh AES-256 key for this packet.
- Encrypt the JSON with AES-256-GCM (fast + authenticated).
- Encrypt just the AES key with RSA-OAEP.
- Concatenate:
[256 bytes RSA-encrypted AES key][12 bytes IV][AES ciphertext + 16-byte GCM tag].
Why GCM specifically? It's authenticated encryption. If an intermediate flips one bit anywhere in the ciphertext, decryption throws an exception — the GCM tag won't verify. The server cannot be tricked into processing tampered data.
This is the same scheme TLS uses. See crypto_service.py.
Three bridge nodes hold the same packet. They all walk outside at the same instant. They all POST to /api/bridge/ingest within milliseconds of each other. If you naively process all three, the sender is debited ₹1500 instead of ₹500.
Solution: Atomic compare-and-set on the ciphertext hash.
The very first thing the server does on receiving a packet is compute SHA-256(ciphertext) and try to "claim" that hash:
prev = seen.get(packet_hash)
if prev is None:
seen[packet_hash] = now
return True # first claimer
return False # duplicatedict access under a threading.Lock is atomic. Even if 100 threads call it at the exact same nanosecond, exactly one returns True and the rest return False. Only the first claimer proceeds to decrypt and settle. The rest are short-circuited as DUPLICATE_DROPPED.
Why hash the ciphertext, not the packetId or the cleartext?
packetIdcan be rewritten by a malicious intermediate. Two copies of the same payment could have different packetIds. Bad key.- The cleartext requires decryption first. We want to dedupe before spending CPU on RSA.
- The ciphertext is authenticated by GCM, so any tampering is detectable on decrypt. Two legitimate deliveries of the same payment have byte-identical ciphertexts.
In production this in-memory dict becomes Redis: SET key NX EX 86400. Same semantics, distributed across replicas.
There's also a defense-in-depth fallback: transactions.packet_hash has a unique index. If the cache layer ever fails and two settlements somehow try to write the same hash, the database rejects the second one.
An attacker who captured a ciphertext weeks ago could replay it whenever convenient.
Solution: Two layers.
- Inside the encrypted payload, the sender includes
signedAt(epoch millis). The server rejects any packet older than 24 hours. The attacker can't changesignedAtwithout breaking the GCM tag. - Inside the encrypted payload, the sender includes a
nonce(UUID). Even if Alice legitimately sends Bob ₹100 twice, the nonces differ → ciphertexts differ → hashes differ → both settle. But a replay of one specific signed packet is byte-identical, so the idempotency cache catches it.
See BridgeIngestionService.ingest() in services.py for the freshness check.
upi-offline-mesh/
├── README.md this file
├── requirements.txt Python dependencies
├── app.py Flask bootstrap, DB init, scheduler start
├── config.py App settings (port, DB URI, TTLs)
├── models.py SQLAlchemy ORM models (Account, Transaction)
├── crypto_service.py RSA-OAEP + AES-256-GCM encrypt/decrypt + hash
├── services.py All business logic
│ DemoService, VirtualDevice, MeshSimulatorService,
│ IdempotencyService, SettlementService,
│ BridgeIngestionService
├── controllers.py Flask REST API routes + dashboard route
├── templates/
│ └── dashboard.html Interactive demo UI
└── tests/
└── test_idempotency.py The 3-bridges-at-once test + tamper test
| Method | Path | What it does |
|---|---|---|
| GET | / |
Dashboard HTML |
| GET | /api/server-key |
Server's RSA public key (base64) |
| GET | /api/accounts |
All accounts and balances |
| GET | /api/transactions |
Last 20 transactions |
| GET | /api/mesh/state |
Current state of every virtual device |
| POST | /api/demo/send |
Simulate sender phone — encrypt + inject packet |
| POST | /api/mesh/gossip |
Run one round of gossip across the mesh |
| POST | /api/mesh/flush |
Bridges with internet upload to backend (parallel) |
| POST | /api/mesh/reset |
Clear mesh + idempotency cache |
| POST | /api/bridge/ingest |
The production endpoint. Real bridges POST here |
POST /api/bridge/ingest
Content-Type: application/json
X-Bridge-Node-Id: phone-bridge-42
X-Hop-Count: 3
{
"packetId": "550e8400-e29b-41d4-a716-446655440000",
"ttl": 2,
"createdAt": 1730000000000,
"ciphertext": "base64-encoded-RSA-and-AES-blob"
}{
"outcome": "SETTLED",
"packetHash": "a3f8c9...",
"reason": null,
"transactionId": 42
}outcome can also be DUPLICATE_DROPPED or INVALID.
Run all tests:
pytest tests/ -vThe three included tests:
| Test | What it proves |
|---|---|
test_encrypt_decrypt_round_trip |
Sanity-check that hybrid encryption is symmetric. |
test_tampered_ciphertext_is_rejected |
Flip a byte in the ciphertext, verify that BridgeIngestionService returns INVALID instead of crashing or settling. |
test_single_packet_delivered_by_three_bridges_settles_exactly_once |
The headline test. Three threads, one packet, simultaneous delivery. Asserts exactly one SETTLED, two DUPLICATE_DROPPED, and that the sender's balance changed by exactly the amount once. |
This is a teaching demo. To make it production-grade you'd swap these things:
| What's in the demo | What it would be in production |
|---|---|
| SQLite file DB | PostgreSQL / MySQL with replicas |
| In-memory dict for idempotency | Redis with SET NX EX |
| RSA keypair regenerated on every startup | Private key in HSM (AWS KMS, HashiCorp Vault). Public key cached on devices. |
Server-side DemoService.create_packet() |
Same logic running on Android devices |
Software-simulated mesh (MeshSimulatorService) |
Real BLE GATT or Wi-Fi Direct between phones |
| One settlement service that owns the ledger | Integration with NPCI / a real bank core |
No auth on /api/bridge/ingest |
Mutual TLS or signed bridge-node certificates |
| In-memory accounts seeded on startup | Real KYC'd users, real VPAs, real PIN verification against the bank |
| Logs to console | Structured logs to a SIEM, alerts on INVALID spikes |
The cryptography and idempotency code is essentially production-shaped. The infrastructure around it is what changes.
I want this README to be useful to you when someone reviews the project, so let's be straight about what this design does not solve. These are not implementation bugs — they're inherent to "no internet, anywhere in the chain":
-
The receiver has no way to verify the sender has the funds. When the sender hands the receiver a phone showing "₹500 sent," it's an IOU, not a settled payment. If the sender's account is empty when the packet finally reaches the backend, the settlement will be
REJECTEDand the receiver is out ₹500 with no recourse. This is why real offline UPI (UPI Lite) uses a pre-funded hardware-backed wallet — to give cryptographic proof of available funds offline. -
A malicious sender can double-spend offline. With ₹500 in their account, they could send a packet to Bob in basement A, walk to basement B, and send another ₹500 to Carol. Whichever packet hits the backend first wins; the other gets
REJECTED. Same root cause as #1. -
Bluetooth in real life is hard. Background BLE on Android is heavily throttled since Android 8. iOS peripheral mode is locked down. Two strangers' phones reliably forming a GATT connection while the apps aren't actively open is genuinely difficult and a lot of energy. This demo skips that problem entirely by simulating the mesh.
-
Privacy / liability. A stranger carries your encrypted transaction packet on their phone. They can't read it, but its existence is metadata. In a real deployment you'd want to think about regulatory disclosures and what happens if a device is seized.
For a college / portfolio project: name the concept honestly as "mesh-routed deferred settlement" rather than "real-time offline UPI," and you'll have a much stronger pitch. The cryptography and idempotency work here is real engineering and worth showing off.
| Problem | Fix |
|---|---|
python: command not found |
Install Python 3.10+. On Windows, winget install Python.Python.3.12 or download from python.org. |
| Port 8080 already in use | Change SERVER_PORT in config.py. |
ModuleNotFoundError: No module named 'flask' |
Run pip install -r requirements.txt. |
| Tests fail intermittently | The concurrency test is timing-sensitive. If it ever flakes, run it 3×. If it consistently fails, file the actual failure output. |
MIT — use this however you want.