feat(machine): open a verified Bolt Card session at tap-to-enter
Entry now spends the tap's single-use SUN once, on the card server's new /session endpoint (aiolabs/boltcards feat/card-session-endpoint), instead of parsing the lnurlw locally and deferring every check to Complete. The server proves a genuine, non-replayed card and returns the wallet balance plus the hit-keyed LUD-03 withdraw and LUD-06 pay second steps — the same single-use bearer /scan and /pay hand out — so Complete still needs no second tap and the ATM holds no p/c for the visit. - electron/boltcard-session.ts: /scan → /session URL derivation, response parsing, 404 → 'card server does not support sessions'. - lnurl-withdraw / lnurl-pay: the second steps are now callable on their own (executeWithdrawCallback, resolveInvoiceFromPayStep); the tap paths are unchanged and reuse them. - IPC: lnurl:open-card-session, lnurl:withdraw-session, lnurl:pay-session. - store: handleBoltCardEntry opens the session then authorizes the server-returned external_id; the payment handlers take a source (raw tap or session); a withheld withdraw step declines with the server's reason. The boltcard AccessScan no longer carries the lnurlw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ce4b5a5dc6
commit
735132b032
12 changed files with 639 additions and 63 deletions
|
|
@ -115,16 +115,15 @@ describe('access authorize (ADR-003)', () => {
|
|||
|
||||
describe('boltcard credential (tap-to-enter)', () => {
|
||||
it('open-enrollment grants any card as user', async () => {
|
||||
const out = await authorize(
|
||||
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
|
||||
[],
|
||||
{ salt: SALT, openEnrollment: true }
|
||||
)
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a card with no external_id', async () => {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: '', lnurlw: '' }, [], {
|
||||
const out = await authorize({ kind: 'boltcard', externalId: '' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
|
|
@ -134,11 +133,10 @@ describe('access authorize (ADR-003)', () => {
|
|||
it('allow-list matches by external_id hash', async () => {
|
||||
const idHash = await hashId('abc123', SALT)
|
||||
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
|
||||
const out = await authorize(
|
||||
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
|
||||
list,
|
||||
{ salt: SALT, openEnrollment: false }
|
||||
)
|
||||
const out = await authorize({ kind: 'boltcard', externalId: 'abc123' }, list, {
|
||||
salt: SALT,
|
||||
openEnrollment: false,
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ export type { AccessRole }
|
|||
|
||||
/**
|
||||
* A raw credential. Discriminated union so new factors are additive:
|
||||
* - `boltcard` — what ships: a tapped Bolt Card. `externalId` is the
|
||||
* identity (from the lnurlw path); `lnurlw` is the full
|
||||
* voucher (single-use SUN p/c intact) the session presents
|
||||
* once, at Complete, to move sats. Only `externalId` is
|
||||
* ever hashed/authorized — the p/c never enter the
|
||||
* authorize layer.
|
||||
* - `boltcard` — what ships: a tapped Bolt Card, already verified by the
|
||||
* card server's `/session` (the tap's SUN was spent there).
|
||||
* `externalId` is the identity as the server returned it;
|
||||
* it is the only thing hashed/authorized. The session's
|
||||
* payment steps stay in the store, never in this layer.
|
||||
* - `npub` — a Nostr pubkey (bare npub, `nostr:` URI or nprofile).
|
||||
* No reader emits it today; kept, with the PIN second
|
||||
* factor, for a future non-card credential.
|
||||
|
|
@ -27,6 +26,6 @@ export type { AccessRole }
|
|||
* authorized.
|
||||
*/
|
||||
export type AccessScan =
|
||||
| { kind: 'boltcard'; externalId: string; lnurlw: string }
|
||||
| { kind: 'boltcard'; externalId: string }
|
||||
| { kind: 'npub'; npub: string }
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type ATMMachine,
|
||||
type AccessRole,
|
||||
} from '@bitSpire/state-machine'
|
||||
import type { AccessControlConfig } from '@/types/electron'
|
||||
import type { AccessControlConfig, CardSession } from '@/types/electron'
|
||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||
|
|
@ -302,6 +302,10 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// boltcards server bumps the SUN counter on the first GET, so
|
||||
// treat the voucher as spent even when the failure was ours.
|
||||
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined'
|
||||
// Where a Complete gets its voucher: a card tapped right now on the cash
|
||||
// screen (raw lnurlw, spent by this call) or the session opened at entry
|
||||
// (hit-keyed steps, no p/c).
|
||||
type BoltCardSource = { lnurlw: string } | { session: CardSession }
|
||||
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||
|
|
@ -314,11 +318,12 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
})
|
||||
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
||||
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
||||
// Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held
|
||||
// here for the whole session so buy/sell just need "Complete" — no second tap.
|
||||
// Carries the full lnurlw (single-use SUN p/c intact; spent only at payment).
|
||||
// Cleared when the session ends (machine re-locks). Never logged.
|
||||
const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null)
|
||||
// Tap-to-enter (ADR-003): the Bolt Card session opened at the locked screen
|
||||
// is held here for the whole visit so buy/sell just need "Complete" — no
|
||||
// second tap. The tap's SUN was spent opening it; what we hold are the
|
||||
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
|
||||
// the machine re-locks. Never logged.
|
||||
const loadedBoltCard = ref<CardSession | null>(null)
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -631,7 +636,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* arrives through the invoice watcher → PAYMENT_RECEIVED → dispensingCash;
|
||||
* ok here only means the card accepted the pull.
|
||||
*/
|
||||
async function handleBoltCardTap(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
async function handleBoltCardTap(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||
if (nestedState.value !== 'displayingInvoice') return 'skipped'
|
||||
const invoice = context.value?.invoice
|
||||
if (!invoice) return 'skipped'
|
||||
|
|
@ -640,7 +645,17 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
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 })
|
||||
const api = window.electronAPI!
|
||||
const res =
|
||||
'session' in source
|
||||
? source.session.withdraw
|
||||
? await api.withdrawWithSession({
|
||||
withdraw: source.session.withdraw,
|
||||
bolt11: invoice,
|
||||
amountMsat,
|
||||
})
|
||||
: { ok: false, reason: source.session.withdrawBlockedReason ?? 'card cannot pay now' }
|
||||
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
|
||||
if (res.ok) {
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
|
||||
return 'accepted'
|
||||
|
|
@ -663,7 +678,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* over the nostr transport via the normal `payInvoice` → PAYMENT_RECEIVED
|
||||
* path. Settlement + completion reuse the tested cash-in flow.
|
||||
*/
|
||||
async function handleBoltCardReceive(lnurlw: string): Promise<BoltCardOutcome> {
|
||||
async function handleBoltCardReceive(source: BoltCardSource): Promise<BoltCardOutcome> {
|
||||
if (!(isCashIn.value && nestedState.value === 'displayingQR')) return 'skipped'
|
||||
const amountSats = context.value?.satsAmount ?? 0
|
||||
if (amountSats <= 0) return 'skipped'
|
||||
|
|
@ -672,7 +687,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
try {
|
||||
const amountMsat = amountSats * 1000
|
||||
const res = await window.electronAPI!.resolveCardInvoice({ lnurlw, amountMsat })
|
||||
const api = window.electronAPI!
|
||||
const res =
|
||||
'session' in source
|
||||
? await api.resolveSessionInvoice({ pay: source.session.pay, amountMsat })
|
||||
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
|
||||
if (!res.ok || !res.bolt11) {
|
||||
boltCardProcessing.value = false
|
||||
nfcStatus.value = { state: 'declined', message: res.reason ?? 'Card could not receive' }
|
||||
|
|
@ -697,31 +716,43 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry:
|
||||
* parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay
|
||||
* valid), authorize (open-enrollment or allow-list), then hold the full lnurlw
|
||||
* for the session. The cryptographic check happens later, at Complete, when the
|
||||
* stored lnurlw actually moves sats.
|
||||
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Verified
|
||||
* entry: the tap's single-use SUN is spent ONCE, on the card server's
|
||||
* `/session` (main process), which proves a genuine, non-replayed card and
|
||||
* returns the wallet balance plus the hit-keyed withdraw/pay steps. Then the
|
||||
* card's external_id is authorized locally (open-enrollment or allow-list)
|
||||
* and the session is held for the visit — Complete needs no second tap.
|
||||
*/
|
||||
async function handleBoltCardEntry(lnurlw: string) {
|
||||
if (!isLocked.value) return
|
||||
if (boltCardProcessing.value) return
|
||||
const parsed = parseBoltcardLnurlw(lnurlw)
|
||||
if (!parsed) {
|
||||
// Reject a non-card tag before spending anything or calling anyone.
|
||||
if (!parseBoltcardLnurlw(lnurlw)) {
|
||||
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
|
||||
denyAccess('not a Bolt Card')
|
||||
return
|
||||
}
|
||||
if (!isElectron || !window.electronAPI?.openCardSession) {
|
||||
nfcStatus.value = { state: 'declined', message: 'Card sessions need the machine build' }
|
||||
denyAccess('card sessions need the machine build')
|
||||
return
|
||||
}
|
||||
boltCardProcessing.value = true
|
||||
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
|
||||
try {
|
||||
const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw }
|
||||
const opened = await window.electronAPI.openCardSession({ lnurlw })
|
||||
if (!opened.ok) {
|
||||
nfcStatus.value = { state: 'declined', message: opened.reason }
|
||||
denyAccess(opened.reason)
|
||||
return
|
||||
}
|
||||
const scan: AccessScan = { kind: 'boltcard', externalId: opened.session.externalId }
|
||||
const outcome = await authorize(scan, accessControl.value.allowList, {
|
||||
salt: accessControl.value.salt,
|
||||
openEnrollment: accessControl.value.openEnrollment,
|
||||
})
|
||||
if (outcome.status === 'granted') {
|
||||
loadedBoltCard.value = { externalId: parsed.externalId, lnurlw }
|
||||
loadedBoltCard.value = opened.session
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
} else {
|
||||
|
|
@ -730,6 +761,10 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
nfcStatus.value = { state: 'declined', message: reason }
|
||||
denyAccess(reason, outcome.credentialIdHash)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ATM] Bolt Card session failed:', e)
|
||||
nfcStatus.value = { state: 'error', message: 'Card verification failed' }
|
||||
denyAccess('card verification failed')
|
||||
} finally {
|
||||
boltCardProcessing.value = false
|
||||
}
|
||||
|
|
@ -740,19 +775,20 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
||||
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
|
||||
*
|
||||
* The loaded card is SINGLE-SHOT: its lnurlw carries one SUN p/c pair and the
|
||||
* boltcards server consumes it on the first GET, so once the voucher has been
|
||||
* presented — accepted or declined — it can never succeed again. Drop it after
|
||||
* the first real attempt and tell the customer to re-tap; a fresh tap on the
|
||||
* cash screen goes straight through the normal tap-to-pay/receive path.
|
||||
* A 'skipped' outcome (guard bounced it, no server call) keeps the card.
|
||||
* The loaded session is SINGLE-SHOT: its withdraw/pay steps are keyed by one
|
||||
* server-side hit that the first use spends, so once a step has been
|
||||
* presented — accepted or declined — it can never succeed again. Drop the
|
||||
* session after the first real attempt and tell the customer to re-tap; a
|
||||
* fresh tap on the cash screen goes straight through the normal
|
||||
* tap-to-pay/receive path. A 'skipped' outcome (guard bounced it, no server
|
||||
* call) keeps the session.
|
||||
*/
|
||||
async function completeWithCard() {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return
|
||||
let outcome: BoltCardOutcome = 'skipped'
|
||||
if (isCashOut.value) outcome = await handleBoltCardTap(card.lnurlw)
|
||||
else if (isCashIn.value) outcome = await handleBoltCardReceive(card.lnurlw)
|
||||
if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
|
||||
else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
|
||||
if (outcome === 'skipped') return
|
||||
loadedBoltCard.value = null
|
||||
if (outcome === 'declined') {
|
||||
|
|
@ -773,9 +809,9 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
if (isLocked.value) {
|
||||
void handleBoltCardEntry(lnurlw)
|
||||
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
void handleBoltCardTap({ lnurlw })
|
||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||
void handleBoltCardReceive(lnurlw)
|
||||
void handleBoltCardReceive({ lnurlw })
|
||||
}
|
||||
})
|
||||
window.electronAPI.onNfcStatus?.((status) => {
|
||||
|
|
@ -793,12 +829,12 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
|
||||
/** Dev/mock: simulate a cash-out tap with a pasted lnurlw (test without a card). */
|
||||
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)
|
||||
void handleBoltCardReceive({ lnurlw })
|
||||
}
|
||||
|
||||
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
|
||||
|
|
@ -1831,7 +1867,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
boltCardProcessing,
|
||||
simulateBoltCardTap,
|
||||
simulateBoltCardReceive,
|
||||
// Tap-to-enter: card loaded at the locked screen, reused at Complete
|
||||
// Tap-to-enter: card session opened at the locked screen, reused at Complete
|
||||
loadedBoltCard,
|
||||
completeWithCard,
|
||||
simulateBoltCardEntry,
|
||||
|
|
|
|||
44
apps/machine/src/types/electron.d.ts
vendored
44
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -22,6 +22,37 @@ export interface AccessControlConfig {
|
|||
allowList: AllowListEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Bolt Card session opened at tap-to-enter (ADR-003). Wire payload from the
|
||||
* main process (electron/boltcard-session.ts) — mirrored here rather than
|
||||
* imported to avoid a cross-project electron↔renderer import. The withdraw and
|
||||
* pay steps are keyed by a single-use server-side hit; no card secret is held.
|
||||
*/
|
||||
export interface CardSession {
|
||||
externalId: string
|
||||
cardName: string
|
||||
balanceSats: number
|
||||
/** ISO currency the card server priced the balance in; null → no fiat. */
|
||||
currency: string | null
|
||||
/** Balance in `currency` at the card server's rate; null when unknown. */
|
||||
fiat: number | null
|
||||
/** LUD-03 second step, or null when the card server withheld it. */
|
||||
withdraw: {
|
||||
callback: string
|
||||
k1: string
|
||||
minWithdrawable?: number
|
||||
maxWithdrawable?: number
|
||||
} | null
|
||||
/** Why `withdraw` is null (e.g. daily limit spent); safe to show on-screen. */
|
||||
withdrawBlockedReason: string | null
|
||||
/** LUD-06 second step for topping the card wallet up. */
|
||||
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
|
||||
}
|
||||
|
||||
export type OpenCardSessionResult =
|
||||
| { ok: true; session: CardSession }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
|
|
@ -136,6 +167,19 @@ declare global {
|
|||
lnurlw: string
|
||||
amountMsat: number
|
||||
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
|
||||
/** Bolt Card tap-to-enter: open one verified session for a tapped card (spends the SUN). */
|
||||
openCardSession: (args: { lnurlw: string }) => Promise<OpenCardSessionResult>
|
||||
/** Cash-out via a session's withdraw step (no tap). */
|
||||
withdrawWithSession: (args: {
|
||||
withdraw: NonNullable<CardSession['withdraw']>
|
||||
bolt11: string
|
||||
amountMsat?: number
|
||||
}) => Promise<{ ok: boolean; reason?: string }>
|
||||
/** Cash-in via a session's pay step (no tap): a BOLT11 to pay. */
|
||||
resolveSessionInvoice: (args: {
|
||||
pay: CardSession['pay']
|
||||
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