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:
parent
937749f149
commit
cba327d0f0
3 changed files with 34 additions and 29 deletions
26
crud.py
26
crud.py
|
|
@ -410,24 +410,24 @@ async def delete_deposit(deposit_id: str) -> None:
|
||||||
async def create_settlement_idempotent(
|
async def create_settlement_idempotent(
|
||||||
data: CreateDcaSettlementData,
|
data: CreateDcaSettlementData,
|
||||||
) -> Optional[DcaSettlement]:
|
) -> Optional[DcaSettlement]:
|
||||||
"""Insert a settlement keyed by bitspire_event_id. Returns the inserted row
|
"""Insert a settlement keyed by payment_hash. Returns the inserted row on
|
||||||
on first sight; returns the existing row if the event_id was already seen
|
first sight; returns the existing row if the payment_hash was already seen
|
||||||
(subscription replay, relay double-delivery). The UNIQUE constraint on
|
(subscription replay, dispatcher double-fire). The UNIQUE constraint on
|
||||||
bitspire_event_id is the source of truth."""
|
payment_hash is the source of truth."""
|
||||||
existing = await get_settlement_by_event_id(data.bitspire_event_id)
|
existing = await get_settlement_by_payment_hash(data.payment_hash)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
settlement_id = urlsafe_short_hash()
|
settlement_id = urlsafe_short_hash()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO satoshimachine.dca_settlements
|
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,
|
gross_sats, fiat_amount, fiat_code, exchange_rate, net_sats,
|
||||||
commission_sats, platform_fee_sats, operator_fee_sats,
|
commission_sats, platform_fee_sats, operator_fee_sats,
|
||||||
used_fallback_split, tx_type, bills_json, cassettes_json,
|
used_fallback_split, tx_type, bills_json, cassettes_json,
|
||||||
status, created_at)
|
status, created_at)
|
||||||
VALUES (:id, :machine_id, :bitspire_event_id, :bitspire_txid,
|
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
|
||||||
:payment_hash, :gross_sats, :fiat_amount, :fiat_code,
|
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
|
||||||
:exchange_rate, :net_sats, :commission_sats,
|
:exchange_rate, :net_sats, :commission_sats,
|
||||||
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
|
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
|
||||||
:tx_type, :bills_json, :cassettes_json, :status, :created_at)
|
:tx_type, :bills_json, :cassettes_json, :status, :created_at)
|
||||||
|
|
@ -435,9 +435,9 @@ async def create_settlement_idempotent(
|
||||||
{
|
{
|
||||||
"id": settlement_id,
|
"id": settlement_id,
|
||||||
"machine_id": data.machine_id,
|
"machine_id": data.machine_id,
|
||||||
|
"payment_hash": data.payment_hash,
|
||||||
"bitspire_event_id": data.bitspire_event_id,
|
"bitspire_event_id": data.bitspire_event_id,
|
||||||
"bitspire_txid": data.bitspire_txid,
|
"bitspire_txid": data.bitspire_txid,
|
||||||
"payment_hash": data.payment_hash,
|
|
||||||
"gross_sats": data.gross_sats,
|
"gross_sats": data.gross_sats,
|
||||||
"fiat_amount": data.fiat_amount,
|
"fiat_amount": data.fiat_amount,
|
||||||
"fiat_code": data.fiat_code,
|
"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(
|
async def get_settlement_by_payment_hash(
|
||||||
bitspire_event_id: str,
|
payment_hash: str,
|
||||||
) -> Optional[DcaSettlement]:
|
) -> Optional[DcaSettlement]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM satoshimachine.dca_settlements
|
SELECT * FROM satoshimachine.dca_settlements
|
||||||
WHERE bitspire_event_id = :eid
|
WHERE payment_hash = :hash
|
||||||
""",
|
""",
|
||||||
{"eid": bitspire_event_id},
|
{"hash": payment_hash},
|
||||||
DcaSettlement,
|
DcaSettlement,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -290,20 +290,28 @@ async def m005_satmachine_v2_overhaul(db):
|
||||||
"ON satoshimachine.dca_deposits (client_id, created_at DESC)"
|
"ON satoshimachine.dca_deposits (client_id, created_at DESC)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# dca_settlements — idempotency table for bitSpire kind-21000 events.
|
# dca_settlements — idempotency table for bitSpire-driven settlements.
|
||||||
# CRITICAL: platform_fee_sats and operator_fee_sats are stored as absolute BIGINT
|
# The natural unique key is payment_hash (every LN invoice has a globally
|
||||||
# (not as a derived percentage). Today this is just the contractual split. Once
|
# unique hash; subscription replays / dispatcher double-fires dedup via the
|
||||||
# the v2 promotion engine ships, the two values diverge when discounts fire and
|
# UNIQUE constraint). bitspire_event_id is reserved for a future path where
|
||||||
# this row is the only audit-grade record of who forgave what. Do not collapse
|
# we subscribe to raw Nostr events directly (kind-30078/30079 ingestion
|
||||||
# them into a single commission_pct field. See plan section "Customer discounts".
|
# 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(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE satoshimachine.dca_settlements (
|
CREATE TABLE satoshimachine.dca_settlements (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
machine_id TEXT NOT NULL,
|
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,
|
bitspire_txid TEXT,
|
||||||
payment_hash TEXT NOT NULL,
|
|
||||||
gross_sats BIGINT NOT NULL,
|
gross_sats BIGINT NOT NULL,
|
||||||
fiat_amount DECIMAL(10,2) NOT NULL,
|
fiat_amount DECIMAL(10,2) NOT NULL,
|
||||||
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
|
|
@ -327,10 +335,7 @@ async def m005_satmachine_v2_overhaul(db):
|
||||||
"CREATE INDEX dca_settlements_machine_idx "
|
"CREATE INDEX dca_settlements_machine_idx "
|
||||||
"ON satoshimachine.dca_settlements (machine_id, created_at DESC)"
|
"ON satoshimachine.dca_settlements (machine_id, created_at DESC)"
|
||||||
)
|
)
|
||||||
await db.execute(
|
# payment_hash UNIQUE already creates a lookup index — no extra index needed.
|
||||||
"CREATE INDEX dca_settlements_payment_hash_idx "
|
|
||||||
"ON satoshimachine.dca_settlements (payment_hash)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# dca_commission_splits — operator's rules for distributing the *remainder*
|
# dca_commission_splits — operator's rules for distributing the *remainder*
|
||||||
# of each commission (commission_sats - platform_fee_sats). One row per leg.
|
# of each commission (commission_sats - platform_fee_sats). One row per leg.
|
||||||
|
|
|
||||||
|
|
@ -185,9 +185,9 @@ class UpdateDepositStatusData(BaseModel):
|
||||||
|
|
||||||
class CreateDcaSettlementData(BaseModel):
|
class CreateDcaSettlementData(BaseModel):
|
||||||
machine_id: str
|
machine_id: str
|
||||||
bitspire_event_id: str # nostr event id — the idempotency key
|
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
|
||||||
|
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion
|
||||||
bitspire_txid: Optional[str] = None
|
bitspire_txid: Optional[str] = None
|
||||||
payment_hash: str
|
|
||||||
gross_sats: int
|
gross_sats: int
|
||||||
fiat_amount: float
|
fiat_amount: float
|
||||||
fiat_code: str = "GTQ"
|
fiat_code: str = "GTQ"
|
||||||
|
|
@ -205,9 +205,9 @@ class CreateDcaSettlementData(BaseModel):
|
||||||
class DcaSettlement(BaseModel):
|
class DcaSettlement(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
machine_id: str
|
machine_id: str
|
||||||
bitspire_event_id: str
|
|
||||||
bitspire_txid: Optional[str]
|
|
||||||
payment_hash: str
|
payment_hash: str
|
||||||
|
bitspire_event_id: Optional[str]
|
||||||
|
bitspire_txid: Optional[str]
|
||||||
gross_sats: int
|
gross_sats: int
|
||||||
fiat_amount: float
|
fiat_amount: float
|
||||||
fiat_code: str
|
fiat_code: str
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue