diff --git a/chainreg/blake2b_backend.go b/chainreg/blake2b_backend.go index 4eb18218171..698877ca241 100644 --- a/chainreg/blake2b_backend.go +++ b/chainreg/blake2b_backend.go @@ -2,6 +2,7 @@ package chainreg import ( "github.com/btcsuite/btcd/rpcclient" + "github.com/lightninglabs/neutrino" "github.com/lightningnetwork/lnd/lncfg" ) @@ -26,6 +27,41 @@ func chainIdentityStatusPath(cfg *Config) string { func verifyBlake2bBackend(rpc *rpcclient.Client, cfg *Config, quit <-chan struct{}) error { + return verifyBlake2bChain(rpc, cfg, quit) +} + +// verifyBlake2bNeutrino runs the same check against the neutrino light +// client: once its headers reach the activation height, the header there must +// be a v2 header whose id this daemon reproduces, and on mainnet the pinned +// activation block. The status file is written as for bitcoind, so wrappers +// read the same state whichever backend runs. +func verifyBlake2bNeutrino(cs *neutrino.ChainService, cfg *Config, + quit <-chan struct{}) error { + + return verifyBlake2bChain(&neutrinoIdentityRPC{ + src: cs, + chain: bitcoindChainName(cfg.ActiveNetParams.Params), + }, cfg, quit) +} + +// verifyBlake2bChain is the check itself, against whatever answers the +// calls it makes. +func verifyBlake2bChain(rpc blake2bChainRPC, cfg *Config, + quit <-chan struct{}) error { + + if cfg.Shutdown != nil { + either := make(chan struct{}) + stop := quit + go func() { + select { + case <-stop: + case <-cfg.Shutdown: + } + close(either) + }() + quit = either + } + params := cfg.ActiveNetParams statusPath := chainIdentityStatusPath(cfg) diff --git a/chainreg/blake2b_backend_test.go b/chainreg/blake2b_backend_test.go index 2852210340c..cff35af1d25 100644 --- a/chainreg/blake2b_backend_test.go +++ b/chainreg/blake2b_backend_test.go @@ -3,6 +3,7 @@ package chainreg import ( "path/filepath" "testing" + "time" "github.com/lightningnetwork/lnd/lncfg" "github.com/stretchr/testify/require" @@ -33,3 +34,32 @@ func TestChainIdentityStatusPathSelection(t *testing.T) { ChainIdentityFileName), chainIdentityStatusPath(cfg)) } + +// TestChainIdentityWaitStopsOnShutdown pins that a daemon still waiting for +// the backend to reach the activation height stops when asked to. +func TestChainIdentityWaitStopsOnShutdown(t *testing.T) { + shutdown := make(chan struct{}) + cfg := &Config{ + Bitcoin: &lncfg.Chain{ChainDir: t.TempDir()}, + ActiveNetParams: BitcoinMainNetParams, + Shutdown: shutdown, + } + // A backend that never gets there. + rpc := &neutrinoIdentityRPC{ + src: &fakeNeutrino{height: 961000}, + chain: "main", + } + + done := make(chan error, 1) + go func() { + done <- verifyBlake2bChain(rpc, cfg, make(chan struct{})) + }() + close(shutdown) + + select { + case err := <-done: + require.ErrorContains(t, err, "shutting down") + case <-time.After(10 * time.Second): + t.Fatal("the wait did not stop on shutdown") + } +} diff --git a/chainreg/blake2b_neutrino.go b/chainreg/blake2b_neutrino.go new file mode 100644 index 00000000000..4341cfa4322 --- /dev/null +++ b/chainreg/blake2b_neutrino.go @@ -0,0 +1,123 @@ +package chainreg + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + + "github.com/btcsuite/btcd/btcjson" + bitcoinCfg "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/neutrino/headerfs" +) + +// NeutrinoChainParams returns the btcd parameters handed to the neutrino light +// client: the network's own, plus, where the activation block is pinned +// (mainnet), a checkpoint at the activation height with its hash. The light +// client checks BLAKE2b proof of work itself, but would follow any chain with +// the most work that passes those rules from the activation height on; the +// checkpoint ties it to the Bitcoin BLAKE2b chain's activation block. The +// shared parameters are left untouched: the checkpoint goes on a copy. +func NeutrinoChainParams(params BitcoinNetParams) *bitcoinCfg.Params { + p := *params.Params + if params.Blake2bActivationHash == nil || + params.Blake2bActivationHeight == 0 { + + return &p + } + + height := int32(params.Blake2bActivationHeight) + checkpoints := make([]bitcoinCfg.Checkpoint, 0, len(p.Checkpoints)+1) + for _, c := range p.Checkpoints { + if c.Height != height { + checkpoints = append(checkpoints, c) + } + } + hash := *params.Blake2bActivationHash + checkpoints = append(checkpoints, bitcoinCfg.Checkpoint{ + Height: height, Hash: &hash, + }) + sort.Slice(checkpoints, func(i, j int) bool { + return checkpoints[i].Height < checkpoints[j].Height + }) + p.Checkpoints = checkpoints + + return &p +} + +// neutrinoHeaderSource is what the chain-identity check reads from the light +// client: its best block, and the headers it validated. +type neutrinoHeaderSource interface { + BestBlock() (*headerfs.BlockStamp, error) + GetBlockHash(int64) (*chainhash.Hash, error) + GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) +} + +// neutrinoIdentityRPC lets the chain-identity check, written against a +// bitcoind backend, run against the light client: it answers the few calls +// the check makes from neutrino's validated headers. Anything else (the +// deployment info, say) is reported as not available, which the check treats +// as the node not saying. +type neutrinoIdentityRPC struct { + src neutrinoHeaderSource + chain string +} + +// GetBlockChainInfo reports the light client's header height under the chain +// name the check expects, so the wait for the activation height works as it +// does with a node. +func (n *neutrinoIdentityRPC) GetBlockChainInfo() ( + *btcjson.GetBlockChainInfoResult, error) { + + best, err := n.src.BestBlock() + if err != nil { + return nil, err + } + + return &btcjson.GetBlockChainInfoResult{ + Chain: n.chain, + Blocks: best.Height, + Headers: best.Height, + }, nil +} + +// GetBlockHash returns the hash of the light client's header at height. +func (n *neutrinoIdentityRPC) GetBlockHash(height int64) (*chainhash.Hash, + error) { + + return n.src.GetBlockHash(height) +} + +// RawRequest answers getblockheader (non-verbose) from the light client's +// headers. +func (n *neutrinoIdentityRPC) RawRequest(method string, + params []json.RawMessage) (json.RawMessage, error) { + + if method != "getblockheader" || len(params) < 1 { + return nil, fmt.Errorf("%s is not available from the neutrino "+ + "light client", method) + } + + var hashStr string + if err := json.Unmarshal(params[0], &hashStr); err != nil { + return nil, err + } + hash, err := chainhash.NewHashFromStr(hashStr) + if err != nil { + return nil, err + } + header, err := n.src.GetBlockHeader(hash) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := header.Serialize(&buf); err != nil { + return nil, err + } + + return json.Marshal(hex.EncodeToString(buf.Bytes())) +} diff --git a/chainreg/blake2b_neutrino_test.go b/chainreg/blake2b_neutrino_test.go new file mode 100644 index 00000000000..a24970cfd28 --- /dev/null +++ b/chainreg/blake2b_neutrino_test.go @@ -0,0 +1,136 @@ +package chainreg + +import ( + "bytes" + "encoding/hex" + "errors" + "path/filepath" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/neutrino/headerfs" + "github.com/stretchr/testify/require" +) + +// fakeNeutrino serves the chain-identity check from a fixed set of headers, +// as the light client would from the ones it validated. +type fakeNeutrino struct { + height int32 + byHash map[chainhash.Hash]*wire.BlockHeader + byHeight map[int64]chainhash.Hash +} + +func newFakeNeutrino(t *testing.T, height int32, at int64, + hexStr string) *fakeNeutrino { + + t.Helper() + raw, err := hex.DecodeString(hexStr) + require.NoError(t, err) + var h wire.BlockHeader + require.NoError(t, h.Deserialize(bytes.NewReader(raw))) + hash := h.BlockHash() + return &fakeNeutrino{ + height: height, + byHash: map[chainhash.Hash]*wire.BlockHeader{hash: &h}, + byHeight: map[int64]chainhash.Hash{at: hash}, + } +} + +func (f *fakeNeutrino) BestBlock() (*headerfs.BlockStamp, error) { + return &headerfs.BlockStamp{Height: f.height}, nil +} + +func (f *fakeNeutrino) GetBlockHash(h int64) (*chainhash.Hash, error) { + hash, ok := f.byHeight[h] + if !ok { + return nil, errors.New("no header at that height") + } + return &hash, nil +} + +func (f *fakeNeutrino) GetBlockHeader(hash *chainhash.Hash) ( + *wire.BlockHeader, error) { + + h, ok := f.byHash[*hash] + if !ok { + return nil, errors.New("unknown header") + } + return h, nil +} + +// TestNeutrinoChainParamsCheckpoint pins that the light client gets a +// checkpoint at the mainnet activation block, on a copy, and nothing extra on +// a network without a pinned activation hash. +func TestNeutrinoChainParamsCheckpoint(t *testing.T) { + before := len(chaincfg.MainNetParams.Checkpoints) + + p := NeutrinoChainParams(BitcoinMainNetParams) + require.Len(t, chaincfg.MainNetParams.Checkpoints, before, + "shared parameters changed") + require.Len(t, p.Checkpoints, before+1) + + last := p.Checkpoints[len(p.Checkpoints)-1] + require.EqualValues(t, Blake2bMainnetActivationHeight, last.Height) + require.Equal(t, *Blake2bMainnetActivationHash, *last.Hash) + for i := 1; i < len(p.Checkpoints); i++ { + require.Less( + t, p.Checkpoints[i-1].Height, p.Checkpoints[i].Height, + ) + } + + t4 := NeutrinoChainParams(BitcoinTestNet4Params) + require.Equal(t, chaincfg.TestNet4Params.Checkpoints, t4.Checkpoints) +} + +// TestNeutrinoChainIdentity runs the chain-identity check against the light +// client's headers: the real activation header confirms mainnet and writes +// the status file; the last SHA256d header in its place is refused. +func TestNeutrinoChainIdentity(t *testing.T) { + t.Run("activation header confirms", func(t *testing.T) { + status := filepath.Join(t.TempDir(), ChainIdentityFileName) + rpc := &neutrinoIdentityRPC{ + src: newFakeNeutrino( + t, 976700, 961640, activationHeaderHex, + ), + chain: "main", + } + c := newTestChecker(t, rpc, BitcoinMainNetParams, status) + height, err := c.run(make(chan struct{})) + require.NoError(t, err) + require.EqualValues(t, 961640, height) + + st, err := ReadChainIdentityStatus(status) + require.NoError(t, err) + require.Equal(t, ChainIdentityConfirmed, st.State) + require.Equal( + t, Blake2bMainnetActivationHash.String(), + st.ActivationHash, + ) + }) + + t.Run("classic header refused", func(t *testing.T) { + status := filepath.Join(t.TempDir(), ChainIdentityFileName) + rpc := &neutrinoIdentityRPC{ + src: newFakeNeutrino( + t, 976700, 961640, lastSHA256HeaderHex, + ), + chain: "main", + } + c := newTestChecker(t, rpc, BitcoinMainNetParams, status) + _, err := c.run(make(chan struct{})) + var wrong *ErrWrongChain + require.ErrorAs(t, err, &wrong) + + st, err := ReadChainIdentityStatus(status) + require.NoError(t, err) + require.Equal(t, ChainIdentityRefused, st.State) + }) + + t.Run("other calls are not available", func(t *testing.T) { + rpc := &neutrinoIdentityRPC{src: &fakeNeutrino{}, chain: "main"} + _, err := rpc.RawRequest("getdeploymentinfo", nil) + require.Error(t, err) + }) +} diff --git a/chainreg/chainregistry.go b/chainreg/chainregistry.go index e50e2c6a692..6390ad24f44 100644 --- a/chainreg/chainregistry.go +++ b/chainreg/chainregistry.go @@ -80,6 +80,12 @@ type Config struct { // chain. It is expected to stop the daemon. OnChainMismatch func(error) + // Shutdown, when set, ends the chain-identity check's wait for the + // activation height: a daemon still waiting there (a node that never + // gets there, a light client with only SHA256d peers) must stop when + // asked to. + Shutdown <-chan struct{} + // WalletUnlockParams are the parameters that were used for unlocking // the main wallet. WalletUnlockParams *walletunlocker.WalletUnlockParams @@ -297,6 +303,17 @@ func NewPartialChainControl(cfg *Config) (*PartialChainControl, func(), error) { cfg.ActiveNetParams.Params, cfg.NeutrinoCS, ) + // The light client checks BLAKE2b proof of work, and on + // mainnet a checkpoint ties it to the activation block; this + // confirms the chain it ended up on as the bitcoind path does + // (waiting for the activation height, then re-checking), and + // writes the same status file. + if err := verifyBlake2bNeutrino( + cfg.NeutrinoCS, cfg, chainIdentityQuit, + ); err != nil { + return nil, nil, err + } + // Get our best block as a health check. cc.HealthCheck = func() error { _, _, err := cc.ChainSource.GetBestBlock() diff --git a/config.go b/config.go index 260b3a78965..9e2c3cdbcef 100644 --- a/config.go +++ b/config.go @@ -721,6 +721,9 @@ func DefaultConfig() Config { UserAgentName: neutrino.UserAgentName, UserAgentVersion: neutrino.UserAgentVersion, MaxPeers: defaultNeutrinoMaxPeers, + // Checked against the chain by default on this fork: + // see initNeutrinoBackend. + ValidateChannels: true, }, BlockCacheSize: defaultBlockCacheSize, MaxPendingChannels: lncfg.DefaultMaxPendingChannels, @@ -1493,10 +1496,14 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, "credentials for bitcoind: %v", err) } case neutrinoBackendName: - return nil, mkErr("bitcoin.node=neutrino is not supported: this " + - "daemon follows the Bitcoin BLAKE2b chain and does not " + - "validate its proof of work itself; use a Bitcoin Knots " + - "bitcoind backend") + // The light client checks BLAKE2b proof of work itself (btcd's + // blockchain package, from the network's Blake2bHeight), so it + // can only follow a network whose height it knows. + if !cfg.ActiveNetParams.Params.Blake2bPowDeployed() { + return nil, mkErr("bitcoin.node=neutrino needs the " + + "network's BLAKE2b activation height: set " + + "bitcoin.blake2b-activation-height") + } case "nochainbackend": // Nothing to configure, we're running without any chain @@ -1504,7 +1511,8 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, default: str := "only bitcoind (a Bitcoin Knots node on the Bitcoin " + - "BLAKE2b chain) and nochainbackend are supported" + "BLAKE2b chain), neutrino and nochainbackend are " + + "supported" return nil, mkErr(str) } diff --git a/config_blake2b.go b/config_blake2b.go index 2fcf3ee798c..09577595dd2 100644 --- a/config_blake2b.go +++ b/config_blake2b.go @@ -2,6 +2,7 @@ package lnd import ( "fmt" + "math" "path/filepath" "github.com/btcsuite/btcd/txscript" @@ -11,6 +12,10 @@ import ( "github.com/lightningnetwork/lnd/zpay32" ) +// blake2bDefaultTargetShift is Knots' Consensus::Params Blake2bTargetShift +// default, which every network but mainnet (22) uses. +const blake2bDefaultTargetShift = 20 + // applyBlake2bChainConfig applies the Bitcoin BLAKE2b chain-identity options // to the active network parameters once the network has been selected, and // registers the invoice prefix for it. It returns an error for combinations @@ -36,7 +41,39 @@ func applyBlake2bChainConfig(cfg *Config) error { "be set on mainnet: the activation height is fixed " + "at 961640") } - params.Blake2bActivationHeight = cfg.Bitcoin.Blake2bActivationHeight + // It ends up in btcd's int32 heights: a value past that would + // turn negative there and fail with a misleading message. + if cfg.Bitcoin.Blake2bActivationHeight > math.MaxInt32 { + return fmt.Errorf("bitcoin.blake2b-activation-height "+ + "%d is out of range", + cfg.Bitcoin.Blake2bActivationHeight) + } + height := cfg.Bitcoin.Blake2bActivationHeight + params.Blake2bActivationHeight = height + + // The light client validates BLAKE2b proof of work itself from + // the btcd parameters, so they must carry this run's height + // too. A copy, so the package-level parameters stay as + // shipped. + p := *params.Params + p.Blake2bHeight = int32(height) + if p.Blake2bTargetShift == 0 { + p.Blake2bTargetShift = blake2bDefaultTargetShift + } + params.Params = &p + } + + // The light client takes the activation height from btcd's parameters + // (testnet4's built-in 150308, unless overridden above); the + // chain-identity check, which a bitcoind backend can ask the node + // about, gets the same one. + if cfg.Bitcoin.Node == neutrinoBackendName && + params.Blake2bActivationHeight == 0 && + params.Params.Blake2bPowDeployed() { + + params.Blake2bActivationHeight = uint32( + params.Params.Blake2bHeight, + ) } if cfg.Bitcoin.ChainHashOverride != "" { @@ -131,3 +168,51 @@ func applyUnifiedSigHash(enabled bool) { // elsewhere, which stay spendable, and replayable. txauthor.LegacySigHashType = txscript.SigHashAll } + +// checkNeutrinoBlake2b holds the neutrino options that matter on the BLAKE2b +// chain. The DNS seeds of mainnet and testnet4 are Bitcoin's and return almost +// only nodes on the SHA256d chain, which the light client bans at the +// activation height, so it needs peers that serve BLAKE2b headers and compact +// filters, named with neutrino.connect. neutrino.addpeer alone still mixes in +// seed peers: allowed, with a warning. The header import reads 80-byte +// headers only and cannot carry a chain past the activation height. +func checkNeutrinoBlake2b(cfg *Config) error { + if cfg.NeutrinoMode.BlockHeadersSource != "" || + cfg.NeutrinoMode.FilterHeadersSource != "" { + + return fmt.Errorf("neutrino.blockheaderssource and " + + "neutrino.filterheaderssource are not supported on " + + "the Bitcoin BLAKE2b chain: the import reads 80-byte " + + "headers only") + } + + // The same rule chainreg applies when it builds the fee estimator, but + // that runs only once the node has synced to the BLAKE2b activation + // height; refused here it fails at start instead. The deprecated + // feeurl option counts too: chainreg copies it into fee.url later. + if cfg.Bitcoin.MainNet && cfg.Fee.URL == "" && cfg.FeeURL == "" { + return fmt.Errorf("--fee.url parameter required when running " + + "neutrino on mainnet") + } + + if cfg.Bitcoin.IsLocalNetwork() || + len(cfg.NeutrinoMode.ConnectPeers) > 0 { + + return nil + } + if len(cfg.NeutrinoMode.AddPeers) == 0 { + return fmt.Errorf("bitcoin.node=neutrino on %s needs "+ + "neutrino.connect: the DNS seeds return nodes on the "+ + "SHA256d chain; name Bitcoin Knots nodes on the "+ + "BLAKE2b chain with blockfilterindex=1 and "+ + "peerblockfilters=1, ideally your own", + cfg.ActiveNetParams.Name) + } + ltndLog.Warnf("neutrino.addpeer without neutrino.connect: peers from "+ + "the DNS seeds, almost all on the SHA256d chain, are dialled "+ + "too (and banned at the BLAKE2b activation height); prefer "+ + "neutrino.connect to Knots nodes on the BLAKE2b chain on %s", + cfg.ActiveNetParams.Name) + + return nil +} diff --git a/config_blake2b_test.go b/config_blake2b_test.go index 7e94961a200..471f3cbdbf8 100644 --- a/config_blake2b_test.go +++ b/config_blake2b_test.go @@ -133,9 +133,9 @@ func TestApplyBlake2bChainConfig(t *testing.T) { }) } -// TestBackendChoicesRefused pins that the btcd and neutrino backends are -// rejected at configuration time with a message naming the reason, and that -// bitcoind on mainnet gets past that check. +// TestBackendChoicesRefused pins that the btcd backend is rejected at +// configuration time with a message naming the reason, and that bitcoind and +// neutrino on mainnet get past that check. func TestBackendChoicesRefused(t *testing.T) { validate := func(t *testing.T, node string) error { t.Helper() @@ -151,17 +151,21 @@ func TestBackendChoicesRefused(t *testing.T) { return err } - for _, node := range []string{btcdBackendName, neutrinoBackendName} { - err := validate(t, node) - require.Error(t, err, node) - require.Contains(t, err.Error(), "not supported", node) - require.Contains(t, err.Error(), "BLAKE2b", node) - } + err := validate(t, btcdBackendName) + require.Error(t, err) + require.Contains(t, err.Error(), "not supported") + require.Contains(t, err.Error(), "BLAKE2b") - // bitcoind is accepted by the backend switch; whatever fails later - // (RPC credentials are not configured here) must not be that message. - if err := validate(t, bitcoindBackendName); err != nil { - require.NotContains(t, err.Error(), "not supported") + // bitcoind and neutrino are accepted by the backend switch; whatever + // fails later (RPC credentials or peers are not configured here) must + // not be that message. + backends := []string{bitcoindBackendName, neutrinoBackendName} + for _, node := range backends { + if err := validate(t, node); err != nil { + require.NotContains( + t, err.Error(), "not supported", node, + ) + } } } @@ -262,3 +266,131 @@ func TestPeerNetworksOptionsThatContradict(t *testing.T) { require.Contains(t, err.Error(), "contradict") }) } + +// TestNeutrinoBlake2bParams pins that the light client is handed the BLAKE2b +// activation height it validates proof of work from: the btcd parameters' +// own on mainnet, and the configured one on regtest, set on a copy so the +// package-level parameters are left as shipped. +func TestNeutrinoBlake2bParams(t *testing.T) { + t.Run("mainnet", func(t *testing.T) { + cfg := blake2bTestConfig(t, chainreg.BitcoinMainNetParams, + func(c *Config) { + c.Bitcoin.Node = neutrinoBackendName + }) + require.NoError(t, applyBlake2bChainConfig(cfg)) + require.Equal(t, + int32(cfg.ActiveNetParams.Blake2bActivationHeight), + cfg.ActiveNetParams.Params.Blake2bHeight) + require.Equal(t, uint(22), + cfg.ActiveNetParams.Params.Blake2bTargetShift) + }) + + t.Run("regtest takes the configured height", func(t *testing.T) { + cfg := blake2bTestConfig(t, chainreg.BitcoinRegTestNetParams, + func(c *Config) { + c.Bitcoin.Node = neutrinoBackendName + c.Bitcoin.Blake2bActivationHeight = 150 + }) + require.NoError(t, applyBlake2bChainConfig(cfg)) + require.Equal(t, int32(150), + cfg.ActiveNetParams.Params.Blake2bHeight) + require.Equal(t, uint(20), + cfg.ActiveNetParams.Params.Blake2bTargetShift) + require.Zero(t, chaincfg.RegressionNetParams.Blake2bHeight) + }) +} + +// TestNeutrinoNeedsHeightOnLocalNetworks pins that neutrino on regtest or +// simnet without bitcoin.blake2b-activation-height is refused at +// configuration time, since the light client could not validate past it. +func TestNeutrinoNeedsHeightOnLocalNetworks(t *testing.T) { + for _, net := range []string{"regtest", "simnet"} { + t.Run(net, func(t *testing.T) { + cfg := DefaultConfig() + cfg.LndDir = t.TempDir() + cfg.Bitcoin.Node = neutrinoBackendName + cfg.Bitcoin.RegTest = net == "regtest" + cfg.Bitcoin.SimNet = net == "simnet" + fileParser := flags.NewParser(&cfg, flags.Default) + flagParser := flags.NewParser(&cfg, flags.Default) + _, err := ValidateConfig( + cfg, signal.Interceptor{}, fileParser, + flagParser, + ) + require.Error(t, err) + require.Contains( + t, err.Error(), "blake2b-activation-height", + ) + }) + } +} + +// TestNeutrinoTestnet4Height pins that on testnet4 the chain-identity check +// gets the light client's built-in activation height, and that an +// out-of-range configured height is refused before it reaches btcd's int32. +func TestNeutrinoTestnet4Height(t *testing.T) { + cfg := blake2bTestConfig(t, chainreg.BitcoinTestNet4Params, + func(c *Config) { c.Bitcoin.Node = neutrinoBackendName }) + require.NoError(t, applyBlake2bChainConfig(cfg)) + require.EqualValues( + t, 150308, cfg.ActiveNetParams.Blake2bActivationHeight, + ) + + cfg = blake2bTestConfig(t, chainreg.BitcoinRegTestNetParams, + func(c *Config) { + c.Bitcoin.Node = neutrinoBackendName + c.Bitcoin.Blake2bActivationHeight = 1 << 31 + }) + require.ErrorContains(t, applyBlake2bChainConfig(cfg), "out of range") +} + +// TestCheckNeutrinoBlake2b covers the peer, fee and header-import rules for +// neutrino on the BLAKE2b chain. +func TestCheckNeutrinoBlake2b(t *testing.T) { + mainnet := func(set func(*Config)) *Config { + return blake2bTestConfig(t, chainreg.BitcoinMainNetParams, + func(c *Config) { + c.Bitcoin.Node = neutrinoBackendName + c.Fee.URL = "https://fees.example/fees.json" + set(c) + }) + } + + // No fee.url on mainnet: refused at start, not after the sync. + err := checkNeutrinoBlake2b(mainnet(func(c *Config) { + c.Fee.URL = "" + c.NeutrinoMode.ConnectPeers = []string{"knots.example:8333"} + })) + require.ErrorContains(t, err, "fee.url") + + // The deprecated feeurl alone is a fee URL too: chainreg copies it + // into fee.url when it builds the fee estimator. + require.NoError(t, checkNeutrinoBlake2b(mainnet(func(c *Config) { + c.Fee.URL = "" + c.FeeURL = "https://fees.example/fees.json" + c.NeutrinoMode.ConnectPeers = []string{"knots.example:8333"} + }))) + + err = checkNeutrinoBlake2b(mainnet(func(*Config) {})) + require.ErrorContains(t, err, "neutrino.connect") + + require.NoError(t, checkNeutrinoBlake2b(mainnet(func(c *Config) { + c.NeutrinoMode.ConnectPeers = []string{"knots.example:8333"} + }))) + + // addpeer alone is allowed, with a warning. + require.NoError(t, checkNeutrinoBlake2b(mainnet(func(c *Config) { + c.NeutrinoMode.AddPeers = []string{"knots.example:8333"} + }))) + + err = checkNeutrinoBlake2b(mainnet(func(c *Config) { + c.NeutrinoMode.ConnectPeers = []string{"knots.example:8333"} + c.NeutrinoMode.BlockHeadersSource = "headers.bin" + })) + require.ErrorContains(t, err, "80-byte") + + // Local networks need no peer list. + regtest := blake2bTestConfig(t, chainreg.BitcoinRegTestNetParams, + func(c *Config) { c.Bitcoin.Node = neutrinoBackendName }) + require.NoError(t, checkNeutrinoBlake2b(regtest)) +} diff --git a/config_builder.go b/config_builder.go index 6f85ad9eb89..04690337cb3 100644 --- a/config_builder.go +++ b/config_builder.go @@ -656,6 +656,7 @@ func (d *DefaultWalletImpl) BuildWalletConfig(ctx context.Context, d.logger.Criticalf("Stopping: %v", err) d.interceptor.RequestShutdown() }, + Shutdown: d.interceptor.ShutdownChannel(), } // Let's go ahead and create the partial chain control now that is only @@ -1743,10 +1744,11 @@ func initNeutrinoBackend(ctx context.Context, cfg *Config, chainDir string, // is the inverse of each other. Therefore both cannot be true. For // every other case, the neutrino.validatechannels overwrites the // routing.assumechanvalid value. - if cfg.NeutrinoMode.ValidateChannels && cfg.Routing.AssumeChannelValid { - return nil, nil, fmt.Errorf("can't set both " + - "neutrino.validatechannels and routing." + - "assumechanvalid to true at the same time") + // On this fork neutrino.validatechannels defaults to true: a light + // client would otherwise route over channels nobody checked against + // the chain. routing.assumechanvalid=true still turns the check off. + if cfg.Routing.AssumeChannelValid { + cfg.NeutrinoMode.ValidateChannels = false } cfg.Routing.AssumeChannelValid = !cfg.NeutrinoMode.ValidateChannels @@ -1754,6 +1756,9 @@ func initNeutrinoBackend(ctx context.Context, cfg *Config, chainDir string, if err := cfg.NeutrinoMode.Validate(); err != nil { return nil, nil, err } + if err := checkNeutrinoBlake2b(cfg); err != nil { + return nil, nil, err + } // First we'll open the database file for neutrino, creating the // database if needed. We append the normalized network name here to @@ -1821,9 +1826,13 @@ func initNeutrinoBackend(ctx context.Context, cfg *Config, chainDir string, // neutrino light client. We pass in relevant configuration parameters // required. config := neutrino.Config{ - DataDir: dbPath, - Database: db, - ChainParams: *cfg.ActiveNetParams.Params, + DataDir: dbPath, + Database: db, + // With a checkpoint at the BLAKE2b activation block where it + // is pinned (mainnet); see chainreg.NeutrinoChainParams. + ChainParams: *chainreg.NeutrinoChainParams( + cfg.ActiveNetParams, + ), AddPeers: cfg.NeutrinoMode.AddPeers, ConnectPeers: cfg.NeutrinoMode.ConnectPeers, Dialer: func(addr net.Addr) (net.Conn, error) { diff --git a/docs/blake2b.md b/docs/blake2b.md index bfa65bbae88..7aebba5fcec 100644 --- a/docs/blake2b.md +++ b/docs/blake2b.md @@ -221,11 +221,50 @@ Neither applies to a node with no channels, which can be upgraded whenever. ## Backends -Only `bitcoin.node=bitcoind`, pointed at a Bitcoin Knots v29.4.1 or later -node which has upgraded, is supported. `btcd` cannot follow the BLAKE2b proof -of work and -`neutrino` would have to validate BLAKE2b proof of work itself against a -network with no filter servers; both are refused at configuration time. +`bitcoin.node=bitcoind`, pointed at a Bitcoin Knots v29.4.1 or later node +which has upgraded, and `bitcoin.node=neutrino` are supported. `btcd` cannot +follow the BLAKE2b proof of work and is refused at configuration time. + +### neutrino + +`neutrino` validates BLAKE2b proof of work itself (btcd's `blockchain` +package, from the network's `Blake2bHeight`). A peer serving the SHA256d chain +is refused at the activation height, where its headers are still v1, and is +banned for 48 hours (lnd sets neutrino's ban duration to that, whatever +`neutrino.banduration` says) so it is not dialled again meanwhile. A peer +named in `neutrino.connect` or `neutrino.addpeer` is only disconnected, with +an error saying what to check, so a wrong setting here (a regtest activation +height) cannot lock out your own node. On mainnet the light client also gets a +checkpoint at block 961640 with the activation block's hash, and once its +headers reach that height the same chain-identity check as with `bitcoind` +runs against them and writes `chain-identity.json`. + +Peers: neutrino needs peers serving compact block filters, that is Knots nodes +on the BLAKE2b chain with `blockfilterindex=1` and `peerblockfilters=1`. +Mainnet and testnet4 use the SHA256 chain's DNS seeds, which return almost only +nodes on the SHA256d chain, so `neutrino.connect` is required there; `neutrino.addpeer` +alone is accepted with a warning, since it still mixes in seed peers. Point it +at your own Knots node if you have one. + +What a light client does not check: it validates headers and proof of work, +not transactions or scripts. Neither the unified signature hash rule nor the +long coinbase maturity is enforced by it. With this chain's small hashrate a +miner can feed a neutrino node an invalid block more cheaply than on Bitcoin, +which is another reason to `neutrino.connect` to a Knots node you trust. + +Channel announcements are checked against the chain by default on this fork +(`neutrino.validatechannels` defaults to true); `routing.assumechanvalid=true` +turns that off. + +Fees: neutrino has no node to estimate fees from, so mainnet needs `fee.url`. +The public fee estimators follow the SHA256d chain; use one that serves this +chain. + +On regtest and simnet `bitcoin.blake2b-activation-height` is required as with +`bitcoind`. On testnet4 neutrino uses btcd's built-in 150308, which +`bitcoin.blake2b-activation-height` overrides. Importing headers from a file +(`neutrino.blockheaderssource`, `neutrino.filterheaderssource`) is not +supported: the import reads 80-byte headers only. ## Configuration diff --git a/go.mod b/go.mod index 34df71163bc..1fabfc1941b 100644 --- a/go.mod +++ b/go.mod @@ -227,3 +227,5 @@ replace github.com/btcsuite/btcd => github.com/paulscode/btcd-blake2b v0.25.1-bl replace github.com/btcsuite/btcwallet/wallet/txauthor => github.com/paulscode/btcwallet-blake2b/wallet/txauthor v1.3.5-blake2b.1 replace github.com/lightningnetwork/lnd/fn/v2 => ./fn + +replace github.com/lightninglabs/neutrino => github.com/paulscode/neutrino-blake2b v0.17.1-blake2b.1 diff --git a/go.sum b/go.sum index 7ab37e2d66c..23a5e090874 100644 --- a/go.sum +++ b/go.sum @@ -354,8 +354,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= -github.com/lightninglabs/neutrino v0.17.1 h1:lNhgq7ix/N81R6oATroP/kHMzH1qzVVF2dEGcTlN2t4= -github.com/lightninglabs/neutrino v0.17.1/go.mod h1:tcwCgRTGWcaua0L/xzdwllW8eslHDbux4XkiYsivvHE= github.com/lightninglabs/neutrino/cache v1.1.3 h1:rgnabC41W+XaPuBTQrdeFjFCCAVKh1yctAgmb3Se9zA= github.com/lightninglabs/neutrino/cache v1.1.3/go.mod h1:qxkJb+pUxR5p84jl5uIGFCR4dGdFkhNUwMSxw3EUWls= github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS/00EiEg0qp0FhehxnQfk3vv8U6Xt3nN+rTY= @@ -444,6 +442,8 @@ github.com/paulscode/btcwallet-blake2b/wallet/txauthor v1.3.5-blake2b.1 h1:uKixy github.com/paulscode/btcwallet-blake2b/wallet/txauthor v1.3.5-blake2b.1/go.mod h1:+tXJ3Ym0nlQc/iHSwW1qzjmPs3ev+UVWMbGgfV1OZqU= github.com/paulscode/lightning-fork-bridge v0.1.6 h1:XjRjYLj2Idkz72jW7ETV78yVga9xiPpXxtonDW1imeQ= github.com/paulscode/lightning-fork-bridge v0.1.6/go.mod h1:6fYqFoyd+s8Nx6TwDZEgBs5AHhetgp7ibgjxD3hfCjE= +github.com/paulscode/neutrino-blake2b v0.17.1-blake2b.1 h1:e2c/IcfkK7fWDVsWi/CKlz/REQNscIp54G8y7gte8sw= +github.com/paulscode/neutrino-blake2b v0.17.1-blake2b.1/go.mod h1:oLkAavtRxNosJmxZFaYC5PO4ZT1XVnh1r/atiPVNTRk= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/lncfg/neutrino.go b/lncfg/neutrino.go index 62c8b247cbb..b244b33d8c1 100644 --- a/lncfg/neutrino.go +++ b/lncfg/neutrino.go @@ -18,7 +18,7 @@ type Neutrino struct { AssertFilterHeader string `long:"assertfilterheader" description:"Optional filter header in height:hash format to assert the state of neutrino's filter header chain on startup. If the assertion does not hold, then the filter header chain will be re-synced from the genesis block."` UserAgentName string `long:"useragentname" description:"Used to help identify ourselves to other bitcoin peers"` UserAgentVersion string `long:"useragentversion" description:"Used to help identify ourselves to other bitcoin peers"` - ValidateChannels bool `long:"validatechannels" description:"Validate every channel in the graph during sync by downloading the containing block. This is the inverse of routing.assumechanvalid, meaning that for Neutrino the validation is turned off by default for massively increased graph sync performance. This speedup comes at the risk of using an unvalidated view of the network for routing. Overwrites the value of routing.assumechanvalid if Neutrino is used. (default: false)"` + ValidateChannels bool `long:"validatechannels" description:"Validate every channel in the graph during sync by downloading the containing block. This is the inverse of routing.assumechanvalid, and overwrites it if Neutrino is used. On the Bitcoin BLAKE2b fork it is on by default (upstream LND leaves it off for a faster graph sync, at the risk of routing on an unvalidated view of the network). neutrino.validatechannels=false only takes effect in the config file, since a boolean flag cannot be set to false on the command line; there, routing.assumechanvalid=true turns validation off. (default: true)"` BroadcastTimeout time.Duration `long:"broadcasttimeout" description:"The amount of time to wait before giving up on a transaction broadcast attempt."` PersistFilters bool `long:"persistfilters" description:"Whether compact filters fetched from the P2P network should be persisted to disk."` diff --git a/sample-lnd.conf b/sample-lnd.conf index e333c08ec56..9acedfe3d1b 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -962,11 +962,12 @@ ; neutrino.persistfilters=false ; Validate every channel in the graph during sync by downloading the containing -; block. This is the inverse of routing.assumechanvalid, meaning that for -; Neutrino the validation is turned off by default for massively increased graph -; sync performance. This speedup comes at the risk of using an unvalidated view -; of the network for routing. Overwrites the value of routing.assumechanvalid if -; Neutrino is used. +; block. This is the inverse of routing.assumechanvalid, and overwrites it if +; Neutrino is used. On the Bitcoin BLAKE2b fork it is on by default (upstream LND +; leaves it off for a faster graph sync, at the risk of routing on an unvalidated +; view of the network). Setting it to false only takes effect here, in the config +; file, since a boolean flag cannot be set to false on the command line; there, +; routing.assumechanvalid=true turns validation off. ; neutrino.validatechannels=false ; Headers import for fast initial sync. When configured, neutrino imports block