Inspiration
Most payment fraud doesn't break any cryptography. It fools people. Americans reported \$15.9B in fraud losses to the FTC in 2025, and \$3.5B of that came from impostor scams. In those scams someone pretends to be a business or a bank, and the victim approves the payment on the same phone the scammer is coaching them through. At the high end, Bybit lost about \$1.5B when a tampered interface showed a normal transfer while the hardware wallets signed something else.
Hardware security keys mostly ended phishing for logins. After Google gave physical keys to more than 85,000 employees, it reported no confirmed account takeovers. Payments never got an equivalent. We wanted a key that, before you sign, can tell you three things: who you're paying, whether they're actually in front of you, and what you're really signing.
What it does
The Solana DEF CON badge becomes a hardware security key for payments. Before any money moves, the badge's firmware checks three things, and no app can override those checks:
| Guarantee | Question | How |
|---|---|---|
| Who | Is this the payee Capital One verified? | A registry record signed by the issuer, checked on the badge, and anchored with the Solana Attestation Service |
| Present | Is the payee's device right here? | The payer's badge sends a fresh random challenge over ESP-NOW radio, and the merchant's badge has to sign it within a deadline |
| What | What exactly am I signing? | The badge decodes the real Solana transaction bytes and checks recipient, mint, decimals and amount |
If every check passes, the screen turns green ("MHacks Merch ✓ verified ● present · 40.00 HACK") and pressing SELECT signs. Otherwise the screen turns red and signing is disabled.
Payments are SPL transfers of HACK, our stand-in stablecoin on Solana devnet. A laptop dashboard called BadgePay does three jobs:
- it shows every payment live as it lands on chain;
- it lets Capital One, as the issuer, verify and revoke merchants;
- it plays the attacker in the demo.
We demonstrated all of this on real badges:
- A normal payment turned green and landed on devnet.
- An impostor badge calling itself "MHacks Merch" showed red: unverified.
- A compromised checkout displayed 5 HACK while the real transaction was for 500. The badge showed the real 500 and refused.
- A revoked merchant turned red within about 20 seconds and went back to green after we re-issued its record.
How we built it
- Badge firmware (C/C++) is our fork of spacemandev's Solana OS on the ESP32-S3. The trusted core holds the Ed25519 badge key and makes every security decision:
- it verifies the record, the payment request and the presence proof;
- it decodes the transaction;
- it draws the approval screen itself.
- Badge apps (Lua) are untrusted: Pay for the payer and Request for the merchant. They handle the UI and radio messages and pass bytes to the core. The core returns either a signature or a refusal. An app can't sign directly, change what the approval screen says, or skip a check.
- Domain-separated signatures. Every signature carries a prefix (
registry:,pay-req:,pay-proof:, or a raw Solana message), so a signature made for one purpose can't be replayed as another. - Presence timing. On the badge, signing takes about 211 ms and verifying about 18 ms. A full challenge round trip measured 234–251 ms with Wi-Fi off, comfortably inside our deadline:
$$t_{\text{round trip}} = t_{\text{radio}} + t_{\text{sign}} + t_{\text{radio}} \approx 250\,\text{ms} < 400\,\text{ms}$$
- Backend and dashboard. A Node server holds Capital One's issuer key and the Nessie API. It serves signed records to the badges and checks every reported payment against the chain. It stores payments in TimescaleDB (Tiger Data), which pushes each new row to the React dashboard as it arrives.
- Test harness (Python and JS).
- Signature verifiers in both languages, checked against 58 shared test vectors.
- A set of 220 malformed inputs to throw at the transaction decoders.
- A network probe app that runs on the badge.
- A presence-timing collector.
- A host rig that runs the badge apps on the firmware's own Lua build, so we could test without hardware.
Challenges we ran into
Getting the badges onto the internet. Every payment needs the badge to reach our laptop and Solana devnet. Three things stood in the way:
- Campus Wi-Fi didn't work for us. UMich's networks use WPA2-Enterprise behind a firewall. Badges failed to join, or couldn't reach the laptop once they did. A single failed enterprise login also left the Wi-Fi stack stuck, so every retry failed too.
- The only OS we had couldn't type a password. It was a bare-bones build with no text entry, so a badge couldn't join any network that needed one.
- We couldn't wait for the real OS. By the time it was ready there would have been no time left to test on hardware.
Here's how we got around it:
- A stripped-down firmware for networking. We built a minimal Solana OS fork that boots straight into Settings. We flash only the app partition, so each badge keeps its identity key.
- Delivering the password over the network. The badge starts its own access point at
192.168.4.1. The laptop joins it and sends the real network's credentials withbadge-push.py joinover HTTP or BLE. The badge saves them to flash and joins on its own. - One shared 2.4 GHz phone hotspot for all badges and the laptop. That avoids the campus firewall and keeps every badge on the same radio channel, which ESP-NOW needs.
- Fixes to the Wi-Fi manager:
- enterprise joins go through
STA.connect(); - leftover EAP state is cleared before every password join;
- the reason for each failed join is shown on screen;
- the clock syncs over SNTP on the first join, because the badge refuses to sign without a set clock.
- enterprise joins go through
- Reading logs over the air with a
LOGScommand over Wi-Fi or BLE, because of the next problem.
A hardware bug in the badge's shared I²C bus. Opening the USB serial port warm-resets the badge, and after a warm reset the SE050 secure element could hold the I²C bus stuck. The buttons sit on the same bus, so they stopped working too. We hardened the bus bring-up and added continuous recovery, but the root cause looks like hardware. In the end we disabled the SE050 and stored keys in flash. We also found that the SE050 signing path rejects messages over 180 bytes, while a Solana transfer message is about 251 bytes.
Accomplishments that we're proud of
- The firmware decides what the user sees and signs, and an untrusted app can't change either.
- The attack demo works on real hardware: the tampered "5 shown, 500 real" transaction gets caught.
- Revocation reaches the badges within seconds of the issuer pressing a button.
- We built a stand-in firmware and a test harness so badge apps, signing and fuzzing could be tested before the real OS existed. All 220 malformed inputs were refused with no crashes.
What we learned
- On a payment device, the most important part is who draws the screen. If an app can draw the confirmation, the app can lie.
- Identity and presence need each other. An impostor can copy a merchant's name but can't sign with the merchant's key, and a stolen record is useless without the device that answers the radio challenge.
- Embedded constraints shape the protocol. ESP-NOW frames are capped at 240 bytes, the secure element has buffer limits, and a reset can take down the buttons.
- Plan the network before the hardware arrives.
What's next for Verified Payment Key
- Settlement to the bank: merchants receive dollars in their Capital One account when a HACK payment lands. The backend already handles this; the badge flow still needs it.
- A relay network: attested badges carry payments hop by hop to payees out of radio range and earn a HACK fee per hop. Every hop and every fee settles in one Solana transaction.
- Keys back in the secure element, once multi-part signing commands are supported and the I²C issue is fixed.
- Switching HACK for USDC, which is a configuration change.
- Authentication on the backend's badge-facing endpoint, before anything leaves devnet.
Built With
- arduino
- bluetooth
- c++
- capital-one-nessie
- docker
- esp-now
- esp32
- javascript
- lua
- monocypher
- node.js
- postgresql
- python
- react
- solana
- solana-attestation-service
- spl-token
- timescaledb
- vite
- wifi
Log in or sign up for Devpost to join the conversation.