diff --git a/cmd/core-chain/main.go b/cmd/core-chain/main.go new file mode 100644 index 0000000..7b09209 --- /dev/null +++ b/cmd/core-chain/main.go @@ -0,0 +1,18 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package main + +import ( + cli "forge.lthn.ai/core/cli/pkg/cli" + blockchain "forge.lthn.ai/core/go-blockchain" +) + +func main() { + cli.WithAppName("core-chain") + cli.Main( + cli.WithCommands("chain", blockchain.AddChainCommands), + ) +} diff --git a/cmd_explorer.go b/cmd_explorer.go new file mode 100644 index 0000000..17d1a21 --- /dev/null +++ b/cmd_explorer.go @@ -0,0 +1,66 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package blockchain + +import ( + "context" + "log" + "os" + "os/signal" + "path/filepath" + + cli "forge.lthn.ai/core/cli/pkg/cli" + store "forge.lthn.ai/core/go-store" + + "forge.lthn.ai/core/go-blockchain/chain" + "forge.lthn.ai/core/go-blockchain/tui" + "github.com/spf13/cobra" +) + +func newExplorerCmd(dataDir, seed *string, testnet *bool) *cobra.Command { + return &cobra.Command{ + Use: "explorer", + Short: "TUI block explorer", + Long: "Interactive terminal block explorer with live sync status.", + RunE: func(cmd *cobra.Command, args []string) error { + return runExplorer(*dataDir, *seed, *testnet) + }, + } +} + +func runExplorer(dataDir, seed string, testnet bool) error { + if err := ensureDataDir(dataDir); err != nil { + return err + } + + dbPath := filepath.Join(dataDir, "chain.db") + s, err := store.New(dbPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer s.Close() + + c := chain.New(s) + cfg, forks := resolveConfig(testnet, &seed) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + go syncLoop(ctx, c, &cfg, forks, seed) + + node := tui.NewNode(c) + status := tui.NewStatusModel(node) + explorer := tui.NewExplorerModel(c) + hints := tui.NewKeyHintsModel() + + frame := cli.NewFrame("HCF") + frame.Header(status) + frame.Content(explorer) + frame.Footer(hints) + frame.Run() + + return nil +} diff --git a/cmd_sync.go b/cmd_sync.go new file mode 100644 index 0000000..bcb5bfc --- /dev/null +++ b/cmd_sync.go @@ -0,0 +1,134 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package blockchain + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + + "forge.lthn.ai/core/go-blockchain/chain" + "forge.lthn.ai/core/go-process" + store "forge.lthn.ai/core/go-store" + "github.com/spf13/cobra" +) + +func newSyncCmd(dataDir, seed *string, testnet *bool) *cobra.Command { + var ( + daemon bool + stop bool + ) + + cmd := &cobra.Command{ + Use: "sync", + Short: "Headless P2P chain sync", + Long: "Sync the blockchain from P2P peers without the TUI explorer.", + RunE: func(cmd *cobra.Command, args []string) error { + if stop { + return stopSyncDaemon(*dataDir) + } + if daemon { + return runSyncDaemon(*dataDir, *seed, *testnet) + } + return runSyncForeground(*dataDir, *seed, *testnet) + }, + } + + cmd.Flags().BoolVar(&daemon, "daemon", false, "run as background daemon") + cmd.Flags().BoolVar(&stop, "stop", false, "stop a running sync daemon") + + return cmd +} + +func runSyncForeground(dataDir, seed string, testnet bool) error { + if err := ensureDataDir(dataDir); err != nil { + return err + } + + dbPath := filepath.Join(dataDir, "chain.db") + s, err := store.New(dbPath) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer s.Close() + + c := chain.New(s) + cfg, forks := resolveConfig(testnet, &seed) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + log.Println("Starting headless P2P sync...") + syncLoop(ctx, c, &cfg, forks, seed) + log.Println("Sync stopped.") + return nil +} + +func runSyncDaemon(dataDir, seed string, testnet bool) error { + if err := ensureDataDir(dataDir); err != nil { + return err + } + + pidFile := filepath.Join(dataDir, "sync.pid") + + d := process.NewDaemon(process.DaemonOptions{ + PIDFile: pidFile, + Registry: process.DefaultRegistry(), + RegistryEntry: process.DaemonEntry{ + Code: "forge.lthn.ai/core/go-blockchain", + Daemon: "sync", + }, + }) + + if err := d.Start(); err != nil { + return fmt.Errorf("daemon start: %w", err) + } + + dbPath := filepath.Join(dataDir, "chain.db") + s, err := store.New(dbPath) + if err != nil { + _ = d.Stop() + return fmt.Errorf("open store: %w", err) + } + defer s.Close() + + c := chain.New(s) + cfg, forks := resolveConfig(testnet, &seed) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + d.SetReady(true) + log.Println("Sync daemon started.") + + go syncLoop(ctx, c, &cfg, forks, seed) + + return d.Run(ctx) +} + +func stopSyncDaemon(dataDir string) error { + pidFile := filepath.Join(dataDir, "sync.pid") + pid, running := process.ReadPID(pidFile) + if pid == 0 || !running { + return fmt.Errorf("no running sync daemon found") + } + + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find process %d: %w", pid, err) + } + + if err := proc.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("signal process %d: %w", pid, err) + } + + log.Printf("Sent SIGTERM to sync daemon (PID %d)", pid) + return nil +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..a620543 --- /dev/null +++ b/commands.go @@ -0,0 +1,67 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package blockchain + +import ( + "fmt" + "os" + "path/filepath" + + "forge.lthn.ai/core/go-blockchain/config" + "github.com/spf13/cobra" +) + +// AddChainCommands registers the "chain" command group with explorer, +// sync, and mine subcommands. +func AddChainCommands(root *cobra.Command) { + var ( + dataDir string + seed string + testnet bool + ) + + chainCmd := &cobra.Command{ + Use: "chain", + Short: "Lethean blockchain node", + Long: "Manage the Lethean blockchain — sync, explore, and mine.", + } + + chainCmd.PersistentFlags().StringVar(&dataDir, "data-dir", defaultDataDir(), "blockchain data directory") + chainCmd.PersistentFlags().StringVar(&seed, "seed", "seeds.lthn.io:36942", "seed peer address (host:port)") + chainCmd.PersistentFlags().BoolVar(&testnet, "testnet", false, "use testnet") + + chainCmd.AddCommand( + newExplorerCmd(&dataDir, &seed, &testnet), + newSyncCmd(&dataDir, &seed, &testnet), + ) + + root.AddCommand(chainCmd) +} + +func resolveConfig(testnet bool, seed *string) (config.ChainConfig, []config.HardFork) { + if testnet { + if *seed == "seeds.lthn.io:36942" { + *seed = "localhost:46942" + } + return config.Testnet, config.TestnetForks + } + return config.Mainnet, config.MainnetForks +} + +func defaultDataDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".lethean" + } + return filepath.Join(home, ".lethean", "chain") +} + +func ensureDataDir(dataDir string) error { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return fmt.Errorf("create data dir: %w", err) + } + return nil +} diff --git a/commands_test.go b/commands_test.go new file mode 100644 index 0000000..6ef84a5 --- /dev/null +++ b/commands_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package blockchain + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddChainCommands_Good_RegistersParent(t *testing.T) { + root := &cobra.Command{Use: "test"} + AddChainCommands(root) + + chainCmd, _, err := root.Find([]string{"chain"}) + require.NoError(t, err) + assert.Equal(t, "chain", chainCmd.Name()) +} + +func TestAddChainCommands_Good_HasSubcommands(t *testing.T) { + root := &cobra.Command{Use: "test"} + AddChainCommands(root) + + chainCmd, _, _ := root.Find([]string{"chain"}) + + var names []string + for _, sub := range chainCmd.Commands() { + names = append(names, sub.Name()) + } + assert.Contains(t, names, "explorer") + assert.Contains(t, names, "sync") +} + +func TestAddChainCommands_Good_PersistentFlags(t *testing.T) { + root := &cobra.Command{Use: "test"} + AddChainCommands(root) + + chainCmd, _, _ := root.Find([]string{"chain"}) + + assert.NotNil(t, chainCmd.PersistentFlags().Lookup("data-dir")) + assert.NotNil(t, chainCmd.PersistentFlags().Lookup("seed")) + assert.NotNil(t, chainCmd.PersistentFlags().Lookup("testnet")) +} diff --git a/go.mod b/go.mod index 07255f3..0a9fd0f 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,29 @@ module forge.lthn.ai/core/go-blockchain go 1.26.0 require ( - forge.lthn.ai/core/cli v0.0.1 + forge.lthn.ai/core/cli v0.1.0 forge.lthn.ai/core/go-p2p v0.0.0-00010101000000-000000000000 - forge.lthn.ai/core/go-store v0.1.0 + forge.lthn.ai/core/go-process v0.1.2 + forge.lthn.ai/core/go-store v0.1.3 github.com/charmbracelet/bubbletea v1.3.10 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 ) require ( - forge.lthn.ai/core/go v0.0.1 // indirect - forge.lthn.ai/core/go-crypt v0.0.1 // indirect + forge.lthn.ai/core/go v0.1.0 // indirect + forge.lthn.ai/core/go-crypt v0.1.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/ansi v0.11.4 // indirect - github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.7.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.4.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -34,7 +36,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -42,15 +44,14 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.67.7 // indirect + modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.46.1 // indirect @@ -64,4 +65,6 @@ replace forge.lthn.ai/core/go-crypt => /Users/snider/Code/core/go-crypt replace forge.lthn.ai/core/go-p2p => /Users/snider/Code/core/go-p2p +replace forge.lthn.ai/core/go-process => /Users/snider/Code/core/go-process + replace forge.lthn.ai/core/go-store => /Users/snider/Code/core/go-store diff --git a/go.sum b/go.sum index 35b2112..c798a6a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc= +forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -6,20 +8,25 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -50,6 +57,7 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -81,6 +89,7 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -114,6 +123,7 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= diff --git a/sync_service.go b/sync_service.go new file mode 100644 index 0000000..7e51bf2 --- /dev/null +++ b/sync_service.go @@ -0,0 +1,111 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// SPDX-License-Identifier: EUPL-1.2 + +package blockchain + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "log" + "net" + "time" + + "forge.lthn.ai/core/go-blockchain/chain" + "forge.lthn.ai/core/go-blockchain/config" + "forge.lthn.ai/core/go-blockchain/p2p" + levin "forge.lthn.ai/core/go-p2p/node/levin" +) + +func syncLoop(ctx context.Context, c *chain.Chain, cfg *config.ChainConfig, forks []config.HardFork, seed string) { + opts := chain.SyncOptions{ + VerifySignatures: false, + Forks: forks, + } + + for { + select { + case <-ctx.Done(): + return + default: + } + + if err := syncOnce(ctx, c, cfg, opts, seed); err != nil { + log.Printf("sync: %v (retrying in 10s)", err) + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + } + continue + } + + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + } + } +} + +func syncOnce(ctx context.Context, c *chain.Chain, cfg *config.ChainConfig, opts chain.SyncOptions, seed string) error { + conn, err := net.DialTimeout("tcp", seed, 10*time.Second) + if err != nil { + return fmt.Errorf("dial %s: %w", seed, err) + } + defer conn.Close() + + lc := levin.NewConnection(conn) + + var peerIDBuf [8]byte + rand.Read(peerIDBuf[:]) + peerID := binary.LittleEndian.Uint64(peerIDBuf[:]) + + localHeight, _ := c.Height() + + req := p2p.HandshakeRequest{ + NodeData: p2p.NodeData{ + NetworkID: cfg.NetworkID, + PeerID: peerID, + LocalTime: time.Now().Unix(), + MyPort: 0, + }, + PayloadData: p2p.CoreSyncData{ + CurrentHeight: localHeight, + ClientVersion: config.ClientVersion, + NonPruningMode: true, + }, + } + payload, err := p2p.EncodeHandshakeRequest(&req) + if err != nil { + return fmt.Errorf("encode handshake: %w", err) + } + if err := lc.WritePacket(p2p.CommandHandshake, payload, true); err != nil { + return fmt.Errorf("write handshake: %w", err) + } + + hdr, data, err := lc.ReadPacket() + if err != nil { + return fmt.Errorf("read handshake: %w", err) + } + if hdr.Command != uint32(p2p.CommandHandshake) { + return fmt.Errorf("unexpected command %d", hdr.Command) + } + + var resp p2p.HandshakeResponse + if err := resp.Decode(data); err != nil { + return fmt.Errorf("decode handshake: %w", err) + } + + localSync := p2p.CoreSyncData{ + CurrentHeight: localHeight, + ClientVersion: config.ClientVersion, + NonPruningMode: true, + } + p2pConn := chain.NewLevinP2PConn(lc, resp.PayloadData.CurrentHeight, localSync) + + return c.P2PSync(ctx, p2pConn, opts) +}