The design decision
The obvious way to build a payment system is to route every transaction through the server: it already holds the accounts, and it can validate and record each transfer in one place.
This system does not do that. Account operations — registration, login, balance queries — go to the server, but a payment travels directly from payer to payee over a separately negotiated TLS connection. The server’s role in a transaction is to tell each client where the other one is.
The trade is explicit. Direct transfers keep payment traffic off the server entirely, which is the point of a micropayment system: the transactions are individually worthless, so per-transaction server cost is the thing most worth eliminating. What it costs is the server’s position as a single authority over transaction ordering — a real weakness, discussed at the end.
Making that work means every client is simultaneously a client and a server.
Architecture
Server. Accepts TLS connections on a configurable port, dispatching each to a thread pool rather than spawning a thread per connection, so connection count and thread count are decoupled. Shared state — the user table, balances, the online list — is protected by mutexes. Self-signed certificates are generated automatically at startup.
Client. Connects to the server over TLS for account operations. On login it supplies a listening
port of its own, and fork()s a child process that listens for incoming peer connections while
the parent keeps serving the interactive menu.
The fork is what makes concurrency tractable here. A client must accept an inbound payment at any
moment, including while its user is midway through typing one — but a blocking terminal read and a
blocking accept() cannot coexist in one thread of control without turning the whole client into an
event loop. Two processes with separate address spaces get the same result with none of that
complexity, and a crash in the listener cannot corrupt the interactive session.
Transaction flow
- Both clients log in; the server records each one’s IP and listening port.
- The payer requests the online list and selects a recipient.
- The payer opens a TLS connection directly to the recipient’s listening port and sends
<payer>#<amount>#<receiver>&. - The recipient’s forked child accepts, validates and applies the transfer.
- Balances are updated on both sides.
Amounts are bounded to 1–100000 and the payer’s balance is checked before a transfer is accepted.
Protocol
A line-oriented, #-delimited text protocol:
REGISTER#<username>#<initial_balance> register a new account
<username>#<port> login, announcing a listening port
List query balance, server public key, online users
<payer>#<amount>#<receiver>& transfer
Exit logout
with numeric status codes on the response side:
| Code | Meaning |
|---|---|
100 OK | Registration successful |
210 FAIL | Username already taken |
220 AUTH_FAIL | Not registered, or already logged in elsewhere |
Text framing over TLS is not the compact choice, but it makes the protocol inspectable during development, when the alternative is debugging a binary format through an encrypted channel.
Security properties
- All traffic is encrypted — client–server and peer–peer. A direct payment is not a plaintext side channel around the secured path.
- Certificates are exchanged and inspected in both directions, so a peer connection is not accepted purely on the basis of an address supplied by the server.
- Duplicate login is rejected: one account cannot hold two concurrent sessions, which prevents the same balance being spent from two places at once.
- Shared server state is mutex-protected, so concurrent balance updates from pooled threads cannot interleave.
Where this design is weak
Worth stating plainly, since the architecture invites the question.
The certificates are self-signed and generated by the server itself, so the trust root is the server — sufficient for a closed system, useless against an attacker who can impersonate it. Peer authentication inherits that limit: a client trusts that the address the server handed it belongs to the named user.
More fundamentally, moving transactions off the server removes the server’s ability to order them. Two peers can transact while the server holds a stale view of both balances, and the system has no mechanism — no sequence numbers, no reconciliation — for detecting a peer that lies about the outcome. A production system would need the server to at least witness transfers, or the peers to exchange signed receipts that the server can later reconcile.