First real-card tap read the NDEF file with id 0004 and got "not a Bolt Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container (EF E103) after selecting the NDEF app to learn the advertised NDEF FileID, then read that file; fall back to E104/0004. Tolerates a transient transmit error (surfaced as a retryable status; the next tap re-reads). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
3.3 KiB
TypeScript
74 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { extractLnurlw, readNdefLnurlw } from './nfc-service'
|
|
|
|
const LNURLW =
|
|
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
|
|
|
/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */
|
|
function ndefUriMessage(uri: string): Buffer {
|
|
const uriBytes = Buffer.from(uri, 'ascii')
|
|
const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix
|
|
// D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U'
|
|
return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload])
|
|
}
|
|
|
|
describe('extractLnurlw', () => {
|
|
it('pulls an lnurlw:// URI out of an NDEF record', () => {
|
|
expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW)
|
|
})
|
|
it('pulls a boltcards https scan URL', () => {
|
|
const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb'
|
|
expect(extractLnurlw(ndefUriMessage(https))).toBe(https)
|
|
})
|
|
it('stops at the record boundary (no trailing binary)', () => {
|
|
const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])])
|
|
expect(extractLnurlw(msg)).toBe(LNURLW)
|
|
})
|
|
it('returns null when there is no lnurl', () => {
|
|
expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('readNdefLnurlw', () => {
|
|
const SW_OK = Buffer.from([0x90, 0x00])
|
|
const SW_NOTFOUND = Buffer.from([0x6a, 0x82])
|
|
// Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]).
|
|
const CC = Buffer.from([
|
|
0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff,
|
|
])
|
|
|
|
/** Route APDUs by content so the CC-read + fallback loop is exercised. */
|
|
function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) {
|
|
const msg = ndefUriMessage(opts.uri ?? LNURLW)
|
|
const nlen = msg.length
|
|
return vi.fn(async (apdu: Buffer) => {
|
|
const hex = apdu.toString('hex')
|
|
if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app
|
|
if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC
|
|
if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC
|
|
if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104)
|
|
if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent
|
|
if (hex.startsWith('00b0000002'))
|
|
return opts.nlen0
|
|
? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])
|
|
: Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN
|
|
if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message
|
|
return SW_NOTFOUND
|
|
})
|
|
}
|
|
|
|
it('reads CC → NDEF file (E104) and returns the lnurlw', async () => {
|
|
const transmit = cardMock()
|
|
expect(await readNdefLnurlw(transmit)).toBe(LNURLW)
|
|
// First APDU selects the NDEF application (AID D2760000850101).
|
|
expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101')
|
|
})
|
|
|
|
it('returns null if selecting the NDEF app fails', async () => {
|
|
expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull()
|
|
})
|
|
|
|
it('returns null on an empty NDEF file', async () => {
|
|
expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull()
|
|
})
|
|
})
|