feat(machine): Bolt Card (NFC) tap-to-receive on cash-in #84
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'
|
} 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 { executeLnurlWithdraw } from './lnurl-withdraw.js'
|
||||||
|
import { resolveCardInvoice } from './lnurl-pay.js'
|
||||||
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
import { startNfcReader, type NfcStatus } from './nfc-service.js'
|
||||||
|
|
||||||
// ESM equivalent of __dirname
|
// ESM equivalent of __dirname
|
||||||
|
|
@ -120,9 +121,7 @@ function loadBranding(): BrandingConfig | null {
|
||||||
if (Object.keys(colors).length > 0) customColors = colors
|
if (Object.keys(colors).length > 0) customColors = colors
|
||||||
if (dark && typeof dark === 'object') {
|
if (dark && typeof dark === 'object') {
|
||||||
const darkColors = Object.fromEntries(
|
const darkColors = Object.fromEntries(
|
||||||
Object.entries(dark as Record<string, unknown>).filter(
|
Object.entries(dark as Record<string, unknown>).filter(([, v]) => typeof v === 'string')
|
||||||
([, v]) => typeof v === 'string'
|
|
||||||
)
|
|
||||||
) as Record<string, string>
|
) as Record<string, string>
|
||||||
if (Object.keys(darkColors).length > 0) customColorsDark = darkColors
|
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
|
// 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))
|
||||||
|
|
@ -447,9 +460,7 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB
|
||||||
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
ipcMain.handle('state:get-last-known-config-created-at', (): number =>
|
||||||
getLastKnownConfigCreatedAt()
|
getLastKnownConfigCreatedAt()
|
||||||
)
|
)
|
||||||
ipcMain.handle('state:get-bootstrap-published-at', (): number | null =>
|
ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt())
|
||||||
getBootstrapPublishedAt()
|
|
||||||
)
|
|
||||||
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => {
|
||||||
markBootstrapPublished(unixTimestamp)
|
markBootstrapPublished(unixTimestamp)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
amountMsat?: number
|
amountMsat?: number
|
||||||
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
|
}): 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: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: {
|
payload: {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
|
|
@ -272,6 +279,10 @@ declare global {
|
||||||
bolt11: string
|
bolt11: string
|
||||||
amountMsat?: number
|
amountMsat?: number
|
||||||
}) => Promise<{ ok: boolean; reason?: string }>
|
}) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
resolveCardInvoice: (args: {
|
||||||
|
lnurlw: string
|
||||||
|
amountMsat: number
|
||||||
|
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,8 @@ import {
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import {
|
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||||
startOperatorConfigService,
|
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
|
||||||
type OperatorConfigService,
|
|
||||||
} from '@/services/operator-config'
|
|
||||||
import {
|
|
||||||
startOperatorFeesService,
|
|
||||||
type OperatorFeesService,
|
|
||||||
} from '@/services/operator-fees'
|
|
||||||
import type { HalConfig, HalServices } from '@/services/hal'
|
import type { HalConfig, HalServices } from '@/services/hal'
|
||||||
import type { MachineModel } from '@/config'
|
import type { MachineModel } from '@/config'
|
||||||
import type { LightningBackend } from '@/services/lightning'
|
import type { LightningBackend } from '@/services/lightning'
|
||||||
|
|
@ -52,7 +46,8 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
||||||
`Unit fraction expected (0.05 = 5%), not a percentage.`
|
`Unit fraction expected (0.05 = 5%), not a percentage.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const principalSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
const principalSats =
|
||||||
|
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
||||||
const feeSats = isCashIn
|
const feeSats = isCashIn
|
||||||
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
? principalSats - ctx.satsAmount // cash-in: customer receives less than principal
|
||||||
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
|
: ctx.satsAmount - principalSats // cash-out: customer pays more than principal
|
||||||
|
|
@ -567,9 +562,13 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear Bolt Card state whenever we leave the invoice screen (dispensed,
|
// Clear Bolt Card state whenever we leave a tap screen — the cash-out
|
||||||
// timed out, or cancelled) so a stale "processing"/error can't linger.
|
// invoice ('displayingInvoice') or the cash-in QR ('displayingQR') — so a
|
||||||
if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') {
|
// stale "processing"/error can't linger into the next flow.
|
||||||
|
const leftBoltCardScreen =
|
||||||
|
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
|
||||||
|
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
|
||||||
|
if (leftBoltCardScreen) {
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = null
|
nfcStatus.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -614,26 +613,76 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tapped Bolt Card during the cash-in QR screen: RECEIVE sats to the card.
|
||||||
|
* The card's lnurlw is only a spend voucher, so we resolve it to the card
|
||||||
|
* wallet's lnurlp (main process), fetch an invoice for the payout, and pay it
|
||||||
|
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
||||||
|
* path. Settlement + completion reuse the tested cash-in flow.
|
||||||
|
*/
|
||||||
|
async function handleBoltCardReceive(lnurlw: string) {
|
||||||
|
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return
|
||||||
|
const amountSats = context.value?.satsAmount ?? 0
|
||||||
|
if (amountSats <= 0) return
|
||||||
|
if (boltCardProcessing.value) return // one at a time
|
||||||
|
boltCardProcessing.value = true
|
||||||
|
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||||
|
try {
|
||||||
|
const amountMsat = amountSats * 1000
|
||||||
|
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
|
||||||
|
if (!res.ok || !res.bolt11) {
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nfcStatus.value = { state: 'accepted', message: 'Card found — sending sats…' }
|
||||||
|
const paid = await payInvoice(res.bolt11)
|
||||||
|
if (!paid) {
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = { state: 'error', message: paymentError.value ?? 'Payment failed' }
|
||||||
|
}
|
||||||
|
// On success payInvoice fires PAYMENT_RECEIVED; state leaves displayingQR
|
||||||
|
// and the subscribe-cleanup above resets nfcStatus/boltCardProcessing.
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ATM] Bolt Card receive failed:', e)
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
nfcStatus.value = { state: 'error', message: 'Card payment failed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||||
function setupNfcListener() {
|
function setupNfcListener() {
|
||||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||||
|
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
|
||||||
|
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||||
void handleBoltCardTap(lnurlw)
|
void handleBoltCardTap(lnurlw)
|
||||||
|
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||||
|
void handleBoltCardReceive(lnurlw)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
window.electronAPI.onNfcStatus?.((status) => {
|
window.electronAPI.onNfcStatus?.((status) => {
|
||||||
// Only surface reader status on the invoice screen, and don't clobber an
|
// Only surface reader status on a tap screen, and don't clobber an
|
||||||
// in-flight pull's message.
|
// in-flight tap's message.
|
||||||
if (nestedState.value === 'displayingInvoice' && !boltCardProcessing.value) {
|
const onTapScreen =
|
||||||
|
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
||||||
|
(isCashIn.value && nestedState.value === 'displayingQR')
|
||||||
|
if (onTapScreen && !boltCardProcessing.value) {
|
||||||
nfcStatus.value = status
|
nfcStatus.value = status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dev/mock: simulate a tap with a pasted lnurlw (test without a card). */
|
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
|
||||||
function simulateBoltCardTap(lnurlw: string) {
|
function simulateBoltCardTap(lnurlw: string) {
|
||||||
void handleBoltCardTap(lnurlw)
|
void handleBoltCardTap(lnurlw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dev/mock: simulate a cash-in (receive) tap with a pasted lnurlw. */
|
||||||
|
function simulateBoltCardReceive(lnurlw: string) {
|
||||||
|
void handleBoltCardReceive(lnurlw)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1586,10 +1635,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
isCashOut,
|
isCashOut,
|
||||||
nestedState,
|
nestedState,
|
||||||
|
|
||||||
// Bolt Card cash-out (NFC)
|
// Bolt Card (NFC): cash-out pulls, cash-in receives
|
||||||
nfcStatus,
|
nfcStatus,
|
||||||
boltCardProcessing,
|
boltCardProcessing,
|
||||||
simulateBoltCardTap,
|
simulateBoltCardTap,
|
||||||
|
simulateBoltCardReceive,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
|
|
|
||||||
5
apps/machine/src/types/electron.d.ts
vendored
5
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -109,6 +109,11 @@ declare global {
|
||||||
bolt11: string
|
bolt11: string
|
||||||
amountMsat?: number
|
amountMsat?: number
|
||||||
}) => Promise<{ ok: boolean; reason?: string }>
|
}) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
/** 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 }>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||||
eventCreatedAt: number
|
eventCreatedAt: number
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ const showCancelButton = computed(() => {
|
||||||
// Invoice input for manual payment
|
// Invoice input for manual payment
|
||||||
const invoiceInput = ref('')
|
const invoiceInput = ref('')
|
||||||
|
|
||||||
|
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
|
||||||
|
const mockLnurlw = ref('')
|
||||||
|
|
||||||
// Copy state for ndebit URI
|
// Copy state for ndebit URI
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
|
|
@ -279,7 +282,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
<Button
|
<Button
|
||||||
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
|
||||||
size="kiosk-lg"
|
size="kiosk-lg"
|
||||||
:disabled="!context || context.billsInserted.length === 0 || context.billPending !== null"
|
:disabled="
|
||||||
|
!context || context.billsInserted.length === 0 || context.billPending !== null
|
||||||
|
"
|
||||||
@click="finishInserting"
|
@click="finishInserting"
|
||||||
>
|
>
|
||||||
Done Inserting
|
Done Inserting
|
||||||
|
|
@ -331,10 +336,31 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Waiting indicator -->
|
<!-- Waiting indicator + Bolt Card tap-to-receive 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 wallet scan...</p>
|
<p class="text-sm lg:text-xl text-muted-foreground">
|
||||||
|
{{
|
||||||
|
atmStore.boltCardProcessing
|
||||||
|
? 'Processing card…'
|
||||||
|
: isElectron
|
||||||
|
? 'Tap your card or scan to receive'
|
||||||
|
: 'Waiting for wallet scan...'
|
||||||
|
}}
|
||||||
|
</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>
|
||||||
|
|
||||||
<!-- LNURL URI (web-ui only) -->
|
<!-- LNURL URI (web-ui only) -->
|
||||||
|
|
@ -353,8 +379,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug: Simulate payment button -->
|
<!-- Debug: simulate payment / Bolt Card tap-to-receive -->
|
||||||
<div v-if="atmStore.debugMode" class="pt-2">
|
<div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -363,6 +389,21 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
>
|
>
|
||||||
Dev: Skip to Success
|
Dev: Skip to Success
|
||||||
</Button>
|
</Button>
|
||||||
|
<div class="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.simulateBoltCardReceive(mockLnurlw)"
|
||||||
|
>
|
||||||
|
Tap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
110
docs/boltcard-receive-resolver.md
Normal file
110
docs/boltcard-receive-resolver.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Bolt Card tap-to-receive — LNbits resolver endpoint
|
||||||
|
|
||||||
|
Spec for the small **custom endpoint** the ATM needs on the LNbits `boltcards`
|
||||||
|
extension to support **tap-to-receive** (the cash-in / buy flow). The ATM side
|
||||||
|
(`apps/machine/electron/lnurl-pay.ts`) is already built against this contract;
|
||||||
|
this document is what to implement in the `omni-private` LNbits fork.
|
||||||
|
|
||||||
|
## Why a new endpoint
|
||||||
|
|
||||||
|
A Bolt Card only ever emits its `lnurlw://…/scan/<external_id>?p=&c=` voucher —
|
||||||
|
a **withdraw** (spend) credential. You cannot push sats _into_ the card with it.
|
||||||
|
To deposit to the card's wallet, the ATM uses the same tap as an **authenticated
|
||||||
|
identity** (the `external_id` + the SUN `p`/`c`, verified exactly as `/scan`
|
||||||
|
does) and needs the wallet's **pay** target back. Stock `boltcards` is
|
||||||
|
withdraw-only, so we add a `pay` sibling of `scan`.
|
||||||
|
|
||||||
|
The card is **not re-written** — same NDEF, same keys, same `external_id`. Only
|
||||||
|
the server learns a new way to answer the same tap.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /boltcards/api/v1/pay/{external_id}?p={p}&c={c}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Same URL shape as `GET /boltcards/api/v1/scan/{external_id}?p=&c=`, with the
|
||||||
|
path segment `scan` → `pay`. The ATM derives it by string-substitution on the
|
||||||
|
tapped `lnurlw` (`scanUrlToResolver()` in `lnurl-pay.ts`).
|
||||||
|
- **Verify `p`/`c` exactly like `/scan`**: decrypt the PICC (`p`) with the
|
||||||
|
card's `k1`, recompute the CMAC (`c`) with `k2`, check the read counter is
|
||||||
|
fresh (monotonic). Reject replays. Reuse the boltcards SUN verification path —
|
||||||
|
do not fork it. A valid `p`/`c` is the authorization: it proves card
|
||||||
|
possession and prevents a cloned UID from misdirecting a deposit.
|
||||||
|
- No auth key/header — like `/scan`, this is a public LNURL-style endpoint
|
||||||
|
gated solely by the SUN.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
Return **one** of the following JSON shapes (the ATM accepts all three). Since
|
||||||
|
each card wallet has a Lightning Address, either of the first two is simplest.
|
||||||
|
|
||||||
|
### (a) Lightning Address (recommended)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "lightningAddress": "cardname@l484.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The ATM resolves it via LUD-16 (`/.well-known/lnurlp/cardname`) → LUD-06 pay.
|
||||||
|
|
||||||
|
### (b) LUD-06 payRequest, inline
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "payRequest",
|
||||||
|
"callback": "https://lnbits.l484.com/lnurlp/api/v1/lnurl/<id>",
|
||||||
|
"minSendable": 1000,
|
||||||
|
"maxSendable": 100000000,
|
||||||
|
"metadata": "[[\"text/plain\",\"bolt card top-up\"]]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hand back the card wallet's existing `lnurlp` payRequest directly (no extra
|
||||||
|
round-trip for the ATM).
|
||||||
|
|
||||||
|
### (c) lnurlp pointer
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "lnurlp": "https://lnbits.l484.com/lnurlp/<id>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
An `https://` (or `lnurl://`) URL the ATM will fetch to get the payRequest.
|
||||||
|
|
||||||
|
### Error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ERROR", "reason": "invalid card" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for a failed SUN check, a disabled/unknown card, or a wallet with no pay
|
||||||
|
target. `reason` is surfaced verbatim on the ATM screen, so keep it terse and
|
||||||
|
non-sensitive.
|
||||||
|
|
||||||
|
## Flow, end to end
|
||||||
|
|
||||||
|
```
|
||||||
|
customer inserts cash → ATM owes N sats → customer taps Bolt Card
|
||||||
|
→ ATM reads lnurlw (external_id + fresh p/c)
|
||||||
|
→ GET /boltcards/api/v1/pay/<external_id>?p=&c= ← THIS ENDPOINT
|
||||||
|
→ { lightningAddress | payRequest | lnurlp }
|
||||||
|
→ ATM: LUD-16/LUD-06 → GET callback?amount=<N*1000 msat> → BOLT11
|
||||||
|
→ ATM pays the BOLT11 over its own nostr transport → card wallet credited
|
||||||
|
→ PAYMENT_RECEIVED → cash-in completes
|
||||||
|
```
|
||||||
|
|
||||||
|
Amounts are in **millisatoshis** on the LUD-06 callback (`amount=<msat>`), per
|
||||||
|
spec. Make sure each card wallet's `minSendable`/`maxSendable` span the ATM's
|
||||||
|
payout range or the tap will be declined with "amount is above/below the card
|
||||||
|
wallet …".
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a
|
||||||
|
fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry
|
||||||
|
while a tap is in flight and leaves `displayingQR` on success; the withdraw
|
||||||
|
link is `uses:1`. A customer would have to both tap and pull near-simultaneously
|
||||||
|
to double-collect — acceptable for now, revisit if it bites.
|
||||||
|
- **Future nostr transport:** `resolveCardPayTarget` (the `/pay` GET) is the one
|
||||||
|
HTTPS-today / nostr-tomorrow seam. A nostr-native boltcard would answer the
|
||||||
|
same `external_id + SUN` identity over the ATM's existing nostr connection,
|
||||||
|
dropping the clearnet HTTPS call. The rest (standard LNURL-pay) is unchanged.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue