mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
staticaddr/loopin: check deposits before htlc signing
Before we send HTLC signatures to the server, the server cannot publish the HTLC transaction. After those signatures are handed over, the server can publish an HTLC that spends the selected deposits even if it never pays the swap invoice. Defend against stale local deposit state by checking the wallet's current txout view immediately before signing. A deposit can have been spent by a known withdrawal, channel open, timeout sweep, replacement, or another wallet transaction while the loop-in FSM is recovering or while earlier state still marked it as selected. Failing before signing leaves the server without spend authority over an unavailable input. Include mempool spends in the check so wallet-known unconfirmed spends are treated as unavailable too.
This commit is contained in:
parent
bd3882d5b0
commit
6582aa0807
4 changed files with 159 additions and 0 deletions
|
|
@ -692,6 +692,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
|
|||
Server: staticAddressClient,
|
||||
QuoteGetter: swapClient.Server,
|
||||
LndClient: d.lnd.Client,
|
||||
TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client),
|
||||
InvoicesClient: d.lnd.Invoices,
|
||||
NodePubkey: d.lnd.NodePubkey,
|
||||
AddressManager: staticAddressManager,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
"github.com/lightninglabs/lndclient"
|
||||
"github.com/lightninglabs/loop"
|
||||
|
|
@ -413,6 +414,11 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
|
|||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
err = f.checkDepositsAvailable(ctx)
|
||||
if err != nil {
|
||||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
// Create a musig2 session for each deposit and different htlc tx fee
|
||||
// rates.
|
||||
createSession := staticutil.CreateMusig2Sessions
|
||||
|
|
@ -526,6 +532,68 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
|
|||
return OnHtlcTxSigned
|
||||
}
|
||||
|
||||
// checkDepositsAvailable verifies that all loop-in deposits are still available
|
||||
// before the client signs the HTLC transaction.
|
||||
func (f *FSM) checkDepositsAvailable(ctx context.Context) error {
|
||||
outpoints, err := f.validateSigningDepositOutpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.cfg.TxOutChecker == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to check deposits: %w", err)
|
||||
}
|
||||
|
||||
for _, outpoint := range outpoints {
|
||||
if txOuts[outpoint] == nil {
|
||||
return fmt.Errorf("deposit %v is no longer available",
|
||||
outpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSigningDepositOutpoints verifies that the current deposit rows match
|
||||
// the server-side outpoint snapshot before signing the HTLC transaction.
|
||||
func (f *FSM) validateSigningDepositOutpoints() ([]wire.OutPoint, error) {
|
||||
currentOutpoints := f.loopIn.Outpoints()
|
||||
if len(f.loopIn.DepositOutpoints) == 0 {
|
||||
return currentOutpoints, nil
|
||||
}
|
||||
|
||||
if len(f.loopIn.DepositOutpoints) != len(currentOutpoints) {
|
||||
return nil, fmt.Errorf("deposit outpoint snapshot has %d "+
|
||||
"outpoints, current deposits have %d",
|
||||
len(f.loopIn.DepositOutpoints), len(currentOutpoints))
|
||||
}
|
||||
|
||||
snapshotOutpoints := make(
|
||||
[]wire.OutPoint, len(f.loopIn.DepositOutpoints),
|
||||
)
|
||||
for i, snapshot := range f.loopIn.DepositOutpoints {
|
||||
outpoint, err := wire.NewOutPointFromString(snapshot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse deposit "+
|
||||
"outpoint snapshot %q: %w", snapshot, err)
|
||||
}
|
||||
|
||||
snapshotOutpoints[i] = *outpoint
|
||||
if *outpoint != currentOutpoints[i] {
|
||||
return nil, fmt.Errorf("deposit outpoint snapshot "+
|
||||
"mismatch at index %d: snapshot %v, "+
|
||||
"current %v", i, outpoint, currentOutpoints[i])
|
||||
}
|
||||
}
|
||||
|
||||
return snapshotOutpoints, nil
|
||||
}
|
||||
|
||||
// cleanUpSessions releases allocated memory of the musig2 sessions.
|
||||
func (f *FSM) cleanUpSessions(ctx context.Context,
|
||||
sessions []*input.MuSig2SessionInfo) {
|
||||
|
|
|
|||
|
|
@ -430,6 +430,72 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) {
|
|||
test.RequireRouteHintsEqual(t, loopIn.RouteHints, routeHints)
|
||||
}
|
||||
|
||||
func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) {
|
||||
dep := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x77},
|
||||
Index: 2,
|
||||
},
|
||||
Value: 200_000,
|
||||
}
|
||||
checker := &recordingTxOutChecker{}
|
||||
|
||||
f := &FSM{
|
||||
StateMachine: &fsm.StateMachine{},
|
||||
cfg: &Config{
|
||||
AddressManager: &mockAddressManager{
|
||||
params: &script.Parameters{
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
},
|
||||
},
|
||||
TxOutChecker: checker,
|
||||
},
|
||||
loopIn: &StaticAddressLoopIn{
|
||||
Deposits: []*deposit.Deposit{dep},
|
||||
DepositOutpoints: []string{dep.OutPoint.String()},
|
||||
},
|
||||
}
|
||||
|
||||
event := f.SignHtlcTxAction(t.Context(), nil)
|
||||
require.Equal(t, fsm.OnError, event)
|
||||
require.ErrorContains(
|
||||
t, f.LastActionError, "deposit "+
|
||||
dep.OutPoint.String()+" is no longer available",
|
||||
)
|
||||
require.Equal(t, [][]wire.OutPoint{{dep.OutPoint}}, checker.outpoints)
|
||||
}
|
||||
|
||||
func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints(
|
||||
t *testing.T) {
|
||||
|
||||
currentOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x89},
|
||||
Index: 1,
|
||||
}
|
||||
snapshotOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x88},
|
||||
Index: 0,
|
||||
}
|
||||
checker := &recordingTxOutChecker{}
|
||||
|
||||
f := &FSM{
|
||||
cfg: &Config{
|
||||
TxOutChecker: checker,
|
||||
},
|
||||
loopIn: &StaticAddressLoopIn{
|
||||
Deposits: []*deposit.Deposit{{
|
||||
OutPoint: currentOutpoint,
|
||||
Value: 200_000,
|
||||
}},
|
||||
DepositOutpoints: []string{snapshotOutpoint.String()},
|
||||
},
|
||||
}
|
||||
|
||||
err := f.checkDepositsAvailable(t.Context())
|
||||
require.ErrorContains(t, err, "deposit outpoint snapshot mismatch")
|
||||
require.Empty(t, checker.outpoints)
|
||||
}
|
||||
|
||||
// mockStaticAddressServer captures static-address loop-in requests in tests.
|
||||
type mockStaticAddressServer struct {
|
||||
swapserverrpc.StaticAddressServerClient
|
||||
|
|
@ -780,6 +846,26 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context,
|
|||
return r.err
|
||||
}
|
||||
|
||||
type recordingTxOutChecker struct {
|
||||
outpoints [][]wire.OutPoint
|
||||
txOuts map[wire.OutPoint]*wire.TxOut
|
||||
err error
|
||||
}
|
||||
|
||||
// GetTxOuts records the request and returns the configured available outputs.
|
||||
func (r *recordingTxOutChecker) GetTxOuts(_ context.Context,
|
||||
outpoints []wire.OutPoint) (map[wire.OutPoint]*wire.TxOut, error) {
|
||||
|
||||
r.outpoints = append(
|
||||
r.outpoints, append([]wire.OutPoint(nil), outpoints...),
|
||||
)
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
return r.txOuts, nil
|
||||
}
|
||||
|
||||
// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
|
||||
// response without standing up the full gRPC client.
|
||||
type initHtlcTestServer struct {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ type Config struct {
|
|||
// LndClient is used to add invoices and select hop hints.
|
||||
LndClient lndclient.LightningClient
|
||||
|
||||
// TxOutChecker checks that selected deposits are still available before
|
||||
// the client gives the server HTLC signatures.
|
||||
TxOutChecker TxOutChecker
|
||||
|
||||
// InvoicesClient is used to subscribe to invoice settlements and
|
||||
// cancel invoices.
|
||||
InvoicesClient lndclient.InvoicesClient
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue