+ A relay looks unreachable from this machine — pairing will fail unless it can reach the
+ relay. Check the URL/network, or rescan a corrected code.
+
+
+
+
+ No camera or NFC reader is available on this machine. Pair by provisioning
+ VITE_SPIRE_SEED instead.
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts
index b36f6f2..20517e9 100644
--- a/apps/machine/src/composables/useAvailabilityBroadcast.ts
+++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts
@@ -13,7 +13,7 @@
import { watch, type Ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
-import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client'
+import type { NostrClient, Signer } from '@bitSpire/nostr-client'
import { createSignedEvent } from '@bitSpire/nostr-client'
type CashLevel = 'none' | 'low' | 'good' | 'full'
@@ -26,7 +26,7 @@ interface AvailabilitySnapshot {
interface UseAvailabilityBroadcastOptions {
nostrClient: NostrClient
- identity: MachineIdentity
+ signer: Signer
/** Reactive inventory: denomination -> count */
inventory: Ref>
/** Reactive Lightning.Pub balance in sats (null = unknown) */
@@ -38,7 +38,7 @@ interface UseAvailabilityBroadcastOptions {
}
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
- const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
+ const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options
let lastSnapshot: AvailabilitySnapshot | null = null
@@ -73,19 +73,22 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
model,
})
- const event = createSignedEvent(identity, {
- kind: 30078,
- created_at: Math.floor(Date.now() / 1000),
- tags: [['d', 'atm-availability']],
- content,
- })
-
+ // Signing goes through the bunker, so it can throw BunkerTimeoutError /
+ // BunkerRejectedError — keep it INSIDE the try so a transient signer blip
+ // is swallowed (the beacon re-publishes every interval) rather than
+ // surfacing as an uncaught rejection. `publish()` is fire-and-forget.
try {
+ const event = await createSignedEvent(signer, {
+ kind: 30078,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['d', 'atm-availability']],
+ content,
+ })
await nostrClient.publish(event)
lastSnapshot = snap
console.log('[Availability] Published:', content)
} catch (e) {
- console.warn('[Availability] Failed to publish:', e)
+ console.warn('[Availability] Publish failed (sign or relay):', e)
}
}
diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts
index 9a22ab8..9a06c9a 100644
--- a/apps/machine/src/config/device.ts
+++ b/apps/machine/src/config/device.ts
@@ -15,7 +15,15 @@ import type { HalConfig, CassetteConfig } from '@/services/hal'
/**
* Supported machine models
*/
-export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom'
+export type MachineModel =
+ | 'sintra'
+ | 'tejo'
+ | 'douro'
+ | 'gaia'
+ | 'batm3'
+ | 'rpi4'
+ | 'rpi5'
+ | 'custom'
/**
* Full device configuration
@@ -28,7 +36,10 @@ export interface DeviceConfig {
/** Bill validator configuration */
validator: {
/** Validator protocol type */
- type: 'id003' | 'ebds'
+ // 'apex' = Pyramid Apex RS-232. The driver landed with the Pi 5 work
+ // (packages/hal ValidatorType) but these app-side unions were never
+ // widened, so no machine could actually be configured to use it.
+ type: 'id003' | 'ebds' | 'apex'
/** Serial device path(s) */
device: string | string[]
}
@@ -117,6 +128,52 @@ export const MACHINE_PRESETS: Record {
+ it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => {
+ expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired')
+ })
+
+ it('maps a bunker timeout to "signer-unreachable"', () => {
+ expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable')
+ })
+
+ it('classifies by error name across bundle boundaries (no instanceof)', () => {
+ // A structurally-equivalent error from a different module copy still maps.
+ const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' })
+ expect(classifyInitError(lookalike)).toBe('unpaired')
+ })
+
+ it('surfaces a generic error message unchanged', () => {
+ expect(classifyInitError(new Error('relay down'))).toBe('relay down')
+ })
+
+ it('uses the fallback for non-Error throws', () => {
+ expect(classifyInitError('boom', 'Lightning initialization failed')).toBe(
+ 'Lightning initialization failed'
+ )
+ expect(classifyInitError(undefined)).toBe('Initialization failed')
+ })
+})
diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts
index 1e20cc3..4ab83ee 100644
--- a/apps/machine/src/services/hal.ts
+++ b/apps/machine/src/services/hal.ts
@@ -23,7 +23,7 @@ export interface CassetteConfig {
export interface HalConfig {
validator: {
- type: 'id003' | 'ebds'
+ type: 'id003' | 'ebds' | 'apex'
device: string | string[]
fiatCode: string
}
@@ -109,6 +109,12 @@ export async function initializeHalServices(config: HalConfig): Promise = {}
for (const cassette of dispConfig.cassettes) {
@@ -206,10 +212,13 @@ export async function initializeHalServices(config: HalConfig): Promise {
+ if (inFlightDenomination === null) {
+ console.warn('[HAL] billsValid with no bill in flight — ignoring')
+ return
+ }
+ const denomination = inFlightDenomination
+ inFlightDenomination = null
+ console.log('[HAL] Bill stacked (confirmed):', denomination)
+ callbacks.onBillInserted(denomination)
+ })
+
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
+ escrowDenomination = null
+ inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown')
})
@@ -248,8 +271,19 @@ export async function initializeHalServices(config: HalConfig): Promise validator.stack(),
- rejectBill: () => validator.reject(),
+ stackBill: () => {
+ if (escrowDenomination === null) {
+ console.warn('[HAL] stackBill with no bill in escrow — ignoring')
+ return
+ }
+ inFlightDenomination = escrowDenomination
+ escrowDenomination = null
+ validator.stack()
+ },
+ rejectBill: () => {
+ escrowDenomination = null
+ validator.reject()
+ },
cleanup: async () => {
return new Promise((resolve) => {
diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts
new file mode 100644
index 0000000..bbeb117
--- /dev/null
+++ b/apps/machine/src/services/init-error.ts
@@ -0,0 +1,20 @@
+/**
+ * Classify an initialization failure into a maintenance-screen sentinel
+ * (see App.vue's MAINTENANCE_SCREENS).
+ *
+ * Bunker failures (aiolabs/bitspire#52) get dedicated screens:
+ * - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the
+ * interactive QR-pairing wizard so the operator can scan a spire-seed.
+ * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
+ * `unpaired` too — re-pairing is the same scan-a-fresh-seed flow.
+ * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
+ * a transient condition.
+ * Everything else surfaces its raw message (or the caller's fallback).
+ */
+export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
+ const name = (error as { name?: string } | null)?.name
+ if (name === 'NoPairingError') return 'unpaired'
+ if (name === 'BunkerRejectedError') return 'unpaired'
+ if (name === 'BunkerTimeoutError') return 'signer-unreachable'
+ return error instanceof Error ? error.message : fallback
+}
diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts
index 1466e20..eb2e1c7 100644
--- a/apps/machine/src/services/lightning.ts
+++ b/apps/machine/src/services/lightning.ts
@@ -12,12 +12,8 @@
* the customer's invoice.
*/
-import {
- NostrClient,
- generateIdentity,
- loadIdentityFromHex,
- type MachineIdentity,
-} from '@bitSpire/nostr-client'
+import { NostrClient, type Signer } from '@bitSpire/nostr-client'
+import { resolveSigner } from './signer-resolver.js'
import { LnbitsClient } from '@bitSpire/lnbits'
import { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
@@ -37,14 +33,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
*
* Environment variables:
* - VITE_RELAY_URL: Nostr relay WebSocket URL
- * - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
- * - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
- * - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret
- * - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
+ * - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex)
+ * - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts
+ * - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex)
*/
interface LightningConfig {
relayUrl: string
- atmPrivateKey: string
appId: string
operatorPubkeys: string[]
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
@@ -60,8 +54,10 @@ interface LightningConfig {
*/
async function loadLightningConfig(): Promise {
const defaults: LightningConfig = {
- relayUrl: 'ws://localhost:7777',
- atmPrivateKey: '',
+ // Empty when unset (not the dev relay) so initializeLightningServices can
+ // tell "operator gave us a relay" from "fall back to the pairing seed". See
+ // aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
+ relayUrl: '',
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [],
lnbitsServerPubkey: '',
@@ -70,10 +66,8 @@ async function loadLightningConfig(): Promise {
if (isElectron && window.electronAPI) {
try {
const rc = await window.electronAPI.getConfig()
- const sec = await window.electronAPI.getAtmSecrets()
return {
relayUrl: rc.relayUrl || defaults.relayUrl,
- atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
appId: rc.appId || defaults.appId,
operatorPubkeys: rc.operatorPubkeys
? rc.operatorPubkeys
@@ -90,7 +84,6 @@ async function loadLightningConfig(): Promise {
return {
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
- atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
appId: import.meta.env.VITE_APP_ID || defaults.appId,
lnbitsServerPubkey:
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
@@ -107,6 +100,10 @@ async function loadLightningConfig(): Promise {
// Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig
+/** Dev-only relay used when neither env nor the pairing supplies one. Matches
+ * the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
+const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
+
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
* Sessions are normally cleaned up by the state machine on idle transition.
* This is a safety net in case the state machine doesn't clean up properly. */
@@ -119,33 +116,27 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
/** Active LNURL-withdraw session */
interface LnurlSession {
sessionId: string
- /** Link ID for management operations (delete/update) */
+ /** Link ID — the management + settlement-watch key (delete/subscribe). */
linkId: string
- uniqueHash: string
satsAmount: number
status: 'active' | 'claimed' | 'expired'
createdAt: number
cleanup?: () => void
}
-/** Map of uniqueHash -> LNURL session data */
+/** Map of linkId -> LNURL session data. Keyed on link_id since the secure
+ * `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
const lnurlSessions = new Map()
/**
- * Register a new LNURL-withdraw session
+ * Register a new LNURL-withdraw session, keyed by linkId.
*/
-function registerLnurlSession(
- sessionId: string,
- linkId: string,
- uniqueHash: string,
- satsAmount: number,
-): void {
- console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
+function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
+ console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
- lnurlSessions.set(uniqueHash, {
+ lnurlSessions.set(linkId, {
sessionId,
linkId,
- uniqueHash,
satsAmount,
status: 'active',
createdAt: Date.now(),
@@ -153,10 +144,10 @@ function registerLnurlSession(
// Safety timeout — normally cleaned up by state machine on idle transition.
setTimeout(() => {
- const session = lnurlSessions.get(uniqueHash)
+ const session = lnurlSessions.get(linkId)
if (session && session.status === 'active') {
- console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
- expireLnurlSession(uniqueHash)
+ console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
+ expireLnurlSession(linkId)
}
}, SESSION_SAFETY_TIMEOUT_MS)
}
@@ -164,23 +155,23 @@ function registerLnurlSession(
/** Invalidate an active LNURL session by cash-in sessionId. The session's
* cleanup closure unsubscribes from LNbits and deletes the link. */
function invalidateLnurlSessionBySessionId(sessionId: string): void {
- for (const [hash, session] of lnurlSessions.entries()) {
+ for (const [linkId, session] of lnurlSessions.entries()) {
if (session.sessionId === sessionId && session.status === 'active') {
- console.log('[LNURL Session] Invalidating previous session:', hash)
- expireLnurlSession(hash)
+ console.log('[LNURL Session] Invalidating previous session:', linkId)
+ expireLnurlSession(linkId)
}
}
}
/** Expire a single LNURL session via its cleanup closure. */
-function expireLnurlSession(uniqueHash: string): void {
- const session = lnurlSessions.get(uniqueHash)
+function expireLnurlSession(linkId: string): void {
+ const session = lnurlSessions.get(linkId)
if (!session || session.status !== 'active') return
- console.log('[LNURL Session] Expiring:', uniqueHash)
+ console.log('[LNURL Session] Expiring:', linkId)
session.status = 'expired'
if (session.cleanup) session.cleanup()
- setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
+ setTimeout(() => lnurlSessions.delete(linkId), 60000)
}
let _lnbitsRef: LnbitsClient | null = null
@@ -234,7 +225,7 @@ interface LightningServices {
nostrClient: NostrClient
lightningPub: LightningBackend
clink: CLINKClient
- identity: MachineIdentity
+ signer: Signer
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
operatorPubkeys: string[]
atmServices: ATMServices
@@ -411,50 +402,85 @@ export async function initializeLightningServices(options?: {
// Load configuration (async for Electron runtime config)
CONFIG = await loadLightningConfig()
- console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
- console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
+ // Resolve the signing identity BEFORE validating the LNbits transport
+ // config. An unpaired machine must reach the QR-pairing wizard regardless
+ // of relay/server-pubkey provisioning — pairing is what provides those — so
+ // resolveSigner (which throws NoPairingError → 'unpaired' → wizard for a
+ // machine with no seed and no binding) has to run ahead of the config
+ // checks below. The relay/pubkey validation then only gates a *paired*
+ // machine that's actually trying to talk to LNbits. See aiolabs/bitspire#70.
+ //
+ // In production this is a BunkerSigner over NIP-46 (the ATM holds only a
+ // transport key; the operator's nsecbunkerd holds the signing key); in dev
+ // it falls back to an in-process LocalSigner. The Phase-A Signer seam means
+ // nothing downstream changes. See aiolabs/bitspire#52.
+ const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict })
+ console.log('[Lightning] ATM pubkey:', signer.pubkey)
- // Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
+ // Resolve the effective LNbits transport. Precedence: explicit env wins (dev
+ // + operator override), else the pairing (seed/binding) supplies it (#70) so
+ // a blank-.env paired machine reaches the backend from the seed alone, else a
+ // dev-only localhost fallback. CONFIG is mutated to the resolved values so
+ // downstream (and the exported CONFIG) see a single source of truth.
+ const envRelay = CONFIG.relayUrl
+ const envPubkey = CONFIG.lnbitsServerPubkey
+ const relays: string[] = envRelay
+ ? [envRelay]
+ : transport && transport.relays.length > 0
+ ? transport.relays
+ : [DEV_DEFAULT_RELAY]
+ CONFIG.relayUrl = relays[0]!
+ CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || ''
+ console.log(
+ '[Lightning] Relay(s):',
+ relays.join(', '),
+ envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)',
+ )
+ console.log(
+ '[Lightning] LNbits server pubkey:',
+ CONFIG.lnbitsServerPubkey || '(not configured)',
+ envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '',
+ )
+ // Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
+ // (env). An empty set disables the fees/operator-config services → the machine
+ // sits at "awaiting configuration" — so log it loudly rather than fail silent.
+ // (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
+ console.log(
+ '[Lightning] Operator pubkey(s):',
+ CONFIG.operatorPubkeys.length
+ ? CONFIG.operatorPubkeys.join(', ') + ' (env)'
+ : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)',
+ )
+
+ // Strict mode: validate the RESOLVED config is production-ready (no
+ // localhost). Values may come from env or the pairing seed (#70).
if (options?.strict) {
const errors: string[] = []
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
- errors.push('VITE_RELAY_URL contains localhost')
- }
- if (!CONFIG.atmPrivateKey) {
- errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
+ errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)')
}
if (!CONFIG.lnbitsServerPubkey) {
- errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
+ errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)')
}
if (errors.length > 0) {
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
}
}
- // Validate required configuration
+ // Validate required configuration. Reached only for a paired machine (an
+ // unpaired one threw NoPairingError above) — it needs the LNbits server
+ // pubkey to talk to the transport, from either env or the pairing seed.
if (!CONFIG.lnbitsServerPubkey) {
throw new Error(
- '[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
- 'Get it from: docker logs lnbits | grep nostr_transport pubkey',
+ '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
+ 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
)
}
- // Load or generate ATM identity
- let identity: MachineIdentity
- if (CONFIG.atmPrivateKey) {
- identity = loadIdentityFromHex(CONFIG.atmPrivateKey)
- console.log('[Lightning] Loaded ATM identity from config')
- } else {
- identity = generateIdentity()
- console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity')
- console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts')
- }
- console.log('[Lightning] ATM pubkey:', identity.publicKey)
-
// Create Nostr client
const nostrClient = new NostrClient({
- relays: [{ url: CONFIG.relayUrl }],
- identity,
+ relays: relays.map((url) => ({ url })),
+ signer,
})
await nostrClient.connect()
@@ -463,9 +489,9 @@ export async function initializeLightningServices(options?: {
// LNbits nostr-transport client.
const lnbits = new LnbitsClient({
serverPubkey: CONFIG.lnbitsServerPubkey,
- relays: [CONFIG.relayUrl],
+ relays,
})
- lnbits.initialize(nostrClient, identity)
+ lnbits.initialize(nostrClient, signer)
_lnbitsRef = lnbits
console.log('[Lightning] LNbits client initialized')
@@ -479,14 +505,54 @@ export async function initializeLightningServices(options?: {
}
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
+ // #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
+ // transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
+ // VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
+ // at "awaiting configuration". Only for the seed-only case — an explicit
+ // VITE_OPERATOR_PUBKEYS override keeps the env/kind-30078 path untouched.
+ // Soft-fail: an older spirekeeper (no RPC) or a transport error falls back to
+ // whatever the operator services can pull from kind-30078.
+ if (CONFIG.operatorPubkeys.length === 0) {
+ try {
+ const mc = await lnbits.getMachineConfig()
+ if (mc.operator_pubkey) {
+ CONFIG.operatorPubkeys = [mc.operator_pubkey]
+ console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)')
+ }
+ if (mc.fee_config && isElectron && window.electronAPI) {
+ // Persist the server-delivered fee config so atm.ts's awaiting-fees gate
+ // (getFeeConfig) clears immediately — robust to the replaceable kind-30078
+ // event not being fetchable from the relay. The live kind-30078
+ // subscription still handles mid-run fee updates.
+ const applied = await window.electronAPI.applyFeeConfig(
+ {
+ cashInFeeFraction: mc.fee_config.cash_in_fee_fraction,
+ cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
+ schemaVersion: mc.fee_config.schema_version,
+ },
+ mc.created_at,
+ )
+ console.log(
+ '[Lightning] Server-delivered fee config:',
+ applied.applied ? 'applied' : `skipped (${applied.reason})`,
+ )
+ }
+ } catch (e) {
+ console.warn(
+ '[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
+ (e as Error).message,
+ )
+ }
+ }
+
// CLINK client — kept in tree but not actively wired into LNbits flows.
// operatorPubkey is the operator allowlist for kind-21003 management
// commands; it has no Lightning.Pub dependency.
const clink = new CLINKClient({
nostrClient,
- identity,
+ signer,
operatorPubkey: CONFIG.operatorPubkeys,
- relays: [CONFIG.relayUrl],
+ relays,
})
// Callbacks for events
@@ -574,7 +640,6 @@ export async function initializeLightningServices(options?: {
}
const atmServices = createATMServices(
- identity,
(preimage) => {
if (paymentReceivedCallback) {
paymentReceivedCallback(preimage)
@@ -588,7 +653,7 @@ export async function initializeLightningServices(options?: {
nostrClient,
lightningPub,
clink,
- identity,
+ signer,
operatorPubkeys: CONFIG.operatorPubkeys,
atmServices,
onOfferRequest: (callback: OfferRequestCallback) => {
@@ -617,7 +682,6 @@ export async function initializeLightningServices(options?: {
* Create ATMServices implementation using the LNbits nostr-transport.
*/
function createATMServices(
- _identity: MachineIdentity,
onPaymentSuccess: (preimage: string) => void,
lnbits: LnbitsClient,
lnbitsWalletId: string,
@@ -661,57 +725,70 @@ function createATMServices(
* over nostr, we trigger dispense.
*/
generateLnurlWithdraw: async (context: ATMContext): Promise => {
- console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
+ // GROSS principal (fiat × rate, BEFORE commission). The server derives
+ // fee + NET from this, so we must NOT send the already-fee'd
+ // context.satsAmount — doing so double-applies the commission (client
+ // subtracts it in calculateSats, then the server subtracts it again,
+ // e.g. 12% → 22.6% effective; the customer is short-changed while the
+ // quote/receipt still read 12%). Mirror calculateSats's principal.
+ const grossPrincipalSats = Math.floor((context.fiatCents / 100) * context.exchangeRate)
+ console.log(
+ `[ATM Service] Generating LNURL-withdraw: gross principal=${grossPrincipalSats} sats ` +
+ `(net after ${(context.feeFraction * 100).toFixed(2)}% ≈ ${context.satsAmount})`
+ )
try {
if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId)
}
- const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
+ // Secure cash-in: the ATM sends only the hardware-attested gross
+ // principal; the operator side verifies the signer, derives fee + NET,
+ // and stamps attribution (spirekeeper#31/#32). The ATM no longer sets
+ // the amount or extra. We display the returned LNURL (for NET) and
+ // watch link_id for settlement.
+ const link = await lnbits.createWithdraw(lnbitsWalletId, {
+ principal_sats: grossPrincipalSats,
+ fiat_amount: context.fiatCents / 100,
+ fiat_code: context.currency,
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
- min_withdrawable: context.satsAmount,
- max_withdrawable: context.satsAmount,
- uses: 1,
- wait_time: 1,
- is_unique: false,
+ client_ref: context.txid ?? context.cashInSessionId ?? undefined,
})
if (!link.lnurl) {
throw new Error(
- '[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)'
+ '[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
)
}
const lnurl = link.lnurl.toUpperCase()
+ console.log(
+ `[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}`
+ )
if (context.cashInSessionId) {
- registerLnurlSession(
- context.cashInSessionId,
- link.id,
- link.unique_hash,
- context.satsAmount,
- )
+ // Track the NET (what the customer withdraws); keyed by link_id.
+ registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
const subId = await lnbits.subscribePayments(
lnbitsWalletId,
- { tag: 'withdraw', link_id: link.id, max_seconds: 600 },
+ { tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
(push) => {
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
- const session = lnurlSessions.get(link.unique_hash)
+ const session = lnurlSessions.get(link.link_id)
if (session) {
session.status = 'claimed'
- lnurlSessions.delete(link.unique_hash)
+ lnurlSessions.delete(link.link_id)
}
if (onPaymentCallback) {
- onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`)
+ onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
}
},
)
// Wire per-session cleanup so abort/expiry tears it down cleanly.
- const session = lnurlSessions.get(link.unique_hash)
+ const session = lnurlSessions.get(link.link_id)
if (session) {
session.cleanup = () => {
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
- void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
+ void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
}
}
}
diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts
index c9b26f8..d853114 100644
--- a/apps/machine/src/services/operator-config.ts
+++ b/apps/machine/src/services/operator-config.ts
@@ -23,12 +23,10 @@
*/
import {
- type MachineIdentity,
+ type Signer,
type NostrClient,
type Event,
createSignedEvent,
- decryptContentV2,
- encryptContentV2,
validateEvent,
} from '@bitSpire/nostr-client'
@@ -47,17 +45,28 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorConfigServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient
- /** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */
- identity: MachineIdentity
+ /** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */
+ signer: Signer
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[]
- /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
+ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string
}
export interface OperatorConfigService {
/** Unsubscribe from operator events and free resources. */
stop(): void
+ /**
+ * Republish the current cassette state (kind-30078, replaceable). Call after
+ * a dispense and on a cassette reload so the operator's view tracks reality.
+ * Best-effort — logs and swallows errors.
+ */
+ publishCassettesState(): Promise
+}
+
+const NOOP_SERVICE: OperatorConfigService = {
+ stop: () => {},
+ publishCassettesState: async () => {},
}
export async function startOperatorConfigService(
@@ -65,14 +74,14 @@ export async function startOperatorConfigService(
): Promise {
if (cfg.operatorPubkeys.length === 0) {
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
- return { stop: () => {} }
+ return NOOP_SERVICE
}
if (!isElectron || !window.electronAPI) {
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
- return { stop: () => {} }
+ return NOOP_SERVICE
}
const api = window.electronAPI
- const machineId = cfg.machineId ?? cfg.identity.publicKey
+ const machineId = cfg.machineId ?? cfg.signer.pubkey
// Bootstrap hello-event on first boot (best-effort — failure leaves the
// gate null so the next boot retries).
@@ -88,7 +97,7 @@ export async function startOperatorConfigService(
[
{
kinds: [KIND_NIP78],
- '#p': [cfg.identity.publicKey],
+ '#p': [cfg.signer.pubkey],
'#d': [dTag],
authors: cfg.operatorPubkeys,
},
@@ -105,6 +114,12 @@ export async function startOperatorConfigService(
return {
stop: () => cfg.nostrClient.unsubscribe(subscriptionId),
+ publishCassettesState: () =>
+ publishCassettesState(cfg, api, machineId)
+ .then(() => {})
+ .catch((err) => {
+ console.warn('[OperatorConfig] cassettes-state republish failed:', err)
+ }),
}
}
@@ -150,7 +165,7 @@ async function handleOperatorConfigEvent(
// 4. Decrypt content (NIP-44 v2).
let parsed: { positions: Record }
try {
- const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
+ const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
parsed = JSON.parse(plaintext) as typeof parsed
} catch (err) {
console.error('[OperatorConfig] Decrypt/parse failed:', err)
@@ -195,38 +210,44 @@ async function handleOperatorConfigEvent(
console.log(
`[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}`
)
+
+ // Republish our resulting cassette state so the operator's view reflects the
+ // applied config (the "on cassette reload" case). Different d-tag from the
+ // operator's config event, so no echo loop. Best-effort.
+ const machineId = cfg.machineId ?? cfg.signer.pubkey
+ await publishCassettesState(cfg, api, machineId).catch((err) =>
+ console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err)
+ )
}
-async function maybePublishBootstrap(
+/**
+ * Publish the ATM's current cassette state as a replaceable kind-30078 event
+ * (`bitspire-cassettes-state:`), NIP-44-encrypted to the operator.
+ * Replaceable → latest wins; the operator consumes every update. Call after a
+ * dispense and on a cassette reload so the operator view tracks reality, not
+ * the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56).
+ *
+ * NOT gated on the bootstrap flag — this is the live update. Returns whether an
+ * event was published (false when there are no cassettes / no operator).
+ */
+async function publishCassettesState(
cfg: OperatorConfigServiceConfig,
api: NonNullable,
machineId: string
-): Promise {
- const already = await api.getBootstrapPublishedAt()
- if (already !== null) {
- console.log('[OperatorConfig] Bootstrap already published at unix', already)
- return
- }
+): Promise {
const cassettes = await api.loadCassettes()
- if (cassettes.length === 0) {
- console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
- return
- }
-
+ if (cassettes.length === 0) return false
const operatorPubkey = cfg.operatorPubkeys[0]
- if (!operatorPubkey) {
- console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
- return
- }
+ if (!operatorPubkey) return false
const positions: Record = {}
for (const c of cassettes) {
positions[String(c.position)] = { denomination: c.denomination, count: c.count }
}
- const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions })
+ const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
const dTag = atmStateDTag(machineId)
- const event = createSignedEvent(cfg.identity, {
+ const event = await createSignedEvent(cfg.signer, {
kind: KIND_NIP78,
content: ciphertext,
tags: [
@@ -237,6 +258,30 @@ async function maybePublishBootstrap(
})
await cfg.nostrClient.publish(event)
- await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
- console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
+ console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id })
+ return true
+}
+
+/**
+ * First-boot hello: publish the cassette state once and mark the gate. The
+ * gate (lamassu-next#56) prevents re-emitting the *bootstrap* on every boot;
+ * live updates after dispenses go through `publishCassettesState` directly.
+ */
+async function maybePublishBootstrap(
+ cfg: OperatorConfigServiceConfig,
+ api: NonNullable,
+ machineId: string
+): Promise {
+ const already = await api.getBootstrapPublishedAt()
+ if (already !== null) {
+ console.log('[OperatorConfig] Bootstrap already published at unix', already)
+ return
+ }
+ const published = await publishCassettesState(cfg, api, machineId)
+ if (published) {
+ await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
+ console.log('[OperatorConfig] Bootstrap hello-event published')
+ } else {
+ console.log('[OperatorConfig] No cassettes/operator — skipping bootstrap')
+ }
}
diff --git a/apps/machine/src/services/operator-fees.ts b/apps/machine/src/services/operator-fees.ts
index 8581ce8..4e699a3 100644
--- a/apps/machine/src/services/operator-fees.ts
+++ b/apps/machine/src/services/operator-fees.ts
@@ -56,10 +56,9 @@
*/
import {
- type MachineIdentity,
+ type Signer,
type NostrClient,
type Event,
- decryptContentV2,
validateEvent,
} from '@bitSpire/nostr-client'
@@ -80,11 +79,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorFeesServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient
- /** ATM's nostr identity. Used to decrypt operator events. */
- identity: MachineIdentity
+ /** Signer for the ATM identity. Decrypts operator events. */
+ signer: Signer
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[]
- /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
+ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string
/**
* Called when a valid fee-config event is applied. Renderer should
@@ -112,7 +111,7 @@ export async function startOperatorFeesService(
return { stop: () => {} }
}
const api = window.electronAPI
- const machineId = cfg.machineId ?? cfg.identity.publicKey
+ const machineId = cfg.machineId ?? cfg.signer.pubkey
// Subscribe to operator-published fee config events.
const dTag = feeConfigDTag(machineId)
@@ -120,7 +119,7 @@ export async function startOperatorFeesService(
[
{
kinds: [KIND_NIP78],
- '#p': [cfg.identity.publicKey],
+ '#p': [cfg.signer.pubkey],
'#d': [dTag],
authors: cfg.operatorPubkeys,
},
@@ -189,7 +188,7 @@ async function handleFeeConfigEvent(
// fields (v2 forward-compat — future promo payloads).
let parsed: ParsedFeePayload
try {
- const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
+ const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
const raw = JSON.parse(plaintext) as Record
parsed = parseV1Payload(raw)
} catch (err) {
diff --git a/apps/machine/src/services/pairing/__tests__/ingest.test.ts b/apps/machine/src/services/pairing/__tests__/ingest.test.ts
new file mode 100644
index 0000000..f6c069e
--- /dev/null
+++ b/apps/machine/src/services/pairing/__tests__/ingest.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { ingestScannedSeed } from '../ingest'
+import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
+import { npubEncode } from 'nostr-tools/nip19'
+
+/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
+function makeSeed(json: unknown): string {
+ const b64 = Buffer.from(JSON.stringify(json), 'utf8')
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+ return SPIRE_SEED_SCHEME + b64
+}
+
+const SPIRE_PUBKEY = 'a'.repeat(64)
+const VALID_SEED = makeSeed({
+ v: 1,
+ spire_npub: npubEncode(SPIRE_PUBKEY),
+ lnbits_npub: npubEncode('b'.repeat(64)),
+ bunker_secret: 'deadbeef',
+ relays: ['wss://events.relay/'],
+})
+
+describe('ingestScannedSeed', () => {
+ const originalWindow = globalThis.window
+
+ afterEach(() => {
+ globalThis.window = originalWindow
+ vi.restoreAllMocks()
+ })
+
+ it('rejects a non-seed scan without touching the bridge', async () => {
+ const saveSpireSeed = vi.fn()
+ globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
+
+ const result = await ingestScannedSeed('https://example.com/not-a-seed')
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.reason).toBe('invalid-seed')
+ expect(saveSpireSeed).not.toHaveBeenCalled()
+ })
+
+ it('reports no-bridge when Electron is absent', async () => {
+ globalThis.window = {} as unknown as Window & typeof globalThis
+ const result = await ingestScannedSeed(VALID_SEED)
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.reason).toBe('no-bridge')
+ })
+
+ it('persists the seed and relaunches on a valid scan', async () => {
+ const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
+ const relaunchApp = vi.fn().mockResolvedValue(undefined)
+ globalThis.window = {
+ electronAPI: { saveSpireSeed, relaunchApp },
+ } as unknown as Window & typeof globalThis
+
+ const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
+ expect(result.ok).toBe(true)
+ if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
+ expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
+ expect(relaunchApp).toHaveBeenCalledOnce()
+ })
+
+ it('surfaces persist-failed when saveSpireSeed throws', async () => {
+ const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
+ globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
+
+ const result = await ingestScannedSeed(VALID_SEED)
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.reason).toBe('persist-failed')
+ })
+})
+
+describe('ingest does not pair in-renderer', () => {
+ it('never imports connect logic — persistence + relaunch only', () => {
+ // Guard: the design intentionally reuses the boot-time pairing path.
+ // If someone wires connectNewSeed here, this comment + the ingest source
+ // should be revisited together.
+ expect(ingestScannedSeed).toBeTypeOf('function')
+ })
+})
diff --git a/apps/machine/src/services/pairing/index.ts b/apps/machine/src/services/pairing/index.ts
new file mode 100644
index 0000000..faaad95
--- /dev/null
+++ b/apps/machine/src/services/pairing/index.ts
@@ -0,0 +1,32 @@
+/**
+ * Pairing module surface (aiolabs/bitspire#52).
+ *
+ * `availablePairingSources()` probes each known source and returns those the
+ * current device can actually run, in preference order (camera first, NFC if
+ * present). The wizard renders the first available source and offers the rest
+ * as alternates.
+ */
+
+import { QrPairingSource } from './qr-source'
+import { NfcPairingSource } from './nfc-source'
+import type { PairingSource } from './types'
+
+export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
+export { QrPairingSource } from './qr-source'
+export { NfcPairingSource } from './nfc-source'
+export { ingestScannedSeed, parseScannedSeed } from './ingest'
+export type { IngestResult, SeedPreview } from './ingest'
+export { testRelay } from './relay-test'
+export type { RelayTestResult } from './relay-test'
+
+/** All sources in preference order, regardless of availability. */
+export function allPairingSources(): PairingSource[] {
+ return [new QrPairingSource(), new NfcPairingSource()]
+}
+
+/** Only the sources this device can run, in preference order. */
+export async function availablePairingSources(): Promise {
+ const sources = allPairingSources()
+ const flags = await Promise.all(sources.map((s) => s.isAvailable()))
+ return sources.filter((_, i) => flags[i])
+}
diff --git a/apps/machine/src/services/pairing/ingest.ts b/apps/machine/src/services/pairing/ingest.ts
new file mode 100644
index 0000000..dc22ad2
--- /dev/null
+++ b/apps/machine/src/services/pairing/ingest.ts
@@ -0,0 +1,94 @@
+/**
+ * Seed ingest pipeline (aiolabs/bitspire#52).
+ *
+ * Turns a raw scanned payload into a paired machine. The wizard captures a
+ * string off some PairingSource and hands it here; we:
+ * 1. validate it parses as a spire-seed (reject anything else — a QR on the
+ * counter, a URL, a different protocol),
+ * 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
+ * 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
+ * performs the actual bunker pairing.
+ *
+ * We do NOT pair in-renderer here: persisting + relaunching reuses the single,
+ * hardware-tested pairing path rather than duplicating connect/redeem logic in
+ * the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
+ * one-time provisioning step.
+ */
+
+import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
+
+export type IngestResult =
+ | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
+ | { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
+
+export type SeedPreview =
+ | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
+ | { ok: false; reason: 'invalid-seed'; message: string }
+
+/**
+ * Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
+ * relaunching. The wizard uses this to show a review step (decoded relay + a
+ * "test relay" button) before committing, so a well-formed but unreachable
+ * relay is caught before the machine relaunches into a pairing crash-loop.
+ * `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
+ * `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
+ */
+export function parseScannedSeed(raw: string): SeedPreview {
+ const trimmed = (raw || '').trim()
+ try {
+ const seed = parseSpireSeed(trimmed)
+ return {
+ ok: true,
+ spirePubkey: seed.spirePubkey,
+ fingerprint: seedFingerprint(trimmed),
+ relays: seed.relays,
+ }
+ } catch (e) {
+ return {
+ ok: false,
+ reason: 'invalid-seed',
+ message: e instanceof Error ? e.message : 'Not a valid pairing code',
+ }
+ }
+}
+
+export async function ingestScannedSeed(raw: string): Promise {
+ const trimmed = (raw || '').trim()
+
+ let spirePubkey: string
+ let relays: string[]
+ try {
+ const seed = parseSpireSeed(trimmed)
+ spirePubkey = seed.spirePubkey
+ relays = seed.relays
+ } catch (e) {
+ return {
+ ok: false,
+ reason: 'invalid-seed',
+ message: e instanceof Error ? e.message : 'Not a valid pairing code',
+ }
+ }
+
+ if (typeof window === 'undefined' || !window.electronAPI) {
+ return {
+ ok: false,
+ reason: 'no-bridge',
+ message: 'Pairing must run on the machine (no kiosk bridge available).',
+ }
+ }
+
+ try {
+ await window.electronAPI.saveSpireSeed(trimmed)
+ } catch (e) {
+ return {
+ ok: false,
+ reason: 'persist-failed',
+ message: e instanceof Error ? e.message : 'Could not save the pairing.',
+ }
+ }
+
+ // Fire-and-forget: the relaunch tears this process down.
+ void window.electronAPI.relaunchApp()
+
+ return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
+}
diff --git a/apps/machine/src/services/pairing/nfc-source.ts b/apps/machine/src/services/pairing/nfc-source.ts
new file mode 100644
index 0000000..5291b3c
--- /dev/null
+++ b/apps/machine/src/services/pairing/nfc-source.ts
@@ -0,0 +1,67 @@
+/**
+ * NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
+ *
+ * The user flagged NFC as a plausible future pairing method (tap a tag/phone
+ * carrying the spire-seed). This wires the seam against the Web NFC API
+ * (`NDEFReader`) so a future build can light it up without reworking the
+ * wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
+ * for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
+ * and the wizard simply won't offer it.
+ *
+ * When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
+ * replace the body of `start()` with that driver — the PairingSource contract
+ * stays the same.
+ */
+
+import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
+
+// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
+interface NDEFReaderLike {
+ scan(): Promise
+ addEventListener(
+ type: 'reading',
+ listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
+ ): void
+ addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
+}
+
+function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
+ const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
+ return ctor ?? null
+}
+
+export class NfcPairingSource implements PairingSource {
+ readonly kind = 'nfc' as const
+ readonly label = 'NFC tap'
+
+ async isAvailable(): Promise {
+ return getNDEFReaderCtor() !== null
+ }
+
+ async start(opts: PairingSourceStartOptions): Promise {
+ const Ctor = getNDEFReaderCtor()
+ if (!Ctor) throw new Error('Web NFC unavailable on this device')
+
+ const reader = new Ctor()
+ const decoder = new TextDecoder()
+ let stopped = false
+
+ reader.addEventListener('reading', (event) => {
+ if (stopped) return
+ for (const record of event.message.records) {
+ if (record.recordType === 'text' && record.data) {
+ const raw = decoder.decode(record.data).trim()
+ if (raw) opts.onScan(raw)
+ }
+ }
+ })
+ reader.addEventListener('readingerror', (e) => opts.onError?.(e))
+
+ await reader.scan()
+ // Web NFC has no explicit stop; the AbortController form would, but the
+ // scaffold just flips a guard so late events are ignored after teardown.
+ return () => {
+ stopped = true
+ }
+ }
+}
diff --git a/apps/machine/src/services/pairing/qr-source.ts b/apps/machine/src/services/pairing/qr-source.ts
new file mode 100644
index 0000000..1628a4b
--- /dev/null
+++ b/apps/machine/src/services/pairing/qr-source.ts
@@ -0,0 +1,90 @@
+/**
+ * Camera-based QR pairing source (aiolabs/bitspire#52).
+ *
+ * Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
+ * MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
+ * nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
+ * active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
+ * the per-frame decode loop, so this source is a thin adapter onto the
+ * PairingSource contract.
+ *
+ * The first successful decode wins; the loop then stops itself so a single
+ * seed isn't ingested repeatedly.
+ */
+
+import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
+import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
+
+export class QrPairingSource implements PairingSource {
+ readonly kind = 'qr' as const
+ readonly label = 'Camera'
+
+ async isAvailable(): Promise {
+ return (
+ typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof navigator.mediaDevices.getUserMedia === 'function'
+ )
+ }
+
+ async start(opts: PairingSourceStartOptions): Promise {
+ const { onScan, onError, video } = opts
+ if (!video) throw new Error('QrPairingSource requires a