fix(v2): use payment_hash as settlement idempotency key

The initial m005 made bitspire_event_id the UNIQUE idempotency key on
dca_settlements, but settlements arriving through LNbits' invoice
listener (the canonical path per nostr-transport-branch architecture)
don't carry a Nostr event id at the Payment level — that's the
underlying transport's concern, not exposed to extensions.

The natural unique key is payment_hash:
- every LN invoice has a globally unique payment_hash
- subscription replays / dispatcher double-fires dedup via UNIQUE
- it's always present on the Payment object the invoice_listener delivers

Reshape the dca_settlements column constraints:
- payment_hash: TEXT NOT NULL UNIQUE (was: NOT NULL + separate index)
- bitspire_event_id: TEXT (was: NOT NULL UNIQUE) — kept nullable for
  a future path where we subscribe to raw kind-21000 Nostr events
  directly, bypassing the Payment system

Also rename the CRUD helper: get_settlement_by_event_id →
get_settlement_by_payment_hash, and update create_settlement_idempotent
to dedup on payment_hash. CreateDcaSettlementData / DcaSettlement
adjust accordingly.

The schema is unshipped (v2-bitspire branch is local only) — fixing
m005 in-place is appropriate. The separate dca_telemetry path for
kind-30078/30079 events already uses (machine_id, beacon_received_at)
semantics, so the UNIQUE-by-Nostr-event-id pattern isn't needed there
either.

Caught during P1a design before subscribing to register_invoice_listener.

Refs: aiolabs/satmachineadmin#9, aiolabs/lamassu-next#44

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 14:46:08 +02:00
commit cba327d0f0
3 changed files with 34 additions and 29 deletions

26
crud.py
View file

@ -410,24 +410,24 @@ async def delete_deposit(deposit_id: str) -> None:
async def create_settlement_idempotent(
data: CreateDcaSettlementData,
) -> Optional[DcaSettlement]:
"""Insert a settlement keyed by bitspire_event_id. Returns the inserted row
on first sight; returns the existing row if the event_id was already seen
(subscription replay, relay double-delivery). The UNIQUE constraint on
bitspire_event_id is the source of truth."""
existing = await get_settlement_by_event_id(data.bitspire_event_id)
"""Insert a settlement keyed by payment_hash. Returns the inserted row on
first sight; returns the existing row if the payment_hash was already seen
(subscription replay, dispatcher double-fire). The UNIQUE constraint on
payment_hash is the source of truth."""
existing = await get_settlement_by_payment_hash(data.payment_hash)
if existing is not None:
return existing
settlement_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO satoshimachine.dca_settlements
(id, machine_id, bitspire_event_id, bitspire_txid, payment_hash,
(id, machine_id, payment_hash, bitspire_event_id, bitspire_txid,
gross_sats, fiat_amount, fiat_code, exchange_rate, net_sats,
commission_sats, platform_fee_sats, operator_fee_sats,
used_fallback_split, tx_type, bills_json, cassettes_json,
status, created_at)
VALUES (:id, :machine_id, :bitspire_event_id, :bitspire_txid,
:payment_hash, :gross_sats, :fiat_amount, :fiat_code,
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
:exchange_rate, :net_sats, :commission_sats,
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
:tx_type, :bills_json, :cassettes_json, :status, :created_at)
@ -435,9 +435,9 @@ async def create_settlement_idempotent(
{
"id": settlement_id,
"machine_id": data.machine_id,
"payment_hash": data.payment_hash,
"bitspire_event_id": data.bitspire_event_id,
"bitspire_txid": data.bitspire_txid,
"payment_hash": data.payment_hash,
"gross_sats": data.gross_sats,
"fiat_amount": data.fiat_amount,
"fiat_code": data.fiat_code,
@ -465,15 +465,15 @@ async def get_settlement(settlement_id: str) -> Optional[DcaSettlement]:
)
async def get_settlement_by_event_id(
bitspire_event_id: str,
async def get_settlement_by_payment_hash(
payment_hash: str,
) -> Optional[DcaSettlement]:
return await db.fetchone(
"""
SELECT * FROM satoshimachine.dca_settlements
WHERE bitspire_event_id = :eid
WHERE payment_hash = :hash
""",
{"eid": bitspire_event_id},
{"hash": payment_hash},
DcaSettlement,
)