feat(machine): Bolt Card (NFC) tap-to-pay on cash-out #83
2 changed files with 76 additions and 40 deletions
fix(machine): read NTAG424 NDEF via Capability Container (Bolt Card FileID)
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>
commit
ea736dfab0
|
|
@ -31,36 +31,44 @@ describe('extractLnurlw', () => {
|
||||||
|
|
||||||
describe('readNdefLnurlw', () => {
|
describe('readNdefLnurlw', () => {
|
||||||
const SW_OK = Buffer.from([0x90, 0x00])
|
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,
|
||||||
|
])
|
||||||
|
|
||||||
it('runs the Type-4 read sequence and returns the lnurlw', async () => {
|
/** Route APDUs by content so the CC-read + fallback loop is exercised. */
|
||||||
const msg = ndefUriMessage(LNURLW)
|
function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) {
|
||||||
|
const msg = ndefUriMessage(opts.uri ?? LNURLW)
|
||||||
const nlen = msg.length
|
const nlen = msg.length
|
||||||
const transmit = vi
|
return vi.fn(async (apdu: Buffer) => {
|
||||||
.fn()
|
const hex = apdu.toString('hex')
|
||||||
.mockResolvedValueOnce(SW_OK) // select NDEF app
|
if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app
|
||||||
.mockResolvedValueOnce(SW_OK) // select NDEF file
|
if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC
|
||||||
.mockResolvedValueOnce(Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK])) // NLEN
|
if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC
|
||||||
.mockResolvedValueOnce(Buffer.concat([msg, SW_OK])) // NDEF message
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const out = await readNdefLnurlw(transmit)
|
it('reads CC → NDEF file (E104) and returns the lnurlw', async () => {
|
||||||
expect(out).toBe(LNURLW)
|
const transmit = cardMock()
|
||||||
|
expect(await readNdefLnurlw(transmit)).toBe(LNURLW)
|
||||||
// First APDU selects the NDEF application (AID D2760000850101).
|
// First APDU selects the NDEF application (AID D2760000850101).
|
||||||
expect(Buffer.from((transmit.mock.calls[0][0] as Buffer)).toString('hex')).toContain(
|
expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101')
|
||||||
'd2760000850101'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null if selecting the NDEF app fails', async () => {
|
it('returns null if selecting the NDEF app fails', async () => {
|
||||||
const transmit = vi.fn().mockResolvedValue(Buffer.from([0x6a, 0x82])) // file not found SW
|
expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull()
|
||||||
expect(await readNdefLnurlw(transmit)).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null on an empty NDEF file', async () => {
|
it('returns null on an empty NDEF file', async () => {
|
||||||
const transmit = vi
|
expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull()
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(SW_OK)
|
|
||||||
.mockResolvedValueOnce(SW_OK)
|
|
||||||
.mockResolvedValueOnce(Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])) // NLEN = 0
|
|
||||||
expect(await readNdefLnurlw(transmit)).toBeNull()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -39,35 +39,27 @@ export function extractLnurlw(ndef: Buffer): string | null {
|
||||||
return m ? m[0] : null
|
return m ? m[0] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
||||||
* Read the NDEF file of a Type-4 tag and return the extracted lnurlw, or null.
|
|
||||||
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
|
|
||||||
*/
|
|
||||||
export async function readNdefLnurlw(
|
|
||||||
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
|
|
||||||
): Promise<string | null> {
|
|
||||||
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
|
||||||
const ok = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
|
||||||
|
|
||||||
// 1) Select the NDEF Tag Application (AID D2760000850101).
|
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
|
||||||
if (!ok(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) {
|
async function readNdefFile(
|
||||||
return null
|
send: (bytes: number[]) => Promise<Buffer>,
|
||||||
}
|
fid: [number, number]
|
||||||
// 2) Select the NDEF file (EF 0x0004).
|
): Promise<string | null> {
|
||||||
if (!ok(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0x00, 0x04]))) return null
|
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
|
||||||
// 3) Read the 2-byte NLEN header.
|
// 2-byte NLEN header at offset 0.
|
||||||
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
||||||
if (!ok(lenResp)) return null
|
if (!swOk(lenResp)) return null
|
||||||
const nlen = (lenResp[0] << 8) | lenResp[1]
|
const nlen = (lenResp[0] << 8) | lenResp[1]
|
||||||
if (nlen <= 0 || nlen > 0x2000) return null
|
if (nlen <= 0 || nlen > 0x2000) return null
|
||||||
// 4) Read the NDEF message (starts at offset 2), in <=250-byte chunks.
|
// NDEF message starts at offset 2; read in <=250-byte chunks.
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
let offset = 2
|
let offset = 2
|
||||||
let remaining = nlen
|
let remaining = nlen
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
const toRead = Math.min(remaining, 0xfa)
|
const toRead = Math.min(remaining, 0xfa)
|
||||||
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
||||||
if (!ok(resp)) break
|
if (!swOk(resp)) break
|
||||||
const data = resp.subarray(0, resp.length - 2)
|
const data = resp.subarray(0, resp.length - 2)
|
||||||
if (data.length === 0) break
|
if (data.length === 0) break
|
||||||
chunks.push(data)
|
chunks.push(data)
|
||||||
|
|
@ -77,6 +69,42 @@ export async function readNdefLnurlw(
|
||||||
return extractLnurlw(Buffer.concat(chunks))
|
return extractLnurlw(Buffer.concat(chunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null.
|
||||||
|
* `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2.
|
||||||
|
*
|
||||||
|
* Select the NDEF Tag Application, read the Capability Container to learn the
|
||||||
|
* real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use),
|
||||||
|
* then read that file. Falls back to E104/0004 if the CC read is unavailable.
|
||||||
|
*/
|
||||||
|
export async function readNdefLnurlw(
|
||||||
|
transmit: (apdu: Buffer, maxLen: number) => Promise<Buffer>
|
||||||
|
): Promise<string | null> {
|
||||||
|
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
||||||
|
|
||||||
|
// Select the NDEF Tag Application (AID D2760000850101).
|
||||||
|
if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the FileID advertised by the Capability Container (EF E103).
|
||||||
|
const candidates: Array<[number, number]> = []
|
||||||
|
if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) {
|
||||||
|
const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f])
|
||||||
|
// CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID.
|
||||||
|
if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) candidates.push([cc[9], cc[10]])
|
||||||
|
}
|
||||||
|
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
|
||||||
|
if (!candidates.some((c) => c[0] === fid[0] && c[1] === fid[1])) candidates.push(fid)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fid of candidates) {
|
||||||
|
const found = await readNdefFile(send, fid)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
let stopFn: (() => void) | null = null
|
let stopFn: (() => void) | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue