
Interchange: An ISO 8583 Switch That Settles Onchain
Every writeup about crypto cards has the same diagram. A card on the left, a blockchain on the right, an arrow between them. The arrow is doing an enormous amount of work, and nobody ever opens it.
I wanted to open it. Interchange is a working ISO 8583 authorization switch whose money lives in a Solidity vault: it decodes real card messages off a TCP socket, decides whether to approve them, and settles onchain. It runs against Anvil, it has never touched a card network, and it never will. The point was not to build a payment company. The point was to find out what actually breaks when you put a two-phase protocol from 1987 on top of a one-phase ledger from 2015.
Something does break. This article is mostly about that.
What is actually on the wire
Before any of it makes sense, here is a real authorization request, lifted from a recorded session rather than typed out by hand. It opens with a length header, a four-digit message type indicator, and a bitmap:
00ab length header, 171 bytes
30313030 MTI, ASCII "0100"
37323343343430303038453038303030 primary bitmapThe bitmap is the part worth staring at. Sixteen hex-ASCII characters, sixty-four bits, and every set bit claims one data element. That one says fields 2, 3, 4, 7, 11, 12, 13, 14, 18, 22, 37, 41, 42, 43 and 49 follow, in exactly that order.
There are no field names on the wire and no delimiters between fields. You know where field 4 ends because the spec says it is twelve digits. If your spec and the sender's disagree by a single digit, you do not get an error, you read the next field's bytes as part of this one and carry on confidently. That is why the codec is the untrusted boundary and why it is the thing I pointed a fuzzer at.
Decoded, the fields above read:
DE 2 Primary account number 411111••••••1111
DE 3 Processing code 000000 (purchase)
DE 4 Amount transaction 12.34 USD
DE 18 Merchant category code 5812 (Eating places, restaurants)
DE 37 Retrieval reference number 622315880514
DE 49 Currency code transaction 840 (USD)Two things to carry forward. DE 4 is not 12.34 on the wire, it is 000000001234: twelve digits meaning 1234 minor units of whatever DE 49 names, which comes back to bite later. And DE 2 arrives as a full card number, which is why it is already masked here.
The two seconds you are given
When you tap a card, a terminal sends an 0100 authorization request and waits. If it does not get an 0110 back in roughly two seconds it times out, and the customer taps again, and now you may have two authorizations for one coffee.
Two seconds sounds generous. It is not, once you notice what has to happen inside it, and it is the hard constraint every other decision falls out of.
So the first question I had was simple: can the chain write fit inside the window? I measured it rather than guessing.
| Authorization decision | 2.46 microseconds |
| Round trip over a socket | 28.3 microseconds p50, 45.7 microseconds p99 |
lock() against Anvil | 1.955 milliseconds p50 |
The chain write is about eight hundred times the decision and about seventy times the whole network round trip. On paper, 1.955 milliseconds still fits inside two seconds with room to spare.
That is the trap, and I want to be precise about it, because "blockchains are too slow for payments" is the lazy version of this argument and it is wrong. Anvil is a local node with instant blocks and no competition. A real chain has block time you do not control, finality you have to wait for, and congestion that arrives exactly when everyone is buying coffee. The tail is what kills you, not the median. You cannot put an unbounded tail inside a two-second window and call it an authorization system.
There is a second, quieter reason, and it is the one that actually decides the architecture. If the chain write is inside the authorization path, then authorization throughput becomes a function of chain throughput. I measured that too, by building it both ways: 683 messages per second with the write inline, against 32,818 with it moved off the path. Coupling the two is precisely the thing the design exists to avoid.
So: answer from memory, write afterwards.
The one idea: the answer leaves before the write
If you remember one thing about Interchange, make it this.
The switch decides using an in-memory hold book and a balance cache. It writes the 0110 to the socket. Then, and only then, it submits the lock() to the vault.
Which means the switch can approve a payment and then fail to write it.
Not "might, in a rare edge case." Will, eventually, by construction. The acquirer holds a valid authorization code for money that was never reserved, and there is no mechanism anywhere in the system to take that back. The answer is already gone.
I found that this is where most designs quietly cheat. They write to the chain first and hope the window holds, or they treat the failure as an operational problem for later. I decided early that this hole was the actual subject of the project, and everything else is scaffolding around making it visible, bounded, and reconciled.
Making the ordering a type, not a rule
The obvious way to get the ordering right is to write the code carefully and leave a comment. That works until someone refactors it at eleven at night.
So the handler does not do chain work at all. It returns a value:
pub struct Outcome {
pub response: Option<Message>,
pub after: Vec<AfterResponse>,
}The caller writes response to the socket, and then submits everything in after. The handler has no chain client to misuse and no way to await a transaction, because it cannot reach one. Inverting the order is not a mistake you can make by forgetting; it is a mistake you would have to commit deliberately by restructuring two modules.
There is a test that asserts the socket write happens before the submission counter increments, but the test is a belt on top of braces. The shape is the guarantee.
Deciding from memory without lying about the balance
If the chain is not in the authorization path, the switch has to answer from its own state, and that state is always slightly wrong. The question is which direction it is wrong in.
Two structures:
The balance cache holds each account's available balance along with the block it was read at. That block number is not decoration. It is the input the staleness rule needs: if the head has moved more than fifty blocks past the read, the switch declines with 91 rather than approving against a number it can no longer defend.
The hold book holds every reservation currently in flight, including ones whose lock() has not confirmed yet. This part is easy to get wrong. If unconfirmed locks did not reserve, two authorizations arriving on different connections a millisecond apart would both see the whole balance and both approve.
I built a race scenario for this before I trusted it. Ten concurrent authorizations against an account with room for two, and the invariant is that the sum of approvals never exceeds the balance.
The hold book is deliberately conservative in a way worth explaining, because I tried to fix it and had to revert.
The chain's own availableBalance already subtracts confirmed holds. So when the switch subtracts its whole hold book from the cached balance, confirmed holds get counted twice, and the account is starved of spending room it genuinely has. The obvious refinement is to skip holds the cache should already know about, by comparing the hold's confirmation block against the cache's read block.
I implemented that. It over-approved: six authorizations against room for two. The reason is that those two block numbers come from separate, unordered reads, so "the cache is newer than the confirmation" is not a fact you can conclude from comparing them. Being wrong in the direction of starving an account is a bad user experience. Being wrong in the direction of over-approving is the failure the entire project is about. I reverted it and wrote the reasoning into the code, where the next person to have the same clever idea will find it.
What the vault has to model
A blockchain write is one phase and final. A card hold is neither, and the vault has to absorb that difference.
SettlementVault therefore models the hold explicitly: an amount, an expiry, a merchant category, and a running captured total. Three things about it turned out to matter more than I expected.
Tolerance is snapshotted at lock time. Restaurants can capture more than they authorized, because of tips. That tolerance belongs to the merchant category of the authorization, and it is written into the hold when the hold is created. A clearing message arriving two days later cannot claim a different MCC and buy itself a larger tolerance.
Holds expire, and expiry is the cardholder's escape hatch. If the switch dies holding a reservation, the money must not be locked forever. There is a maximum hold duration, and after expiry anyone can reclaim it.
The vault does not trust the switch. The delegate key can lock and capture, but every limit is re-checked onchain. A buggy switch can waste gas; it cannot move money outside what the hold permits.
The invariant suite found a real bug
I wrote seven invariants, ran them with actor-based handlers and ghost variables, and one of them failed.
An over-tolerance capture, with two holds on the same account, could consume the sibling hold's reservation and underflow the available balance. The capture path was checking the amount against the wrong quantity: it compared against the account balance without first subtracting what the other holds still had spoken for.
uint256 heldAfter = totalHeld[account] - fromHeld;
uint256 spendable = balanceOf[account] - heldAfter;
if (amount > spendable) revert InsufficientAvailable(amount, spendable);I would not have found that by reading the code. It needs two holds, an over-tolerance capture, and a specific order, which is exactly the shape a fuzzer finds and a human does not. It is the best argument for invariant testing I have personally produced.
One thing I got wrong in the harness itself, since it cost me an afternoon: I put coverage assertions in afterInvariant, which fires per sequence. A shrunk one-call sequence legitimately has zero counters, so the assertions failed on a passing run. Coverage belongs in its own standalone test.
Then the lock fails
Everything above is the happy path with guardrails. Here is the case the project is named for.
The 0110 is on the wire. The lock() reverts, because the account's available balance moved between the switch's cached read and the transaction landing. The switch now knows something the acquirer does not.
What it does, in order:
- Record the exposure. Amount, retrieval reference, STAN, and cause, in a book kept separately from reservations. An exposure is not a reservation; it is a loss.
- Release the reservation. The speculation lost, so the room goes back. Holding it would starve the account for nothing.
- Send an unsolicited
0420advice. ISO 8583 has exactly one mechanism for telling an acquirer that something already answered has gone wrong, and this is it. It is not a retraction. It is a notification, sent down the same link. - Let reconciliation find it. A three-way diff across the internal ledger, the batch clearing file, and onchain state, sorted into eight named break categories. An approval with nothing behind it is a specific, countable row.
Notice what is not in that list: any way to make it not have happened. That is the honest answer, and the project's position is that saying so plainly is better than a design that pretends otherwise.
Details that are easy to get wrong
A few things I would flag to anyone building in this space, because each of them was a real bug or a near miss.
Never divide by one hundred. Amounts on the wire are integers in the currency's minor units, and how many minor units there are is a property of the currency, carried in a different field. The same twelve digits are three different amounts:
DE 4 = 000000001234, DE 49 = 840 USD, exponent 2 -> 12.34 USD
DE 4 = 000000001234, DE 49 = 392 JPY, exponent 0 -> 1234 JPY
DE 4 = 000000001234, DE 49 = 414 KWD, exponent 3 -> 1.234 KWDA hardcoded / 100 is correct for most of the table and silently wrong by a factor of a hundred for yen. Every conversion goes through the exponent table instead. The token is six decimals and the display currency is usually two, so there is a second conversion on top of that, and it happens exactly once, in exactly one function, for the same reason.
Redaction belongs in the code, not the config. The card number and PIN block are redacted before anything leaves the process, and which fields those are is a constant:
/// Fields whose contents never reach a log, a Debug output, or the ledger.
///
/// This is a constant and not a config value on purpose. A config file that can
/// turn redaction off is a config file that will eventually turn redaction off.
/// The TOML carries the same flags for readability and `Spec::load` rejects any
/// disagreement, so the two cannot drift.
pub const REDACTED: [u8; 2] = [2, 52];The field table is otherwise entirely config-driven, so the temptation to make this one more row in the TOML was real. The loader carries the flags for readability and then refuses to start if they disagree with the constant, which means the config can describe the policy but never relax it.
That still left a gap I did not see until I audited my own code: two structs had a derived Debug that would have printed a card number and a PAN salt straight into a log line. Redacting at the encoder does nothing if a struct can print itself. The fix was a CardNumber newtype whose Debug and Display both redact, and a hand-written Debug for the context that owns the salt.
Releasing a hold is not the same as forgetting it. I had a bug where releasing a reservation also erased the replay record, so the same authorization could be approved twice. Replay protection and reservations look similar and have completely different lifetimes; they are now separate structures, and the replay set is bounded so it cannot grow forever.
Two processes produced identical retrieval references. The STAN counter was per-connection, so two terminals generated the same sequence. It is now process-global and randomly seeded, which is not a cryptographic fix but is the right shape for a reference number.
Measure through the real client. My first lock() measurement said 290 milliseconds. It was timing cast process spawn. Measured through the actual Alloy client it is 1.955 milliseconds, a factor of 150. Every number in the repository comes from the path the switch actually uses, and I would not trust a benchmark that does not.
Showing it instead of describing it
All of the above is prose about ordering and bytes, and prose is bad at both. So the last thing I built is a protocol explainer: one HTML file, no build step, no dependencies, that you can open straight off disk.
It does two things text cannot.
It draws every byte of a real ISO 8583 message in a hex grid with each field located in it. Hover the amount field and the twelve bytes carrying it light up. Click the bitmap and it expands into the list of data elements its bits claim. The card number shows as 411111 dots 1111 with a MASKED tag, because the masking happened in the switch and the browser never received anything else.
And it puts the timing on one line:
0100 in · 4ms → 0110 out · 4ms → lock() sent · 4ms → lock() reverted · 4msThe approval left before the write was submitted. That is the whole argument, and it took a timeline to make it land in a way three paragraphs could not.
Two constraints I set for it that I think are worth stealing. First, the fixtures are recordings, never hand-written JSON: a real terminal drives a real switch over a real socket, and whatever the switch emits is what gets written down. A test re-records all six and fails if they drift structurally from what the code now does, so the demo cannot quietly become a lie. Second, every number shown is measured. Confirmation latency is timed. Gas is reported as null rather than invented, because the client returns a transaction hash and not a receipt. A page that fabricates one number is a page you cannot trust about any of them.
What I would tell someone starting this
Build the failure first. I spent the early phases on the codec and the happy path, and the project only became interesting when I built the case where the chain write fails and could not make it go away. Everything worth reading in the repository is downstream of taking that case seriously rather than deferring it.
Let the fuzzer and the invariant suite disagree with you. The codec ran 857 million executions with no findings, which was reassuring. The vault invariants found a real bug in the second hour, which was worth more.
And be careful with the word "settles." A card approval and a chain write are both sometimes described as settlement, and they are not the same event. Roughly a third of the difficulty in this project was keeping those two meanings apart in my own head.
The code is at github.com/frdrckj/interchange. It is a simulation, it only talks to Anvil, and it refuses to start against anything else.
FJerusha