DEV Community

Cover image for Coins in Motion, Take Two: I Built AWS's Road-Toll Payment Demo (and Broke My Own Ledger Doing It)
Yuuki Yamashita
Yuuki Yamashita

Posted on

Coins in Motion, Take Two: I Built AWS's Road-Toll Payment Demo (and Broke My Own Ledger Doing It)

Coins in Motion, Take Two: I Built AWS's Road-Toll Payment Demo (and Broke My Own Ledger Doing It)

AWS published Coins in Motion, a piece on letting vehicles pay for things on their own — tolls, EV charging, in-car purchases — using blockchain-style agentic payments instead of a human tapping a card. The road-toll example stuck with me: a car crosses a border, the license plate gets read, and somehow that turns into a toll paid in the right currency without the driver doing anything. I wanted to see what the actual mechanics of that would look like in code, so I built a small Next.js app that simulates a car driving from Rotterdam to Milan through four toll segments, hashing the plate, converting each local charge into a settlement stablecoin, and writing it all to a ledger that's supposed to be tamper-evident. It mostly worked on the first try. The ledger's tamper detection did not — more on that below.

What the demo actually does

The app is called toll-payment-agent, and it plays out a single route: A12 in the Netherlands, A3 in Germany, A2 in Switzerland, A9 in Italy. Click through each segment and the backend does four things per toll:

  1. Hashes the license plate with a salted SHA-256 (lib/plateHash.ts) — the plate itself is never stored, only the hash, which is the actual privacy mechanism AWS's post describes.
  2. Looks up the segment's distance and per-km rate to get a local-currency amount.
  3. Converts that amount into USDC at a mock exchange rate (lib/exchangeRates.ts) — this is where the cross-border part shows up, since Switzerland bills in CHF while the Netherlands, Germany, and Italy bill in EUR.
  4. Appends the result to an in-memory, hash-chained ledger (lib/ledger.ts), where each entry embeds the hash of the entry before it.

Nothing here touches a real chain or moves real money — it's a simulation of the logic, not a payment rail. What I wanted to get right was the shape of the system: privacy-preserving identity, automatic currency conversion at the moment of charge, and a ledger you can independently verify wasn't edited after the fact.

The bug: my "tamper-evident" ledger flagged its very first entry

The ledger chain works the way you'd expect from a toy blockchain: each entry stores a previousHash, and its own hash is SHA256(index + timestamp + previousHash + payload). Verifying the chain means recomputing that hash for every entry and checking it matches what's stored. I wrote appendEntry() to build the hash from an object literal, and verifyLedger() to rebuild an equivalent object from a stored entry and hash it the same way. Then I drove the car through toll segment one, and the UI immediately showed "tamper detected" — on a ledger with exactly one entry that nothing had touched.

The two object literals didn't have the same key order. appendEntry() built its object as { index, timestamp, previousHash, ...input }, and my toUnhashed() helper in verifyLedger() rebuilt it as { index, timestamp, plateHash, segmentId, ..., previousHash } — same fields, different order. JSON.stringify() serializes object keys in insertion order, so the two "identical" payloads produced two different strings, and therefore two different hashes. The chain was never actually broken; my two hashing code paths just disagreed about how to describe the same data.

The fix I ended up with doesn't repair the object-literal approach, it removes the ambiguity entirely — both appendEntry and verifyLedger now call one computeHash() that joins an explicit, ordered list of fields with | instead of relying on whatever order an object happens to serialize in:

function computeHash(entry: {
  index: number;
  timestamp: string;
  previousHash: string;
  plateHash: string;
  segmentId: string;
  country: string;
  localAmount: number;
  localCurrency: string;
  settlementAmount: number;
  settlementCurrency: string;
  exchangeRate: number;
}): string {
  const canonical = [
    entry.index, entry.timestamp, entry.previousHash, entry.plateHash,
    entry.segmentId, entry.country, entry.localAmount, entry.localCurrency,
    entry.settlementAmount, entry.settlementCurrency, entry.exchangeRate,
  ].join("|");
  return createHash("sha256").update(canonical).digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

It's a small bug, but it's the kind that matters more than it looks: a "tamper-evident" ledger that cries wolf on legitimate data is worse than no verification at all, because the first thing a real operator does after seeing a false positive is stop trusting the alarm. If JSON.stringify key ordering can silently break a from-scratch demo in an afternoon, I'd want to know a production system pinned its serialization format explicitly rather than assumed V8 would always insert keys the same way on both sides of the check.

Driving the route

With the bug fixed, one plate (NL-123-AB) driving all four segments produces this:

# Country Local charge Settled as Rate used
0 Netherlands 8.10 EUR 8.8290 USDC 1 EUR = 1.09 USDC
1 Germany 8.80 EUR 9.5920 USDC 1 EUR = 1.09 USDC
2 Switzerland 11.40 CHF 13.1100 USDC 1 CHF = 1.15 USDC
3 Italy 5.00 EUR 5.4500 USDC 1 EUR = 1.09 USDC

Same plate hash on every row, four different local currencies and toll rates, one running total in a single settlement currency, and a ledger that verifies clean end to end. That's the part of AWS's post I wanted to actually see working: the driver never touches a currency converter or a payment app per country — the conversion happens automatically at the moment the toll is charged, and the audit trail is the ledger itself rather than a stack of receipts in four currencies.

What's real and what isn't

The plate hashing is real SHA-256, and the ledger chaining is a real (if simplified) hash chain that you can verify by walking it. What's simulated: the exchange rates are hardcoded constants instead of a live feed, there's no actual stablecoin or blockchain underneath the "USDC" label, and the ledger lives in a module-level array — on Vercel's serverless functions that means it resets whenever the function instance recycles, which is fine for a demo and not fine for anything real. A production version of this would need a persistent, genuinely append-only store (or an actual chain) behind the same interface, and real FX rates pulled at transaction time instead of baked into exchangeRates.ts.

Code's on GitHub at yama3133/toll-payment-agent, live demo at toll-payment-agent.vercel.app if you want to drive the route yourself and watch the ledger fill up.

Top comments (0)