Skip to content

Commit

Permalink
staticaddr: deposit state machine
Browse files Browse the repository at this point in the history
  • Loading branch information
hieblmi committed Mar 4, 2024
1 parent dcf2151 commit 46150c1
Show file tree
Hide file tree
Showing 2 changed files with 381 additions and 0 deletions.
130 changes: 130 additions & 0 deletions staticaddr/actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package staticaddr

import (
"errors"
"fmt"

"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/script"
)

const (
defaultConfTarget = 3
)

// PublishExpiredDepositAction 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) PublishExpiredDepositAction(_ fsm.EventContext) fsm.EventType {
msgTx := wire.NewMsgTx(2)

// Add the deposit outpoint as input to the transaction.
msgTx.AddTxIn(&wire.TxIn{
PreviousOutPoint: f.deposit.OutPoint,
Sequence: f.addressParameters.Expiry,
SignatureScript: nil,
})

// Estimate the fee rate of an expiry spend transaction.
feeRateEstimator, err := f.cfg.WalletKit.EstimateFeeRate(
f.ctx, defaultConfTarget,
)
if err != nil {
return f.HandleError(fmt.Errorf("timeout sweep fee "+
"estimation failed: %v", err))
}

weight := script.ExpirySpendWeight()

fee := feeRateEstimator.FeeForWeight(weight)

// We cap the fee at 20% of the deposit value.
if fee > f.deposit.Value/5 {
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: f.addressParameters.PkScript,
}

prevOut := []*wire.TxOut{txOut}

signDesc, err := f.SignDescriptor()
if err != nil {
return f.HandleError(err)
}

rawSigs, err := f.cfg.Signer.SignOutputRaw(
f.ctx, msgTx, []*lndclient.SignDescriptor{&signDesc}, prevOut,
)
if err != nil {
return f.HandleError(err)
}

sig := rawSigs[0]
msgTx.TxIn[0].Witness, err = f.staticAddress.GenTimeoutWitness(sig)
if err != nil {
return f.HandleError(err)
}

err = f.cfg.WalletKit.PublishTransaction(
f.ctx, msgTx, f.deposit.OutPoint.Hash.String()+"-close-sweep",
)
if err != nil {
return f.HandleError(err)
}

f.Debugf("published timeout sweep with txid: %v", msgTx.TxHash())

return OnExpiryPublished
}

// WaitForExpirySweepAction waits for a sufficient number of confirmations
// before a timeout sweep is considered successful.
func (f *FSM) WaitForExpirySweepAction(_ fsm.EventContext) fsm.EventType {
spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll
f.ctx, nil, f.deposit.TimeOutSweepPkScript, defaultConfTarget,
int32(f.deposit.ConfirmationHeight),
)
if err != nil {
return f.HandleError(err)
}

select {
case err := <-errSpendChan:
log.Debugf("spend expired deposit error: %v", err)
return fsm.OnError

case <-spendChan:
return OnExpirySwept

case <-f.ctx.Done():
return fsm.OnError
}
}

// SweptExpiredDepositAction is the final action of the FSM. It signals to the
// manager that the deposit has been swept and the FSM can be removed. It also
// ends the state machine main loop by cancelling its context.
func (f *FSM) SweptExpiredDepositAction(_ fsm.EventContext) fsm.EventType {
select {
case <-f.ctx.Done():
return fsm.OnError

default:
f.finalizedDepositChan <- f.deposit.OutPoint
f.ctx.Done()
}

return fsm.NoOp
}
251 changes: 251 additions & 0 deletions staticaddr/fsm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package staticaddr

import (
"context"

"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
)

const (
DefaultObserverSize = 20
)

// States.
var (
Deposited = fsm.StateType("Deposited")

PublishExpiredDeposit = fsm.StateType("PublishExpiredDeposit")
WaitForExpirySweep = fsm.StateType("WaitForExpirySweep")

SweptExpiredDeposit = fsm.StateType("SweptExpiredDeposit")

Failed = fsm.StateType("DepositFailed")
)

// Events.
var (
OnStart = fsm.EventType("OnStart")
OnExpiry = fsm.EventType("OnExpiry")
OnExpiryPublished = fsm.EventType("OnExpiryPublished")
OnExpirySwept = fsm.EventType("OnExpirySwept")
OnRecover = fsm.EventType("OnRecover")
)

// FSM is the state machine that handles the instant out.
type FSM struct {
*fsm.StateMachine

cfg *ManagerConfig

addressParameters *AddressParameters

staticAddress *script.StaticAddress

deposit *Deposit

ctx context.Context

blockNtfnChan chan uint32

finalizedDepositChan chan wire.OutPoint
}

// NewFSM creates a new state machine that can action on all static address
// feature requests.
func NewFSM(ctx context.Context, addressParameters *AddressParameters,
staticAddress *script.StaticAddress, deposit *Deposit,
cfg *ManagerConfig, finalizedDepositChan chan wire.OutPoint,
recoverStateMachine bool) (*FSM, error) {

depoFsm := &FSM{
cfg: cfg,
addressParameters: addressParameters,
staticAddress: staticAddress,
deposit: deposit,
ctx: ctx,
blockNtfnChan: make(chan uint32),
finalizedDepositChan: finalizedDepositChan,
}

if recoverStateMachine {
depoFsm.StateMachine = fsm.NewStateMachineWithState(
depoFsm.DepositStates(), deposit.State,
DefaultObserverSize,
)
} else {
depoFsm.StateMachine = fsm.NewStateMachine(
depoFsm.DepositStates(), DefaultObserverSize,
)
}

depoFsm.ActionEntryFunc = depoFsm.updateDeposit

go func() {
for {
select {
case currentHeight := <-depoFsm.blockNtfnChan:
depoFsm.handleBlockNotification(currentHeight)

case <-ctx.Done():
return
}
}
}()

return depoFsm, nil
}

func (f *FSM) handleBlockNotification(currentHeight uint32) {
isExpired := func() bool {
return currentHeight >= uint32(f.deposit.ConfirmationHeight)+
f.addressParameters.Expiry
}

if isExpired() && f.deposit.State != WaitForExpirySweep &&
!f.deposit.IsFinal() {

go func() {
err := f.SendEvent(OnExpiry, nil)
if err != nil {
log.Debugf("error sending OnExpiry event: %v",
err)
}
}()
}
}

// DepositStates returns the states a deposit can be in.
func (f *FSM) DepositStates() fsm.States {
return fsm.States{
fsm.EmptyState: fsm.State{
Transitions: fsm.Transitions{
OnStart: Deposited,
},
Action: fsm.NoOpAction,
},
Deposited: fsm.State{
Transitions: fsm.Transitions{
OnExpiry: PublishExpiredDeposit,
OnRecover: Deposited,
},
Action: fsm.NoOpAction,
},
PublishExpiredDeposit: fsm.State{
Transitions: fsm.Transitions{
OnRecover: PublishExpiredDeposit,
OnExpiryPublished: WaitForExpirySweep,
// If the timeout sweep failed we go back to
// Deposited, hoping that another timeout sweep
// attempt will be successful. Alternatively,
// the client can try to coop-spend the deposit.
fsm.OnError: Deposited,
},
Action: f.PublishExpiredDepositAction,
},
WaitForExpirySweep: fsm.State{
Transitions: fsm.Transitions{
OnExpirySwept: SweptExpiredDeposit,
OnRecover: PublishExpiredDeposit,
// If the timeout sweep failed we go back to
// Deposited, hoping that another timeout sweep
// attempt will be successful. Alternatively,
// the client can try to coop-spend the deposit.
fsm.OnError: Deposited,
},
Action: f.WaitForExpirySweepAction,
},
SweptExpiredDeposit: fsm.State{
Transitions: fsm.Transitions{
OnExpiry: SweptExpiredDeposit,
},
Action: f.SweptExpiredDepositAction,
},
Failed: fsm.State{
Action: fsm.NoOpAction,
},
}
}

// DepositEntryFunction is called after every action and updates the deposit in
// the db.
func (f *FSM) updateDeposit(notification fsm.Notification) {
if f.deposit == nil {
return
}

f.Debugf("NextState: %v, PreviousState: %v, Event: %v",
notification.NextState, notification.PreviousState,
notification.Event,
)

f.deposit.State = notification.NextState

// Don't update the deposit if we are in an initial state or if we
// are transitioning from an initial state to a failed state.
state := f.deposit.State
if state == fsm.EmptyState || state == Deposited ||
(notification.PreviousState == Deposited && state == Failed) {

return
}

err := f.cfg.Store.UpdateDeposit(f.ctx, f.deposit)
if err != nil {
f.Errorf("unable to update deposit: %v", err)
}
}

// Infof logs an info message with the deposit outpoint.
func (f *FSM) Infof(format string, args ...interface{}) {
log.Infof(
"Deposit %v: "+format,
append(
[]interface{}{f.deposit.OutPoint},
args...,
)...,
)
}

// Debugf logs a debug message with the deposit outpoint.
func (f *FSM) Debugf(format string, args ...interface{}) {
log.Debugf(
"Deposit %v: "+format,
append(
[]interface{}{f.deposit.OutPoint},
args...,
)...,
)
}

// Errorf logs an error message with the deposit outpoint.
func (f *FSM) Errorf(format string, args ...interface{}) {
log.Errorf(
"Deposit %v: "+format,
append(
[]interface{}{f.deposit.OutPoint},
args...,
)...,
)
}

// SignDescriptor returns the sign descriptor for the static address output.
func (f *FSM) SignDescriptor() (lndclient.SignDescriptor, error) {
return lndclient.SignDescriptor{
WitnessScript: f.staticAddress.TimeoutLeaf.Script,
KeyDesc: keychain.KeyDescriptor{
PubKey: f.addressParameters.ClientPubkey,
},
Output: wire.NewTxOut(
int64(f.deposit.Value), f.addressParameters.PkScript,
),
HashType: txscript.SigHashDefault,
InputIndex: 0,
SignMethod: input.TaprootScriptSpendSignMethod,
}, nil
}

0 comments on commit 46150c1

Please sign in to comment.