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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue