go-blockchain/mining/miner_test.go

350 lines
9 KiB
Go
Raw Permalink Normal View History

// 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 mining
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"sync/atomic"
"testing"
"time"
"dappco.re/go/core/blockchain/rpc"
"dappco.re/go/core/blockchain/types"
"dappco.re/go/core/blockchain/wire"
"github.com/stretchr/testify/assert"
)
type mockProvider struct {
templates []*rpc.BlockTemplateResponse
infos []*rpc.DaemonInfo
templateCalls atomic.Int64
infoCalls atomic.Int64
submitCalls atomic.Int64
submitted []string
}
func (m *mockProvider) GetBlockTemplate(walletAddr string) (*rpc.BlockTemplateResponse, error) {
idx := int(m.templateCalls.Add(1) - 1)
if idx >= len(m.templates) {
idx = len(m.templates) - 1
}
return m.templates[idx], nil
}
func (m *mockProvider) SubmitBlock(hexBlob string) error {
m.submitCalls.Add(1)
m.submitted = append(m.submitted, hexBlob)
return nil
}
func (m *mockProvider) GetInfo() (*rpc.DaemonInfo, error) {
idx := int(m.infoCalls.Add(1) - 1)
if idx >= len(m.infos) {
idx = len(m.infos) - 1
}
return m.infos[idx], nil
}
// minimalBlockBlob returns a serialised block that can be decoded by wire.DecodeBlock.
func minimalBlockBlob(t *testing.T) []byte {
t.Helper()
block := types.Block{
BlockHeader: types.BlockHeader{
MajorVersion: 1,
Nonce: 0,
Timestamp: 1770897600,
},
MinerTx: types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 100}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 1000000000000,
Target: types.TxOutToKey{},
}},
Extra: []byte{0x00}, // varint 0 = empty variant vector
Attachment: []byte{0x00}, // varint 0 = empty variant vector
},
}
var buf bytes.Buffer
enc := wire.NewEncoder(&buf)
wire.EncodeBlock(enc, &block)
if enc.Err() != nil {
t.Fatalf("encode block: %v", enc.Err())
}
return buf.Bytes()
}
func TestNewMiner_Good(t *testing.T) {
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 5 * time.Second,
}
m := NewMiner(cfg)
assert.NotNil(t, m)
stats := m.Stats()
assert.Equal(t, float64(0), stats.Hashrate)
assert.Equal(t, uint64(0), stats.BlocksFound)
assert.Equal(t, uint64(0), stats.Height)
assert.Equal(t, uint64(0), stats.Difficulty)
assert.Equal(t, time.Duration(0), stats.Uptime)
}
func TestNewMiner_Good_DefaultPollInterval(t *testing.T) {
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
}
m := NewMiner(cfg)
// PollInterval should default to 3s.
assert.Equal(t, 3*time.Second, m.cfg.PollInterval)
}
func TestMiner_Start_Good_ShutdownOnCancel(t *testing.T) {
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{
Difficulty: "1",
Height: 100,
BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)),
Status: "OK",
},
},
infos: []*rpc.DaemonInfo{{Height: 100}},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := m.Start(ctx)
assert.ErrorIs(t, err, context.DeadlineExceeded)
stats := m.Stats()
assert.Equal(t, uint64(100), stats.Height)
assert.Equal(t, uint64(1), stats.Difficulty)
}
func TestMiner_Start_Good_TemplateRefresh(t *testing.T) {
// First call returns height 100, second returns 101 — triggers refresh.
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "1", Height: 100, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
{Difficulty: "2", Height: 101, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
},
infos: []*rpc.DaemonInfo{
{Height: 100},
{Height: 101}, // triggers refresh
{Height: 101},
},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 50 * time.Millisecond,
Provider: mock,
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
_ = m.Start(ctx)
assert.GreaterOrEqual(t, mock.templateCalls.Load(), int64(2))
}
func TestMiner_Start_Good_BlockFound(t *testing.T) {
// With difficulty=1, every hash is valid — should find a block immediately.
var foundHeight uint64
var foundHash types.Hash
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "1", Height: 50, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
},
infos: []*rpc.DaemonInfo{{Height: 50}},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
OnBlockFound: func(height uint64, hash types.Hash) {
foundHeight = height
foundHash = hash
},
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Start will find a block (diff=1), submit it, then fetch another template.
// The second template fetch is the same, so it finds another block.
// Eventually the context times out.
_ = m.Start(ctx)
assert.Equal(t, uint64(50), foundHeight)
assert.False(t, foundHash.IsZero())
assert.GreaterOrEqual(t, mock.submitCalls.Load(), int64(1))
assert.GreaterOrEqual(t, m.Stats().BlocksFound, uint64(1))
}
func TestMiner_Start_Good_StatsUpdate(t *testing.T) {
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "1", Height: 200, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
},
infos: []*rpc.DaemonInfo{{Height: 200}},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_ = m.Start(ctx)
stats := m.Stats()
assert.Greater(t, stats.Hashrate, float64(0))
assert.Greater(t, stats.Uptime, time.Duration(0))
}
func TestMiner_Start_Bad_InvalidDifficulty(t *testing.T) {
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "not_a_number", Height: 100, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
},
infos: []*rpc.DaemonInfo{{Height: 100}},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
err := m.Start(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid difficulty")
}
func TestMiner_Start_Bad_InvalidBlob(t *testing.T) {
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "1", Height: 100, BlockTemplateBlob: "not_valid_hex!", Status: "OK"},
},
infos: []*rpc.DaemonInfo{{Height: 100}},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
err := m.Start(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid template blob hex")
}
type failingSubmitter struct {
mockProvider
}
func (f *failingSubmitter) SubmitBlock(hexBlob string) error {
return fmt.Errorf("connection refused")
}
func TestMiner_Start_Good_OnNewTemplate(t *testing.T) {
var tmplHeight uint64
var tmplDiff uint64
mock := &mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "42", Height: 300, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
},
infos: []*rpc.DaemonInfo{{Height: 300}},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
OnNewTemplate: func(height uint64, difficulty uint64) {
tmplHeight = height
tmplDiff = difficulty
},
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_ = m.Start(ctx)
assert.Equal(t, uint64(300), tmplHeight)
assert.Equal(t, uint64(42), tmplDiff)
}
func TestMiner_Start_Bad_SubmitFails(t *testing.T) {
mock := &failingSubmitter{
mockProvider: mockProvider{
templates: []*rpc.BlockTemplateResponse{
{Difficulty: "1", Height: 100, BlockTemplateBlob: hex.EncodeToString(minimalBlockBlob(t)), Status: "OK"},
},
infos: []*rpc.DaemonInfo{{Height: 100}},
},
}
cfg := Config{
DaemonURL: "http://localhost:46941",
WalletAddr: "iTHNtestaddr",
PollInterval: 100 * time.Millisecond,
Provider: mock,
}
m := NewMiner(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := m.Start(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "submit block")
}