Merchant app: MIFARE Classic tap → payment
How a merchant mobile app reads a personalized MIFARE Classic 1K card and turns
that tap into a payment on the merchant API (entry_mode=chip_tap).
The personalization side — how the card got its keys and its data blocks — is
perso-android.md. This document is the read side, and it
assumes the card is already PERSONALIZED or LOCKED.
0. Read this before you build anything
Three facts decide whether this integration is possible at all, and they are all load-bearing.
1 — Android only, and only on NXP silicon. android.nfc.tech.MifareClassic
works only where the NFC controller implements Crypto1.
val supported = packageManager.hasSystemFeature("com.nxp.mifare")
Most Samsung handsets pass; Pixels and many Xiaomi models do not. iOS cannot do this at all — CoreNFC exposes no Crypto1 authentication, so a Classic sector can never be read from an iPhone. The fallback for both is an ACR122U/ACR1252U over USB OTG, which moves Crypto1 into the reader and makes the handset's chipset irrelevant.
2 — The tap proves the card, never the cardholder. Crypto1 keys are
recoverable, so a cloned chip produces a cryptogram that verifies. Every
chip_tap authorize therefore lands in pending_verification and settles only
after an SMS OTP or the card's printed PIN is submitted to
POST /merchant/payments/{auth_id}/verify. There is no single-tap payment on
this rail and there will not be one — do not design a UI that assumes tap-and-go.
3 — The app never holds the key that signs a tap. A merchant session reads a card through two endpoints, and neither one emits the signing key:
| Endpoint | Credential | Emits |
|---|---|---|
POST /merchant/chip/tap/prepare | merchant session | token-sector Key A, counter-sector Key B, expected block contents |
POST /merchant/chip/tap/cryptogram | merchant session | a nonce and a server-computed cryptogram |
POST /chip/keys | platform only | the full 16-sector bundle + auth_key |
POST /chip/keys is the perso surface and stays platform-only: platform key
(X-API-Key), Store system-admin token, or a single-card perso job token
(X-Perso-Job). A merchant credential there gets 401 unauthorized, and that is
deliberate — see §1 for what an app would be holding if it did not.
The generated OpenAPI lists
corporateAdmin/xperiseonPOST /chip/keystoo. That is an artifact of@ApiSecuritydecorators accumulating from the controller class; the guard metadata on the handler overrides it. Trust the three credentials above.
Migration required
chip_tap was missing from the authorizations_entry_mode_check constraint, so
every tap authenticated, advanced the counter, wrote its audit row, and then died
at insert with a check violation — 500 internal_error on both the approve and
the decline branch. Fixed by
AddChipTapEntryMode1784878600000;
run npm run migration:run on any environment that predates it. The same
constraint was blocking the platform reader surface (POST /chip/authorize), so
that path is unblocked by the same migration.
1. Where the keys live
Two actors, and the split between them is the whole security design.
| Actor | Holds | Never holds |
|---|---|---|
| Handset (merchant app) | merchant session token; for the duration of one tap: token-sector Key A, counter-sector Key B | the platform key, auth_key, the other 14 sectors' keys, any sector trailer |
| card-core | the issuer master key (HSM in production); derives everything per tap | — |
Crypto1 keys are diversified per chip_uid, they cannot be rotated over the air,
and re-keying means physically re-personalizing the fleet. That is why a platform
key must never ship inside an APK: one extracted APK would be a fleet recall.
card-core hosts the key broker, so you do not have to build one. Earlier
revisions of this document told integrators to stand up their own backend holding
X-API-Key and proxy /chip/keys per tap. That is no longer necessary — and the
hosted version is narrower than a hand-rolled proxy, because the signing key never
crosses the network at all.
handset card-core
│ tap → read UID │
├── POST /merchant/chip/tap/prepare {chip_uid} ──────────────────►│
│◄── {card_token, expected_mac, issuer_block, layout, ────────────┤
│ token_key_a, counter_key_b, key_version} │ 2 keys of 32.
│ │ no auth_key,
│ read blk 4/5/6, check MAC against expected_mac │ no trailers.
│ increment counter on-card → N, read back to confirm │
│ │
├── POST /merchant/chip/tap/cryptogram {chip_uid, counter: N} ───►│
│◄── {nonce, cryptogram, key_version, card_token, counter} ───────┤ CMAC here,
│ │ with auth_key
├── POST /merchant/payments/authorize entry_mode=chip_tap ──────►│ never leaving.
│ │
├── POST /merchant/payments/{auth_id}/verify {code} ────────────►│ ← the real gate
Two calls per tap is intentional. Each writes a chip_auth_logs row — KEY_ISSUE
for the prepare, CRYPTOGRAM_ISSUE for the signature — carrying the sub-account
that asked, which is the per-terminal trail for key handout.
What the cryptogram does and does not prove. It proves the tap was brokered by
card-core for this merchant session. It does not prove plastic was present: a
caller that already knows a chip_uid can ask for a signature without a card.
What actually stands behind a chip_tap payment is the counter — which the card
owns, and which regresses into chip_counter_replay on the next genuine tap if it
was fabricated — and the cardholder OTP/PIN that every chip_tap authorization is
gated behind (§0.2). Design accordingly: the tap identifies the card, /verify
authorizes the payment.
The handset writes no key material to disk, ever. Keys live in memory for one tap and are zeroed after.
2. What is on the card
Layout comes from layout in the /prepare response — read it, do not hardcode
it. Today's values:
| Block | Sector | Content | Read | Write |
|---|---|---|---|---|
| 4 | 1 | card_token — opaque 16 bytes | Key A or B | Key B |
| 5 | 1 | issuer_block — metadata | Key A or B | Key B |
| 6 | 1 | expected_mac — AES-CMAC over uid ‖ blk4 ‖ blk5 | Key A or B | Key B |
| 8 | 2 | counter — a MIFARE value block | Key A or B | write & increment: Key B · decrement/transfer: A or B |
| 3, 7, 11, … | all | sector trailers | never readable | Key B |
All 16 sectors are re-keyed at personalization — nothing is left on
FFFFFFFFFFFF, so the app authenticates every sector it touches with a key from
the bundle.
issuer_block is fixed-layout big-endian, parse it without a schema:
key_version(1) │ perso_epoch(4) │ expiry_epoch(4) │ crc16-ccitt(2) │ rfu(5)
The CRC is a transcription check only. Integrity is expected_mac's job: the
UID is inside the MAC message, so a payload lifted off one card does not verify
on another.
The counter is a value block in the on-card format the increment/transfer commands require:
value(4 LE) ‖ ~value(4 LE) ‖ value(4 LE) ‖ addr ‖ ~addr ‖ addr ‖ ~addr
addr is the block index (0x08 for block 8, so 08 F7 08 F7).
3. The tap sequence
1 tag discovered → read UID (chip_uid)
2 POST /merchant/chip/tap/prepare {chip_uid}
→ card_token, expected_mac, issuer_block,
layout, token_key_a, counter_key_b, key_version
3 authenticate token sector with Key A
read blocks 4, 5, 6
assert blk6 == expected_mac ← not our card / tampered → abort the tap
assert blk4 == card_token
4 authenticate counter sector with Key B
read value block → v
increment(counter_block, 1); transfer(counter_block)
read back → v+1 ← this is the `counter` you present
5 POST /merchant/chip/tap/cryptogram {chip_uid, counter: v+1}
→ nonce, cryptogram, key_version
6 POST /merchant/payments/authorize entry_mode=chip_tap
7 collect the OTP / PIN → POST /merchant/payments/{auth_id}/verify
8 pre-auth only: POST /merchant/payments/{auth_id}/capture
Step 4 before step 5, always, and only with a counter you read back off the card.
The on-card counter is the source of truth; the server only remembers the highest
value it has ever verified. /cryptogram refuses to sign a counter that is not
ahead of that (409 counter_replay) — which is a retryable error, whereas the same
counter smuggled into /authorize is a chip_counter_replay decline that revokes
the credential and takes the plastic out of service.
Android
MifareClassic authenticates per sector, not per block — convert with
blockToSector(block) and authenticate once per sector.
val mfc = MifareClassic.get(tag) ?: error("this handset cannot do Crypto1")
mfc.use {
it.connect()
it.timeout = 5_000
// ── token sector: read-only with Key A ──
val tokenSector = it.blockToSector(prep.layout.tokenBlock)
check(it.authenticateSectorWithKeyA(tokenSector, prep.tokenKeyA.hexToBytes())) {
"auth failed on sector $tokenSector"
}
val cardToken = it.readBlock(prep.layout.tokenBlock).toHex()
val issuer = it.readBlock(prep.layout.issuerBlock).toHex()
val mac = it.readBlock(prep.layout.macBlock).toHex()
require(mac.equals(prep.expectedMac, true) && cardToken.equals(prep.cardToken, true)) {
"card did not match the issuer's record — refuse the tap"
}
// ── counter sector: increment needs Key B ──
val ctrSector = it.blockToSector(prep.layout.counterBlock)
check(it.authenticateSectorWithKeyB(ctrSector, prep.counterKeyB.hexToBytes()))
val before = valueOf(it.readBlock(prep.layout.counterBlock))
it.increment(prep.layout.counterBlock, 1)
it.transfer(prep.layout.counterBlock)
val counter = valueOf(it.readBlock(prep.layout.counterBlock))
require(counter == before + 1) { "counter did not advance" }
}
/** Parse a value block, validating the redundant copies the chip enforces. */
fun valueOf(b: ByteArray): Int {
fun le(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int
val v = le(0)
require(v == le(8) && le(4) == v.inv()) { "block 8 is not a valid value block" }
return v
}
Some controllers refuse increment/transfer on a value block. The fallback is
a plain writeBlock of the re-assembled value block (write is Key B, which you
already authenticated with) — build the bytes with the format in §2.
Drive the tag with reader mode, not foreground dispatch: it suppresses NDEF handling and the system tap sound, and keeps the tag alive across the read.
nfcAdapter.enableReaderMode(
activity, callback,
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
Bundle().apply { putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) },
)
4. The cryptogram
You do not compute this — POST /merchant/chip/tap/cryptogram does, and returns it
ready to paste into the authorize body:
POST /merchant/chip/tap/cryptogram
Authorization: Bearer <access_token>
{ "chip_uid": "04a1b2c3", "counter": 7 }
→ 200 { "nonce": "b3c1a95e77d240f8ac1e6b02d95f4c81",
"cryptogram": "3b1d7c9e5f2a48d06e8c1b4a7f309d52",
"key_version": 1,
"card_token": "9f86d081884c7d659a2feaa0c55ad015",
"counter": 7 }
The construction, for reference when debugging a decline:
cryptogram = AES-128-CMAC(auth_key, nonce ‖ counter ‖ card_token)
| Field | Bytes | Notes |
|---|---|---|
nonce | 16 | server-minted per call, hex. Single-use per credential |
counter | 4 | uint32 big-endian — the on-card value after the increment |
card_token | 16 | block 4, hex (32 lowercase hex chars) |
auth_key | 16 | AES-128, derived from chip_uid + key_version. Server-side only — no merchant endpoint returns it |
The reference CMAC lives in
chip-crypto.util.ts, and the signing path is
ChipService.merchantTapCryptogram.
Rules the server enforces on the resulting authorize, in this order — the first failure wins:
card_tokenresolves to a credential (else404 not_found)- credential is not revoked, and is
PERSONALIZEDorLOCKED (credential, nonce)has never carried a verified cryptogram — nonce is single-use- the cryptogram verifies against the re-derived key
counteris strictly greater than the highest counter recorded for the credential.chip_counters.last_counteris seeded to0at personalization, so the first ever tap must present≥ 1
Pass key_version through from the /cryptogram response. Omitting it makes the
server fall back to the credential's stored version, which is right today but
silently wrong the moment a card is re-personalized at a new version.
5. The payment calls
Authentication
The same two credentials authenticate the tap endpoints in §1 and the payment endpoints here:
| Credential | Header | Use |
|---|---|---|
| Merchant session | Authorization: Bearer <access_token> | the mobile app |
| Parent machine key | X-Merchant-Key: <api_key> | server-to-server only |
Never ship X-Merchant-Key in an app — it is the parent merchant's machine
credential and it is not a Bearer token. Devices log in per operator:
POST /merchant/login ← public, throttled to 10/min
{ "alias": "cafe-hcm", "username": "pos-07",
"password": "5f4dcc3b5aa765d61d8327deb882cf99" }
→ 200 { "access_token": "…", "expires_in": 900, "refresh_token": "…",
"sub_account": { "sub_account_id": "…", "role": "operator" },
"merchant": { "merchant_id": "…", "name": "…" } }
password is the client-side MD5 hex digest of the raw password, not the
password itself — the backend never sees plaintext. That also means the app owns
password-strength enforcement before hashing: at least 10 characters using 3 of
the 4 classes (upper, lower, digit, symbol).
The refresh token comes back in the body (no httpOnly cookie) precisely so
native clients can store it. 401 token_expired means refresh; anything else
means re-login. Both admin and operator may authorize; an operator sees
only its own rows in GET /merchant/payments.
There is a second reason to log in per device: the sub-account id is written to
chip_auth_logs.reader_id on both tap calls, so a device session gives you
per-terminal attribution for every key handout and every signature. A parent-key
call leaves it null — which is the only after-the-fact control there is over a
device that starts pulling keys it should not.
Authorize
POST /merchant/payments/authorize
Authorization: Bearer <access_token>
Idempotency-Key: 7d9f4e21-3a54-4a1e-9f0c-8b2d5c6e1a04 ← required (422 without it)
Content-Type: application/json
{
"entry_mode": "chip_tap",
"card_token": "9f86d081884c7d659a2feaa0c55ad015",
"cryptogram": "3b1d7c9e5f2a48d06e8c1b4a7f309d52",
"counter": 7,
"nonce": "b3c1a95e77d240f8ac1e6b02d95f4c81",
"key_version": 1,
"amount": "120000",
"currency": "VND",
"external_ref":"POS-000481",
"auto_capture": true,
"mcc": "4121"
}
card_tokenmust be the chip's own token — 32 lowercase hex. Aptok_…or a PAN here is422 validation_failed, deliberately: the shapes are pinned so one credential cannot be presented as another.amountis a decimal string,^\d{1,16}(\.\d{1,2})?$. VND has no minor units, so"120000"is 120 000 ₫. Never a float.external_refis required and is also recorded aschip_auth_logs.client_tx_id.auto_capture: true→ the sale captures inside/verify.false→/verifycreates a hold (48h by default) and you capture later.enhanced_data/metadataare accepted here as on every other entry mode; if the merchant hasenhanced_data_required, capture enforces it.
Response — 201, always pending_verification on the happy path:
{
"auth_id": "0f3c…",
"status": "pending_verification",
"capture_id": null,
"decline_reason": null,
"amount": "120000",
"currency": "VND",
"card_id": "6f1c…",
"corporate_id": "corp-1",
"external_ref": "POS-000481",
"attribution": { "user_id": "user-123", "cost_center_id": "CC-SALES-HCM", "trip_id": null, "trip_match": "unassigned", "department": "Sales", "corporate_id": "corp-1" },
"policy_check": "evaluated",
"hold_expires_at": null,
"verification": {
"method": "sms_otp",
"expires_at": "2026-07-30T08:12:00.000Z",
"attempts_remaining": 5,
"phone_masked": "****1234"
},
"created_at": "2026-07-30T08:09:00.000Z"
}
verification.method decides the UI:
sms_otp— a 6-digit code was texted to the cardholder, naming the exact amount and merchant. TTL 5 minutes, 5 attempts.static_code— no SMS. Ask for the PIN printed on the card. Same TTL and attempt budget.
A decline is also 201 with status: "declined" and a decline_reason;
only broken requests are 4xx/5xx. No hold is placed for a pending
chip_tap/manual_pan/card_qr auth, so an abandoned OTP locks no funds, and a
new pending auth for the same (card, merchant) auto-voids the previous one.
Verify
POST /merchant/payments/{auth_id}/verify
{ "code": "482913" }
200 in every ordinary outcome — read status:
status | Meaning |
|---|---|
captured (+ capture_id) | auto_capture: true, money moved |
approved (+ hold_expires_at) | pre-auth, hold placed — capture next |
pending_verification | wrong code; verification.attempts_remaining decremented |
declined | 3rd wrong code (card_locked_too_many_attempts, and the card's manual/QR/tap entry is locked for 15 minutes), sale past its verification_deadline (verification_expired), wallet or funds refusal |
The one ordinary outcome that is NOT a 200: a code submitted after
verification.expires_at is 422 otp_expired. That kills the code, not the sale —
the auth stays pending_verification until verification.verification_deadline.
No Idempotency-Key on this call. Re-verifying an already settled auth is not an
error — it replays the current state.
Resend the code
POST /merchant/payments/{auth_id}/resend-verification
For "the SMS never arrived" and for recovering from otp_expired. Re-arms the same
challenge — new code, fresh expires_at, attempts_remaining back to 5 — and
returns the auth with its updated verification block. The previous code stops
working immediately.
It costs no velocity slot (that gate counts challenges per card, and this opens
none), so it carries its own limits instead: 429 resend_too_soon inside the 30s
cooldown (details.retry_after_seconds), 422 resend_limit_reached after 2
resends, 422 resend_not_applicable for static_code cards. Past the sale's
deadline it returns the auth declined verification_expired — start a new payment.
No Idempotency-Key here either: replaying a stored response would mean not
sending the SMS the POS asked for. The cooldown is what makes a double-tap safe.
Capture / void
POST /merchant/payments/{auth_id}/capture
Idempotency-Key: <new key>
{ "amount": "120000", "final": true } → 201 capture
POST /merchant/payments/{auth_id}/void → 200 (approved or pending only)
Refunds go against a capture: POST /merchant/captures/{capture_id}/refunds.
Note the wallet accepts one refund per payment — a payment refunded once,
fully or partially, cannot be refunded again (422 wallet_payment_already_reversed).
6. Declines and errors
Chip-specific declines — 201, status: "declined":
decline_reason | Cause | What the app should do |
|---|---|---|
chip_cryptogram_invalid | CMAC did not verify — wrong key, wrong message order, wrong counter encoding | integration bug; do not retry the tap |
chip_nonce_replayed | this nonce already carried a verified cryptogram | fetch a fresh nonce per tap |
chip_counter_replay | counter did not advance — the clone signal | see the warning below |
chip_credential_revoked | credential is dead (lost/stolen/compromised) | tell the cardholder to contact the issuer |
chip_credential_not_active | still PENDING — personalization never confirmed | card needs re-personalizing |
channel_not_allowed | the card's contactless_tap channel is off | another entry mode, or ask the corporate admin |
card_blocked / card_inactive / card_expired | card state | — |
card_outside_validity_window | the card is outside the windows of time its corporate admin allows it to pay in | another card; the admin sets the card's validity_windows |
card_locked_too_many_attempts | 15-minute lock from 3 failed codes | retry after the lock |
velocity_limit_exceeded | more than 3 challenges for this card in 5 minutes | wait |
insufficient_funds, per_transaction_limit_exceeded, daily_limit_exceeded, monthly_limit_exceeded, mcc_not_allowed, merchant_not_allowed, policy reasons | ordinary money-path declines, all raised by card-core itself | show and stop |
service_payment_insufficient_funds | the Xperise wallet is empty — not the card's limit, despite the similar name | show and stop; the cardholder tops up the wallet, not the card |
other service_payment_* | the wallet refused or was unreachable when /verify settled the charge | see §4.9 of merchant-authorize-errors.md — service_payment_in_flight is the one worth retrying |
chip_counter_replayrevokes the credential. WithCHIP_AUTO_REVOKE_ON_REPLAYat its default (true), a counter regression kills the credential in the same transaction as the decline — the card stops working and must be re-personalized. An app bug that replays counters therefore bricks real plastic. Run staging withCHIP_AUTO_REVOKE_ON_REPLAY=falsewhile integrating, and turn it back on before production.
Tap-broker errors — these come back from /merchant/chip/tap/*, before any
payment exists:
| HTTP | code | Meaning | What the app should do |
|---|---|---|---|
| 404 | credential_unknown | no card is registered for this chip_uid | not our card — abort the tap |
| 409 | credential_revoked | credential is dead | tell the cardholder to contact the issuer |
| 409 | credential_not_active | still PENDING — personalization never confirmed | card needs personalizing |
| 409 | not_classic | a DESFire credential; its keys stay in the HSM | not readable this way |
| 409 | counter_replay | the counter presented to /cryptogram is not ahead of the last verified tap | re-read the counter off the card; do not send it to authorize |
| 429 | rate_limited | 120 calls/min, so ~60 taps | back off |
Payment errors — { "code": …, "message": … }:
| HTTP | code | Meaning |
|---|---|---|
| 401 | token_expired | refresh the merchant session |
| 401 | unauthorized | bad merchant key |
| 404 | not_found | card_token resolves to no credential |
| 409 | idempotency_conflict | same Idempotency-Key, different body |
| 409 | request_in_flight | the original call is still committing — retry unchanged |
| 422 | validation_failed | bad card_token shape, or chip_tap without cryptogram/counter/nonce |
| 422 | missing_idempotency_key | header absent on authorize/capture/refund |
| 422 | verification_not_pending | /verify on an auth with no open challenge |
| 422 | static_code_unavailable | card prefers static_code but has no PIN on file |
| 422 | merchant_mismatch | body merchant_id ≠ the authenticated merchant |
| 429 | rate_limited | throttled |
| 502 | otp_delivery_failed | SMS gateway refused; nothing was authorized |
7. Retry and failure rules
A timed-out authorize is not a failed authorize. Retry the identical body
with the same Idempotency-Key: same key + same payload replays the original
response, same key + different payload is 409 idempotency_conflict. Never build
a fresh tap (new nonce, new counter) to retry a call that may have landed — the
retry has to be byte-identical.
A genuine re-tap is a new payment attempt: new nonce, another on-card
increment, and a new Idempotency-Key. It auto-voids the previous pending
auth for that (card, merchant).
Counter drift is one-directional and harmless. If the app increments the card and the network call never arrives, the card is ahead of the server — the next tap still presents a higher value and passes. The dangerous direction is a counter the card never took, which is why the increment must be confirmed by a read-back before you present it.
A rejected cryptogram does not move the stored counter. The counter advances
only after the cryptogram verifies, so a chip_cryptogram_invalid decline leaves
the server exactly where it was and the next genuine tap continues from the next
value. There is nothing to reset.
Tag lost mid-sequence. Nothing has moved: the token sector is read-only to the app and an interrupted increment/transfer either took or did not. Read the counter again on the next tap and continue.
Key hygiene. The two sector keys live in memory for one tap and are zeroed
after. No disk, no logs, no crash reporter, no analytics payload. /prepare
responses must never be cached — a cached one is a Crypto1 key at rest on a device
you do not control, and Classic keys cannot be rotated over the air.
8. Testing without plastic
The whole path is exercisable with no NFC hardware at all — and, past the setup step, with nothing but a merchant login.
# 1. bind a credential to a card (any UID you like) and mark it personalized
POST /cards/{card_id}/chip/perso/prepare X-API-Key
{ "chip_uid": "04a1b2c3", "chip_generation": "MIFARE_CLASSIC_EV1_1K",
"interface_caps": "CONTACTLESS", "applet_aid": "" }
POST /cards/{card_id}/chip/perso/confirm X-API-Key
{ "credential_id": "…", "chip_uid": "04a1b2c3",
"result": "WRITTEN", "key_loaded": true }
# 2. log a terminal in
POST /merchant/login (public)
{ "alias": "cafe-hcm", "username": "pos-07", "password": "<md5 hex>" }
# 3. what the app would read off the card
POST /merchant/chip/tap/prepare Bearer
{ "chip_uid": "04a1b2c3" }
# 4. sign a tap — counter starts at 1, chip_counters is seeded to 0 at confirm
POST /merchant/chip/tap/cryptogram Bearer
{ "chip_uid": "04a1b2c3", "counter": 1 }
# 5. POST /merchant/payments/authorize with entry_mode=chip_tap
# 6. POST /merchant/payments/{auth_id}/verify
Bump counter by one on each subsequent run — step 4 refuses to re-sign a value
the previous run already got through authorize.
The platform equivalent still exists if you want the full bundle (all 16 sectors,
auth_key, the trailers): POST /chip/keys with X-API-Key, then compute the
CMAC yourself per §4.
For step 5, either set the card's verification_preference to static_code and
submit the printed PIN, or leave it on SMS and read the code out of the server
log — with NOTIFICATION_CENTER_ORIGIN unset the OTP is logged instead of sent.
For the real thing on plastic, personalize a scrap card with
scripts/perso-classic.js first; it prints every
block it writes, which is the fastest way to confirm your app is reading the
right ones.