Merge pull request 'feat(machine): Bolt Card (NFC) tap-to-pay on cash-out' (#83) from feat/boltcard-nfc-cashout into dev
Reviewed-on: #83
This commit is contained in:
commit
1e074682e3
13 changed files with 764 additions and 8 deletions
103
apps/machine/electron/lnurl-withdraw.test.ts
Normal file
103
apps/machine/electron/lnurl-withdraw.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw'
|
||||||
|
|
||||||
|
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||||
|
const LNURLW =
|
||||||
|
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||||
|
|
||||||
|
/** Build a mock fetch that returns the given JSON bodies per call, in order. */
|
||||||
|
function mockFetch(bodies: unknown[]) {
|
||||||
|
const calls: string[] = []
|
||||||
|
const impl = vi.fn(async (url: string | URL) => {
|
||||||
|
calls.push(url.toString())
|
||||||
|
const body = bodies[calls.length - 1]
|
||||||
|
return { json: async () => body } as Response
|
||||||
|
})
|
||||||
|
return { impl: impl as unknown as typeof fetch, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lnurlwToHttps', () => {
|
||||||
|
it('maps lnurlw:// and lnurl:// to https://', () => {
|
||||||
|
expect(lnurlwToHttps('lnurlw://host/p?x=1')).toBe('https://host/p?x=1')
|
||||||
|
expect(lnurlwToHttps('lnurl://host/p')).toBe('https://host/p')
|
||||||
|
})
|
||||||
|
it('strips a lightning: prefix', () => {
|
||||||
|
expect(lnurlwToHttps('lightning:lnurlw://host/p')).toBe('https://host/p')
|
||||||
|
})
|
||||||
|
it('passes https:// through and trims', () => {
|
||||||
|
expect(lnurlwToHttps(' https://host/p ')).toBe('https://host/p')
|
||||||
|
})
|
||||||
|
it('rejects http://, bech32 lnurl1…, and empty', () => {
|
||||||
|
expect(lnurlwToHttps('http://host/p')).toBeNull()
|
||||||
|
expect(lnurlwToHttps('LNURL1DP68GURN8GHJ7')).toBeNull()
|
||||||
|
expect(lnurlwToHttps('')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeLnurlWithdraw', () => {
|
||||||
|
const withdrawReq = {
|
||||||
|
tag: 'withdrawRequest',
|
||||||
|
callback: 'https://lnbits.l484.com/boltcards/api/v1/scan/cb',
|
||||||
|
k1: 'K1TOKEN',
|
||||||
|
minWithdrawable: 1000,
|
||||||
|
maxWithdrawable: 5_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('completes the two-step withdraw and passes k1 + pr to the callback', async () => {
|
||||||
|
const { impl, calls } = mockFetch([withdrawReq, { status: 'OK' }])
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||||
|
expect(res).toEqual({ ok: true })
|
||||||
|
// First call = the lnurlw as https; second = callback with k1 + pr.
|
||||||
|
expect(calls[0]).toContain('https://lnbits.l484.com/boltcards/api/v1/scan/abc123')
|
||||||
|
expect(calls[1]).toContain('k1=K1TOKEN')
|
||||||
|
expect(calls[1]).toContain(`pr=${encodeURIComponent(BOLT11)}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-lnurlw tag', async () => {
|
||||||
|
const { impl } = mockFetch([])
|
||||||
|
const res = await executeLnurlWithdraw('http://nope', BOLT11, { fetchImpl: impl })
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toMatch(/not a valid Bolt Card/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when there is no invoice', async () => {
|
||||||
|
const { impl } = mockFetch([])
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, '', { fetchImpl: impl })
|
||||||
|
expect(res).toMatchObject({ ok: false, reason: 'no invoice to charge' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces an ERROR from the withdraw request', async () => {
|
||||||
|
const { impl } = mockFetch([{ status: 'ERROR', reason: 'spent today limit' }])
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||||
|
expect(res).toMatchObject({ ok: false, reason: 'spent today limit' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a response that is not a withdrawRequest', async () => {
|
||||||
|
const { impl } = mockFetch([{ tag: 'payRequest', callback: 'x' }])
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||||
|
expect(res).toMatchObject({ ok: false })
|
||||||
|
expect(res.reason).toMatch(/withdraw voucher/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects (without calling the callback) when the amount exceeds the card limit', async () => {
|
||||||
|
const { impl, calls } = mockFetch([{ ...withdrawReq, maxWithdrawable: 2000 }])
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl, amountMsat: 5000 })
|
||||||
|
expect(res).toMatchObject({ ok: false, reason: 'card limit is below this amount' })
|
||||||
|
expect(calls).toHaveLength(1) // callback never hit
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces an ERROR from the callback (card declined)', async () => {
|
||||||
|
const { impl } = mockFetch([withdrawReq, { status: 'ERROR', reason: 'insufficient funds' }])
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||||
|
expect(res).toMatchObject({ ok: false, reason: 'insufficient funds' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a network failure gracefully', async () => {
|
||||||
|
const impl = vi.fn(async () => {
|
||||||
|
throw new Error('ECONNREFUSED')
|
||||||
|
}) as unknown as typeof fetch
|
||||||
|
const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl })
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toMatch(/could not reach the card/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
127
apps/machine/electron/lnurl-withdraw.ts
Normal file
127
apps/machine/electron/lnurl-withdraw.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party.
|
||||||
|
*
|
||||||
|
* Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an
|
||||||
|
* `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has
|
||||||
|
* already generated its cash-out BOLT11; here it asks the card's wallet to pay
|
||||||
|
* that invoice:
|
||||||
|
* 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min).
|
||||||
|
* 2. GET `callback?k1=…&pr=<our bolt11>` → the card's wallet pays it.
|
||||||
|
* Settlement itself is observed elsewhere (the existing invoice watcher over
|
||||||
|
* nostr), so a returned `{ ok: true }` means "the card accepted the pull", not
|
||||||
|
* "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED.
|
||||||
|
*
|
||||||
|
* Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL
|
||||||
|
* endpoints don't send CORS headers, so a renderer fetch to the card's host
|
||||||
|
* would be blocked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LnurlWithdrawResult {
|
||||||
|
ok: boolean
|
||||||
|
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */
|
||||||
|
interface WithdrawRequest {
|
||||||
|
tag?: string
|
||||||
|
callback?: string
|
||||||
|
k1?: string
|
||||||
|
minWithdrawable?: number
|
||||||
|
maxWithdrawable?: number
|
||||||
|
defaultDescription?: string
|
||||||
|
status?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch
|
||||||
|
|
||||||
|
export interface ExecuteLnurlWithdrawOptions {
|
||||||
|
/** Injected for tests; defaults to global fetch. */
|
||||||
|
fetchImpl?: FetchLike
|
||||||
|
/**
|
||||||
|
* Our invoice amount in millisats. When set, we reject early if it exceeds
|
||||||
|
* the voucher's maxWithdrawable (defensive; the callback would reject anyway).
|
||||||
|
*/
|
||||||
|
amountMsat?: number
|
||||||
|
/** Per-request timeout (default 15s). */
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a Bolt Card / LNURL-withdraw pointer to an https URL.
|
||||||
|
* Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a
|
||||||
|
* bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards
|
||||||
|
* never use it) and rejected with a clear reason.
|
||||||
|
*/
|
||||||
|
export function lnurlwToHttps(raw: string): string | null {
|
||||||
|
let s = raw.trim()
|
||||||
|
if (!s) return null
|
||||||
|
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
||||||
|
const lower = s.toLowerCase()
|
||||||
|
if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length)
|
||||||
|
if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length)
|
||||||
|
if (lower.startsWith('https://')) return s
|
||||||
|
// Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card).
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuery(url: string, params: Record<string, string>): string {
|
||||||
|
const u = new URL(url)
|
||||||
|
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v)
|
||||||
|
return u.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeLnurlWithdraw(
|
||||||
|
lnurlw: string,
|
||||||
|
bolt11: string,
|
||||||
|
opts: ExecuteLnurlWithdrawOptions = {}
|
||||||
|
): Promise<LnurlWithdrawResult> {
|
||||||
|
const doFetch = opts.fetchImpl ?? fetch
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 15_000
|
||||||
|
|
||||||
|
const paramsUrl = lnurlwToHttps(lnurlw)
|
||||||
|
if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||||
|
if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) {
|
||||||
|
return { ok: false, reason: 'no invoice to charge' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Fetch the withdraw request.
|
||||||
|
let params: WithdrawRequest
|
||||||
|
try {
|
||||||
|
const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
|
params = (await res.json()) as WithdrawRequest
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||||
|
}
|
||||||
|
if (params.status === 'ERROR') {
|
||||||
|
return { ok: false, reason: params.reason || 'card rejected the tap' }
|
||||||
|
}
|
||||||
|
if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) {
|
||||||
|
return { ok: false, reason: 'card did not return a withdraw voucher' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
opts.amountMsat != null &&
|
||||||
|
typeof params.maxWithdrawable === 'number' &&
|
||||||
|
opts.amountMsat > params.maxWithdrawable
|
||||||
|
) {
|
||||||
|
return { ok: false, reason: 'card limit is below this amount' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Hand our invoice to the callback — the card's wallet pays it.
|
||||||
|
const cbUrl = appendQuery(params.callback, { k1: params.k1, pr: bolt11.trim() })
|
||||||
|
let cb: { status?: string; reason?: string }
|
||||||
|
try {
|
||||||
|
const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
|
cb = (await res.json()) as { status?: string; reason?: string }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: `card payment failed: ${errMsg(e)}` }
|
||||||
|
}
|
||||||
|
if (cb.status === 'OK') return { ok: true }
|
||||||
|
return { ok: false, reason: cb.reason || 'card declined the payment' }
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,8 @@ import {
|
||||||
type StoredBunkerBinding,
|
type StoredBunkerBinding,
|
||||||
} from './state-store.js'
|
} from './state-store.js'
|
||||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||||
|
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
||||||
|
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||||
|
|
||||||
// ESM equivalent of __dirname
|
// ESM equivalent of __dirname
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
|
@ -415,6 +417,20 @@ ipcMain.handle('app:recover', (): void => {
|
||||||
reloadRenderer()
|
reloadRenderer()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bolt Card cash-out: pull payment for the current invoice from a tapped card
|
||||||
|
// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer
|
||||||
|
// CORS. Returns once the card accepts; settlement arrives via the invoice
|
||||||
|
// watcher. See lnurl-withdraw.ts.
|
||||||
|
ipcMain.handle(
|
||||||
|
'lnurl:withdraw',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
args: { lnurlw: string; bolt11: string; amountMsat?: number }
|
||||||
|
): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: args.amountMsat })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// State persistence IPC handlers
|
// State persistence IPC handlers
|
||||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||||
|
|
@ -813,6 +829,23 @@ app.whenReady().then(() => {
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
startCommandPoller()
|
startCommandPoller()
|
||||||
|
|
||||||
|
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully
|
||||||
|
// best-effort: if the reader/pcscd is absent it just reports 'unavailable'
|
||||||
|
// and the cash-out QR path is unaffected.
|
||||||
|
void startNfcReader(
|
||||||
|
(lnurlw) => {
|
||||||
|
// Don't log the value — it carries the card's single-use SUN p/c.
|
||||||
|
console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`)
|
||||||
|
mainWindow?.webContents.send('nfc:card-tapped', lnurlw)
|
||||||
|
},
|
||||||
|
(status: NfcStatus) => {
|
||||||
|
console.log(
|
||||||
|
`[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}`
|
||||||
|
)
|
||||||
|
mainWindow?.webContents.send('nfc:status', status)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
// macOS: re-create window when dock icon clicked
|
// macOS: re-create window when dock icon clicked
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|
|
||||||
74
apps/machine/electron/nfc-service.test.ts
Normal file
74
apps/machine/electron/nfc-service.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
198
apps/machine/electron/nfc-service.ts
Normal file
198
apps/machine/electron/nfc-service.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/**
|
||||||
|
* NFC reader driver (main process) for Bolt Card tap-to-pay.
|
||||||
|
*
|
||||||
|
* Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card
|
||||||
|
* tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts
|
||||||
|
* the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap),
|
||||||
|
* then hands it to the renderer over IPC. The renderer, when showing a
|
||||||
|
* cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts).
|
||||||
|
*
|
||||||
|
* Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it
|
||||||
|
* is dynamically imported and every failure is swallowed into a status
|
||||||
|
* callback. If the reader/library is absent, NFC is simply unavailable and the
|
||||||
|
* QR path keeps working — cash-out never depends on this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
||||||
|
export interface NfcStatus {
|
||||||
|
state: NfcState
|
||||||
|
reader?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardHandler = (lnurlw: string) => void
|
||||||
|
type StatusHandler = (status: NfcStatus) => void
|
||||||
|
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */
|
||||||
|
export function extractLnurlw(ndef: Buffer): string | null {
|
||||||
|
// Robust to record framing: the URI record embeds the literal string; grab
|
||||||
|
// it directly, bounded to URL-safe characters so we stop at the record end.
|
||||||
|
const text = ndef.toString('latin1')
|
||||||
|
const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+"
|
||||||
|
const m =
|
||||||
|
text.match(new RegExp('lnurlw://' + urlChars, 'i')) ||
|
||||||
|
text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i'))
|
||||||
|
return m ? m[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00
|
||||||
|
|
||||||
|
/** Select an EF by its 2-byte file id and read + parse its NDEF message. */
|
||||||
|
async function readNdefFile(
|
||||||
|
send: (bytes: number[]) => Promise<Buffer>,
|
||||||
|
fid: [number, number]
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null
|
||||||
|
// 2-byte NLEN header at offset 0.
|
||||||
|
const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02])
|
||||||
|
if (!swOk(lenResp)) return null
|
||||||
|
const nlen = (lenResp[0] << 8) | lenResp[1]
|
||||||
|
if (nlen <= 0 || nlen > 0x2000) return null
|
||||||
|
// NDEF message starts at offset 2; read in <=250-byte chunks.
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
let offset = 2
|
||||||
|
let remaining = nlen
|
||||||
|
while (remaining > 0) {
|
||||||
|
const toRead = Math.min(remaining, 0xfa)
|
||||||
|
const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead])
|
||||||
|
if (!swOk(resp)) break
|
||||||
|
const data = resp.subarray(0, resp.length - 2)
|
||||||
|
if (data.length === 0) break
|
||||||
|
chunks.push(data)
|
||||||
|
offset += data.length
|
||||||
|
remaining -= data.length
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to
|
||||||
|
// minimise APDU round-trips over a flaky RF link; only fall back to reading
|
||||||
|
// the Capability Container to discover the id if both direct reads fail.
|
||||||
|
for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) {
|
||||||
|
const found = await readNdefFile(send, fid)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const found = await readNdefFile(send, [cc[9], cc[10]])
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopFn: (() => void) | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
||||||
|
* Never throws — failures surface via onStatus.
|
||||||
|
*/
|
||||||
|
export async function startNfcReader(
|
||||||
|
onCard: CardHandler,
|
||||||
|
onStatus: StatusHandler
|
||||||
|
): Promise<() => void> {
|
||||||
|
if (stopFn) return stopFn
|
||||||
|
|
||||||
|
let mod: unknown
|
||||||
|
try {
|
||||||
|
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc
|
||||||
|
// while resolving normally at runtime.
|
||||||
|
const pkg = 'nfc-pcsc'
|
||||||
|
mod = (await import(pkg)) as unknown
|
||||||
|
} catch (e) {
|
||||||
|
onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` })
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
const NFC =
|
||||||
|
(mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC
|
||||||
|
if (typeof NFC !== 'function') {
|
||||||
|
onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' })
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void }
|
||||||
|
try {
|
||||||
|
nfc = new (NFC as new () => typeof nfc)()
|
||||||
|
} catch (e) {
|
||||||
|
onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` })
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
nfc.on('reader', (reader: unknown) => {
|
||||||
|
const r = reader as {
|
||||||
|
name?: string
|
||||||
|
reader?: { name?: string }
|
||||||
|
autoProcessing?: boolean
|
||||||
|
on: (e: string, cb: (...a: unknown[]) => void) => void
|
||||||
|
transmit: (data: Buffer, maxLen: number) => Promise<Buffer>
|
||||||
|
}
|
||||||
|
const name = r.name ?? r.reader?.name ?? 'reader'
|
||||||
|
// We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing.
|
||||||
|
r.autoProcessing = false
|
||||||
|
onStatus({ state: 'ready', reader: name })
|
||||||
|
|
||||||
|
// Cooldown after a failed read: these cheap CCID readers can get wedged into
|
||||||
|
// a present↔empty storm when hammered, so ignore re-detections for a beat
|
||||||
|
// after a failure. Successful reads don't cool down.
|
||||||
|
let cooldownUntil = 0
|
||||||
|
r.on('card', async () => {
|
||||||
|
if (Date.now() < cooldownUntil) return
|
||||||
|
onStatus({ state: 'reading', reader: name })
|
||||||
|
// Single attempt: retrying hammers a flaky RF link. A read is a few APDU
|
||||||
|
// round-trips; if the card shifts mid-read the transmit fails and the
|
||||||
|
// user simply re-taps.
|
||||||
|
try {
|
||||||
|
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
|
||||||
|
if (lnurlw) {
|
||||||
|
onCard(lnurlw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
|
||||||
|
} catch (e) {
|
||||||
|
onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' })
|
||||||
|
void e
|
||||||
|
}
|
||||||
|
cooldownUntil = Date.now() + 1500
|
||||||
|
})
|
||||||
|
r.on('card.off', () => onStatus({ state: 'card-removed', reader: name }))
|
||||||
|
r.on('error', (err: unknown) =>
|
||||||
|
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
||||||
|
)
|
||||||
|
r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' }))
|
||||||
|
})
|
||||||
|
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
||||||
|
|
||||||
|
stopFn = () => {
|
||||||
|
try {
|
||||||
|
nfc.close?.()
|
||||||
|
} catch {
|
||||||
|
/* idempotent */
|
||||||
|
}
|
||||||
|
stopFn = null
|
||||||
|
}
|
||||||
|
return stopFn
|
||||||
|
}
|
||||||
|
|
@ -127,6 +127,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
// Reload the renderer to re-attempt initialization (connectivity recovery).
|
||||||
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
|
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
|
||||||
|
|
||||||
|
// Bolt Card cash-out: pull payment for the current invoice from a tapped card.
|
||||||
|
lnurlWithdraw: (args: {
|
||||||
|
lnurlw: string
|
||||||
|
bolt11: string
|
||||||
|
amountMsat?: number
|
||||||
|
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
|
||||||
|
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: {
|
payload: {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
|
|
@ -188,6 +195,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.on('hal:error', (_event, error) => callback(error))
|
ipcRenderer.on('hal:error', (_event, error) => callback(error))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Bolt Card reader (main process → renderer). removeAllListeners first: a
|
||||||
|
// renderer reload re-runs this, and a duplicated card-tap listener would
|
||||||
|
// trigger the LNURL-withdraw twice.
|
||||||
|
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
|
||||||
|
ipcRenderer.removeAllListeners('nfc:card-tapped')
|
||||||
|
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
|
||||||
|
},
|
||||||
|
onNfcStatus: (
|
||||||
|
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||||
|
) => {
|
||||||
|
ipcRenderer.removeAllListeners('nfc:status')
|
||||||
|
ipcRenderer.on('nfc:status', (_event, status) => callback(status))
|
||||||
|
},
|
||||||
|
|
||||||
// Watchdog heartbeat (main process → renderer → main process)
|
// Watchdog heartbeat (main process → renderer → main process)
|
||||||
onWatchdogPing: (callback: () => void) => {
|
onWatchdogPing: (callback: () => void) => {
|
||||||
ipcRenderer.on('watchdog:ping', () => callback())
|
ipcRenderer.on('watchdog:ping', () => callback())
|
||||||
|
|
@ -246,6 +267,11 @@ declare global {
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
recoverApp: () => Promise<void>
|
recoverApp: () => Promise<void>
|
||||||
|
lnurlWithdraw: (args: {
|
||||||
|
lnurlw: string
|
||||||
|
bolt11: string
|
||||||
|
amountMsat?: number
|
||||||
|
}) => Promise<{ ok: boolean; reason?: string }>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
@ -283,6 +309,10 @@ declare global {
|
||||||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||||
onHalError: (callback: (error: string) => void) => void
|
onHalError: (callback: (error: string) => void) => void
|
||||||
|
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||||
|
onNfcStatus: (
|
||||||
|
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||||
|
) => void
|
||||||
onWatchdogPing: (callback: () => void) => void
|
onWatchdogPing: (callback: () => void) => void
|
||||||
watchdogPong: () => Promise<void>
|
watchdogPong: () => Promise<void>
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.563.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
|
"nfc-pcsc": "^0.8.1",
|
||||||
"nostr-tools": "^2.10.0",
|
"nostr-tools": "^2.10.0",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"qr": "^0.6.0",
|
"qr": "^0.6.0",
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const debugMode = ref(true)
|
const debugMode = ref(true)
|
||||||
const allowMockFallback = ref(true) // default true for browser dev
|
const allowMockFallback = ref(true) // default true for browser dev
|
||||||
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
const initError = ref<string | null>(null) // fatal error → maintenance screen
|
||||||
|
// Bolt Card cash-out (NFC tap-to-pay). nfcStatus surfaces reader state on the
|
||||||
|
// invoice screen; boltCardProcessing gates against double-taps while a pull
|
||||||
|
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||||
|
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||||
|
const boltCardProcessing = ref(false)
|
||||||
const fiatCode = ref('USD')
|
const fiatCode = ref('USD')
|
||||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||||
|
|
@ -562,14 +567,73 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear Bolt Card state whenever we leave the invoice screen (dispensed,
|
||||||
|
// timed out, or cancelled) so a stale "processing"/error can't linger.
|
||||||
|
if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') {
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = null
|
||||||
|
}
|
||||||
|
|
||||||
prevNestedState = currentNested
|
prevNestedState = currentNested
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start the machine
|
// Start the machine
|
||||||
actor.value.start()
|
actor.value.start()
|
||||||
|
setupNfcListener()
|
||||||
console.log('[ATM] State machine initialized')
|
console.log('[ATM] State machine initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tapped Bolt Card during the cash-out invoice screen: pull payment for
|
||||||
|
* the shown invoice via LNURL-withdraw (main process). Settlement still
|
||||||
|
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||||
|
* ok here only means the card accepted the pull.
|
||||||
|
*/
|
||||||
|
async function handleBoltCardTap(lnurlw: string) {
|
||||||
|
if (nestedState.value !== 'displayingInvoice') return
|
||||||
|
const invoice = context.value?.invoice
|
||||||
|
if (!invoice) return
|
||||||
|
if (boltCardProcessing.value) return // one pull at a time
|
||||||
|
boltCardProcessing.value = true
|
||||||
|
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||||
|
try {
|
||||||
|
const amountMsat = (context.value?.satsAmount ?? 0) * 1000
|
||||||
|
const res = await window.electronAPI!.lnurlWithdraw({ lnurlw, bolt11: invoice, amountMsat })
|
||||||
|
if (res.ok) {
|
||||||
|
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||||
|
} else {
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card declined' }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ATM] Bolt Card withdraw failed:', e)
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||||
|
function setupNfcListener() {
|
||||||
|
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||||
|
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||||
|
void handleBoltCardTap(lnurlw)
|
||||||
|
})
|
||||||
|
window.electronAPI.onNfcStatus?.((status) => {
|
||||||
|
// Only surface reader status on the invoice screen, and don't clobber an
|
||||||
|
// in-flight pull's message.
|
||||||
|
if (nestedState.value === 'displayingInvoice' && !boltCardProcessing.value) {
|
||||||
|
nfcStatus.value = status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */
|
||||||
|
function simulateBoltCardTap(lnurlw: string) {
|
||||||
|
void handleBoltCardTap(lnurlw)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1522,6 +1586,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
isCashOut,
|
isCashOut,
|
||||||
nestedState,
|
nestedState,
|
||||||
|
|
||||||
|
// Bolt Card cash-out (NFC)
|
||||||
|
nfcStatus,
|
||||||
|
boltCardProcessing,
|
||||||
|
simulateBoltCardTap,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
initializeWithLightning,
|
initializeWithLightning,
|
||||||
|
|
|
||||||
12
apps/machine/src/types/electron.d.ts
vendored
12
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -103,6 +103,12 @@ declare global {
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
|
/** Reload the renderer to re-attempt initialization (connectivity recovery). */
|
||||||
recoverApp: () => Promise<void>
|
recoverApp: () => Promise<void>
|
||||||
|
/** Bolt Card cash-out: pull payment for the current invoice from a tapped card. */
|
||||||
|
lnurlWithdraw: (args: {
|
||||||
|
lnurlw: string
|
||||||
|
bolt11: string
|
||||||
|
amountMsat?: number
|
||||||
|
}) => Promise<{ ok: boolean; reason?: string }>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
@ -140,6 +146,12 @@ declare global {
|
||||||
onHalBillInserted: (callback: (denomination: number) => void) => void
|
onHalBillInserted: (callback: (denomination: number) => void) => void
|
||||||
onHalBillRejected: (callback: (reason: string) => void) => void
|
onHalBillRejected: (callback: (reason: string) => void) => void
|
||||||
onHalError: (callback: (error: string) => void) => void
|
onHalError: (callback: (error: string) => void) => void
|
||||||
|
/** Bolt Card reader: a tapped card's lnurlw voucher. */
|
||||||
|
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
|
||||||
|
/** Bolt Card reader status (ready / reading / error / unavailable). */
|
||||||
|
onNfcStatus: (
|
||||||
|
callback: (status: { state: string; reader?: string; message?: string }) => void
|
||||||
|
) => void
|
||||||
onWatchdogPing: (callback: () => void) => void
|
onWatchdogPing: (callback: () => void) => void
|
||||||
watchdogPong: () => Promise<void>
|
watchdogPong: () => Promise<void>
|
||||||
platform: NodeJS.Platform
|
platform: NodeJS.Platform
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
|
|
||||||
const cashOutSteps = ['Select', 'Pay', 'Collect']
|
const cashOutSteps = ['Select', 'Pay', 'Collect']
|
||||||
|
|
||||||
|
// Dev-only: paste a real card's lnurlw to exercise the Bolt Card pull without
|
||||||
|
// the reader (single-use, so a live tap each time).
|
||||||
|
const mockLnurlw = ref('')
|
||||||
|
|
||||||
const currentStepIndex = computed(() => {
|
const currentStepIndex = computed(() => {
|
||||||
switch (nestedState.value) {
|
switch (nestedState.value) {
|
||||||
case 'fetchingRate':
|
case 'fetchingRate':
|
||||||
|
|
@ -284,7 +288,9 @@ function formatFiat(cents: number): string {
|
||||||
<div
|
<div
|
||||||
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
|
class="flex w-full lg:w-[52%] flex-col items-center justify-center gap-3 lg:gap-5 px-4 lg:px-[4vw] py-4 lg:py-0"
|
||||||
>
|
>
|
||||||
<p class="text-lg lg:text-[2rem] font-semibold text-warning">Scan to Pay</p>
|
<p class="text-lg lg:text-[2rem] font-semibold text-warning">
|
||||||
|
{{ isElectron ? 'Tap Card or Scan to Pay' : 'Scan to Pay' }}
|
||||||
|
</p>
|
||||||
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
<p class="text-3xl lg:text-[7vh] font-bold text-bitcoin leading-tight">
|
||||||
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
{{ context ? formatSats(context.satsAmount) : 0 }} sats
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -295,10 +301,31 @@ function formatFiat(cents: number): string {
|
||||||
</Badge>
|
</Badge>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Waiting indicator -->
|
<!-- Waiting indicator + Bolt Card status -->
|
||||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<PickaxeIcon :size="32" />
|
<PickaxeIcon :size="32" />
|
||||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for payment...</p>
|
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||||
|
{{
|
||||||
|
atmStore.boltCardProcessing
|
||||||
|
? 'Processing card…'
|
||||||
|
: isElectron
|
||||||
|
? 'Tap your card or scan the QR'
|
||||||
|
: 'Waiting for payment...'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="atmStore.nfcStatus?.message"
|
||||||
|
class="text-sm lg:text-lg"
|
||||||
|
:class="
|
||||||
|
atmStore.nfcStatus.state === 'declined' || atmStore.nfcStatus.state === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ atmStore.nfcStatus.message }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoice info with copy button (web-ui only) -->
|
<!-- Invoice info with copy button (web-ui only) -->
|
||||||
|
|
@ -322,6 +349,21 @@ function formatFiat(cents: number): string {
|
||||||
>
|
>
|
||||||
Simulate Payment
|
Simulate Payment
|
||||||
</Button>
|
</Button>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="mockLnurlw"
|
||||||
|
placeholder="lnurlw://… (paste to simulate a card tap)"
|
||||||
|
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!mockLnurlw"
|
||||||
|
@click="atmStore.simulateBoltCardTap(mockLnurlw)"
|
||||||
|
>
|
||||||
|
Tap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,26 @@
|
||||||
cpuFreqGovernor = "performance";
|
cpuFreqGovernor = "performance";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID
|
||||||
|
# smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it
|
||||||
|
# binds the CCID driver to the reader; the app talks to pcscd's socket (via
|
||||||
|
# nfc-pcsc) rather than the USB device directly. Harmless if no reader is
|
||||||
|
# attached — pcscd just idles.
|
||||||
|
services.pcscd.enable = true;
|
||||||
|
|
||||||
|
# pcscd gates client access via polkit; without a rule the sandboxed
|
||||||
|
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
||||||
|
# it to talk to the daemon and the card.
|
||||||
|
security.polkit.extraConfig = ''
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||||
|
subject.user == "bitspire") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
'';
|
||||||
|
|
||||||
# Disable suspend/hibernate for kiosk
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
sleep.enable = false;
|
sleep.enable = false;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||||
inherit pnpm;
|
inherit pnpm;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-03ANBQ7bHJwsqlX2ScA1+1LFuO8njiuU7O4VGOb6cMM=";
|
hash = "sha256-XqpQpFL3PqnFltb4ujAmmnnV0LOqTHKV/bo3riFu9pY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
|
|
@ -57,6 +57,11 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
pkgs.sqlite.dev # better-sqlite3
|
pkgs.sqlite.dev # better-sqlite3
|
||||||
pkgs.libudev-zero # serialport
|
pkgs.libudev-zero # serialport
|
||||||
pkgs.stdenv.cc.cc.lib # libstdc++
|
pkgs.stdenv.cc.cc.lib # libstdc++
|
||||||
|
# @pokusew/pcsclite (nfc-pcsc): the `lib` output carries libpcsclite.so so
|
||||||
|
# autoPatchelf wires it into the .node RPATH at runtime. Compile/link paths
|
||||||
|
# are injected via CPATH/LIBRARY_PATH in buildPhase (its binding.gyp
|
||||||
|
# hardcodes Debian /usr paths instead of using pkg-config).
|
||||||
|
pkgs.pcsclite.lib
|
||||||
];
|
];
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
|
|
@ -83,6 +88,19 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
--arch=x64
|
--arch=x64
|
||||||
popd
|
popd
|
||||||
|
|
||||||
|
# @pokusew/pcsclite (nfc-pcsc's native addon) — also V8 C++ API, so it too
|
||||||
|
# must be rebuilt against Electron's headers. Its binding.gyp hardcodes
|
||||||
|
# /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs'
|
||||||
|
# pcsclite explicitly (winscard.h lives under include/PCSC).
|
||||||
|
echo "=== Rebuilding @pokusew/pcsclite against Electron ${electron.version} headers ==="
|
||||||
|
pushd node_modules/.pnpm/@pokusew+pcsclite@*/node_modules/@pokusew/pcsclite
|
||||||
|
CPATH="${pkgs.pcsclite.dev}/include/PCSC''${CPATH:+:$CPATH}" \
|
||||||
|
LIBRARY_PATH="${pkgs.pcsclite.lib}/lib''${LIBRARY_PATH:+:$LIBRARY_PATH}" \
|
||||||
|
HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \
|
||||||
|
--nodedir="$electron_nodedir" \
|
||||||
|
--arch=x64
|
||||||
|
popd
|
||||||
|
|
||||||
# Build the Electron app (turbo builds all workspace deps + app)
|
# Build the Electron app (turbo builds all workspace deps + app)
|
||||||
pnpm --filter="@bitSpire/machine..." build
|
pnpm --filter="@bitSpire/machine..." build
|
||||||
|
|
||||||
|
|
@ -95,7 +113,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
mkdir -p $out/node_modules/{@lamassu,@serialport}
|
mkdir -p $out/node_modules/{@lamassu,@serialport,@pokusew}
|
||||||
|
|
||||||
# Helper: find a package dir inside the pnpm virtual store.
|
# Helper: find a package dir inside the pnpm virtual store.
|
||||||
# pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name>
|
# pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name>
|
||||||
|
|
@ -132,6 +150,12 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||||
copy_pnpm_pkg bindings $out/node_modules/bindings
|
copy_pnpm_pkg bindings $out/node_modules/bindings
|
||||||
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
|
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
|
||||||
|
|
||||||
|
# nfc-pcsc + @pokusew/pcsclite (Bolt Card reader). The compiled
|
||||||
|
# pcsclite.node (from the rebuild above) rides along in the package dir and
|
||||||
|
# loads via `bindings` (already copied). autoPatchelf wires libpcsclite.
|
||||||
|
copy_pnpm_pkg nfc-pcsc $out/node_modules/nfc-pcsc
|
||||||
|
copy_pnpm_pkg @pokusew/pcsclite $out/node_modules/@pokusew/pcsclite
|
||||||
|
|
||||||
# @bitSpire/hal (workspace package, dynamically imported for hardware access)
|
# @bitSpire/hal (workspace package, dynamically imported for hardware access)
|
||||||
mkdir -p $out/node_modules/@bitSpire/hal/dist
|
mkdir -p $out/node_modules/@bitSpire/hal/dist
|
||||||
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
|
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
|
||||||
|
|
|
||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
|
|
@ -59,6 +59,9 @@ importers:
|
||||||
marked:
|
marked:
|
||||||
specifier: ^17.0.5
|
specifier: ^17.0.5
|
||||||
version: 17.0.5
|
version: 17.0.5
|
||||||
|
nfc-pcsc:
|
||||||
|
specifier: ^0.8.1
|
||||||
|
version: 0.8.1
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
specifier: ^2.10.0
|
specifier: ^2.10.0
|
||||||
version: 2.19.4(typescript@5.9.3)
|
version: 2.19.4(typescript@5.9.3)
|
||||||
|
|
@ -897,6 +900,9 @@ packages:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@pokusew/pcsclite@0.6.0':
|
||||||
|
resolution: {integrity: sha512-jX7zRXM2Or5Pms1AFjNtawsXDjLiZOzOUo7Sf0put7Pnq/EKIR9g0KvTx62HtwdPpVP6hWHGydUTHgIi9PxodQ==}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.56.0':
|
'@rollup/rollup-android-arm-eabi@4.56.0':
|
||||||
resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==}
|
resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
|
|
@ -2484,6 +2490,9 @@ packages:
|
||||||
muggle-string@0.4.1:
|
muggle-string@0.4.1:
|
||||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
|
nan@2.28.0:
|
||||||
|
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
|
@ -2496,6 +2505,9 @@ packages:
|
||||||
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
nfc-pcsc@0.8.1:
|
||||||
|
resolution: {integrity: sha512-wEfacG0dwPVZOG/WY28Mk3P4Q+yz6q7LnjpnZvdFddx3iXavEXiGhftRZXBtudr0NrzH1MrGWSkWq77tef7BMA==}
|
||||||
|
|
||||||
node-abi@3.87.0:
|
node-abi@3.87.0:
|
||||||
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2966,7 +2978,7 @@ packages:
|
||||||
tar@6.2.1:
|
tar@6.2.1:
|
||||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
|
||||||
|
|
||||||
temp-file@3.4.0:
|
temp-file@3.4.0:
|
||||||
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
||||||
|
|
@ -3760,6 +3772,11 @@ snapshots:
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@pokusew/pcsclite@0.6.0':
|
||||||
|
dependencies:
|
||||||
|
bindings: 1.5.0
|
||||||
|
nan: 2.28.0
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.56.0':
|
'@rollup/rollup-android-arm-eabi@4.56.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -5506,12 +5523,18 @@ snapshots:
|
||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
|
nan@2.28.0: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
napi-build-utils@2.0.0: {}
|
napi-build-utils@2.0.0: {}
|
||||||
|
|
||||||
negotiator@0.6.4: {}
|
negotiator@0.6.4: {}
|
||||||
|
|
||||||
|
nfc-pcsc@0.8.1:
|
||||||
|
dependencies:
|
||||||
|
'@pokusew/pcsclite': 0.6.0
|
||||||
|
|
||||||
node-abi@3.87.0:
|
node-abi@3.87.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue