- 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 20517e9..b36f6f2 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, Signer } from '@bitSpire/nostr-client'
+import type { NostrClient, MachineIdentity } 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
- signer: Signer
+ identity: MachineIdentity
/** 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, signer, inventory, balanceSats, fiatCode, model } = options
+ const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options
let lastSnapshot: AvailabilitySnapshot | null = null
@@ -73,22 +73,19 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
model,
})
- // 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.
+ const event = createSignedEvent(identity, {
+ kind: 30078,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['d', 'atm-availability']],
+ content,
+ })
+
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] Publish failed (sign or relay):', e)
+ console.warn('[Availability] Failed to publish:', e)
}
}
diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts
index cffdaa3..9a22ab8 100644
--- a/apps/machine/src/config/device.ts
+++ b/apps/machine/src/config/device.ts
@@ -139,21 +139,11 @@ 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 f48fda8..1e20cc3 100644
--- a/apps/machine/src/services/hal.ts
+++ b/apps/machine/src/services/hal.ts
@@ -109,12 +109,6 @@ export async function initializeHalServices(config: HalConfig): Promise = {}
for (const cassette of dispConfig.cassettes) {
@@ -212,13 +206,10 @@ 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')
})
@@ -271,19 +248,8 @@ export async function initializeHalServices(config: HalConfig): Promise {
- 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()
- },
+ stackBill: () => validator.stack(),
+ rejectBill: () => 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
deleted file mode 100644
index bbeb117..0000000
--- a/apps/machine/src/services/init-error.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * 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 eb2e1c7..1466e20 100644
--- a/apps/machine/src/services/lightning.ts
+++ b/apps/machine/src/services/lightning.ts
@@ -12,8 +12,12 @@
* the customer's invoice.
*/
-import { NostrClient, type Signer } from '@bitSpire/nostr-client'
-import { resolveSigner } from './signer-resolver.js'
+import {
+ NostrClient,
+ generateIdentity,
+ loadIdentityFromHex,
+ type MachineIdentity,
+} from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits'
import { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
@@ -33,12 +37,14 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
*
* Environment variables:
* - VITE_RELAY_URL: Nostr relay WebSocket URL
- * - 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)
+ * - 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)
*/
interface LightningConfig {
relayUrl: string
+ atmPrivateKey: string
appId: string
operatorPubkeys: string[]
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
@@ -54,10 +60,8 @@ interface LightningConfig {
*/
async function loadLightningConfig(): Promise {
const defaults: LightningConfig = {
- // 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: '',
+ relayUrl: 'ws://localhost:7777',
+ atmPrivateKey: '',
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [],
lnbitsServerPubkey: '',
@@ -66,8 +70,10 @@ 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
@@ -84,6 +90,7 @@ 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) ||
@@ -100,10 +107,6 @@ 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. */
@@ -116,27 +119,33 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000
/** Active LNURL-withdraw session */
interface LnurlSession {
sessionId: string
- /** Link ID — the management + settlement-watch key (delete/subscribe). */
+ /** Link ID for management operations (delete/update) */
linkId: string
+ uniqueHash: string
satsAmount: number
status: 'active' | 'claimed' | 'expired'
createdAt: number
cleanup?: () => void
}
-/** Map of linkId -> LNURL session data. Keyed on link_id since the secure
- * `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */
+/** Map of uniqueHash -> LNURL session data */
const lnurlSessions = new Map()
/**
- * Register a new LNURL-withdraw session, keyed by linkId.
+ * Register a new LNURL-withdraw session
*/
-function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void {
- console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats')
+function registerLnurlSession(
+ sessionId: string,
+ linkId: string,
+ uniqueHash: string,
+ satsAmount: number,
+): void {
+ console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
- lnurlSessions.set(linkId, {
+ lnurlSessions.set(uniqueHash, {
sessionId,
linkId,
+ uniqueHash,
satsAmount,
status: 'active',
createdAt: Date.now(),
@@ -144,10 +153,10 @@ function registerLnurlSession(sessionId: string, linkId: string, satsAmount: num
// Safety timeout — normally cleaned up by state machine on idle transition.
setTimeout(() => {
- const session = lnurlSessions.get(linkId)
+ const session = lnurlSessions.get(uniqueHash)
if (session && session.status === 'active') {
- console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId)
- expireLnurlSession(linkId)
+ console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
+ expireLnurlSession(uniqueHash)
}
}, SESSION_SAFETY_TIMEOUT_MS)
}
@@ -155,23 +164,23 @@ function registerLnurlSession(sessionId: string, linkId: string, satsAmount: num
/** 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 [linkId, session] of lnurlSessions.entries()) {
+ for (const [hash, session] of lnurlSessions.entries()) {
if (session.sessionId === sessionId && session.status === 'active') {
- console.log('[LNURL Session] Invalidating previous session:', linkId)
- expireLnurlSession(linkId)
+ console.log('[LNURL Session] Invalidating previous session:', hash)
+ expireLnurlSession(hash)
}
}
}
/** Expire a single LNURL session via its cleanup closure. */
-function expireLnurlSession(linkId: string): void {
- const session = lnurlSessions.get(linkId)
+function expireLnurlSession(uniqueHash: string): void {
+ const session = lnurlSessions.get(uniqueHash)
if (!session || session.status !== 'active') return
- console.log('[LNURL Session] Expiring:', linkId)
+ console.log('[LNURL Session] Expiring:', uniqueHash)
session.status = 'expired'
if (session.cleanup) session.cleanup()
- setTimeout(() => lnurlSessions.delete(linkId), 60000)
+ setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
}
let _lnbitsRef: LnbitsClient | null = null
@@ -225,7 +234,7 @@ interface LightningServices {
nostrClient: NostrClient
lightningPub: LightningBackend
clink: CLINKClient
- signer: Signer
+ identity: MachineIdentity
/** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */
operatorPubkeys: string[]
atmServices: ATMServices
@@ -402,85 +411,50 @@ export async function initializeLightningServices(options?: {
// Load configuration (async for Electron runtime config)
CONFIG = await loadLightningConfig()
- // 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)
+ console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
+ console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
- // 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).
+ // Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
if (options?.strict) {
const errors: string[] = []
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
- errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)')
+ 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)')
}
if (!CONFIG.lnbitsServerPubkey) {
- errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)')
+ errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
}
if (errors.length > 0) {
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
}
}
- // 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.
+ // Validate required configuration
if (!CONFIG.lnbitsServerPubkey) {
throw new Error(
- '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
- 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
+ '[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
+ 'Get it from: docker logs lnbits | grep nostr_transport pubkey',
)
}
+ // 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: relays.map((url) => ({ url })),
- signer,
+ relays: [{ url: CONFIG.relayUrl }],
+ identity,
})
await nostrClient.connect()
@@ -489,9 +463,9 @@ export async function initializeLightningServices(options?: {
// LNbits nostr-transport client.
const lnbits = new LnbitsClient({
serverPubkey: CONFIG.lnbitsServerPubkey,
- relays,
+ relays: [CONFIG.relayUrl],
})
- lnbits.initialize(nostrClient, signer)
+ lnbits.initialize(nostrClient, identity)
_lnbitsRef = lnbits
console.log('[Lightning] LNbits client initialized')
@@ -505,54 +479,14 @@ 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,
- signer,
+ identity,
operatorPubkey: CONFIG.operatorPubkeys,
- relays,
+ relays: [CONFIG.relayUrl],
})
// Callbacks for events
@@ -640,6 +574,7 @@ export async function initializeLightningServices(options?: {
}
const atmServices = createATMServices(
+ identity,
(preimage) => {
if (paymentReceivedCallback) {
paymentReceivedCallback(preimage)
@@ -653,7 +588,7 @@ export async function initializeLightningServices(options?: {
nostrClient,
lightningPub,
clink,
- signer,
+ identity,
operatorPubkeys: CONFIG.operatorPubkeys,
atmServices,
onOfferRequest: (callback: OfferRequestCallback) => {
@@ -682,6 +617,7 @@ 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,
@@ -725,70 +661,57 @@ function createATMServices(
* over nostr, we trigger dispense.
*/
generateLnurlWithdraw: async (context: ATMContext): Promise => {
- // 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})`
- )
+ console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
try {
if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId)
}
- // 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,
+ const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
- client_ref: context.txid ?? context.cashInSessionId ?? undefined,
+ min_withdrawable: context.satsAmount,
+ max_withdrawable: context.satsAmount,
+ uses: 1,
+ wait_time: 1,
+ is_unique: false,
})
if (!link.lnurl) {
throw new Error(
- '[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server'
+ '[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)'
)
}
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) {
- // Track the NET (what the customer withdraws); keyed by link_id.
- registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats)
+ registerLnurlSession(
+ context.cashInSessionId,
+ link.id,
+ link.unique_hash,
+ context.satsAmount,
+ )
const subId = await lnbits.subscribePayments(
lnbitsWalletId,
- { tag: 'withdraw', link_id: link.link_id, max_seconds: 600 },
+ { tag: 'withdraw', link_id: link.id, max_seconds: 600 },
(push) => {
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
- const session = lnurlSessions.get(link.link_id)
+ const session = lnurlSessions.get(link.unique_hash)
if (session) {
session.status = 'claimed'
- lnurlSessions.delete(link.link_id)
+ lnurlSessions.delete(link.unique_hash)
}
if (onPaymentCallback) {
- onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
+ onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`)
}
},
)
// Wire per-session cleanup so abort/expiry tears it down cleanly.
- const session = lnurlSessions.get(link.link_id)
+ const session = lnurlSessions.get(link.unique_hash)
if (session) {
session.cleanup = () => {
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
- void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {})
+ void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
}
}
}
diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts
index d853114..c9b26f8 100644
--- a/apps/machine/src/services/operator-config.ts
+++ b/apps/machine/src/services/operator-config.ts
@@ -23,10 +23,12 @@
*/
import {
- type Signer,
+ type MachineIdentity,
type NostrClient,
type Event,
createSignedEvent,
+ decryptContentV2,
+ encryptContentV2,
validateEvent,
} from '@bitSpire/nostr-client'
@@ -45,28 +47,17 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorConfigServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient
- /** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */
- signer: Signer
+ /** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */
+ identity: MachineIdentity
/** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[]
- /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
+ /** Machine identifier for the d-tag. Defaults to identity.publicKey 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(
@@ -74,14 +65,14 @@ export async function startOperatorConfigService(
): Promise {
if (cfg.operatorPubkeys.length === 0) {
console.log('[OperatorConfig] No operator pubkeys configured — service disabled')
- return NOOP_SERVICE
+ return { stop: () => {} }
}
if (!isElectron || !window.electronAPI) {
console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)')
- return NOOP_SERVICE
+ return { stop: () => {} }
}
const api = window.electronAPI
- const machineId = cfg.machineId ?? cfg.signer.pubkey
+ const machineId = cfg.machineId ?? cfg.identity.publicKey
// Bootstrap hello-event on first boot (best-effort — failure leaves the
// gate null so the next boot retries).
@@ -97,7 +88,7 @@ export async function startOperatorConfigService(
[
{
kinds: [KIND_NIP78],
- '#p': [cfg.signer.pubkey],
+ '#p': [cfg.identity.publicKey],
'#d': [dTag],
authors: cfg.operatorPubkeys,
},
@@ -114,12 +105,6 @@ 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)
- }),
}
}
@@ -165,7 +150,7 @@ async function handleOperatorConfigEvent(
// 4. Decrypt content (NIP-44 v2).
let parsed: { positions: Record }
try {
- const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
+ const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content)
parsed = JSON.parse(plaintext) as typeof parsed
} catch (err) {
console.error('[OperatorConfig] Decrypt/parse failed:', err)
@@ -210,63 +195,8 @@ 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)
- )
}
-/**
- * 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 cassettes = await api.loadCassettes()
- if (cassettes.length === 0) return false
- const operatorPubkey = cfg.operatorPubkeys[0]
- if (!operatorPubkey) return false
-
- const positions: Record = {}
- for (const c of cassettes) {
- positions[String(c.position)] = { denomination: c.denomination, count: c.count }
- }
- const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions }))
-
- const dTag = atmStateDTag(machineId)
- const event = await createSignedEvent(cfg.signer, {
- kind: KIND_NIP78,
- content: ciphertext,
- tags: [
- ['d', dTag],
- ['p', operatorPubkey],
- ],
- created_at: Math.floor(Date.now() / 1000),
- })
-
- await cfg.nostrClient.publish(event)
- 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,
@@ -277,11 +207,36 @@ async function maybePublishBootstrap(
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')
+ const cassettes = await api.loadCassettes()
+ if (cassettes.length === 0) {
+ console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap')
+ return
}
+
+ const operatorPubkey = cfg.operatorPubkeys[0]
+ if (!operatorPubkey) {
+ console.log('[OperatorConfig] No operator pubkey — skipping bootstrap')
+ return
+ }
+
+ 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 dTag = atmStateDTag(machineId)
+ const event = createSignedEvent(cfg.identity, {
+ kind: KIND_NIP78,
+ content: ciphertext,
+ tags: [
+ ['d', dTag],
+ ['p', operatorPubkey],
+ ],
+ created_at: Math.floor(Date.now() / 1000),
+ })
+
+ await cfg.nostrClient.publish(event)
+ await api.markBootstrapPublished(Math.floor(Date.now() / 1000))
+ console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id })
}
diff --git a/apps/machine/src/services/operator-fees.ts b/apps/machine/src/services/operator-fees.ts
index 4e699a3..8581ce8 100644
--- a/apps/machine/src/services/operator-fees.ts
+++ b/apps/machine/src/services/operator-fees.ts
@@ -56,9 +56,10 @@
*/
import {
- type Signer,
+ type MachineIdentity,
type NostrClient,
type Event,
+ decryptContentV2,
validateEvent,
} from '@bitSpire/nostr-client'
@@ -79,11 +80,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
export interface OperatorFeesServiceConfig {
/** Connected NostrClient — shared with the Lightning service. */
nostrClient: NostrClient
- /** Signer for the ATM identity. Decrypts operator events. */
- signer: Signer
+ /** ATM's nostr identity. Used to decrypt operator events. */
+ identity: MachineIdentity
/** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */
operatorPubkeys: string[]
- /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
+ /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */
machineId?: string
/**
* Called when a valid fee-config event is applied. Renderer should
@@ -111,7 +112,7 @@ export async function startOperatorFeesService(
return { stop: () => {} }
}
const api = window.electronAPI
- const machineId = cfg.machineId ?? cfg.signer.pubkey
+ const machineId = cfg.machineId ?? cfg.identity.publicKey
// Subscribe to operator-published fee config events.
const dTag = feeConfigDTag(machineId)
@@ -119,7 +120,7 @@ export async function startOperatorFeesService(
[
{
kinds: [KIND_NIP78],
- '#p': [cfg.signer.pubkey],
+ '#p': [cfg.identity.publicKey],
'#d': [dTag],
authors: cfg.operatorPubkeys,
},
@@ -188,7 +189,7 @@ async function handleFeeConfigEvent(
// fields (v2 forward-compat — future promo payloads).
let parsed: ParsedFeePayload
try {
- const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content)
+ const plaintext = decryptContentV2(cfg.identity, 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
deleted file mode 100644
index f6c069e..0000000
--- a/apps/machine/src/services/pairing/__tests__/ingest.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-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
deleted file mode 100644
index faaad95..0000000
--- a/apps/machine/src/services/pairing/index.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * 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
deleted file mode 100644
index dc22ad2..0000000
--- a/apps/machine/src/services/pairing/ingest.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 5291b3c..0000000
--- a/apps/machine/src/services/pairing/nfc-source.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 1628a4b..0000000
--- a/apps/machine/src/services/pairing/qr-source.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * 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