Card format
and tap to pay, byte by byte.
One card format is shared by all four apps. A card holds a link, an Android record, and optionally a PIN-encrypted recovery backup and the owner's public receive addresses. Never a private key.
01The records on a card
A card holds one NDEF message. Every record keeps its bytes and its place; new records only ever go after the existing ones. The apps and Card Studio find records by type, never by position.
| # | Record | Written by | Notes |
|---|---|---|---|
| 1 | URI https://<host>/card/<32 hex id> (NFC Crypto) or https://<host>/c/<32 hex id> (CryptoCard) | Pairing, or Card Studio | iOS opens the app from the first URI record. The ID is random; it identifies the card, it is not a secret. |
| 2 | External android.com:pkg = the app's Android package | Pairing, or Card Studio | Android Application Record: a tap opens this app. |
| 3 | External mikodes.com:nfcseed (optional) | "Save recovery backup" (NFC Crypto), creating a wallet (CryptoCard), Clone (Card Studio) | The recovery phrase, encrypted with a PIN. See below. |
| 4 | External mikodes.com:nfcaddr (optional) | "Share address on card" (NFC Crypto) | Public receive addresses. See below. |
The type names mikodes.com:nfcseed and mikodes.com:nfcaddr are part of the format, not branding. Changing them would make your cards unreadable to the other apps of the kit and to cards already issued. Leave them as they are when you rebrand.
02Recovery backup (nfcseed)
| Bytes | Content |
|---|---|
| 0 | Version (1) |
| 1 | KDF: 1 = Argon2id |
| 2 | Memory, as log2 of KiB (standard: 16 = 64 MiB) |
| 3 | Iterations (standard: 4) |
| 4 | Lanes (standard: 1) |
| 5–20 | Salt, 16 bytes |
| 21–32 | AES-GCM nonce, 12 bytes |
| 33– | AES-256-GCM of the BIP-39 entropy, plus the 16-byte tag. Bytes 0–32 are the associated data. |
- Only the entropy is stored (16 bytes for 12 words, 32 for 24), so the record is 65 to 81 bytes.
- Readers accept only memory 210–217 KiB, 1–6 iterations, 1–4 lanes and 65–81 bytes, so a hostile card cannot make a phone allocate gigabytes.
- A new PIN must be 8 to 12 digits and not trivial (not one digit repeated, not a run like 12345678). Older backups with 6 or 7 digits still open.
- Each guess costs about a second of Argon2id on a phone. The PIN is the only thing protecting the phrase, because anyone can read the card. See Security.
Implementations: apps/nfc_crypto/lib/core/nfc/nfc_seed_backup.dart, apps/cryptocard_wallet/lib/core/nfc/seed_backup.dart, apps/nfc_recovery/lib/nfc/card_backup.dart, apps/card_studio/studio/nfc_core.py. Their tests open each other's test vectors.
03Receive addresses (nfcaddr)
payload 0x01 version
UTF-8 lines "<key>:<address>", separated by "\n"
keys evm EVM address (same on every EVM network)
sol base58 Solana address
btc mainnet Bitcoin address (P2PKH, P2SH, bech32, bech32m)
All three addresses take about 150 bytes. Link + Android record + the largest backup + three addresses stay under 400 bytes, so an NTAG215 (504 bytes) holds everything.
A reader refuses the whole record if it is not version 1, is longer than 320 bytes, is not valid UTF-8, repeats a key, has a line without :, or holds an address that is not valid for its key. It skips keys it does not know, and refuses a card with two nfcaddr records. Source: lib/core/nfc/nfc_card_addresses.dart.
04Tap to pay, tap to get paid (NFC Crypto)
- Get paid. Card tab → Share address quick action, or open a card → Share address on card. The app writes the wallet's EVM, Solana and Bitcoin receive addresses to the card. It takes two taps: the card is read first, so the recovery backup is copied back byte for byte, then rewritten. Only the same card can be written (the write is checked against its link).
- Pay. In Send, choose Tap recipient's card and hold the other person's card to the phone. The app shows the full address for the selected network on a confirm screen: Use this address or Cancel.
- Nothing is filled in from a card without an
nfcaddrrecord, from a damaged record, or for a network the card does not carry. - If the card is one of your own, the app warns that the payment would go back to this wallet.
- Saving or removing the backup later keeps the addresses. Pairing the card again ("Pair a new card") writes only the link and the Android record: backup and addresses are removed, and the app says so.
- Card Studio's Clone keeps the source card's records, so a clone carries the same addresses.
Tap to pay is covered by unit and widget tests; it has not yet been verified on a phone with real cards.
05Pairing details
- After writing, the app reads the card back and compares it. A card pulled away mid-write is reported as failed, never as paired.
- Writes that must hit a specific card (backup, addresses) are guarded on the native side: any other card answers "wrong card" and is not touched. A blank or damaged card answers "unreadable", and the app asks to pair it again.
- An unknown card can only be paired from inside the app (Test a card or Pair a new card), never from a link: anyone can publish a link, but only someone holding the card can tap it.
- Card I/O is native, not a plugin: Core NFC (
NFCNDEFReaderSession) inios/Runner/AppDelegate.swiftand reader mode inMainActivity.kt, on the method channelnfc_crypto/nfc.
06Desk tool (optional)
apps/nfc_crypto/tool/nfc/nfccard is a command-line tool for a Mac with an ACR122U. It writes exactly the bytes the app writes. Card Studio does all of this with a window, so most operators can skip it.
tool/nfc/nfccard status # UID, chip, lock state, link
tool/nfc/nfccard program --count 10 # program 10 cards one after another
tool/nfc/nfccard check-backup # open a backup with its PIN, without printing words
tool/nfc/nfccard erase # empty the card
--host must match your app's card host. A programmed card is not paired yet: the customer pairs it in the app.