feat: operator command channel via Nostr (manual dispense)
Add a Nostr-native operator command channel using Kind 21003 (CLINK Manage) events. Operators listed in OPERATOR_PUBKEYS can send encrypted commands to the machine. Phase 1 implements manual dispense: operator sends a dispense command, machine verifies sender, checks it's idle, performs a direct HAL dispense (bypassing state machine), and records the transaction. When ref_txid is provided, the referenced failed transaction is updated to status 'remediated', closing the loop on dispense errors. Changes: - CLINK types: add 'machine' resource, MachineDispenseRequest type - CLINK client: support operator pubkey list (string | string[]) - Runtime config: VITE_OPERATOR_PUBKEYS env var - Schema v4→v5: manual_dispense type, remediated_by column - Lightning services: wire onManagement callback - ATM store: handleManagementCommand with idle check + remediation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
88a52cf769
commit
e03782803b
11 changed files with 274 additions and 24 deletions
|
|
@ -41,6 +41,14 @@ VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
|
|||
# If not set, generates ephemeral identity on each restart
|
||||
VITE_ATM_PRIVATE_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# Operator Identity
|
||||
# =============================================================================
|
||||
|
||||
# Comma-separated list of Nostr hex pubkeys authorized to send operator commands
|
||||
# (manual dispense, remote management). Decoupled from Lightning.Pub identity.
|
||||
# VITE_OPERATOR_PUBKEYS=abcd1234...,ef567890...
|
||||
|
||||
# =============================================================================
|
||||
# Mock Fallback (Production Safety)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
setCassettes,
|
||||
getInventory,
|
||||
recordTransaction,
|
||||
remediateTransaction,
|
||||
emptyCashbox,
|
||||
getCashbox,
|
||||
} from './state-store.js'
|
||||
|
|
@ -180,6 +181,9 @@ ipcMain.handle('get-config', () => {
|
|||
// SECURITY: In production (packaged app), mock fallback is always disabled.
|
||||
// Only allow it in development mode, and only when explicitly opted in via env.
|
||||
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
|
||||
|
||||
// Operator identity (comma-separated hex pubkeys)
|
||||
operatorPubkeys: process.env.VITE_OPERATOR_PUBKEYS || '',
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -213,6 +217,9 @@ ipcMain.handle('state:get-inventory', () => getInventory())
|
|||
ipcMain.handle('state:get-cashbox', () => getCashbox())
|
||||
ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx))
|
||||
ipcMain.handle('state:empty-cashbox', () => emptyCashbox())
|
||||
ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedByTxid: string) =>
|
||||
remediateTransaction(txid, remediatedByTxid)
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// HAL Hardware IPC Handlers
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface RuntimeConfig {
|
|||
dispenserDevice?: string
|
||||
cassettes?: string
|
||||
allowMockFallback: boolean
|
||||
operatorPubkeys: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -54,8 +55,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
|
||||
recordTransaction: (tx: {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
status: 'complete' | 'dispense_error' | 'partial'
|
||||
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
feeSats: number
|
||||
|
|
@ -74,6 +75,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
error?: string | null
|
||||
}) => ipcRenderer.invoke('state:record-transaction', tx),
|
||||
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
|
||||
|
||||
// HAL hardware (runs in main process, exposed via IPC)
|
||||
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
|
||||
|
|
@ -147,6 +150,7 @@ declare global {
|
|||
error?: string | null
|
||||
}) => Promise<void>
|
||||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
// HAL hardware IPC
|
||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||
halDispense: (amounts: any) => Promise<any>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
|||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '4'
|
||||
const SCHEMA_VERSION = '5'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/lamassu-atm'
|
||||
|
|
@ -65,7 +65,7 @@ export function initDatabase(dbPath?: string): void {
|
|||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
txid TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')),
|
||||
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
|
||||
fiat_cents INTEGER NOT NULL,
|
||||
sats INTEGER NOT NULL,
|
||||
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||||
|
|
@ -74,6 +74,7 @@ export function initDatabase(dbPath?: string): void {
|
|||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||||
status TEXT NOT NULL DEFAULT 'complete',
|
||||
error TEXT,
|
||||
remediated_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
|
@ -143,6 +144,34 @@ export function initDatabase(dbPath?: string): void {
|
|||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
|
||||
existing.value = '4'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '4') {
|
||||
// Migration v4 → v5: add manual_dispense type and remediated_by column
|
||||
// SQLite cannot ALTER CHECK constraints, so recreate the table
|
||||
db.exec(`
|
||||
CREATE TABLE transactions_new (
|
||||
txid TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
|
||||
fiat_cents INTEGER NOT NULL,
|
||||
sats INTEGER NOT NULL,
|
||||
fee_sats INTEGER NOT NULL DEFAULT 0,
|
||||
fee_percent REAL NOT NULL DEFAULT 0,
|
||||
exchange_rate REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||||
status TEXT NOT NULL DEFAULT 'complete',
|
||||
error TEXT,
|
||||
remediated_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO transactions_new SELECT txid, type, fiat_cents, sats, fee_sats, fee_percent,
|
||||
exchange_rate, currency, status, error, NULL, created_at FROM transactions;
|
||||
DROP TABLE transactions;
|
||||
ALTER TABLE transactions_new RENAME TO transactions;
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('5', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by)')
|
||||
}
|
||||
|
||||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||
|
|
@ -269,8 +298,8 @@ export function emptyCashbox(): void {
|
|||
|
||||
interface TransactionInput {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
status: 'complete' | 'dispense_error' | 'partial'
|
||||
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
feeSats: number
|
||||
|
|
@ -372,6 +401,26 @@ export function recordTransaction(tx: TransactionInput): void {
|
|||
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a failed transaction as remediated by a manual dispense.
|
||||
* Only updates transactions with status 'dispense_error' or 'partial'.
|
||||
* Returns true if the transaction was updated, false if not found or not in error state.
|
||||
*/
|
||||
export function remediateTransaction(txid: string, remediatedByTxid: string): boolean {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
'UPDATE transactions SET status = ?, remediated_by = ? WHERE txid = ? AND status IN (?, ?)'
|
||||
)
|
||||
.run('remediated', remediatedByTxid, txid, 'dispense_error', 'partial')
|
||||
|
||||
if (result.changes > 0) {
|
||||
console.log('[StateStore] Remediated transaction:', txid, '→', remediatedByTxid)
|
||||
}
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database (for clean shutdown).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
encodeNdebit,
|
||||
formatNdebitUri,
|
||||
} from '@lamassu/clink'
|
||||
import type { OfferRequest } from '@lamassu/clink'
|
||||
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@lamassu/clink'
|
||||
import type { ATMServices, ATMContext } from '@lamassu/state-machine'
|
||||
|
||||
// Import Electron types
|
||||
|
|
@ -62,6 +62,7 @@ interface LightningConfig {
|
|||
adminToken: string
|
||||
atmPrivateKey: string
|
||||
appId: string
|
||||
operatorPubkeys: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,6 +82,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
adminToken: 'lamassu-dev-admin-token',
|
||||
atmPrivateKey: '',
|
||||
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
|
||||
operatorPubkeys: [],
|
||||
}
|
||||
|
||||
// In Electron, get runtime config from main process
|
||||
|
|
@ -97,6 +99,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
adminToken: secrets.adminToken || defaults.adminToken,
|
||||
atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey,
|
||||
appId: runtimeConfig.appId || defaults.appId,
|
||||
operatorPubkeys: runtimeConfig.operatorPubkeys
|
||||
? runtimeConfig.operatorPubkeys
|
||||
.split(',')
|
||||
.map((k: string) => k.trim())
|
||||
.filter(Boolean)
|
||||
: defaults.operatorPubkeys,
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Lightning] Failed to get runtime config from Electron:', e)
|
||||
|
|
@ -112,6 +120,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
|
||||
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
|
||||
appId: import.meta.env.VITE_APP_ID || defaults.appId,
|
||||
operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS
|
||||
? (import.meta.env.VITE_OPERATOR_PUBKEYS as string)
|
||||
.split(',')
|
||||
.map((k: string) => k.trim())
|
||||
.filter(Boolean)
|
||||
: defaults.operatorPubkeys,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -669,6 +683,13 @@ interface LightningServices {
|
|||
onDebitPaymentApproved: (callback: DebitPaymentCallback) => void
|
||||
/** Stop the debit approval service */
|
||||
stopDebitApproval: () => void
|
||||
/** Set callback for operator management commands (Kind 21003) */
|
||||
onManagement: (
|
||||
callback: (
|
||||
request: ManagementRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<ManagementResponse | null>
|
||||
) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -896,7 +917,7 @@ export async function initializeLightningServices(options?: {
|
|||
const clink = new CLINKClient({
|
||||
nostrClient,
|
||||
identity,
|
||||
operatorPubkey: CONFIG.lightningPubPubkey, // Use Lightning.Pub as operator for dev
|
||||
operatorPubkey: [CONFIG.lightningPubPubkey, ...CONFIG.operatorPubkeys].filter(Boolean),
|
||||
relays: [CONFIG.relayUrl],
|
||||
})
|
||||
|
||||
|
|
@ -904,6 +925,9 @@ export async function initializeLightningServices(options?: {
|
|||
let offerRequestCallback: OfferRequestCallback | null = null
|
||||
let paymentReceivedCallback: PaymentReceivedCallback | null = null
|
||||
let debitPaymentCallback: DebitPaymentCallback | null = null
|
||||
let managementCallback:
|
||||
| ((request: ManagementRequest, senderPubkey: string) => Promise<ManagementResponse | null>)
|
||||
| null = null
|
||||
|
||||
// Start the debit approval service
|
||||
const stopDebitApproval = startDebitApprovalService(
|
||||
|
|
@ -968,8 +992,17 @@ export async function initializeLightningServices(options?: {
|
|||
}
|
||||
})
|
||||
|
||||
// Set up management command handler (Kind 21003, resource='machine')
|
||||
clink.onManagement(async (request, senderPubkey) => {
|
||||
console.log('[CLINK] Received management command from', senderPubkey.slice(0, 16) + '...')
|
||||
if (managementCallback) {
|
||||
return managementCallback(request, senderPubkey)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
clink.startListening()
|
||||
console.log('[Lightning] CLINK client initialized with offer handler')
|
||||
console.log('[Lightning] CLINK client initialized with offer + management handlers')
|
||||
|
||||
// Create ATM services using Lightning.Pub's native LNURL-withdraw
|
||||
const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => {
|
||||
|
|
@ -994,6 +1027,14 @@ export async function initializeLightningServices(options?: {
|
|||
debitPaymentCallback = callback
|
||||
},
|
||||
stopDebitApproval,
|
||||
onManagement: (
|
||||
callback: (
|
||||
request: ManagementRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<ManagementResponse | null>
|
||||
) => {
|
||||
managementCallback = callback
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning
|
|||
import type { HalConfig, HalServices } from '@/services/hal'
|
||||
import type { MachineModel } from '@/config'
|
||||
import type { LightningPubClient } from '@lamassu/lightning'
|
||||
import type { CLINKClient } from '@lamassu/clink'
|
||||
import {
|
||||
isMachineDispenseRequest,
|
||||
GFYCode,
|
||||
type CLINKClient,
|
||||
type ManagementRequest,
|
||||
type ManagementResponse,
|
||||
} from '@lamassu/clink'
|
||||
import type { TransactionRecord } from '@/types/state'
|
||||
|
||||
// Check if we're running in Electron
|
||||
|
|
@ -31,6 +37,85 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
|||
return Math.max(0, feeSats)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an operator management command (Kind 21003, resource='machine').
|
||||
* Performs a direct HAL dispense when idle, bypassing the state machine.
|
||||
*/
|
||||
async function handleManagementCommand(
|
||||
request: ManagementRequest,
|
||||
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
|
||||
machineIdle: boolean,
|
||||
currency: string
|
||||
): Promise<ManagementResponse | null> {
|
||||
if (!isMachineDispenseRequest(request)) return null
|
||||
|
||||
console.log('[ATM] Manual dispense command received')
|
||||
|
||||
if (!machineIdle) {
|
||||
return {
|
||||
res: 'GFY',
|
||||
code: GFYCode.RequestDenied,
|
||||
error: 'Machine is not idle',
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.bills || request.bills.length === 0) {
|
||||
return {
|
||||
res: 'GFY',
|
||||
code: GFYCode.InvalidRequest,
|
||||
error: 'No bills specified',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dispenseFn(request.bills)
|
||||
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const totalFiatCents = request.bills.reduce((sum, b) => sum + b.denomination * b.count * 100, 0)
|
||||
|
||||
await persistTransaction({
|
||||
txid,
|
||||
type: 'manual_dispense',
|
||||
status: result.dispensed ? 'complete' : 'dispense_error',
|
||||
fiatCents: totalFiatCents,
|
||||
sats: 0,
|
||||
feeSats: 0,
|
||||
feePercent: 0,
|
||||
exchangeRate: 0,
|
||||
currency,
|
||||
bills: request.bills,
|
||||
cassettes: result.cassettes,
|
||||
error: result.error,
|
||||
})
|
||||
|
||||
// Remediate referenced failed transaction
|
||||
let refRemediated = false
|
||||
if (request.ref_txid && isElectron && window.electronAPI) {
|
||||
refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
|
||||
if (refRemediated) {
|
||||
console.log('[ATM] Remediated failed tx:', request.ref_txid)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
res: 'ok',
|
||||
resource: 'machine',
|
||||
details: {
|
||||
txid,
|
||||
dispensed: result.bills,
|
||||
error: result.error,
|
||||
ref_txid_remediated: refRemediated,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ATM] Manual dispense failed:', error)
|
||||
return {
|
||||
res: 'GFY',
|
||||
code: GFYCode.TemporaryFailure,
|
||||
error: error instanceof Error ? error.message : 'Dispense failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load inventory from SQLite via IPC (Electron only).
|
||||
* Returns empty object in browser dev mode.
|
||||
|
|
@ -430,6 +515,16 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
services.stopDebitApproval()
|
||||
}
|
||||
|
||||
// Wire operator management commands (Lightning-only mode, mock dispense)
|
||||
services.onManagement(async (request) => {
|
||||
return handleManagementCommand(
|
||||
request,
|
||||
(amounts) => services.atmServices.dispenseCash(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value
|
||||
)
|
||||
})
|
||||
|
||||
// Inject DB-backed inventory into services
|
||||
const servicesWithInventory: ATMServices = {
|
||||
...services.atmServices,
|
||||
|
|
@ -657,6 +752,16 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
lightning.stopDebitApproval()
|
||||
}
|
||||
|
||||
// Wire operator management commands (manual dispense via direct HAL)
|
||||
lightning.onManagement(async (request) => {
|
||||
return handleManagementCommand(
|
||||
request,
|
||||
(amounts) => hal.atmServices.dispenseCash(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value
|
||||
)
|
||||
})
|
||||
|
||||
// Merge HAL hardware services with Lightning payment services
|
||||
const mergedServices: ATMServices = {
|
||||
...lightning.atmServices,
|
||||
|
|
@ -897,6 +1002,16 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
lightning.stopDebitApproval()
|
||||
}
|
||||
|
||||
// Wire operator management commands (manual dispense via IPC HAL)
|
||||
lightning.onManagement(async (request) => {
|
||||
return handleManagementCommand(
|
||||
request,
|
||||
(amounts) => api.halDispense(amounts),
|
||||
isIdle.value,
|
||||
fiatCode.value
|
||||
)
|
||||
})
|
||||
|
||||
// Merge HAL hardware services with Lightning payment services
|
||||
const mergedServices: ATMServices = {
|
||||
...lightning.atmServices,
|
||||
|
|
|
|||
6
apps/machine/src/types/electron.d.ts
vendored
6
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -14,6 +14,7 @@ export interface RuntimeConfig {
|
|||
dispenserDevice?: string
|
||||
cassettes?: string
|
||||
allowMockFallback: boolean
|
||||
operatorPubkeys: string
|
||||
}
|
||||
|
||||
export interface AtmSecrets {
|
||||
|
|
@ -37,8 +38,8 @@ declare global {
|
|||
}>
|
||||
recordTransaction: (tx: {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
status: 'complete' | 'dispense_error' | 'partial'
|
||||
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
feeSats: number
|
||||
|
|
@ -57,6 +58,7 @@ declare global {
|
|||
error?: string | null
|
||||
}) => Promise<void>
|
||||
emptyCashbox: () => Promise<void>
|
||||
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
|
||||
// HAL hardware IPC
|
||||
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
|
||||
halDispense: (amounts: any) => Promise<any>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ export interface CashboxState {
|
|||
|
||||
export interface TransactionRecord {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
status: 'complete' | 'dispense_error' | 'partial'
|
||||
type: 'cash_in' | 'cash_out' | 'manual_dispense'
|
||||
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
|
||||
fiatCents: number
|
||||
sats: number
|
||||
feeSats: number
|
||||
|
|
@ -33,6 +33,7 @@ export interface TransactionRecord {
|
|||
rejected: number
|
||||
}[]
|
||||
error?: string | null
|
||||
remediatedBy?: string | null
|
||||
}
|
||||
|
||||
export interface ATMAvailability {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue