A simulation of an offline UPI payment. Three processes stand in for the three parties:
| Process | Role | Internet |
|---|---|---|
cmd/bank |
The bank. Holds the only copy of the RSA private key and the ledger. | n/a — it is the server |
cmd/node (Alice) |
Payer with a dead data connection. | no |
cmd/node (Bob) |
A stranger's phone in radio range. Relays packets. | yes |
Alice cannot reach the bank. She builds an encrypted payment packet and broadcasts it over UDP (standing in for Bluetooth/NFC). Bob picks it up and POSTs it to the bank. Bob is a courier, not a trusted party — he cannot read who is paying whom, or how much.
Everything below is runnable as written; no other document is needed.
- How the packet keeps Bob out
- Prerequisites
- Fast check: the automated tests
- Running the demo
- What to point at, in order
- Follow-ups an audience will ask for
- Resetting between runs
- Piping input instead of typing
- Bank HTTP API
- Known limitations
Hybrid encryption, in crypto/hybrid.go:
- Alice generates a fresh 32-byte AES key, unique to this transaction.
- The payment details (
sender_upi,receiver_upi,amount) are sealed with AES-GCM. GCM also authenticates, so flipping a single byte makes decryption fail rather than silently produce different numbers. - That AES key is encrypted with the bank's RSA-2048 public key. Only the bank's private key can recover it.
What travels in the clear is only what a relay needs to do its job: an opaque transaction ID (for de-duplication) and a TTL (so stale packets die). See models/packet.go.
Go 1.22 or newer. The SQLite driver is the pure-Go modernc.org/sqlite, so no
C compiler is needed.
go version
If that fails, install Go and then open a new terminal so the PATH change takes effect:
winget install GoLang.GoThen, from the repository root:
go mod download
The repo ships bank_public.pem / bank_private.pem, so there is nothing to
generate. To make a fresh pair anyway:
go run ./cmd/keygen
Regenerating requires restarting the bank, and any packet built with the old public key will fail to decrypt.
This verifies the crypto and every ledger rule (replay, double-spend, insufficient funds, unknown accounts) with no servers running. Good smoke test before a demo:
go test ./...
ok github.com/Amitesh-exp/NoInternetUpi/bank
ok github.com/Amitesh-exp/NoInternetUpi/crypto
Compile everything once so nothing stalls mid-demo:
go build ./...
Run all four from the repository root — the bank reads bank_private.pem and
the nodes read bank_public.pem from the working directory. Arrange the windows
so all four are visible at once; the point lands visually.
Terminal 1 — the bank. -reset restores the demo balances and clears the
replay log, so the demo is repeatable.
go run ./cmd/bank -reset
Demo balances reset to starting values
Bank server running on :8080
alice@upi 1000.00
bob@upi 500.00
charlie@upi 750.00
Terminal 2 — Bob, the stranger who has internet.
go run ./cmd/node Bob true 9997
Terminal 3 — Charlie, another phone with no internet. Optional, but he shows that a relay without internet correctly declines to forward.
go run ./cmd/node Charlie false 9996
Terminal 4 — Alice, who wants to pay and has no signal.
go run ./cmd/node Alice false 9998
Each node needs its own terminal with a live keyboard. A node launched with its input piped or redirected drops the menu and stays a pure listener — useful for scripting, but not what you want for Alice.
In Alice's terminal, choose 1 and answer:
Your UPI ID: alice@upi
Receiver UPI ID: bob@upi
Amount: 100
1. Alice — she broadcasts, and never talks to the bank:
Node has no internet — broadcasting to nearby nodes
Packet broadcast to 2 node(s): 69d43c75b6798841e5a74fbfd6814c53
2. Charlie — received it, and is a dead end:
[Charlie] Received packet: 69d43c75b6798841e5a74fbfd6814c53
[Charlie] No internet — cannot forward
3. Bob — carried a payment he cannot read:
[Bob] Received packet: 69d43c75b6798841e5a74fbfd6814c53
[Bob] Has internet — forwarding to bank
Payment forwarded successfully: payment successful
[Bob] Forward successful
4. The bank — settles it:
SETTLED 69d43c75b6798841e5a74fbfd6814c53: alice@upi -> bob@upi 100.00
In Bob's terminal press 2 and enter alice@upi (expect 900), then again
with bob@upi (expect 600).
Or query the bank directly:
curl.exe "http://localhost:8080/balance?upi=alice@upi"
curl.exe "http://localhost:8080/balance?upi=bob@upi"
In PowerShell use curl.exe, not curl — the bare name is an alias for
Invoke-WebRequest. The PowerShell-native form is:
Invoke-RestMethod "http://localhost:8080/balance?upi=alice@upi"Add fmt.Printf("%+v\n", packet) to the handler in
cmd/node/main.go. All Bob can print is the transaction ID,
the TTL, and two opaque byte blobs — no UPI IDs, no amount.
The node CLI only produces well-formed packets, so cmd/probe plays the part of a misbehaving client. With the bank running, in a fifth terminal:
go run ./cmd/probe all
valid PASS success=true message="payment successful"
tamper PASS success=false message="decryption failed — packet may be tampered"
expired PASS success=false message="packet expired — TTL exceeded"
replay/1st PASS success=true message="payment successful"
replay/2nd PASS success=true message="already processed"
tamperflips one bit of ciphertext, proving a relay cannot rewrite the amount without the bank noticing.expiredsends a packet whose TTL is already in the past.replaysends the identical packet twice; the money moves once.
Individual cases and options: go run ./cmd/probe -amount 5 tamper,
go run ./cmd/probe -to nobody@upi valid. It exits non-zero if any case
misbehaves, so it also works in CI.
Stop Charlie (Ctrl+C) and restart him with internet:
go run ./cmd/node Dave true 9996
Send again from Alice. Both Bob and Dave forward the identical packet, but the
bank prints SETTLED once and REPLAY for the other:
SETTLED f502b4d3bbd6b793fc27dd22b8730232: alice@upi -> bob@upi 25.00
REPLAY f502b4d3bbd6b793fc27dd22b8730232: already processed
The balance drops by one payment, not two. The transaction ID is recorded in the same SQL transaction that moves the money, so two relays arriving at the same instant cannot both settle.
Send 99999 from alice@upi. Bob reports
bank rejected payment: insufficient balance, and no balance changes.
Send to nobody@upi. Bob reports
bank rejected payment: receiver account not found, and Alice is not
debited.
Ctrl+C the bank and restart it with -reset. Balances return to
1000 / 500 / 750 and the replay log clears, so the whole demo can be run
again — including reusing transaction IDs.
go run ./cmd/bank -reset
The node CLI reads plain lines from stdin, so a run can be scripted. End with
menu choice 3 to exit; if stdin simply runs out, the node keeps listening as a
relay (which is what you want when starting Bob with no terminal attached).
In bash:
printf '1\nalice@upi\nbob@upi\n100\n3\n' | go run ./cmd/node Alice false 9998In PowerShell, feed the lines as an array so no escape characters are needed:
@('1','alice@upi','bob@upi','100','3') | go run ./cmd/node Alice false 9998POST /pay — body {"packet": {...}}, replies {"success":bool,"message":string}.
GET /balance?upi=alice@upi — replies {"upi":string,"balance":number}.
Flags: -addr (default :8080), -db (default bank.db), -key (default
bank_private.pem), -reset.
go run ./cmd/node <node-name> <has-internet> <port>
has-internet is true or false. port must be one of the ports in
KnownNodes (9998, 9997, 9996) for other nodes to reach it.
Deliberate simplifications, not bugs — worth knowing before anyone mistakes this for a design. Better said out loud in a demo than waited on.
- No sender authentication. The packet is encrypted to the bank but not
signed by Alice. Anyone holding
bank_public.pem— which every node has by design — can mint a packet that debitsalice@upi. A real system would give each user a keypair, sign the payload, and have the bank verify against a registered public key. This is the largest gap. bank_private.pemis committed to the repository. Fine for a demo, catastrophic anywhere else.- Balances are
float64. Real money should be integer minor units to avoid rounding drift. - Single-hop relaying. A node that receives a packet and has no internet drops it rather than re-broadcasting. Multi-hop would need a hop count and loop suppression.
- No offline balance check. Alice cannot know she has funds until the bank settles, so an offline payment can be rejected after the fact. Real designs use pre-authorised spending limits.
KnownNodesis a hardcoded port list in node/broadcast.go, standing in for "whoever is within radio range".