Compare commits
2 commits
9c4d2c1324
...
4ac640a499
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ac640a499 | |||
| a059e3f596 |
28 changed files with 384 additions and 385 deletions
|
|
@ -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 satoshimachine database
|
- **Database Access**: Only superusers can write to spirekeeper 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_satoshimachine.dca_machines m
|
JOIN ext_spirekeeper.dca_machines m
|
||||||
ON LOWER(a.pubkey) = LOWER(m.machine_npub);
|
ON LOWER(a.pubkey) = LOWER(m.machine_npub);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
│ └── satmachineadmin/
|
│ └── spirekeeper/
|
||||||
│ └── index.html # Main UI template
|
│ └── index.html # Main UI template
|
||||||
└── static/
|
└── static/
|
||||||
└── js/
|
└── js/
|
||||||
|
|
|
||||||
36
__init__.py
36
__init__.py
|
|
@ -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 satmachineadmin_generic_router
|
from .views import spirekeeper_generic_router
|
||||||
from .views_api import satmachineadmin_api_router
|
from .views_api import spirekeeper_api_router
|
||||||
|
|
||||||
logger.info("satmachineadmin v2 loaded")
|
logger.info("spirekeeper v2 loaded")
|
||||||
|
|
||||||
|
|
||||||
satmachineadmin_ext: APIRouter = APIRouter(
|
spirekeeper_ext: APIRouter = APIRouter(
|
||||||
prefix="/satmachineadmin", tags=["DCA Admin"]
|
prefix="/spirekeeper", tags=["DCA Admin"]
|
||||||
)
|
)
|
||||||
satmachineadmin_ext.include_router(satmachineadmin_generic_router)
|
spirekeeper_ext.include_router(spirekeeper_generic_router)
|
||||||
satmachineadmin_ext.include_router(satmachineadmin_api_router)
|
spirekeeper_ext.include_router(spirekeeper_api_router)
|
||||||
|
|
||||||
satmachineadmin_static_files = [
|
spirekeeper_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/satmachineadmin/static",
|
"path": "/spirekeeper/static",
|
||||||
"name": "satmachineadmin_static",
|
"name": "spirekeeper_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
scheduled_tasks: list[asyncio.Task] = []
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
def satmachineadmin_stop():
|
def spirekeeper_stop():
|
||||||
for task in scheduled_tasks:
|
for task in scheduled_tasks:
|
||||||
try:
|
try:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
@ -37,10 +37,10 @@ def satmachineadmin_stop():
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
def satmachineadmin_start():
|
def spirekeeper_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_satmachineadmin", wait_for_paid_invoices
|
"ext_spirekeeper", 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 satmachineadmin_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_satmachineadmin_cassette_bootstrap", wait_for_cassette_state_events
|
"ext_spirekeeper_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 satmachineadmin_start():
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"db",
|
"db",
|
||||||
"satmachineadmin_ext",
|
"spirekeeper_ext",
|
||||||
"satmachineadmin_start",
|
"spirekeeper_start",
|
||||||
"satmachineadmin_static_files",
|
"spirekeeper_static_files",
|
||||||
"satmachineadmin_stop",
|
"spirekeeper_stop",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin-managed wallet without stamping the
|
landed on a spirekeeper-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 satmachineadmin's recompute, log on out-of-
|
# fee_sats against spirekeeper'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)
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin's
|
coord-log entry): always the ATM's hex pubkey, NEVER spirekeeper'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 satmachineadmin API path)
|
# Publish — operator → ATM (the spirekeeper API path)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "DCA Admin",
|
"name": "spirekeeper",
|
||||||
"short_description": "Dollar Cost Averaging administration for Lamassu ATM integration",
|
"short_description": "Dollar Cost Averaging administration for Lamassu ATM integration",
|
||||||
"tile": "/satmachineadmin/static/image/aio.png",
|
"tile": "/spirekeeper/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": "/satmachineadmin/description.md",
|
"description_md": "/spirekeeper/description.md",
|
||||||
"terms_and_conditions_md": "/satmachineadmin/toc.md",
|
"terms_and_conditions_md": "/spirekeeper/toc.md",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
crud.py
156
crud.py
|
|
@ -38,7 +38,7 @@ from .models import (
|
||||||
UpsertDcaLpData,
|
UpsertDcaLpData,
|
||||||
)
|
)
|
||||||
|
|
||||||
db = Database("ext_satoshimachine")
|
db = Database("ext_spirekeeper")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -48,7 +48,7 @@ db = Database("ext_satoshimachine")
|
||||||
|
|
||||||
async def get_super_config() -> SuperConfig | None:
|
async def get_super_config() -> SuperConfig | None:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM satoshimachine.super_config WHERE id = :id",
|
"SELECT * FROM spirekeeper.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 satoshimachine.super_config SET {set_clause} WHERE id = :id",
|
f"UPDATE spirekeeper.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 satoshimachine.dca_machines
|
INSERT INTO spirekeeper.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 satoshimachine.dca_machines WHERE id = :id",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_machines WHERE machine_npub = :npub",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_machines
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_machines
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_machines
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_machines SET {set_clause} WHERE id = :id",
|
f"UPDATE spirekeeper.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 satoshimachine.dca_machines WHERE id = :id",
|
"DELETE FROM spirekeeper.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 satoshimachine.dca_clients
|
INSERT INTO spirekeeper.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 = (
|
||||||
"satoshimachine.dca_clients c "
|
"spirekeeper.dca_clients c "
|
||||||
"LEFT JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id"
|
"LEFT JOIN spirekeeper.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 satoshimachine.dca_machines m ON m.id = c.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id
|
JOIN spirekeeper.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 satoshimachine.dca_lp WHERE user_id = :uid",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_lp WHERE user_id = :uid",
|
"SELECT user_id FROM spirekeeper.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 satoshimachine.dca_lp
|
INSERT INTO spirekeeper.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 satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid",
|
f"UPDATE spirekeeper.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 satoshimachine.dca_clients SET {set_clause} WHERE id = :id",
|
f"UPDATE spirekeeper.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 satoshimachine.dca_clients WHERE id = :id",
|
"DELETE FROM spirekeeper.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 satoshimachine.dca_deposits
|
INSERT INTO spirekeeper.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 satoshimachine.dca_deposits WHERE id = :id",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_deposits
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_deposits d
|
FROM spirekeeper.dca_deposits d
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = d.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_deposits SET {set_clause} WHERE id = :id",
|
f"UPDATE spirekeeper.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 satoshimachine.dca_deposits
|
UPDATE spirekeeper.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 satoshimachine.dca_deposits WHERE id = :id",
|
"DELETE FROM spirekeeper.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 satoshimachine.dca_settlements
|
INSERT INTO spirekeeper.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 satoshimachine.dca_settlements WHERE id = :id",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_settlements
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_settlements
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_settlements s
|
FROM spirekeeper.dca_settlements s
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_settlements s
|
FROM spirekeeper.dca_settlements s
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_settlements s
|
FROM spirekeeper.dca_settlements s
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_settlements s
|
FROM spirekeeper.dca_settlements s
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_settlements
|
UPDATE spirekeeper.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 satoshimachine.dca_settlements s
|
FROM spirekeeper.dca_settlements s
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
|
JOIN spirekeeper.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 satoshimachine.dca_settlements
|
UPDATE spirekeeper.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 satoshimachine.dca_settlements
|
UPDATE spirekeeper.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 satoshimachine.dca_payments
|
UPDATE spirekeeper.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 satoshimachine.dca_settlements
|
UPDATE spirekeeper.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 satoshimachine.dca_settlements
|
UPDATE spirekeeper.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 satoshimachine.dca_payments
|
SELECT COUNT(*) AS n FROM spirekeeper.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 satoshimachine.dca_settlements
|
UPDATE spirekeeper.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 satoshimachine.dca_payments
|
UPDATE spirekeeper.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 satoshimachine.dca_commission_splits
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_commission_splits
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_commission_splits
|
DELETE FROM spirekeeper.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 satoshimachine.dca_commission_splits
|
DELETE FROM spirekeeper.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 satoshimachine.dca_commission_splits
|
INSERT INTO spirekeeper.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 satoshimachine.dca_payments
|
INSERT INTO spirekeeper.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 satoshimachine.dca_payments WHERE id = :id",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_payments
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_payments
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_payments
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_payments
|
SELECT * FROM spirekeeper.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 satoshimachine.dca_payments
|
UPDATE spirekeeper.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 satoshimachine.dca_deposits
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.dca_telemetry WHERE machine_id = :mid",
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_telemetry
|
INSERT INTO spirekeeper.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 satoshimachine.dca_telemetry SET
|
UPDATE spirekeeper.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 satoshimachine.dca_telemetry
|
INSERT INTO spirekeeper.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 satoshimachine.dca_telemetry
|
UPDATE spirekeeper.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 satoshimachine.cassette_configs "
|
"SELECT * FROM spirekeeper.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 satoshimachine.cassette_configs "
|
"SELECT * FROM spirekeeper.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 satoshimachine.cassette_configs SET {set_clause} "
|
f"UPDATE spirekeeper.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 satoshimachine.cassette_configs "
|
"SELECT state_event_id FROM spirekeeper.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 satoshimachine.cassette_configs
|
INSERT INTO spirekeeper.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)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
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.
|
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.
|
||||||
|
|
||||||
This is a longform description that will be used in the advanced description when users click on the "more" button on the extension cards.
|
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.
|
||||||
|
|
||||||
Adding some bullets is nice covering:
|
- **ATM fleet management** — register and configure each bitSpire by its machine npub; publish fee config and cassette state over Nostr.
|
||||||
|
- **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.
|
||||||
|
|
||||||
- Functionality
|
Requires LNbits superuser access for administration. The Lightning ATM side is served by the bitSpire device; this extension is the back office.
|
||||||
- Use cases
|
|
||||||
|
|
||||||
...and some other text about just how great this etension is.
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## 0 · Why this document exists
|
## 0 · Why this document exists
|
||||||
|
|
||||||
Today the satoshi‑machine code lives at `~/dev/shared/extensions/satmachineadmin` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostr‑native one: bitSpire publishes invoices over kind‑21000 NIP‑44 v2 events, LNbits pays them, and our extension hooks the resulting `Payment` object.
|
Today the satoshi‑machine code lives at `~/dev/shared/extensions/spirekeeper` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostr‑native one: bitSpire publishes invoices over kind‑21000 NIP‑44 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
|
||||||
satmachineadmin/tasks.py:_handle_payment
|
spirekeeper/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 code‑level audits of `~/dev/shared/extensions/satmachineadmin` (operator‑scoping inventory) and `~/dev/lnbits/nostr-transport` (transport primitives).
|
Pulled from the two recent code‑level audits of `~/dev/shared/extensions/spirekeeper` (operator‑scoping 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 — NIP‑78 per‑machine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler cross‑checks ATM npub ∈ fleet; reads max‑withdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwards‑compat path for pre‑NIP‑78 machines. |
|
| **S4 — NIP‑78 per‑machine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler cross‑checks ATM npub ∈ fleet; reads max‑withdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwards‑compat path for pre‑NIP‑78 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 post‑write breaks the HMAC. | G2 (DB‑side), G5, G6 | 3–5 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 post‑write breaks the HMAC. | G2 (DB‑side), G5, G6 | 3–5 days | LNbits PR — fairly localised. |
|
||||||
| **S6 — Rate limiting + roster‑gated auto‑account** | Auto‑account‑from‑npub only fires if the npub appears in some operator's NIP‑78 fleet OR if an explicit "open enrollment" flag is set. Relay/handler‑level rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. |
|
| **S6 — Rate limiting + roster‑gated auto‑account** | Auto‑account‑from‑npub only fires if the npub appears in some operator's NIP‑78 fleet OR if an explicit "open enrollment" flag is set. Relay/handler‑level rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. |
|
||||||
| **S7 — NIP‑46 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 | 4–6 weeks | Largest. Defer until S0–S5 land. |
|
| **S7 — NIP‑46 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 | 4–6 weeks | Largest. Defer until S0–S5 land. |
|
||||||
| **S8 — Cash‑in path** | Wire `is_out=True` cash‑in handling: LNURL‑withdraw with expiration matching the kind‑21000 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 — Cash‑in path** | Wire `is_out=True` cash‑in handling: LNURL‑withdraw with expiration matching the kind‑21000 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, well‑scoped LNbits patch for S5. S2/S3/S4 are the proper Nostr‑native 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, well‑scoped LNbits patch for S5. S2/S3/S4 are the proper Nostr‑native layer and should land in the sprint after.
|
||||||
|
|
@ -359,12 +359,12 @@ For an auditor or new contributor doing a walk‑through:
|
||||||
|
|
||||||
| File | Role | Note |
|
| File | Role | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `~/dev/shared/extensions/satmachineadmin/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — load‑bearing routing. |
|
| `~/dev/shared/extensions/spirekeeper/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — load‑bearing routing. |
|
||||||
| `~/dev/shared/extensions/satmachineadmin/bitspire.py` | Parses Payment.extra. The trust boundary. | `parse_settlement:68-92` — happy vs fallback path. |
|
| `~/dev/shared/extensions/spirekeeper/bitspire.py` | Parses Payment.extra. The trust boundary. | `parse_settlement:68-92` — happy vs fallback path. |
|
||||||
| `~/dev/shared/extensions/satmachineadmin/distribution.py` | Three‑leg distribution chain. | `process_settlement` — uses claim pattern. |
|
| `~/dev/shared/extensions/spirekeeper/distribution.py` | Three‑leg distribution chain. | `process_settlement` — uses claim pattern. |
|
||||||
| `~/dev/shared/extensions/satmachineadmin/crud.py` | Operator‑scoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. |
|
| `~/dev/shared/extensions/spirekeeper/crud.py` | Operator‑scoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. |
|
||||||
| `~/dev/shared/extensions/satmachineadmin/views_api.py` | 33 routes, all `check_user_exists` except super‑config PUT. | `_assert_wallet_owned_by` is the wallet‑IDOR fix. |
|
| `~/dev/shared/extensions/spirekeeper/views_api.py` | 33 routes, all `check_user_exists` except super‑config PUT. | `_assert_wallet_owned_by` is the wallet‑IDOR fix. |
|
||||||
| `~/dev/shared/extensions/satmachineadmin/migrations.py` | Schema. | `dca_settlements` is the audit row; `dca_payments` is the leg row. |
|
| `~/dev/shared/extensions/spirekeeper/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 Option‑1 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 Option‑1 stopgap. |
|
||||||
| `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/` | LNbits transport handler (upstream we depend on). | NIP‑44 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). | NIP‑44 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 end‑to‑end, once S0–S5 land:
|
||||||
|
|
||||||
Once approved:
|
Once approved:
|
||||||
|
|
||||||
1. The PDF for printing will be generated post‑plan‑mode (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 in‑repo.
|
1. The PDF for printing will be generated post‑plan‑mode (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 in‑repo.
|
||||||
2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0–S7)."
|
2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0–S7)."
|
||||||
3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbits‑side primitives (S2, S5, S6).
|
3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbits‑side 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.
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ async def publish_fee_config(
|
||||||
)
|
)
|
||||||
except (SignerUnavailable, RelayUnavailable) as exc:
|
except (SignerUnavailable, RelayUnavailable) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"satmachineadmin: fee-config publish soft-fail for machine "
|
f"spirekeeper: 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"satmachineadmin: fee-config publish unexpected transport "
|
f"spirekeeper: 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
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
"id": "satmachineadmin",
|
"id": "spirekeeper",
|
||||||
"organisation": "atitlanio",
|
"organisation": "aiolabs",
|
||||||
"repository": "satmachineadmin"
|
"repository": "spirekeeper"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 satoshimachine.{table}")
|
await db.execute(f"DROP TABLE IF EXISTS spirekeeper.{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 satoshimachine.super_config (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.super_config WHERE id = 'default'"
|
"SELECT id FROM spirekeeper.super_config WHERE id = 'default'"
|
||||||
)
|
)
|
||||||
if not existing:
|
if not existing:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
|
"INSERT INTO spirekeeper.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 satoshimachine.dca_machines (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_clients (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satmachineadmin during distribution to resolve "where do
|
# read by spirekeeper during distribution to resolve "where do
|
||||||
# DCA payouts for this LP go?"
|
# DCA payouts for this LP go?"
|
||||||
#
|
#
|
||||||
# Gating: satmachineadmin refuses to create deposits for an LP who
|
# Gating: spirekeeper 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 satoshimachine.dca_lp (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_deposits (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_settlements (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_commission_splits (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_payments (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_telemetry (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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
|
||||||
satoshimachine.sqlite3 file on disk.
|
spirekeeper.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 satoshimachine.dca_commission_splits LIMIT 1"
|
"SELECT wallet_id FROM spirekeeper.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 satoshimachine.dca_commission_splits "
|
"ALTER TABLE spirekeeper.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 satoshimachine.dca_settlements LIMIT 1")
|
await db.fetchone("SELECT net_sats FROM spirekeeper.dca_settlements LIMIT 1")
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE satoshimachine.dca_settlements "
|
"ALTER TABLE spirekeeper.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 satoshimachine.dca_clients LIMIT 1")
|
await db.fetchone("SELECT wallet_id FROM spirekeeper.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 satoshimachine.dca_lp (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.dca_lp
|
INSERT OR IGNORE INTO spirekeeper.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 satoshimachine.dca_clients
|
FROM spirekeeper.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 satoshimachine.dca_clients DROP COLUMN {col}")
|
await db.execute(f"ALTER TABLE spirekeeper.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 satoshimachine.{table} LIMIT 1")
|
await db.fetchone(f"SELECT {old_col} FROM spirekeeper.{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 satoshimachine.{table} "
|
f"ALTER TABLE spirekeeper.{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 satoshimachine.{table} LIMIT 1")
|
await db.fetchone(f"SELECT {col} FROM spirekeeper.{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 satoshimachine.{table} DROP COLUMN {col}")
|
await db.execute(f"ALTER TABLE spirekeeper.{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 satoshimachine.dca_deposits AS d
|
UPDATE spirekeeper.dca_deposits AS d
|
||||||
SET currency = (
|
SET currency = (
|
||||||
SELECT m.fiat_code
|
SELECT m.fiat_code
|
||||||
FROM satoshimachine.dca_machines m
|
FROM spirekeeper.dca_machines m
|
||||||
WHERE m.id = d.machine_id
|
WHERE m.id = d.machine_id
|
||||||
)
|
)
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM satoshimachine.dca_machines m
|
FROM spirekeeper.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 satmachineadmin dashboard and published to the ATM as encrypted
|
via the spirekeeper 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 satoshimachine.cassette_configs (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.cassette_configs " "LIMIT 1"
|
"SELECT state_denomination FROM spirekeeper.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 satoshimachine.cassette_configs_new (
|
CREATE TABLE IF NOT EXISTS spirekeeper.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 satoshimachine.cassette_configs_new
|
INSERT INTO spirekeeper.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 satoshimachine.cassette_configs
|
FROM spirekeeper.cassette_configs
|
||||||
""")
|
""")
|
||||||
|
|
||||||
await db.execute("DROP TABLE satoshimachine.cassette_configs")
|
await db.execute("DROP TABLE spirekeeper.cassette_configs")
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs"
|
"ALTER TABLE spirekeeper.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
|
||||||
satmachineadmin's principal-based recompute.
|
spirekeeper'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 satoshimachine.{table} LIMIT 1")
|
await db.fetchone(f"SELECT {col} FROM spirekeeper.{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 satoshimachine.{table} ADD COLUMN {col} {coltype}"
|
f"ALTER TABLE spirekeeper.{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 satoshimachine.super_config LIMIT 1"
|
"SELECT super_fee_fraction FROM spirekeeper.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 satoshimachine.super_config
|
UPDATE spirekeeper.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 satoshimachine.super_config DROP COLUMN super_fee_fraction"
|
"ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions lt
|
FROM spirekeeper.lamassu_transactions lt
|
||||||
LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
LEFT JOIN spirekeeper.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 satoshimachine.dca_clients WHERE remaining_balance > 0);
|
HAVING COUNT(dp.id) > (SELECT COUNT(*) FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions
|
ALTER TABLE spirekeeper.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 satoshimachine.lamassu_transactions;
|
FROM spirekeeper.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 satoshimachine.dca_payments dp
|
FROM spirekeeper.dca_payments dp
|
||||||
JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
|
JOIN spirekeeper.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 satoshimachine.lamassu_transactions;
|
FROM spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
||||||
LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
|
LEFT JOIN spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
||||||
LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
|
LEFT JOIN spirekeeper.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 satoshimachine.dca_deposits
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.dca_deposits (client_id, amount, status, note)
|
INSERT INTO spirekeeper.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 satoshimachine.dca_payments (client_id, amount_sats, status, note)
|
INSERT INTO spirekeeper.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 satoshimachine.dca_clients
|
UPDATE spirekeeper.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 satoshimachine.dca_clients
|
ALTER TABLE spirekeeper.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 satoshimachine.dca_deposits
|
ALTER TABLE spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions lt
|
FROM spirekeeper.lamassu_transactions lt
|
||||||
JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
||||||
JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
|
JOIN spirekeeper.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 satoshimachine.dca_clients c
|
UPDATE spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_config
|
FROM spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
||||||
LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
|
LEFT JOIN spirekeeper.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 satoshimachine.lamassu_transactions;
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.dca_deposits
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_config
|
UPDATE spirekeeper.lamassu_config
|
||||||
SET polling_enabled = false
|
SET polling_enabled = false
|
||||||
WHERE id = (SELECT MAX(id) FROM satoshimachine.lamassu_config);
|
WHERE id = (SELECT MAX(id) FROM spirekeeper.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 satoshimachine.dca_clients
|
UPDATE spirekeeper.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 satoshimachine.lamassu_transactions;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_payments;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_deposits;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_clients;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.lamassu_transactions lt
|
FROM spirekeeper.lamassu_transactions lt
|
||||||
LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
LEFT JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
||||||
LEFT JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
|
LEFT JOIN spirekeeper.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 satoshimachine.lamassu_transactions
|
ALTER TABLE spirekeeper.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 satoshimachine.dca_clients
|
ALTER TABLE spirekeeper.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 satoshimachine.dca_deposits
|
ALTER TABLE spirekeeper.dca_deposits
|
||||||
ADD CONSTRAINT positive_deposit CHECK (amount > 0);
|
ADD CONSTRAINT positive_deposit CHECK (amount > 0);
|
||||||
|
|
||||||
ALTER TABLE satoshimachine.dca_payments
|
ALTER TABLE spirekeeper.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>/satoshimachine`
|
- URL: `https://<your-lnbits-host>/spirekeeper`
|
||||||
- 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 satoshimachine.<table_name>;"
|
sqlite3 /path/to/db "SELECT * FROM spirekeeper.<table_name>;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Log Files**:
|
**Log Files**:
|
||||||
|
|
@ -1274,7 +1274,7 @@ pkill -f lnbits
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Disable automatic polling
|
-- Disable automatic polling
|
||||||
UPDATE satoshimachine.lamassu_config
|
UPDATE spirekeeper.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 satoshimachine.dca_clients
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
SELECT * FROM spirekeeper.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 satoshimachine.lamassu_transactions;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_payments;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_deposits;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_clients;" \
|
"SELECT * FROM spirekeeper.dca_clients;" \
|
||||||
> ${BACKUP_DIR}/dca_clients.csv
|
> ${BACKUP_DIR}/dca_clients.csv
|
||||||
|
|
||||||
echo "Backup complete in ${BACKUP_DIR}/"
|
echo "Backup complete in ${BACKUP_DIR}/"
|
||||||
|
|
|
||||||
|
|
@ -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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions lt
|
FROM spirekeeper.lamassu_transactions lt
|
||||||
LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
LEFT JOIN spirekeeper.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 satoshimachine.dca_clients WHERE remaining_balance > 0);
|
HAVING COUNT(dp.id) > (SELECT COUNT(*) FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions
|
ALTER TABLE spirekeeper.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 satoshimachine.lamassu_transactions;
|
FROM spirekeeper.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 satoshimachine.dca_payments dp
|
FROM spirekeeper.dca_payments dp
|
||||||
JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
|
JOIN spirekeeper.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 satoshimachine.lamassu_transactions;
|
FROM spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
||||||
LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
|
LEFT JOIN spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
||||||
LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
|
LEFT JOIN spirekeeper.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 satoshimachine.dca_deposits
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.dca_deposits (client_id, amount, status, note)
|
INSERT INTO spirekeeper.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 satoshimachine.dca_payments (client_id, amount_sats, status, note)
|
INSERT INTO spirekeeper.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 satoshimachine.dca_clients
|
UPDATE spirekeeper.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 satoshimachine.dca_clients
|
ALTER TABLE spirekeeper.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 satoshimachine.dca_deposits
|
ALTER TABLE spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions lt
|
FROM spirekeeper.lamassu_transactions lt
|
||||||
JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
||||||
JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
|
JOIN spirekeeper.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 satoshimachine.dca_clients c
|
UPDATE spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_config
|
FROM spirekeeper.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 satoshimachine.dca_clients c
|
FROM spirekeeper.dca_clients c
|
||||||
LEFT JOIN satoshimachine.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
LEFT JOIN spirekeeper.dca_deposits d ON c.id = d.client_id AND d.status = 'confirmed'
|
||||||
LEFT JOIN satoshimachine.dca_payments p ON c.id = p.client_id
|
LEFT JOIN spirekeeper.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 satoshimachine.lamassu_transactions;
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
FROM spirekeeper.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 satoshimachine.dca_deposits
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.lamassu_config
|
UPDATE spirekeeper.lamassu_config
|
||||||
SET polling_enabled = false
|
SET polling_enabled = false
|
||||||
WHERE id = (SELECT MAX(id) FROM satoshimachine.lamassu_config);
|
WHERE id = (SELECT MAX(id) FROM spirekeeper.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 satoshimachine.dca_clients
|
UPDATE spirekeeper.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 satoshimachine.lamassu_transactions;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_payments;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_deposits;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_clients;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.lamassu_transactions lt
|
FROM spirekeeper.lamassu_transactions lt
|
||||||
LEFT JOIN satoshimachine.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
LEFT JOIN spirekeeper.dca_payments dp ON dp.lamassu_transaction_id = lt.id
|
||||||
LEFT JOIN satoshimachine.dca_clients c ON dp.client_id = c.id
|
LEFT JOIN spirekeeper.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 satoshimachine.lamassu_transactions
|
ALTER TABLE spirekeeper.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 satoshimachine.dca_clients
|
ALTER TABLE spirekeeper.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 satoshimachine.dca_deposits
|
ALTER TABLE spirekeeper.dca_deposits
|
||||||
ADD CONSTRAINT positive_deposit CHECK (amount > 0);
|
ADD CONSTRAINT positive_deposit CHECK (amount > 0);
|
||||||
|
|
||||||
ALTER TABLE satoshimachine.dca_payments
|
ALTER TABLE spirekeeper.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>/satoshimachine`
|
- URL: `https://<your-lnbits-host>/spirekeeper`
|
||||||
- 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 satoshimachine.<table_name>;"
|
sqlite3 /path/to/db "SELECT * FROM spirekeeper.<table_name>;"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Log Files**:
|
**Log Files**:
|
||||||
|
|
@ -1274,7 +1274,7 @@ pkill -f lnbits
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Disable automatic polling
|
-- Disable automatic polling
|
||||||
UPDATE satoshimachine.lamassu_config
|
UPDATE spirekeeper.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 satoshimachine.dca_clients
|
FROM spirekeeper.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 satoshimachine.lamassu_transactions
|
FROM spirekeeper.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 satoshimachine.dca_payments
|
SELECT * FROM spirekeeper.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 satoshimachine.lamassu_transactions;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_payments;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_deposits;" \
|
"SELECT * FROM spirekeeper.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 satoshimachine.dca_clients;" \
|
"SELECT * FROM spirekeeper.dca_clients;" \
|
||||||
> ${BACKUP_DIR}/dca_clients.csv
|
> ${BACKUP_DIR}/dca_clients.csv
|
||||||
|
|
||||||
echo "Backup complete in ${BACKUP_DIR}/"
|
echo "Backup complete in ${BACKUP_DIR}/"
|
||||||
|
|
|
||||||
|
|
@ -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"satmachineadmin: {prefix}published kind-30078 to ATM "
|
f"spirekeeper: {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]}..."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin keeps
|
registration call lazily-imports + soft-fails so spirekeeper 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 = "satmachineadmin"
|
_SOURCE_EXTENSION = "spirekeeper"
|
||||||
|
|
||||||
|
|
||||||
@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 satmachineadmin
|
state until the path-B lnbits PR lands. Either way spirekeeper
|
||||||
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 `satmachineadmin_start()`. Idempotent on the
|
Called once from `spirekeeper_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(
|
||||||
"satmachineadmin: nostr-transport roster-resolver hook not "
|
"spirekeeper: 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"satmachineadmin: registered '{_SOURCE_EXTENSION}' roster "
|
f"spirekeeper: 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 "
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "satmachineadmin"
|
name = "spirekeeper"
|
||||||
version = "0.0.4"
|
version = "0.1.0"
|
||||||
description = "Eightball is a simple API that allows you to create a random number generator."
|
description = "bitSpire admin — Lightning DCA + Lamassu ATM operator administration for LNbits"
|
||||||
authors = ["benarc", "dni <dni@lnbits.com>"]
|
authors = ["benarc", "dni <dni@lnbits.com>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin REST surface
|
// Vue 3 + Quasar UMD app. Talks to the v2 spirekeeper 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 = '/satmachineadmin/api/v1/dca'
|
const API = '/spirekeeper/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/satmachineadmin/index.html.
|
// templates/spirekeeper/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
|
||||||
|
|
|
||||||
46
tasks.py
46
tasks.py
|
|
@ -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_satmachineadmin"
|
LISTENER_NAME = "ext_spirekeeper"
|
||||||
|
|
||||||
# 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(
|
||||||
"satmachineadmin v2: invoice listener registered as "
|
"spirekeeper 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"satmachineadmin: error handling payment "
|
f"spirekeeper: 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"satmachineadmin: failed to insert settlement for "
|
f"spirekeeper: 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"satmachineadmin: landed settlement {settlement.id} for "
|
f"spirekeeper: 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"satmachineadmin: failed to insert rejected settlement for "
|
f"spirekeeper: 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"satmachineadmin: rejected settlement {rejected.id} "
|
f"spirekeeper: 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; satmachineadmin's apply_bootstrap_
|
# publish idempotent on ATM-side restart; spirekeeper'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 = "satmachineadmin-cassette-bootstrap"
|
CASSETTE_BOOTSTRAP_SUB_ID = "spirekeeper-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(
|
||||||
"satmachineadmin v2: cassette bootstrap consumer starting "
|
"spirekeeper 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(
|
||||||
"satmachineadmin: nostrclient extension not installed; "
|
"spirekeeper: 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"satmachineadmin: cassette consumer loop error (continuing): " f"{exc}"
|
f"spirekeeper: 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(
|
||||||
"satmachineadmin: (re)registered cassette bootstrap "
|
"spirekeeper: (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(
|
||||||
"satmachineadmin: no active machines; closed cassette "
|
"spirekeeper: 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"satmachineadmin: cassette state event handler "
|
f"spirekeeper: 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"satmachineadmin: cassette event of unexpected type "
|
f"spirekeeper: 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"satmachineadmin: cassette state event sig verify failed "
|
f"spirekeeper: 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"satmachineadmin: cassette state event from unknown ATM "
|
f"spirekeeper: 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"satmachineadmin: can't resolve signer for operator "
|
f"spirekeeper: 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"satmachineadmin: cassette state event for machine {machine.id} "
|
f"spirekeeper: 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"satmachineadmin: cassette state event decode failed for "
|
f"spirekeeper: 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"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
|
f"spirekeeper: 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"satmachineadmin: cassette state event {event_id[:12]}... "
|
f"spirekeeper: 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)"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{{ window_vars(user) }}
|
{{ window_vars(user) }}
|
||||||
<script src="{{ static_url_for('satmachineadmin/static', path='js/index.js') }}"></script>
|
<script src="{{ static_url_for('spirekeeper/static', path='js/index.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page %}
|
{% block page %}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Pytest configuration for the satmachineadmin extension test suite.
|
Pytest configuration for the spirekeeper 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
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin restart should no-op, not re-upsert the same
|
or spirekeeper 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin UI in the first place.
|
through the spirekeeper 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
|
||||||
|
|
|
||||||
|
|
@ -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 satmachineadmin
|
Positive = bitspire over-reported (claimed more fee than spirekeeper
|
||||||
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; satmachineadmin recomputes 8_000.
|
100_000 * 0.0777 = 7_770 sats; spirekeeper 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)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""
|
"""
|
||||||
Tests for `nostr_transport_roster.resolve` — the lookup function
|
Tests for `nostr_transport_roster.resolve` — the lookup function
|
||||||
satmachineadmin hands lnbits' nostr-transport via
|
spirekeeper 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 == "satmachineadmin"
|
assert result.source_extension == "spirekeeper"
|
||||||
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
|
||||||
satmachineadmin keeps booting on every lnbits version."""
|
spirekeeper keeps booting on every lnbits version."""
|
||||||
real_import = (
|
real_import = (
|
||||||
__builtins__["__import__"]
|
__builtins__["__import__"]
|
||||||
if isinstance(__builtins__, dict)
|
if isinstance(__builtins__, dict)
|
||||||
|
|
|
||||||
12
views.py
12
views.py
|
|
@ -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
|
||||||
|
|
||||||
satmachineadmin_generic_router = APIRouter()
|
spirekeeper_generic_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def satmachineadmin_renderer():
|
def spirekeeper_renderer():
|
||||||
return template_renderer(["satmachineadmin/templates"])
|
return template_renderer(["spirekeeper/templates"])
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_generic_router.get("/", response_class=HTMLResponse)
|
@spirekeeper_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 satmachineadmin_renderer().TemplateResponse(
|
return spirekeeper_renderer().TemplateResponse(
|
||||||
"satmachineadmin/index.html",
|
"spirekeeper/index.html",
|
||||||
{"request": req, "user": user.json()},
|
{"request": req, "user": user.json()},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
74
views_api.py
74
views_api.py
|
|
@ -94,7 +94,7 @@ from .models import (
|
||||||
UpsertCassetteConfigData,
|
UpsertCassetteConfigData,
|
||||||
)
|
)
|
||||||
|
|
||||||
satmachineadmin_api_router = APIRouter()
|
spirekeeper_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(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post("/api/v1/dca/machines", response_model=Machine)
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.put(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.delete(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post("/api/v1/dca/clients", response_model=DcaClient)
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get("/api/v1/dca/clients", response_model=list[DcaClient])
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.put(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.delete(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post("/api/v1/dca/deposits", response_model=DcaDeposit)
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get("/api/v1/dca/deposits", response_model=list[DcaDeposit])
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.put(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.put(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.delete(
|
@spirekeeper_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(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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).
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post(
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post(
|
@spirekeeper_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(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get("/api/v1/dca/payments", response_model=list[DcaPayment])
|
@spirekeeper_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(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.put(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.delete(
|
@spirekeeper_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(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get("/api/v1/dca/super-config", response_model=SuperConfig)
|
@spirekeeper_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
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.put("/api/v1/dca/super-config", response_model=SuperConfig)
|
@spirekeeper_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.
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@spirekeeper_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)
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.post(
|
@spirekeeper_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; "
|
||||||
"satmachineadmin will auto-populate cassette_configs on "
|
"spirekeeper 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
|
||||||
# satmachineadmin reflects the published payload, even if the ATM
|
# spirekeeper 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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue