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
|
|
@ -11,14 +11,8 @@ import {
|
|||
} from '@bitSpire/state-machine'
|
||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import {
|
||||
startOperatorConfigService,
|
||||
type OperatorConfigService,
|
||||
} from '@/services/operator-config'
|
||||
import {
|
||||
startOperatorFeesService,
|
||||
type OperatorFeesService,
|
||||
} from '@/services/operator-fees'
|
||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||
import { startOperatorFeesService, type OperatorFeesService } from '@/services/operator-fees'
|
||||
import type { HalConfig, HalServices } from '@/services/hal'
|
||||
import type { MachineModel } from '@/config'
|
||||
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.`
|
||||
)
|
||||
}
|
||||
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
|
||||
? principalSats - ctx.satsAmount // cash-in: customer receives less 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,
|
||||
// timed out, or cancelled) so a stale "processing"/error can't linger.
|
||||
if (currentNested !== 'displayingInvoice' && prevNestedState === 'displayingInvoice') {
|
||||
// Clear Bolt Card state whenever we leave a tap screen — the cash-out
|
||||
// invoice ('displayingInvoice') or the cash-in QR ('displayingQR') — so a
|
||||
// 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
|
||||
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). */
|
||||
function setupNfcListener() {
|
||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
|
||||
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||
void handleBoltCardTap(lnurlw)
|
||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||
void handleBoltCardReceive(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) {
|
||||
// Only surface reader status on a tap screen, and don't clobber an
|
||||
// in-flight tap's message.
|
||||
const onTapScreen =
|
||||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
||||
(isCashIn.value && nestedState.value === 'displayingQR')
|
||||
if (onTapScreen && !boltCardProcessing.value) {
|
||||
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) {
|
||||
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.
|
||||
*/
|
||||
|
|
@ -1586,10 +1635,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
isCashOut,
|
||||
nestedState,
|
||||
|
||||
// Bolt Card cash-out (NFC)
|
||||
// Bolt Card (NFC): cash-out pulls, cash-in receives
|
||||
nfcStatus,
|
||||
boltCardProcessing,
|
||||
simulateBoltCardTap,
|
||||
simulateBoltCardReceive,
|
||||
|
||||
// Actions
|
||||
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
|
||||
amountMsat?: number
|
||||
}) => 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: (
|
||||
payload: { positions: Record<string, { denomination: number; count: number }> },
|
||||
eventCreatedAt: number
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ const showCancelButton = computed(() => {
|
|||
// Invoice input for manual payment
|
||||
const invoiceInput = ref('')
|
||||
|
||||
// Dev: paste an lnurlw to simulate a Bolt Card tap-to-receive
|
||||
const mockLnurlw = ref('')
|
||||
|
||||
// Copy state for ndebit URI
|
||||
const copied = ref(false)
|
||||
|
||||
|
|
@ -279,7 +282,9 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
<Button
|
||||
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"
|
||||
:disabled="!context || context.billsInserted.length === 0 || context.billPending !== null"
|
||||
:disabled="
|
||||
!context || context.billsInserted.length === 0 || context.billPending !== null
|
||||
"
|
||||
@click="finishInserting"
|
||||
>
|
||||
Done Inserting
|
||||
|
|
@ -331,10 +336,31 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}
|
||||
</p>
|
||||
|
||||
<!-- Waiting indicator -->
|
||||
<div class="flex items-center gap-3 pt-2 lg:pt-4">
|
||||
<PickaxeIcon :size="32" />
|
||||
<p class="text-sm lg:text-xl text-muted-foreground">Waiting for wallet scan...</p>
|
||||
<!-- Waiting indicator + Bolt Card tap-to-receive status -->
|
||||
<div class="flex flex-col items-center gap-2 pt-2 lg:pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<PickaxeIcon :size="32" />
|
||||
<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>
|
||||
|
||||
<!-- LNURL URI (web-ui only) -->
|
||||
|
|
@ -353,8 +379,8 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug: Simulate payment button -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2">
|
||||
<!-- Debug: simulate payment / Bolt Card tap-to-receive -->
|
||||
<div v-if="atmStore.debugMode" class="pt-2 flex flex-col items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -363,6 +389,21 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
>
|
||||
Dev: Skip to Success
|
||||
</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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue