loopdb: persist static loop-in risk decisions

Add schema, sqlc queries, store fields, and SqlStore support for
recording server confirmation-risk decisions with static loop-in swaps.
Store the decision timestamp so payment-deadline recovery can
reconstruct elapsed time after restart.
This commit is contained in:
Slyghtning 2026-07-08 13:57:01 +02:00
parent b0f43bbe1e
commit 491fddbc34
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
9 changed files with 302 additions and 78 deletions

View file

@ -0,0 +1,3 @@
-- Drop confirmation-risk decision fields from static address loop-ins.
ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision;
ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision_time;

View file

@ -0,0 +1,15 @@
-- confirmation_risk_decision records the server's confirmation-risk decision
-- for a static address loop-in. Possible values are:
-- - '': no decision has been received yet;
-- - 'accepted': the server accepted waiting for the low-confirmation
-- deposits, which starts or reconstructs the payment deadline;
-- - 'rejected': the server stopped waiting for the low-confirmation deposits
-- before paying the invoice.
-- Once rejected, a later accepted update is ignored.
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision TEXT NOT NULL DEFAULT '';
-- confirmation_risk_decision_time records when loopd received and persisted
-- the server's decision, so payment deadlines can be reconstructed after
-- restart. Same-decision replays preserve the original timestamp; changing
-- from accepted to rejected updates it.
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision_time TIMESTAMP;

View file

@ -137,18 +137,20 @@ type StaticAddress struct {
}
type StaticAddressSwap struct {
ID int32
SwapHash []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ID int32
SwapHash []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
}
type StaticAddressSwapUpdate struct {

View file

@ -67,6 +67,7 @@ type Querier interface {
MapDepositToSwap(ctx context.Context, arg MapDepositToSwapParams) error
OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error
OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error
RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error
SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error)
UpdateBatch(ctx context.Context, arg UpdateBatchParams) error
UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error

View file

@ -33,6 +33,22 @@ SET
WHERE
swap_hash = $1;
-- name: RecordStaticAddressRiskDecision :exec
UPDATE static_address_swaps
SET
confirmation_risk_decision = $2,
confirmation_risk_decision_time = CASE
WHEN confirmation_risk_decision = $2 THEN
COALESCE(confirmation_risk_decision_time, $3)
ELSE $3
END
WHERE
swap_hash = $1
AND NOT (
confirmation_risk_decision = 'rejected'
AND $2 = 'accepted'
);
-- name: InsertStaticAddressMetaUpdate :exec
INSERT INTO static_address_swap_updates (
swap_hash,
@ -147,7 +163,3 @@ WHERE
d.swap_hash = $1;

View file

@ -153,7 +153,7 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]
const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one
SELECT
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
FROM
swaps
@ -166,36 +166,38 @@ WHERE
`
type GetStaticAddressLoopInSwapRow struct {
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
}
func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) {
@ -225,6 +227,8 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
&i.HtlcTimeoutSweepAddress,
&i.SelectedAmount,
&i.Fast,
&i.ConfirmationRiskDecision,
&i.ConfirmationRiskDecisionTime,
&i.SwapHash_3,
&i.SenderScriptPubkey,
&i.ReceiverScriptPubkey,
@ -239,7 +243,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many
SELECT
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
FROM
swaps
@ -263,36 +267,38 @@ ORDER BY
`
type GetStaticAddressLoopInSwapsByStatesRow struct {
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
}
func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) {
@ -328,6 +334,8 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla
&i.HtlcTimeoutSweepAddress,
&i.SelectedAmount,
&i.Fast,
&i.ConfirmationRiskDecision,
&i.ConfirmationRiskDecisionTime,
&i.SwapHash_3,
&i.SenderScriptPubkey,
&i.ReceiverScriptPubkey,
@ -482,6 +490,34 @@ func (q *Queries) OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSe
return err
}
const recordStaticAddressRiskDecision = `-- name: RecordStaticAddressRiskDecision :exec
UPDATE static_address_swaps
SET
confirmation_risk_decision = $2,
confirmation_risk_decision_time = CASE
WHEN confirmation_risk_decision = $2 THEN
COALESCE(confirmation_risk_decision_time, $3)
ELSE $3
END
WHERE
swap_hash = $1
AND NOT (
confirmation_risk_decision = 'rejected'
AND $2 = 'accepted'
)
`
type RecordStaticAddressRiskDecisionParams struct {
SwapHash []byte
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
}
func (q *Queries) RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error {
_, err := q.db.ExecContext(ctx, recordStaticAddressRiskDecision, arg.SwapHash, arg.ConfirmationRiskDecision, arg.ConfirmationRiskDecisionTime)
return err
}
const swapHashForDepositID = `-- name: SwapHashForDepositID :one
SELECT
swap_hash

View file

@ -31,6 +31,23 @@ import (
"github.com/lightningnetwork/lnd/zpay32"
)
// ConfirmationRiskDecision records the server's decision on whether it accepts
// waiting for low-confirmation deposits before paying a static loop-in invoice.
type ConfirmationRiskDecision string
const (
// ConfirmationRiskDecisionNone means no risk decision has been received.
ConfirmationRiskDecisionNone ConfirmationRiskDecision = ""
// ConfirmationRiskDecisionAccepted means the server accepted waiting for
// deposit confirmations and the payment deadline has started.
ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted"
// ConfirmationRiskDecisionRejected means the server stopped waiting for
// deposit confirmations before paying the invoice.
ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected"
)
// StaticAddressLoopIn represents the in-memory loop-in information.
type StaticAddressLoopIn struct {
// SwapHash is the hashed preimage of the swap invoice. It represents
@ -107,6 +124,15 @@ type StaticAddressLoopIn struct {
// LastUpdateTime is the timestamp of the latest persisted state update.
LastUpdateTime time.Time
// ConfirmationRiskDecision records the server's persisted decision on
// low-confirmation deposit risk.
ConfirmationRiskDecision ConfirmationRiskDecision
// ConfirmationRiskDecisionTime is when loopd persisted the server risk
// decision. It is used to reconstruct payment-deadline timeouts after
// restart.
ConfirmationRiskDecisionTime time.Time
// state is the current state of the swap.
state fsm.StateType

View file

@ -27,6 +27,9 @@ var (
// ErrInvalidOutpoint is returned when an outpoint contains the outpoint
// separator.
ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator")
// ErrLoopInNotFound is returned when a loop-in swap is not stored.
ErrLoopInNotFound = errors.New("static address loop-in not found")
)
// Querier is the interface that contains all the queries generated by sqlc for
@ -51,6 +54,11 @@ type Querier interface {
UpdateStaticAddressLoopIn(ctx context.Context,
arg sqlc.UpdateStaticAddressLoopInParams) error
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
// decision for a loop-in swap.
RecordStaticAddressRiskDecision(ctx context.Context,
arg sqlc.RecordStaticAddressRiskDecisionParams) error
// GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash.
GetStaticAddressLoopInSwap(ctx context.Context,
swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error)
@ -361,6 +369,43 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context,
)
}
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
// decision for a static address loop-in. The timestamp is written by the store
// so recovery can reconstruct the remaining payment deadline from one durable
// clock source.
func (s *SqlStore) RecordStaticAddressRiskDecision(ctx context.Context,
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error {
if decision != ConfirmationRiskDecisionAccepted &&
decision != ConfirmationRiskDecisionRejected {
return errors.New("unknown confirmation risk decision")
}
params := sqlc.RecordStaticAddressRiskDecisionParams{
SwapHash: swapHash[:],
ConfirmationRiskDecision: string(decision),
ConfirmationRiskDecisionTime: sql.NullTime{
Time: s.clock.Now(),
Valid: true,
},
}
return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
func(q Querier) error {
stored, err := q.IsStored(ctx, swapHash[:])
if err != nil {
return err
}
if !stored {
return ErrLoopInNotFound
}
return q.RecordStaticAddressRiskDecision(ctx, params)
},
)
}
func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context,
updateAmounts map[lntypes.Hash]btcutil.Amount) error {
@ -584,6 +629,9 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
DepositOutpoints: depositOutpoints,
SelectedAmount: btcutil.Amount(swap.SelectedAmount),
Fast: swap.Fast,
ConfirmationRiskDecision: ConfirmationRiskDecision(
swap.ConfirmationRiskDecision,
),
HtlcTxFeeRate: chainfee.SatPerKWeight(
swap.HtlcTxFeeRateSatKw,
),
@ -591,6 +639,10 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash,
Deposits: depositList,
}
if swap.ConfirmationRiskDecisionTime.Valid {
loopIn.ConfirmationRiskDecisionTime =
swap.ConfirmationRiskDecisionTime.Time
}
if len(updates) > 0 {
lastUpdate := updates[len(updates)-1]

View file

@ -349,6 +349,83 @@ func TestCreateLoopIn(t *testing.T) {
require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()},
swap.DepositOutpoints)
require.Equal(t, SignHtlcTx, swap.GetState())
require.Equal(
t, ConfirmationRiskDecisionNone,
swap.ConfirmationRiskDecision,
)
decisionTime := time.Unix(123, 0).UTC()
testClock.SetTime(decisionTime)
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionAccepted,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
// Replaying the same decision must retain its original deadline anchor.
laterDecisionTime := decisionTime.Add(time.Hour)
testClock.SetTime(laterDecisionTime)
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionAccepted,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
// A different decision is a new event and receives a new timestamp.
rejectedDecisionTime := laterDecisionTime.Add(time.Hour)
testClock.SetTime(rejectedDecisionTime)
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionRejected,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionRejected,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(
rejectedDecisionTime,
))
// Rejected is terminal: a racing synthetic acceptance must not replace
// the server's rejection or move its deadline anchor.
testClock.SetTime(rejectedDecisionTime.Add(time.Hour))
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionRejected,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(
rejectedDecisionTime,
))
err = swapStore.RecordStaticAddressRiskDecision(
ctx, lntypes.Hash{0x9, 0x9, 0x9},
ConfirmationRiskDecisionRejected,
)
require.ErrorIs(t, err, ErrLoopInNotFound)
require.Len(t, swap.Deposits, 2)