diff --git a/discovery/gossiper.go b/discovery/gossiper.go index 9cff71c6e..d00ee8d52 100644 --- a/discovery/gossiper.go +++ b/discovery/gossiper.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "strings" "sync" "sync/atomic" "time" @@ -23,10 +24,13 @@ import ( "github.com/lightningnetwork/lnd/graph" graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnpeer" "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" + "github.com/lightningnetwork/lnd/lnwallet/chanvalidate" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/multimutex" "github.com/lightningnetwork/lnd/netann" @@ -79,6 +83,23 @@ var ( // the remote peer. ErrGossipSyncerNotFound = errors.New("gossip syncer not found") + // ErrNoFundingTransaction is returned when we are unable to find the + // funding transaction described by the short channel ID on chain. + ErrNoFundingTransaction = errors.New( + "unable to find the funding transaction", + ) + + // ErrInvalidFundingOutput is returned if the channel funding output + // fails validation. + ErrInvalidFundingOutput = errors.New( + "channel funding output validation failed", + ) + + // ErrChannelSpent is returned when we go to validate a channel, but + // the purported funding output has actually already been spent on + // chain. + ErrChannelSpent = errors.New("channel output has been spent") + // emptyPubkey is used to compare compressed pubkeys against an empty // byte array. emptyPubkey [33]byte @@ -364,6 +385,11 @@ type Config struct { // updates for a channel and returns true if the channel should be // considered a zombie based on these timestamps. IsStillZombieChannel func(time.Time, time.Time) bool + + // AssumeChannelValid toggles whether the gossiper will check for + // spent-ness of channel outpoints. For neutrino, this saves long + // rescans from blocking initial usage of the daemon. + AssumeChannelValid bool } // processedNetworkMsg is a wrapper around networkMsg and a boolean. It is @@ -2072,7 +2098,7 @@ func (d *AuthenticatedGossiper) processNetworkAnnouncement( // the existence of a channel and not yet the routing policies in // either direction of the channel. case *lnwire.ChannelAnnouncement1: - return d.handleChanAnnouncement(nMsg, msg, schedulerOp) + return d.handleChanAnnouncement(nMsg, msg, schedulerOp...) // A new authenticated channel edge update has arrived. This indicates // that the directional information for an already known channel has @@ -2453,7 +2479,7 @@ func (d *AuthenticatedGossiper) handleNodeAnnouncement(nMsg *networkMsg, // handleChanAnnouncement processes a new channel announcement. func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, ann *lnwire.ChannelAnnouncement1, - ops []batch.SchedulerOption) ([]networkMsg, bool) { + ops ...batch.SchedulerOption) ([]networkMsg, bool) { scid := ann.ShortChannelID @@ -2614,6 +2640,7 @@ func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, // If there were any optional message fields provided, we'll include // them in its serialized disk representation now. + var tapscriptRoot fn.Option[chainhash.Hash] if nMsg.optionalMsgFields != nil { if nMsg.optionalMsgFields.capacity != nil { edge.Capacity = *nMsg.optionalMsgFields.capacity @@ -2624,7 +2651,127 @@ func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, } // Optional tapscript root for custom channels. - edge.TapscriptRoot = nMsg.optionalMsgFields.tapscriptRoot + tapscriptRoot = nMsg.optionalMsgFields.tapscriptRoot + } + + // Before we start validation or add the edge to the database, we obtain + // the mutex for this channel ID. We do this to ensure no other + // goroutine has read the database and is now making decisions based on + // this DB state, before it writes to the DB. It also ensures that we + // don't perform the expensive validation check on the same channel + // announcement at the same time. + d.channelMtx.Lock(scid.ToUint64()) + + // If AssumeChannelValid is present, then we are unable to perform any + // of the expensive checks below, so we'll short-circuit our path + // straight to adding the edge to our graph. If the passed + // ShortChannelID is an alias, then we'll skip validation as it will + // not map to a legitimate tx. This is not a DoS vector as only we can + // add an alias ChannelAnnouncement from the gossiper. + if !(d.cfg.AssumeChannelValid || d.cfg.IsAlias(scid)) { //nolint:nestif + op, capacity, script, err := d.validateFundingTransaction( + ann, tapscriptRoot, + ) + if err != nil { + defer d.channelMtx.Unlock(scid.ToUint64()) + + switch { + case errors.Is(err, ErrNoFundingTransaction), + errors.Is(err, ErrInvalidFundingOutput): + + key := newRejectCacheKey( + scid.ToUint64(), + sourceToPub(nMsg.source), + ) + _, _ = d.recentRejects.Put( + key, &cachedReject{}, + ) + + // Increment the peer's ban score. We check + // isRemote so we don't actually ban the peer in + // case of a local bug. + if nMsg.isRemote { + d.banman.incrementBanScore( + nMsg.peer.PubKey(), + ) + } + + case errors.Is(err, ErrChannelSpent): + key := newRejectCacheKey( + scid.ToUint64(), + sourceToPub(nMsg.source), + ) + _, _ = d.recentRejects.Put(key, &cachedReject{}) + + // Since this channel has already been closed, + // we'll add it to the graph's closed channel + // index such that we won't attempt to do + // expensive validation checks on it again. + // TODO: Populate the ScidCloser by using closed + // channel notifications. + dbErr := d.cfg.ScidCloser.PutClosedScid(scid) + if dbErr != nil { + log.Errorf("failed to mark scid(%v) "+ + "as closed: %v", scid, dbErr) + + nMsg.err <- dbErr + + return nil, false + } + + // Increment the peer's ban score. We check + // isRemote so we don't accidentally ban + // ourselves in case of a bug. + if nMsg.isRemote { + d.banman.incrementBanScore( + nMsg.peer.PubKey(), + ) + } + + default: + // Otherwise, this is just a regular rejected + // edge. + key := newRejectCacheKey( + scid.ToUint64(), + sourceToPub(nMsg.source), + ) + _, _ = d.recentRejects.Put(key, &cachedReject{}) + } + + if !nMsg.isRemote { + log.Errorf("failed to add edge for local "+ + "channel: %v", err) + nMsg.err <- err + + return nil, false + } + + shouldDc, dcErr := d.ShouldDisconnect( + nMsg.peer.IdentityKey(), + ) + if dcErr != nil { + log.Errorf("failed to check if we should "+ + "disconnect peer: %v", dcErr) + nMsg.err <- dcErr + + return nil, false + } + + if shouldDc { + nMsg.peer.Disconnect(ErrPeerBanned) + } + + nMsg.err <- err + + return nil, false + } + + edge.FundingScript = fn.Some(script) + + // TODO(roasbeef): this is a hack, needs to be removed after + // commitment fees are dynamic. + edge.Capacity = capacity + edge.ChannelPoint = op } log.Debugf("Adding edge for short_chan_id: %v", scid.ToUint64()) @@ -2632,12 +2779,6 @@ func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, // We will add the edge to the channel router. If the nodes present in // this channel are not present in the database, a partial node will be // added to represent each node while we wait for a node announcement. - // - // Before we add the edge to the database, we obtain the mutex for this - // channel ID. We do this to ensure no other goroutine has read the - // database and is now making decisions based on this DB state, before - // it writes to the DB. - d.channelMtx.Lock(scid.ToUint64()) err = d.cfg.Graph.AddEdge(edge, ops...) if err != nil { log.Debugf("Graph rejected edge for short_chan_id(%v): %v", @@ -2648,8 +2789,7 @@ func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, // If the edge was rejected due to already being known, then it // may be the case that this new message has a fresh channel // proof, so we'll check. - switch { - case graph.IsError(err, graph.ErrIgnored): + if graph.IsError(err, graph.ErrIgnored) { // Attempt to process the rejected message to see if we // get any new announcements. anns, rErr := d.processRejectedEdge(ann, proof) @@ -2662,6 +2802,7 @@ func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, _, _ = d.recentRejects.Put(key, cr) nMsg.err <- rErr + return nil, false } @@ -2677,62 +2818,15 @@ func (d *AuthenticatedGossiper) handleChanAnnouncement(nMsg *networkMsg, nMsg.err <- nil return anns, true - - case errors.Is(err, graph.ErrNoFundingTransaction), - errors.Is(err, graph.ErrInvalidFundingOutput): - - key := newRejectCacheKey( - scid.ToUint64(), - sourceToPub(nMsg.source), - ) - _, _ = d.recentRejects.Put(key, &cachedReject{}) - - // Increment the peer's ban score. We check isRemote - // so we don't actually ban the peer in case of a local - // bug. - if nMsg.isRemote { - d.banman.incrementBanScore(nMsg.peer.PubKey()) - } - - case errors.Is(err, graph.ErrChannelSpent): - key := newRejectCacheKey( - scid.ToUint64(), - sourceToPub(nMsg.source), - ) - _, _ = d.recentRejects.Put(key, &cachedReject{}) - - // Since this channel has already been closed, we'll - // add it to the graph's closed channel index such that - // we won't attempt to do expensive validation checks - // on it again. - // TODO: Populate the ScidCloser by using closed - // channel notifications. - dbErr := d.cfg.ScidCloser.PutClosedScid(scid) - if dbErr != nil { - log.Errorf("failed to mark scid(%v) as "+ - "closed: %v", scid, dbErr) - - nMsg.err <- dbErr - - return nil, false - } - - // Increment the peer's ban score. We check isRemote - // so we don't accidentally ban ourselves in case of a - // bug. - if nMsg.isRemote { - d.banman.incrementBanScore(nMsg.peer.PubKey()) - } - - default: - // Otherwise, this is just a regular rejected edge. - key := newRejectCacheKey( - scid.ToUint64(), - sourceToPub(nMsg.source), - ) - _, _ = d.recentRejects.Put(key, &cachedReject{}) } + // Otherwise, this is just a regular rejected edge. + key := newRejectCacheKey( + scid.ToUint64(), + sourceToPub(nMsg.source), + ) + _, _ = d.recentRejects.Put(key, &cachedReject{}) + if !nMsg.isRemote { log.Errorf("failed to add edge for local channel: %v", err) @@ -3593,3 +3687,165 @@ func (d *AuthenticatedGossiper) ShouldDisconnect(pubkey *btcec.PublicKey) ( return false, nil } + +// validateFundingTransaction fetches the channel announcements claimed funding +// transaction from chain to ensure that it exists, is not spent and matches +// the channel announcement proof. The transaction's outpoint and value are +// returned if we can glean them from the work done in this method. +func (d *AuthenticatedGossiper) validateFundingTransaction( + ann *lnwire.ChannelAnnouncement1, + tapscriptRoot fn.Option[chainhash.Hash]) (wire.OutPoint, btcutil.Amount, + []byte, error) { + + scid := ann.ShortChannelID + + // Before we can add the channel to the channel graph, we need to obtain + // the full funding outpoint that's encoded within the channel ID. + fundingTx, err := lnwallet.FetchFundingTxWrapper( + d.cfg.ChainIO, &scid, d.quit, + ) + if err != nil { + //nolint:ll + // + // In order to ensure we don't erroneously mark a channel as a + // zombie due to an RPC failure, we'll attempt to string match + // for the relevant errors. + // + // * btcd: + // * https://github.com/btcsuite/btcd/blob/master/rpcserver.go#L1316 + // * https://github.com/btcsuite/btcd/blob/master/rpcserver.go#L1086 + // * bitcoind: + // * https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/rpc/blockchain.cpp#L770 + // * https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/rpc/blockchain.cpp#L954 + switch { + case strings.Contains(err.Error(), "not found"): + fallthrough + + case strings.Contains(err.Error(), "out of range"): + // If the funding transaction isn't found at all, then + // we'll mark the edge itself as a zombie so we don't + // continue to request it. We use the "zero key" for + // both node pubkeys so this edge can't be resurrected. + zErr := d.cfg.Graph.MarkZombieEdge(scid.ToUint64()) + if zErr != nil { + return wire.OutPoint{}, 0, nil, zErr + } + + default: + } + + return wire.OutPoint{}, 0, nil, fmt.Errorf("%w: %w", + ErrNoFundingTransaction, err) + } + + // Recreate witness output to be sure that declared in channel edge + // bitcoin keys and channel value corresponds to the reality. + fundingPkScript, err := makeFundingScript( + ann.BitcoinKey1[:], ann.BitcoinKey2[:], ann.Features, + tapscriptRoot, + ) + if err != nil { + return wire.OutPoint{}, 0, nil, err + } + + // Next we'll validate that this channel is actually well formed. If + // this check fails, then this channel either doesn't exist, or isn't + // the one that was meant to be created according to the passed channel + // proofs. + fundingPoint, err := chanvalidate.Validate( + &chanvalidate.Context{ + Locator: &chanvalidate.ShortChanIDChanLocator{ + ID: scid, + }, + MultiSigPkScript: fundingPkScript, + FundingTx: fundingTx, + }, + ) + if err != nil { + // Mark the edge as a zombie so we won't try to re-validate it + // on start up. + zErr := d.cfg.Graph.MarkZombieEdge(scid.ToUint64()) + if zErr != nil { + return wire.OutPoint{}, 0, nil, zErr + } + + return wire.OutPoint{}, 0, nil, fmt.Errorf("%w: %w", + ErrInvalidFundingOutput, err) + } + + // Now that we have the funding outpoint of the channel, ensure + // that it hasn't yet been spent. If so, then this channel has + // been closed so we'll ignore it. + chanUtxo, err := d.cfg.ChainIO.GetUtxo( + fundingPoint, fundingPkScript, scid.BlockHeight, d.quit, + ) + if err != nil { + if errors.Is(err, btcwallet.ErrOutputSpent) { + zErr := d.cfg.Graph.MarkZombieEdge(scid.ToUint64()) + if zErr != nil { + return wire.OutPoint{}, 0, nil, zErr + } + } + + return wire.OutPoint{}, 0, nil, fmt.Errorf("%w: unable to "+ + "fetch utxo for chan_id=%v, chan_point=%v: %w", + ErrChannelSpent, scid.ToUint64(), fundingPoint, err) + } + + return *fundingPoint, btcutil.Amount(chanUtxo.Value), fundingPkScript, + nil +} + +// makeFundingScript is used to make the funding script for both segwit v0 and +// segwit v1 (taproot) channels. +func makeFundingScript(bitcoinKey1, bitcoinKey2 []byte, + features *lnwire.RawFeatureVector, + tapscriptRoot fn.Option[chainhash.Hash]) ([]byte, error) { + + legacyFundingScript := func() ([]byte, error) { + witnessScript, err := input.GenMultiSigScript( + bitcoinKey1, bitcoinKey2, + ) + if err != nil { + return nil, err + } + pkScript, err := input.WitnessScriptHash(witnessScript) + if err != nil { + return nil, err + } + + return pkScript, nil + } + + if features.IsEmpty() { + return legacyFundingScript() + } + + chanFeatureBits := lnwire.NewFeatureVector(features, lnwire.Features) + if chanFeatureBits.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + ) { + + pubKey1, err := btcec.ParsePubKey(bitcoinKey1) + if err != nil { + return nil, err + } + pubKey2, err := btcec.ParsePubKey(bitcoinKey2) + if err != nil { + return nil, err + } + + fundingScript, _, err := input.GenTaprootFundingScript( + pubKey1, pubKey2, 0, tapscriptRoot, + ) + if err != nil { + return nil, err + } + + // TODO(roasbeef): add tapscript root to gossip v1.5 + + return fundingScript, nil + } + + return legacyFundingScript() +} diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go index afd07047a..cdcd6d249 100644 --- a/discovery/gossiper_test.go +++ b/discovery/gossiper_test.go @@ -27,15 +27,18 @@ import ( "github.com/lightningnetwork/lnd/graph" graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnmock" "github.com/lightningnetwork/lnd/lnpeer" "github.com/lightningnetwork/lnd/lntest/mock" "github.com/lightningnetwork/lnd/lntest/wait" + "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/netann" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/ticker" + tmock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -82,7 +85,6 @@ type mockGraphSource struct { edges map[uint64][]models.ChannelEdgePolicy zombies map[uint64][][33]byte chansToReject map[uint64]struct{} - addEdgeErr error } func newMockRouter(height uint32) *mockGraphSource { @@ -107,16 +109,29 @@ func (r *mockGraphSource) AddNode(node *models.LightningNode, return nil } +func (r *mockGraphSource) MarkZombieEdge(scid uint64) error { + return r.MarkEdgeZombie( + lnwire.NewShortChanIDFromInt(scid), [33]byte{}, [33]byte{}, + ) +} + +func (r *mockGraphSource) IsZombieEdge(chanID lnwire.ShortChannelID) (bool, + error) { + + r.mu.Lock() + defer r.mu.Unlock() + + _, ok := r.zombies[chanID.ToUint64()] + + return ok, nil +} + func (r *mockGraphSource) AddEdge(info *models.ChannelEdgeInfo, _ ...batch.SchedulerOption) error { r.mu.Lock() defer r.mu.Unlock() - if r.addEdgeErr != nil { - return r.addEdgeErr - } - if _, ok := r.infos[info.ChannelID]; ok { return errors.New("info already exist") } @@ -129,14 +144,6 @@ func (r *mockGraphSource) AddEdge(info *models.ChannelEdgeInfo, return nil } -func (r *mockGraphSource) resetAddEdgeErr() { - r.addEdgeErr = nil -} - -func (r *mockGraphSource) setAddEdgeErr(err error) { - r.addEdgeErr = err -} - func (r *mockGraphSource) queueValidationFail(chanID uint64) { r.mu.Lock() defer r.mu.Unlock() @@ -583,7 +590,7 @@ func createUpdateAnnouncement(blockHeight uint32, var err error - htlcMinMsat := lnwire.MilliSatoshi(prand.Int63()) + htlcMinMsat := lnwire.MilliSatoshi(100) a := &lnwire.ChannelUpdate1{ ShortChannelID: lnwire.ShortChannelID{ BlockHeight: blockHeight, @@ -627,8 +634,37 @@ func signUpdate(nodeKey *btcec.PrivateKey, a *lnwire.ChannelUpdate1) error { return nil } +// fundingTxPrepType determines how we will prep the mock Chain for calls during +// a test run. +type fundingTxPrepType int + +const ( + // fundingTxPrepTypeGood is the default type and will result in a valid + // block and funding transaction being returned by the mock Chain. + fundingTxPrepTypeGood fundingTxPrepType = iota + + // fundingTxPrepTypeNone will result in the mock Chain not being prepped + // for any calls. + fundingTxPrepTypeNone + + // fundingTxPrepTypeInvalidOutput will result in the mock Chain + // behaving such that the funding transaction it returns in a block is + // invalid. + fundingTxPrepTypeInvalidOutput + + // fundingTxPrepTypeNoTx will result in the mock Chain behaving such + // the desired block cannot be found. + fundingTxPrepTypeNoTx + + // fundingTxPrepTypeSpent will result in the mock Chain behaving such + // that the block is valid but the GetUtxo call will return a + // btcwallet.ErrOutputSpent error. + fundingTxPrepTypeSpent +) + type fundingTxOpts struct { - extraBytes []byte + extraBytes []byte + fundingTxPrep fundingTxPrepType } type fundingTxOption func(*fundingTxOpts) @@ -639,6 +675,12 @@ func withExtraBytes(extraBytes []byte) fundingTxOption { } } +func withFundingTxPrep(prep fundingTxPrepType) fundingTxOption { + return func(opts *fundingTxOpts) { + opts.fundingTxPrep = prep + } +} + func (ctx *testCtx) createAnnouncementWithoutProof(blockHeight uint32, key1, key2 *btcec.PublicKey, options ...fundingTxOption) *lnwire.ChannelAnnouncement1 { @@ -648,6 +690,59 @@ func (ctx *testCtx) createAnnouncementWithoutProof(blockHeight uint32, opt(&opts) } + switch opts.fundingTxPrep { + case fundingTxPrepTypeGood: + info := makeFundingTxInBlock(ctx.t) + + ctx.chain.On("GetBlockHash", int64(blockHeight)). + Return(&chainhash.Hash{}, nil).Once() + + ctx.chain.On("GetBlock", tmock.Anything). + Return(info.fundingBlock, nil).Once() + + ctx.chain.On( + "GetUtxo", tmock.Anything, tmock.Anything, + tmock.Anything, tmock.Anything, + ).Return(info.fundingTx, nil).Once() + + case fundingTxPrepTypeInvalidOutput: + ctx.chain.On( + "GetBlockHash", int64(blockHeight), + ).Return(&chainhash.Hash{}, nil).Once() + + ctx.chain.On( + "GetBlock", tmock.Anything, + ).Return( + &wire.MsgBlock{Transactions: []*wire.MsgTx{{}}}, nil, + ).Once() + + case fundingTxPrepTypeSpent: + info := makeFundingTxInBlock(ctx.t) + + ctx.chain.On( + "GetBlockHash", int64(blockHeight), + ).Return(&chainhash.Hash{}, nil).Once() + + ctx.chain.On( + "GetBlock", tmock.Anything, + ).Return(info.fundingBlock, nil).Once() + + ctx.chain.On( + "GetUtxo", tmock.Anything, tmock.Anything, + tmock.Anything, tmock.Anything, + ).Return(nil, btcwallet.ErrOutputSpent).Once() + + case fundingTxPrepTypeNoTx: + ctx.chain.On("GetBlockHash", int64(blockHeight)).Return( + &chainhash.Hash{}, nil, + ).Once() + ctx.chain.On("GetBlock", tmock.Anything).Return( + nil, fmt.Errorf("block not found"), + ).Once() + + case fundingTxPrepTypeNone: + } + a := &lnwire.ChannelAnnouncement1{ ShortChannelID: lnwire.ShortChannelID{ BlockHeight: blockHeight, @@ -665,6 +760,38 @@ func (ctx *testCtx) createAnnouncementWithoutProof(blockHeight uint32, return a } +type fundingTxInfo struct { + chanUtxo *wire.OutPoint + fundingBlock *wire.MsgBlock + fundingTx *wire.TxOut +} + +func makeFundingTxInBlock(t *testing.T) *fundingTxInfo { + fundingTx := wire.NewMsgTx(2) + _, tx, err := input.GenFundingPkScript( + bitcoinKeyPub1.SerializeCompressed(), + bitcoinKeyPub2.SerializeCompressed(), + int64(1000), + ) + require.NoError(t, err) + + fundingTx.TxOut = append(fundingTx.TxOut, tx) + chanUtxo := &wire.OutPoint{ + Hash: fundingTx.TxHash(), + Index: 0, + } + + block := &wire.MsgBlock{ + Transactions: []*wire.MsgTx{fundingTx}, + } + + return &fundingTxInfo{ + chanUtxo: chanUtxo, + fundingBlock: block, + fundingTx: tx, + } +} + func (ctx *testCtx) createRemoteChannelAnnouncement(blockHeight uint32, opts ...fundingTxOption) (*lnwire.ChannelAnnouncement1, error) { @@ -998,7 +1125,9 @@ func TestPrematureAnnouncement(t *testing.T) { // remote side, but block height of this announcement is greater than // highest know to us, for that reason it should be ignored and not // added to the router. - ca, err := ctx.createRemoteChannelAnnouncement(1) + ca, err := ctx.createRemoteChannelAnnouncement( + 1, withFundingTxPrep(fundingTxPrepTypeNone), + ) require.NoError(t, err, "can't create channel announcement") select { @@ -1823,7 +1952,9 @@ func TestDeDuplicatedAnnouncements(t *testing.T) { // Ensure that remote channel announcements are properly stored // and de-duplicated. - ca, err := ctx.createRemoteChannelAnnouncement(0) + ca, err := ctx.createRemoteChannelAnnouncement( + 0, withFundingTxPrep(fundingTxPrepTypeNone), + ) require.NoError(t, err, "can't create remote channel announcement") nodePeer := &mockPeer{bitcoinKeyPub2, nil, nil, atomic.Bool{}} @@ -1839,7 +1970,9 @@ func TestDeDuplicatedAnnouncements(t *testing.T) { // We'll create a second instance of the same announcement with the // same channel ID. Adding this shouldn't cause an increase in the // number of items as they should be de-duplicated. - ca2, err := ctx.createRemoteChannelAnnouncement(0) + ca2, err := ctx.createRemoteChannelAnnouncement( + 0, withFundingTxPrep(fundingTxPrepTypeNone), + ) require.NoError(t, err, "can't create remote channel announcement") announcements.AddMsgs(networkMsg{ msg: ca2, @@ -3599,11 +3732,18 @@ func TestProcessChannelAnnouncementOptionalMsgFields(t *testing.T) { ctx, err := createTestCtx(t, 0, false) require.NoError(t, err, "unable to create test context") + // We set AssumeValid to true for this test so that the full validation + // of a funding transaction is not done and ie, we don't fetch the + // channel capacity from the on-chain transaction. + ctx.gossiper.cfg.AssumeChannelValid = true + chanAnn1 := ctx.createAnnouncementWithoutProof( 100, selfKeyDesc.PubKey, remoteKeyPub1, + withFundingTxPrep(fundingTxPrepTypeNone), ) chanAnn2 := ctx.createAnnouncementWithoutProof( 101, selfKeyDesc.PubKey, remoteKeyPub1, + withFundingTxPrep(fundingTxPrepTypeNone), ) // assertOptionalMsgFields is a helper closure that ensures the optional @@ -4228,21 +4368,22 @@ func TestChanAnnBanningNonChanPeer(t *testing.T) { remoteKeyPriv2.PubKey(), nil, nil, atomic.Bool{}, } - ctx.router.setAddEdgeErr(graph.ErrInvalidFundingOutput) - // Loop 100 times to get nodePeer banned. for i := 0; i < 100; i++ { // Craft a valid channel announcement for a channel we don't // have. We will ensure that it fails validation by modifying - // the router. - ca, err := ctx.createRemoteChannelAnnouncement(uint32(i)) + // the tx script. + ca, err := ctx.createRemoteChannelAnnouncement( + uint32(i), + withFundingTxPrep(fundingTxPrepTypeInvalidOutput), + ) require.NoError(t, err, "can't create channel announcement") select { case err = <-ctx.gossiper.ProcessRemoteAnnouncement( ca, nodePeer1, ): - require.ErrorIs(t, err, graph.ErrInvalidFundingOutput) + require.ErrorIs(t, err, ErrInvalidFundingOutput) case <-time.After(2 * time.Second): t.Fatalf("remote announcement not processed") @@ -4255,16 +4396,16 @@ func TestChanAnnBanningNonChanPeer(t *testing.T) { // Assert that nodePeer has been disconnected. require.True(t, nodePeer1.disconnected.Load()) - ca, err := ctx.createRemoteChannelAnnouncement(101) + // Mark the UTXO as spent so that we get the ErrChannelSpent error and + // can thus tests that the gossiper ignores closed channels. + ca, err := ctx.createRemoteChannelAnnouncement( + 101, withFundingTxPrep(fundingTxPrepTypeSpent), + ) require.NoError(t, err, "can't create channel announcement") - // Set the error to ErrChannelSpent so that we can test that the - // gossiper ignores closed channels. - ctx.router.setAddEdgeErr(graph.ErrChannelSpent) - select { case err = <-ctx.gossiper.ProcessRemoteAnnouncement(ca, nodePeer2): - require.ErrorIs(t, err, graph.ErrChannelSpent) + require.ErrorIs(t, err, ErrChannelSpent) case <-time.After(2 * time.Second): t.Fatalf("remote announcement not processed") @@ -4285,13 +4426,15 @@ func TestChanAnnBanningNonChanPeer(t *testing.T) { ctx.gossiper.recentRejects.Delete(key) - // Reset the AddEdge error and pass the same announcement again. An - // error should be returned even though AddEdge won't fail. - ctx.router.resetAddEdgeErr() + // The validateFundingTransaction method will mark this channel + // as a zombie if any error occurs in the chanvalidate.Validate call. + // For the sake of the rest of the test, however, we mark it as live + // here. + _ = ctx.router.MarkEdgeLive(ca.ShortChannelID) select { case err = <-ctx.gossiper.ProcessRemoteAnnouncement(ca, nodePeer2): - require.NotNil(t, err) + require.ErrorContains(t, err, "ignoring closed channel") case <-time.After(2 * time.Second): t.Fatalf("remote announcement not processed") @@ -4308,21 +4451,22 @@ func TestChanAnnBanningChanPeer(t *testing.T) { nodePeer := &mockPeer{remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{}} - ctx.router.setAddEdgeErr(graph.ErrInvalidFundingOutput) - // Loop 100 times to get nodePeer banned. for i := 0; i < 100; i++ { // Craft a valid channel announcement for a channel we don't // have. We will ensure that it fails validation by modifying // the router. - ca, err := ctx.createRemoteChannelAnnouncement(uint32(i)) + ca, err := ctx.createRemoteChannelAnnouncement( + uint32(i), + withFundingTxPrep(fundingTxPrepTypeInvalidOutput), + ) require.NoError(t, err, "can't create channel announcement") select { case err = <-ctx.gossiper.ProcessRemoteAnnouncement( ca, nodePeer, ): - require.ErrorIs(t, err, graph.ErrInvalidFundingOutput) + require.ErrorIs(t, err, ErrInvalidFundingOutput) case <-time.After(2 * time.Second): t.Fatalf("remote announcement not processed") @@ -4335,3 +4479,75 @@ func TestChanAnnBanningChanPeer(t *testing.T) { // Assert that the peer wasn't disconnected. require.False(t, nodePeer.disconnected.Load()) } + +// TestChannelOnChainRejectionZombie tests that if we fail validating a channel +// due to some sort of on-chain rejection (no funding transaction, or invalid +// UTXO), then we'll mark the channel as a zombie. +func TestChannelOnChainRejectionZombie(t *testing.T) { + t.Parallel() + + ctx, err := createTestCtx(t, 1000, true) + require.NoError(t, err) + + // To start, we'll make an edge for the channel, but we won't add the + // funding transaction to the mock blockchain, which should cause the + // validation to fail below. + chanAnn, err := ctx.createRemoteChannelAnnouncement( + 1, withFundingTxPrep(fundingTxPrepTypeNoTx), + ) + require.NoError(t, err) + + // We expect this to fail as the transaction isn't present in the + // chain (nor the block). + assertChanChainRejection(t, ctx, chanAnn, ErrNoFundingTransaction) + + // Next, we'll make another channel edge, but actually add it to the + // graph this time. + chanAnn, err = ctx.createRemoteChannelAnnouncement( + 2, withFundingTxPrep(fundingTxPrepTypeSpent), + ) + require.NoError(t, err) + + // Instead now, we'll remove it from the set of UTXOs which should + // cause the spentness validation to fail. + assertChanChainRejection(t, ctx, chanAnn, ErrChannelSpent) + + // If we cause the funding transaction the chain to fail validation, we + // should see similar behavior. + chanAnn, err = ctx.createRemoteChannelAnnouncement( + 3, withFundingTxPrep(fundingTxPrepTypeInvalidOutput), + ) + require.NoError(t, err) + assertChanChainRejection(t, ctx, chanAnn, ErrInvalidFundingOutput) +} + +func assertChanChainRejection(t *testing.T, ctx *testCtx, + edge *lnwire.ChannelAnnouncement1, expectedErr error) { + + t.Helper() + + nodePeer := &mockPeer{bitcoinKeyPub2, nil, nil, atomic.Bool{}} + errChan := make(chan error, 1) + nMsg := &networkMsg{ + msg: edge, + isRemote: true, + peer: nodePeer, + source: nodePeer.IdentityKey(), + err: errChan, + } + + _, added := ctx.gossiper.handleChanAnnouncement(nMsg, edge) + require.False(t, added) + + select { + case err := <-errChan: + require.ErrorIs(t, err, expectedErr) + case <-time.After(2 * time.Second): + t.Fatal("channel announcement not processed") + } + + // This channel should now be present in the zombie channel index. + isZombie, err := ctx.router.IsZombieEdge(edge.ShortChannelID) + require.NoError(t, err) + require.True(t, isZombie, "edge should be marked as zombie") +} diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md index 315176272..f5dadbe57 100644 --- a/docs/release-notes/release-notes-0.19.0.md +++ b/docs/release-notes/release-notes-0.19.0.md @@ -251,10 +251,10 @@ The underlying functionality between those two options remain the same. * [Golang was updated to `v1.22.11`](https://github.com/lightningnetwork/lnd/pull/9462). -* Various refactors and preparations to simplify the - `graph.Builder` and to move the funding tx validation to the gossiper. +* Move funding transaction validation to the gossiper [1](https://github.com/lightningnetwork/lnd/pull/9476) [2](https://github.com/lightningnetwork/lnd/pull/9477) + [3](https://github.com/lightningnetwork/lnd/pull/9478). ## Breaking Changes diff --git a/graph/builder.go b/graph/builder.go index db73c9272..572bcd5d8 100644 --- a/graph/builder.go +++ b/graph/builder.go @@ -1,29 +1,21 @@ package graph import ( - "bytes" "fmt" - "strings" "sync" "sync/atomic" "time" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/go-errors/errors" "github.com/lightningnetwork/lnd/batch" "github.com/lightningnetwork/lnd/chainntnfs" - "github.com/lightningnetwork/lnd/fn/v2" graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/graph/db/models" - "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" - "github.com/lightningnetwork/lnd/lnwallet/btcwallet" - "github.com/lightningnetwork/lnd/lnwallet/chanvalidate" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/multimutex" "github.com/lightningnetwork/lnd/netann" @@ -95,9 +87,9 @@ type Config struct { // blocked by this job. FirstTimePruneDelay time.Duration - // AssumeChannelValid toggles whether the router will check for - // spentness of channel outpoints. For neutrino, this saves long rescans - // from blocking initial usage of the daemon. + // AssumeChannelValid toggles whether the builder will prune channels + // based on their spentness vs using the fact that they are considered + // zombies. AssumeChannelValid bool // StrictZombiePruning determines if we attempt to prune zombie @@ -1008,9 +1000,9 @@ func (b *Builder) assertNodeAnnFreshness(node route.Vertex, return nil } -// addZombieEdge adds a channel that failed complete validation into the zombie +// MarkZombieEdge adds a channel that failed complete validation into the zombie // index so we can avoid having to re-validate it in the future. -func (b *Builder) addZombieEdge(chanID uint64) error { +func (b *Builder) MarkZombieEdge(chanID uint64) error { // If the edge fails validation we'll mark the edge itself as a zombie // so we don't continue to request it. We use the "zero key" for both // node pubkeys so this edge can't be resurrected. @@ -1024,72 +1016,6 @@ func (b *Builder) addZombieEdge(chanID uint64) error { return nil } -// makeFundingScript is used to make the funding script for both segwit v0 and -// segwit v1 (taproot) channels. -// -// TODO(roasbeef: export and use elsewhere? -func makeFundingScript(bitcoinKey1, bitcoinKey2 []byte, chanFeatures []byte, - tapscriptRoot fn.Option[chainhash.Hash]) ([]byte, error) { - - legacyFundingScript := func() ([]byte, error) { - witnessScript, err := input.GenMultiSigScript( - bitcoinKey1, bitcoinKey2, - ) - if err != nil { - return nil, err - } - pkScript, err := input.WitnessScriptHash(witnessScript) - if err != nil { - return nil, err - } - - return pkScript, nil - } - - if len(chanFeatures) == 0 { - return legacyFundingScript() - } - - // In order to make the correct funding script, we'll need to parse the - // chanFeatures bytes into a feature vector we can interact with. - rawFeatures := lnwire.NewRawFeatureVector() - err := rawFeatures.Decode(bytes.NewReader(chanFeatures)) - if err != nil { - return nil, fmt.Errorf("unable to parse chan feature "+ - "bits: %w", err) - } - - chanFeatureBits := lnwire.NewFeatureVector( - rawFeatures, lnwire.Features, - ) - if chanFeatureBits.HasFeature( - lnwire.SimpleTaprootChannelsOptionalStaging, - ) { - - pubKey1, err := btcec.ParsePubKey(bitcoinKey1) - if err != nil { - return nil, err - } - pubKey2, err := btcec.ParsePubKey(bitcoinKey2) - if err != nil { - return nil, err - } - - fundingScript, _, err := input.GenTaprootFundingScript( - pubKey1, pubKey2, 0, tapscriptRoot, - ) - if err != nil { - return nil, err - } - - // TODO(roasbeef): add tapscript root to gossip v1.5 - - return fundingScript, nil - } - - return legacyFundingScript() -} - // routingMsg couples a routing related routing topology update to the // error channel. type routingMsg struct { @@ -1258,137 +1184,47 @@ func (b *Builder) addEdge(edge *models.ChannelEdgeInfo, edge.ChannelID) } - // If AssumeChannelValid is present, then we are unable to perform any - // of the expensive checks below, so we'll short-circuit our path - // straight to adding the edge to our graph. If the passed - // ShortChannelID is an alias, then we'll skip validation as it will - // not map to a legitimate tx. This is not a DoS vector as only we can - // add an alias ChannelAnnouncement from the gossiper. + if err := b.cfg.Graph.AddChannelEdge(edge, op...); err != nil { + return fmt.Errorf("unable to add edge: %w", err) + } + + b.stats.incNumEdgesDiscovered() + + // If AssumeChannelValid is present, of if the SCID is an alias, then + // the gossiper would not have done the expensive work of fetching + // a funding transaction and validating it. So we won't have the channel + // capacity nor the funding script. So we just log and return here. scid := lnwire.NewShortChanIDFromInt(edge.ChannelID) if b.cfg.AssumeChannelValid || b.cfg.IsAlias(scid) { - err := b.cfg.Graph.AddChannelEdge(edge, op...) - if err != nil { - return fmt.Errorf("unable to add edge: %w", err) - } log.Tracef("New channel discovered! Link connects %x and %x "+ "with ChannelID(%v)", edge.NodeKey1Bytes, edge.NodeKey2Bytes, edge.ChannelID) - b.stats.incNumEdgesDiscovered() return nil } - // Before we can add the channel to the channel graph, we need to obtain - // the full funding outpoint that's encoded within the channel ID. - channelID := lnwire.NewShortChanIDFromInt(edge.ChannelID) - fundingTx, err := lnwallet.FetchFundingTxWrapper( - b.cfg.Chain, &channelID, b.quit, - ) - if err != nil { - //nolint:ll - // - // In order to ensure we don't erroneously mark a channel as a - // zombie due to an RPC failure, we'll attempt to string match - // for the relevant errors. - // - // * btcd: - // * https://github.com/btcsuite/btcd/blob/master/rpcserver.go#L1316 - // * https://github.com/btcsuite/btcd/blob/master/rpcserver.go#L1086 - // * bitcoind: - // * https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/rpc/blockchain.cpp#L770 - // * https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/rpc/blockchain.cpp#L954 - switch { - case strings.Contains(err.Error(), "not found"): - fallthrough + log.Debugf("New channel discovered! Link connects %x and %x with "+ + "ChannelPoint(%v): chan_id=%v, capacity=%v", edge.NodeKey1Bytes, + edge.NodeKey2Bytes, edge.ChannelPoint, edge.ChannelID, + edge.Capacity) - case strings.Contains(err.Error(), "out of range"): - // If the funding transaction isn't found at all, then - // we'll mark the edge itself as a zombie so we don't - // continue to request it. We use the "zero key" for - // both node pubkeys so this edge can't be resurrected. - zErr := b.addZombieEdge(edge.ChannelID) - if zErr != nil { - return zErr - } - - default: - } - - return fmt.Errorf("%w: %w", ErrNoFundingTransaction, err) - } - - // Recreate witness output to be sure that declared in channel edge - // bitcoin keys and channel value corresponds to the reality. - fundingPkScript, err := makeFundingScript( - edge.BitcoinKey1Bytes[:], edge.BitcoinKey2Bytes[:], - edge.Features, edge.TapscriptRoot, - ) + // Otherwise, then we expect the funding script to be present on the + // edge since it would have been fetched when the gossiper validated the + // announcement. + fundingPkScript, err := edge.FundingScript.UnwrapOrErr(fmt.Errorf( + "expected the funding transaction script to be set", + )) if err != nil { return err } - // Next we'll validate that this channel is actually well formed. If - // this check fails, then this channel either doesn't exist, or isn't - // the one that was meant to be created according to the passed channel - // proofs. - fundingPoint, err := chanvalidate.Validate( - &chanvalidate.Context{ - Locator: &chanvalidate.ShortChanIDChanLocator{ - ID: channelID, - }, - MultiSigPkScript: fundingPkScript, - FundingTx: fundingTx, - }, - ) - if err != nil { - // Mark the edge as a zombie so we won't try to re-validate it - // on start up. - if err := b.addZombieEdge(edge.ChannelID); err != nil { - return err - } - - return fmt.Errorf("%w: %w", ErrInvalidFundingOutput, err) - } - - // Now that we have the funding outpoint of the channel, ensure - // that it hasn't yet been spent. If so, then this channel has - // been closed so we'll ignore it. - chanUtxo, err := b.cfg.Chain.GetUtxo( - fundingPoint, fundingPkScript, channelID.BlockHeight, b.quit, - ) - if err != nil { - if errors.Is(err, btcwallet.ErrOutputSpent) { - zErr := b.addZombieEdge(edge.ChannelID) - if zErr != nil { - return zErr - } - } - - return fmt.Errorf("%w: unable to fetch utxo for chan_id=%v, "+ - "chan_point=%v: %w", ErrChannelSpent, scid.ToUint64(), - fundingPoint, err) - } - - // TODO(roasbeef): this is a hack, needs to be removed after commitment - // fees are dynamic. - edge.Capacity = btcutil.Amount(chanUtxo.Value) - edge.ChannelPoint = *fundingPoint - if err := b.cfg.Graph.AddChannelEdge(edge, op...); err != nil { - return errors.Errorf("unable to add edge: %v", err) - } - - log.Debugf("New channel discovered! Link connects %x and %x with "+ - "ChannelPoint(%v): chan_id=%v, capacity=%v", edge.NodeKey1Bytes, - edge.NodeKey2Bytes, fundingPoint, edge.ChannelID, edge.Capacity) - b.stats.incNumEdgesDiscovered() - // As a new edge has been added to the channel graph, we'll update the // current UTXO filter within our active FilteredChainView so we are // notified if/when this channel is closed. filterUpdate := []graphdb.EdgePoint{ { FundingPkScript: fundingPkScript, - OutPoint: *fundingPoint, + OutPoint: edge.ChannelPoint, }, } @@ -1630,6 +1466,16 @@ func (b *Builder) IsKnownEdge(chanID lnwire.ShortChannelID) bool { return exists || isZombie } +// IsZombieEdge returns true if the graph source has marked the given channel ID +// as a zombie edge. +// +// NOTE: This method is part of the ChannelGraphSource interface. +func (b *Builder) IsZombieEdge(chanID lnwire.ShortChannelID) (bool, error) { + _, _, _, isZombie, err := b.cfg.Graph.HasChannelEdge(chanID.ToUint64()) + + return isZombie, err +} + // IsStaleEdgePolicy returns true if the graph source has a channel edge for // the passed channel ID (and flags) that have a more recent timestamp. // diff --git a/graph/builder_test.go b/graph/builder_test.go index 1818d495b..b813c17e2 100644 --- a/graph/builder_test.go +++ b/graph/builder_test.go @@ -20,6 +20,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/go-errors/errors" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch" @@ -52,8 +53,8 @@ func TestAddProof(t *testing.T) { // In order to be able to add the edge we should have a valid funding // UTXO within the blockchain. - fundingTx, _, chanID, err := createChannelEdge( - ctx, bitcoinKey1.SerializeCompressed(), + script, fundingTx, _, chanID, err := createChannelEdge( + bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), 100, 0, ) require.NoError(t, err, "unable create channel edge") @@ -68,6 +69,7 @@ func TestAddProof(t *testing.T) { NodeKey1Bytes: node1.PubKeyBytes, NodeKey2Bytes: node2.PubKeyBytes, AuthProof: nil, + FundingScript: fn.Some(script), } copy(edge.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -136,8 +138,8 @@ func TestIgnoreChannelEdgePolicyForUnknownChannel(t *testing.T) { // Add the edge between the two unknown nodes to the graph, and check // that the nodes are found after the fact. - fundingTx, _, chanID, err := createChannelEdge( - ctx, bitcoinKey1.SerializeCompressed(), + script, fundingTx, _, chanID, err := createChannelEdge( + bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), 10000, 500, ) require.NoError(t, err, "unable to create channel edge") @@ -153,6 +155,7 @@ func TestIgnoreChannelEdgePolicyForUnknownChannel(t *testing.T) { BitcoinKey1Bytes: pub1, BitcoinKey2Bytes: pub2, AuthProof: nil, + FundingScript: fn.Some(script), } edgePolicy := &models.ChannelEdgePolicy{ SigBytes: testSig.Serialize(), @@ -199,22 +202,25 @@ func TestWakeUpOnStaleBranch(t *testing.T) { var chanID2 uint64 // Create 10 common blocks, confirming chanID1. + var fundingScript1 []byte for i := uint32(1); i <= 10; i++ { block := &wire.MsgBlock{ Transactions: []*wire.MsgTx{}, } height := startingBlockHeight + i if i == 5 { - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, height) + chanValue, height, + ) if err != nil { t.Fatalf("unable create channel edge: %v", err) } block.Transactions = append(block.Transactions, fundingTx) chanID1 = chanID.ToUint64() + fundingScript1 = script } ctx.chain.addBlock(block, height, rand.Uint32()) ctx.chain.setBestBlock(int32(height)) @@ -229,13 +235,14 @@ func TestWakeUpOnStaleBranch(t *testing.T) { require.NoError(t, err, "unable to ge best block") // Create 10 blocks on the minority chain, confirming chanID2. + var fundingScript2 []byte for i := uint32(1); i <= 10; i++ { block := &wire.MsgBlock{ Transactions: []*wire.MsgTx{}, } height := uint32(forkHeight) + i if i == 5 { - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), chanValue, height) @@ -245,6 +252,7 @@ func TestWakeUpOnStaleBranch(t *testing.T) { block.Transactions = append(block.Transactions, fundingTx) chanID2 = chanID.ToUint64() + fundingScript2 = script } ctx.chain.addBlock(block, height, rand.Uint32()) ctx.chain.setBestBlock(int32(height)) @@ -269,6 +277,7 @@ func TestWakeUpOnStaleBranch(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + FundingScript: fn.Some(fundingScript1), } copy(edge1.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge1.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -287,6 +296,7 @@ func TestWakeUpOnStaleBranch(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + FundingScript: fn.Some(fundingScript2), } copy(edge2.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge2.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -406,10 +416,11 @@ func TestDisconnectedBlocks(t *testing.T) { } height := startingBlockHeight + i if i == 5 { - fundingTx, _, chanID, err := createChannelEdge(ctx, + _, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, height) + chanValue, height, + ) if err != nil { t.Fatalf("unable create channel edge: %v", err) } @@ -437,10 +448,11 @@ func TestDisconnectedBlocks(t *testing.T) { } height := uint32(forkHeight) + i if i == 5 { - fundingTx, _, chanID, err := createChannelEdge(ctx, + _, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, height) + chanValue, height, + ) if err != nil { t.Fatalf("unable create channel edge: %v", err) } @@ -474,6 +486,7 @@ func TestDisconnectedBlocks(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + FundingScript: fn.Some([]byte{}), } copy(edge1.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge1.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -494,6 +507,7 @@ func TestDisconnectedBlocks(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + FundingScript: fn.Some([]byte{}), } copy(edge2.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge2.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -595,10 +609,11 @@ func TestChansClosedOfflinePruneGraph(t *testing.T) { Transactions: []*wire.MsgTx{}, } nextHeight := startingBlockHeight + 1 - fundingTx1, chanUTXO, chanID1, err := createChannelEdge(ctx, + script, fundingTx1, chanUTXO, chanID1, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, uint32(nextHeight)) + chanValue, uint32(nextHeight), + ) require.NoError(t, err, "unable create channel edge") block102.Transactions = append(block102.Transactions, fundingTx1) ctx.chain.addBlock(block102, uint32(nextHeight), rand.Uint32()) @@ -622,6 +637,9 @@ func TestChansClosedOfflinePruneGraph(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + ChannelPoint: *chanUTXO, + Capacity: chanValue, + FundingScript: fn.Some(script), } copy(edge1.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge1.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -1007,10 +1025,11 @@ func TestIsStaleNode(t *testing.T) { copy(pub1[:], priv1.PubKey().SerializeCompressed()) copy(pub2[:], priv2.PubKey().SerializeCompressed()) - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - 10000, 500) + 10000, 500, + ) require.NoError(t, err, "unable to create channel edge") fundingBlock := &wire.MsgBlock{ Transactions: []*wire.MsgTx{fundingTx}, @@ -1024,6 +1043,7 @@ func TestIsStaleNode(t *testing.T) { BitcoinKey1Bytes: pub1, BitcoinKey2Bytes: pub2, AuthProof: nil, + FundingScript: fn.Some(script), } if err := ctx.builder.AddEdge(edge); err != nil { t.Fatalf("unable to add edge: %v", err) @@ -1083,10 +1103,11 @@ func TestIsKnownEdge(t *testing.T) { copy(pub1[:], priv1.PubKey().SerializeCompressed()) copy(pub2[:], priv2.PubKey().SerializeCompressed()) - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - 10000, 500) + 10000, 500, + ) require.NoError(t, err, "unable to create channel edge") fundingBlock := &wire.MsgBlock{ Transactions: []*wire.MsgTx{fundingTx}, @@ -1100,6 +1121,7 @@ func TestIsKnownEdge(t *testing.T) { BitcoinKey1Bytes: pub1, BitcoinKey2Bytes: pub2, AuthProof: nil, + FundingScript: fn.Some(script), } if err := ctx.builder.AddEdge(edge); err != nil { t.Fatalf("unable to add edge: %v", err) @@ -1129,10 +1151,11 @@ func TestIsStaleEdgePolicy(t *testing.T) { copy(pub1[:], priv1.PubKey().SerializeCompressed()) copy(pub2[:], priv2.PubKey().SerializeCompressed()) - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - 10000, 500) + 10000, 500, + ) require.NoError(t, err, "unable to create channel edge") fundingBlock := &wire.MsgBlock{ Transactions: []*wire.MsgTx{fundingTx}, @@ -1156,6 +1179,7 @@ func TestIsStaleEdgePolicy(t *testing.T) { BitcoinKey1Bytes: pub1, BitcoinKey2Bytes: pub2, AuthProof: nil, + FundingScript: fn.Some(script), } if err := ctx.builder.AddEdge(edge); err != nil { t.Fatalf("unable to add edge: %v", err) @@ -1210,120 +1234,6 @@ func TestIsStaleEdgePolicy(t *testing.T) { } } -// edgeCreationModifier is an enum-like type used to modify steps that are -// skipped when creating a channel in the test context. -type edgeCreationModifier uint8 - -const ( - // edgeCreationNoFundingTx is used to skip adding the funding - // transaction of an edge to the chain. - edgeCreationNoFundingTx edgeCreationModifier = iota - - // edgeCreationNoUTXO is used to skip adding the UTXO of a channel to - // the UTXO set. - edgeCreationNoUTXO - - // edgeCreationBadScript is used to create the edge, but use the wrong - // scrip which should cause it to fail output validation. - edgeCreationBadScript -) - -// newChannelEdgeInfo is a helper function used to create a new channel edge, -// possibly skipping adding it to parts of the chain/state as well. -func newChannelEdgeInfo(t *testing.T, ctx *testCtx, fundingHeight uint32, - ecm edgeCreationModifier) (*models.ChannelEdgeInfo, error) { - - node1 := createTestNode(t) - node2 := createTestNode(t) - - fundingTx, _, chanID, err := createChannelEdge( - ctx, bitcoinKey1.SerializeCompressed(), - bitcoinKey2.SerializeCompressed(), 100, fundingHeight, - ) - if err != nil { - return nil, fmt.Errorf("unable to create edge: %w", err) - } - - edge := &models.ChannelEdgeInfo{ - ChannelID: chanID.ToUint64(), - NodeKey1Bytes: node1.PubKeyBytes, - NodeKey2Bytes: node2.PubKeyBytes, - } - copy(edge.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) - copy(edge.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) - - if ecm == edgeCreationNoFundingTx { - return edge, nil - } - - fundingBlock := &wire.MsgBlock{ - Transactions: []*wire.MsgTx{fundingTx}, - } - ctx.chain.addBlock(fundingBlock, chanID.BlockHeight, chanID.BlockHeight) - - if ecm == edgeCreationNoUTXO { - ctx.chain.delUtxo(wire.OutPoint{ - Hash: fundingTx.TxHash(), - }) - } - - if ecm == edgeCreationBadScript { - fundingTx.TxOut[0].PkScript[0] ^= 1 - } - - return edge, nil -} - -func assertChanChainRejection(t *testing.T, ctx *testCtx, - edge *models.ChannelEdgeInfo, expectedErr error) { - - t.Helper() - - err := ctx.builder.AddEdge(edge) - require.ErrorIs(t, err, expectedErr) - - // This channel should now be present in the zombie channel index. - _, _, _, isZombie, err := ctx.graph.HasChannelEdge( - edge.ChannelID, - ) - require.Nil(t, err) - require.True(t, isZombie, "edge should be marked as zombie") -} - -// TestChannelOnChainRejectionZombie tests that if we fail validating a channel -// due to some sort of on-chain rejection (no funding transaction, or invalid -// UTXO), then we'll mark the channel as a zombie. -func TestChannelOnChainRejectionZombie(t *testing.T) { - t.Parallel() - - ctx := createTestCtxSingleNode(t, 0) - - // To start, we'll make an edge for the channel, but we won't add the - // funding transaction to the mock blockchain, which should cause the - // validation to fail below. - edge, err := newChannelEdgeInfo(t, ctx, 1, edgeCreationNoFundingTx) - require.Nil(t, err) - - // We expect this to fail as the transaction isn't present in the - // chain (nor the block). - assertChanChainRejection(t, ctx, edge, ErrNoFundingTransaction) - - // Next, we'll make another channel edge, but actually add it to the - // graph this time. - edge, err = newChannelEdgeInfo(t, ctx, 2, edgeCreationNoUTXO) - require.Nil(t, err) - - // Instead now, we'll remove it from the set of UTXOs which should - // cause the spentness validation to fail. - assertChanChainRejection(t, ctx, edge, ErrChannelSpent) - - // If we cause the funding transaction the chain to fail validation, we - // should see similar behavior. - edge, err = newChannelEdgeInfo(t, ctx, 3, edgeCreationBadScript) - require.Nil(t, err) - assertChanChainRejection(t, ctx, edge, ErrInvalidFundingOutput) -} - // TestBlockDifferenceFix tests if when the router is behind on blocks, the // router catches up to the best block head. func TestBlockDifferenceFix(t *testing.T) { diff --git a/graph/db/models/channel_edge_info.go b/graph/db/models/channel_edge_info.go index 6aa67acc6..39e3b196f 100644 --- a/graph/db/models/channel_edge_info.go +++ b/graph/db/models/channel_edge_info.go @@ -63,10 +63,11 @@ type ChannelEdgeInfo struct { // the value output in the outpoint that created this channel. Capacity btcutil.Amount - // TapscriptRoot is the optional Merkle root of the tapscript tree if - // this channel is a taproot channel that also commits to a tapscript - // tree (custom channel). - TapscriptRoot fn.Option[chainhash.Hash] + // FundingScript holds the script of the channel's funding transaction. + // + // NOTE: this is not currently persisted and so will not be present if + // the edge object is loaded from the database. + FundingScript fn.Option[[]byte] // ExtraOpaqueData is the set of data that was appended to this // message, some of which we may not actually know how to iterate or diff --git a/graph/errors.go b/graph/errors.go index 4192a26aa..729107419 100644 --- a/graph/errors.go +++ b/graph/errors.go @@ -2,25 +2,6 @@ package graph import "github.com/go-errors/errors" -var ( - // ErrNoFundingTransaction is returned when we are unable to find the - // funding transaction described by the short channel ID on chain. - ErrNoFundingTransaction = errors.New( - "unable to find the funding transaction", - ) - - // ErrInvalidFundingOutput is returned if the channel funding output - // fails validation. - ErrInvalidFundingOutput = errors.New( - "channel funding output validation failed", - ) - - // ErrChannelSpent is returned when we go to validate a channel, but - // the purported funding output has actually already been spent on - // chain. - ErrChannelSpent = errors.New("channel output has been spent") -) - // ErrorCode is used to represent the various errors that can occur within this // package. type ErrorCode uint8 diff --git a/graph/interfaces.go b/graph/interfaces.go index 10ca200f3..87a004d25 100644 --- a/graph/interfaces.go +++ b/graph/interfaces.go @@ -85,6 +85,13 @@ type ChannelGraphSource interface { // public key. channeldb.ErrGraphNodeNotFound is returned if the node // doesn't exist within the graph. FetchLightningNode(route.Vertex) (*models.LightningNode, error) + + // MarkZombieEdge marks the channel with the given ID as a zombie edge. + MarkZombieEdge(chanID uint64) error + + // IsZombieEdge returns true if the edge with the given channel ID is + // currently marked as a zombie edge. + IsZombieEdge(chanID lnwire.ShortChannelID) (bool, error) } // DB is an interface describing a persisted Lightning Network graph. diff --git a/graph/notifications_test.go b/graph/notifications_test.go index fdbed68b0..4049c9f81 100644 --- a/graph/notifications_test.go +++ b/graph/notifications_test.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch" @@ -24,7 +25,6 @@ import ( "github.com/lightningnetwork/lnd/kvdb" lnmock "github.com/lightningnetwork/lnd/lntest/mock" "github.com/lightningnetwork/lnd/lnwallet" - "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/chainview" "github.com/lightningnetwork/lnd/routing/route" @@ -126,18 +126,18 @@ func randEdgePolicy(chanID *lnwire.ShortChannelID, }, nil } -func createChannelEdge(ctx *testCtx, bitcoinKey1, bitcoinKey2 []byte, - chanValue btcutil.Amount, fundingHeight uint32) (*wire.MsgTx, *wire.OutPoint, - *lnwire.ShortChannelID, error) { +func createChannelEdge(bitcoinKey1, bitcoinKey2 []byte, + chanValue btcutil.Amount, fundingHeight uint32) ([]byte, *wire.MsgTx, + *wire.OutPoint, *lnwire.ShortChannelID, error) { fundingTx := wire.NewMsgTx(2) - _, tx, err := input.GenFundingPkScript( + script, tx, err := input.GenFundingPkScript( bitcoinKey1, bitcoinKey2, int64(chanValue), ) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } fundingTx.TxOut = append(fundingTx.TxOut, tx) @@ -146,9 +146,6 @@ func createChannelEdge(ctx *testCtx, bitcoinKey1, bitcoinKey2 []byte, Index: 0, } - // With the utxo constructed, we'll mark it as closed. - ctx.chain.addUtxo(chanUtxo, tx) - // Our fake channel will be "confirmed" at height 101. chanID := &lnwire.ShortChannelID{ BlockHeight: fundingHeight, @@ -156,16 +153,16 @@ func createChannelEdge(ctx *testCtx, bitcoinKey1, bitcoinKey2 []byte, TxPosition: 0, } - return fundingTx, &chanUtxo, chanID, nil + return script, fundingTx, &chanUtxo, chanID, nil } type mockChain struct { + lnwallet.BlockChainIO + blocks map[chainhash.Hash]*wire.MsgBlock blockIndex map[uint32]chainhash.Hash blockHeightIndex map[chainhash.Hash]uint32 - utxos map[wire.OutPoint]wire.TxOut - bestHeight int32 sync.RWMutex @@ -179,7 +176,6 @@ func newMockChain(currentHeight uint32) *mockChain { chain := &mockChain{ bestHeight: int32(currentHeight), blocks: make(map[chainhash.Hash]*wire.MsgBlock), - utxos: make(map[wire.OutPoint]wire.TxOut), blockIndex: make(map[uint32]chainhash.Hash), blockHeightIndex: make(map[chainhash.Hash]uint32), } @@ -213,10 +209,6 @@ func (m *mockChain) GetBestBlock() (*chainhash.Hash, int32, error) { return &blockHash, m.bestHeight, nil } -func (m *mockChain) GetTransaction(txid *chainhash.Hash) (*wire.MsgTx, error) { - return nil, nil -} - func (m *mockChain) GetBlockHash(blockHeight int64) (*chainhash.Hash, error) { m.RLock() defer m.RUnlock() @@ -230,31 +222,6 @@ func (m *mockChain) GetBlockHash(blockHeight int64) (*chainhash.Hash, error) { return &hash, nil } -func (m *mockChain) addUtxo(op wire.OutPoint, out *wire.TxOut) { - m.Lock() - m.utxos[op] = *out - m.Unlock() -} - -func (m *mockChain) delUtxo(op wire.OutPoint) { - m.Lock() - delete(m.utxos, op) - m.Unlock() -} - -func (m *mockChain) GetUtxo(op *wire.OutPoint, _ []byte, _ uint32, - _ <-chan struct{}) (*wire.TxOut, error) { - m.RLock() - defer m.RUnlock() - - utxo, ok := m.utxos[*op] - if !ok { - return nil, btcwallet.ErrOutputSpent - } - - return &utxo, nil -} - func (m *mockChain) addBlock(block *wire.MsgBlock, height uint32, nonce uint32) { m.Lock() block.Header.Nonce = nonce @@ -459,9 +426,10 @@ func TestEdgeUpdateNotification(t *testing.T) { // First we'll create the utxo for the channel to be "closed" const chanValue = 10000 - fundingTx, chanPoint, chanID, err := createChannelEdge(ctx, - bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, 0) + script, fundingTx, chanPoint, chanID, err := createChannelEdge( + bitcoinKey1.SerializeCompressed(), + bitcoinKey2.SerializeCompressed(), chanValue, 0, + ) require.NoError(t, err, "unable create channel edge") // We'll also add a record for the block that included our funding @@ -488,6 +456,9 @@ func TestEdgeUpdateNotification(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + ChannelPoint: *chanPoint, + Capacity: chanValue, + FundingScript: fn.Some(script), } copy(edge.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -643,10 +614,11 @@ func TestNodeUpdateNotification(t *testing.T) { // We only accept node announcements from nodes having a known channel, // so create one now. const chanValue = 10000 - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, _, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, startingBlockHeight) + chanValue, startingBlockHeight, + ) require.NoError(t, err, "unable create channel edge") // We'll also add a record for the block that included our funding @@ -675,6 +647,7 @@ func TestNodeUpdateNotification(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + FundingScript: fn.Some(script), } copy(edge.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -825,10 +798,11 @@ func TestNotificationCancellation(t *testing.T) { // We'll create the utxo for a new channel. const chanValue = 10000 - fundingTx, _, chanID, err := createChannelEdge(ctx, + script, fundingTx, chanPoint, chanID, err := createChannelEdge( bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, startingBlockHeight) + chanValue, startingBlockHeight, + ) require.NoError(t, err, "unable create channel edge") // We'll also add a record for the block that included our funding @@ -859,6 +833,9 @@ func TestNotificationCancellation(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + ChannelPoint: *chanPoint, + Capacity: chanValue, + FundingScript: fn.Some(script), } copy(edge.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) @@ -899,9 +876,11 @@ func TestChannelCloseNotification(t *testing.T) { // First we'll create the utxo for the channel to be "closed" const chanValue = 10000 - fundingTx, chanUtxo, chanID, err := createChannelEdge(ctx, - bitcoinKey1.SerializeCompressed(), bitcoinKey2.SerializeCompressed(), - chanValue, startingBlockHeight) + script, fundingTx, chanUtxo, chanID, err := createChannelEdge( + bitcoinKey1.SerializeCompressed(), + bitcoinKey2.SerializeCompressed(), chanValue, + startingBlockHeight, + ) require.NoError(t, err, "unable create channel edge") // We'll also add a record for the block that included our funding @@ -928,6 +907,9 @@ func TestChannelCloseNotification(t *testing.T) { BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), }, + ChannelPoint: *chanUtxo, + Capacity: chanValue, + FundingScript: fn.Some(script), } copy(edge.BitcoinKey1Bytes[:], bitcoinKey1.SerializeCompressed()) copy(edge.BitcoinKey2Bytes[:], bitcoinKey2.SerializeCompressed()) diff --git a/server.go b/server.go index d71ebc032..f85d60f52 100644 --- a/server.go +++ b/server.go @@ -1142,6 +1142,7 @@ func newServer(cfg *Config, listenAddrs []net.Addr, FindChannel: s.findChannel, IsStillZombieChannel: s.graphBuilder.IsZombieChannel, ScidCloser: scidCloserMan, + AssumeChannelValid: cfg.Routing.AssumeChannelValid, }, nodeKeyDesc) selfVertex := route.Vertex(nodeKeyDesc.PubKey.SerializeCompressed())