staticaddr/loopin: use generated change addresses

Create a fresh static change address for fractional loop-ins and persist
its key locator with the selected HTLC outpoint. Recovery reconstructs
the same change output instead of returning funds to the legacy root
address.
This commit is contained in:
Slyghtning 2026-07-10 14:23:57 +02:00
parent b8157ef901
commit dc42446bae
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
18 changed files with 878 additions and 90 deletions

View file

@ -0,0 +1 @@
ALTER TABLE static_address_swaps DROP COLUMN change_static_address_id;

View file

@ -0,0 +1,13 @@
ALTER TABLE static_address_swaps
ADD change_static_address_id INT REFERENCES static_addresses(id);
-- Existing fractional swaps sent change back to the legacy static address.
-- Backfill that relation so in-flight swaps remain recoverable after the
-- client starts requiring explicit per-swap change metadata.
UPDATE static_address_swaps
SET change_static_address_id = (
SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1
)
WHERE selected_amount > 0
AND change_static_address_id IS NULL
AND EXISTS (SELECT 1 FROM static_addresses);

View file

@ -0,0 +1,8 @@
ALTER TABLE static_address_swaps
DROP COLUMN confirmed_htlc_output_value;
ALTER TABLE static_address_swaps
DROP COLUMN confirmed_htlc_output_index;
ALTER TABLE static_address_swaps
DROP COLUMN confirmed_htlc_tx_id;

View file

@ -0,0 +1,8 @@
ALTER TABLE static_address_swaps
ADD confirmed_htlc_tx_id TEXT;
ALTER TABLE static_address_swaps
ADD confirmed_htlc_output_index INTEGER;
ALTER TABLE static_address_swaps
ADD confirmed_htlc_output_value BIGINT;

View file

@ -152,6 +152,10 @@ type StaticAddressSwap struct {
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
ChangeStaticAddressID sql.NullInt32
ConfirmedHtlcTxID sql.NullString
ConfirmedHtlcOutputIndex sql.NullInt32
ConfirmedHtlcOutputValue sql.NullInt64
}
type StaticAddressSwapUpdate struct {

View file

@ -10,7 +10,8 @@ INSERT INTO static_address_swaps (
htlc_tx_fee_rate_sat_kw,
htlc_timeout_sweep_tx_id,
htlc_timeout_sweep_address,
fast
fast,
change_static_address_id
) VALUES (
$1,
$2,
@ -22,14 +23,18 @@ INSERT INTO static_address_swaps (
$8,
$9,
$10,
$11
$11,
$12
);
-- name: UpdateStaticAddressLoopIn :exec
UPDATE static_address_swaps
SET
htlc_tx_fee_rate_sat_kw = $2,
htlc_timeout_sweep_tx_id = $3
htlc_timeout_sweep_tx_id = $3,
confirmed_htlc_tx_id = $4,
confirmed_htlc_output_index = $5,
confirmed_htlc_output_value = $6
WHERE
swap_hash = $1;
@ -64,13 +69,24 @@ INSERT INTO static_address_swap_updates (
SELECT
swaps.*,
static_address_swaps.*,
htlc_keys.*
htlc_keys.*,
change_address.client_pubkey change_client_pubkey,
change_address.server_pubkey change_server_pubkey,
change_address.expiry change_expiry,
change_address.client_key_family change_client_key_family,
change_address.client_key_index change_client_key_index,
change_address.pkscript change_pkscript,
change_address.protocol_version change_protocol_version,
change_address.initiation_height change_initiation_height
FROM
swaps
JOIN
static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash
JOIN
htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash
LEFT JOIN
static_addresses change_address
ON static_address_swaps.change_static_address_id = change_address.id
WHERE
swaps.swap_hash = $1;
@ -78,13 +94,24 @@ WHERE
SELECT
swaps.*,
static_address_swaps.*,
htlc_keys.*
htlc_keys.*,
change_address.client_pubkey change_client_pubkey,
change_address.server_pubkey change_server_pubkey,
change_address.expiry change_expiry,
change_address.client_key_family change_client_key_family,
change_address.client_key_index change_client_key_index,
change_address.pkscript change_pkscript,
change_address.protocol_version change_protocol_version,
change_address.initiation_height change_initiation_height
FROM
swaps
JOIN
static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash
JOIN
htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash
LEFT JOIN
static_addresses change_address
ON static_address_swaps.change_static_address_id = change_address.id
JOIN
static_address_swap_updates u ON swaps.swap_hash = u.swap_hash
-- This subquery ensures that we are checking only the latest update for
@ -170,5 +197,3 @@ FROM
)
WHERE
d.swap_hash = $1;

View file

@ -180,14 +180,25 @@ 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.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
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, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value,
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,
change_address.client_pubkey change_client_pubkey,
change_address.server_pubkey change_server_pubkey,
change_address.expiry change_expiry,
change_address.client_key_family change_client_key_family,
change_address.client_key_index change_client_key_index,
change_address.pkscript change_pkscript,
change_address.protocol_version change_protocol_version,
change_address.initiation_height change_initiation_height
FROM
swaps
JOIN
static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash
JOIN
htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash
LEFT JOIN
static_addresses change_address
ON static_address_swaps.change_static_address_id = change_address.id
WHERE
swaps.swap_hash = $1
`
@ -218,6 +229,10 @@ type GetStaticAddressLoopInSwapRow struct {
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
ChangeStaticAddressID sql.NullInt32
ConfirmedHtlcTxID sql.NullString
ConfirmedHtlcOutputIndex sql.NullInt32
ConfirmedHtlcOutputValue sql.NullInt64
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
@ -225,6 +240,14 @@ type GetStaticAddressLoopInSwapRow struct {
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
ChangeClientPubkey []byte
ChangeServerPubkey []byte
ChangeExpiry sql.NullInt32
ChangeClientKeyFamily sql.NullInt32
ChangeClientKeyIndex sql.NullInt32
ChangePkscript []byte
ChangeProtocolVersion sql.NullInt32
ChangeInitiationHeight sql.NullInt32
}
func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) {
@ -256,6 +279,10 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
&i.Fast,
&i.ConfirmationRiskDecision,
&i.ConfirmationRiskDecisionTime,
&i.ChangeStaticAddressID,
&i.ConfirmedHtlcTxID,
&i.ConfirmedHtlcOutputIndex,
&i.ConfirmedHtlcOutputValue,
&i.SwapHash_3,
&i.SenderScriptPubkey,
&i.ReceiverScriptPubkey,
@ -263,6 +290,14 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
&i.ReceiverInternalPubkey,
&i.ClientKeyFamily,
&i.ClientKeyIndex,
&i.ChangeClientPubkey,
&i.ChangeServerPubkey,
&i.ChangeExpiry,
&i.ChangeClientKeyFamily,
&i.ChangeClientKeyIndex,
&i.ChangePkscript,
&i.ChangeProtocolVersion,
&i.ChangeInitiationHeight,
)
return i, err
}
@ -270,14 +305,25 @@ 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.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
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, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value,
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,
change_address.client_pubkey change_client_pubkey,
change_address.server_pubkey change_server_pubkey,
change_address.expiry change_expiry,
change_address.client_key_family change_client_key_family,
change_address.client_key_index change_client_key_index,
change_address.pkscript change_pkscript,
change_address.protocol_version change_protocol_version,
change_address.initiation_height change_initiation_height
FROM
swaps
JOIN
static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash
JOIN
htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash
LEFT JOIN
static_addresses change_address
ON static_address_swaps.change_static_address_id = change_address.id
JOIN
static_address_swap_updates u ON swaps.swap_hash = u.swap_hash
-- This subquery ensures that we are checking only the latest update for
@ -319,6 +365,10 @@ type GetStaticAddressLoopInSwapsByStatesRow struct {
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
ChangeStaticAddressID sql.NullInt32
ConfirmedHtlcTxID sql.NullString
ConfirmedHtlcOutputIndex sql.NullInt32
ConfirmedHtlcOutputValue sql.NullInt64
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
@ -326,6 +376,14 @@ type GetStaticAddressLoopInSwapsByStatesRow struct {
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
ChangeClientPubkey []byte
ChangeServerPubkey []byte
ChangeExpiry sql.NullInt32
ChangeClientKeyFamily sql.NullInt32
ChangeClientKeyIndex sql.NullInt32
ChangePkscript []byte
ChangeProtocolVersion sql.NullInt32
ChangeInitiationHeight sql.NullInt32
}
func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) {
@ -363,6 +421,10 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla
&i.Fast,
&i.ConfirmationRiskDecision,
&i.ConfirmationRiskDecisionTime,
&i.ChangeStaticAddressID,
&i.ConfirmedHtlcTxID,
&i.ConfirmedHtlcOutputIndex,
&i.ConfirmedHtlcOutputValue,
&i.SwapHash_3,
&i.SenderScriptPubkey,
&i.ReceiverScriptPubkey,
@ -370,6 +432,14 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla
&i.ReceiverInternalPubkey,
&i.ClientKeyFamily,
&i.ClientKeyIndex,
&i.ChangeClientPubkey,
&i.ChangeServerPubkey,
&i.ChangeExpiry,
&i.ChangeClientKeyFamily,
&i.ChangeClientKeyIndex,
&i.ChangePkscript,
&i.ChangeProtocolVersion,
&i.ChangeInitiationHeight,
); err != nil {
return nil, err
}
@ -396,7 +466,8 @@ INSERT INTO static_address_swaps (
htlc_tx_fee_rate_sat_kw,
htlc_timeout_sweep_tx_id,
htlc_timeout_sweep_address,
fast
fast,
change_static_address_id
) VALUES (
$1,
$2,
@ -408,7 +479,8 @@ INSERT INTO static_address_swaps (
$8,
$9,
$10,
$11
$11,
$12
)
`
@ -424,6 +496,7 @@ type InsertStaticAddressLoopInParams struct {
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
Fast bool
ChangeStaticAddressID sql.NullInt32
}
func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStaticAddressLoopInParams) error {
@ -439,6 +512,7 @@ func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStati
arg.HtlcTimeoutSweepTxID,
arg.HtlcTimeoutSweepAddress,
arg.Fast,
arg.ChangeStaticAddressID,
)
return err
}
@ -565,18 +639,31 @@ const updateStaticAddressLoopIn = `-- name: UpdateStaticAddressLoopIn :exec
UPDATE static_address_swaps
SET
htlc_tx_fee_rate_sat_kw = $2,
htlc_timeout_sweep_tx_id = $3
htlc_timeout_sweep_tx_id = $3,
confirmed_htlc_tx_id = $4,
confirmed_htlc_output_index = $5,
confirmed_htlc_output_value = $6
WHERE
swap_hash = $1
`
type UpdateStaticAddressLoopInParams struct {
SwapHash []byte
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
SwapHash []byte
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
ConfirmedHtlcTxID sql.NullString
ConfirmedHtlcOutputIndex sql.NullInt32
ConfirmedHtlcOutputValue sql.NullInt64
}
func (q *Queries) UpdateStaticAddressLoopIn(ctx context.Context, arg UpdateStaticAddressLoopInParams) error {
_, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, arg.SwapHash, arg.HtlcTxFeeRateSatKw, arg.HtlcTimeoutSweepTxID)
_, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn,
arg.SwapHash,
arg.HtlcTxFeeRateSatKw,
arg.HtlcTimeoutSweepTxID,
arg.ConfirmedHtlcTxID,
arg.ConfirmedHtlcOutputIndex,
arg.ConfirmedHtlcOutputValue,
)
return err
}

View file

@ -1,6 +1,7 @@
package loopin
import (
"bytes"
"context"
"crypto/rand"
"errors"
@ -109,6 +110,29 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
}
swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee
var changeOutput *swapserverrpc.StaticAddressChangeOutput
if hasChange {
changeAmount := f.loopIn.ExpectedChangeAmount()
f.loopIn.ChangeAddressParams, err =
f.cfg.AddressManager.NewChangeAddress(ctx)
if err != nil {
err = fmt.Errorf("unable to create static address "+
"change output: %w", err)
return returnError(err)
}
changeOutput, err = staticutil.ChangeOutput(
f.loopIn.ChangeAddressParams, changeAmount,
)
if err != nil {
err = fmt.Errorf("unable to prepare static address "+
"change output: %w", err)
return returnError(err)
}
}
// Generate random preimage.
var swapPreimage lntypes.Preimage
if _, err = rand.Read(swapPreimage[:]); err != nil {
@ -179,6 +203,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds,
Fast: f.loopIn.Fast,
DepositToClientPubkeys: depositClientPubkeys,
ChangeOutput: changeOutput,
}
if f.loopIn.LastHop != nil {
loopInReq.LastHop = f.loopIn.LastHop
@ -1147,9 +1172,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
htlcConfirmed := false
for {
select {
case <-htlcConfChan:
case conf := <-htlcConfChan:
f.Infof("htlc tx confirmed")
err = f.recordConfirmedHtlc(ctx, conf, htlc.PkScript)
if err != nil {
return f.HandleError(err)
}
htlcConfirmed = true
if invoiceCanceledForNonPayment {
err = transitionDepositsToHtlcTimeout(
@ -1190,6 +1220,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
// confirmation and re-register for the next
// confirmation.
htlcConfirmed = false
err = f.clearConfirmedHtlc(ctx)
if err != nil {
return f.HandleError(err)
}
htlcConfChan, htlcErrConfChan, err = registerHtlcConf()
if err != nil {
@ -1367,6 +1401,51 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
}
}
func (f *FSM) recordConfirmedHtlc(ctx context.Context,
conf *chainntnfs.TxConfirmation, htlcPkScript []byte) error {
if conf == nil || conf.Tx == nil {
return errors.New("htlc confirmation missing transaction")
}
if f.cfg.Store == nil {
return errors.New("missing static address loop-in store")
}
tx := conf.Tx
txHash := tx.TxHash()
for idx, txOut := range tx.TxOut {
if !bytes.Equal(txOut.PkScript, htlcPkScript) {
continue
}
f.loopIn.HtlcTxHash = &txHash
f.loopIn.HtlcOutputIndex = uint32(idx)
f.loopIn.HtlcOutputValue = btcutil.Amount(txOut.Value)
return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn)
}
return fmt.Errorf("confirmed htlc tx %v missing expected htlc "+
"output", txHash)
}
func (f *FSM) clearConfirmedHtlc(ctx context.Context) error {
if f.loopIn.HtlcTxHash == nil && f.loopIn.HtlcOutputIndex == 0 &&
f.loopIn.HtlcOutputValue == 0 {
return nil
}
if f.cfg.Store == nil {
return errors.New("missing static address loop-in store")
}
f.loopIn.HtlcTxHash = nil
f.loopIn.HtlcOutputIndex = 0
f.loopIn.HtlcOutputValue = 0
return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn)
}
// htlcTimeoutSweepRetryDelay is the delay between retries when publishing the
// htlc timeout sweep transaction fails.
const htlcTimeoutSweepRetryDelay = time.Hour

View file

@ -12,12 +12,14 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/zpay32"
@ -899,6 +901,7 @@ func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints(
// update failure must not roll back the action or state transition.
func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) {
mockLnd := test.NewMockLnd()
_, depositClientPubkey := test.CreateKey(21)
_, serverKey := test.CreateKey(22)
server := &mockStaticAddressServer{
@ -913,6 +916,10 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) {
Index: 0,
},
Value: 500_000,
AddressParams: &address.Parameters{
ClientPubkey: depositClientPubkey,
PkScript: []byte{0x51, 0x20, 0x02},
},
}
loopIn := &StaticAddressLoopIn{
@ -933,6 +940,7 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) {
Server: server,
DepositManager: &noopDepositManager{},
LndClient: mockLnd.Client,
InvoicesClient: mockLnd.LndServices.Invoices,
WalletKit: mockLnd.WalletKit,
ChainParams: mockLnd.ChainParams,
Store: &mockStore{},
@ -956,6 +964,82 @@ func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) {
require.True(t, sendUpdateCalled)
}
// TestInitHtlcActionSendsChangeOutput asserts that fractional loop-ins create
// and send an operation-specific static change output to the server.
func TestInitHtlcActionSendsChangeOutput(t *testing.T) {
t.Parallel()
mockLnd := test.NewMockLnd()
_, depositClientPubkey := test.CreateKey(31)
_, changeClientPubkey := test.CreateKey(32)
_, serverKey := test.CreateKey(33)
server := &mockStaticAddressServer{
response: testStaticAddressLoopInResponse(
serverKey.SerializeCompressed(),
),
}
dep := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 0,
},
Value: 500_000,
AddressParams: &address.Parameters{
ClientPubkey: depositClientPubkey,
PkScript: []byte{0x51, 0x20, 0x02},
},
}
changeParams := &address.Parameters{
ID: 1,
ClientPubkey: changeClientPubkey,
PkScript: []byte{0x51, 0x20, 0x01},
}
loopIn := &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{dep},
DepositOutpoints: []string{dep.OutPoint.String()},
SelectedAmount: 300_000,
QuotedSwapFee: 1_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
PaymentTimeoutSeconds: 3_600,
}
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
Server: server,
AddressManager: &mockAddressManager{params: changeParams},
DepositManager: &noopDepositManager{},
LndClient: mockLnd.Client,
WalletKit: mockLnd.WalletKit,
ChainParams: mockLnd.ChainParams,
Store: &mockStore{},
ValidateLoopInContract: testValidateLoopInContract,
MaxStaticAddrHtlcFeePercentage: 1,
MaxStaticAddrHtlcBackupFeePercentage: 1,
},
loopIn: loopIn,
}
event := f.InitHtlcAction(t.Context(), nil)
require.Equal(t, OnHtlcInitiated, event)
require.Nil(t, f.LastActionError)
require.NotNil(t, server.request.ChangeOutput)
require.EqualValues(t, 200_000, server.request.ChangeOutput.Amount)
require.Equal(
t, changeClientPubkey.SerializeCompressed(),
server.request.ChangeOutput.StaticAddress.GetPubkey(),
)
require.Equal(
t, changeParams.PkScript,
server.request.ChangeOutput.StaticAddress.GetPkScript(),
)
require.Same(t, changeParams, loopIn.ChangeAddressParams)
}
// mockStaticAddressServer captures static-address loop-in requests in tests.
type mockStaticAddressServer struct {
swapserverrpc.StaticAddressServerClient
@ -994,6 +1078,70 @@ func testStaticAddressLoopInResponse(
}
}
type recordingLoopInStore struct {
mockStore
updates []*StaticAddressLoopIn
}
func (s *recordingLoopInStore) UpdateLoopIn(_ context.Context,
loopIn *StaticAddressLoopIn) error {
s.updates = append(s.updates, loopIn)
return nil
}
// TestRecordConfirmedHtlcPersistsOutpoint verifies that the FSM records the
// exact confirmed server HTLC output before the timeout branch can sweep it.
func TestRecordConfirmedHtlcPersistsOutpoint(t *testing.T) {
t.Parallel()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
loopIn := &StaticAddressLoopIn{
SwapHash: lntypes.Hash{1, 2, 4},
HtlcCltvExpiry: 800,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
}
htlc, err := loopIn.getHtlc(test.NewMockLnd().ChainParams)
require.NoError(t, err)
htlcValue := int64(123_456)
tx := wire.NewMsgTx(2)
tx.AddTxOut(&wire.TxOut{
Value: 1,
PkScript: []byte{0x51},
})
tx.AddTxOut(&wire.TxOut{
Value: htlcValue,
PkScript: htlc.PkScript,
})
store := &recordingLoopInStore{}
f := &FSM{
cfg: &Config{Store: store},
loopIn: loopIn,
}
err = f.recordConfirmedHtlc(
t.Context(), &chainntnfs.TxConfirmation{Tx: tx},
htlc.PkScript,
)
require.NoError(t, err)
txHash := tx.TxHash()
require.NotNil(t, loopIn.HtlcTxHash)
require.Equal(t, txHash, *loopIn.HtlcTxHash)
require.EqualValues(t, 1, loopIn.HtlcOutputIndex)
require.EqualValues(t, htlcValue, loopIn.HtlcOutputValue)
require.Len(t, store.updates, 1)
}
// testStaticAddressRouteHints returns deterministic route hints for static
// loop-in invoice regression tests.
func testStaticAddressRouteHints() [][]zpay32.HopHint {
@ -1102,10 +1250,18 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) {
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
Store: &recordingLoopInStore{},
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
htlc, err := loopIn.getHtlc(mockLnd.ChainParams)
require.NoError(t, err)
htlcTx := wire.NewMsgTx(2)
htlcTx.AddTxOut(&wire.TxOut{
Value: 1,
PkScript: htlc.PkScript,
})
resultChan := make(chan fsm.EventType, 1)
go func() {
@ -1124,7 +1280,7 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) {
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
confRegistration.ConfChan <- nil
confRegistration.ConfChan <- &chainntnfs.TxConfirmation{Tx: htlcTx}
select {
case hash := <-mockLnd.FailInvoiceChannel:
@ -3367,6 +3523,13 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) (
return nil, nil
}
// NewChangeAddress returns configured parameters for tests that need change.
func (m *mockAddressManager) NewChangeAddress(_ context.Context) (
*address.Parameters, error) {
return m.params, nil
}
// noopDepositManager is a stub DepositManager used to satisfy FSM config.
type noopDepositManager struct {
deposits []*deposit.Deposit

View file

@ -7,6 +7,7 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/swapserverrpc"
@ -41,6 +42,10 @@ type AddressManager interface {
// GetStaticAddress returns the deposit address for the given client and
// server public keys.
GetStaticAddress(ctx context.Context) (*script.StaticAddress, error)
// NewChangeAddress derives and persists a fresh static address from the
// change key family for this operation's change output.
NewChangeAddress(ctx context.Context) (*address.Parameters, error)
}
// DepositManager handles the interaction of loop-ins with deposits.

View file

@ -18,6 +18,7 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/staticutil"
@ -166,6 +167,11 @@ type StaticAddressLoopIn struct {
// Address is the address script that is used for the swap.
Address *script.StaticAddress
// ChangeAddressParams are the static address parameters for the change
// output that belongs to this swap. It is set only when SelectedAmount
// leaves non-dust change.
ChangeAddressParams *address.Parameters
// HTLC fields.
// HtlcTxFeeRate is the fee rate that is used for the htlc transaction.
@ -182,6 +188,16 @@ type StaticAddressLoopIn struct {
// HtlcTimeoutSweepTxHash is the hash of the htlc timeout sweep tx.
HtlcTimeoutSweepTxHash *chainhash.Hash
// HtlcTxHash is the hash of the confirmed htlc tx published by the
// server.
HtlcTxHash *chainhash.Hash
// HtlcOutputIndex is the output index of the confirmed htlc output.
HtlcOutputIndex uint32
// HtlcOutputValue is the value of the confirmed htlc output.
HtlcOutputValue btcutil.Amount
// HtlcTimeoutSweepAddress
HtlcTimeoutSweepAddress btcutil.Address
@ -306,11 +322,10 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params,
// change.
var (
swapAmt = l.TotalDepositAmount()
changeAmount btcutil.Amount
changeAmount = l.ExpectedChangeAmount()
)
if l.SelectedAmount > 0 {
swapAmt = l.SelectedAmount
changeAmount = l.TotalDepositAmount() - l.SelectedAmount
}
// Calculate htlc tx fee for server provided fee rate.
@ -346,9 +361,14 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params,
// We expect change to be sent back to our static address output script.
if changeAmount > 0 {
if l.ChangeAddressParams == nil {
return nil, fmt.Errorf("missing static address change " +
"parameters")
}
msgTx.AddTxOut(&wire.TxOut{
Value: int64(changeAmount),
PkScript: l.AddressParams.PkScript,
PkScript: l.ChangeAddressParams.PkScript,
})
}
@ -408,36 +428,18 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context,
return nil, err
}
htlcTx, err := l.createHtlcTx(
network, l.HtlcTxFeeRate, maxFeePercentage,
htlcOutpoint, htlcOutValue, err := l.confirmedHtlcOutpoint(
network, maxFeePercentage,
)
if err != nil {
return nil, err
}
// The HTLC output is always at index 0 (createHtlcTx adds it first).
// If there is a change output, it is at index 1. Verify this invariant
// so we fail fast if createHtlcTx's layout ever changes.
const htlcInputIndex = uint32(0)
if len(htlcTx.TxOut) == 2 {
if bytes.Equal(
htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript,
) {
return nil, fmt.Errorf("htlc tx output layout " +
"invariant violated: expected HTLC output " +
"at index 0, got change output")
}
}
// Add the htlc input.
sweepTx.AddTxIn(&wire.TxIn{
PreviousOutPoint: wire.OutPoint{
Hash: htlcTx.TxHash(),
Index: htlcInputIndex,
},
SignatureScript: htlc.SigScript,
Sequence: htlc.SuccessSequence(),
PreviousOutPoint: htlcOutpoint,
SignatureScript: htlc.SigScript,
Sequence: htlc.SuccessSequence(),
})
// Add the sweep output.
@ -448,7 +450,6 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context,
fee := feeRate.FeeForWeight(weightEstimator.Weight())
htlcOutValue := htlcTx.TxOut[htlcInputIndex].Value
output := &wire.TxOut{
Value: htlcOutValue - int64(fee),
PkScript: sweepPkScript,
@ -487,6 +488,56 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context,
return sweepTx, nil
}
// confirmedHtlcOutpoint returns the exact confirmed htlc outpoint when it has
// been persisted. Older loop-ins fall back to reconstructing the standard-fee
// htlc tx, which was the historical behavior before we stored the actual
// server-published variant.
func (l *StaticAddressLoopIn) confirmedHtlcOutpoint(
network *chaincfg.Params, maxFeePercentage float64) (wire.OutPoint,
int64, error) {
if l.HtlcTxHash != nil {
if l.HtlcOutputValue <= 0 {
return wire.OutPoint{}, 0, fmt.Errorf("missing htlc "+
"output value for confirmed htlc tx %v",
l.HtlcTxHash)
}
return wire.OutPoint{
Hash: *l.HtlcTxHash,
Index: l.HtlcOutputIndex,
}, int64(l.HtlcOutputValue), nil
}
htlcTx, err := l.createHtlcTx(
network, l.HtlcTxFeeRate, maxFeePercentage,
)
if err != nil {
return wire.OutPoint{}, 0, err
}
// The HTLC output is always at index 0 (createHtlcTx adds it first).
// If there is a change output, it is at index 1. Verify this invariant
// so we fail fast if createHtlcTx's layout ever changes.
const htlcInputIndex = uint32(0)
if len(htlcTx.TxOut) == 2 && l.ChangeAddressParams != nil {
if bytes.Equal(
htlcTx.TxOut[0].PkScript,
l.ChangeAddressParams.PkScript,
) {
return wire.OutPoint{}, 0, fmt.Errorf("htlc tx " +
"output layout invariant violated: expected " +
"HTLC output at index 0, got change output")
}
}
return wire.OutPoint{
Hash: htlcTx.TxHash(),
Index: htlcInputIndex,
}, htlcTx.TxOut[htlcInputIndex].Value, nil
}
// pubkeyTo33ByteSlice converts a pubkey to a 33 byte slice.
func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte {
var pubkeyBytes [33]byte
@ -508,6 +559,22 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount {
return total
}
// ExpectedChangeAmount returns the change that a fractional loop-in should send
// to its generated static change address. A full-amount loop-in has no change.
func (l *StaticAddressLoopIn) ExpectedChangeAmount() btcutil.Amount {
if l.SelectedAmount <= 0 {
return 0
}
totalDepositAmount := l.TotalDepositAmount()
changeAmount := totalDepositAmount - l.SelectedAmount
if changeAmount <= 0 || changeAmount >= totalDepositAmount {
return 0
}
return changeAmount
}
// RemainingPaymentTimeSeconds returns the remaining time in seconds until the
// payment timeout is reached. The remaining time is calculated from the
// initiation time of the swap. If more than the swap's configured payment

View file

@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/version"
@ -77,7 +78,8 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) {
Hash: chainhash.Hash{0xaa},
Index: 0,
},
Value: depositValue,
Value: depositValue,
AddressParams: addrParams,
},
}
@ -96,7 +98,7 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) {
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
Deposits: deposits,
AddressParams: addrParams,
ChangeAddressParams: addrParams,
HtlcTxFeeRate: feeRate,
SelectedAmount: selectedAmount,
PaymentTimeoutSeconds: 3600,
@ -183,6 +185,76 @@ func TestPaymentTimeoutDuration(t *testing.T) {
}
}
// TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint verifies that timeout sweeps
// spend the actual server-published HTLC tx variant once it has been recorded.
func TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint(t *testing.T) {
t.Parallel()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
network := &chaincfg.RegressionNetParams
staticAddr, err := newStaticAddress(
clientKey.PubKey(), serverKey.PubKey(), 4032,
)
require.NoError(t, err)
pkScript, err := staticAddr.StaticAddressScript()
require.NoError(t, err)
addrParams := &address.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PkScript: pkScript,
Expiry: 4032,
ProtocolVersion: version.ProtocolVersion_V0,
}
dep := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{0xbb},
Index: 0,
},
Value: 500_000,
AddressParams: addrParams,
}
confirmedHtlcHash := chainhash.Hash{0xcc}
confirmedHtlcValue := btcutil.Amount(275_000)
loopIn := &StaticAddressLoopIn{
SwapHash: lntypes.Hash{3, 2, 1},
HtlcCltvExpiry: 800,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
Deposits: []*deposit.Deposit{dep},
HtlcTxFeeRate: chainfee.SatPerKWeight(253),
HtlcTxHash: &confirmedHtlcHash,
HtlcOutputIndex: 2,
HtlcOutputValue: confirmedHtlcValue,
}
sweepAddr, err := btcutil.NewAddressTaproot(make([]byte, 32), network)
require.NoError(t, err)
sweepTx, err := loopIn.createHtlcSweepTx(
t.Context(), &noopSigner{}, sweepAddr,
chainfee.SatPerKWeight(253), network,
uint32(loopIn.HtlcCltvExpiry)+1, 1,
)
require.NoError(t, err)
require.Len(t, sweepTx.TxIn, 1)
require.Equal(
t, wire.OutPoint{
Hash: confirmedHtlcHash,
Index: 2,
}, sweepTx.TxIn[0].PreviousOutPoint,
)
require.Less(t, sweepTx.TxOut[0].Value, int64(confirmedHtlcValue))
require.Greater(t, sweepTx.TxOut[0].Value, int64(0))
}
// newStaticAddress creates a StaticAddress for testing.
func newStaticAddress(clientKey, serverKey *btcec.PublicKey,
csvExpiry int64) (*script.StaticAddress, error) {

View file

@ -21,7 +21,6 @@ import (
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/staticutil"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/input"
@ -333,7 +332,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
// If the user selected an amount that is less than the total deposit
// amount we'll check that the server sends us the correct change amount
// back to our static address.
err = m.checkChange(ctx, sweepTx, loopIn.AddressParams)
err = m.checkChange(ctx, sweepTx)
if err != nil {
return err
}
@ -471,7 +470,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
// swaps with identical change outputs. The client needs to ensure that any
// swap referenced by the inputs has a respective change output in the batch.
func (m *Manager) checkChange(ctx context.Context,
sweepTx *wire.MsgTx, changeAddr *script.Parameters) error {
sweepTx *wire.MsgTx) error {
prevOuts := make([]string, len(sweepTx.TxIn))
for i, in := range sweepTx.TxIn {
@ -496,42 +495,67 @@ func (m *Manager) checkChange(ctx context.Context,
return err
}
var expectedChange btcutil.Amount
var expectedChanges []*wire.TxOut
for swapHash := range swapHashes {
loopIn, err := m.cfg.Store.GetLoopInByHash(ctx, swapHash)
if err != nil {
return err
}
totalDepositAmount := loopIn.TotalDepositAmount()
changeAmt := totalDepositAmount - loopIn.SelectedAmount
if changeAmt > 0 && changeAmt < totalDepositAmount {
log.Debugf("expected change output to our "+
"static address, total_deposit_amount=%v, "+
"selected_amount=%v, "+
"expected_change_amount=%v ",
totalDepositAmount, loopIn.SelectedAmount,
changeAmt)
expectedChange += changeAmt
changeAmt := loopIn.ExpectedChangeAmount()
if changeAmt == 0 {
continue
}
if loopIn.ChangeAddressParams == nil {
return fmt.Errorf("missing change address for swap %x",
swapHash[:])
}
log.Debugf("expected change output to static address, "+
"swap_hash=%x, selected_amount=%v, "+
"expected_change_amount=%v", swapHash[:],
loopIn.SelectedAmount, changeAmt)
expectedChanges = append(expectedChanges, &wire.TxOut{
Value: int64(changeAmt),
PkScript: loopIn.ChangeAddressParams.PkScript,
})
}
if expectedChange == 0 {
if len(expectedChanges) == 0 {
return nil
}
for _, out := range sweepTx.TxOut {
if out.Value == int64(expectedChange) &&
bytes.Equal(out.PkScript, changeAddr.PkScript) {
// Match expected change outputs as a multiset. This rejects batched
// transactions that collapse two equal client change outputs into one
// output unless the protocol explicitly negotiates such aggregation.
matchedOutputs := make([]bool, len(sweepTx.TxOut))
for _, expected := range expectedChanges {
var found bool
for i, out := range sweepTx.TxOut {
if matchedOutputs[i] {
continue
}
// We found the expected change output.
return nil
if out.Value == expected.Value &&
bytes.Equal(out.PkScript, expected.PkScript) {
matchedOutputs[i] = true
found = true
break
}
}
if found {
continue
}
return fmt.Errorf("couldn't find expected change of %v "+
"satoshis sent to static address", expected.Value)
}
return fmt.Errorf("couldn't find expected change of %v "+
"satoshis sent to our static address", expectedChange)
return nil
}
// recover stars a loop-in state machine for each non-final loop-in to pick up

View file

@ -666,9 +666,9 @@ func TestCheckChange(t *testing.T) {
var hash lntypes.Hash
hash[0] = h
li := &StaticAddressLoopIn{
Deposits: deposits,
SelectedAmount: selected,
AddressParams: changeAddr,
Deposits: deposits,
SelectedAmount: selected,
ChangeAddressParams: changeAddr,
}
return hash, li
}
@ -723,7 +723,6 @@ func TestCheckChange(t *testing.T) {
name string
inDeps []*deposit.Deposit // deposits referenced by tx inputs
outputs []*wire.TxOut // outputs in sweep tx
addr *script.Parameters
expectErr bool
expectedErrMsg string
}
@ -739,7 +738,6 @@ func TestCheckChange(t *testing.T) {
PkScript: serverAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "single swap change present",
@ -754,43 +752,59 @@ func TestCheckChange(t *testing.T) {
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "multiple swaps different change amounts",
inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)=900
inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 900,
Value: 500,
PkScript: changeAddr.PkScript,
},
{
Value: 400,
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
},
{
name: "two swaps with identical change values sum correctly",
inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)=800
name: "two swaps with identical change values both present",
inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)
outputs: []*wire.TxOut{
{
Value: 1337,
PkScript: serverAddr.PkScript,
},
{
Value: 400,
PkScript: changeAddr.PkScript,
},
{
Value: 400,
PkScript: changeAddr.PkScript,
},
},
},
{
name: "collapsed identical change output rejected",
inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)
outputs: []*wire.TxOut{
{
Value: 800,
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
{
name: "missing change output results in error",
inDeps: []*deposit.Deposit{s2d1}, // expect 500
outputs: []*wire.TxOut{},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
@ -807,7 +821,6 @@ func TestCheckChange(t *testing.T) {
PkScript: otherAddr.PkScript,
},
},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
@ -824,7 +837,6 @@ func TestCheckChange(t *testing.T) {
PkScript: changeAddr.PkScript,
},
},
addr: changeAddr,
expectErr: true,
expectedErrMsg: "couldn't find expected change",
},
@ -845,7 +857,6 @@ func TestCheckChange(t *testing.T) {
PkScript: otherAddr.PkScript,
},
},
addr: changeAddr,
},
}
@ -868,7 +879,7 @@ func TestCheckChange(t *testing.T) {
mgr.cfg.DepositManager = mdm
tx := makeSweepTx(inputs, tc.outputs)
err := mgr.checkChange(ctx, tx, tc.addr)
err := mgr.checkChange(ctx, tx)
if tc.expectErr {
require.Error(t, err)
if tc.expectedErrMsg != "" {

View file

@ -14,6 +14,7 @@ import (
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/loopdb/sqlc"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightningnetwork/lnd/clock"
@ -294,6 +295,17 @@ func (s *SqlStore) CreateLoopIn(ctx context.Context,
PaymentTimeoutSeconds: int32(loopIn.PaymentTimeoutSeconds),
Fast: loopIn.Fast,
}
if loopIn.ChangeAddressParams != nil {
if loopIn.ChangeAddressParams.ID == 0 {
return errors.New("static address change parameters " +
"missing database ID")
}
staticAddressLoopInParams.ChangeStaticAddressID = sql.NullInt32{
Int32: loopIn.ChangeAddressParams.ID,
Valid: true,
}
}
updateTime := sqlStoreUpdateTime(s.clock)
updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{
@ -357,6 +369,11 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context,
htlcTimeoutSweepTxID = loopIn.HtlcTimeoutSweepTxHash.String()
}
var htlcTxID string
if loopIn.HtlcTxHash != nil {
htlcTxID = loopIn.HtlcTxHash.String()
}
updateParams := sqlc.UpdateStaticAddressLoopInParams{
SwapHash: loopIn.SwapHash[:],
HtlcTxFeeRateSatKw: int64(loopIn.HtlcTxFeeRate),
@ -364,6 +381,18 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context,
String: htlcTimeoutSweepTxID,
Valid: htlcTimeoutSweepTxID != "",
},
ConfirmedHtlcTxID: sql.NullString{
String: htlcTxID,
Valid: htlcTxID != "",
},
ConfirmedHtlcOutputIndex: sql.NullInt32{
Int32: int32(loopIn.HtlcOutputIndex),
Valid: htlcTxID != "",
},
ConfirmedHtlcOutputValue: sql.NullInt64{
Int64: int64(loopIn.HtlcOutputValue),
Valid: htlcTxID != "",
},
}
updateTime := sqlStoreUpdateTime(s.clock)
@ -575,6 +604,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
}
}
var htlcTxHash *chainhash.Hash
if swap.ConfirmedHtlcTxID.Valid {
htlcTxHash, err = chainhash.NewHashFromStr(
swap.ConfirmedHtlcTxID.String,
)
if err != nil {
return nil, err
}
}
var depositOutpoints []string
if swap.DepositOutpoints != "" {
depositOutpoints = strings.Split(
@ -638,6 +677,11 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
}
depositList = orderDepositsBySnapshot(depositList, depositOutpoints)
changeAddressParams, err := toChangeAddressParameters(swap)
if err != nil {
return nil, err
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
SwapPreimage: swapPreImage,
@ -670,7 +714,13 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
),
HtlcTimeoutSweepAddress: timeoutAddress,
HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash,
Deposits: depositList,
HtlcTxHash: htlcTxHash,
HtlcOutputIndex: uint32(swap.ConfirmedHtlcOutputIndex.Int32),
HtlcOutputValue: btcutil.Amount(
swap.ConfirmedHtlcOutputValue.Int64,
),
Deposits: depositList,
ChangeAddressParams: changeAddressParams,
}
if swap.ConfirmationRiskDecisionTime.Valid {
loopIn.ConfirmationRiskDecisionTime =
@ -719,3 +769,41 @@ func orderDepositsBySnapshot(deposits []*deposit.Deposit,
return orderedDeposits
}
// toChangeAddressParameters converts the optional joined static address row
// into the change address parameters used to verify batched sweepless sweeps.
func toChangeAddressParameters(row sqlc.GetStaticAddressLoopInSwapRow) (
*address.Parameters, error) {
if !row.ChangeStaticAddressID.Valid {
return nil, nil
}
clientKey, err := btcec.ParsePubKey(row.ChangeClientPubkey)
if err != nil {
return nil, err
}
serverKey, err := btcec.ParsePubKey(row.ChangeServerPubkey)
if err != nil {
return nil, err
}
return &address.Parameters{
ID: row.ChangeStaticAddressID.Int32,
ClientPubkey: clientKey,
ServerPubkey: serverKey,
Expiry: uint32(row.ChangeExpiry.Int32),
PkScript: row.ChangePkscript,
KeyLocator: keychain.KeyLocator{
Family: keychain.KeyFamily(
row.ChangeClientKeyFamily.Int32,
),
Index: uint32(row.ChangeClientKeyIndex.Int32),
},
ProtocolVersion: version.AddressProtocolVersion(
row.ChangeProtocolVersion.Int32,
),
InitiationHeight: row.ChangeInitiationHeight.Int32,
}, nil
}

View file

@ -544,6 +544,70 @@ func TestGetLoopInByHashOrdersDepositsBySnapshot(t *testing.T) {
require.Equal(t, d1.ID, storedSwap.Deposits[1].ID)
}
func TestUpdateLoopInPersistsConfirmedHtlcOutpoint(t *testing.T) {
ctxb := context.Background()
testDb := loopdb.NewTestDB(t)
testClock := clock.NewTestClock(time.Now())
defer testDb.Close()
depositStore := deposit.NewSqlStore(testDb.BaseDB)
swapStore := NewSqlStore(
loopdb.NewTypedStore[Querier](testDb), testClock,
&chaincfg.RegressionNetParams,
)
depositID, err := deposit.GetRandomDepositID()
require.NoError(t, err)
d := &deposit.Deposit{
ID: depositID,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d},
Index: 0,
},
Value: btcutil.Amount(100_000),
TimeOutSweepPkScript: []byte{
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41,
},
}
require.NoError(t, depositStore.CreateDeposit(ctxb, d))
d.SetState(deposit.LoopingIn)
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
_, clientPubKey := test.CreateKey(1)
_, serverPubKey := test.CreateKey(2)
addr, err := btcutil.DecodeAddress(P2wkhAddr, nil)
require.NoError(t, err)
swapHash := lntypes.Hash{0x4, 0x2, 0x3, 0x5}
swap := StaticAddressLoopIn{
SwapHash: swapHash,
SwapPreimage: lntypes.Preimage{0x4, 0x2, 0x3, 0x5},
DepositOutpoints: []string{d.OutPoint.String()},
Deposits: []*deposit.Deposit{d},
ClientPubkey: clientPubKey,
ServerPubkey: serverPubKey,
HtlcTimeoutSweepAddress: addr,
}
swap.SetState(MonitorInvoiceAndHtlcTx)
require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap))
confirmedHtlcTxHash := chainhash.Hash{0x55}
swap.HtlcTxHash = &confirmedHtlcTxHash
swap.HtlcOutputIndex = 2
swap.HtlcOutputValue = 88_000
require.NoError(t, swapStore.UpdateLoopIn(ctxb, &swap))
storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash)
require.NoError(t, err)
require.NotNil(t, storedSwap.HtlcTxHash)
require.Equal(t, confirmedHtlcTxHash, *storedSwap.HtlcTxHash)
require.EqualValues(t, 2, storedSwap.HtlcOutputIndex)
require.EqualValues(t, 88_000, storedSwap.HtlcOutputValue)
require.Equal(t, MonitorInvoiceAndHtlcTx, storedSwap.GetState())
}
// TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins
// keep the original outpoint snapshot stored when the swap was created.
func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) {

View file

@ -12,6 +12,7 @@ import (
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/input"
@ -96,6 +97,35 @@ func DepositClientPubkeys(deposits []*deposit.Deposit) (
return clientPubkeys, nil
}
// ChangeOutput converts a locally generated static address into the RPC change
// descriptor sent to the server. The descriptor binds the expected script,
// amount and client key so the server can derive and verify the same address.
func ChangeOutput(params *address.Parameters,
amount btcutil.Amount) (*swapserverrpc.StaticAddressChangeOutput, error) {
if amount <= 0 {
return nil, nil
}
if params == nil {
return nil, fmt.Errorf("missing static address change parameters")
}
if params.ClientPubkey == nil {
return nil, fmt.Errorf("missing static address change client " +
"pubkey")
}
if len(params.PkScript) == 0 {
return nil, fmt.Errorf("missing static address change pkscript")
}
return &swapserverrpc.StaticAddressChangeOutput{
StaticAddress: &swapserverrpc.StaticAddressDescriptor{
Pubkey: params.ClientPubkey.SerializeCompressed(),
PkScript: params.PkScript,
},
Amount: int64(amount),
}, nil
}
// CreateMusig2Sessions creates a musig2 session for a number of deposits.
func CreateMusig2Sessions(ctx context.Context,
signer lndclient.SignerClient, deposits []*deposit.Deposit) (

View file

@ -237,6 +237,45 @@ func TestDepositClientPubkeysRejectsInvalidDeposits(t *testing.T) {
})
}
func TestChangeOutput(t *testing.T) {
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
params := &address.Parameters{
ClientPubkey: clientKey.PubKey(),
PkScript: []byte{0x51, 0x20, 0x01},
}
amount := btcutil.Amount(12345)
changeOutput, err := ChangeOutput(params, amount)
require.NoError(t, err)
require.Equal(
t, clientKey.PubKey().SerializeCompressed(),
changeOutput.StaticAddress.GetPubkey(),
)
require.Equal(t, params.PkScript, changeOutput.StaticAddress.GetPkScript())
require.EqualValues(t, amount, changeOutput.Amount)
changeOutput, err = ChangeOutput(params, 0)
require.NoError(t, err)
require.Nil(t, changeOutput)
}
func TestChangeOutputRejectsInvalidParams(t *testing.T) {
_, err := ChangeOutput(nil, 100)
require.ErrorContains(t, err, "missing static address change parameters")
_, err = ChangeOutput(&address.Parameters{}, 100)
require.ErrorContains(t, err, "missing static address change client pubkey")
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
_, err = ChangeOutput(&address.Parameters{
ClientPubkey: clientKey.PubKey(),
}, 100)
require.ErrorContains(t, err, "missing static address change pkscript")
}
func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) {
// Helper to create a hash from string.
must := func(s string) chainhash.Hash {