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

View file

@ -290,20 +290,28 @@ async def m005_satmachine_v2_overhaul(db):
"ON satoshimachine.dca_deposits (client_id, created_at DESC)"
)
# dca_settlements — idempotency table for bitSpire kind-21000 events.
# CRITICAL: platform_fee_sats and operator_fee_sats are stored as absolute BIGINT
# (not as a derived percentage). Today this is just the contractual split. Once
# the v2 promotion engine ships, the two values diverge when discounts fire and
# this row is the only audit-grade record of who forgave what. Do not collapse
# them into a single commission_pct field. See plan section "Customer discounts".
# dca_settlements — idempotency table for bitSpire-driven settlements.
# The natural unique key is payment_hash (every LN invoice has a globally
# unique hash; subscription replays / dispatcher double-fires dedup via the
# UNIQUE constraint). bitspire_event_id is reserved for a future path where
# we subscribe to raw Nostr events directly (kind-30078/30079 ingestion
# uses dca_telemetry; bitspire_event_id is kept here for future bookkeeping
# if we ever bypass the LNbits Payment system).
#
# CRITICAL: platform_fee_sats and operator_fee_sats are stored as absolute
# BIGINT (not a derived percentage). Today this is just the contractual
# split. Once the v2 promotion engine ships, the two values diverge when
# discounts fire and this row is the only audit-grade record of who forgave
# what. Do not collapse them into a single commission_pct field. See plan
# section "Customer discounts".
await db.execute(
f"""
CREATE TABLE satoshimachine.dca_settlements (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
bitspire_event_id TEXT NOT NULL UNIQUE,
payment_hash TEXT NOT NULL UNIQUE,
bitspire_event_id TEXT,
bitspire_txid TEXT,
payment_hash TEXT NOT NULL,
gross_sats BIGINT NOT NULL,
fiat_amount DECIMAL(10,2) NOT NULL,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
@ -327,10 +335,7 @@ async def m005_satmachine_v2_overhaul(db):
"CREATE INDEX dca_settlements_machine_idx "
"ON satoshimachine.dca_settlements (machine_id, created_at DESC)"
)
await db.execute(
"CREATE INDEX dca_settlements_payment_hash_idx "
"ON satoshimachine.dca_settlements (payment_hash)"
)
# payment_hash UNIQUE already creates a lookup index — no extra index needed.
# dca_commission_splits — operator's rules for distributing the *remainder*
# of each commission (commission_sats - platform_fee_sats). One row per leg.