Compare commits

..

No commits in common. "4ac640a49919a7ff55e61225123d017ef4cf3efa" and "9c4d2c132449c210d97e37f14153a3f6b99f9215" have entirely different histories.

28 changed files with 385 additions and 384 deletions

View file

@ -213,7 +213,7 @@ commission_amount = 266800 - 258835 = 7,965 sats (to commission wallet)
### Security Considerations ### Security Considerations
- **Superuser Authentication**: Admin extension requires LNBits superuser login - **Superuser Authentication**: Admin extension requires LNBits superuser login
- **Wallet Admin Keys**: Client extension uses wallet admin keys for user operations - **Wallet Admin Keys**: Client extension uses wallet admin keys for user operations
- **Database Access**: Only superusers can write to spirekeeper database - **Database Access**: Only superusers can write to satoshimachine database
- SSH tunnel encryption for database connectivity - SSH tunnel encryption for database connectivity
- Read-only database permissions for Lamassu access - Read-only database permissions for Lamassu access
- Input sanitization and type validation - Input sanitization and type validation
@ -230,7 +230,7 @@ on existing installs:
```sql ```sql
SELECT a.id, a.username, a.pubkey, m.id, m.machine_npub SELECT a.id, a.username, a.pubkey, m.id, m.machine_npub
FROM accounts a FROM accounts a
JOIN ext_spirekeeper.dca_machines m JOIN ext_satoshimachine.dca_machines m
ON LOWER(a.pubkey) = LOWER(m.machine_npub); ON LOWER(a.pubkey) = LOWER(m.machine_npub);
``` ```

View file

@ -188,7 +188,7 @@ The extension creates several tables:
├── config.json # Extension configuration ├── config.json # Extension configuration
├── manifest.json # Extension manifest ├── manifest.json # Extension manifest
├── templates/ ├── templates/
│ └── spirekeeper/ │ └── satmachineadmin/
│ └── index.html # Main UI template │ └── index.html # Main UI template
└── static/ └── static/
└── js/ └── js/

View file

@ -7,29 +7,29 @@ from loguru import logger
from .crud import db from .crud import db
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
from .views import spirekeeper_generic_router from .views import satmachineadmin_generic_router
from .views_api import spirekeeper_api_router from .views_api import satmachineadmin_api_router
logger.info("spirekeeper v2 loaded") logger.info("satmachineadmin v2 loaded")
spirekeeper_ext: APIRouter = APIRouter( satmachineadmin_ext: APIRouter = APIRouter(
prefix="/spirekeeper", tags=["DCA Admin"] prefix="/satmachineadmin", tags=["DCA Admin"]
) )
spirekeeper_ext.include_router(spirekeeper_generic_router) satmachineadmin_ext.include_router(satmachineadmin_generic_router)
spirekeeper_ext.include_router(spirekeeper_api_router) satmachineadmin_ext.include_router(satmachineadmin_api_router)
spirekeeper_static_files = [ satmachineadmin_static_files = [
{ {
"path": "/spirekeeper/static", "path": "/satmachineadmin/static",
"name": "spirekeeper_static", "name": "satmachineadmin_static",
} }
] ]
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
def spirekeeper_stop(): def satmachineadmin_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
try: try:
task.cancel() task.cancel()
@ -37,10 +37,10 @@ def spirekeeper_stop():
logger.warning(ex) logger.warning(ex)
def spirekeeper_start(): def satmachineadmin_start():
# bitSpire invoice listener — replaces the v1 SSH/PostgreSQL poller. # bitSpire invoice listener — replaces the v1 SSH/PostgreSQL poller.
invoice_task = create_permanent_unique_task( invoice_task = create_permanent_unique_task(
"ext_spirekeeper", wait_for_paid_invoices "ext_satmachineadmin", wait_for_paid_invoices
) )
scheduled_tasks.append(invoice_task) scheduled_tasks.append(invoice_task)
# Cassette bootstrap consumer (#29 v1) — subscribes to # Cassette bootstrap consumer (#29 v1) — subscribes to
@ -48,7 +48,7 @@ def spirekeeper_start():
# cassette_configs on receipt. Soft-fails if nostrclient isn't # cassette_configs on receipt. Soft-fails if nostrclient isn't
# installed (logs + backs off, never crashes). # installed (logs + backs off, never crashes).
cassette_task = create_permanent_unique_task( cassette_task = create_permanent_unique_task(
"ext_spirekeeper_cassette_bootstrap", wait_for_cassette_state_events "ext_satmachineadmin_cassette_bootstrap", wait_for_cassette_state_events
) )
scheduled_tasks.append(cassette_task) scheduled_tasks.append(cassette_task)
# Path-B wallet-routing hook (#20 / coord-log 2026-05-31T15:25Z): # Path-B wallet-routing hook (#20 / coord-log 2026-05-31T15:25Z):
@ -61,8 +61,8 @@ def spirekeeper_start():
__all__ = [ __all__ = [
"db", "db",
"spirekeeper_ext", "satmachineadmin_ext",
"spirekeeper_start", "satmachineadmin_start",
"spirekeeper_static_files", "satmachineadmin_static_files",
"spirekeeper_stop", "satmachineadmin_stop",
] ]

View file

@ -94,7 +94,7 @@ class SettlementMetadataError(ValueError):
Raised by `parse_settlement`. Caller records the settlement as Raised by `parse_settlement`. Caller records the settlement as
'rejected' with the exception message in `error_message`. Operator 'rejected' with the exception message in `error_message`. Operator
investigates the ATM that issued the invoice a bitSpire ATM that investigates the ATM that issued the invoice a bitSpire ATM that
landed on a spirekeeper-managed wallet without stamping the landed on a satmachineadmin-managed wallet without stamping the
canonical fields is a real upstream bug (lamassu-next side), not a canonical fields is a real upstream bug (lamassu-next side), not a
graceful-degradation case. Pre-v2 reverse-derivation from the graceful-degradation case. Pre-v2 reverse-derivation from the
wire amount + a machine-level fallback rate is no longer supported: wire amount + a machine-level fallback rate is no longer supported:
@ -279,7 +279,7 @@ def parse_settlement(
) )
# Phase-1 observability per aiolabs/satmachineadmin#38 + coord-log # Phase-1 observability per aiolabs/satmachineadmin#38 + coord-log
# §2026-06-01T07:00Z (option A locked): compare bitspire's reported # §2026-06-01T07:00Z (option A locked): compare bitspire's reported
# fee_sats against spirekeeper's recompute, log on out-of- # fee_sats against satmachineadmin's recompute, log on out-of-
# tolerance drift, record the delta unconditionally for triage. # tolerance drift, record the delta unconditionally for triage.
# Phase 2 (settlement-reject) lands after observability data. # Phase 2 (settlement-reject) lands after observability data.
fee_mismatch_sats = fee_sats - (platform_fee_sats + operator_fee_sats) fee_mismatch_sats = fee_sats - (platform_fee_sats + operator_fee_sats)

View file

@ -33,7 +33,7 @@ task (tasks.py) calls `decrypt_and_parse_state_event` per incoming event;
the API endpoint (views_api.py) calls `publish_to_atm` per operator submit. the API endpoint (views_api.py) calls `publish_to_atm` per operator submit.
The `<m>` placeholder semantics (load-bearing per the 2026-05-30T11:50Z The `<m>` placeholder semantics (load-bearing per the 2026-05-30T11:50Z
coord-log entry): always the ATM's hex pubkey, NEVER spirekeeper's coord-log entry): always the ATM's hex pubkey, NEVER satmachineadmin's
internal dca_machines.id UUID. Helper `_atm_hex_pubkey(machine)` internal dca_machines.id UUID. Helper `_atm_hex_pubkey(machine)`
centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key. centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key.
""" """
@ -146,7 +146,7 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
# ============================================================================= # =============================================================================
# Publish — operator → ATM (the spirekeeper API path) # Publish — operator → ATM (the satmachineadmin API path)
# ============================================================================= # =============================================================================

View file

@ -1,7 +1,7 @@
{ {
"name": "spirekeeper", "name": "DCA Admin",
"short_description": "Dollar Cost Averaging administration for Lamassu ATM integration", "short_description": "Dollar Cost Averaging administration for Lamassu ATM integration",
"tile": "/spirekeeper/static/image/aio.png", "tile": "/satmachineadmin/static/image/aio.png",
"min_lnbits_version": "1.0.0", "min_lnbits_version": "1.0.0",
"contributors": [ "contributors": [
{ {
@ -26,7 +26,7 @@
} }
], ],
"images": [], "images": [],
"description_md": "/spirekeeper/description.md", "description_md": "/satmachineadmin/description.md",
"terms_and_conditions_md": "/spirekeeper/toc.md", "terms_and_conditions_md": "/satmachineadmin/toc.md",
"license": "MIT" "license": "MIT"
} }

156
crud.py
View file

@ -38,7 +38,7 @@ from .models import (
UpsertDcaLpData, UpsertDcaLpData,
) )
db = Database("ext_spirekeeper") db = Database("ext_satoshimachine")
# ============================================================================= # =============================================================================
@ -48,7 +48,7 @@ db = Database("ext_spirekeeper")
async def get_super_config() -> SuperConfig | None: async def get_super_config() -> SuperConfig | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.super_config WHERE id = :id", "SELECT * FROM satoshimachine.super_config WHERE id = :id",
{"id": "default"}, {"id": "default"},
SuperConfig, SuperConfig,
) )
@ -62,7 +62,7 @@ async def update_super_config(data: UpdateSuperConfigData) -> SuperConfig | None
set_clause = ", ".join(f"{k} = :{k}" for k in update_data) set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["id"] = "default" update_data["id"] = "default"
await db.execute( await db.execute(
f"UPDATE spirekeeper.super_config SET {set_clause} WHERE id = :id", f"UPDATE satoshimachine.super_config SET {set_clause} WHERE id = :id",
update_data, update_data,
) )
return await get_super_config() return await get_super_config()
@ -78,7 +78,7 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
now = datetime.now() now = datetime.now()
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_machines INSERT INTO satoshimachine.dca_machines
(id, operator_user_id, machine_npub, wallet_id, name, location, (id, operator_user_id, machine_npub, wallet_id, name, location,
fiat_code, is_active, fiat_code, is_active,
operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, operator_cash_in_fee_fraction, operator_cash_out_fee_fraction,
@ -110,7 +110,7 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
async def get_machine(machine_id: str) -> Machine | None: async def get_machine(machine_id: str) -> Machine | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_machines WHERE id = :id", "SELECT * FROM satoshimachine.dca_machines WHERE id = :id",
{"id": machine_id}, {"id": machine_id},
Machine, Machine,
) )
@ -118,7 +118,7 @@ async def get_machine(machine_id: str) -> Machine | None:
async def get_machine_by_npub(machine_npub: str) -> Machine | None: async def get_machine_by_npub(machine_npub: str) -> Machine | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_machines WHERE machine_npub = :npub", "SELECT * FROM satoshimachine.dca_machines WHERE machine_npub = :npub",
{"npub": machine_npub}, {"npub": machine_npub},
Machine, Machine,
) )
@ -128,7 +128,7 @@ async def get_active_machine_by_wallet_id(wallet_id: str) -> Machine | None:
"""Used by the invoice listener to route an incoming payment to a machine.""" """Used by the invoice listener to route an incoming payment to a machine."""
return await db.fetchone( return await db.fetchone(
""" """
SELECT * FROM spirekeeper.dca_machines SELECT * FROM satoshimachine.dca_machines
WHERE wallet_id = :wid AND is_active = true WHERE wallet_id = :wid AND is_active = true
LIMIT 1 LIMIT 1
""", """,
@ -140,7 +140,7 @@ async def get_active_machine_by_wallet_id(wallet_id: str) -> Machine | None:
async def get_machines_for_operator(operator_user_id: str) -> list[Machine]: async def get_machines_for_operator(operator_user_id: str) -> list[Machine]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_machines SELECT * FROM satoshimachine.dca_machines
WHERE operator_user_id = :uid WHERE operator_user_id = :uid
ORDER BY created_at DESC ORDER BY created_at DESC
""", """,
@ -157,7 +157,7 @@ async def list_all_active_machines() -> list[Machine]:
""" """
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_machines SELECT * FROM satoshimachine.dca_machines
WHERE is_active = true WHERE is_active = true
ORDER BY created_at DESC ORDER BY created_at DESC
""", """,
@ -196,7 +196,7 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
set_clause = ", ".join(f"{k} = :{k}" for k in update_data) set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["id"] = machine_id update_data["id"] = machine_id
await db.execute( await db.execute(
f"UPDATE spirekeeper.dca_machines SET {set_clause} WHERE id = :id", f"UPDATE satoshimachine.dca_machines SET {set_clause} WHERE id = :id",
update_data, update_data,
) )
return await get_machine(machine_id) return await get_machine(machine_id)
@ -204,7 +204,7 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
async def delete_machine(machine_id: str) -> None: async def delete_machine(machine_id: str) -> None:
await db.execute( await db.execute(
"DELETE FROM spirekeeper.dca_machines WHERE id = :id", "DELETE FROM satoshimachine.dca_machines WHERE id = :id",
{"id": machine_id}, {"id": machine_id},
) )
@ -226,7 +226,7 @@ async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
now = datetime.now() now = datetime.now()
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_clients INSERT INTO satoshimachine.dca_clients
(id, machine_id, user_id, username, status, created_at, updated_at) (id, machine_id, user_id, username, status, created_at, updated_at)
VALUES (:id, :machine_id, :user_id, :username, :status, VALUES (:id, :machine_id, :user_id, :username, :status,
:created_at, :updated_at) :created_at, :updated_at)
@ -255,8 +255,8 @@ _CLIENT_SELECT = """
(lp.user_id IS NOT NULL) AS lp_onboarded (lp.user_id IS NOT NULL) AS lp_onboarded
""" """
_CLIENT_FROM = ( _CLIENT_FROM = (
"spirekeeper.dca_clients c " "satoshimachine.dca_clients c "
"LEFT JOIN spirekeeper.dca_lp lp ON lp.user_id = c.user_id" "LEFT JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id"
) )
@ -299,7 +299,7 @@ async def get_dca_clients_for_operator(operator_user_id: str) -> list[DcaClient]
f""" f"""
SELECT {_CLIENT_SELECT} SELECT {_CLIENT_SELECT}
FROM {_CLIENT_FROM} FROM {_CLIENT_FROM}
JOIN spirekeeper.dca_machines m ON m.id = c.machine_id JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
WHERE m.operator_user_id = :uid WHERE m.operator_user_id = :uid
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
""", """,
@ -332,8 +332,8 @@ async def get_flow_mode_clients_for_machine(machine_id: str) -> list[DcaClient]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT c.* SELECT c.*
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
JOIN spirekeeper.dca_lp lp ON lp.user_id = c.user_id JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id
WHERE c.machine_id = :machine_id WHERE c.machine_id = :machine_id
AND lp.default_dca_mode = 'flow' AND lp.default_dca_mode = 'flow'
AND c.status = 'active' AND c.status = 'active'
@ -353,7 +353,7 @@ async def get_dca_lp(user_id: str) -> DcaLpPreferences | None:
"""Return the LP's preferences row, or None if they haven't onboarded """Return the LP's preferences row, or None if they haven't onboarded
via satmachineclient yet.""" via satmachineclient yet."""
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_lp WHERE user_id = :uid", "SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid",
{"uid": user_id}, {"uid": user_id},
DcaLpPreferences, DcaLpPreferences,
) )
@ -362,7 +362,7 @@ async def get_dca_lp(user_id: str) -> DcaLpPreferences | None:
async def lp_is_onboarded(user_id: str) -> bool: async def lp_is_onboarded(user_id: str) -> bool:
"""Cheap existence check used by the deposit-creation gate.""" """Cheap existence check used by the deposit-creation gate."""
row = await db.fetchone( row = await db.fetchone(
"SELECT user_id FROM spirekeeper.dca_lp WHERE user_id = :uid", "SELECT user_id FROM satoshimachine.dca_lp WHERE user_id = :uid",
{"uid": user_id}, {"uid": user_id},
) )
return row is not None return row is not None
@ -392,7 +392,7 @@ async def upsert_dca_lp(
) )
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_lp INSERT INTO satoshimachine.dca_lp
(user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit, (user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled, autoforward_ln_address, autoforward_enabled,
created_at, updated_at) created_at, updated_at)
@ -417,7 +417,7 @@ async def upsert_dca_lp(
set_clause = ", ".join(f"{k} = :{k}" for k in update_data) set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["uid"] = user_id update_data["uid"] = user_id
await db.execute( await db.execute(
f"UPDATE spirekeeper.dca_lp SET {set_clause} WHERE user_id = :uid", f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid",
update_data, update_data,
) )
refreshed = await get_dca_lp(user_id) refreshed = await get_dca_lp(user_id)
@ -435,7 +435,7 @@ async def update_dca_client(
set_clause = ", ".join(f"{k} = :{k}" for k in update_data) set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["id"] = client_id update_data["id"] = client_id
await db.execute( await db.execute(
f"UPDATE spirekeeper.dca_clients SET {set_clause} WHERE id = :id", f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id",
update_data, update_data,
) )
return await get_dca_client(client_id) return await get_dca_client(client_id)
@ -443,7 +443,7 @@ async def update_dca_client(
async def delete_dca_client(client_id: str) -> None: async def delete_dca_client(client_id: str) -> None:
await db.execute( await db.execute(
"DELETE FROM spirekeeper.dca_clients WHERE id = :id", "DELETE FROM satoshimachine.dca_clients WHERE id = :id",
{"id": client_id}, {"id": client_id},
) )
@ -466,7 +466,7 @@ async def create_deposit(
deposit_id = urlsafe_short_hash() deposit_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_deposits INSERT INTO satoshimachine.dca_deposits
(id, client_id, machine_id, creator_user_id, amount, currency, (id, client_id, machine_id, creator_user_id, amount, currency,
status, notes, created_at) status, notes, created_at)
VALUES (:id, :client_id, :machine_id, :creator_user_id, :amount, VALUES (:id, :client_id, :machine_id, :creator_user_id, :amount,
@ -491,7 +491,7 @@ async def create_deposit(
async def get_deposit(deposit_id: str) -> DcaDeposit | None: async def get_deposit(deposit_id: str) -> DcaDeposit | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_deposits WHERE id = :id", "SELECT * FROM satoshimachine.dca_deposits WHERE id = :id",
{"id": deposit_id}, {"id": deposit_id},
DcaDeposit, DcaDeposit,
) )
@ -500,7 +500,7 @@ async def get_deposit(deposit_id: str) -> DcaDeposit | None:
async def get_deposits_for_client(client_id: str) -> list[DcaDeposit]: async def get_deposits_for_client(client_id: str) -> list[DcaDeposit]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_deposits SELECT * FROM satoshimachine.dca_deposits
WHERE client_id = :client_id WHERE client_id = :client_id
ORDER BY created_at DESC ORDER BY created_at DESC
""", """,
@ -513,8 +513,8 @@ async def get_deposits_for_operator(operator_user_id: str) -> list[DcaDeposit]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT d.* SELECT d.*
FROM spirekeeper.dca_deposits d FROM satoshimachine.dca_deposits d
JOIN spirekeeper.dca_machines m ON m.id = d.machine_id JOIN satoshimachine.dca_machines m ON m.id = d.machine_id
WHERE m.operator_user_id = :uid WHERE m.operator_user_id = :uid
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
""", """,
@ -532,7 +532,7 @@ async def update_deposit(
set_clause = ", ".join(f"{k} = :{k}" for k in update_data) set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["id"] = deposit_id update_data["id"] = deposit_id
await db.execute( await db.execute(
f"UPDATE spirekeeper.dca_deposits SET {set_clause} WHERE id = :id", f"UPDATE satoshimachine.dca_deposits SET {set_clause} WHERE id = :id",
update_data, update_data,
) )
return await get_deposit(deposit_id) return await get_deposit(deposit_id)
@ -549,7 +549,7 @@ async def update_deposit_status(
} }
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_deposits UPDATE satoshimachine.dca_deposits
SET status = :status, SET status = :status,
notes = COALESCE(:notes, notes), notes = COALESCE(:notes, notes),
confirmed_at = COALESCE(:confirmed_at, confirmed_at) confirmed_at = COALESCE(:confirmed_at, confirmed_at)
@ -562,7 +562,7 @@ async def update_deposit_status(
async def delete_deposit(deposit_id: str) -> None: async def delete_deposit(deposit_id: str) -> None:
await db.execute( await db.execute(
"DELETE FROM spirekeeper.dca_deposits WHERE id = :id", "DELETE FROM satoshimachine.dca_deposits WHERE id = :id",
{"id": deposit_id}, {"id": deposit_id},
) )
@ -598,7 +598,7 @@ async def create_settlement_idempotent(
settlement_id = urlsafe_short_hash() settlement_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_settlements INSERT INTO satoshimachine.dca_settlements
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats,
fee_sats, platform_fee_sats, operator_fee_sats, fee_mismatch_sats, fee_sats, platform_fee_sats, operator_fee_sats, fee_mismatch_sats,
@ -639,7 +639,7 @@ async def create_settlement_idempotent(
async def get_settlement(settlement_id: str) -> DcaSettlement | None: async def get_settlement(settlement_id: str) -> DcaSettlement | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_settlements WHERE id = :id", "SELECT * FROM satoshimachine.dca_settlements WHERE id = :id",
{"id": settlement_id}, {"id": settlement_id},
DcaSettlement, DcaSettlement,
) )
@ -650,7 +650,7 @@ async def get_settlement_by_payment_hash(
) -> DcaSettlement | None: ) -> DcaSettlement | None:
return await db.fetchone( return await db.fetchone(
""" """
SELECT * FROM spirekeeper.dca_settlements SELECT * FROM satoshimachine.dca_settlements
WHERE payment_hash = :hash WHERE payment_hash = :hash
""", """,
{"hash": payment_hash}, {"hash": payment_hash},
@ -663,7 +663,7 @@ async def get_settlements_for_machine(
) -> list[DcaSettlement]: ) -> list[DcaSettlement]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_settlements SELECT * FROM satoshimachine.dca_settlements
WHERE machine_id = :machine_id WHERE machine_id = :machine_id
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT :lim LIMIT :lim
@ -698,8 +698,8 @@ async def get_stuck_settlements_for_operator(
rejected = await db.fetchall( rejected = await db.fetchall(
""" """
SELECT s.* SELECT s.*
FROM spirekeeper.dca_settlements s FROM satoshimachine.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid AND s.status = 'rejected' WHERE m.operator_user_id = :uid AND s.status = 'rejected'
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
""", """,
@ -709,8 +709,8 @@ async def get_stuck_settlements_for_operator(
errored = await db.fetchall( errored = await db.fetchall(
""" """
SELECT s.* SELECT s.*
FROM spirekeeper.dca_settlements s FROM satoshimachine.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid AND s.status = 'errored' WHERE m.operator_user_id = :uid AND s.status = 'errored'
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
""", """,
@ -720,8 +720,8 @@ async def get_stuck_settlements_for_operator(
stuck_pending = await db.fetchall( stuck_pending = await db.fetchall(
""" """
SELECT s.* SELECT s.*
FROM spirekeeper.dca_settlements s FROM satoshimachine.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid WHERE m.operator_user_id = :uid
AND s.status = 'pending' AND s.status = 'pending'
AND s.created_at < :threshold AND s.created_at < :threshold
@ -733,8 +733,8 @@ async def get_stuck_settlements_for_operator(
stuck_processing = await db.fetchall( stuck_processing = await db.fetchall(
""" """
SELECT s.* SELECT s.*
FROM spirekeeper.dca_settlements s FROM satoshimachine.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid WHERE m.operator_user_id = :uid
AND s.status = 'processing' AND s.status = 'processing'
AND s.created_at < :threshold AND s.created_at < :threshold
@ -763,7 +763,7 @@ async def force_reset_stuck_settlement(
decision.""" decision."""
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_settlements UPDATE satoshimachine.dca_settlements
SET status = 'errored', SET status = 'errored',
processing_claim = NULL, processing_claim = NULL,
error_message = 'force-reset by operator (was stuck)' error_message = 'force-reset by operator (was stuck)'
@ -780,8 +780,8 @@ async def get_settlements_for_operator(
return await db.fetchall( return await db.fetchall(
""" """
SELECT s.* SELECT s.*
FROM spirekeeper.dca_settlements s FROM satoshimachine.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid WHERE m.operator_user_id = :uid
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT :lim LIMIT :lim
@ -801,7 +801,7 @@ async def mark_settlement_status(
fresh claim attempt won't see a stale token.""" fresh claim attempt won't see a stale token."""
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_settlements UPDATE satoshimachine.dca_settlements
SET status = :status, SET status = :status,
error_message = :err, error_message = :err,
processed_at = CASE processed_at = CASE
@ -840,7 +840,7 @@ async def claim_settlement_for_processing(
token = urlsafe_short_hash() token = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_settlements UPDATE satoshimachine.dca_settlements
SET status = 'processing', processing_claim = :token SET status = 'processing', processing_claim = :token
WHERE id = :id AND status = 'pending' WHERE id = :id AND status = 'pending'
""", """,
@ -862,7 +862,7 @@ async def reset_settlement_for_retry(
are left in place we never re-pay sats that already moved.""" are left in place we never re-pay sats that already moved."""
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_payments UPDATE satoshimachine.dca_payments
SET status = 'voided' SET status = 'voided'
WHERE settlement_id = :sid AND status = 'failed' WHERE settlement_id = :sid AND status = 'failed'
""", """,
@ -870,7 +870,7 @@ async def reset_settlement_for_retry(
) )
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_settlements UPDATE satoshimachine.dca_settlements
SET status = 'pending', SET status = 'pending',
error_message = NULL, error_message = NULL,
processing_claim = NULL, processing_claim = NULL,
@ -902,7 +902,7 @@ async def apply_partial_dispense(
can re-distribute via the existing idempotent path.""" can re-distribute via the existing idempotent path."""
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_settlements UPDATE satoshimachine.dca_settlements
SET wire_sats = :gross, SET wire_sats = :gross,
principal_sats = :principal, principal_sats = :principal,
fee_sats = :commission, fee_sats = :commission,
@ -937,7 +937,7 @@ async def count_completed_legs_for_settlement(settlement_id: str) -> int:
successfully moved sats (Lightning payments can't be clawed back).""" successfully moved sats (Lightning payments can't be clawed back)."""
row = await db.fetchone( row = await db.fetchone(
""" """
SELECT COUNT(*) AS n FROM spirekeeper.dca_payments SELECT COUNT(*) AS n FROM satoshimachine.dca_payments
WHERE settlement_id = :sid AND status = 'completed' WHERE settlement_id = :sid AND status = 'completed'
""", """,
{"sid": settlement_id}, {"sid": settlement_id},
@ -957,7 +957,7 @@ async def append_settlement_note(
formatted = f"[{ts} by {author_user_id}] {note}" formatted = f"[{ts} by {author_user_id}] {note}"
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_settlements UPDATE satoshimachine.dca_settlements
SET notes = CASE SET notes = CASE
WHEN notes IS NULL OR notes = '' THEN :note WHEN notes IS NULL OR notes = '' THEN :note
ELSE :note || char(10) || char(10) || notes ELSE :note || char(10) || char(10) || notes
@ -977,7 +977,7 @@ async def void_open_legs_for_settlement(settlement_id: str) -> None:
writes its own (possibly different) skipped reasons.""" writes its own (possibly different) skipped reasons."""
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_payments UPDATE satoshimachine.dca_payments
SET status = 'voided' SET status = 'voided'
WHERE settlement_id = :sid WHERE settlement_id = :sid
AND status IN ('pending', 'failed', 'skipped') AND status IN ('pending', 'failed', 'skipped')
@ -1002,7 +1002,7 @@ async def get_commission_splits(
if machine_id is None: if machine_id is None:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_commission_splits SELECT * FROM satoshimachine.dca_commission_splits
WHERE operator_user_id = :uid AND machine_id IS NULL WHERE operator_user_id = :uid AND machine_id IS NULL
ORDER BY sort_order ASC ORDER BY sort_order ASC
""", """,
@ -1011,7 +1011,7 @@ async def get_commission_splits(
) )
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_commission_splits SELECT * FROM satoshimachine.dca_commission_splits
WHERE operator_user_id = :uid AND machine_id = :mid WHERE operator_user_id = :uid AND machine_id = :mid
ORDER BY sort_order ASC ORDER BY sort_order ASC
""", """,
@ -1040,7 +1040,7 @@ async def replace_commission_splits(
if machine_id is None: if machine_id is None:
await db.execute( await db.execute(
""" """
DELETE FROM spirekeeper.dca_commission_splits DELETE FROM satoshimachine.dca_commission_splits
WHERE operator_user_id = :uid AND machine_id IS NULL WHERE operator_user_id = :uid AND machine_id IS NULL
""", """,
{"uid": operator_user_id}, {"uid": operator_user_id},
@ -1048,7 +1048,7 @@ async def replace_commission_splits(
else: else:
await db.execute( await db.execute(
""" """
DELETE FROM spirekeeper.dca_commission_splits DELETE FROM satoshimachine.dca_commission_splits
WHERE operator_user_id = :uid AND machine_id = :mid WHERE operator_user_id = :uid AND machine_id = :mid
""", """,
{"uid": operator_user_id, "mid": machine_id}, {"uid": operator_user_id, "mid": machine_id},
@ -1057,7 +1057,7 @@ async def replace_commission_splits(
for leg in legs: for leg in legs:
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_commission_splits INSERT INTO satoshimachine.dca_commission_splits
(id, machine_id, operator_user_id, target, label, fraction, (id, machine_id, operator_user_id, target, label, fraction,
sort_order, created_at) sort_order, created_at)
VALUES (:id, :machine_id, :uid, :target, :label, :fraction, VALUES (:id, :machine_id, :uid, :target, :label, :fraction,
@ -1086,7 +1086,7 @@ async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment:
payment_id = urlsafe_short_hash() payment_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_payments INSERT INTO satoshimachine.dca_payments
(id, settlement_id, client_id, machine_id, operator_user_id, (id, settlement_id, client_id, machine_id, operator_user_id,
leg_type, destination_wallet_id, destination_ln_address, leg_type, destination_wallet_id, destination_ln_address,
amount_sats, amount_fiat, exchange_rate, transaction_time, amount_sats, amount_fiat, exchange_rate, transaction_time,
@ -1122,7 +1122,7 @@ async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment:
async def get_dca_payment(payment_id: str) -> DcaPayment | None: async def get_dca_payment(payment_id: str) -> DcaPayment | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_payments WHERE id = :id", "SELECT * FROM satoshimachine.dca_payments WHERE id = :id",
{"id": payment_id}, {"id": payment_id},
DcaPayment, DcaPayment,
) )
@ -1131,7 +1131,7 @@ async def get_dca_payment(payment_id: str) -> DcaPayment | None:
async def get_payments_for_settlement(settlement_id: str) -> list[DcaPayment]: async def get_payments_for_settlement(settlement_id: str) -> list[DcaPayment]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_payments SELECT * FROM satoshimachine.dca_payments
WHERE settlement_id = :sid WHERE settlement_id = :sid
ORDER BY created_at ASC ORDER BY created_at ASC
""", """,
@ -1143,7 +1143,7 @@ async def get_payments_for_settlement(settlement_id: str) -> list[DcaPayment]:
async def get_payments_for_client(client_id: str) -> list[DcaPayment]: async def get_payments_for_client(client_id: str) -> list[DcaPayment]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_payments SELECT * FROM satoshimachine.dca_payments
WHERE client_id = :cid WHERE client_id = :cid
ORDER BY created_at DESC ORDER BY created_at DESC
""", """,
@ -1158,7 +1158,7 @@ async def get_payments_for_operator(
if leg_type is None: if leg_type is None:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_payments SELECT * FROM satoshimachine.dca_payments
WHERE operator_user_id = :uid WHERE operator_user_id = :uid
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT :lim LIMIT :lim
@ -1168,7 +1168,7 @@ async def get_payments_for_operator(
) )
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM spirekeeper.dca_payments SELECT * FROM satoshimachine.dca_payments
WHERE operator_user_id = :uid AND leg_type = :leg WHERE operator_user_id = :uid AND leg_type = :leg
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT :lim LIMIT :lim
@ -1186,7 +1186,7 @@ async def update_payment_status(
) -> DcaPayment | None: ) -> DcaPayment | None:
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_payments UPDATE satoshimachine.dca_payments
SET status = :status, SET status = :status,
external_payment_hash = COALESCE(:hash, external_payment_hash), external_payment_hash = COALESCE(:hash, external_payment_hash),
error_message = :err error_message = :err
@ -1221,7 +1221,7 @@ async def get_client_balance_summary(
deposits_row = await db.fetchone( deposits_row = await db.fetchone(
""" """
SELECT COALESCE(SUM(amount), 0) AS total SELECT COALESCE(SUM(amount), 0) AS total
FROM spirekeeper.dca_deposits FROM satoshimachine.dca_deposits
WHERE client_id = :cid AND status = 'confirmed' WHERE client_id = :cid AND status = 'confirmed'
""", """,
{"cid": client_id}, {"cid": client_id},
@ -1231,7 +1231,7 @@ async def get_client_balance_summary(
payments_row = await db.fetchone( payments_row = await db.fetchone(
""" """
SELECT COALESCE(SUM(amount_fiat), 0) AS total SELECT COALESCE(SUM(amount_fiat), 0) AS total
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
WHERE client_id = :cid WHERE client_id = :cid
AND leg_type IN ('dca', 'settlement') AND leg_type IN ('dca', 'settlement')
AND status = 'completed' AND status = 'completed'
@ -1260,7 +1260,7 @@ async def get_client_balance_summary(
async def get_telemetry(machine_id: str) -> TelemetrySnapshot | None: async def get_telemetry(machine_id: str) -> TelemetrySnapshot | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.dca_telemetry WHERE machine_id = :mid", "SELECT * FROM satoshimachine.dca_telemetry WHERE machine_id = :mid",
{"mid": machine_id}, {"mid": machine_id},
TelemetrySnapshot, TelemetrySnapshot,
) )
@ -1290,7 +1290,7 @@ async def upsert_beacon_snapshot(
if existing is None: if existing is None:
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_telemetry INSERT INTO satoshimachine.dca_telemetry
(machine_id, beacon_cash_in, beacon_cash_out, beacon_cash_level, (machine_id, beacon_cash_in, beacon_cash_out, beacon_cash_level,
beacon_fiat, beacon_model, beacon_name, beacon_location, beacon_fiat, beacon_model, beacon_name, beacon_location,
beacon_geo, beacon_fees_json, beacon_limits_json, beacon_geo, beacon_fees_json, beacon_limits_json,
@ -1319,7 +1319,7 @@ async def upsert_beacon_snapshot(
else: else:
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_telemetry SET UPDATE satoshimachine.dca_telemetry SET
beacon_cash_in = COALESCE(:cash_in, beacon_cash_in), beacon_cash_in = COALESCE(:cash_in, beacon_cash_in),
beacon_cash_out = COALESCE(:cash_out, beacon_cash_out), beacon_cash_out = COALESCE(:cash_out, beacon_cash_out),
beacon_cash_level = COALESCE(:cash_level, beacon_cash_level), beacon_cash_level = COALESCE(:cash_level, beacon_cash_level),
@ -1366,7 +1366,7 @@ async def upsert_fleet_snapshot(
if existing is None: if existing is None:
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.dca_telemetry INSERT INTO satoshimachine.dca_telemetry
(machine_id, telemetry_json, telemetry_received_at) (machine_id, telemetry_json, telemetry_received_at)
VALUES (:mid, :json, :now) VALUES (:mid, :json, :now)
""", """,
@ -1375,7 +1375,7 @@ async def upsert_fleet_snapshot(
else: else:
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.dca_telemetry UPDATE satoshimachine.dca_telemetry
SET telemetry_json = :json, telemetry_received_at = :now SET telemetry_json = :json, telemetry_received_at = :now
WHERE machine_id = :mid WHERE machine_id = :mid
""", """,
@ -1416,7 +1416,7 @@ async def get_cassette_config(
machine_id: str, position: int machine_id: str, position: int
) -> CassetteConfig | None: ) -> CassetteConfig | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM spirekeeper.cassette_configs " "SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid AND position = :pos", "WHERE machine_id = :mid AND position = :pos",
{"mid": machine_id, "pos": position}, {"mid": machine_id, "pos": position},
CassetteConfig, CassetteConfig,
@ -1427,7 +1427,7 @@ async def list_cassette_configs_for_machine(
machine_id: str, machine_id: str,
) -> list[CassetteConfig]: ) -> list[CassetteConfig]:
return await db.fetchall( return await db.fetchall(
"SELECT * FROM spirekeeper.cassette_configs " "SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid ORDER BY position", "WHERE machine_id = :mid ORDER BY position",
{"mid": machine_id}, {"mid": machine_id},
CassetteConfig, CassetteConfig,
@ -1459,7 +1459,7 @@ async def update_cassette_config(
update_data["mid"] = machine_id update_data["mid"] = machine_id
update_data["pos"] = position update_data["pos"] = position
await db.execute( await db.execute(
f"UPDATE spirekeeper.cassette_configs SET {set_clause} " f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
"WHERE machine_id = :mid AND position = :pos", "WHERE machine_id = :mid AND position = :pos",
update_data, update_data,
) )
@ -1487,7 +1487,7 @@ async def apply_bootstrap_state(
events land + the operator subsequently edits. events land + the operator subsequently edits.
""" """
existing_first: dict | None = await db.fetchone( existing_first: dict | None = await db.fetchone(
"SELECT state_event_id FROM spirekeeper.cassette_configs " "SELECT state_event_id FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid LIMIT 1", "WHERE machine_id = :mid LIMIT 1",
{"mid": machine_id}, {"mid": machine_id},
) )
@ -1505,7 +1505,7 @@ async def apply_bootstrap_state(
for pos, row in payload.positions.items(): for pos, row in payload.positions.items():
await db.execute( await db.execute(
""" """
INSERT INTO spirekeeper.cassette_configs INSERT INTO satoshimachine.cassette_configs
(machine_id, position, denomination, count, updated_at, (machine_id, position, denomination, count, updated_at,
updated_by, state_denomination, state_count, state_at, updated_by, state_denomination, state_count, state_at,
state_event_id) state_event_id)

View file

@ -1,9 +1,10 @@
spirekeeper is the operator-side administration extension for bitSpire — it manages one or more bitSpire ATMs and runs the Dollar Cost Averaging (DCA) distribution that pays registered clients out of their confirmed deposit balances. SatMachineAdmin can be used as a template for building new extensions, it includes a bunch of functions that can be edited/deleted as you need them.
It listens for settlement events published by each ATM over Nostr (kind-21000, NIP-44 v2 encrypted), pays the resulting invoices through LNbits, and splits the proceeds across the principal, operator-fee, and commission legs according to the per-machine and super-config fee settings. This is a longform description that will be used in the advanced description when users click on the "more" button on the extension cards.
- **ATM fleet management** — register and configure each bitSpire by its machine npub; publish fee config and cassette state over Nostr. Adding some bullets is nice covering:
- **DCA distribution** — proportional ("flow") allocation across clients based on their remaining deposit balances, with a full per-leg audit trail.
- **Operator administration** — deposit confirmation workflow, client balance tracking, settlement history with drill-down, and CSV export for accounting.
Requires LNbits superuser access for administration. The Lightning ATM side is served by the bitSpire device; this extension is the back office. - Functionality
- Use cases
...and some other text about just how great this etension is.

View file

@ -7,7 +7,7 @@
## 0 · Why this document exists ## 0 · Why this document exists
Today the satoshimachine code lives at `~/dev/shared/extensions/spirekeeper` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostrnative one: bitSpire publishes invoices over kind21000 NIP44 v2 events, LNbits pays them, and our extension hooks the resulting `Payment` object. Today the satoshimachine code lives at `~/dev/shared/extensions/satmachineadmin` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostrnative one: bitSpire publishes invoices over kind21000 NIP44 v2 events, LNbits pays them, and our extension hooks the resulting `Payment` object.
The hard truth: the *settlement* itself uses Lightning (so it can't be forged once a preimage lands), but everything *around* the settlement — who the ATM is, what operator it belongs to, what the principal/commission split was, and what fiat was dispensed — currently rides on **mutable, unauthenticated metadata** (`Payment.extra`) plus a **stopgap that has the ATM hold the operator's own Nostr private key**. The latter means physical possession of the ATM = total compromise of the operator's LNbits account. The hard truth: the *settlement* itself uses Lightning (so it can't be forged once a preimage lands), but everything *around* the settlement — who the ATM is, what operator it belongs to, what the principal/commission split was, and what fiat was dispensed — currently rides on **mutable, unauthenticated metadata** (`Payment.extra`) plus a **stopgap that has the ATM hold the operator's own Nostr private key**. The latter means physical possession of the ATM = total compromise of the operator's LNbits account.
@ -66,7 +66,7 @@ Lamassu's old answer here was TLS cert pinning. We have a richer toolbox — Nos
register_invoice_listener fires register_invoice_listener fires
spirekeeper/tasks.py:_handle_payment satmachineadmin/tasks.py:_handle_payment
┌─────────────────────────────────┴────────────────────────────┐ ┌─────────────────────────────────┴────────────────────────────┐
▼ ▼ ▼ ▼
@ -144,7 +144,7 @@ T3, T5, T6 are the ones that keep the hardware honest. T3 + T6 are *the* reason
## 4 · Audit findings — current state inventory ## 4 · Audit findings — current state inventory
Pulled from the two recent codelevel audits of `~/dev/shared/extensions/spirekeeper` (operatorscoping inventory) and `~/dev/lnbits/nostr-transport` (transport primitives). Pulled from the two recent codelevel audits of `~/dev/shared/extensions/satmachineadmin` (operatorscoping inventory) and `~/dev/lnbits/nostr-transport` (transport primitives).
### 4.1 What's already strong ### 4.1 What's already strong
@ -307,7 +307,7 @@ None of those need to change. The new layers slot in *above* them.
| **S4 — NIP78 permachine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler crosschecks ATM npub ∈ fleet; reads maxwithdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwardscompat path for preNIP78 machines. | | **S4 — NIP78 permachine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler crosschecks ATM npub ∈ fleet; reads maxwithdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwardscompat path for preNIP78 machines. |
| **S5 — `sender_pubkey` persistence + signed metadata in Payment.extra** | When the dispatcher writes a Payment row, it stamps `Payment.extra.sender_pubkey`, `delegation_root`, and an HMAC over the key fields keyed by the LNbits server's own secret. Mutation postwrite breaks the HMAC. | G2 (DBside), G5, G6 | 35 days | LNbits PR — fairly localised. | | **S5 — `sender_pubkey` persistence + signed metadata in Payment.extra** | When the dispatcher writes a Payment row, it stamps `Payment.extra.sender_pubkey`, `delegation_root`, and an HMAC over the key fields keyed by the LNbits server's own secret. Mutation postwrite breaks the HMAC. | G2 (DBside), G5, G6 | 35 days | LNbits PR — fairly localised. |
| **S6 — Rate limiting + rostergated autoaccount** | Autoaccountfromnpub only fires if the npub appears in some operator's NIP78 fleet OR if an explicit "open enrollment" flag is set. Relay/handlerlevel rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. | | **S6 — Rate limiting + rostergated autoaccount** | Autoaccountfromnpub only fires if the npub appears in some operator's NIP78 fleet OR if an explicit "open enrollment" flag is set. Relay/handlerlevel rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. |
| **S7 — NIP46 bunker option** | Operator can pair spirekeeper with a Bunker (Amber, Nunchuk Custody, etc.). Operator's nsec leaves LNbits' DB; LNbits stores only the bunker connection. | G6, partial G5 | 46 weeks | Largest. Defer until S0S5 land. | | **S7 — NIP46 bunker option** | Operator can pair satmachineadmin with a Bunker (Amber, Nunchuk Custody, etc.). Operator's nsec leaves LNbits' DB; LNbits stores only the bunker connection. | G6, partial G5 | 46 weeks | Largest. Defer until S0S5 land. |
| **S8 — Cashin path** | Wire `is_out=True` cashin handling: LNURLwithdraw with expiration matching the kind21000 invoice TTL, attestation receipt on settle, refund queue for stale links. | G10 | 2 weeks | Out of scope for this security doc but tracked here for completeness. | | **S8 — Cashin path** | Wire `is_out=True` cashin handling: LNURLwithdraw with expiration matching the kind21000 invoice TTL, attestation receipt on settle, refund queue for stale links. | G10 | 2 weeks | Out of scope for this security doc but tracked here for completeness. |
Recommended sequencing for the *next sprint*: **S0 + S1 + S5**. They give us the biggest security delta with no upstream LNbits dependency for S0/S1 and a small, wellscoped LNbits patch for S5. S2/S3/S4 are the proper Nostrnative layer and should land in the sprint after. Recommended sequencing for the *next sprint*: **S0 + S1 + S5**. They give us the biggest security delta with no upstream LNbits dependency for S0/S1 and a small, wellscoped LNbits patch for S5. S2/S3/S4 are the proper Nostrnative layer and should land in the sprint after.
@ -359,12 +359,12 @@ For an auditor or new contributor doing a walkthrough:
| File | Role | Note | | File | Role | Note |
|---|---|---| |---|---|---|
| `~/dev/shared/extensions/spirekeeper/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — loadbearing routing. | | `~/dev/shared/extensions/satmachineadmin/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — loadbearing routing. |
| `~/dev/shared/extensions/spirekeeper/bitspire.py` | Parses Payment.extra. The trust boundary. | `parse_settlement:68-92` — happy vs fallback path. | | `~/dev/shared/extensions/satmachineadmin/bitspire.py` | Parses Payment.extra. The trust boundary. | `parse_settlement:68-92` — happy vs fallback path. |
| `~/dev/shared/extensions/spirekeeper/distribution.py` | Threeleg distribution chain. | `process_settlement` — uses claim pattern. | | `~/dev/shared/extensions/satmachineadmin/distribution.py` | Threeleg distribution chain. | `process_settlement` — uses claim pattern. |
| `~/dev/shared/extensions/spirekeeper/crud.py` | Operatorscoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. | | `~/dev/shared/extensions/satmachineadmin/crud.py` | Operatorscoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. |
| `~/dev/shared/extensions/spirekeeper/views_api.py` | 33 routes, all `check_user_exists` except superconfig PUT. | `_assert_wallet_owned_by` is the walletIDOR fix. | | `~/dev/shared/extensions/satmachineadmin/views_api.py` | 33 routes, all `check_user_exists` except superconfig PUT. | `_assert_wallet_owned_by` is the walletIDOR fix. |
| `~/dev/shared/extensions/spirekeeper/migrations.py` | Schema. | `dca_settlements` is the audit row; `dca_payments` is the leg row. | | `~/dev/shared/extensions/satmachineadmin/migrations.py` | Schema. | `dca_settlements` is the audit row; `dca_payments` is the leg row. |
| `~/dev/shocknet/lamassu-next/deploy/nixos/provision-atm.sh` | Where keys land on the ATM today. | `:81-99``VITE_ATM_PRIVATE_KEY` and the Option1 stopgap. | | `~/dev/shocknet/lamassu-next/deploy/nixos/provision-atm.sh` | Where keys land on the ATM today. | `:81-99``VITE_ATM_PRIVATE_KEY` and the Option1 stopgap. |
| `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/` | LNbits transport handler (upstream we depend on). | NIP44 v2 crypto here; G5/G6/G7 fixes will live here. | | `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/` | LNbits transport handler (upstream we depend on). | NIP44 v2 crypto here; G5/G6/G7 fixes will live here. |
| `~/dev/nostr-protocol/nips/26.md` | Delegation. | Source for S2. | | `~/dev/nostr-protocol/nips/26.md` | Delegation. | Source for S2. |
@ -397,7 +397,7 @@ How we'd test the proposed design endtoend, once S0S5 land:
Once approved: Once approved:
1. The PDF for printing will be generated postplanmode (requires shell exec). Recommended path: render the markdown via `pandoc` to `~/dev/shared/extensions/spirekeeper/docs/security-pathway-v1.pdf`; the markdown source will live at `~/dev/shared/extensions/spirekeeper/docs/security-pathway-v1.md` so future contributors edit it inrepo. 1. The PDF for printing will be generated postplanmode (requires shell exec). Recommended path: render the markdown via `pandoc` to `~/dev/shared/extensions/satmachineadmin/docs/security-pathway-v1.pdf`; the markdown source will live at `~/dev/shared/extensions/satmachineadmin/docs/security-pathway-v1.md` so future contributors edit it inrepo.
2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0S7)." 2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0S7)."
3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbitsside primitives (S2, S5, S6). 3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbitsside primitives (S2, S5, S6).
4. Sequence sprint: **S0 + S1 + S5 first** (highest ratio of security delta to upstream coupling). S2/S3/S4 in the following sprint. 4. Sequence sprint: **S0 + S1 + S5 first** (highest ratio of security delta to upstream coupling). S2/S3/S4 in the following sprint.

View file

@ -132,7 +132,7 @@ async def publish_fee_config(
) )
except (SignerUnavailable, RelayUnavailable) as exc: except (SignerUnavailable, RelayUnavailable) as exc:
logger.warning( logger.warning(
f"spirekeeper: fee-config publish soft-fail for machine " f"satmachineadmin: fee-config publish soft-fail for machine "
f"{machine.id} ({machine.name or machine.machine_npub[:12]}): " f"{machine.id} ({machine.name or machine.machine_npub[:12]}): "
f"{type(exc).__name__}: {exc}. Underlying CRUD operation " f"{type(exc).__name__}: {exc}. Underlying CRUD operation "
"succeeded; operator can re-trigger publish via the next " "succeeded; operator can re-trigger publish via the next "
@ -144,7 +144,7 @@ async def publish_fee_config(
# don't break the caller's CRUD path; a future publish attempt # don't break the caller's CRUD path; a future publish attempt
# (next machine edit / next super edit) will retry. # (next machine edit / next super edit) will retry.
logger.warning( logger.warning(
f"spirekeeper: fee-config publish unexpected transport " f"satmachineadmin: fee-config publish unexpected transport "
f"error for machine {machine.id}: {type(exc).__name__}: {exc}" f"error for machine {machine.id}: {type(exc).__name__}: {exc}"
) )
return None return None

View file

@ -1,9 +1,9 @@
{ {
"repos": [ "repos": [
{ {
"id": "spirekeeper", "id": "satmachineadmin",
"organisation": "aiolabs", "organisation": "atitlanio",
"repository": "spirekeeper" "repository": "satmachineadmin"
} }
] ]
} }

View file

@ -56,11 +56,11 @@ async def m001_satmachine_v2_initial(db):
"dca_deposits", "dca_deposits",
"dca_clients", "dca_clients",
): ):
await db.execute(f"DROP TABLE IF EXISTS spirekeeper.{table}") await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}")
# 2. super_config — singleton (id='default') with platform-fee config. # 2. super_config — singleton (id='default') with platform-fee config.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.super_config ( CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
super_fee_wallet_id TEXT, super_fee_wallet_id TEXT,
@ -68,18 +68,18 @@ async def m001_satmachine_v2_initial(db):
); );
""") """)
existing = await db.fetchone( existing = await db.fetchone(
"SELECT id FROM spirekeeper.super_config WHERE id = 'default'" "SELECT id FROM satoshimachine.super_config WHERE id = 'default'"
) )
if not existing: if not existing:
await db.execute( await db.execute(
"INSERT INTO spirekeeper.super_config (id, super_fee_fraction) " "INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
"VALUES ('default', 0.0000)" "VALUES ('default', 0.0000)"
) )
# 3. dca_machines — one row per bitSpire ATM, owned by exactly one # 3. dca_machines — one row per bitSpire ATM, owned by exactly one
# operator. wallet_id UNIQUE prevents the IDOR funds-theft vector. # operator. wallet_id UNIQUE prevents the IDOR funds-theft vector.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_machines (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
operator_user_id TEXT NOT NULL, operator_user_id TEXT NOT NULL,
machine_npub TEXT NOT NULL UNIQUE, machine_npub TEXT NOT NULL UNIQUE,
@ -107,7 +107,7 @@ async def m001_satmachine_v2_initial(db):
# just decides "this LP is enrolled at my machine"; everything # just decides "this LP is enrolled at my machine"; everything
# delivery-related is the LP's own preference. # delivery-related is the LP's own preference.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_clients ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@ -129,17 +129,17 @@ async def m001_satmachine_v2_initial(db):
# user that has onboarded as a Liquidity Provider, regardless of # user that has onboarded as a Liquidity Provider, regardless of
# how many machines they're enrolled at. Owned by the LP (writes # how many machines they're enrolled at. Owned by the LP (writes
# come from the satmachineclient extension under the LP's session), # come from the satmachineclient extension under the LP's session),
# read by spirekeeper during distribution to resolve "where do # read by satmachineadmin during distribution to resolve "where do
# DCA payouts for this LP go?" # DCA payouts for this LP go?"
# #
# Gating: spirekeeper refuses to create deposits for an LP who # Gating: satmachineadmin refuses to create deposits for an LP who
# doesn't have a dca_lp row yet. The LP must onboard via # doesn't have a dca_lp row yet. The LP must onboard via
# satmachineclient first (which auto-creates the row with their # satmachineclient first (which auto-creates the row with their
# default LNbits wallet on first dashboard visit). Forces every # default LNbits wallet on first dashboard visit). Forces every
# LP through a "yes, I am here and this is where I want my sats" # LP through a "yes, I am here and this is where I want my sats"
# gesture before any fiat starts accumulating against them. # gesture before any fiat starts accumulating against them.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_lp ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
dca_wallet_id TEXT NOT NULL, dca_wallet_id TEXT NOT NULL,
default_dca_mode TEXT NOT NULL DEFAULT 'flow', default_dca_mode TEXT NOT NULL DEFAULT 'flow',
@ -154,7 +154,7 @@ async def m001_satmachine_v2_initial(db):
# 5. dca_deposits — fiat the operator (or super) records against an LP # 5. dca_deposits — fiat the operator (or super) records against an LP
# at a machine. creator_user_id preserves audit trail. # at a machine. creator_user_id preserves audit trail.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_deposits ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_deposits (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
client_id TEXT NOT NULL, client_id TEXT NOT NULL,
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
@ -184,7 +184,7 @@ async def m001_satmachine_v2_initial(db):
# forgave what per transaction. Do not collapse them into a single # forgave what per transaction. Do not collapse them into a single
# fee_fraction. See plan section "Customer discounts" and #10. # fee_fraction. See plan section "Customer discounts" and #10.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_settlements ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
payment_hash TEXT NOT NULL UNIQUE, payment_hash TEXT NOT NULL UNIQUE,
@ -227,7 +227,7 @@ async def m001_satmachine_v2_initial(db):
# - LNURL string (bech32 LNURL...) # - LNURL string (bech32 LNURL...)
# Resolution lives in distribution._pay_one_split_leg. # Resolution lives in distribution._pay_one_split_leg.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_commission_splits ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
machine_id TEXT, machine_id TEXT,
operator_user_id TEXT NOT NULL, operator_user_id TEXT NOT NULL,
@ -248,7 +248,7 @@ async def m001_satmachine_v2_initial(db):
# autoforward | refund. status enum: pending | completed | failed | # autoforward | refund. status enum: pending | completed | failed |
# voided | skipped | refunded. # voided | skipped | refunded.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_payments ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_payments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
settlement_id TEXT, settlement_id TEXT,
client_id TEXT, client_id TEXT,
@ -287,7 +287,7 @@ async def m001_satmachine_v2_initial(db):
# (name, location, geo, fees, limits, denominations, version) are # (name, location, geo, fees, limits, denominations, version) are
# nullable until that upstream issue lands. Ingest opportunistically. # nullable until that upstream issue lands. Ingest opportunistically.
await db.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_telemetry ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry (
machine_id TEXT PRIMARY KEY, machine_id TEXT PRIMARY KEY,
beacon_cash_in BOOLEAN, beacon_cash_in BOOLEAN,
beacon_cash_out BOOLEAN, beacon_cash_out BOOLEAN,
@ -315,7 +315,7 @@ async def m002_rename_commission_split_wallet_id_to_target(db):
EXISTS`, which is a no-op when the table already exists so the EXISTS`, which is a no-op when the table already exists so the
schema drift survives the documented uninstall + reinstall workflow schema drift survives the documented uninstall + reinstall workflow
because LNbits' uninstall wipes the dbversions tracker but NOT the because LNbits' uninstall wipes the dbversions tracker but NOT the
spirekeeper.sqlite3 file on disk. satoshimachine.sqlite3 file on disk.
Idempotent: probes for the `wallet_id` column via a SELECT. If the Idempotent: probes for the `wallet_id` column via a SELECT. If the
probe succeeds the column still exists and we RENAME it; otherwise probe succeeds the column still exists and we RENAME it; otherwise
@ -326,14 +326,14 @@ async def m002_rename_commission_split_wallet_id_to_target(db):
""" """
try: try:
await db.fetchone( await db.fetchone(
"SELECT wallet_id FROM spirekeeper.dca_commission_splits LIMIT 1" "SELECT wallet_id FROM satoshimachine.dca_commission_splits LIMIT 1"
) )
except Exception: except Exception:
# wallet_id column doesn't exist; either m001 produced the correct # wallet_id column doesn't exist; either m001 produced the correct
# schema on a fresh install or the rename already landed. # schema on a fresh install or the rename already landed.
return return
await db.execute( await db.execute(
"ALTER TABLE spirekeeper.dca_commission_splits " "ALTER TABLE satoshimachine.dca_commission_splits "
"RENAME COLUMN wallet_id TO target" "RENAME COLUMN wallet_id TO target"
) )
@ -350,11 +350,11 @@ async def m003_rename_settlements_net_sats_to_principal_sats(db):
Idempotent: probes for the old `net_sats` column. If present, rename. Idempotent: probes for the old `net_sats` column. If present, rename.
""" """
try: try:
await db.fetchone("SELECT net_sats FROM spirekeeper.dca_settlements LIMIT 1") await db.fetchone("SELECT net_sats FROM satoshimachine.dca_settlements LIMIT 1")
except Exception: except Exception:
return return
await db.execute( await db.execute(
"ALTER TABLE spirekeeper.dca_settlements " "ALTER TABLE satoshimachine.dca_settlements "
"RENAME COLUMN net_sats TO principal_sats" "RENAME COLUMN net_sats TO principal_sats"
) )
@ -383,14 +383,14 @@ async def m004_introduce_dca_lp_table(db):
absent the install already on the new shape; no-op. absent the install already on the new shape; no-op.
""" """
try: try:
await db.fetchone("SELECT wallet_id FROM spirekeeper.dca_clients LIMIT 1") await db.fetchone("SELECT wallet_id FROM satoshimachine.dca_clients LIMIT 1")
except Exception: except Exception:
return return
# Step 1: create dca_lp if it doesn't exist yet. m001 on a fresh install # Step 1: create dca_lp if it doesn't exist yet. m001 on a fresh install
# already created it; on a pre-m004 install we're creating it here. # already created it; on a pre-m004 install we're creating it here.
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dca_lp ( CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
dca_wallet_id TEXT NOT NULL, dca_wallet_id TEXT NOT NULL,
default_dca_mode TEXT NOT NULL DEFAULT 'flow', default_dca_mode TEXT NOT NULL DEFAULT 'flow',
@ -407,7 +407,7 @@ async def m004_introduce_dca_lp_table(db):
# enrolled at multiple machines — that row reflects their most # enrolled at multiple machines — that row reflects their most
# recent intent. ROW_NUMBER() OVER (...) requires SQLite 3.25+ (2018). # recent intent. ROW_NUMBER() OVER (...) requires SQLite 3.25+ (2018).
await db.execute(""" await db.execute("""
INSERT OR IGNORE INTO spirekeeper.dca_lp INSERT OR IGNORE INTO satoshimachine.dca_lp
(user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit, (user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled, autoforward_ln_address, autoforward_enabled,
created_at, updated_at) created_at, updated_at)
@ -419,7 +419,7 @@ async def m004_introduce_dca_lp_table(db):
PARTITION BY user_id PARTITION BY user_id
ORDER BY updated_at DESC, created_at DESC ORDER BY updated_at DESC, created_at DESC
) AS rn ) AS rn
FROM spirekeeper.dca_clients FROM satoshimachine.dca_clients
) ranked ) ranked
WHERE rn = 1 WHERE rn = 1
""") """)
@ -434,7 +434,7 @@ async def m004_introduce_dca_lp_table(db):
"autoforward_ln_address", "autoforward_ln_address",
"autoforward_enabled", "autoforward_enabled",
): ):
await db.execute(f"ALTER TABLE spirekeeper.dca_clients DROP COLUMN {col}") await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
async def m006_rename_to_canonical_sat_vocabulary(db): async def m006_rename_to_canonical_sat_vocabulary(db):
@ -474,13 +474,13 @@ async def m006_rename_to_canonical_sat_vocabulary(db):
] ]
for table, old_col, new_col in renames: for table, old_col, new_col in renames:
try: try:
await db.fetchone(f"SELECT {old_col} FROM spirekeeper.{table} LIMIT 1") await db.fetchone(f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1")
except Exception: except Exception:
# old column doesn't exist; either rename already landed or # old column doesn't exist; either rename already landed or
# m001 produced the canonical schema directly on fresh install. # m001 produced the canonical schema directly on fresh install.
continue continue
await db.execute( await db.execute(
f"ALTER TABLE spirekeeper.{table} " f"ALTER TABLE satoshimachine.{table} "
f"RENAME COLUMN {old_col} TO {new_col}" f"RENAME COLUMN {old_col} TO {new_col}"
) )
@ -494,11 +494,11 @@ async def m006_rename_to_canonical_sat_vocabulary(db):
] ]
for table, col in drops: for table, col in drops:
try: try:
await db.fetchone(f"SELECT {col} FROM spirekeeper.{table} LIMIT 1") await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1")
except Exception: except Exception:
# column doesn't exist; either already dropped or never present. # column doesn't exist; either already dropped or never present.
continue continue
await db.execute(f"ALTER TABLE spirekeeper.{table} DROP COLUMN {col}") await db.execute(f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}")
async def m005_lock_deposit_currency_to_machine_fiat_code(db): async def m005_lock_deposit_currency_to_machine_fiat_code(db):
@ -518,15 +518,15 @@ async def m005_lock_deposit_currency_to_machine_fiat_code(db):
no mismatches it's a no-op UPDATE. no mismatches it's a no-op UPDATE.
""" """
await db.execute(""" await db.execute("""
UPDATE spirekeeper.dca_deposits AS d UPDATE satoshimachine.dca_deposits AS d
SET currency = ( SET currency = (
SELECT m.fiat_code SELECT m.fiat_code
FROM spirekeeper.dca_machines m FROM satoshimachine.dca_machines m
WHERE m.id = d.machine_id WHERE m.id = d.machine_id
) )
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 SELECT 1
FROM spirekeeper.dca_machines m FROM satoshimachine.dca_machines m
WHERE m.id = d.machine_id WHERE m.id = d.machine_id
AND m.fiat_code IS NOT NULL AND m.fiat_code IS NOT NULL
AND m.fiat_code != d.currency AND m.fiat_code != d.currency
@ -538,7 +538,7 @@ async def m007_add_cassette_configs(db):
"""Add cassette_configs table for operator-driven ATM cassette inventory. """Add cassette_configs table for operator-driven ATM cassette inventory.
Tracks per-machine cassette state (denomination, count, position) editable Tracks per-machine cassette state (denomination, count, position) editable
via the spirekeeper dashboard and published to the ATM as encrypted via the satmachineadmin dashboard and published to the ATM as encrypted
kind-30078 events. See aiolabs/satmachineadmin#29 + lamassu-next#56. kind-30078 events. See aiolabs/satmachineadmin#29 + lamassu-next#56.
Schema choice: PK (machine_id, denomination) mirrors the ATM-side Schema choice: PK (machine_id, denomination) mirrors the ATM-side
@ -556,7 +556,7 @@ async def m007_add_cassette_configs(db):
render them; v2 reconciliation UI consumes them without a migration. render them; v2 reconciliation UI consumes them without a migration.
""" """
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.cassette_configs ( CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs (
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
denomination INTEGER NOT NULL, denomination INTEGER NOT NULL,
count INTEGER NOT NULL, count INTEGER NOT NULL,
@ -600,14 +600,14 @@ async def m008_flip_cassette_configs_pk_to_position(db):
# Probe: does the old PK shape still exist? If state_denomination # Probe: does the old PK shape still exist? If state_denomination
# column already exists, m008 already ran — no-op. # column already exists, m008 already ran — no-op.
await db.fetchone( await db.fetchone(
"SELECT state_denomination FROM spirekeeper.cassette_configs " "LIMIT 1" "SELECT state_denomination FROM satoshimachine.cassette_configs " "LIMIT 1"
) )
return return
except Exception: except Exception:
pass pass
await db.execute(f""" await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.cassette_configs_new ( CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs_new (
machine_id TEXT NOT NULL, machine_id TEXT NOT NULL,
position INTEGER NOT NULL, position INTEGER NOT NULL,
denomination INTEGER NOT NULL, denomination INTEGER NOT NULL,
@ -630,19 +630,19 @@ async def m008_flip_cassette_configs_pk_to_position(db):
# = current denomination as a best-guess baseline; the next bootstrap # = current denomination as a best-guess baseline; the next bootstrap
# event re-populates the state_* columns authoritatively. # event re-populates the state_* columns authoritatively.
await db.execute(""" await db.execute("""
INSERT INTO spirekeeper.cassette_configs_new INSERT INTO satoshimachine.cassette_configs_new
(machine_id, position, denomination, count, (machine_id, position, denomination, count,
updated_at, updated_by, updated_at, updated_by,
state_denomination, state_count, state_at, state_event_id) state_denomination, state_count, state_at, state_event_id)
SELECT machine_id, position, denomination, count, SELECT machine_id, position, denomination, count,
updated_at, updated_by, updated_at, updated_by,
denomination, state_count, state_at, state_event_id denomination, state_count, state_at, state_event_id
FROM spirekeeper.cassette_configs FROM satoshimachine.cassette_configs
""") """)
await db.execute("DROP TABLE spirekeeper.cassette_configs") await db.execute("DROP TABLE satoshimachine.cassette_configs")
await db.execute( await db.execute(
"ALTER TABLE spirekeeper.cassette_configs_new " "RENAME TO cassette_configs" "ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs"
) )
@ -676,7 +676,7 @@ async def m009_split_fee_fractions_by_direction(db):
operators set via the new UI surface). operators set via the new UI surface).
- dca_settlements gains fee_mismatch_sats BIGINT NULL records - dca_settlements gains fee_mismatch_sats BIGINT NULL records
bitspire-reported fee minus expected per bitspire-reported fee minus expected per
spirekeeper's principal-based recompute. satmachineadmin's principal-based recompute.
Phase 1 observability: log + record, never Phase 1 observability: log + record, never
reject (per coord-log §2026-06-01T07:00Z reject (per coord-log §2026-06-01T07:00Z
lnbits advisory; option A locked). lnbits advisory; option A locked).
@ -697,13 +697,13 @@ async def m009_split_fee_fractions_by_direction(db):
] ]
for table, col, coltype in additions: for table, col, coltype in additions:
try: try:
await db.fetchone(f"SELECT {col} FROM spirekeeper.{table} LIMIT 1") await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1")
# column already present — migration partially-ran previously, skip # column already present — migration partially-ran previously, skip
continue continue
except Exception: except Exception:
pass pass
await db.execute( await db.execute(
f"ALTER TABLE spirekeeper.{table} ADD COLUMN {col} {coltype}" f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}"
) )
# Backfill + drop the legacy singleton, gated on the column still # Backfill + drop the legacy singleton, gated on the column still
@ -711,7 +711,7 @@ async def m009_split_fee_fractions_by_direction(db):
# steps cleanly. # steps cleanly.
try: try:
await db.fetchone( await db.fetchone(
"SELECT super_fee_fraction FROM spirekeeper.super_config LIMIT 1" "SELECT super_fee_fraction FROM satoshimachine.super_config LIMIT 1"
) )
legacy_present = True legacy_present = True
except Exception: except Exception:
@ -724,7 +724,7 @@ async def m009_split_fee_fractions_by_direction(db):
# still at DEFAULT 0). # still at DEFAULT 0).
await db.execute( await db.execute(
""" """
UPDATE spirekeeper.super_config UPDATE satoshimachine.super_config
SET super_cash_in_fee_fraction = super_fee_fraction, SET super_cash_in_fee_fraction = super_fee_fraction,
super_cash_out_fee_fraction = super_fee_fraction super_cash_out_fee_fraction = super_fee_fraction
WHERE super_cash_in_fee_fraction = 0 WHERE super_cash_in_fee_fraction = 0
@ -733,5 +733,5 @@ async def m009_split_fee_fractions_by_direction(db):
""" """
) )
await db.execute( await db.execute(
"ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction" "ALTER TABLE satoshimachine.super_config DROP COLUMN super_fee_fraction"
) )

View file

@ -38,7 +38,7 @@
-- Find duplicate transactions -- Find duplicate transactions
SELECT transaction_id, COUNT(*) as count, SELECT transaction_id, COUNT(*) as count,
STRING_AGG(id::text, ', ') as record_ids STRING_AGG(id::text, ', ') as record_ids
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
GROUP BY transaction_id GROUP BY transaction_id
HAVING COUNT(*) > 1; HAVING COUNT(*) > 1;
``` ```
@ -60,16 +60,16 @@ HAVING COUNT(*) > 1;
-- Step 1: Identify duplicate distributions -- Step 1: Identify duplicate distributions
SELECT lt.transaction_id, lt.id, lt.created_at, lt.base_amount, SELECT lt.transaction_id, lt.id, lt.created_at, lt.base_amount,
COUNT(dp.id) as distribution_count COUNT(dp.id) as distribution_count
FROM spirekeeper.lamassu_transactions lt FROM satoshimachine.lamassu_transactions lt
LEFT JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
GROUP BY lt.id GROUP BY lt.id
HAVING COUNT(dp.id) > (SELECT COUNT(*) FROM spirekeeper.dca_clients WHERE remaining_balance > 0); HAVING COUNT(dp.id) > (SELECT COUNT(*) FROM satoshimachine.dca_clients WHERE remaining_balance > 0);
-- Step 2: Calculate over-distributed amounts per client -- Step 2: Calculate over-distributed amounts per client
SELECT client_id, SELECT client_id,
SUM(amount_sats) as total_received, SUM(amount_sats) as total_received,
-- Manual calculation of expected amount needed here -- Manual calculation of expected amount needed here
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
WHERE lamassu_transaction_id IN (SELECT id FROM duplicates_table) WHERE lamassu_transaction_id IN (SELECT id FROM duplicates_table)
GROUP BY client_id; GROUP BY client_id;
``` ```
@ -94,7 +94,7 @@ if existing:
**Required Database Change**: **Required Database Change**:
```sql ```sql
ALTER TABLE spirekeeper.lamassu_transactions ALTER TABLE satoshimachine.lamassu_transactions
ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id); ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id);
``` ```
@ -117,7 +117,7 @@ ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id);
-- Check last successful poll -- Check last successful poll
SELECT MAX(created_at) as last_transaction, SELECT MAX(created_at) as last_transaction,
EXTRACT(EPOCH FROM (NOW() - MAX(created_at)))/3600 as hours_since_last EXTRACT(EPOCH FROM (NOW() - MAX(created_at)))/3600 as hours_since_last
FROM spirekeeper.lamassu_transactions; FROM satoshimachine.lamassu_transactions;
-- If hours_since_last > 24, investigate immediately -- If hours_since_last > 24, investigate immediately
``` ```
@ -194,8 +194,8 @@ ssh-keygen -t ed25519 -f ~/.ssh/satmachine_lamassu -C "satmachine-polling"
-- Find stuck/failed payments (older than 1 hour, not completed) -- Find stuck/failed payments (older than 1 hour, not completed)
SELECT dp.id, dp.client_id, dp.amount_sats, dp.status, dp.created_at, SELECT dp.id, dp.client_id, dp.amount_sats, dp.status, dp.created_at,
c.username, dp.payment_hash c.username, dp.payment_hash
FROM spirekeeper.dca_payments dp FROM satoshimachine.dca_payments dp
JOIN spirekeeper.dca_clients c ON dp.client_id = c.id JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
WHERE dp.status != 'completed' WHERE dp.status != 'completed'
AND dp.created_at < NOW() - INTERVAL '1 hour' AND dp.created_at < NOW() - INTERVAL '1 hour'
ORDER BY dp.created_at DESC; ORDER BY dp.created_at DESC;
@ -207,7 +207,7 @@ ORDER BY dp.created_at DESC;
SELECT SELECT
SUM(commission_amount) as total_commission_expected, SUM(commission_amount) as total_commission_expected,
-- Manually check actual wallet balance in LNBits -- Manually check actual wallet balance in LNBits
FROM spirekeeper.lamassu_transactions; FROM satoshimachine.lamassu_transactions;
``` ```
#### Immediate Response #### Immediate Response
@ -258,9 +258,9 @@ SELECT
c.remaining_balance, c.remaining_balance,
COALESCE(SUM(d.amount), 0) as total_deposits, COALESCE(SUM(d.amount), 0) as total_deposits,
COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed' LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
LEFT JOIN spirekeeper.dca_payments p ON c.id = p.client_id LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
GROUP BY c.id; GROUP BY c.id;
``` ```
@ -319,9 +319,9 @@ SELECT
COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_distributed, COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_distributed,
COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as calculated_balance, COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as calculated_balance,
c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0)) as discrepancy c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0)) as discrepancy
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed' LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
LEFT JOIN spirekeeper.dca_payments p ON c.id = p.client_id LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
GROUP BY c.id, c.username, c.remaining_balance GROUP BY c.id, c.username, c.remaining_balance
HAVING ABS(c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0))) > 1; HAVING ABS(c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0))) > 1;
``` ```
@ -341,11 +341,11 @@ HAVING ABS(c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE
```sql ```sql
-- Get complete transaction history for client -- Get complete transaction history for client
SELECT 'DEPOSIT' as type, id, amount, status, created_at, confirmed_at SELECT 'DEPOSIT' as type, id, amount, status, created_at, confirmed_at
FROM spirekeeper.dca_deposits FROM satoshimachine.dca_deposits
WHERE client_id = <client_id> WHERE client_id = <client_id>
UNION ALL UNION ALL
SELECT 'PAYMENT' as type, id, amount_sats, status, created_at, NULL SELECT 'PAYMENT' as type, id, amount_sats, status, created_at, NULL
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
WHERE client_id = <client_id> WHERE client_id = <client_id>
ORDER BY created_at; ORDER BY created_at;
``` ```
@ -361,18 +361,18 @@ ORDER BY created_at;
**Option A: Adjustment Entry** (Recommended) **Option A: Adjustment Entry** (Recommended)
```sql ```sql
-- Create compensating deposit for positive discrepancy -- Create compensating deposit for positive discrepancy
INSERT INTO spirekeeper.dca_deposits (client_id, amount, status, note) INSERT INTO satoshimachine.dca_deposits (client_id, amount, status, note)
VALUES (<client_id>, <adjustment_amount>, 'confirmed', 'Balance correction - reconciliation 2025-10-19'); VALUES (<client_id>, <adjustment_amount>, 'confirmed', 'Balance correction - reconciliation 2025-10-19');
-- OR Create compensating payment for negative discrepancy -- OR Create compensating payment for negative discrepancy
INSERT INTO spirekeeper.dca_payments (client_id, amount_sats, status, note) INSERT INTO satoshimachine.dca_payments (client_id, amount_sats, status, note)
VALUES (<client_id>, <adjustment_amount>, 'completed', 'Balance correction - reconciliation 2025-10-19'); VALUES (<client_id>, <adjustment_amount>, 'completed', 'Balance correction - reconciliation 2025-10-19');
``` ```
**Option B: Direct Balance Update** (Use with extreme caution) **Option B: Direct Balance Update** (Use with extreme caution)
```sql ```sql
-- ONLY if audit trail is complete and discrepancy is unexplained -- ONLY if audit trail is complete and discrepancy is unexplained
UPDATE spirekeeper.dca_clients UPDATE satoshimachine.dca_clients
SET remaining_balance = <correct_balance>, SET remaining_balance = <correct_balance>,
updated_at = NOW() updated_at = NOW()
WHERE id = <client_id>; WHERE id = <client_id>;
@ -396,11 +396,11 @@ async def daily_reconciliation_check():
**Database Constraints**: **Database Constraints**:
```sql ```sql
-- Prevent negative balances -- Prevent negative balances
ALTER TABLE spirekeeper.dca_clients ALTER TABLE satoshimachine.dca_clients
ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0); ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0);
-- Prevent confirmed deposits with zero amount -- Prevent confirmed deposits with zero amount
ALTER TABLE spirekeeper.dca_deposits ALTER TABLE satoshimachine.dca_deposits
ADD CONSTRAINT positive_deposit CHECK (amount > 0); ADD CONSTRAINT positive_deposit CHECK (amount > 0);
``` ```
@ -435,7 +435,7 @@ SELECT
-- Calculate differences -- Calculate differences
base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as base_difference, base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as base_difference,
commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) as commission_difference commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) as commission_difference
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
WHERE ABS(base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) > 1 WHERE ABS(base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) > 1
OR ABS(commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))))) > 1; OR ABS(commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))))) > 1;
``` ```
@ -485,9 +485,9 @@ SELECT
SUM(lt.base_amount) as total_distributed, SUM(lt.base_amount) as total_distributed,
SUM(expected_base) as should_have_distributed, SUM(expected_base) as should_have_distributed,
SUM(expected_base - lt.base_amount) as client_impact SUM(expected_base - lt.base_amount) as client_impact
FROM spirekeeper.lamassu_transactions lt FROM satoshimachine.lamassu_transactions lt
JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
JOIN spirekeeper.dca_clients c ON dp.client_id = c.id JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
WHERE -- filter for affected transactions WHERE -- filter for affected transactions
GROUP BY c.id; GROUP BY c.id;
``` ```
@ -505,7 +505,7 @@ GROUP BY c.id;
- Create compensating payments to affected clients: - Create compensating payments to affected clients:
```sql ```sql
-- Add to client balances -- Add to client balances
UPDATE spirekeeper.dca_clients c UPDATE satoshimachine.dca_clients c
SET remaining_balance = remaining_balance + adjustment.amount SET remaining_balance = remaining_balance + adjustment.amount
FROM ( FROM (
-- Calculate adjustment per client -- Calculate adjustment per client
@ -572,7 +572,7 @@ assert abs(calculated_total - crypto_atoms) <= 1, "Commission calculation error
SELECT SELECT
SUM(commission_amount) as expected_total_commission, SUM(commission_amount) as expected_total_commission,
-- Compare to actual wallet balance in LNBits dashboard -- Compare to actual wallet balance in LNBits dashboard
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
WHERE created_at > '<date-of-last-known-good-balance>'; WHERE created_at > '<date-of-last-known-good-balance>';
``` ```
@ -607,7 +607,7 @@ curl -X GET https://<lnbits-host>/api/v1/wallet \
SELECT commission_wallet_id, SELECT commission_wallet_id,
LEFT(commission_wallet_adminkey, 10) || '...' as key_preview, LEFT(commission_wallet_adminkey, 10) || '...' as key_preview,
updated_at updated_at
FROM spirekeeper.lamassu_config FROM satoshimachine.lamassu_config
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 1; LIMIT 1;
``` ```
@ -783,9 +783,9 @@ WITH client_financials AS (
COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments, COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments,
COUNT(DISTINCT d.id) as deposit_count, COUNT(DISTINCT d.id) as deposit_count,
COUNT(DISTINCT p.id) as payment_count COUNT(DISTINCT p.id) as payment_count
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed' LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
LEFT JOIN spirekeeper.dca_payments p ON c.id = p.client_id LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
GROUP BY c.id GROUP BY c.id
) )
SELECT SELECT
@ -813,7 +813,7 @@ SELECT
SUM(commission_amount) as total_commission, SUM(commission_amount) as total_commission,
MIN(created_at) as first_transaction, MIN(created_at) as first_transaction,
MAX(created_at) as last_transaction MAX(created_at) as last_transaction
FROM spirekeeper.lamassu_transactions; FROM satoshimachine.lamassu_transactions;
-- 3. Failed/Pending Payments Check -- 3. Failed/Pending Payments Check
SELECT SELECT
@ -822,7 +822,7 @@ SELECT
SUM(amount_sats) as total_amount, SUM(amount_sats) as total_amount,
MIN(created_at) as oldest, MIN(created_at) as oldest,
MAX(created_at) as newest MAX(created_at) as newest
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
GROUP BY status GROUP BY status
ORDER BY ORDER BY
CASE status CASE status
@ -839,7 +839,7 @@ SELECT
status, status,
created_at, created_at,
EXTRACT(EPOCH FROM (NOW() - created_at))/3600 as hours_pending EXTRACT(EPOCH FROM (NOW() - created_at))/3600 as hours_pending
FROM spirekeeper.dca_deposits FROM satoshimachine.dca_deposits
WHERE status = 'pending' WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '48 hours' AND created_at < NOW() - INTERVAL '48 hours'
ORDER BY created_at; ORDER BY created_at;
@ -855,7 +855,7 @@ SELECT
commission_amount, commission_amount,
ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as expected_base, ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as expected_base,
base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as difference base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as difference
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20; LIMIT 20;
``` ```
@ -876,15 +876,15 @@ psql -h localhost -U lnbits -d lnbits
2. **Direct Configuration Update**: 2. **Direct Configuration Update**:
```sql ```sql
-- Update Lamassu config directly -- Update Lamassu config directly
UPDATE spirekeeper.lamassu_config UPDATE satoshimachine.lamassu_config
SET polling_enabled = false SET polling_enabled = false
WHERE id = (SELECT MAX(id) FROM spirekeeper.lamassu_config); WHERE id = (SELECT MAX(id) FROM satoshimachine.lamassu_config);
``` ```
3. **Manual Client Balance Update**: 3. **Manual Client Balance Update**:
```sql ```sql
-- ONLY in emergency when dashboard unavailable -- ONLY in emergency when dashboard unavailable
UPDATE spirekeeper.dca_clients UPDATE satoshimachine.dca_clients
SET remaining_balance = <correct_amount> SET remaining_balance = <correct_amount>
WHERE id = <client_id>; WHERE id = <client_id>;
-- MUST document this action in incident log -- MUST document this action in incident log
@ -910,19 +910,19 @@ uv run lnbits
```bash ```bash
# Export all DCA-related tables to CSV # Export all DCA-related tables to CSV
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.lamassu_transactions;" \ "SELECT * FROM satoshimachine.lamassu_transactions;" \
> lamassu_transactions_export_$(date +%Y%m%d_%H%M%S).csv > lamassu_transactions_export_$(date +%Y%m%d_%H%M%S).csv
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.dca_payments;" \ "SELECT * FROM satoshimachine.dca_payments;" \
> dca_payments_export_$(date +%Y%m%d_%H%M%S).csv > dca_payments_export_$(date +%Y%m%d_%H%M%S).csv
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.dca_deposits;" \ "SELECT * FROM satoshimachine.dca_deposits;" \
> dca_deposits_export_$(date +%Y%m%d_%H%M%S).csv > dca_deposits_export_$(date +%Y%m%d_%H%M%S).csv
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.dca_clients;" \ "SELECT * FROM satoshimachine.dca_clients;" \
> dca_clients_export_$(date +%Y%m%d_%H%M%S).csv > dca_clients_export_$(date +%Y%m%d_%H%M%S).csv
``` ```
@ -946,9 +946,9 @@ SELECT
dp.amount_sats as client_received, dp.amount_sats as client_received,
dp.status as payment_status, dp.status as payment_status,
dp.payment_hash dp.payment_hash
FROM spirekeeper.lamassu_transactions lt FROM satoshimachine.lamassu_transactions lt
LEFT JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
LEFT JOIN spirekeeper.dca_clients c ON dp.client_id = c.id LEFT JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
ORDER BY lt.created_at DESC, c.username; ORDER BY lt.created_at DESC, c.username;
``` ```
@ -981,18 +981,18 @@ async def process_lamassu_transaction(txn_data: dict) -> Optional[LamassuTransac
```sql ```sql
-- Add unique constraint on transaction_id -- Add unique constraint on transaction_id
ALTER TABLE spirekeeper.lamassu_transactions ALTER TABLE satoshimachine.lamassu_transactions
ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id); ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id);
-- Prevent negative balances -- Prevent negative balances
ALTER TABLE spirekeeper.dca_clients ALTER TABLE satoshimachine.dca_clients
ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0); ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0);
-- Ensure positive amounts -- Ensure positive amounts
ALTER TABLE spirekeeper.dca_deposits ALTER TABLE satoshimachine.dca_deposits
ADD CONSTRAINT positive_deposit CHECK (amount > 0); ADD CONSTRAINT positive_deposit CHECK (amount > 0);
ALTER TABLE spirekeeper.dca_payments ALTER TABLE satoshimachine.dca_payments
ADD CONSTRAINT positive_payment CHECK (amount_sats > 0); ADD CONSTRAINT positive_payment CHECK (amount_sats > 0);
``` ```
@ -1196,7 +1196,7 @@ grep "wallet.*api\|payment_hash" lnbits.log | tail -50
### System Access ### System Access
**LNBits Admin Dashboard**: **LNBits Admin Dashboard**:
- URL: `https://<your-lnbits-host>/spirekeeper` - URL: `https://<your-lnbits-host>/satoshimachine`
- Requires superuser authentication - Requires superuser authentication
**Database Access**: **Database Access**:
@ -1205,7 +1205,7 @@ grep "wallet.*api\|payment_hash" lnbits.log | tail -50
sqlite3 /home/padreug/AioLabs/Git/lnbits-extensions/lnbits/data/database.sqlite sqlite3 /home/padreug/AioLabs/Git/lnbits-extensions/lnbits/data/database.sqlite
# Direct table access # Direct table access
sqlite3 /path/to/db "SELECT * FROM spirekeeper.<table_name>;" sqlite3 /path/to/db "SELECT * FROM satoshimachine.<table_name>;"
``` ```
**Log Files**: **Log Files**:
@ -1274,7 +1274,7 @@ pkill -f lnbits
```sql ```sql
-- Disable automatic polling -- Disable automatic polling
UPDATE spirekeeper.lamassu_config UPDATE satoshimachine.lamassu_config
SET polling_enabled = false; SET polling_enabled = false;
``` ```
@ -1283,7 +1283,7 @@ SET polling_enabled = false;
```sql ```sql
-- All client balances summary -- All client balances summary
SELECT id, username, remaining_balance, created_at SELECT id, username, remaining_balance, created_at
FROM spirekeeper.dca_clients FROM satoshimachine.dca_clients
ORDER BY remaining_balance DESC; ORDER BY remaining_balance DESC;
``` ```
@ -1291,7 +1291,7 @@ ORDER BY remaining_balance DESC;
```sql ```sql
SELECT id, transaction_id, created_at, crypto_atoms, base_amount, commission_amount SELECT id, transaction_id, created_at, crypto_atoms, base_amount, commission_amount
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 10; LIMIT 10;
``` ```
@ -1299,7 +1299,7 @@ LIMIT 10;
### Failed Payments ### Failed Payments
```sql ```sql
SELECT * FROM spirekeeper.dca_payments SELECT * FROM satoshimachine.dca_payments
WHERE status != 'completed' WHERE status != 'completed'
ORDER BY created_at DESC; ORDER BY created_at DESC;
``` ```
@ -1314,19 +1314,19 @@ BACKUP_DIR="emergency_backup_${DATE}"
mkdir -p $BACKUP_DIR mkdir -p $BACKUP_DIR
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.lamassu_transactions;" \ "SELECT * FROM satoshimachine.lamassu_transactions;" \
> ${BACKUP_DIR}/lamassu_transactions.csv > ${BACKUP_DIR}/lamassu_transactions.csv
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.dca_payments;" \ "SELECT * FROM satoshimachine.dca_payments;" \
> ${BACKUP_DIR}/dca_payments.csv > ${BACKUP_DIR}/dca_payments.csv
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.dca_deposits;" \ "SELECT * FROM satoshimachine.dca_deposits;" \
> ${BACKUP_DIR}/dca_deposits.csv > ${BACKUP_DIR}/dca_deposits.csv
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.dca_clients;" \ "SELECT * FROM satoshimachine.dca_clients;" \
> ${BACKUP_DIR}/dca_clients.csv > ${BACKUP_DIR}/dca_clients.csv
echo "Backup complete in ${BACKUP_DIR}/" echo "Backup complete in ${BACKUP_DIR}/"

View file

@ -38,7 +38,7 @@
-- Find duplicate transactions -- Find duplicate transactions
SELECT transaction_id, COUNT(*) as count, SELECT transaction_id, COUNT(*) as count,
STRING_AGG(id::text, ', ') as record_ids STRING_AGG(id::text, ', ') as record_ids
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
GROUP BY transaction_id GROUP BY transaction_id
HAVING COUNT(*) > 1; HAVING COUNT(*) > 1;
``` ```
@ -60,16 +60,16 @@ HAVING COUNT(*) > 1;
-- Step 1: Identify duplicate distributions -- Step 1: Identify duplicate distributions
SELECT lt.transaction_id, lt.id, lt.created_at, lt.base_amount, SELECT lt.transaction_id, lt.id, lt.created_at, lt.base_amount,
COUNT(dp.id) as distribution_count COUNT(dp.id) as distribution_count
FROM spirekeeper.lamassu_transactions lt FROM satoshimachine.lamassu_transactions lt
LEFT JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
GROUP BY lt.id GROUP BY lt.id
HAVING COUNT(dp.id) > (SELECT COUNT(*) FROM spirekeeper.dca_clients WHERE remaining_balance > 0); HAVING COUNT(dp.id) > (SELECT COUNT(*) FROM satoshimachine.dca_clients WHERE remaining_balance > 0);
-- Step 2: Calculate over-distributed amounts per client -- Step 2: Calculate over-distributed amounts per client
SELECT client_id, SELECT client_id,
SUM(amount_sats) as total_received, SUM(amount_sats) as total_received,
-- Manual calculation of expected amount needed here -- Manual calculation of expected amount needed here
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
WHERE lamassu_transaction_id IN (SELECT id FROM duplicates_table) WHERE lamassu_transaction_id IN (SELECT id FROM duplicates_table)
GROUP BY client_id; GROUP BY client_id;
``` ```
@ -94,7 +94,7 @@ if existing:
**Required Database Change**: **Required Database Change**:
```sql ```sql
ALTER TABLE spirekeeper.lamassu_transactions ALTER TABLE satoshimachine.lamassu_transactions
ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id); ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id);
``` ```
@ -117,7 +117,7 @@ ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id);
-- Check last successful poll -- Check last successful poll
SELECT MAX(created_at) as last_transaction, SELECT MAX(created_at) as last_transaction,
EXTRACT(EPOCH FROM (NOW() - MAX(created_at)))/3600 as hours_since_last EXTRACT(EPOCH FROM (NOW() - MAX(created_at)))/3600 as hours_since_last
FROM spirekeeper.lamassu_transactions; FROM satoshimachine.lamassu_transactions;
-- If hours_since_last > 24, investigate immediately -- If hours_since_last > 24, investigate immediately
``` ```
@ -194,8 +194,8 @@ ssh-keygen -t ed25519 -f ~/.ssh/satmachine_lamassu -C "satmachine-polling"
-- Find stuck/failed payments (older than 1 hour, not completed) -- Find stuck/failed payments (older than 1 hour, not completed)
SELECT dp.id, dp.client_id, dp.amount_sats, dp.status, dp.created_at, SELECT dp.id, dp.client_id, dp.amount_sats, dp.status, dp.created_at,
c.username, dp.payment_hash c.username, dp.payment_hash
FROM spirekeeper.dca_payments dp FROM satoshimachine.dca_payments dp
JOIN spirekeeper.dca_clients c ON dp.client_id = c.id JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
WHERE dp.status != 'completed' WHERE dp.status != 'completed'
AND dp.created_at < NOW() - INTERVAL '1 hour' AND dp.created_at < NOW() - INTERVAL '1 hour'
ORDER BY dp.created_at DESC; ORDER BY dp.created_at DESC;
@ -207,7 +207,7 @@ ORDER BY dp.created_at DESC;
SELECT SELECT
SUM(commission_amount) as total_commission_expected, SUM(commission_amount) as total_commission_expected,
-- Manually check actual wallet balance in LNBits -- Manually check actual wallet balance in LNBits
FROM spirekeeper.lamassu_transactions; FROM satoshimachine.lamassu_transactions;
``` ```
#### Immediate Response #### Immediate Response
@ -258,9 +258,9 @@ SELECT
c.remaining_balance, c.remaining_balance,
COALESCE(SUM(d.amount), 0) as total_deposits, COALESCE(SUM(d.amount), 0) as total_deposits,
COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed' LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
LEFT JOIN spirekeeper.dca_payments p ON c.id = p.client_id LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
GROUP BY c.id; GROUP BY c.id;
``` ```
@ -319,9 +319,9 @@ SELECT
COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_distributed, COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_distributed,
COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as calculated_balance, COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as calculated_balance,
c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0)) as discrepancy c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0)) as discrepancy
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed' LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
LEFT JOIN spirekeeper.dca_payments p ON c.id = p.client_id LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
GROUP BY c.id, c.username, c.remaining_balance GROUP BY c.id, c.username, c.remaining_balance
HAVING ABS(c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0))) > 1; HAVING ABS(c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0))) > 1;
``` ```
@ -341,11 +341,11 @@ HAVING ABS(c.remaining_balance - (COALESCE(SUM(d.amount), 0) - COALESCE(SUM(CASE
```sql ```sql
-- Get complete transaction history for client -- Get complete transaction history for client
SELECT 'DEPOSIT' as type, id, amount, status, created_at, confirmed_at SELECT 'DEPOSIT' as type, id, amount, status, created_at, confirmed_at
FROM spirekeeper.dca_deposits FROM satoshimachine.dca_deposits
WHERE client_id = <client_id> WHERE client_id = <client_id>
UNION ALL UNION ALL
SELECT 'PAYMENT' as type, id, amount_sats, status, created_at, NULL SELECT 'PAYMENT' as type, id, amount_sats, status, created_at, NULL
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
WHERE client_id = <client_id> WHERE client_id = <client_id>
ORDER BY created_at; ORDER BY created_at;
``` ```
@ -361,18 +361,18 @@ ORDER BY created_at;
**Option A: Adjustment Entry** (Recommended) **Option A: Adjustment Entry** (Recommended)
```sql ```sql
-- Create compensating deposit for positive discrepancy -- Create compensating deposit for positive discrepancy
INSERT INTO spirekeeper.dca_deposits (client_id, amount, status, note) INSERT INTO satoshimachine.dca_deposits (client_id, amount, status, note)
VALUES (<client_id>, <adjustment_amount>, 'confirmed', 'Balance correction - reconciliation 2025-10-19'); VALUES (<client_id>, <adjustment_amount>, 'confirmed', 'Balance correction - reconciliation 2025-10-19');
-- OR Create compensating payment for negative discrepancy -- OR Create compensating payment for negative discrepancy
INSERT INTO spirekeeper.dca_payments (client_id, amount_sats, status, note) INSERT INTO satoshimachine.dca_payments (client_id, amount_sats, status, note)
VALUES (<client_id>, <adjustment_amount>, 'completed', 'Balance correction - reconciliation 2025-10-19'); VALUES (<client_id>, <adjustment_amount>, 'completed', 'Balance correction - reconciliation 2025-10-19');
``` ```
**Option B: Direct Balance Update** (Use with extreme caution) **Option B: Direct Balance Update** (Use with extreme caution)
```sql ```sql
-- ONLY if audit trail is complete and discrepancy is unexplained -- ONLY if audit trail is complete and discrepancy is unexplained
UPDATE spirekeeper.dca_clients UPDATE satoshimachine.dca_clients
SET remaining_balance = <correct_balance>, SET remaining_balance = <correct_balance>,
updated_at = NOW() updated_at = NOW()
WHERE id = <client_id>; WHERE id = <client_id>;
@ -396,11 +396,11 @@ async def daily_reconciliation_check():
**Database Constraints**: **Database Constraints**:
```sql ```sql
-- Prevent negative balances -- Prevent negative balances
ALTER TABLE spirekeeper.dca_clients ALTER TABLE satoshimachine.dca_clients
ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0); ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0);
-- Prevent confirmed deposits with zero amount -- Prevent confirmed deposits with zero amount
ALTER TABLE spirekeeper.dca_deposits ALTER TABLE satoshimachine.dca_deposits
ADD CONSTRAINT positive_deposit CHECK (amount > 0); ADD CONSTRAINT positive_deposit CHECK (amount > 0);
``` ```
@ -435,7 +435,7 @@ SELECT
-- Calculate differences -- Calculate differences
base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as base_difference, base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as base_difference,
commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) as commission_difference commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) as commission_difference
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
WHERE ABS(base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) > 1 WHERE ABS(base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100)))) > 1
OR ABS(commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))))) > 1; OR ABS(commission_amount - ROUND(crypto_atoms - (crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))))) > 1;
``` ```
@ -485,9 +485,9 @@ SELECT
SUM(lt.base_amount) as total_distributed, SUM(lt.base_amount) as total_distributed,
SUM(expected_base) as should_have_distributed, SUM(expected_base) as should_have_distributed,
SUM(expected_base - lt.base_amount) as client_impact SUM(expected_base - lt.base_amount) as client_impact
FROM spirekeeper.lamassu_transactions lt FROM satoshimachine.lamassu_transactions lt
JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
JOIN spirekeeper.dca_clients c ON dp.client_id = c.id JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
WHERE -- filter for affected transactions WHERE -- filter for affected transactions
GROUP BY c.id; GROUP BY c.id;
``` ```
@ -505,7 +505,7 @@ GROUP BY c.id;
- Create compensating payments to affected clients: - Create compensating payments to affected clients:
```sql ```sql
-- Add to client balances -- Add to client balances
UPDATE spirekeeper.dca_clients c UPDATE satoshimachine.dca_clients c
SET remaining_balance = remaining_balance + adjustment.amount SET remaining_balance = remaining_balance + adjustment.amount
FROM ( FROM (
-- Calculate adjustment per client -- Calculate adjustment per client
@ -572,7 +572,7 @@ assert abs(calculated_total - crypto_atoms) <= 1, "Commission calculation error
SELECT SELECT
SUM(commission_amount) as expected_total_commission, SUM(commission_amount) as expected_total_commission,
-- Compare to actual wallet balance in LNBits dashboard -- Compare to actual wallet balance in LNBits dashboard
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
WHERE created_at > '<date-of-last-known-good-balance>'; WHERE created_at > '<date-of-last-known-good-balance>';
``` ```
@ -607,7 +607,7 @@ curl -X GET https://<lnbits-host>/api/v1/wallet \
SELECT commission_wallet_id, SELECT commission_wallet_id,
LEFT(commission_wallet_adminkey, 10) || '...' as key_preview, LEFT(commission_wallet_adminkey, 10) || '...' as key_preview,
updated_at updated_at
FROM spirekeeper.lamassu_config FROM satoshimachine.lamassu_config
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 1; LIMIT 1;
``` ```
@ -783,9 +783,9 @@ WITH client_financials AS (
COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments, COALESCE(SUM(CASE WHEN p.status = 'completed' THEN p.amount_sats ELSE 0 END), 0) as total_payments,
COUNT(DISTINCT d.id) as deposit_count, COUNT(DISTINCT d.id) as deposit_count,
COUNT(DISTINCT p.id) as payment_count COUNT(DISTINCT p.id) as payment_count
FROM spirekeeper.dca_clients c FROM satoshimachine.dca_clients c
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed' LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
LEFT JOIN spirekeeper.dca_payments p ON c.id = p.client_id LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
GROUP BY c.id GROUP BY c.id
) )
SELECT SELECT
@ -813,7 +813,7 @@ SELECT
SUM(commission_amount) as total_commission, SUM(commission_amount) as total_commission,
MIN(created_at) as first_transaction, MIN(created_at) as first_transaction,
MAX(created_at) as last_transaction MAX(created_at) as last_transaction
FROM spirekeeper.lamassu_transactions; FROM satoshimachine.lamassu_transactions;
-- 3. Failed/Pending Payments Check -- 3. Failed/Pending Payments Check
SELECT SELECT
@ -822,7 +822,7 @@ SELECT
SUM(amount_sats) as total_amount, SUM(amount_sats) as total_amount,
MIN(created_at) as oldest, MIN(created_at) as oldest,
MAX(created_at) as newest MAX(created_at) as newest
FROM spirekeeper.dca_payments FROM satoshimachine.dca_payments
GROUP BY status GROUP BY status
ORDER BY ORDER BY
CASE status CASE status
@ -839,7 +839,7 @@ SELECT
status, status,
created_at, created_at,
EXTRACT(EPOCH FROM (NOW() - created_at))/3600 as hours_pending EXTRACT(EPOCH FROM (NOW() - created_at))/3600 as hours_pending
FROM spirekeeper.dca_deposits FROM satoshimachine.dca_deposits
WHERE status = 'pending' WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '48 hours' AND created_at < NOW() - INTERVAL '48 hours'
ORDER BY created_at; ORDER BY created_at;
@ -855,7 +855,7 @@ SELECT
commission_amount, commission_amount,
ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as expected_base, ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as expected_base,
base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as difference base_amount - ROUND(crypto_atoms / (1 + (commission_percentage * (100 - discount) / 100))) as difference
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20; LIMIT 20;
``` ```
@ -876,15 +876,15 @@ psql -h localhost -U lnbits -d lnbits
2. **Direct Configuration Update**: 2. **Direct Configuration Update**:
```sql ```sql
-- Update Lamassu config directly -- Update Lamassu config directly
UPDATE spirekeeper.lamassu_config UPDATE satoshimachine.lamassu_config
SET polling_enabled = false SET polling_enabled = false
WHERE id = (SELECT MAX(id) FROM spirekeeper.lamassu_config); WHERE id = (SELECT MAX(id) FROM satoshimachine.lamassu_config);
``` ```
3. **Manual Client Balance Update**: 3. **Manual Client Balance Update**:
```sql ```sql
-- ONLY in emergency when dashboard unavailable -- ONLY in emergency when dashboard unavailable
UPDATE spirekeeper.dca_clients UPDATE satoshimachine.dca_clients
SET remaining_balance = <correct_amount> SET remaining_balance = <correct_amount>
WHERE id = <client_id>; WHERE id = <client_id>;
-- MUST document this action in incident log -- MUST document this action in incident log
@ -910,19 +910,19 @@ uv run lnbits
```bash ```bash
# Export all DCA-related tables to CSV # Export all DCA-related tables to CSV
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.lamassu_transactions;" \ "SELECT * FROM satoshimachine.lamassu_transactions;" \
> lamassu_transactions_export_$(date +%Y%m%d_%H%M%S).csv > lamassu_transactions_export_$(date +%Y%m%d_%H%M%S).csv
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.dca_payments;" \ "SELECT * FROM satoshimachine.dca_payments;" \
> dca_payments_export_$(date +%Y%m%d_%H%M%S).csv > dca_payments_export_$(date +%Y%m%d_%H%M%S).csv
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.dca_deposits;" \ "SELECT * FROM satoshimachine.dca_deposits;" \
> dca_deposits_export_$(date +%Y%m%d_%H%M%S).csv > dca_deposits_export_$(date +%Y%m%d_%H%M%S).csv
sqlite3 -header -csv /path/to/lnbits/database.sqlite \ sqlite3 -header -csv /path/to/lnbits/database.sqlite \
"SELECT * FROM spirekeeper.dca_clients;" \ "SELECT * FROM satoshimachine.dca_clients;" \
> dca_clients_export_$(date +%Y%m%d_%H%M%S).csv > dca_clients_export_$(date +%Y%m%d_%H%M%S).csv
``` ```
@ -946,9 +946,9 @@ SELECT
dp.amount_sats as client_received, dp.amount_sats as client_received,
dp.status as payment_status, dp.status as payment_status,
dp.payment_hash dp.payment_hash
FROM spirekeeper.lamassu_transactions lt FROM satoshimachine.lamassu_transactions lt
LEFT JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
LEFT JOIN spirekeeper.dca_clients c ON dp.client_id = c.id LEFT JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
ORDER BY lt.created_at DESC, c.username; ORDER BY lt.created_at DESC, c.username;
``` ```
@ -981,18 +981,18 @@ async def process_lamassu_transaction(txn_data: dict) -> Optional[LamassuTransac
```sql ```sql
-- Add unique constraint on transaction_id -- Add unique constraint on transaction_id
ALTER TABLE spirekeeper.lamassu_transactions ALTER TABLE satoshimachine.lamassu_transactions
ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id); ADD CONSTRAINT unique_transaction_id UNIQUE (transaction_id);
-- Prevent negative balances -- Prevent negative balances
ALTER TABLE spirekeeper.dca_clients ALTER TABLE satoshimachine.dca_clients
ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0); ADD CONSTRAINT positive_balance CHECK (remaining_balance >= 0);
-- Ensure positive amounts -- Ensure positive amounts
ALTER TABLE spirekeeper.dca_deposits ALTER TABLE satoshimachine.dca_deposits
ADD CONSTRAINT positive_deposit CHECK (amount > 0); ADD CONSTRAINT positive_deposit CHECK (amount > 0);
ALTER TABLE spirekeeper.dca_payments ALTER TABLE satoshimachine.dca_payments
ADD CONSTRAINT positive_payment CHECK (amount_sats > 0); ADD CONSTRAINT positive_payment CHECK (amount_sats > 0);
``` ```
@ -1196,7 +1196,7 @@ grep "wallet.*api\|payment_hash" lnbits.log | tail -50
### System Access ### System Access
**LNBits Admin Dashboard**: **LNBits Admin Dashboard**:
- URL: `https://<your-lnbits-host>/spirekeeper` - URL: `https://<your-lnbits-host>/satoshimachine`
- Requires superuser authentication - Requires superuser authentication
**Database Access**: **Database Access**:
@ -1205,7 +1205,7 @@ grep "wallet.*api\|payment_hash" lnbits.log | tail -50
sqlite3 /home/padreug/AioLabs/Git/lnbits-extensions/lnbits/data/database.sqlite sqlite3 /home/padreug/AioLabs/Git/lnbits-extensions/lnbits/data/database.sqlite
# Direct table access # Direct table access
sqlite3 /path/to/db "SELECT * FROM spirekeeper.<table_name>;" sqlite3 /path/to/db "SELECT * FROM satoshimachine.<table_name>;"
``` ```
**Log Files**: **Log Files**:
@ -1274,7 +1274,7 @@ pkill -f lnbits
```sql ```sql
-- Disable automatic polling -- Disable automatic polling
UPDATE spirekeeper.lamassu_config UPDATE satoshimachine.lamassu_config
SET polling_enabled = false; SET polling_enabled = false;
``` ```
@ -1283,7 +1283,7 @@ SET polling_enabled = false;
```sql ```sql
-- All client balances summary -- All client balances summary
SELECT id, username, remaining_balance, created_at SELECT id, username, remaining_balance, created_at
FROM spirekeeper.dca_clients FROM satoshimachine.dca_clients
ORDER BY remaining_balance DESC; ORDER BY remaining_balance DESC;
``` ```
@ -1291,7 +1291,7 @@ ORDER BY remaining_balance DESC;
```sql ```sql
SELECT id, transaction_id, created_at, crypto_atoms, base_amount, commission_amount SELECT id, transaction_id, created_at, crypto_atoms, base_amount, commission_amount
FROM spirekeeper.lamassu_transactions FROM satoshimachine.lamassu_transactions
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 10; LIMIT 10;
``` ```
@ -1299,7 +1299,7 @@ LIMIT 10;
### Failed Payments ### Failed Payments
```sql ```sql
SELECT * FROM spirekeeper.dca_payments SELECT * FROM satoshimachine.dca_payments
WHERE status != 'completed' WHERE status != 'completed'
ORDER BY created_at DESC; ORDER BY created_at DESC;
``` ```
@ -1314,19 +1314,19 @@ BACKUP_DIR="emergency_backup_${DATE}"
mkdir -p $BACKUP_DIR mkdir -p $BACKUP_DIR
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.lamassu_transactions;" \ "SELECT * FROM satoshimachine.lamassu_transactions;" \
> ${BACKUP_DIR}/lamassu_transactions.csv > ${BACKUP_DIR}/lamassu_transactions.csv
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.dca_payments;" \ "SELECT * FROM satoshimachine.dca_payments;" \
> ${BACKUP_DIR}/dca_payments.csv > ${BACKUP_DIR}/dca_payments.csv
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.dca_deposits;" \ "SELECT * FROM satoshimachine.dca_deposits;" \
> ${BACKUP_DIR}/dca_deposits.csv > ${BACKUP_DIR}/dca_deposits.csv
sqlite3 -header -csv /path/to/database.sqlite \ sqlite3 -header -csv /path/to/database.sqlite \
"SELECT * FROM spirekeeper.dca_clients;" \ "SELECT * FROM satoshimachine.dca_clients;" \
> ${BACKUP_DIR}/dca_clients.csv > ${BACKUP_DIR}/dca_clients.csv
echo "Backup complete in ${BACKUP_DIR}/" echo "Backup complete in ${BACKUP_DIR}/"

View file

@ -288,7 +288,7 @@ async def publish_encrypted_kind_30078(
await publish_signed_event(signed) await publish_signed_event(signed)
prefix = f"{log_context}: " if log_context else "" prefix = f"{log_context}: " if log_context else ""
logger.info( logger.info(
f"spirekeeper: {prefix}published kind-30078 to ATM " f"satmachineadmin: {prefix}published kind-30078 to ATM "
f"{recipient_pubkey_hex[:12]}... d-tag={d_tag} " f"{recipient_pubkey_hex[:12]}... d-tag={d_tag} "
f"event_id={signed['id'][:12]}..." f"event_id={signed['id'][:12]}..."
) )

View file

@ -11,7 +11,7 @@ on a match.
The hook is registered with lnbits' `nostr_transport` at extension-init The hook is registered with lnbits' `nostr_transport` at extension-init
time via `register_with_lnbits()`. Until the lnbits side ships time via `register_with_lnbits()`. Until the lnbits side ships
`lnbits.core.services.nostr_transport.register_roster_resolver`, the `lnbits.core.services.nostr_transport.register_roster_resolver`, the
registration call lazily-imports + soft-fails so spirekeeper keeps registration call lazily-imports + soft-fails so satmachineadmin keeps
loading cleanly on any lnbits version. loading cleanly on any lnbits version.
When the lnbits implementation lands + the satmachine instance has When the lnbits implementation lands + the satmachine instance has
@ -37,7 +37,7 @@ from loguru import logger
from .crud import get_machine_by_atm_pubkey_hex from .crud import get_machine_by_atm_pubkey_hex
_SOURCE_EXTENSION = "spirekeeper" _SOURCE_EXTENSION = "satmachineadmin"
@dataclass(frozen=True) @dataclass(frozen=True)
@ -111,11 +111,11 @@ def register_with_lnbits() -> bool:
Returns True if the registration landed (lnbits surface available Returns True if the registration landed (lnbits surface available
+ call succeeded), False if soft-failed because lnbits hasn't + call succeeded), False if soft-failed because lnbits hasn't
shipped `register_roster_resolver` yet that's the expected shipped `register_roster_resolver` yet that's the expected
state until the path-B lnbits PR lands. Either way spirekeeper state until the path-B lnbits PR lands. Either way satmachineadmin
boots cleanly; only the routing-via-roster behavior is gated on boots cleanly; only the routing-via-roster behavior is gated on
the lnbits side being present. the lnbits side being present.
Called once from `spirekeeper_start()`. Idempotent on the Called once from `satmachineadmin_start()`. Idempotent on the
lnbits side per their 15:15Z spec ("re-registration on extension lnbits side per their 15:15Z spec ("re-registration on extension
reload replaces cleanly"). reload replaces cleanly").
""" """
@ -125,7 +125,7 @@ def register_with_lnbits() -> bool:
) )
except ImportError: except ImportError:
logger.info( logger.info(
"spirekeeper: nostr-transport roster-resolver hook not " "satmachineadmin: nostr-transport roster-resolver hook not "
"available on this lnbits version (pre-path-B); ATM-npub " "available on this lnbits version (pre-path-B); ATM-npub "
"routing falls through to lnbits' default auto-account-from-" "routing falls through to lnbits' default auto-account-from-"
"npub behaviour. See aiolabs/satmachineadmin#20 / coord-log " "npub behaviour. See aiolabs/satmachineadmin#20 / coord-log "
@ -134,7 +134,7 @@ def register_with_lnbits() -> bool:
return False return False
register_roster_resolver(_SOURCE_EXTENSION, resolve) register_roster_resolver(_SOURCE_EXTENSION, resolve)
logger.info( logger.info(
f"spirekeeper: registered '{_SOURCE_EXTENSION}' roster " f"satmachineadmin: registered '{_SOURCE_EXTENSION}' roster "
"resolver with lnbits nostr-transport — inbound kind-21000 " "resolver with lnbits nostr-transport — inbound kind-21000 "
"from a registered ATM npub will route to the operator's wallet " "from a registered ATM npub will route to the operator's wallet "
"directly. (Behavior gated server-side by " "directly. (Behavior gated server-side by "

View file

@ -1,7 +1,7 @@
[tool.poetry] [tool.poetry]
name = "spirekeeper" name = "satmachineadmin"
version = "0.1.0" version = "0.0.4"
description = "bitSpire admin — Lightning DCA + Lamassu ATM operator administration for LNbits" description = "Eightball is a simple API that allows you to create a random number generator."
authors = ["benarc", "dni <dni@lnbits.com>"] authors = ["benarc", "dni <dni@lnbits.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]

View file

@ -1,6 +1,6 @@
// Satoshi Machine v2 — operator dashboard (P9a foundation). // Satoshi Machine v2 — operator dashboard (P9a foundation).
// //
// Vue 3 + Quasar UMD app. Talks to the v2 spirekeeper REST surface // Vue 3 + Quasar UMD app. Talks to the v2 satmachineadmin REST surface
// (machines / clients / deposits / settlements / commission-splits / // (machines / clients / deposits / settlements / commission-splits /
// super-config). All endpoints are operator-scoped via the LNbits session. // super-config). All endpoints are operator-scoped via the LNbits session.
// //
@ -12,7 +12,7 @@
// - For pale backgrounds (bg-*-1), pair with explicit dark text class // - For pale backgrounds (bg-*-1), pair with explicit dark text class
// so dark-mode users don't get unreadable white-on-cream. // so dark-mode users don't get unreadable white-on-cream.
const API = '/spirekeeper/api/v1/dca' const API = '/satmachineadmin/api/v1/dca'
const SUPER_FEE_PATH = `${API}/super-config` const SUPER_FEE_PATH = `${API}/super-config`
const MACHINES_PATH = `${API}/machines` const MACHINES_PATH = `${API}/machines`
const SETTLEMENTS_PATH = `${API}/settlements` const SETTLEMENTS_PATH = `${API}/settlements`
@ -198,7 +198,7 @@ window.app = Vue.createApp({
settlements: [], settlements: [],
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm / // Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
// submitCassettePublish methods + the cassettes panel in // submitCassettePublish methods + the cassettes panel in
// templates/spirekeeper/index.html. // templates/satmachineadmin/index.html.
activeTab: 'settlements', activeTab: 'settlements',
cassetteEdits: [], // editable working copy of cassette_configs rows cassetteEdits: [], // editable working copy of cassette_configs rows
cassettesPristine: [], // last-known-clean snapshot for revert cassettesPristine: [], // last-known-clean snapshot for revert

View file

@ -45,7 +45,7 @@ from .crud import (
from .distribution import process_settlement from .distribution import process_settlement
from .models import CreateDcaSettlementData, Machine from .models import CreateDcaSettlementData, Machine
LISTENER_NAME = "ext_spirekeeper" LISTENER_NAME = "ext_satmachineadmin"
# Holds strong refs to in-flight distribution tasks so Python's GC doesn't # Holds strong refs to in-flight distribution tasks so Python's GC doesn't
# collect them mid-flight (asyncio.create_task only weakly references its # collect them mid-flight (asyncio.create_task only weakly references its
@ -58,7 +58,7 @@ async def wait_for_paid_invoices() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue() invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, LISTENER_NAME) register_invoice_listener(invoice_queue, LISTENER_NAME)
logger.info( logger.info(
"spirekeeper v2: invoice listener registered as " "satmachineadmin v2: invoice listener registered as "
f"`{LISTENER_NAME}` — waiting for bitSpire settlements." f"`{LISTENER_NAME}` — waiting for bitSpire settlements."
) )
while True: while True:
@ -67,7 +67,7 @@ async def wait_for_paid_invoices() -> None:
await _handle_payment(payment) await _handle_payment(payment)
except Exception as exc: # listener must never die except Exception as exc: # listener must never die
logger.error( logger.error(
f"spirekeeper: error handling payment " f"satmachineadmin: error handling payment "
f"{payment.payment_hash[:12]}...: {exc}" f"{payment.payment_hash[:12]}...: {exc}"
) )
@ -169,12 +169,12 @@ async def _handle_payment(payment: Payment) -> None:
settlement = await create_settlement_idempotent(data, initial_status="pending") settlement = await create_settlement_idempotent(data, initial_status="pending")
if settlement is None: if settlement is None:
logger.error( logger.error(
f"spirekeeper: failed to insert settlement for " f"satmachineadmin: failed to insert settlement for "
f"payment_hash={payment.payment_hash[:12]}..." f"payment_hash={payment.payment_hash[:12]}..."
) )
return return
logger.info( logger.info(
f"spirekeeper: landed settlement {settlement.id} for " f"satmachineadmin: landed settlement {settlement.id} for "
f"machine={machine.machine_npub[:12]}... " f"machine={machine.machine_npub[:12]}... "
f"wire={data.wire_sats}sats principal={data.principal_sats}sats " f"wire={data.wire_sats}sats principal={data.principal_sats}sats "
f"fee={data.fee_sats}sats " f"fee={data.fee_sats}sats "
@ -224,12 +224,12 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
) )
if rejected is None: if rejected is None:
logger.error( logger.error(
f"spirekeeper: failed to insert rejected settlement for " f"satmachineadmin: failed to insert rejected settlement for "
f"payment_hash={payment.payment_hash[:12]}..." f"payment_hash={payment.payment_hash[:12]}..."
) )
return return
logger.error( logger.error(
f"spirekeeper: rejected settlement {rejected.id} " f"satmachineadmin: rejected settlement {rejected.id} "
f"(machine={machine.machine_npub[:12]}..., " f"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}" f"payment_hash={payment.payment_hash[:12]}...): {exc}"
) )
@ -245,7 +245,7 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
# upserts cassette_configs via apply_bootstrap_state. # upserts cassette_configs via apply_bootstrap_state.
# #
# v1 = one-shot per machine (ATM's meta.bootstrapPublishedAt makes the # v1 = one-shot per machine (ATM's meta.bootstrapPublishedAt makes the
# publish idempotent on ATM-side restart; spirekeeper's apply_bootstrap_ # publish idempotent on ATM-side restart; satmachineadmin's apply_bootstrap_
# state dedups on state_event_id for relay re-delivery). # state dedups on state_event_id for relay re-delivery).
# #
# v2 (separate issue) = continuous reverse-channel consumer with a # v2 (separate issue) = continuous reverse-channel consumer with a
@ -258,7 +258,7 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
# websocket. The relay manager is the same singleton publish_to_atm uses, # websocket. The relay manager is the same singleton publish_to_atm uses,
# so add_subscription registers a filter against the same relay pool. # so add_subscription registers a filter against the same relay pool.
CASSETTE_BOOTSTRAP_SUB_ID = "spirekeeper-cassette-bootstrap" CASSETTE_BOOTSTRAP_SUB_ID = "satmachineadmin-cassette-bootstrap"
_CASSETTE_POLL_INTERVAL_S = 2.0 _CASSETTE_POLL_INTERVAL_S = 2.0
_CASSETTE_BACKOFF_S = 30.0 # when nostrclient isn't installed yet _CASSETTE_BACKOFF_S = 30.0 # when nostrclient isn't installed yet
@ -280,7 +280,7 @@ async def wait_for_cassette_state_events() -> None:
- apply_bootstrap_state errors log + skip - apply_bootstrap_state errors log + skip
""" """
logger.info( logger.info(
"spirekeeper v2: cassette bootstrap consumer starting " "satmachineadmin v2: cassette bootstrap consumer starting "
f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})" f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})"
) )
current_filter_key: str | None = None current_filter_key: str | None = None
@ -290,7 +290,7 @@ async def wait_for_cassette_state_events() -> None:
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S) await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
except _NostrclientUnavailable: except _NostrclientUnavailable:
logger.warning( logger.warning(
"spirekeeper: nostrclient extension not installed; " "satmachineadmin: nostrclient extension not installed; "
f"cassette bootstrap consumer sleeping {_CASSETTE_BACKOFF_S}s " f"cassette bootstrap consumer sleeping {_CASSETTE_BACKOFF_S}s "
"before retry. Install + activate nostrclient on this " "before retry. Install + activate nostrclient on this "
"LNbits instance." "LNbits instance."
@ -299,7 +299,7 @@ async def wait_for_cassette_state_events() -> None:
await asyncio.sleep(_CASSETTE_BACKOFF_S) await asyncio.sleep(_CASSETTE_BACKOFF_S)
except Exception as exc: # listener must never die except Exception as exc: # listener must never die
logger.error( logger.error(
f"spirekeeper: cassette consumer loop error (continuing): " f"{exc}" f"satmachineadmin: cassette consumer loop error (continuing): " f"{exc}"
) )
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S) await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
@ -346,13 +346,13 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
CASSETTE_BOOTSTRAP_SUB_ID, filters # type: ignore[arg-type] CASSETTE_BOOTSTRAP_SUB_ID, filters # type: ignore[arg-type]
) )
logger.info( logger.info(
"spirekeeper: (re)registered cassette bootstrap " "satmachineadmin: (re)registered cassette bootstrap "
f"subscription with {len(d_tags)} d-tag(s)" f"subscription with {len(d_tags)} d-tag(s)"
) )
else: else:
nostr_client.relay_manager.close_subscription(CASSETTE_BOOTSTRAP_SUB_ID) nostr_client.relay_manager.close_subscription(CASSETTE_BOOTSTRAP_SUB_ID)
logger.info( logger.info(
"spirekeeper: no active machines; closed cassette " "satmachineadmin: no active machines; closed cassette "
"bootstrap subscription" "bootstrap subscription"
) )
@ -368,7 +368,7 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
) )
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
f"spirekeeper: cassette state event handler " f"satmachineadmin: cassette state event handler "
f"failed (skipping): {exc}" f"failed (skipping): {exc}"
) )
@ -419,14 +419,14 @@ async def _handle_cassette_state_event(
event_obj = event_raw event_obj = event_raw
else: else:
logger.warning( logger.warning(
f"spirekeeper: cassette event of unexpected type " f"satmachineadmin: cassette event of unexpected type "
f"{type(event_raw).__name__}; skipping" f"{type(event_raw).__name__}; skipping"
) )
return return
if not verify_event(event_obj): if not verify_event(event_obj):
logger.warning( logger.warning(
f"spirekeeper: cassette state event sig verify failed " f"satmachineadmin: cassette state event sig verify failed "
f"(id={event_obj.get('id', '?')[:12]}...)" f"(id={event_obj.get('id', '?')[:12]}...)"
) )
return return
@ -437,7 +437,7 @@ async def _handle_cassette_state_event(
# Unknown sender — could be relay noise or an attacker. Don't # Unknown sender — could be relay noise or an attacker. Don't
# treat as our problem. # treat as our problem.
logger.warning( logger.warning(
f"spirekeeper: cassette state event from unknown ATM " f"satmachineadmin: cassette state event from unknown ATM "
f"pubkey {sender_pubkey[:12]}... (not in dca_machines); " f"pubkey {sender_pubkey[:12]}... (not in dca_machines); "
"skipping" "skipping"
) )
@ -448,7 +448,7 @@ async def _handle_cassette_state_event(
except CassetteTransportError as exc: except CassetteTransportError as exc:
# OperatorIdentityMissing / SignerUnavailable — log + skip. # OperatorIdentityMissing / SignerUnavailable — log + skip.
logger.warning( logger.warning(
f"spirekeeper: can't resolve signer for operator " f"satmachineadmin: can't resolve signer for operator "
f"{machine.operator_user_id[:8]}... (machine {machine.id}): " f"{machine.operator_user_id[:8]}... (machine {machine.id}): "
f"{exc}" f"{exc}"
) )
@ -458,13 +458,13 @@ async def _handle_cassette_state_event(
payload = await decrypt_and_parse_state_event(event_obj, account, signer) payload = await decrypt_and_parse_state_event(event_obj, account, signer)
except CassetteEventTransientError as exc: except CassetteEventTransientError as exc:
logger.info( logger.info(
f"spirekeeper: cassette state event for machine {machine.id} " f"satmachineadmin: cassette state event for machine {machine.id} "
f"hit a transient signer error (will retry next poll): {exc}" f"hit a transient signer error (will retry next poll): {exc}"
) )
return return
except CassetteEventDecodeError as exc: except CassetteEventDecodeError as exc:
logger.warning( logger.warning(
f"spirekeeper: cassette state event decode failed for " f"satmachineadmin: cassette state event decode failed for "
f"machine {machine.id} (id={event_obj.get('id', '?')[:12]}...): " f"machine {machine.id} (id={event_obj.get('id', '?')[:12]}...): "
f"{exc}" f"{exc}"
) )
@ -479,12 +479,12 @@ async def _handle_cassette_state_event(
) )
if applied: if applied:
logger.info( logger.info(
f"spirekeeper: applied bootstrap state event {event_id[:12]}... " f"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
f"to machine {machine.id} ({len(payload.positions)} cassettes)" f"to machine {machine.id} ({len(payload.positions)} cassettes)"
) )
else: else:
# Replay: event_id already on file. Normal on relay reconnect. # Replay: event_id already on file. Normal on relay reconnect.
logger.debug( logger.debug(
f"spirekeeper: cassette state event {event_id[:12]}... " f"satmachineadmin: cassette state event {event_id[:12]}... "
f"already applied to machine {machine.id} (replay no-op)" f"already applied to machine {machine.id} (replay no-op)"
) )

View file

@ -3,7 +3,7 @@
{% block scripts %} {% block scripts %}
{{ window_vars(user) }} {{ window_vars(user) }}
<script src="{{ static_url_for('spirekeeper/static', path='js/index.js') }}"></script> <script src="{{ static_url_for('satmachineadmin/static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}
{% block page %} {% block page %}

View file

@ -1,5 +1,5 @@
""" """
Pytest configuration for the spirekeeper extension test suite. Pytest configuration for the satmachineadmin extension test suite.
Provides a `loguru_capture` fixture for tests that need to verify Provides a `loguru_capture` fixture for tests that need to verify
loguru WARN/ERROR side-effects. Loguru attaches its default sink to loguru WARN/ERROR side-effects. Loguru attaches its default sink to

View file

@ -210,7 +210,7 @@ class TestShouldApplyBootstrapState:
def test_skips_when_existing_event_id_matches(self): def test_skips_when_existing_event_id_matches(self):
"""The same bootstrap event re-delivered after a relay reconnect """The same bootstrap event re-delivered after a relay reconnect
or spirekeeper restart should no-op, not re-upsert the same or satmachineadmin restart should no-op, not re-upsert the same
rows (which would clobber any operator edits since).""" rows (which would clobber any operator edits since)."""
assert _should_apply_bootstrap_state("same-event", "same-event") is False assert _should_apply_bootstrap_state("same-event", "same-event") is False

View file

@ -10,7 +10,7 @@ auto-account wallet.
The guard refuses to register a machine whose npub matches any LNbits The guard refuses to register a machine whose npub matches any LNbits
operator account's `accounts.pubkey`, so this state cannot be entered operator account's `accounts.pubkey`, so this state cannot be entered
through the spirekeeper UI in the first place. through the satmachineadmin UI in the first place.
Monkeypatches `views_api.get_account_by_pubkey` to avoid needing a live Monkeypatches `views_api.get_account_by_pubkey` to avoid needing a live
LNbits DB; this matches the assertion-style of tests/test_nostr_attribution LNbits DB; this matches the assertion-style of tests/test_nostr_attribution

View file

@ -7,7 +7,7 @@ Each settlement records:
fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats) fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)
Positive = bitspire over-reported (claimed more fee than spirekeeper Positive = bitspire over-reported (claimed more fee than satmachineadmin
recomputed against principal). Negative = bitspire under-reported. recomputed against principal). Negative = bitspire under-reported.
Zero = exact match. Zero = exact match.
@ -116,7 +116,7 @@ class TestFeeMismatchSatsRecording:
"""Real-world Phase-1 scenario before Layer 3 (lamassu-next#57) """Real-world Phase-1 scenario before Layer 3 (lamassu-next#57)
ships: ATM hardcodes 7.77% cash-out; operator configures 5% ships: ATM hardcodes 7.77% cash-out; operator configures 5%
operator + 3% super = 8% total. Bitspire reports operator + 3% super = 8% total. Bitspire reports
100_000 * 0.0777 = 7_770 sats; spirekeeper recomputes 8_000. 100_000 * 0.0777 = 7_770 sats; satmachineadmin recomputes 8_000.
Delta is large and visible for triage; behavior unchanged.""" Delta is large and visible for triage; behavior unchanged."""
machine = _machine(op_out=0.05) machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03) super_cfg = _super_config(out_frac=0.03)

View file

@ -1,6 +1,6 @@
""" """
Tests for `nostr_transport_roster.resolve` the lookup function Tests for `nostr_transport_roster.resolve` the lookup function
spirekeeper hands lnbits' nostr-transport via satmachineadmin hands lnbits' nostr-transport via
`register_roster_resolver` (path-B wallet-routing fix, #20 / `register_roster_resolver` (path-B wallet-routing fix, #20 /
coord-log 2026-05-31T15:25Z). coord-log 2026-05-31T15:25Z).
@ -73,7 +73,7 @@ def test_resolve_known_atm_returns_route_hit(monkeypatch):
assert result is not None assert result is not None
assert result.operator_user_id == "op-123" assert result.operator_user_id == "op-123"
assert result.wallet_id == "wallet-abc" assert result.wallet_id == "wallet-abc"
assert result.source_extension == "spirekeeper" assert result.source_extension == "satmachineadmin"
assert captured["pubkey_hex"] == _ATM_PUB_HEX assert captured["pubkey_hex"] == _ATM_PUB_HEX
@ -141,7 +141,7 @@ def test_resolve_raises_on_malformed_input(monkeypatch):
def test_register_with_lnbits_soft_fails_without_hook(monkeypatch): def test_register_with_lnbits_soft_fails_without_hook(monkeypatch):
"""Until the lnbits-side path-B PR lands, the registration call """Until the lnbits-side path-B PR lands, the registration call
must soft-fail cleanly (returns False, no exception) so must soft-fail cleanly (returns False, no exception) so
spirekeeper keeps booting on every lnbits version.""" satmachineadmin keeps booting on every lnbits version."""
real_import = ( real_import = (
__builtins__["__import__"] __builtins__["__import__"]
if isinstance(__builtins__, dict) if isinstance(__builtins__, dict)

View file

@ -10,16 +10,16 @@ from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
spirekeeper_generic_router = APIRouter() satmachineadmin_generic_router = APIRouter()
def spirekeeper_renderer(): def satmachineadmin_renderer():
return template_renderer(["spirekeeper/templates"]) return template_renderer(["satmachineadmin/templates"])
@spirekeeper_generic_router.get("/", response_class=HTMLResponse) @satmachineadmin_generic_router.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)): async def index(req: Request, user: User = Depends(check_user_exists)):
return spirekeeper_renderer().TemplateResponse( return satmachineadmin_renderer().TemplateResponse(
"spirekeeper/index.html", "satmachineadmin/index.html",
{"request": req, "user": user.json()}, {"request": req, "user": user.json()},
) )

View file

@ -94,7 +94,7 @@ from .models import (
UpsertCassetteConfigData, UpsertCassetteConfigData,
) )
spirekeeper_api_router = APIRouter() satmachineadmin_api_router = APIRouter()
async def _assert_wallet_owned_by(wallet_id: str, user_id: str) -> None: async def _assert_wallet_owned_by(wallet_id: str, user_id: str) -> None:
@ -254,7 +254,7 @@ async def _assert_super_config_cap_safe(
# ============================================================================= # =============================================================================
@spirekeeper_api_router.post("/api/v1/dca/machines", response_model=Machine) @satmachineadmin_api_router.post("/api/v1/dca/machines", response_model=Machine)
async def api_create_machine( async def api_create_machine(
data: CreateMachineData, user: User = Depends(check_user_exists) data: CreateMachineData, user: User = Depends(check_user_exists)
) -> Machine: ) -> Machine:
@ -274,14 +274,14 @@ async def api_create_machine(
return machine return machine
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) @satmachineadmin_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
async def api_list_machines( async def api_list_machines(
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
) -> list[Machine]: ) -> list[Machine]:
return await get_machines_for_operator(user.id) return await get_machines_for_operator(user.id)
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/machines/{machine_id}", response_model=Machine "/api/v1/dca/machines/{machine_id}", response_model=Machine
) )
async def api_get_machine( async def api_get_machine(
@ -293,7 +293,7 @@ async def api_get_machine(
return machine return machine
@spirekeeper_api_router.put( @satmachineadmin_api_router.put(
"/api/v1/dca/machines/{machine_id}", response_model=Machine "/api/v1/dca/machines/{machine_id}", response_model=Machine
) )
async def api_update_machine( async def api_update_machine(
@ -341,7 +341,7 @@ async def api_update_machine(
return updated return updated
@spirekeeper_api_router.delete( @satmachineadmin_api_router.delete(
"/api/v1/dca/machines/{machine_id}", status_code=HTTPStatus.NO_CONTENT "/api/v1/dca/machines/{machine_id}", status_code=HTTPStatus.NO_CONTENT
) )
async def api_delete_machine( async def api_delete_machine(
@ -379,7 +379,7 @@ async def _client_owned_by(client_id: str, user_id: str) -> DcaClient:
return client return client
@spirekeeper_api_router.post("/api/v1/dca/clients", response_model=DcaClient) @satmachineadmin_api_router.post("/api/v1/dca/clients", response_model=DcaClient)
async def api_create_client( async def api_create_client(
data: CreateDcaClientData, user: User = Depends(check_user_exists) data: CreateDcaClientData, user: User = Depends(check_user_exists)
) -> DcaClient: ) -> DcaClient:
@ -388,7 +388,7 @@ async def api_create_client(
return await create_dca_client(data) return await create_dca_client(data)
@spirekeeper_api_router.get("/api/v1/dca/clients", response_model=list[DcaClient]) @satmachineadmin_api_router.get("/api/v1/dca/clients", response_model=list[DcaClient])
async def api_list_clients( async def api_list_clients(
machine_id: str | None = None, machine_id: str | None = None,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
@ -402,7 +402,7 @@ async def api_list_clients(
return await get_dca_clients_for_machine(machine_id) return await get_dca_clients_for_machine(machine_id)
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/clients/{client_id}", response_model=DcaClient "/api/v1/dca/clients/{client_id}", response_model=DcaClient
) )
async def api_get_client( async def api_get_client(
@ -411,7 +411,7 @@ async def api_get_client(
return await _client_owned_by(client_id, user.id) return await _client_owned_by(client_id, user.id)
@spirekeeper_api_router.put( @satmachineadmin_api_router.put(
"/api/v1/dca/clients/{client_id}", response_model=DcaClient "/api/v1/dca/clients/{client_id}", response_model=DcaClient
) )
async def api_update_client( async def api_update_client(
@ -426,7 +426,7 @@ async def api_update_client(
return updated return updated
@spirekeeper_api_router.delete( @satmachineadmin_api_router.delete(
"/api/v1/dca/clients/{client_id}", status_code=HTTPStatus.NO_CONTENT "/api/v1/dca/clients/{client_id}", status_code=HTTPStatus.NO_CONTENT
) )
async def api_delete_client( async def api_delete_client(
@ -436,7 +436,7 @@ async def api_delete_client(
await delete_dca_client(client_id) await delete_dca_client(client_id)
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/clients/{client_id}/balance", "/api/v1/dca/clients/{client_id}/balance",
response_model=ClientBalanceSummary, response_model=ClientBalanceSummary,
) )
@ -450,7 +450,7 @@ async def api_get_client_balance(
return summary return summary
@spirekeeper_api_router.post( @satmachineadmin_api_router.post(
"/api/v1/dca/clients/{client_id}/settle", response_model=DcaPayment "/api/v1/dca/clients/{client_id}/settle", response_model=DcaPayment
) )
async def api_settle_client_balance( async def api_settle_client_balance(
@ -498,7 +498,7 @@ async def _deposit_owned_by(deposit_id: str, user_id: str) -> DcaDeposit:
return deposit return deposit
@spirekeeper_api_router.post("/api/v1/dca/deposits", response_model=DcaDeposit) @satmachineadmin_api_router.post("/api/v1/dca/deposits", response_model=DcaDeposit)
async def api_create_deposit( async def api_create_deposit(
data: CreateDepositData, user: User = Depends(check_user_exists) data: CreateDepositData, user: User = Depends(check_user_exists)
) -> DcaDeposit: ) -> DcaDeposit:
@ -531,7 +531,7 @@ async def api_create_deposit(
return await create_deposit(user.id, data, currency=machine.fiat_code) return await create_deposit(user.id, data, currency=machine.fiat_code)
@spirekeeper_api_router.get("/api/v1/dca/deposits", response_model=list[DcaDeposit]) @satmachineadmin_api_router.get("/api/v1/dca/deposits", response_model=list[DcaDeposit])
async def api_list_deposits( async def api_list_deposits(
client_id: str | None = None, client_id: str | None = None,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
@ -544,7 +544,7 @@ async def api_list_deposits(
return await get_deposits_for_operator(user.id) return await get_deposits_for_operator(user.id)
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/deposits/{deposit_id}", response_model=DcaDeposit "/api/v1/dca/deposits/{deposit_id}", response_model=DcaDeposit
) )
async def api_get_deposit( async def api_get_deposit(
@ -553,7 +553,7 @@ async def api_get_deposit(
return await _deposit_owned_by(deposit_id, user.id) return await _deposit_owned_by(deposit_id, user.id)
@spirekeeper_api_router.put( @satmachineadmin_api_router.put(
"/api/v1/dca/deposits/{deposit_id}", response_model=DcaDeposit "/api/v1/dca/deposits/{deposit_id}", response_model=DcaDeposit
) )
async def api_update_deposit( async def api_update_deposit(
@ -573,7 +573,7 @@ async def api_update_deposit(
return updated return updated
@spirekeeper_api_router.put( @satmachineadmin_api_router.put(
"/api/v1/dca/deposits/{deposit_id}/status", response_model=DcaDeposit "/api/v1/dca/deposits/{deposit_id}/status", response_model=DcaDeposit
) )
async def api_update_deposit_status( async def api_update_deposit_status(
@ -588,7 +588,7 @@ async def api_update_deposit_status(
return updated return updated
@spirekeeper_api_router.delete( @satmachineadmin_api_router.delete(
"/api/v1/dca/deposits/{deposit_id}", status_code=HTTPStatus.NO_CONTENT "/api/v1/dca/deposits/{deposit_id}", status_code=HTTPStatus.NO_CONTENT
) )
async def api_delete_deposit( async def api_delete_deposit(
@ -608,7 +608,7 @@ async def api_delete_deposit(
# ============================================================================= # =============================================================================
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/settlements", response_model=list[DcaSettlement] "/api/v1/dca/settlements", response_model=list[DcaSettlement]
) )
async def api_list_settlements( async def api_list_settlements(
@ -617,7 +617,7 @@ async def api_list_settlements(
return await get_settlements_for_operator(user.id) return await get_settlements_for_operator(user.id)
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/machines/{machine_id}/settlements", "/api/v1/dca/machines/{machine_id}/settlements",
response_model=list[DcaSettlement], response_model=list[DcaSettlement],
) )
@ -637,7 +637,7 @@ async def api_list_settlements_for_machine(
# order). # order).
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/settlements/stuck", response_model=StuckSettlementsResponse "/api/v1/dca/settlements/stuck", response_model=StuckSettlementsResponse
) )
async def api_list_stuck_settlements( async def api_list_stuck_settlements(
@ -668,7 +668,7 @@ async def api_list_stuck_settlements(
) )
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/settlements/{settlement_id}", response_model=DcaSettlement "/api/v1/dca/settlements/{settlement_id}", response_model=DcaSettlement
) )
async def api_get_settlement( async def api_get_settlement(
@ -683,7 +683,7 @@ async def api_get_settlement(
return settlement return settlement
@spirekeeper_api_router.post( @satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/partial-dispense", "/api/v1/dca/settlements/{settlement_id}/partial-dispense",
response_model=DcaSettlement, response_model=DcaSettlement,
) )
@ -718,7 +718,7 @@ async def api_partial_dispense(
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
@spirekeeper_api_router.post( @satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/force-reset", "/api/v1/dca/settlements/{settlement_id}/force-reset",
response_model=DcaSettlement, response_model=DcaSettlement,
) )
@ -768,7 +768,7 @@ async def api_force_reset_settlement(
return updated return updated
@spirekeeper_api_router.post( @satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/retry", "/api/v1/dca/settlements/{settlement_id}/retry",
response_model=DcaSettlement, response_model=DcaSettlement,
) )
@ -821,7 +821,7 @@ async def api_retry_settlement(
return after if after is not None else updated return after if after is not None else updated
@spirekeeper_api_router.post( @satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/notes", "/api/v1/dca/settlements/{settlement_id}/notes",
response_model=DcaSettlement, response_model=DcaSettlement,
) )
@ -854,7 +854,7 @@ async def api_append_settlement_note(
# ============================================================================= # =============================================================================
@spirekeeper_api_router.get("/api/v1/dca/payments", response_model=list[DcaPayment]) @satmachineadmin_api_router.get("/api/v1/dca/payments", response_model=list[DcaPayment])
async def api_list_payments( async def api_list_payments(
leg_type: str | None = None, leg_type: str | None = None,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
@ -869,7 +869,7 @@ async def api_list_payments(
# ============================================================================= # =============================================================================
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/commission-splits", response_model=list[CommissionSplit] "/api/v1/dca/commission-splits", response_model=list[CommissionSplit]
) )
async def api_get_commission_splits( async def api_get_commission_splits(
@ -889,7 +889,7 @@ async def api_get_commission_splits(
return await get_commission_splits(user.id, None) return await get_commission_splits(user.id, None)
@spirekeeper_api_router.put( @satmachineadmin_api_router.put(
"/api/v1/dca/commission-splits", response_model=list[CommissionSplit] "/api/v1/dca/commission-splits", response_model=list[CommissionSplit]
) )
async def api_replace_commission_splits( async def api_replace_commission_splits(
@ -905,7 +905,7 @@ async def api_replace_commission_splits(
return await replace_commission_splits(user.id, data.machine_id, data.legs) return await replace_commission_splits(user.id, data.machine_id, data.legs)
@spirekeeper_api_router.delete( @satmachineadmin_api_router.delete(
"/api/v1/dca/commission-splits", "/api/v1/dca/commission-splits",
status_code=HTTPStatus.NO_CONTENT, status_code=HTTPStatus.NO_CONTENT,
) )
@ -927,7 +927,7 @@ async def api_delete_commission_splits(
# ============================================================================= # =============================================================================
@spirekeeper_api_router.get("/api/v1/dca/super-config", response_model=SuperConfig) @satmachineadmin_api_router.get("/api/v1/dca/super-config", response_model=SuperConfig)
async def api_get_super_config( async def api_get_super_config(
_user: User = Depends(check_user_exists), _user: User = Depends(check_user_exists),
) -> SuperConfig: ) -> SuperConfig:
@ -940,7 +940,7 @@ async def api_get_super_config(
return config return config
@spirekeeper_api_router.put("/api/v1/dca/super-config", response_model=SuperConfig) @satmachineadmin_api_router.put("/api/v1/dca/super-config", response_model=SuperConfig)
async def api_update_super_config( async def api_update_super_config(
data: UpdateSuperConfigData, data: UpdateSuperConfigData,
_user: User = Depends(check_super_user), _user: User = Depends(check_super_user),
@ -988,7 +988,7 @@ async def api_update_super_config(
# fields per row are denomination and count. # fields per row are denomination and count.
@spirekeeper_api_router.get( @satmachineadmin_api_router.get(
"/api/v1/dca/machines/{machine_id}/cassettes", "/api/v1/dca/machines/{machine_id}/cassettes",
response_model=list[CassetteConfig], response_model=list[CassetteConfig],
) )
@ -1003,7 +1003,7 @@ async def api_list_machine_cassettes(
return await list_cassette_configs_for_machine(machine_id) return await list_cassette_configs_for_machine(machine_id)
@spirekeeper_api_router.post( @satmachineadmin_api_router.post(
"/api/v1/dca/machines/{machine_id}/cassettes/publish", "/api/v1/dca/machines/{machine_id}/cassettes/publish",
response_model=list[CassetteConfig], response_model=list[CassetteConfig],
) )
@ -1049,7 +1049,7 @@ async def api_publish_machine_cassettes(
"No cassette_configs rows exist for this machine yet — " "No cassette_configs rows exist for this machine yet — "
"waiting for the ATM's bootstrap state event. Power on the " "waiting for the ATM's bootstrap state event. Power on the "
"ATM and confirm it has reached the configured relay; " "ATM and confirm it has reached the configured relay; "
"spirekeeper will auto-populate cassette_configs on " "satmachineadmin will auto-populate cassette_configs on "
"receipt." "receipt."
), ),
) )
@ -1068,7 +1068,7 @@ async def api_publish_machine_cassettes(
) )
# Apply each per-row edit so the operator-believed state on # Apply each per-row edit so the operator-believed state on
# spirekeeper reflects the published payload, even if the ATM # satmachineadmin reflects the published payload, even if the ATM
# ack lands later (v2). updated_by audit-stamps the operator user id. # ack lands later (v2). updated_by audit-stamps the operator user id.
for pos, row in payload.positions.items(): for pos, row in payload.positions.items():
updated = await update_cassette_config( updated = await update_cassette_config(