loop/staticaddr/deposit/actions.go
Slyghtning 58fbe2230e
staticaddr/deposit: guard confirmation height access
Document deposit lock ownership for mutable confirmation state and
route production reads through deposit accessors.

Keep store persistence on no-lock helpers while callers hold the
deposit lock, preserving the existing transition behavior without
leaving direct field reads in user-facing paths.
2026-07-08 09:01:01 +02:00

185 lines
4.7 KiB
Go

package deposit
import (
"context"
"errors"
"fmt"
"strings"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/utils"
"github.com/lightningnetwork/lnd/lntypes"
)
const (
DefaultConfTarget = 3
)
// PublishDepositExpirySweepAction creates and publishes the timeout transaction
// that spends the deposit from the static address timeout leaf to the
// predefined timeout sweep pkscript.
func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
msgTx := wire.NewMsgTx(2)
params, err := f.cfg.AddressManager.GetStaticAddressParameters(ctx)
if err != nil {
return fsm.OnError
}
// Add the deposit outpoint as input to the transaction.
msgTx.AddTxIn(&wire.TxIn{
PreviousOutPoint: f.deposit.OutPoint,
Sequence: params.Expiry,
SignatureScript: nil,
})
// Estimate the fee rate of an expiry spend transaction.
feeRateEstimator, err := f.cfg.WalletKit.EstimateFeeRate(
ctx, DefaultConfTarget,
)
if err != nil {
return f.HandleError(fmt.Errorf("timeout sweep fee "+
"estimation failed: %w", err))
}
minRelayFeeRate, err := f.cfg.WalletKit.MinRelayFee(ctx)
if err != nil {
return f.HandleError(fmt.Errorf("timeout sweep min relay "+
"query failed: %w", err))
}
weight := script.ExpirySpendWeight()
fee := feeRateEstimator.FeeForWeight(lntypes.WeightUnit(weight))
// We cap the fee at 20% of the deposit value.
_, clamped, err := utils.ClampSweepFee(
fee, f.deposit.Value, utils.MaxFeeToAmountRatio,
minRelayFeeRate, lntypes.WeightUnit(weight),
)
if err != nil {
return f.HandleError(err)
}
if clamped {
return f.HandleError(errors.New("fee is greater than 20% of " +
"the deposit value"))
}
output := &wire.TxOut{
Value: int64(f.deposit.Value - fee),
PkScript: f.deposit.TimeOutSweepPkScript,
}
msgTx.AddTxOut(output)
txOut := &wire.TxOut{
Value: int64(f.deposit.Value),
PkScript: params.PkScript,
}
prevOut := []*wire.TxOut{txOut}
signDesc, err := f.SignDescriptor(ctx)
if err != nil {
return f.HandleError(err)
}
rawSigs, err := f.cfg.Signer.SignOutputRaw(
ctx, msgTx, []*lndclient.SignDescriptor{signDesc}, prevOut,
)
if err != nil {
return f.HandleError(err)
}
address, err := f.cfg.AddressManager.GetStaticAddress(ctx)
if err != nil {
return f.HandleError(err)
}
sig := rawSigs[0]
msgTx.TxIn[0].Witness, err = address.GenTimeoutWitness(sig)
if err != nil {
return f.HandleError(err)
}
txLabel := fmt.Sprintf("timeout sweep for deposit %v",
f.deposit.OutPoint)
err = f.cfg.WalletKit.PublishTransaction(ctx, msgTx, txLabel)
if err != nil {
if !strings.Contains(err.Error(), "output already spent") {
log.Errorf("%v: %v", txLabel, err)
f.LastActionError = err
return fsm.OnError
}
} else {
txHash := msgTx.TxHash()
f.deposit.ExpirySweepTxid = txHash
f.Debugf("published timeout sweep with txid: %v", txHash)
}
return OnExpiryPublished
}
// WaitForExpirySweepAction waits for enough confirmations before a timeout
// sweep is considered successful.
func (f *FSM) WaitForExpirySweepAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
var txID *chainhash.Hash
// Only pass the txid if we know it from our own publication.
if f.deposit.ExpirySweepTxid != (chainhash.Hash{}) {
txID = &f.deposit.ExpirySweepTxid
}
spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll
ctx, txID, f.deposit.TimeOutSweepPkScript, DefaultConfTarget,
int32(f.deposit.GetConfirmationHeight()),
)
if err != nil {
return f.HandleError(err)
}
select {
case err = <-errSpendChan:
log.Debugf("error while sweeping expired deposit: %v", err)
return fsm.OnError
case confirmedTx := <-spendChan:
f.deposit.ExpirySweepTxid = confirmedTx.Tx.TxHash()
return OnExpirySwept
case <-ctx.Done():
return fsm.OnError
}
}
// FinalizeDepositAction is the final action after a withdrawal. It signals to
// the manager that the deposit has been swept and the FSM can be removed.
func (f *FSM) FinalizeDepositAction(_ context.Context,
_ fsm.EventContext) fsm.EventType {
outpoint := f.deposit.OutPoint
// The finalization notification only tells the manager to remove the
// deposit from its active set. Send it asynchronously so a busy manager
// loop can't stall withdrawal confirmation while deposit locks are held.
go func() {
select {
case <-f.quitChan:
// The deposit is already in a final state. If shutdown wins
// this race, startup recovery will skip it instead of
// re-adding it to the active set.
return
case f.finalizedDepositChan <- outpoint:
}
}()
return fsm.NoOp
}