
Interchange
Interchange is an ISO 8583 authorization switch whose money lives in a Solidity vault. A terminal taps, the switch decodes a real ISO 8583 message off a length-delimited TCP socket, decides in microseconds against an in-memory hold book, and answers inside the two-second window a payment terminal will tolerate. Only then does it submit the onchain lock. That ordering is not an optimization, it is forced: a chain write cannot fit inside the issuer window, and the reason is block time and finality rather than raw speed. The consequence is a hole. The switch can approve a transaction and then fail to write it, leaving the acquirer holding a valid authorization code for money that was never reserved, and nothing can take that back. Most writing about crypto cards skips this. Here it is the thing the whole design answers for: the ordering is enforced by the type system, the failure is recorded as a bounded exposure, an unsolicited 0420 advice goes back down the link, and reconciliation sorts what is left into named break categories.
GitHub Repository: https://github.com/frdrckj/interchange
Features
- Real ISO 8583, Not a Toy Encoding: A 1987-spec codec with MTI, hex-ASCII bitmaps, fixed and LLVAR/LLLVAR fields, and a field table driven by config rather than hardcoded. It is the untrusted input boundary, so it is the fuzz target: 857,332,237 libFuzzer executions with no findings, plus proptests that assert decode and encode round-trip byte-for-byte.
- The Ordering Guarantee, Enforced by Types: The handler returns
Outcome { response, after }rather than performing chain work itself. The response is written to the socket, and only then is theafterlist of chain jobs submitted. Reordering the two is not a discipline anyone has to remember, it is a shape the compiler will not let you invert. - Decisions From Memory: The authorization path never waits on the chain. It reads an in-memory hold book of in-flight reservations and a balance cache stamped with the block it was read at. Unconfirmed locks still reserve, because otherwise two authorizations arriving together would both see the whole balance. If the cache is more than fifty blocks stale the switch declines with 91 rather than guessing.
- Measured, Not Estimated: A decision takes 2.46 microseconds. A round trip over a real socket is 28.3 microseconds p50 and 45.7 microseconds p99, sustaining 33,196 messages per second on one connection. A
lock()against Anvil takes 1.955 milliseconds p50, roughly eight hundred times the decision. Every number in the repository was measured through the real client after an earlier attempt usingcast sendproved to be 150 times wrong, because it was timing process spawn. - Settlement Off the Authorization Path: Chain writes drain through a bounded queue into a single worker, because one delegate key is one nonce sequence. Doing the write inline made authorization throughput a function of chain throughput, which is precisely the coupling the design exists to break: 683 messages per second against 32,818, measured both ways.
- Approved But Not Locked: When a lock reverts, the acquirer already has the approval. The switch records the exposure, releases the speculative reservation, logs it loudly, and sends an unsolicited 0420 advice down the same link, which is the only mechanism ISO 8583 offers for retracting something already answered.
- Two-Phase Clearing: Authorization reserves, clearing moves. Presentments arrive with their own STAN days later, matched on the retrieval reference number, and captured against the tolerance of the authorization's own merchant category rather than the presentment's, so a clearing message cannot buy itself a larger tolerance by claiming a different MCC. Tips, partial captures, hotel incrementals, reversals, and expiry all have scenarios.
- Settlement Vault: A Solidity vault that models the hold explicitly, since a chain write is one-phase and a card hold is not. Tolerance is snapshotted into the hold at lock time, holds have a maximum duration, and expiry is the cardholder's escape hatch. A Foundry invariant suite with actor-based handlers and ghost variables found a real bug: an over-tolerance capture with two holds could consume a sibling's reservation and underflow the available balance.
- Money Handled Correctly: Amounts are integers in currency minor units and every conversion goes through the currency exponent table, never a hardcoded division by one hundred, because JPY has no minor unit and KWD has three. The token is six decimals and the display currency is usually two, so that conversion happens exactly once, in one function.
- Redaction as a Code Constant: The primary account number and the PIN block are redacted before anything leaves the process. The redacted field list is a Rust constant, not configuration, and
Spec::loadrejects any config that disagrees with it, so a config file cannot weaken it. A test asserts no emitted event payload contains a test card number, checked against the real constants. - Three-Way Reconciliation: A reconciler diffs the internal ledger, the batch clearing file, and onchain state into eight named break categories, so an approval with no lock behind it is a specific, countable thing rather than a discrepancy someone notices later.
- Protocol Explainer: A single HTML page, no build step and no dependencies, that draws every byte of a real message in a hex grid with each field located in it. Hovering a field lights up its exact bytes, the bitmap expands into the data elements its bits claim, and a timeline shows the 0110 approval leaving at four milliseconds, before the lock that then reverts. The six shipped sessions are recordings of a real switch driven over a real socket, and a test re-records them all and fails on structural drift.
- Safety Rails:
unsafe_codeis forbidden, andunwrap,expect,panic, and slice indexing are all denied outside tests. The switch refuses to start against a non-loopback RPC endpoint, only Luhn-valid test card numbers are accepted, and a CI job fails the build if anything internal is ever tracked.
Stack
• Codec & Switch
- Rust - The codec, the switch, the ledger, the reconciler, and the traffic generator.
- Tokio - Async runtime, the TCP acquirer link, and the settlement worker.
- tokio-util - Length-delimited framing for the ISO 8583 link.
- cargo-fuzz / proptest - The codec fuzz target and the round-trip property suite.
• Chain
- Alloy - Provider, signing, and typed contract bindings (not the deprecated ethers-rs).
- Solidity - The settlement vault and its test token.
- Foundry - Build, fuzz, and invariant testing, plus the Anvil dev chain.
• Storage & Reporting
- SQLite via sqlx - The double-entry ledger, where every posting set sums to zero.
- criterion - Benchmarks for the authorization path.
• Protocol Explainer
- axum - The event stream and static page, bound only under
--demo. - A single static HTML file with inline CSS and vanilla JavaScript, no npm and no build step.