diff --git a/Makefile b/Makefile index 54a77cb..6c57f61 100644 --- a/Makefile +++ b/Makefile @@ -51,8 +51,7 @@ test: go test ./... test-e2e: - cd $(TOOLS_DIR); go install -trimpath $(BABYLON_PKG); - go test -mod=readonly -timeout=25m -v $(PACKAGES_E2E) -count=1 --tags=e2e + go test -mod=readonly -timeout=25m -failfast -v $(PACKAGES_E2E) -count=1 --tags=e2e proto-gen: @$(call print, "Compiling protos.") diff --git a/itest/bitcoind_node_setup.go b/itest/bitcoind_node_setup.go index f757a77..30d9084 100644 --- a/itest/bitcoind_node_setup.go +++ b/itest/bitcoind_node_setup.go @@ -3,6 +3,7 @@ package e2etest import ( "encoding/json" "fmt" + "github.com/ory/dockertest/v3" "os" "strconv" "strings" @@ -34,16 +35,14 @@ type BitcoindTestHandler struct { m *containers.Manager } -func NewBitcoindHandler(t *testing.T) *BitcoindTestHandler { - m, err := containers.NewManager() - require.NoError(t, err) +func NewBitcoindHandler(t *testing.T, m *containers.Manager) *BitcoindTestHandler { return &BitcoindTestHandler{ t: t, m: m, } } -func (h *BitcoindTestHandler) Start() { +func (h *BitcoindTestHandler) Start() *dockertest.Resource { tempPath, err := os.MkdirTemp("", "bitcoind-staker-test-*") require.NoError(h.t, err) @@ -51,7 +50,7 @@ func (h *BitcoindTestHandler) Start() { _ = os.RemoveAll(tempPath) }) - _, err = h.m.RunBitcoindResource(tempPath) + bitcoinResource, err := h.m.RunBitcoindResource(h.t, tempPath) require.NoError(h.t, err) h.t.Cleanup(func() { @@ -64,6 +63,7 @@ func (h *BitcoindTestHandler) Start() { return err == nil }, startTimeout, 500*time.Millisecond, "bitcoind did not start") + return bitcoinResource } func (h *BitcoindTestHandler) GetBlockCount() (int, error) { diff --git a/itest/containers/config.go b/itest/containers/config.go index c93dbd0..ab3ab72 100644 --- a/itest/containers/config.go +++ b/itest/containers/config.go @@ -1,24 +1,36 @@ package containers +import ( + "github.com/babylonlabs-io/btc-staker/itest/testutil" + "github.com/stretchr/testify/require" + "testing" +) + // ImageConfig contains all images and their respective tags // needed for running e2e tests. type ImageConfig struct { BitcoindRepository string BitcoindVersion string + BabylonRepository string + BabylonVersion string } //nolint:deadcode const ( dockerBitcoindRepository = "lncm/bitcoind" dockerBitcoindVersionTag = "v26.0" + dockerBabylondRepository = "babylonlabs/babylond" ) // NewImageConfig returns ImageConfig needed for running e2e test. -func NewImageConfig() ImageConfig { - config := ImageConfig{ +func NewImageConfig(t *testing.T) ImageConfig { + babylondVersion, err := testutil.GetBabylonVersion() + require.NoError(t, err) + + return ImageConfig{ BitcoindRepository: dockerBitcoindRepository, BitcoindVersion: dockerBitcoindVersionTag, + BabylonRepository: dockerBabylondRepository, + BabylonVersion: babylondVersion, } - return config - } diff --git a/itest/containers/containers.go b/itest/containers/containers.go index 2e4100b..d490131 100644 --- a/itest/containers/containers.go +++ b/itest/containers/containers.go @@ -4,7 +4,12 @@ import ( "bytes" "context" "fmt" + bbn "github.com/babylonlabs-io/babylon/types" + "github.com/babylonlabs-io/btc-staker/itest/testutil" + "github.com/btcsuite/btcd/btcec/v2" "regexp" + "strconv" + "strings" "testing" "time" @@ -14,7 +19,8 @@ import ( ) const ( - bitcoindContainerName = "bitcoind-test" + bitcoindContainerName = "bitcoind" + babylondContainerName = "babylond" ) var errRegex = regexp.MustCompile(`(E|e)rror`) @@ -29,9 +35,9 @@ type Manager struct { // NewManager creates a new Manager instance and initializes // all Docker specific utilities. Returns an error if initialization fails. -func NewManager() (docker *Manager, err error) { +func NewManager(t *testing.T) (docker *Manager, err error) { docker = &Manager{ - cfg: NewImageConfig(), + cfg: NewImageConfig(t), resources: make(map[string]*dockertest.Resource), } docker.pool, err = dockertest.NewPool("") @@ -122,32 +128,23 @@ func (m *Manager) ExecCmd(t *testing.T, containerName string, command []string) } func (m *Manager) RunBitcoindResource( + t *testing.T, bitcoindCfgPath string, ) (*dockertest.Resource, error) { bitcoindResource, err := m.pool.RunWithOptions( &dockertest.RunOptions{ - Name: bitcoindContainerName, + Name: fmt.Sprintf("%s-%s", bitcoindContainerName, t.Name()), Repository: m.cfg.BitcoindRepository, Tag: m.cfg.BitcoindVersion, User: "root:root", Mounts: []string{ fmt.Sprintf("%s/:/data/.bitcoin", bitcoindCfgPath), }, - ExposedPorts: []string{ - "8332", - "8333", - "28332", - "28333", - "18443", - "18444", + Labels: map[string]string{ + "e2e": "bitcoind", }, - PortBindings: map[docker.Port][]docker.PortBinding{ - "8332/tcp": {{HostIP: "", HostPort: "8332"}}, - "8333/tcp": {{HostIP: "", HostPort: "8333"}}, - "28332/tcp": {{HostIP: "", HostPort: "28332"}}, - "28333/tcp": {{HostIP: "", HostPort: "28333"}}, - "18443/tcp": {{HostIP: "", HostPort: "18443"}}, - "18444/tcp": {{HostIP: "", HostPort: "18444"}}, + ExposedPorts: []string{ + "18443/tcp", }, Cmd: []string{ "-regtest", @@ -158,15 +155,141 @@ func (m *Manager) RunBitcoindResource( "-rpcbind=0.0.0.0", }, }, + func(config *docker.HostConfig) { + config.PortBindings = map[docker.Port][]docker.PortBinding{ + "18443/tcp": {{HostIP: "", HostPort: strconv.Itoa(testutil.AllocateUniquePort(t))}}, // only expose what we need + } + config.PublishAllPorts = false // because in dockerfile they already expose them + }, noRestart, ) if err != nil { return nil, err } m.resources[bitcoindContainerName] = bitcoindResource + return bitcoindResource, nil } +// RunBabylondResource starts a babylond container +func (m *Manager) RunBabylondResource( + t *testing.T, + mounthPath string, + coventantQuorum int, + baseHeaderHex string, + slashingPkScript string, + covenantPk1 *btcec.PublicKey, + covenantPk2 *btcec.PublicKey, + covenantPk3 *btcec.PublicKey, +) (*dockertest.Resource, error) { + covenantPks := []*bbn.BIP340PubKey{ + bbn.NewBIP340PubKeyFromBTCPK(covenantPk1), + bbn.NewBIP340PubKeyFromBTCPK(covenantPk2), + bbn.NewBIP340PubKeyFromBTCPK(covenantPk3), + } + + var covenantPksStr []string + for _, pk := range covenantPks { + covenantPksStr = append(covenantPksStr, pk.MarshalHex()) + } + + cmd := []string{ + "sh", "-c", fmt.Sprintf( + "babylond testnet --v=1 --output-dir=/home --starting-ip-address=192.168.10.2 "+ + "--keyring-backend=test --chain-id=chain-test --btc-finalization-timeout=4 "+ + "--btc-confirmation-depth=2 --additional-sender-account --btc-network=regtest "+ + "--min-staking-time-blocks=200 --min-staking-amount-sat=10000 "+ + "--slashing-pk-script=%s --btc-base-header=%s --covenant-quorum=%d "+ + "--covenant-pks=%s && chmod -R 777 /home && "+ + "babylond start --home=/home/node0/babylond", + slashingPkScript, baseHeaderHex, coventantQuorum, strings.Join(covenantPksStr, ",")), + } + + resource, err := m.pool.RunWithOptions( + &dockertest.RunOptions{ + Name: fmt.Sprintf("%s-%s", babylondContainerName, t.Name()), + Repository: m.cfg.BabylonRepository, + Tag: m.cfg.BabylonVersion, + Labels: map[string]string{ + "e2e": "babylond", + }, + User: "root:root", + Mounts: []string{ + fmt.Sprintf("%s/:/home/", mounthPath), + }, + ExposedPorts: []string{ + "9090/tcp", // only expose what we need + "26657/tcp", + }, + Cmd: cmd, + }, + func(config *docker.HostConfig) { + config.PortBindings = map[docker.Port][]docker.PortBinding{ + "9090/tcp": {{HostIP: "", HostPort: strconv.Itoa(testutil.AllocateUniquePort(t))}}, + "26657/tcp": {{HostIP: "", HostPort: strconv.Itoa(testutil.AllocateUniquePort(t))}}, + } + }, + noRestart, + ) + if err != nil { + return nil, err + } + + m.resources[babylondContainerName] = resource + + return resource, nil +} + +// BabylondTxBankSend send transaction to an address from the node address. +func (m *Manager) BabylondTxBankSend(t *testing.T, addr, coins, walletName string) (bytes.Buffer, bytes.Buffer, error) { + flags := []string{ + "babylond", + "tx", + "bank", + "send", + walletName, + addr, + coins, + "--keyring-backend=test", + "--home=/home/node0/babylond", + "--log_level=debug", + "--chain-id=chain-test", + "-b=sync", "--yes", "--gas-prices=10ubbn", + } + + return m.ExecCmd(t, babylondContainerName, flags) +} + +// BabylondTxBankMultiSend send transaction to an addresses from the node address. +func (m *Manager) BabylondTxBankMultiSend(t *testing.T, walletName string, coins string, addresses ...string) (bytes.Buffer, bytes.Buffer, error) { + // babylond tx bank multi-send [from_key_or_address] [to_address_1 to_address_2 ...] [amount] [flags] + switch len(addresses) { + case 0: + return bytes.Buffer{}, bytes.Buffer{}, nil + case 1: + return m.BabylondTxBankSend(t, addresses[0], coins, walletName) + } + + flags := []string{ + "babylond", + "tx", + "bank", + "multi-send", + walletName, + } + flags = append(flags, addresses...) + flags = append(flags, + coins, + "--keyring-backend=test", + "--home=/home/node0/babylond", + "--log_level=debug", + "--chain-id=chain-test", + "-b=sync", "--yes", "--gas-prices=10ubbn", + ) + + return m.ExecCmd(t, babylondContainerName, flags) +} + // ClearResources removes all outstanding Docker resources created by the Manager. func (m *Manager) ClearResources() error { for _, resource := range m.resources { diff --git a/itest/e2e_test.go b/itest/e2e_test.go index d9ca01e..638d5d2 100644 --- a/itest/e2e_test.go +++ b/itest/e2e_test.go @@ -8,6 +8,9 @@ import ( "context" "encoding/hex" "errors" + "fmt" + "github.com/babylonlabs-io/btc-staker/itest/containers" + "github.com/babylonlabs-io/btc-staker/itest/testutil" "math/rand" "net" "net/netip" @@ -76,7 +79,7 @@ func keyToAddr(key *btcec.PrivateKey, net *chaincfg.Params) (btcutil.Address, er return pubKeyAddr.AddressPubKeyHash(), nil } -func defaultStakerConfig(t *testing.T, walletName, passphrase string) (*stakercfg.Config, *rpcclient.Client) { +func defaultStakerConfig(t *testing.T, walletName, passphrase, host string) (*stakercfg.Config, *rpcclient.Client) { defaultConfig := stakercfg.DefaultConfig() // both wallet and node are bicoind @@ -92,6 +95,10 @@ func defaultStakerConfig(t *testing.T, walletName, passphrase string) (*stakercf bitcoindUser := "user" bitcoindPass := "pass" + if host != "" { + bitcoindHost = host + } + // Wallet configuration defaultConfig.WalletRpcConfig.Host = bitcoindHost defaultConfig.WalletRpcConfig.User = bitcoindUser @@ -150,6 +157,7 @@ type TestManager struct { CovenantPrivKeys []*btcec.PrivateKey BitcoindHandler *BitcoindTestHandler TestRpcClient *rpcclient.Client + manger *containers.Manager } type testStakingData struct { @@ -188,7 +196,7 @@ func (tm *TestManager) getTestStakingData( strAddrs[i] = fpAddr.String() } - err = tm.BabylonHandler.BabylonNode.TxBankMultiSend("1000000ubbn", strAddrs...) + _, _, err = tm.manger.BabylondTxBankMultiSend(t, "node0", "1000000ubbn", strAddrs...) require.NoError(t, err) return &testStakingData{ @@ -221,13 +229,16 @@ func StartManager( t *testing.T, numMatureOutputsInWallet uint32, ) *TestManager { - h := NewBitcoindHandler(t) - h.Start() + manager, err := containers.NewManager(t) + require.NoError(t, err) + + bitcoindHandler := NewBitcoindHandler(t, manager) + bitcoind := bitcoindHandler.Start() passphrase := "pass" walletName := "test-wallet" - _ = h.CreateWallet(walletName, passphrase) + _ = bitcoindHandler.CreateWallet(walletName, passphrase) // only outputs which are 100 deep are mature - br := h.GenerateBlocks(int(numMatureOutputsInWallet) + 100) + br := bitcoindHandler.GenerateBlocks(int(numMatureOutputsInWallet) + 100) minerAddressDecoded, err := btcutil.DecodeAddress(br.Address, regtestParams) require.NoError(t, err) @@ -249,28 +260,35 @@ func StartManager( pkScript, err := txscript.PayToAddrScript(minerAddressDecoded) require.NoError(t, err) - bh, err := NewBabylonNodeHandler( + tmpDir, err := testutil.TempDir(t) + require.NoError(t, err) + babylond, err := manager.RunBabylondResource( + t, + tmpDir, quorum, + baseHeaderHex, + hex.EncodeToString(pkScript), // all slashing will be sent back to wallet coventantPrivKeys[0].PubKey(), coventantPrivKeys[1].PubKey(), coventantPrivKeys[2].PubKey(), - // all slashings will be sent back to wallet - hex.EncodeToString(pkScript), - baseHeaderHex, ) require.NoError(t, err) - err = bh.Start() - require.NoError(t, err) + rpcHost := fmt.Sprintf("127.0.0.1:%s", bitcoind.GetPort("18443/tcp")) + cfg, c := defaultStakerConfig(t, walletName, passphrase, rpcHost) + cfg.BtcNodeBackendConfig.Bitcoind.RPCHost = rpcHost + cfg.WalletRpcConfig.Host = fmt.Sprintf("127.0.0.1:%s", bitcoind.GetPort("18443/tcp")) - cfg, c := defaultStakerConfig(t, walletName, passphrase) + // update port with the dynamically allocated one from docker + cfg.BabylonConfig.RPCAddr = fmt.Sprintf("http://localhost:%s", babylond.GetPort("26657/tcp")) + cfg.BabylonConfig.GRPCAddr = fmt.Sprintf("https://localhost:%s", babylond.GetPort("9090/tcp")) logger := logrus.New() logger.SetLevel(logrus.DebugLevel) logger.Out = os.Stdout // babylon configs for sending transactions - cfg.BabylonConfig.KeyDirectory = bh.GetNodeDataDir() + cfg.BabylonConfig.KeyDirectory = filepath.Join(tmpDir, "node0", "babylond") // need to use this one to send otherwise we will have account sequence mismatch // errors cfg.BabylonConfig.Key = "test-spending-key" @@ -312,12 +330,12 @@ func StartManager( interceptor, err := signal.Intercept() require.NoError(t, err) - addressString := "127.0.0.1:15001" + addressString := fmt.Sprintf("127.0.0.1:%d", testutil.AllocateUniquePort(t)) addrPort := netip.MustParseAddrPort(addressString) address := net.TCPAddrFromAddrPort(addrPort) - cfg.RpcListeners = append(cfg.RpcListeners, address) + cfg.RpcListeners = append(cfg.RpcListeners, address) // todo(lazar): check with konrad who uses this - service := service.NewStakerService( + stakerService := service.NewStakerService( cfg, stakerApp, logger, @@ -329,7 +347,7 @@ func StartManager( wg.Add(1) go func() { defer wg.Done() - err := service.RunUntilShutdown() + err := stakerService.RunUntilShutdown() if err != nil { t.Fatalf("Error running server: %v", err) } @@ -341,7 +359,6 @@ func StartManager( require.NoError(t, err) return &TestManager{ - BabylonHandler: bh, Config: cfg, Db: dbbackend, Sa: stakerApp, @@ -353,15 +370,16 @@ func StartManager( serviceAddress: addressString, StakerClient: stakerClient, CovenantPrivKeys: coventantPrivKeys, - BitcoindHandler: h, + BitcoindHandler: bitcoindHandler, TestRpcClient: c, + manger: manager, } } func (tm *TestManager) Stop(t *testing.T) { tm.serverStopper.RequestShutdown() tm.wg.Wait() - err := tm.BabylonHandler.Stop() + err := tm.manger.ClearResources() require.NoError(t, err) err = os.RemoveAll(tm.Config.DBConfig.DBPath) require.NoError(t, err) @@ -1505,8 +1523,11 @@ func containsOutput(outputs []walletcontroller.Utxo, address string, amount btcu } func TestBitcoindWalletRpcApi(t *testing.T) { - h := NewBitcoindHandler(t) - h.Start() + t.Parallel() + manager, err := containers.NewManager(t) + require.NoError(t, err) + h := NewBitcoindHandler(t, manager) + bitcoind := h.Start() passphrase := "pass" numMatureOutputs := 1 walletName := "test-wallet" @@ -1516,7 +1537,7 @@ func TestBitcoindWalletRpcApi(t *testing.T) { // hardcoded config scfg := stakercfg.DefaultConfig() - scfg.WalletRpcConfig.Host = "127.0.0.1:18443" + scfg.WalletRpcConfig.Host = fmt.Sprintf("127.0.0.1:%s", bitcoind.GetPort("18443/tcp")) scfg.WalletRpcConfig.User = "user" scfg.WalletRpcConfig.Pass = "pass" scfg.ActiveNetParams.Name = "regtest" @@ -1578,12 +1599,17 @@ func TestBitcoindWalletRpcApi(t *testing.T) { } func TestBitcoindWalletBip322Signing(t *testing.T) { - h := NewBitcoindHandler(t) - h.Start() + t.Parallel() + manager, err := containers.NewManager(t) + require.NoError(t, err) + h := NewBitcoindHandler(t, manager) + bitcoind := h.Start() passphrase := "pass" walletName := "test-wallet" _ = h.CreateWallet(walletName, passphrase) - cfg, c := defaultStakerConfig(t, walletName, passphrase) + + rpcHost := fmt.Sprintf("127.0.0.1:%s", bitcoind.GetPort("18443/tcp")) + cfg, c := defaultStakerConfig(t, walletName, passphrase, rpcHost) segwitAddress, err := c.GetNewAddress("") require.NoError(t, err) diff --git a/itest/testutil/dir.go b/itest/testutil/dir.go new file mode 100644 index 0000000..1ba5b68 --- /dev/null +++ b/itest/testutil/dir.go @@ -0,0 +1,25 @@ +package testutil + +import ( + "os" + "testing" +) + +// TempDir creates a tmp dir +func TempDir(t *testing.T) (string, error) { + t.Helper() + tempPath, err := os.MkdirTemp(os.TempDir(), "babylon-test-*") + if err != nil { + return "", err + } + + if err = os.Chmod(tempPath, 0777); err != nil { + return "", err + } + + t.Cleanup(func() { + _ = os.RemoveAll(tempPath) + }) + + return tempPath, err +} diff --git a/itest/testutil/port.go b/itest/testutil/port.go new file mode 100644 index 0000000..19ad7f7 --- /dev/null +++ b/itest/testutil/port.go @@ -0,0 +1,61 @@ +package testutil + +import ( + "fmt" + mrand "math/rand/v2" + "net" + "sync" + "testing" +) + +// Track allocated ports, protected by a mutex +var ( + allocatedPorts = make(map[int]struct{}) + portMutex sync.Mutex +) + +// AllocateUniquePort tries to find an available TCP port on the localhost +// by testing multiple random ports within a specified range. +func AllocateUniquePort(t *testing.T) int { + randPort := func(base, spread int) int { + return base + mrand.IntN(spread) + } + + // Base port and spread range for port selection + const ( + basePort = 20000 + portRange = 30000 + ) + + // Try up to 10 times to find an available port + for i := 0; i < 10; i++ { + port := randPort(basePort, portRange) + + // Lock the mutex to check and modify the shared map + portMutex.Lock() + if _, exists := allocatedPorts[port]; exists { + // Port already allocated, try another one + portMutex.Unlock() + continue + } + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + portMutex.Unlock() + continue + } + + allocatedPorts[port] = struct{}{} + portMutex.Unlock() + + if err := listener.Close(); err != nil { + continue + } + + return port + } + + // If no available port was found, fail the test + t.Fatalf("failed to find an available port in range %d-%d", basePort, basePort+portRange) + return 0 +} diff --git a/itest/testutil/version.go b/itest/testutil/version.go new file mode 100644 index 0000000..5a2dfaf --- /dev/null +++ b/itest/testutil/version.go @@ -0,0 +1,32 @@ +package testutil + +import ( + "fmt" + "golang.org/x/mod/modfile" + "os" + "path/filepath" +) + +// GetBabylonVersion returns babylond version from go.mod +func GetBabylonVersion() (string, error) { + goModPath := filepath.Join("..", "go.mod") + data, err := os.ReadFile(goModPath) + if err != nil { + return "", err + } + + // Parse the go.mod file + modFile, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return "", err + } + + const modName = "github.com/babylonlabs-io/babylon" + for _, require := range modFile.Require { + if require.Mod.Path == modName { + return require.Mod.Version, nil + } + } + + return "", fmt.Errorf("module %s not found", modName) +}