feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
Tap-to-receive for the buy flow, the receive counterpart to #83's cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher (wrong direction to deposit into it), so the tap is used as an authenticated identity (external_id + SUN p/c) to resolve the card wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the payout over its existing nostr transport. - electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single HTTPS-today / nostr-tomorrow transport seam. 13 tests. - IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) + preload/electron.d.ts surface. - stores/atm.ts: handleBoltCardReceive() settles via the existing payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes the same tap by flow (cash-out pulls, cash-in receives). - CashInView.vue: NFC status + dev tap input. - docs/boltcard-receive-resolver.md: spec for the custom LNbits /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side). Card issuance is unchanged — same NDEF/keys/external_id; receive is a server-side reading of the same tap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
73376a6c68
commit
64582e7fe6
8 changed files with 605 additions and 31 deletions
120
apps/machine/electron/lnurl-pay.test.ts
Normal file
120
apps/machine/electron/lnurl-pay.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
const BOLT11 = 'lnbc10u1p3xyz...'
|
||||
|
||||
/** 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('scanUrlToResolver', () => {
|
||||
it('rewrites /scan/ to /pay/ and preserves p + c', () => {
|
||||
const r = scanUrlToResolver(LNURLW)
|
||||
expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123')
|
||||
expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
||||
expect(r).toContain('c=1122334455667788')
|
||||
})
|
||||
it('returns null for a non-scan URL', () => {
|
||||
expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
||||
expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lnAddressToLnurlp', () => {
|
||||
it('maps name@host to the well-known lnurlp URL', () => {
|
||||
expect(lnAddressToLnurlp('cardname@l484.com')).toBe(
|
||||
'https://l484.com/.well-known/lnurlp/cardname'
|
||||
)
|
||||
})
|
||||
it('rejects non-addresses', () => {
|
||||
expect(lnAddressToLnurlp('not-an-address')).toBeNull()
|
||||
expect(lnAddressToLnurlp('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCardInvoice', () => {
|
||||
const payReq = {
|
||||
tag: 'payRequest',
|
||||
callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb',
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000,
|
||||
metadata: '[["text/plain","bolt card top-up"]]',
|
||||
}
|
||||
|
||||
it('resolver returns a payRequest inline → fetches the invoice', async () => {
|
||||
const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
// 1st call = the /pay resolver; 2nd = the callback with amount in msat.
|
||||
expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123')
|
||||
expect(calls[1]).toContain('amount=21000')
|
||||
})
|
||||
|
||||
it('resolver returns a Lightning Address → LUD-16 → invoice', async () => {
|
||||
const { impl, calls } = mockFetch([
|
||||
{ lightningAddress: 'cardname@l484.com' },
|
||||
payReq,
|
||||
{ pr: BOLT11 },
|
||||
])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toEqual({ ok: true, bolt11: BOLT11 })
|
||||
expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname')
|
||||
expect(calls[2]).toContain('amount=21000')
|
||||
})
|
||||
|
||||
it('rejects a non-lnurlw tag', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false })
|
||||
expect(res.reason).toMatch(/not a valid Bolt Card/i)
|
||||
})
|
||||
|
||||
it('rejects a zero amount', async () => {
|
||||
const { impl } = mockFetch([])
|
||||
const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'no amount to send' })
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the resolver (bad SUN)', async () => {
|
||||
const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'invalid card' })
|
||||
})
|
||||
|
||||
it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => {
|
||||
const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' })
|
||||
expect(calls).toHaveLength(1) // callback never hit
|
||||
})
|
||||
|
||||
it('surfaces an ERROR from the pay callback', async () => {
|
||||
const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' })
|
||||
})
|
||||
|
||||
it('rejects when the card wallet has no receive address', async () => {
|
||||
const { impl } = mockFetch([{ foo: 'bar' }])
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' })
|
||||
})
|
||||
|
||||
it('handles a network failure gracefully', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}) as unknown as typeof fetch
|
||||
const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl })
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toMatch(/could not reach the card/i)
|
||||
})
|
||||
})
|
||||
226
apps/machine/electron/lnurl-pay.ts
Normal file
226
apps/machine/electron/lnurl-pay.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party.
|
||||
*
|
||||
* Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever
|
||||
* emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so
|
||||
* we can't push sats into it directly. Instead the tap is used as an
|
||||
* authenticated identity (external_id + SUN p/c) to look up the card wallet's
|
||||
* *pay* target, then the ATM fetches an invoice for the payout amount:
|
||||
* 1. resolveCardPayTarget — GET the boltcards `/pay/<id>?p=&c=` resolver
|
||||
* (a sibling of `/scan`); it verifies the same SUN and returns the card
|
||||
* wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly).
|
||||
* 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest.
|
||||
* 3. requestInvoice — GET `callback?amount=<msat>` → a BOLT11 for the amount.
|
||||
* The returned BOLT11 is handed back to the renderer, which pays it over the
|
||||
* ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so
|
||||
* settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path.
|
||||
*
|
||||
* Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like
|
||||
* lnurl-withdraw.ts.
|
||||
*
|
||||
* Transport seam: `resolveCardPayTarget()` is the single HTTPS-today /
|
||||
* Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever
|
||||
* pay target it returns and is transport-independent.
|
||||
*/
|
||||
|
||||
import { lnurlwToHttps } from './lnurl-withdraw.js'
|
||||
|
||||
export interface ResolveCardInvoiceResult {
|
||||
ok: boolean
|
||||
/** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */
|
||||
bolt11?: string
|
||||
/** Human-readable reason when ok is false (safe to surface on-screen). */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type FetchLike = typeof fetch
|
||||
|
||||
export interface ResolveCardInvoiceOptions {
|
||||
/** Injected for tests; defaults to global fetch. */
|
||||
fetchImpl?: FetchLike
|
||||
/** Per-request timeout (default 15s). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** Resolver response — any of these shapes is accepted (see the spec doc). */
|
||||
interface CardPayTarget {
|
||||
status?: string
|
||||
reason?: string
|
||||
// (a) a LUD-06 payRequest, inline
|
||||
tag?: string
|
||||
callback?: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
// (b) a Lightning Address, e.g. "cardname@l484.com"
|
||||
lightningAddress?: string
|
||||
// (c) an lnurlp pointer (https or lnurl://)
|
||||
lnurlp?: string
|
||||
lnurl?: string
|
||||
}
|
||||
|
||||
/** LUD-06 payRequest (subset) + error shape. */
|
||||
interface PayRequest {
|
||||
tag?: string
|
||||
callback?: string
|
||||
minSendable?: number
|
||||
maxSendable?: number
|
||||
metadata?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** LUD-06 second-response (the callback body). */
|
||||
interface PayValues {
|
||||
pr?: string
|
||||
status?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
doFetch: FetchLike
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error)
|
||||
return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message
|
||||
return String(e)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`.
|
||||
* The card presents `…/boltcards/api/v1/scan/<id>?p=&c=` (a withdraw voucher);
|
||||
* the receive resolver is its sibling `…/boltcards/api/v1/pay/<id>?p=&c=`,
|
||||
* carrying the same SUN p/c. This is the HTTPS transport seam — a future
|
||||
* nostr-native card would resolve the same identity over nostr instead.
|
||||
*/
|
||||
export function scanUrlToResolver(lnurlw: string): string | null {
|
||||
const https = lnurlwToHttps(lnurlw)
|
||||
if (!https) return null
|
||||
const u = new URL(https)
|
||||
if (!u.pathname.includes('/scan/')) return null
|
||||
u.pathname = u.pathname.replace('/scan/', '/pay/')
|
||||
return u.toString()
|
||||
}
|
||||
|
||||
/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */
|
||||
export function lnAddressToLnurlp(addr: string): string | null {
|
||||
const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i)
|
||||
if (!m) return null
|
||||
return `https://${m[2]}/.well-known/lnurlp/${m[1]}`
|
||||
}
|
||||
|
||||
async function fetchPayRequest(
|
||||
url: string,
|
||||
ctx: Ctx
|
||||
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
||||
let body: PayRequest
|
||||
try {
|
||||
const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
body = (await res.json()) as PayRequest
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` }
|
||||
}
|
||||
if (body.status === 'ERROR') {
|
||||
return { ok: false, reason: body.reason || 'card wallet rejected the request' }
|
||||
}
|
||||
if (body.tag !== 'payRequest' || !body.callback) {
|
||||
return { ok: false, reason: 'card wallet did not return a pay request' }
|
||||
}
|
||||
return { ok: true, payRequest: body }
|
||||
}
|
||||
|
||||
/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */
|
||||
async function toPayRequest(
|
||||
target: CardPayTarget,
|
||||
ctx: Ctx
|
||||
): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> {
|
||||
// (a) resolver returned a LUD-06 payRequest inline.
|
||||
if (target.tag === 'payRequest' && target.callback) {
|
||||
return { ok: true, payRequest: target }
|
||||
}
|
||||
// (b) resolver returned a Lightning Address (the common case here).
|
||||
if (typeof target.lightningAddress === 'string') {
|
||||
const url = lnAddressToLnurlp(target.lightningAddress)
|
||||
if (!url) return { ok: false, reason: 'card wallet address is invalid' }
|
||||
return fetchPayRequest(url, ctx)
|
||||
}
|
||||
// (c) resolver returned an lnurlp pointer.
|
||||
const pointer = target.lnurlp ?? target.lnurl
|
||||
if (typeof pointer === 'string') {
|
||||
const url = lnurlwToHttps(pointer)
|
||||
if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' }
|
||||
return fetchPayRequest(url, ctx)
|
||||
}
|
||||
return { ok: false, reason: 'card wallet has no receive address' }
|
||||
}
|
||||
|
||||
async function requestInvoice(
|
||||
pr: PayRequest,
|
||||
amountMsat: number,
|
||||
ctx: Ctx
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) {
|
||||
return { ok: false, reason: 'amount is below the card wallet minimum' }
|
||||
}
|
||||
if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) {
|
||||
return { ok: false, reason: 'amount is above the card wallet maximum' }
|
||||
}
|
||||
const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) })
|
||||
let vals: PayValues
|
||||
try {
|
||||
const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
vals = (await res.json()) as PayValues
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` }
|
||||
}
|
||||
if (vals.status === 'ERROR') {
|
||||
return { ok: false, reason: vals.reason || 'card wallet declined' }
|
||||
}
|
||||
if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) {
|
||||
return { ok: false, reason: 'card wallet returned no invoice' }
|
||||
}
|
||||
return { ok: true, bolt11: vals.pr.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay.
|
||||
* Never throws — every failure returns `{ ok: false, reason }`.
|
||||
*/
|
||||
export async function resolveCardInvoice(
|
||||
lnurlw: string,
|
||||
amountMsat: number,
|
||||
opts: ResolveCardInvoiceOptions = {}
|
||||
): Promise<ResolveCardInvoiceResult> {
|
||||
const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 }
|
||||
|
||||
const resolverUrl = scanUrlToResolver(lnurlw)
|
||||
if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' }
|
||||
if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' }
|
||||
|
||||
// 1) Resolve card → pay target (the transport seam: HTTPS today).
|
||||
let target: CardPayTarget
|
||||
try {
|
||||
const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) })
|
||||
target = (await res.json()) as CardPayTarget
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `could not reach the card: ${errMsg(e)}` }
|
||||
}
|
||||
if (target.status === 'ERROR') {
|
||||
return { ok: false, reason: target.reason || 'card rejected the tap' }
|
||||
}
|
||||
|
||||
// 2) Normalize to a LUD-06 payRequest.
|
||||
const pr = await toPayRequest(target, ctx)
|
||||
if (!pr.ok) return pr
|
||||
|
||||
// 3) Ask for an invoice for the payout amount.
|
||||
return requestInvoice(pr.payRequest, amountMsat, ctx)
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import {
|
|||
} from './state-store.js'
|
||||
import { initializeHal, type HalInstance } from './hal-service.js'
|
||||
import { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
||||
import { resolveCardInvoice } from './lnurl-pay.js'
|
||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
|
|
@ -120,9 +121,7 @@ function loadBranding(): BrandingConfig | null {
|
|||
if (Object.keys(colors).length > 0) customColors = colors
|
||||
if (dark && typeof dark === 'object') {
|
||||
const darkColors = Object.fromEntries(
|
||||
Object.entries(dark as Record<string, unknown>).filter(
|
||||
([, v]) => typeof v === 'string'
|
||||
)
|
||||
Object.entries(dark as Record<string, unknown>).filter(([, v]) => typeof v === 'string')
|
||||
) as Record<string, string>
|
||||
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
||||
}
|
||||
|
|
@ -431,6 +430,20 @@ ipcMain.handle(
|
|||
}
|
||||
)
|
||||
|
||||
// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a
|
||||
// BOLT11 on the card wallet, which the renderer then pays over the nostr
|
||||
// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the
|
||||
// main process to dodge renderer CORS. See lnurl-pay.ts.
|
||||
ipcMain.handle(
|
||||
'lnurl:pay-card',
|
||||
async (
|
||||
_event,
|
||||
args: { lnurlw: string; amountMsat: number }
|
||||
): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => {
|
||||
return resolveCardInvoice(args.lnurlw, args.amountMsat)
|
||||
}
|
||||
)
|
||||
|
||||
// State persistence IPC handlers
|
||||
ipcMain.handle('state:load-cassettes', () => loadCassettes())
|
||||
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))
|
||||
|
|
@ -447,9 +460,7 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
|||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||
getLastKnownConfigCreatedAt()
|
||||
)
|
||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null =>
|
||||
getBootstrapPublishedAt()
|
||||
)
|
||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
|
||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||
markBootstrapPublished(unixTimestamp)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -134,6 +134,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
amountMsat?: number
|
||||
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
|
||||
|
||||
// Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay.
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
|
||||
ipcRenderer.invoke('lnurl:pay-card', args),
|
||||
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
|
|
@ -272,6 +279,10 @@ declare global {
|
|||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
resolveCardInvoice: (args: {
|
||||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
applyOperatorCassettesConfig: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue