pool/account/manager.go
2024-05-31 10:39:23 +02:00

2666 lines
84 KiB
Go

package account
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"github.com/btcsuite/btcd/blockchain"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/btcutil/txsort"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/wallet"
"github.com/btcsuite/btcwallet/wallet/txrules"
"github.com/btcsuite/btcwallet/wtxmgr"
"github.com/davecgh/go-spew/spew"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/pool/account/watcher"
"github.com/lightninglabs/pool/poolscript"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/verrpc"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/btcwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
)
const (
// minConfs and maxConfs represent the thresholds at both extremes for
// valid number of confirmations on an account before it is considered
// open.
minConfs = 3
maxConfs = 6
// MinAccountValue is the minimum value for an account output in
// satoshis.
MinAccountValue btcutil.Amount = 100000
// minAccountExpiry and maxAccountExpiry represent the thresholds at
// both extremes for valid account expirations.
minAccountExpiry = 144 // One day worth of blocks.
maxAccountExpiry = 144 * 365 // A year worth of blocks.
txLabelPrefixTag = "poold -- "
)
var (
// errTxNotFound is an error returned when we attempt to locate a
// transaction but we are unable to find it.
errTxNotFound = errors.New("transaction not found")
)
// witnessType denotes the possible witness types of an account.
type witnessType uint8
const (
// expiryWitness is the type used for a witness taking the expiration
// path of an account.
expiryWitness witnessType = iota
// multiSigWitness is the type used for a witness taking the multi-sig
// path of an account.
multiSigWitness
// expiryTaproot is the type used for a witness taking the expiration
// path of a Taproot account.
expiryTaproot
// muSig2Taproot is the type used for a witness taking the MuSig2
// combined signature key spend path of a Taproot account.
muSig2Taproot
)
// scriptVersion returns the Pool script version the witness type uses.
func (wt witnessType) scriptVersion() poolscript.Version {
switch wt {
case expiryTaproot, muSig2Taproot:
return poolscript.VersionTaprootMuSig2
default:
return poolscript.VersionWitnessScript
}
}
// witnessSize returns the estimated weight units for an account input witness.
func (wt witnessType) witnessSize() (lntypes.WeightUnit, error) {
switch wt {
case expiryWitness:
return poolscript.ExpiryWitnessSize, nil
case multiSigWitness:
return poolscript.MultiSigWitnessSize, nil
case expiryTaproot:
return poolscript.TaprootExpiryWitnessSize, nil
case muSig2Taproot:
return poolscript.TaprootMultiSigWitnessSize, nil
default:
return 0, fmt.Errorf("unknown witness type %v", wt)
}
}
// IsExpirySpend returns true if the witness is taking an expiration path.
func (wt witnessType) IsExpirySpend() bool {
switch wt {
case expiryWitness, expiryTaproot:
return true
default:
return false
}
}
// Action is a type of account modification.
type Action string
const (
CREATE Action = "create"
DEPOSIT Action = "deposit"
WITHDRAW Action = "withdraw"
RENEW Action = "renew"
CLOSE Action = "close"
)
type TxLabel struct {
Account AccountTxLabel `json:"account"`
}
type AccountTxLabel struct {
Key string `json:"key"`
Action Action `json:"action"`
ExpiryHeight uint32 `json:"expiry_height"`
OutputIndex uint32 `json:"output_index"`
IsExpirySpend bool `json:"expiry_spend"`
TxFee *btcutil.Amount `json:"tx_fee"`
BalanceDiff btcutil.Amount `json:"balance_diff"`
}
// actionTxLabel returns a transaction label for use with account
// modification actions.
func actionTxLabel(account *Account, action Action, isExpirySpend bool,
txFee *btcutil.Amount, balanceDiff btcutil.Amount) string {
acctKey := account.TraderKey.PubKey.SerializeCompressed()
key := fmt.Sprintf("%x", acctKey)
label := TxLabel{
Account: AccountTxLabel{
Key: key,
Action: action,
ExpiryHeight: account.Expiry,
OutputIndex: account.OutPoint.Index,
IsExpirySpend: isExpirySpend,
TxFee: txFee,
BalanceDiff: balanceDiff,
},
}
labelJson, err := json.Marshal(label)
if err != nil {
log.Errorf("Internal error: failed to serialize json "+
"from %v: %v", label, err)
return fmt.Sprintf("%s%s", txLabelPrefixTag, action)
}
return fmt.Sprintf("%s%s", txLabelPrefixTag, labelJson)
}
// IsPoolTx returns true if the given transaction is related to pool.
func IsPoolTx(tx *lnrpc.Transaction) bool {
return strings.HasPrefix(tx.Label, txLabelPrefixTag)
}
// ParseTxLabel parses and returns data fields stored in a given transaction
// label.
func ParseTxLabel(label string) (*TxLabel, error) {
label = strings.TrimPrefix(label, txLabelPrefixTag)
var data TxLabel
err := json.Unmarshal([]byte(label), &data)
if err != nil {
return nil, err
}
return &data, nil
}
// ManagerConfig contains all of the required dependencies for the Manager to
// carry out its duties.
type ManagerConfig struct {
// Store is responsible for storing and retrieving account information
// reliably.
Store Store
// Auctioneer provides us with the different ways we are able to
// communicate with our auctioneer during the process of
// opening/closing/modifying accounts.
Auctioneer Auctioneer
// Wallet handles all of our on-chain transaction interaction, whether
// that is deriving keys, creating transactions, etc.
Wallet lndclient.WalletKitClient
// Signer is responsible for deriving shared secrets for accounts
// between the trader and auctioneer and signing account-related
// transactions.
Signer lndclient.SignerClient
// ChainNotifier is responsible for requesting confirmation and spend
// notifications for accounts.
ChainNotifier lndclient.ChainNotifierClient
// TxSource is a source that provides us with transactions previously
// broadcast by us.
TxSource TxSource
// TxFeeEstimator is an estimator that can calculate the total on-chain
// fees to send to an account output.
TxFeeEstimator TxFeeEstimator
// TxLabelPrefix is set, then all transactions the account manager
// makes will use this string as a prefix for added transaction labels.
TxLabelPrefix string
// ChainParams are the currently used chain parameters.
ChainParams *chaincfg.Params
// LndVersion is the version of the connected lnd node.
LndVersion *verrpc.Version
}
// Manager is responsible for the management of accounts on-chain.
type manager struct {
started sync.Once
stopped sync.Once
cfg ManagerConfig
watcherCtrl watcher.Controller
// pendingBatchMtx guards access to any database calls involving pending
// batches. This is mostly used to prevent race conditions when handling
// multiple accounts spends as part of a batch that we didn't receive a
// Finalize message for.
pendingBatchMtx sync.Mutex
// reservationMtx prevents a trader from attempting to have more than
// once active reservation at a time when creating new accounts. This is
// done to ensure an account picks up the correct reservation once its
// time to fund it.
reservationMtx sync.Mutex
wg sync.WaitGroup
quit chan struct{}
}
// Compile time assertion that manager implements the Manager interface.
var _ Manager = (*manager)(nil)
// NewManager instantiates a new Manager backed by the given config.
func NewManager(cfg *ManagerConfig) *manager { // nolint:golint
m := &manager{
cfg: *cfg,
quit: make(chan struct{}),
}
m.watcherCtrl = watcher.NewController(&watcher.CtrlConfig{
ChainNotifier: cfg.ChainNotifier,
// The manager implements the EventHandler interface.
Handlers: m,
})
return m
}
// Start resumes all account on-chain operation after a restart.
func (m *manager) Start() error {
var err error
m.started.Do(func() {
err = m.start()
})
return err
}
// start resumes all account on-chain operation after a restart.
func (m *manager) start() error {
ctx := context.Background()
// We'll start by resuming all of our accounts. This requires the
// watcher to be started first.
if err := m.watcherCtrl.Start(); err != nil {
return err
}
// Then, we'll resume all complete accounts, followed by partial
// accounts. If we were to do it the other way around, we'd resume
// partial accounts twice.
accounts, err := m.cfg.Store.Accounts()
if err != nil {
return fmt.Errorf("unable to retrieve accounts: %v", err)
}
// We calculate the default fee rate that will be used
// for resuming accounts for which we haven't created and broadcast
// a transaction yet
feeRate, err := m.cfg.Wallet.EstimateFeeRate(
ctx, int32(DefaultFundingConfTarget),
)
if err != nil {
return fmt.Errorf("unable to estimate default fees %w", err)
}
for _, account := range accounts {
acctKey := account.TraderKey.PubKey.SerializeCompressed()
// Detect if poold is using a different LND Signer
// than the one used for creating this account.
if err := m.verifyAccountSigner(ctx, account); err != nil {
return fmt.Errorf("unable to resume account %x: %v",
acctKey, err)
}
// Try to resume the account now.
//
// TODO(guggero): Refactor this to extract the init/funding
// part so we properly abandon the account if it fails before
// publishing the TX instead of trying to re-fund on startup.
if err := m.resumeAccount(
ctx, account, true, false, feeRate,
); err != nil {
return fmt.Errorf("unable to resume account %x: %v",
acctKey, err)
}
}
return nil
}
// Stop safely stops any ongoing operations within the Manager.
func (m *manager) Stop() {
m.stopped.Do(func() {
m.watcherCtrl.Stop()
close(m.quit)
m.wg.Wait()
})
}
// QuoteAccount returns the expected fee rate and total miner fee to send to an
// account funding output with the given confTarget.
func (m *manager) QuoteAccount(ctx context.Context, value btcutil.Amount,
confTarget uint32) (chainfee.SatPerKWeight, btcutil.Amount, error) {
// First, make sure we have a valid amount to create the account. We
// need to ask the auctioneer for the maximum as it dynamically defines
// that value.
terms, err := m.cfg.Auctioneer.Terms(ctx)
if err != nil {
return 0, 0, fmt.Errorf("could not query auctioneer terms: %v",
err)
}
err = validateAccountValue(value, terms.MaxAccountValue)
if err != nil {
return 0, 0, err
}
// Now calculate the estimated fee rate from the confTarget.
feeRate, err := m.cfg.Wallet.EstimateFeeRate(ctx, int32(confTarget))
if err != nil {
return 0, 0, fmt.Errorf("error estimating fee rate: %v", err)
}
// Then calculate the total fee to pay. This asks lnd to create a full
// transaction to spend to a P2WSH output. If not enough confirmed funds
// are available in the wallet, this will return an error.
totalMinerFee, err := m.cfg.TxFeeEstimator.EstimateFeeToP2WSH(
ctx, value, int32(confTarget),
)
if err != nil {
return 0, 0, fmt.Errorf("error estimating total on-chain fee: "+
"%v", err)
}
return feeRate, totalMinerFee, nil
}
// InitAccount handles a request to create a new account with the provided
// parameters.
func (m *manager) InitAccount(ctx context.Context, value btcutil.Amount,
version Version, feeRate chainfee.SatPerKWeight, expiry,
bestHeight uint32) (*Account, error) {
// We'll make sure to acquire the reservation lock throughout the
// account funding process to ensure we use the same reservation, as
// only one can be active per trader L402.
m.reservationMtx.Lock()
defer m.reservationMtx.Unlock()
// First, make sure we have a valid amount to create the account. We
// need to ask the auctioneer for the maximum as it dynamically defines
// that value.
terms, err := m.cfg.Auctioneer.Terms(ctx)
if err != nil {
return nil, fmt.Errorf("could not query auctioneer terms: %v",
err)
}
err = validateAccountParams(
value, terms.MaxAccountValue, expiry, bestHeight, version,
)
if err != nil {
return nil, err
}
// We'll start by deriving a key for ourselves that we'll use in our
// 2-of-2 multi-sig construction.
keyDesc, err := m.cfg.Wallet.DeriveNextKey(
ctx, int32(poolscript.AccountKeyFamily),
)
if err != nil {
return nil, err
}
// With our key obtained, we'll reserve an account with our auctioneer,
// who will provide us with their base key and our initial per-batch
// key.
reservation, err := m.cfg.Auctioneer.ReserveAccount(
ctx, value, expiry, keyDesc.PubKey, version,
)
if err != nil {
return nil, err
}
// We'll also need to compute a shared secret based on both base keys
// (the trader and auctioneer's) to ensure only they are able to
// successfully identify every past/future output of the account.
secret, err := m.cfg.Signer.DeriveSharedKey(
ctx, reservation.AuctioneerKey, &keyDesc.KeyLocator,
)
if err != nil {
return nil, err
}
// With all of the details gathered, we'll persist our intent to create
// an account to disk and proceed to fund it and wait for its
// confirmation.
account := &Account{
Value: value,
Expiry: expiry,
TraderKey: keyDesc,
AuctioneerKey: reservation.AuctioneerKey,
BatchKey: reservation.InitialBatchKey,
Secret: secret,
State: StateInitiated,
HeightHint: bestHeight,
Version: version,
}
if err := m.cfg.Store.AddAccount(account); err != nil {
return nil, err
}
log.Infof("Creating new account %x of %v that expires at height %v",
keyDesc.PubKey.SerializeCompressed(), value, expiry)
err = m.resumeAccount(ctx, account, false, false, feeRate)
if err != nil {
return nil, err
}
return account, nil
}
// WatchMatchedAccounts resumes accounts that were just matched in a batch and
// are expecting the batch transaction to confirm as their next account output.
// This will cancel all previous spend and conf watchers of all accounts
// involved in the batch.
func (m *manager) WatchMatchedAccounts(ctx context.Context,
matchedAccounts []*btcec.PublicKey) error {
for _, matchedAccount := range matchedAccounts {
acct, err := m.cfg.Store.Account(matchedAccount)
if err != nil {
return fmt.Errorf("error reading account %x: %v",
matchedAccount.SerializeCompressed(), err)
}
// The account was just involved in a batch. That means our
// account output was spent by a batch transaction. Since we
// know that a batch transaction cannot simply be rolled back or
// replaced without us being involved, we know that the batch TX
// will eventually confirm. To handle the case where an account
// is involved in multiple consecutive batches that are all
// unconfirmed, we make sure we only track the latest state by
// canceling all previous spend and confirmation watchers. We
// then only watch the latest batch and once it confirms, create
// a new spend watcher on that.
m.watcherCtrl.CancelAccountSpend(matchedAccount)
m.watcherCtrl.CancelAccountConf(matchedAccount)
// After taking part in a batch, the account is either pending
// closed because it was used up or pending batch update because
// it was recreated. Either way, let's resume it now by creating
// the appropriate watchers again.
// We set feerate to 0 because we know that we won't need to
// create a new transaction for resuming the account.
err = m.resumeAccount(ctx, acct, false, false, 0)
if err != nil {
return fmt.Errorf("error resuming account %x: %v",
matchedAccount.SerializeCompressed(), err)
}
}
return nil
}
// maybeBroadcastTx attempts to broadcast the transaction only if all of its
// inputs have been signed for.
func (m *manager) maybeBroadcastTx(ctx context.Context, tx *wire.MsgTx,
label string) error {
// If any of the transaction inputs aren't signed, don't broadcast.
for _, txIn := range tx.TxIn {
if len(txIn.Witness) == 0 && len(txIn.SignatureScript) == 0 {
return nil
}
}
return m.cfg.Wallet.PublishTransaction(ctx, tx, label)
}
// verifyAccountSigner ensures that we are able to recreate the account
// secret for active accounts. That means that the LND signerClient did
// not change and we are able to generate valid signatures for this account.
func (m *manager) verifyAccountSigner(ctx context.Context,
account *Account) error {
// The secret was based on both base keys, the trader and auctioneer's.
secret, err := m.cfg.Signer.DeriveSharedKey(
ctx, account.AuctioneerKey, &account.TraderKey.KeyLocator,
)
if err != nil {
return fmt.Errorf("unable to regenerate secret: %v", err)
}
// Here we would detect if the backend LND node (signer) changed.
if !bytes.Equal(secret[:], account.Secret[:]) {
return fmt.Errorf("couldn't derive account secret; make sure " +
"you are using the same lnd node/seed that was used " +
"for creating the account")
}
return nil
}
// resumeAccount performs different operations based on the account's state.
// This method serves as a way to consolidate the logic of resuming accounts on
// startup and during normal operation.
func (m *manager) resumeAccount(ctx context.Context, account *Account, // nolint
onRestart bool, onRecovery bool, feeRate chainfee.SatPerKWeight) error {
accountOutput, err := account.Output()
if err != nil {
return fmt.Errorf("unable to construct account output: %v", err)
}
switch account.State {
// In StateInitiated, we'll attempt to fund our account.
case StateInitiated:
// If we're resuming the account from a restart, we'll want to
// make sure we haven't created and broadcast a transaction for
// this account already, so we'll inspect our TxSource to do so.
var (
accountTx *wire.MsgTx
createTx = true
)
if onRestart || onRecovery {
tx, err := m.locateTxByOutput(
ctx, accountOutput, account.LatestTx,
)
switch err {
// If we do find one, we can rebroadcast it.
case nil:
accountTx = tx
createTx = false
// If we don't, we'll need to create one.
case errTxNotFound:
// If lnd doesn't know a transaction that sends
// to the account output, it could be that it
// was never published or it never confirmed.
// In that case the funds should be SAFU and can
// be double spent. We don't need to try a
// recovery in that case. And we certainly don't
// want to send funds again, so we exit here.
if onRecovery {
state := StateCanceledAfterRecovery
err := m.cfg.Store.UpdateAccount(
account, StateModifier(state),
)
if err != nil {
return fmt.Errorf("account "+
"funding TX not found "+
"but was unable to "+
"update account to "+
"state recovery "+
"failed: %v", err)
}
return fmt.Errorf("account funding "+
"TX with output %x not found",
accountOutput.PkScript)
}
default:
return fmt.Errorf("unable to locate output "+
"%x: %v", accountOutput.PkScript, err)
}
}
if createTx {
acctKey := account.TraderKey.PubKey.SerializeCompressed()
if feeRate == 0 {
return fmt.Errorf("unable to create "+
" transaction for account with "+
" trader key %x, feeRate should "+
"be greater than 0", acctKey)
}
// Attach additional meta data to transaction label.
//
// TODO(ffranr): Tx inputs are required to calculate the
// fee. The fee will be added to the tx label.
// m.cfg.Wallet.SendOutputs returns and selects tx
// inputs but also requires the label as an argument.
// Instead, the tx should be constructed via
// m.cfg.Wallet.FundPsbt which would give us an
// opportunity to inspect tx inputs before creating a
// tx label.
var txFee *btcutil.Amount = nil
balanceDiff := account.Value
contextLabel := actionTxLabel(
account, CREATE, false, txFee,
balanceDiff,
)
label := makeTxnLabel(m.cfg.TxLabelPrefix, contextLabel)
// TODO(wilmer): Expose manual controls to bump fees.
tx, err := m.cfg.Wallet.SendOutputs(
ctx, []*wire.TxOut{accountOutput}, feeRate,
label,
)
if err != nil {
return err
}
accountTx = tx
log.Infof("Funded new account %x with transaction %v",
account.TraderKey.PubKey.SerializeCompressed(),
tx.TxHash())
}
// With the transaction obtained, we'll locate the index of our
// account output in the transaction to obtain our account
// outpoint and store it to disk. This will be the main way we
// identify our accounts, and is also required to watch for its
// spend.
outputIndex, ok := poolscript.LocateOutputScript(
accountTx, accountOutput.PkScript,
)
if !ok {
return fmt.Errorf("transaction %v does not include "+
"expected script %x", accountTx.TxHash(),
accountOutput.PkScript)
}
op := wire.OutPoint{Hash: accountTx.TxHash(), Index: outputIndex}
err := m.cfg.Store.UpdateAccount(
account, StateModifier(StatePendingOpen),
OutPointModifier(op), LatestTxModifier(accountTx),
)
if err != nil {
return err
}
fallthrough
// In StatePendingOpen, we should already have broadcast a funding
// transaction for the account, so the most we can do is attempt to
// rebroadcast it and wait for its confirmation.
case StatePendingOpen:
// If we're resuming from a restart, we'll have to locate the
// transaction in our TxSource by its hash. We should definitely
// find one in this state, so if we don't, that would indicate
// something has gone wrong.
if onRestart {
accountTx := account.LatestTx
// Since we store the latest account modification TX in
// the account itself, we don't need to rely on lnd
// keeping track of all our TXns anymore. If what we
// have in the DB is correct, we can just re-broadcast
// that TX.
if accountTx == nil ||
accountTx.TxHash() != account.OutPoint.Hash {
var err error
accountTx, err = m.locateTxByHash(
ctx, account.OutPoint.Hash,
)
if err != nil {
return fmt.Errorf("unable to locate "+
"transaction %v: %v",
account.OutPoint.Hash, err)
}
}
fee, err := m.deriveFeeFromTx(ctx, accountTx)
if err != nil {
log.Errorf("Failed to derive fee from "+
"transaction: %v, %v", accountTx, err)
}
balanceDiff := account.Value
contextLabel := actionTxLabel(
account, CREATE, false, fee, balanceDiff,
)
label := makeTxnLabel(m.cfg.TxLabelPrefix, contextLabel)
err = m.maybeBroadcastTx(ctx, accountTx, label)
if err != nil {
return err
}
}
// Send the account parameters over to the auctioneer so that
// they're also aware of the account.
err := m.cfg.Auctioneer.InitAccount(ctx, account)
if err != nil {
return err
}
terms, err := m.cfg.Auctioneer.Terms(ctx)
if err != nil {
return fmt.Errorf("could not query auctioneer terms: "+
"%v", err)
}
// Proceed to watch for the account on-chain.
numConfs := NumConfsForValue(
account.Value, terms.MaxAccountValue,
)
log.Infof("Waiting for %v confirmation(s) of account %x",
numConfs, account.TraderKey.PubKey.SerializeCompressed())
err = m.watcherCtrl.WatchAccountConf(
account.TraderKey.PubKey, account.OutPoint.Hash,
accountOutput.PkScript, numConfs, account.HeightHint,
)
if err != nil {
return fmt.Errorf("unable to watch for confirmation: "+
"%v", err)
}
// In StatePendingUpdate or StatePendingBatch, we've processed an
// account update due to either a matched order or trader modification,
// so we'll need to wait for its confirmation. Once it confirms,
// handleAccountConf will take care of the rest of the flow.
//
// TODO(wilmer): Handle restart case where the client shuts down after
// the modification has been reflected on-disk, but the auctioneer's
// signature hasn't been received.
//
// TODO(guggero): Handle the case of a malicious auctioneer that
// replaces batch A with a batch A' that contains none of our accounts
// and would therefore not be noticed by us. The account would stay
// pending forever in that case.
case StatePendingUpdate, StatePendingBatch:
// We need to know the maximum account value to scale the number
// of confirmations the same way the auctioneer does to avoid
// getting the state out of sync.
terms, err := m.cfg.Auctioneer.Terms(ctx)
if err != nil {
return fmt.Errorf("could not query auctioneer terms: "+
"%v", err)
}
numConfs := NumConfsForValue(
account.Value, terms.MaxAccountValue,
)
log.Infof("Waiting for %v confirmation(s) of account %x",
numConfs, account.TraderKey.PubKey.SerializeCompressed())
err = m.watcherCtrl.WatchAccountConf(
account.TraderKey.PubKey, account.OutPoint.Hash,
accountOutput.PkScript, numConfs, account.HeightHint,
)
if err != nil {
return fmt.Errorf("unable to watch for confirmation: "+
"%v", err)
}
// Only subscribe to auction updates for this account if it's in
// the pending batch state, to allow traders to participate in
// consecutive batches. This isn't necessary for the pending
// update state, as that state is ineligible for batch
// execution.
if account.State == StatePendingBatch {
err = m.cfg.Auctioneer.StartAccountSubscription(
ctx, account.TraderKey,
)
if err != nil {
return fmt.Errorf("unable to subscribe for "+
"account updates: %v", err)
}
}
// In StateOpen, the funding transaction for the account has already
// confirmed, so we only need to watch for its spend and expiration and
// register for account updates.
case StateOpen:
if err := m.handleStateOpen(ctx, account); err != nil {
return err
}
// In StateExpiredPendingUpdate, the account expired while having a
// pending update. To make sure the account can be renewed, we'll wait
// for the pending update to confirm and transition the account to
// StateExpired then.
case StateExpiredPendingUpdate:
terms, err := m.cfg.Auctioneer.Terms(ctx)
if err != nil {
return fmt.Errorf("could not query auctioneer terms: "+
"%v", err)
}
numConfs := NumConfsForValue(
account.Value, terms.MaxAccountValue,
)
log.Infof("Waiting for %v confirmation(s) of expired account %x",
numConfs, account.TraderKey.PubKey.SerializeCompressed())
err = m.watcherCtrl.WatchAccountConf(
account.TraderKey.PubKey, account.OutPoint.Hash,
accountOutput.PkScript, numConfs, account.HeightHint,
)
if err != nil {
return fmt.Errorf("unable to watch for confirmation: "+
"%v", err)
}
// In StateExpired, we'll wait for the account to be spent so that we
// can detect whether its been closed or renewed.
case StateExpired:
log.Infof("Watching expired account %x for spend",
account.TraderKey.PubKey.SerializeCompressed())
err = m.watcherCtrl.WatchAccountSpend(
account.TraderKey.PubKey, account.OutPoint,
accountOutput.PkScript, account.HeightHint,
)
if err != nil {
return fmt.Errorf("unable to watch for spend: %v", err)
}
// In StatePendingClosed, we'll wait for the account's closing
// transaction to confirm so that we can transition the account to its
// final state.
case StatePendingClosed:
fee, err := m.deriveFeeFromTx(ctx, account.LatestTx)
if err != nil {
log.Errorf("Failed to derive fee from "+
"transaction: %v, %v", account.LatestTx, err)
}
balanceDiff := account.Value
contextLabel := actionTxLabel(
account, CLOSE, false, fee, balanceDiff,
)
label := makeTxnLabel(m.cfg.TxLabelPrefix, contextLabel)
err = m.maybeBroadcastTx(ctx, account.LatestTx, label)
if err != nil {
return err
}
log.Infof("Watching account %x for spend",
account.TraderKey.PubKey.SerializeCompressed())
err = m.watcherCtrl.WatchAccountSpend(
account.TraderKey.PubKey, account.OutPoint,
accountOutput.PkScript, account.HeightHint,
)
if err != nil {
return fmt.Errorf("unable to watch for spend: %v", err)
}
// If the account has already been closed or canceled, there's nothing
// to be done.
case StateClosed, StateCanceledAfterRecovery:
break
default:
return fmt.Errorf("unhandled account state %v", account.State)
}
return nil
}
// deriveFeeFromTx derives the transaction fee from a given transaction message.
func (m *manager) deriveFeeFromTx(ctx context.Context,
tx *wire.MsgTx) (*btcutil.Amount, error) {
// Input and output values are required to calculate the tx fee. However,
// tx messages do not contain input values. We will therefore locate those
// input values via the outputs that they represent.
var sumInputs int64
for _, txIn := range tx.TxIn {
spendTx, err := m.locateTxByHash(ctx, txIn.PreviousOutPoint.Hash)
if err != nil {
return nil, err
}
txOut := spendTx.TxOut[txIn.PreviousOutPoint.Index]
sumInputs += txOut.Value
}
var sumOutputs int64
for _, txOut := range tx.TxOut {
sumOutputs += txOut.Value
}
fee := btcutil.Amount(sumInputs - sumOutputs)
return &fee, nil
}
// locateTxByOutput locates a transaction from the Manager's TxSource by one of
// its outputs. If a transaction is not found containing the output, then
// errTxNotFound is returned.
func (m *manager) locateTxByOutput(ctx context.Context,
output *wire.TxOut, fullTx *wire.MsgTx) (*wire.MsgTx, error) {
// We now store the full raw transaction of the last modification. We
// can just use that if available. If for some reason that TX doesn't
// contain our current outpoint, we fall back to the previous behavior.
if fullTx != nil {
idx, ok := poolscript.LocateOutputScript(fullTx, output.PkScript)
if ok && fullTx.TxOut[idx].Value == output.Value {
return fullTx, nil
}
}
// Get all transactions, starting from block 0 and including unconfirmed
// TXes (end block = -1).
txs, err := m.cfg.TxSource.ListTransactions(ctx, 0, -1)
if err != nil {
return nil, err
}
for _, tx := range txs {
idx, ok := poolscript.LocateOutputScript(tx.Tx, output.PkScript)
if !ok {
continue
}
if tx.Tx.TxOut[idx].Value == output.Value {
return tx.Tx, nil
}
}
return nil, errTxNotFound
}
// locateTxByHash locates a transaction from the Manager's TxSource by its hash.
// If the transaction is not found, then errTxNotFound is returned.
func (m *manager) locateTxByHash(ctx context.Context,
hash chainhash.Hash) (*wire.MsgTx, error) {
// Get all transactions, starting from block 0 and including unconfirmed
// TXes (end block = -1).
txs, err := m.cfg.TxSource.ListTransactions(ctx, 0, -1)
if err != nil {
return nil, err
}
for _, tx := range txs {
if tx.Tx.TxHash() == hash {
return tx.Tx, nil
}
}
return nil, errTxNotFound
}
// handleStateOpen performs the necessary operations for accounts found in
// StateOpen.
func (m *manager) handleStateOpen(ctx context.Context, account *Account) error {
var traderKey [33]byte
copy(traderKey[:], account.TraderKey.PubKey.SerializeCompressed())
log.Infof("Watching spend of %v for account %x", account.OutPoint,
traderKey)
accountOutput, err := account.Output()
if err != nil {
return err
}
err = m.watcherCtrl.WatchAccountSpend(
account.TraderKey.PubKey, account.OutPoint,
accountOutput.PkScript, account.HeightHint,
)
if err != nil {
return fmt.Errorf("unable to watch for spend: %v", err)
}
m.watcherCtrl.WatchAccountExpiration(
account.TraderKey.PubKey, account.Expiry,
)
// Now that we have an open account, subscribe for updates to it to the
// server. We subscribe for the account instead of the individual orders
// because all signing operations will need to be executed on an account
// level anyway. And we might end up executing multiple orders for the
// same account in one batch. The messages from the server are received
// and dispatched to the correct manager by the rpcServer.
err = m.cfg.Auctioneer.StartAccountSubscription(ctx, account.TraderKey)
if err != nil {
return fmt.Errorf("unable to subscribe for account updates: %v",
err)
}
return nil
}
// HandleAccountConf takes the necessary steps after detecting the confirmation
// of an account on-chain.
func (m *manager) HandleAccountConf(traderKey *btcec.PublicKey,
confDetails *chainntnfs.TxConfirmation) error {
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return err
}
log.Infof("Account %x is now confirmed at height %v!",
traderKey.SerializeCompressed(), confDetails.BlockHeight)
// The new state we'll transition to depends on the account's current
// state.
var newState State
switch account.State {
// Any pending states will transition to their confirmed state.
case StatePendingOpen, StatePendingUpdate, StatePendingBatch:
newState = StateOpen
// An expired account with a pending update that has now confirmed will
// transition to the confirmed expired case, allowing a trader to renew
// their account.
case StateExpiredPendingUpdate:
newState = StateExpired
default:
return fmt.Errorf("unhandled state %v after confirmation",
account.State)
}
// Update the account's state and proceed with the rest of the flow.
mods := []Modifier{
StateModifier(newState),
HeightHintModifier(confDetails.BlockHeight),
}
if err := m.cfg.Store.UpdateAccount(account, mods...); err != nil {
return err
}
return m.handleStateOpen(context.Background(), account)
}
// HandleAccountSpend handles the different spend paths of an account. If an
// account is spent by the expiration path, it'll always be marked as closed
// thereafter. If it is spent by the cooperative path with the auctioneer, then
// the account will only remain open if the spending transaction recreates the
// account with the expected next account script. Otherwise, it is also marked
// as closed. In case of multiple consecutive batches with the same account, we
// only track the spend of the latest batch, after it confirmed. So the account
// output in the spend transaction should always match our database state if
// it was a cooperative spend.
func (m *manager) HandleAccountSpend(traderKey *btcec.PublicKey,
spendDetails *chainntnfs.SpendDetail) error {
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return err
}
// We'll need to perform different operations based on the witness of
// the spending input of the account.
spendTx := spendDetails.SpendingTx
spendWitness := spendTx.TxIn[spendDetails.SpenderInputIndex].Witness
switch {
// If the witness is for a spend of the account expiration path, then
// we'll mark the account as closed as the account has expired and all
// the funds have been withdrawn.
case poolscript.IsExpirySpend(spendWitness) ||
poolscript.IsTaprootExpirySpend(spendWitness):
break
// If the witness is for a multi-sig spend, then either an order by the
// trader was matched, the account was modified or the account was
// closed. If it was closed, then the account output shouldn't have been
// recreated.
case poolscript.IsMultiSigSpend(spendWitness) ||
poolscript.IsTaprootMultiSigSpend(spendWitness):
// If there's a pending batch which has yet to be completed,
// we'll mark it as so now. This can happen if the trader is not
// connected to the auctioneer when the auctioneer sends them
// the finalize message.
//
// We'll acquire the pending batch lock to ensure that there
// aren't multiple handleAccountSpend threads (in the case of
// multiple accounts participating in a batch) attempting to
// mark the same batch as complete and prevent entering into an
// erroneous state.
m.pendingBatchMtx.Lock()
err := m.cfg.Store.PendingBatch()
switch err {
// If there's no pending batch, we can proceed as normal.
case ErrNoPendingBatch:
break
// If there is, we'll commit it and refresh the account state.
case nil:
if err := m.cfg.Store.MarkBatchComplete(); err != nil {
m.pendingBatchMtx.Unlock()
return err
}
account, err = m.cfg.Store.Account(traderKey)
if err != nil {
m.pendingBatchMtx.Unlock()
return err
}
default:
m.pendingBatchMtx.Unlock()
return err
}
m.pendingBatchMtx.Unlock()
// An account cannot be spent without our knowledge, so we'll
// assume we always persist account updates before a broadcast
// of the spending transaction. Therefore, since we should
// already have the updates applied, we can just look for our
// current output in the transaction.
accountOutput, err := account.Output()
if err != nil {
return err
}
_, ok := poolscript.LocateOutputScript(
spendTx, accountOutput.PkScript,
)
if ok {
// Proceed with the rest of the flow. We won't send to
// the account output again, so we don't need to set
// a valid feeRate.
return m.resumeAccount(
context.Background(), account, false, false, 0,
)
}
default:
return fmt.Errorf("unknown spend witness %x", spendWitness)
}
log.Infof("Account %x has been closed on-chain with transaction %v",
account.TraderKey.PubKey.SerializeCompressed(), spendTx.TxHash())
// Write the spending transaction once again in case the one we
// previously broadcast was replaced with a higher fee one.
return m.cfg.Store.UpdateAccount(
account, StateModifier(StateClosed),
HeightHintModifier(uint32(spendDetails.SpendingHeight)),
LatestTxModifier(spendTx),
)
}
// HandleAccountExpiry marks an account as expired within the database.
func (m *manager) HandleAccountExpiry(traderKey *btcec.PublicKey,
height uint32) error {
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return err
}
var expiredState State
switch account.State {
// If the account has already been closed or is in the process of doing
// so, there's no need to mark it as expired.
case StatePendingClosed, StateClosed:
return nil
// If the account is waiting for a confirmation, use the expired state
// indicating so.
case StatePendingUpdate, StatePendingBatch:
expiredState = StateExpiredPendingUpdate
// If the account is confirmed, use the default expired state.
case StateOpen:
expiredState = StateExpired
default:
return fmt.Errorf("unhandled state %v after expiration",
account.State)
}
log.Infof("Account %x has expired as of height %v",
traderKey.SerializeCompressed(), account.Expiry)
return m.cfg.Store.UpdateAccount(account, StateModifier(expiredState))
}
// DepositAccount attempts to deposit funds into the account associated with the
// given trader key such that the new account value is met using inputs sourced
// from the backing lnd node's wallet. If needed, a change output that does back
// to lnd may be added to the deposit transaction.
func (m *manager) DepositAccount(ctx context.Context,
traderKey *btcec.PublicKey, depositAmount btcutil.Amount,
feeRate chainfee.SatPerKWeight, bestHeight, expiryHeight uint32,
newVersion Version) (*Account, *wire.MsgTx, error) {
// The account can only be modified in `StateOpen` and its new value
// should not exceed the maximum allowed.
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return nil, nil, err
}
if account.State != StateOpen {
return nil, nil, fmt.Errorf("account must be in %v to be "+
"modified", StateOpen)
}
// Can't downgrade an account.
if newVersion < account.Version {
return nil, nil, fmt.Errorf("cannot downgrade account "+
"version to %s", newVersion)
}
// The auctioneer defines the maximum account size.
terms, err := m.cfg.Auctioneer.Terms(ctx)
if err != nil {
return nil, nil, fmt.Errorf("could not query auctioneer "+
"terms: %v", err)
}
newAccountValue := account.Value + depositAmount
if newAccountValue > terms.MaxAccountValue {
return nil, nil, fmt.Errorf("new account value is above "+
"accepted maximum of %v", terms.MaxAccountValue)
}
var newExpiry *uint32
if expiryHeight != 0 {
// Validate the new expiry.
err := validateAccountExpiry(expiryHeight, bestHeight)
if err != nil {
return nil, nil, err
}
newExpiry = &expiryHeight
}
// TODO(wilmer): Reject if account has pending orders.
newAccountOutput, modifiers, err := createNewAccountOutput(
account, newAccountValue, newExpiry, &newVersion,
)
if err != nil {
return nil, nil, err
}
// To start, we'll need to perform coin selection in order to meet the
// required new value of the account as part of the deposit. The
// selected inputs, along with a change output if needed, will then be
// included in the deposit transaction we'll broadcast.
spendWitnessType := determineWitnessType(account, bestHeight)
packet, releaseInputs, err := m.inputsForDeposit(
ctx, account, newAccountOutput, depositAmount, spendWitnessType,
feeRate,
)
if err != nil {
return nil, nil, err
}
log.Tracef("Got funded PSBT packet %s", spew.Sdump(packet))
// We'll tack on the change output if it was needed and an additional
// `StatePendingUpdate` modifier to our account and proceed with the
// rest of the flow. This should request a signature from the auctioneer
// and assuming it's valid, broadcast the deposit transaction.
modifiers = append(modifiers, StateModifier(StatePendingUpdate))
modifiedAccount, spendTx, err := m.spendAccount(
ctx, account, DEPOSIT, packet, spendWitnessType, modifiers, bestHeight,
)
if err != nil {
releaseInputs()
return nil, nil, err
}
return modifiedAccount, spendTx, nil
}
// WithdrawAccount attempts to withdraw funds from the account associated with
// the given trader key into the provided outputs.
func (m *manager) WithdrawAccount(ctx context.Context,
traderKey *btcec.PublicKey, outputs []*wire.TxOut,
feeRate chainfee.SatPerKWeight, bestHeight, expiryHeight uint32,
newVersion Version) (*Account, *wire.MsgTx, error) {
// The account can only be modified in `StateOpen`.
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return nil, nil, err
}
if account.State != StateOpen {
return nil, nil, fmt.Errorf("account must be in %v to be "+
"modified", StateOpen)
}
// Can't downgrade an account.
if newVersion < account.Version {
return nil, nil, fmt.Errorf("cannot downgrade account "+
"version to %s", newVersion)
}
var newExpiry *uint32
if expiryHeight != 0 {
// Validate the new expiry.
err := validateAccountExpiry(expiryHeight, bestHeight)
if err != nil {
return nil, nil, err
}
newExpiry = &expiryHeight
}
// TODO(wilmer): Reject if account has pending orders.
// To start, we'll need to determine the new value of the account after
// creating the outputs specified as part of the withdrawal, which we'll
// then use to create the new account output.
spendWitnessType := determineWitnessType(account, bestHeight)
newAccountValue, err := valueAfterAccountUpdate(
account, outputs, spendWitnessType, feeRate,
)
if err != nil {
return nil, nil, err
}
newAccountOutput, modifiers, err := createNewAccountOutput(
account, newAccountValue, newExpiry, &newVersion,
)
if err != nil {
return nil, nil, err
}
allOutputs := []*wire.TxOut{newAccountOutput}
allOutputs = append(allOutputs, outputs...)
packet, err := m.createSpendTx(account, allOutputs)
if err != nil {
return nil, nil, err
}
// With the output created, we'll tack on an additional
// `StatePendingUpdate` modifier to our account and proceed with the
// rest of the flow. This should request a signature from the auctioneer
// and assuming it's valid, broadcast the withdrawal transaction.
modifiers = append(modifiers, StateModifier(StatePendingUpdate))
modifiedAccount, spendTx, err := m.spendAccount(
ctx, account, WITHDRAW, packet, spendWitnessType, modifiers, bestHeight,
)
if err != nil {
return nil, nil, err
}
return modifiedAccount, spendTx, nil
}
// RenewAccount updates the expiration of an open/expired account. This will
// always require a signature from the auctioneer, even after the account has
// expired, to ensure the auctioneer is aware the account is being renewed.
func (m *manager) RenewAccount(ctx context.Context,
traderKey *btcec.PublicKey, newExpiry uint32,
feeRate chainfee.SatPerKWeight, bestHeight uint32,
newVersion Version) (*Account, *wire.MsgTx, error) {
// The account can only have its expiry updated if it has confirmed
// and/or has expired.
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return nil, nil, err
}
switch account.State {
case StateOpen, StateExpired:
default:
return nil, nil, fmt.Errorf("account must be in either of %v "+
"to be renewed",
[]State{StateOpen, StateExpired})
}
// Validate the new expiry.
if err := validateAccountExpiry(newExpiry, bestHeight); err != nil {
return nil, nil, err
}
// Can't downgrade an account.
if newVersion < account.Version {
return nil, nil, fmt.Errorf("cannot downgrade account "+
"version to %s", newVersion)
}
// Determine the new account output after attempting the expiry update.
// We'll always use the multisig spend path, even if the account is
// expired, to make sure the auctioneer is aware of the change. We
// achieve this by setting the best height to 0 which means our account
// is never seen as expired.
spendWitnessType := multiSigWitness
if account.Version >= VersionTaprootEnabled {
spendWitnessType = muSig2Taproot
}
newAccountValue, err := valueAfterAccountUpdate(
account, nil, spendWitnessType, feeRate,
)
if err != nil {
return nil, nil, err
}
newAccountOutput, modifiers, err := createNewAccountOutput(
account, newAccountValue, &newExpiry, &newVersion,
)
if err != nil {
return nil, nil, err
}
packet, err := m.createSpendTx(account, []*wire.TxOut{newAccountOutput})
if err != nil {
return nil, nil, err
}
// With the output created, we'll tack on an additional
// `StatePendingUpdate` modifier to our account and proceed with the
// rest of the flow. This should request a signature from the auctioneer
// and assuming it's valid, broadcast the update transaction.
modifiers = append(modifiers, StateModifier(StatePendingUpdate))
modifiedAccount, spendTx, err := m.spendAccount(
ctx, account, RENEW, packet, spendWitnessType, modifiers, bestHeight,
)
if err != nil {
return nil, nil, err
}
// Begin to track the new account expiration, which will overwrite the
// existing expiration request.
m.watcherCtrl.WatchAccountExpiration(traderKey, modifiedAccount.Expiry)
return modifiedAccount, spendTx, nil
}
// BumpAccountFee attempts to bump the fee of an account's most recent
// transaction. This is done by locating an eligible output for lnd to CPFP,
// otherwise the fee bump will not succeed. Further invocations of this call for
// the same account will result in the child being replaced by the higher fee
// transaction (RBF).
func (m *manager) BumpAccountFee(ctx context.Context,
traderKey *btcec.PublicKey, newFeeRate chainfee.SatPerKWeight) error {
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return err
}
// Only accounts in pending states can have their transaction fees
// bumped.
switch account.State {
case StatePendingOpen, StatePendingUpdate, StatePendingClosed:
default:
return fmt.Errorf("cannot bump fee for account in state %v",
account.State)
}
// Since we're using lnd's sweeper for fee bumps, we'll need to find an
// output in the transaction under its control to perform the CPFP/RBF.
op := wire.OutPoint{Hash: account.LatestTx.TxHash()}
for i := range account.LatestTx.TxOut {
op.Index = uint32(i)
log.Debugf("Attempting CPFP with %v for account %x", op,
traderKey.SerializeCompressed())
err := m.cfg.Wallet.BumpFee(ctx, op, newFeeRate)
if err != nil {
// Output isn't known to lnd, continue to the next one.
// Unfortunately there are two slightly different error
// messages that can be returned, depending on what code
// path is taken.
if strings.Contains(err.Error(), lnwallet.ErrNotMine.Error()) {
continue
}
if strings.Contains(err.Error(), wallet.ErrNotMine.Error()) {
continue
}
// A fatal error occurred, return it.
return err
}
// Once we've found an eligible output, we can return.
log.Infof("Found eligible output %v for CPFP of account %x",
op, traderKey.SerializeCompressed())
return nil
}
// If we didn't find an eligible output, report it as an error.
return fmt.Errorf("transaction %v did not contain any eligible "+
"outputs to CPFP", op.Hash)
}
// CloseAccount attempts to close the account associated with the given trader
// key. Closing the account requires a signature of the auctioneer if the
// account has not yet expired. The account funds are swept according to the
// provided fee expression.
func (m *manager) CloseAccount(ctx context.Context, traderKey *btcec.PublicKey,
feeExpr FeeExpr, bestHeight uint32) (*wire.MsgTx, error) {
account, err := m.cfg.Store.Account(traderKey)
if err != nil {
return nil, err
}
// Make sure the account hasn't already been closed, or is in the
// process of doing so.
if account.State == StatePendingClosed || account.State == StateClosed {
return nil, errors.New("account has already been closed")
}
// Determine the appropriate witness type for the account input based on
// whether it's expired or not.
spendWitnessType := determineWitnessType(account, bestHeight)
scriptVersion := spendWitnessType.scriptVersion()
// We'll then use the fee expression to determine the closing
// transaction of the account.
//
// If a single output along with a fee rate was provided and the output
// script was not populated, we'll generate one from the backing lnd
// node's wallet.
if feeExpr, ok := feeExpr.(*OutputWithFee); ok && feeExpr.PkScript == nil {
// If the account is P2TR the wallet supports P2TR change
// outputs as well.
changeType := walletrpc.AddressType_WITNESS_PUBKEY_HASH
if scriptVersion == poolscript.VersionTaprootMuSig2 {
changeType = walletrpc.AddressType_TAPROOT_PUBKEY
}
addr, err := m.cfg.Wallet.NextAddr(ctx, "", changeType, false)
if err != nil {
return nil, err
}
feeExpr.PkScript, err = txscript.PayToAddrScript(addr)
if err != nil {
return nil, err
}
}
closeOutputs, err := feeExpr.CloseOutputs(
account.Value, spendWitnessType,
)
if err != nil {
return nil, err
}
packet, err := m.createSpendTx(account, closeOutputs)
if err != nil {
return nil, err
}
// Proceed to create the closing transaction and perform any operations
// thereby required.
modifiers := []Modifier{
ValueModifier(0), StateModifier(StatePendingClosed),
}
_, spendTx, err := m.spendAccount(
ctx, account, CLOSE, packet, spendWitnessType, modifiers, bestHeight,
)
if err != nil {
return nil, err
}
return spendTx, nil
}
// spendAccount houses most of the logic required to properly spend an account
// by creating the spending transaction, updating persisted account states,
// requesting a signature from the auctioneer if necessary, broadcasting the
// spending transaction, and finally watching for the new account state
// on-chain. These operations are performed in this order to ensure trader are
// able to resume the spend of an account upon restarts if they happen to
// shutdown mid-process.
func (m *manager) spendAccount(ctx context.Context, account *Account,
action Action, packet *psbt.Packet, witnessType witnessType,
modifiers []Modifier, bestHeight uint32) (*Account, *wire.MsgTx, error) {
// In case we're not closing the account, let's now locate our
// re-created account output.
accountOutputIdx := -1
if action != CLOSE {
// The account output should be recreated, so we need to locate
// the new account outpoint.
newAccountOutput, err := account.Copy(modifiers...).Output()
if err != nil {
return nil, nil, err
}
idx, ok := poolscript.LocateOutputScript(
packet.UnsignedTx, newAccountOutput.PkScript,
)
if !ok {
return nil, nil, fmt.Errorf("new account output "+
"script %x not found in spending transaction",
newAccountOutput.PkScript)
}
accountOutputIdx = int(idx)
// Make sure that we use the appropriate tx hash by including
// the signature scripts to the inputs that needed it.
unsignedTx, err := unsignedTxWithSignatureScripts(packet)
if err != nil {
return nil, nil, err
}
// The TX is finished now, only the witness is missing. So we
// can create the modifier for the new outpoint now already.
modifiers = append(modifiers, OutPointModifier(wire.OutPoint{
Hash: unsignedTx.TxHash(),
Index: uint32(accountOutputIdx),
}))
}
var lockTime uint32
switch witnessType {
case expiryWitness, expiryTaproot:
if action != CLOSE {
return nil, nil, errors.New("modifications for " +
"expired accounts are not currently supported")
}
lockTime = bestHeight
case multiSigWitness, muSig2Taproot:
lockTime = 0
default:
return nil, nil, fmt.Errorf("unhandled witness type: %v",
witnessType)
}
// Create the spending transaction of an account based on the provided
// witness type.
spendTx, err := m.signSpendTx(
ctx, account, packet, lockTime, witnessType, accountOutputIdx,
modifiers,
)
if err != nil {
return nil, nil, err
}
// Update the account's height hint and latest transaction.
modifiers = append(modifiers, HeightHintModifier(bestHeight))
modifiers = append(modifiers, LatestTxModifier(spendTx))
// With the transaction crafted, update our on-disk state and broadcast
// the transaction. We'll need some additional modifiers if the account
// is being modified.
prevAccountState := account.Copy()
if err := m.cfg.Store.UpdateAccount(account, modifiers...); err != nil {
return nil, nil, err
}
// Generate transaction label.
isExpirySpend := witnessType.IsExpirySpend()
txFee := deriveFeeFromPsbt(packet)
balanceDiff := account.Value - prevAccountState.Value
contextLabel := actionTxLabel(
account, action, isExpirySpend, &txFee, balanceDiff,
)
label := makeTxnLabel(m.cfg.TxLabelPrefix, contextLabel)
if err := m.maybeBroadcastTx(ctx, spendTx, label); err != nil {
return nil, nil, err
}
return account, spendTx, nil
}
// deriveFeeFromPsbt returns the transaction fee from a given PSBT packet.
func deriveFeeFromPsbt(packet *psbt.Packet) btcutil.Amount {
var sumInputs int64
for _, packageInput := range packet.Inputs {
sumInputs += packageInput.WitnessUtxo.Value
}
var sumOutputs int64
for _, txOut := range packet.UnsignedTx.TxOut {
sumOutputs += txOut.Value
}
fee := sumInputs - sumOutputs
return btcutil.Amount(fee)
}
// RecoverAccount re-introduces a recovered account into the database and starts
// all watchers necessary depending on the account's state.
func (m *manager) RecoverAccount(ctx context.Context, account *Account) error {
if account.TraderKey == nil || account.TraderKey.PubKey == nil {
return fmt.Errorf("account is missing trader key")
}
// The full trader key descriptor was restored previously and we can now
// derive the shared secret.
secret, err := m.cfg.Signer.DeriveSharedKey(
ctx, account.AuctioneerKey, &account.TraderKey.KeyLocator,
)
if err != nil {
return err
}
account.Secret = secret
// Now store it to the database and start our watchers according to the
// account's state.
err = m.cfg.Store.AddAccount(account)
if err != nil {
return err
}
// Now let's try to resume the account based on the state of it. We set
// the `onRestart` flag to false because that would try to re-publish
// the opening transaction in some cases which we don't want. Instead we
// set the `onRecovery` flag to true. We won't send to the account
// output again, so we don't need to set a valid funding freeRate.
return m.resumeAccount(ctx, account, false, true, 0)
}
// determineWitnessType determines the appropriate witness type to use for the
// spending transaction for an account based on its version and whether it has
// expired or not.
func determineWitnessType(account *Account, bestHeight uint32) witnessType {
switch account.Version {
case VersionTaprootEnabled, VersionMuSig2V100RC2:
if account.State == StateExpired ||
bestHeight >= account.Expiry {
return expiryTaproot
}
return muSig2Taproot
default:
if account.State == StateExpired ||
bestHeight >= account.Expiry {
return expiryWitness
}
return multiSigWitness
}
}
// getAuctioneerSig requests a signature from the auctioneer for the
// given spending transaction of an account and returns the fully constructed
// witness to spend the account input.
func (m *manager) getAuctioneerSig(ctx context.Context,
account *Account, spendTx *wire.MsgTx, accountInputIdx,
accountOutputIdx int, modifiers []Modifier, traderNonces []byte,
prevOutputs []*wire.TxOut) ([]byte, []byte, error) {
if accountOutputIdx < 0 {
// If the account is being closed, we shouldn't provide any
// modifiers.
return m.cfg.Auctioneer.ModifyAccount(
ctx, account, nil, spendTx.TxOut, nil, traderNonces,
prevOutputs,
)
}
// Otherwise, the account output is being re-created due to a
// modification, so we need to filter out its spent input and re-created
// output from the spending transaction as the auctioneer can
// reconstruct those themselves.
inputs := make([]*wire.TxIn, 0, len(spendTx.TxIn)-1)
inputs = append(inputs, spendTx.TxIn[:accountInputIdx]...)
inputs = append(inputs, spendTx.TxIn[accountInputIdx+1:]...)
outputs := make([]*wire.TxOut, 0, len(spendTx.TxOut)-1)
outputs = append(outputs, spendTx.TxOut[:accountOutputIdx]...)
outputs = append(outputs, spendTx.TxOut[accountOutputIdx+1:]...)
return m.cfg.Auctioneer.ModifyAccount(
ctx, account, inputs, outputs, modifiers, traderNonces,
prevOutputs,
)
}
// createSpendTx creates a PSBT that spends the current account output.
func (m *manager) createSpendTx(account *Account,
outputs []*wire.TxOut) (*psbt.Packet, error) {
tx := wire.NewMsgTx(2)
tx.TxOut = append(tx.TxOut, outputs...)
tx.TxIn = []*wire.TxIn{{
PreviousOutPoint: account.OutPoint,
}}
// The transaction should have its inputs and outputs sorted according
// to BIP-69.
txsort.InPlaceSort(tx)
packet, err := psbt.NewFromUnsignedTx(tx)
if err != nil {
return nil, err
}
packet.Inputs[0].WitnessUtxo, err = account.Output()
if err != nil {
return nil, err
}
return packet, nil
}
// signSpendTx creates the spending transaction of an account and signs it.
// If the spending transaction takes the expiration path, bestHeight is used as
// the lock time of the transaction, otherwise it is 0. The transaction has its
// inputs and outputs sorted according to BIP-69.
func (m *manager) signSpendTx(ctx context.Context, account *Account,
packet *psbt.Packet, lockTime uint32, witnessType witnessType,
accountOutputIndex int, modifiers []Modifier) (*wire.MsgTx, error) {
// lockTime is used as the lock time of the transaction in order to
// satisfy the output's CHECKLOCKTIMEVERIFY in case we're using the
// expiry witness.
packet.UnsignedTx.LockTime = lockTime
// Ensure the transaction crafted passes some basic sanity checks before
// we attempt to sign it.
err := sanityCheckAccountSpendTx(account, packet, witnessType)
if err != nil {
return nil, err
}
// Now let's try and add the signature for the account input that's
// being spent. Depending on the expiry of the account, this might need
// the cooperation of the auctioneer to get a second signature.
signedPacket, err := m.addAccountSpendSignature(
ctx, account, packet, witnessType, accountOutputIndex, modifiers,
)
if err != nil {
return nil, err
}
// We either have a single account input (renew, withdraw, close) that
// has a final script witness set or we have additional inputs (deposit)
// that we need to sign for now. We use the FinalizePsbt method for the
// additional inputs as they belong to the normal wallet and can be
// signed for without additional PSBT metadata fields.
var signedTx *wire.MsgTx
if signedPacket.IsComplete() {
err = psbt.MaybeFinalizeAll(signedPacket)
if err != nil {
return nil, fmt.Errorf("error finalizing PSBT: %v", err)
}
signedTx, err = psbt.Extract(signedPacket)
if err != nil {
return nil, fmt.Errorf("error extracting TX: %v", err)
}
} else {
// We should be able to extract the final TX now, even if the
// witness isn't yet fully correct just yet.
_, signedTx, err = m.cfg.Wallet.FinalizePsbt(
ctx, signedPacket, "",
)
if err != nil {
return nil, fmt.Errorf("error finalizing TX: %v", err)
}
}
return signedTx, nil
}
// addAccountSpendSignature returns a new PSBT packet with the final witness of
// the account input to spend fully populated.
func (m *manager) addAccountSpendSignature(ctx context.Context, account *Account,
packet *psbt.Packet, witnessType witnessType, accountOutputIndex int,
modifiers []Modifier) (*psbt.Packet, error) {
// Use a deep copy of the packet.UnsignedTx that includes the
// SignatureScripts so the auctioneer can calculate the proper
// outpoint for the account.
unsignedTx, err := unsignedTxWithSignatureScripts(packet)
if err != nil {
return nil, err
}
// Determine the new index of the account input now that we know we have
// the full transaction.
accountInputIdx, err := locateAccountInput(unsignedTx, account)
if err != nil {
return nil, err
}
// We now need to add all the PSBT meta information about our account
// input to the packet, even if we're going to sign the input using
// MuSig2.
controlBlock, err := m.decorateAccountInput(
account, packet, accountInputIdx,
)
if err != nil {
return nil, err
}
// Collect all previous outputs that we need to know in case we're
// signing a Taproot input.
prevOutputs := make([]*wire.TxOut, len(unsignedTx.TxIn))
for idx := range unsignedTx.TxIn {
prevOutputs[idx] = packet.Inputs[idx].WitnessUtxo
}
// The collaborative MuSig2 case is fairly simple when it comes to the
// witness. It's a single signature put on the stack. To get the
// final signature by combining the trader's and auctioneer's partial
// sigs is a bit more involved though.
if witnessType == muSig2Taproot {
combinedSig, err := m.signAccountMuSig2(
ctx, account, unsignedTx, accountOutputIndex,
accountInputIdx, modifiers, prevOutputs,
)
if err != nil {
return nil, fmt.Errorf("error creating account "+
"MuSig2 combined signature: %v", err)
}
pIn := &packet.Inputs[accountInputIdx]
witness := poolscript.SpendMuSig2Taproot(combinedSig)
pIn.FinalScriptWitness, err = serializeWitness(witness)
if err != nil {
return nil, fmt.Errorf("error serializing witness: %v",
err)
}
return packet, nil
}
// Let the wallet sign each input. This will add a partial signature for
// the account input which we'll later turn into the correct witness
// depending on the spend path.
signedPacket, err := m.cfg.Wallet.SignPsbt(ctx, packet)
if err != nil {
return nil, err
}
pIn := &signedPacket.Inputs[accountInputIdx]
witnessScript := pIn.WitnessScript
var ourSig []byte
switch witnessType {
case expiryTaproot:
if len(pIn.TaprootScriptSpendSig) != 1 {
return nil, fmt.Errorf("unexpected number of "+
"signatures in signed PSBT, got %d wanted 1",
len(pIn.PartialSigs))
}
ourSig = pIn.TaprootScriptSpendSig[0].Signature
if pIn.TaprootScriptSpendSig[0].SigHash != txscript.SigHashDefault {
ourSig = append(ourSig, byte(
pIn.TaprootScriptSpendSig[0].SigHash,
))
}
default:
if len(pIn.PartialSigs) != 1 {
return nil, fmt.Errorf("unexpected number of "+
"signatures in signed PSBT, got %d wanted 1",
len(pIn.PartialSigs))
}
ourSig = pIn.PartialSigs[0].Signature
}
// We temporarily set the final witness to the partial sig to allow the
// extraction of the final TX. Unless we're using the expiry path in
// which case we _can_ create the full and final witness.
switch witnessType {
case expiryTaproot:
pIn.FinalScriptWitness, err = serializeWitness(
poolscript.SpendExpiryTaproot(
witnessScript, ourSig, controlBlock,
),
)
case expiryWitness:
pIn.FinalScriptWitness, err = serializeWitness(
poolscript.SpendExpiry(witnessScript, ourSig),
)
case multiSigWitness:
// We're not signing a Taproot input, so we don't need to
// specify any trader nonces.
var auctioneerSig []byte
auctioneerSig, _, err = m.getAuctioneerSig(
ctx, account, unsignedTx, accountInputIdx,
accountOutputIndex, modifiers, nil, prevOutputs,
)
if err != nil {
return nil, err
}
witness := poolscript.SpendMultiSig(
witnessScript, ourSig, auctioneerSig,
)
pIn.FinalScriptWitness, err = serializeWitness(witness)
default:
return nil, fmt.Errorf("invalid state, should never get here")
}
if err != nil {
return nil, fmt.Errorf("error serializing witness: %v",
err)
}
return signedPacket, nil
}
// signAccountMuSig2 creates the combined MuSig2 signature to spend a Taproot
// account output through the collaborative key spend path. This sets up a
// MuSig2 signing session on the local signer instance and then asks the
// auctioneer to also send its partial signature.
func (m *manager) signAccountMuSig2(ctx context.Context, account *Account,
spendTx *wire.MsgTx, accountOutputIndex, accountInputIdx int,
modifiers []Modifier, previousOutputs []*wire.TxOut) ([]byte, error) {
sessionInfo, cleanup, err := poolscript.TaprootMuSig2SigningSession(
ctx, account.Version.ScriptVersion(), account.Expiry,
account.TraderKey.PubKey, account.BatchKey, account.Secret,
account.AuctioneerKey, m.cfg.Signer,
&account.TraderKey.KeyLocator, nil,
)
if err != nil {
return nil, err
}
auctioneerSigBytes, auctioneerNonceBytes, err := m.getAuctioneerSig(
ctx, account, spendTx, accountInputIdx, accountOutputIndex,
modifiers, sessionInfo.PublicNonce[:], previousOutputs,
)
if err != nil {
cleanup()
return nil, fmt.Errorf("error getting auctioneer MuSig2 "+
"partial signature: %v", err)
}
var (
remoteNonces poolscript.MuSig2Nonces
remotePartialSig [input.MuSig2PartialSigSize]byte
)
copy(remoteNonces[:], auctioneerNonceBytes)
copy(remotePartialSig[:], auctioneerSigBytes)
finalSig, err := poolscript.TaprootMuSig2Sign(
ctx, accountInputIdx, sessionInfo, m.cfg.Signer, spendTx,
previousOutputs, &remoteNonces, &remotePartialSig,
)
if err != nil {
cleanup()
return nil, fmt.Errorf("error signing batch TX: %v", err)
}
return finalSig, nil
}
// addBaseAccountModificationWeight adds the estimated weight units for a
// transaction that modifies an account by spending the current account input
// and creating the new account output according to the provided `witnessType`.
func addBaseAccountModificationWeight(weightEstimator *input.TxWeightEstimator,
witnessType witnessType) error {
witnessSize, err := witnessType.witnessSize()
if err != nil {
return err
}
weightEstimator.AddWitnessInput(witnessSize)
weightEstimator.AddP2WSHOutput()
return nil
}
// valueAfterAccountUpdate determines the new value of an account after
// processing a withdrawal to the specified outputs at the provided fee rate.
func valueAfterAccountUpdate(account *Account, outputs []*wire.TxOut,
witnessType witnessType,
feeRate chainfee.SatPerKWeight) (btcutil.Amount, error) {
// To determine the new value of the account, we'll need to subtract the
// values of all additional outputs and the resulting fee of the
// transaction, which we'll need to compute based on its weight.
//
// Right off the bat, we'll add weight estimates for the existing
// account output that we're spending, and the new account output being
// created.
var weightEstimator input.TxWeightEstimator
err := addBaseAccountModificationWeight(&weightEstimator, witnessType)
if err != nil {
return 0, err
}
// We'll then add the weight estimates for any additional outputs
// provided, keeping track of the total output value sum as we go.
var outputTotal btcutil.Amount
for _, out := range outputs {
// To determine the proper weight of the output, we'll need to
// know its type.
pkScript, err := txscript.ParsePkScript(out.PkScript)
if err != nil {
return 0, fmt.Errorf("unable to parse output script "+
"%x: %v", out.PkScript, err)
}
switch pkScript.Class() {
case txscript.ScriptHashTy:
weightEstimator.AddP2SHOutput()
case txscript.WitnessV0PubKeyHashTy:
weightEstimator.AddP2WKHOutput()
case txscript.WitnessV0ScriptHashTy:
weightEstimator.AddP2WSHOutput()
case txscript.WitnessV1TaprootTy:
weightEstimator.AddP2TROutput()
default:
return 0, fmt.Errorf("unsupported output script %x",
out.PkScript)
}
outputTotal += btcutil.Amount(out.Value)
}
// With the weight estimated, compute the fee, which we'll then subtract
// from our input total and ensure our new account value isn't below our
// required minimum.
fee := feeRate.FeeForWeight(weightEstimator.Weight())
newAccountValue := account.Value - outputTotal - fee
if newAccountValue < MinAccountValue {
return 0, fmt.Errorf("new account value is below accepted "+
"minimum of %v", MinAccountValue)
}
return newAccountValue, nil
}
// inputsForDeposit returns a list of inputs sources from the backing lnd node's
// wallet which we can use to satisfy an account deposit. A closure to release
// the inputs is also provided to use when coming across an unexpected failure.
// If needed, a change output from the backing lnd node's wallet may be returned
// as well.
func (m *manager) inputsForDeposit(ctx context.Context, account *Account,
newAccountOutput *wire.TxOut, depositAmount btcutil.Amount,
witnessType witnessType, feeRate chainfee.SatPerKWeight) (*psbt.Packet,
func(), error) {
// Unfortunately the FundPsbt call doesn't allow us to specify _any_
// inputs, otherwise it won't perform coin selection at all. So what we
// do instead is to fund our account output just for the funding amount
// plus whatever we need to pay for the additional input (which we know
// exactly how big it will be). Then we add the account input and its
// value to the account output.
var acctInputEstimator input.TxWeightEstimator
witnessSize, err := witnessType.witnessSize()
if err != nil {
return nil, nil, err
}
acctInputEstimator.AddWitnessInput(witnessSize)
acctInputFee := feeRate.FeeForWeight(acctInputEstimator.Weight())
outputToFund := &wire.TxOut{
Value: int64(depositAmount + acctInputFee),
PkScript: newAccountOutput.PkScript,
}
tplPacket, err := psbt.New(nil, []*wire.TxOut{outputToFund}, 2, 0, nil)
if err != nil {
return nil, nil, fmt.Errorf("error creating template PSBT: %v",
err)
}
var tplBytes bytes.Buffer
if err := tplPacket.Serialize(&tplBytes); err != nil {
return nil, nil, fmt.Errorf("error serializing template PSBT: "+
"%v", err)
}
packet, changeOutputIdx, lockedCoins, err := m.cfg.Wallet.FundPsbt(
ctx, &walletrpc.FundPsbtRequest{
Template: &walletrpc.FundPsbtRequest_Psbt{
Psbt: tplBytes.Bytes(),
},
MinConfs: 1,
Fees: &walletrpc.FundPsbtRequest_SatPerVbyte{
SatPerVbyte: uint64(
feeRate.FeePerKVByte() / 1000,
),
},
},
)
if err != nil {
return nil, nil, fmt.Errorf("error funding PSBT: %v", err)
}
releaseInputs := func() {
for _, coin := range lockedCoins {
var lockID wtxmgr.LockID
copy(lockID[:], coin.Id)
hash, _ := chainhash.NewHash(coin.Outpoint.TxidBytes)
op := wire.OutPoint{
Hash: *hash,
Index: coin.Outpoint.OutputIndex,
}
_ = m.cfg.Wallet.ReleaseOutput(ctx, lockID, op)
}
}
// Due to a bug in lnd 0.14.2 up to 0.15.0 we can't use SignPsbt for
// np2wkh inputs. Unfortunately our only choice in the case that we get
// such an input selected is to tell the user to upgrade or "migrate"
// their coins. We only check for lnd version > 0.15.0 because our
// minimum required version is 0.14.3 anyway.
lnd151 := &verrpc.Version{
AppMajor: 0,
AppMinor: 15,
AppPatch: 1,
}
err = lndclient.AssertVersionCompatible(m.cfg.LndVersion, lnd151)
isOldLnd := err != nil
// Unfortunately we can't send an input's sequence to the server, that
// field doesn't exist in the RPC. And the server always assumes a
// sequence of 0. So we need to overwrite the value that lnd set in the
// funding call, otherwise we arrive at a different sighash.
for idx := range packet.UnsignedTx.TxIn {
packet.UnsignedTx.TxIn[idx].Sequence = 0
// Abort if we have any np2wkh inputs with an old lnd.
if len(packet.Inputs[idx].RedeemScript) > 0 && isOldLnd {
releaseInputs()
return nil, nil, fmt.Errorf("due to a bug in lnd " +
"versions prior to v0.15.1-beta, depositing " +
"from np2wkh inputs is not possible; please " +
"upgrade your lnd or forward your coins to a " +
"native SegWit (p2wkh) address")
}
}
// Make sure the previous account is spent into a new output.
packet.UnsignedTx.TxIn = append(packet.UnsignedTx.TxIn, &wire.TxIn{
PreviousOutPoint: account.OutPoint,
})
packet.Inputs = append(packet.Inputs, psbt.PInput{})
// Make sure we have our account output in there and at the same time
// fix that output's value now to the actual account balance we want to
// end up at.
for idx, txOut := range packet.UnsignedTx.TxOut {
// Skip the change output if there is any.
if changeOutputIdx >= 0 && changeOutputIdx == int32(idx) {
continue
}
// If we get here, this _must_ be the account output. Otherwise,
// there is a weird output in the transaction, and we need to
// abort.
if !bytes.Equal(txOut.PkScript, newAccountOutput.PkScript) {
releaseInputs()
return nil, nil, fmt.Errorf("account output not " +
"found in funded packet")
}
// We expect the output to be funded to exactly the amount we
// specified.
if txOut.Value != int64(depositAmount+acctInputFee) {
releaseInputs()
return nil, nil, fmt.Errorf("account output funded "+
"with incorrect value %d, expected %d",
txOut.Value, depositAmount+acctInputFee)
}
packet.UnsignedTx.TxOut[idx].Value = newAccountOutput.Value
}
// We now need to make sure we sort the whole transaction according to
// BIP69.
if err := psbt.InPlaceSort(packet); err != nil {
releaseInputs()
return nil, nil, err
}
return packet, releaseInputs, nil
}
// createNewAccountOutput creates the next account output in the sequence using
// the new account value and optional new account expiry.
func createNewAccountOutput(account *Account, newAccountValue btcutil.Amount,
newAccountExpiry *uint32, newVersion *Version) (*wire.TxOut, []Modifier,
error) {
modifiers := []Modifier{
ValueModifier(newAccountValue),
IncrementBatchKey(),
}
if newAccountExpiry != nil {
modifiers = append(modifiers, ExpiryModifier(*newAccountExpiry))
}
if newVersion != nil && *newVersion > account.Version {
modifiers = append(modifiers, VersionModifier(*newVersion))
}
newAccountOutput, err := account.Copy(modifiers...).Output()
if err != nil {
return nil, nil, err
}
return newAccountOutput, modifiers, nil
}
// sanityCheckAccountSpendTx ensures that the spending transaction of an account
// is well-formed by performing various sanity checks on its inputs and outputs.
// It returns the total amount of fees in satoshis paid by the transaction.
func sanityCheckAccountSpendTx(account *Account, packet *psbt.Packet,
witnessType witnessType) error {
tx, err := unsignedTxWithSignatureScripts(packet)
if err != nil {
return err
}
err = blockchain.CheckTransactionSanity(btcutil.NewTx(tx))
if err != nil {
return err
}
// None of the outputs should be dust.
for _, output := range tx.TxOut {
if txrules.IsDustOutput(output, txrules.DefaultRelayFeePerKb) {
return fmt.Errorf("dust output %x", output.PkScript)
}
}
// CheckTransactionSanity doesn't have enough context to attempt fee
// calculation, but we do.
var (
inputTotal, outputTotal btcutil.Amount
witnessSize int64
)
for idx, inp := range tx.TxIn {
pIn := packet.Inputs[idx]
if inp.PreviousOutPoint == account.OutPoint {
inputTotal += account.Value
acctWitnessSize, err := witnessType.witnessSize()
if err != nil {
return err
}
witnessSize += int64(acctWitnessSize)
} else {
utxo := pIn.WitnessUtxo
inputTotal += btcutil.Amount(utxo.Value)
pkScript, err := txscript.ParsePkScript(utxo.PkScript)
if err != nil {
return err
}
switch pkScript.Class() {
case txscript.WitnessV0PubKeyHashTy:
witnessSize += input.P2WKHWitnessSize
case txscript.ScriptHashTy:
// The witness of a np2wkh input is the same as
// a p2wkh input. The only difference is the
// additional scriptSig which will be added to
// the TX before calculating its weight.
witnessSize += input.P2WKHWitnessSize
case txscript.WitnessV1TaprootTy:
witnessSize += 1 + 1 + schnorr.SignatureSize
default:
return fmt.Errorf("unsupported deposit input "+
"of class <%s>", pkScript.Class())
}
}
}
for _, output := range tx.TxOut {
outputTotal += btcutil.Amount(output.Value)
}
if inputTotal < outputTotal {
return fmt.Errorf("output value of %v exceeds input value "+
"of %v", outputTotal, inputTotal)
}
feesPaid := inputTotal - outputTotal
// The unsigned TX within the package doesn't have any witness set.
// We'll add the witness weight manually in the next step. Fortunately
// with the PSBT funding all the fees should've already been calculated
// properly before.
txWeightNoWitness := blockchain.GetTransactionWeight(btcutil.NewTx(tx))
// The witness size can be translated to weight directly, no scale
// factor is needed. But we need to add the 2 bytes for the marker and
// flag fields that weren't counted above because the unsigned TX has no
// witness.
fullWeight := txWeightNoWitness + 2 + witnessSize
minRelayFee := chainfee.FeePerKwFloor.FeeForWeight(
lntypes.WeightUnit(fullWeight),
)
if feesPaid < minRelayFee {
return fmt.Errorf("signed transaction only pays %d sats "+
"in fees while %d are required for relay", feesPaid,
minRelayFee)
}
return nil
}
// locateAccountInput locates the index of the account input in the provided
// transaction or returns an error.
func locateAccountInput(tx *wire.MsgTx, account *Account) (int, error) {
for i, txIn := range tx.TxIn {
if txIn.PreviousOutPoint == account.OutPoint {
return i, nil
}
}
return 0, errors.New("account input not found")
}
// unsignedTxWithSignatureScripts returns a deep copy of the unsigned tx
// in the packet but includes the SignatureScripts for the inputs that have
// an associated RedeemScript.
//
// Note: an unsigned tx with and without the related SignatureScripts have a
// different TxHash. However, SignatureScripts are not part of the data signed.
func unsignedTxWithSignatureScripts(packet *psbt.Packet) (*wire.MsgTx, error) {
tx := packet.UnsignedTx.Copy()
for idx := range packet.Inputs {
if len(packet.Inputs[idx].RedeemScript) > 0 {
builder := txscript.NewScriptBuilder()
builder.AddData(packet.Inputs[idx].RedeemScript)
sigScript, err := builder.Script()
if err != nil {
return nil, err
}
tx.TxIn[idx].SignatureScript = sigScript
}
}
return tx, nil
}
// decorateAccountInput signs the account input in the spending transaction of an
// account. If the account is being spent with cooperation of the auctioneer,
// their signature will be required as well.
func (m *manager) decorateAccountInput(account *Account, packet *psbt.Packet,
idx int) ([]byte, error) {
traderKeyTweak := poolscript.TraderKeyTweak(
account.BatchKey, account.Secret, account.TraderKey.PubKey,
)
witnessScript, err := poolscript.AccountWitnessScript(
account.Expiry, account.TraderKey.PubKey, account.AuctioneerKey,
account.BatchKey, account.Secret,
)
if err != nil {
return nil, err
}
accountOutput, err := account.Output()
if err != nil {
return nil, err
}
pIn := &packet.Inputs[idx]
pIn.WitnessUtxo = accountOutput
pIn.SighashType = sigHashForScript(accountOutput.PkScript)
pIn.WitnessScript = witnessScript
bip32Path := []uint32{
keychain.BIP0043Purpose + hdkeychain.HardenedKeyStart,
m.cfg.ChainParams.HDCoinType + hdkeychain.HardenedKeyStart,
uint32(account.TraderKey.Family) + hdkeychain.HardenedKeyStart,
0,
account.TraderKey.Index,
}
pIn.Bip32Derivation = []*psbt.Bip32Derivation{{
Bip32Path: bip32Path,
PubKey: account.TraderKey.PubKey.SerializeCompressed(),
}}
pIn.Unknowns = append(pIn.Unknowns, &psbt.Unknown{
Key: btcwallet.PsbtKeyTypeInputSignatureTweakSingle,
Value: traderKeyTweak,
})
var controlBlockBytes []byte
if account.Version >= VersionTaprootEnabled {
scriptVersion := account.Version.ScriptVersion()
aggregateKey, expiryScript, err := poolscript.TaprootKey(
scriptVersion, account.Expiry, account.TraderKey.PubKey,
account.AuctioneerKey, account.BatchKey, account.Secret,
)
if err != nil {
return nil, fmt.Errorf("error creating taproot key: %v",
err)
}
pIn.WitnessScript = expiryScript.Script
tapscript := input.TapscriptFullTree(
aggregateKey.PreTweakedKey, *expiryScript,
)
controlBlockBytes, err = tapscript.ControlBlock.ToBytes()
if err != nil {
return nil, fmt.Errorf("error serializing control "+
"block: %v", err)
}
expiryScriptLeafHash := expiryScript.TapHash()
pIn.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{{
XOnlyPubKey: schnorr.SerializePubKey(
account.TraderKey.PubKey,
),
LeafHashes: [][]byte{expiryScriptLeafHash[:]},
Bip32Path: bip32Path,
}}
pIn.TaprootLeafScript = []*psbt.TaprootTapLeafScript{{
ControlBlock: controlBlockBytes,
Script: expiryScript.Script,
LeafVersion: expiryScript.LeafVersion,
}}
}
return controlBlockBytes, nil
}
// validateAccountValue ensures that a trader has provided a sane account value
// for the creation of a new account.
func validateAccountValue(value, maxValue btcutil.Amount) error {
if value < MinAccountValue {
return fmt.Errorf("minimum account value allowed is %v",
MinAccountValue)
}
if value > maxValue {
return fmt.Errorf("maximum account value allowed is %v",
maxValue)
}
return nil
}
// validateAccountExpiry ensures that a trader has provided a sane account expiry
// for the creation/modification of an account.
func validateAccountExpiry(expiry, bestHeight uint32) error {
if expiry < bestHeight+minAccountExpiry {
return fmt.Errorf("current minimum account expiry allowed is "+
"height %v", bestHeight+minAccountExpiry)
}
if expiry > bestHeight+maxAccountExpiry {
return fmt.Errorf("current maximum account expiry allowed is "+
"height %v", bestHeight+maxAccountExpiry)
}
return nil
}
// validateAccountParams ensures that a trader has provided sane parameters for
// the creation of a new account.
func validateAccountParams(value, maxValue btcutil.Amount, expiry,
bestHeight uint32, version Version) error {
err := validateAccountValue(value, maxValue)
if err != nil {
return err
}
err = validateAccountExpiry(expiry, bestHeight)
if err != nil {
return err
}
return ValidateVersion(version)
}
// NumConfsForValue chooses an appropriate number of confirmations to wait for
// an account based on its initial value.
//
// TODO(wilmer): Determine the recommend number of blocks to wait for a
// particular output size given the current block reward and a user's "risk
// threshold" (basically a multiplier for the amount of work/fiat-burnt that
// would need to be done to undo N blocks).
func NumConfsForValue(value, maxAccountValue btcutil.Amount) uint32 {
confs := maxConfs * value / maxAccountValue
if confs < minConfs {
confs = minConfs
}
if confs > maxConfs {
confs = maxConfs
}
return uint32(confs)
}
// makeTxnLabel makes a transaction label for a given account given a static
// label prefix and a context-specific label.
func makeTxnLabel(labelPrefix, contextLabel string) string {
var label string
// If we have a label prefix, then we'll apply that now and leave a space
// at the end as well to separate it from the contextLabel.
if labelPrefix != "" {
label += labelPrefix + " "
}
label += contextLabel
// If after applying our context label, the label is too long (exceeds
// the 500 char limit), we'll truncate the label to ensure we continue
// operation, and send a warning message to the user.
if len(label) > wtxmgr.TxLabelLimit {
log.Warnf("label=%v is too long (size=%v, max_size=%v)",
label, len(label), wtxmgr.TxLabelLimit)
label = label[:wtxmgr.TxLabelLimit]
}
return label
}
// serializeWitness turns a wire witness into its serialized form.
func serializeWitness(witness wire.TxWitness) ([]byte, error) {
var buf bytes.Buffer
if err := psbt.WriteTxWitness(&buf, witness); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// sigHashForScript returns the sighash flag for the given UTXO's pkScript.
func sigHashForScript(pkScript []byte) txscript.SigHashType {
switch {
case txscript.IsPayToTaproot(pkScript):
return txscript.SigHashDefault
default:
return txscript.SigHashAll
}
}