DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package consensus_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go-blockchain/blockchain"
|
|
"go-blockchain/consensus"
|
|
"go-blockchain/identity"
|
|
)
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func newID(t *testing.T) *identity.Identity {
|
|
t.Helper()
|
|
id, err := identity.Generate()
|
|
if err != nil {
|
|
t.Fatalf("identity.Generate: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func genesisFor(id *identity.Identity) *blockchain.Block {
|
|
return blockchain.GenesisBlock(id.PubKeyHex(), id.PrivKey)
|
|
}
|
|
|
|
// network is a simple in-process message bus between engines.
|
|
// Each engine has a dedicated goroutine that delivers messages in FIFO order,
|
|
// which avoids the race where a PREPARE arrives before the PRE-PREPARE it
|
|
// depends on (which would cause the vote to be silently discarded).
|
|
type network struct {
|
|
mu sync.Mutex
|
|
queues []chan *blockchain.ConsensusMsg
|
|
engines []*consensus.Engine
|
|
}
|
|
|
|
// addEngine registers an engine and starts its delivery goroutine.
|
|
func (n *network) addEngine(e *consensus.Engine) {
|
|
ch := make(chan *blockchain.ConsensusMsg, 256)
|
|
n.mu.Lock()
|
|
n.engines = append(n.engines, e)
|
|
n.queues = append(n.queues, ch)
|
|
n.mu.Unlock()
|
|
go func() {
|
|
for msg := range ch {
|
|
e.HandleMessage(msg)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (n *network) broadcast(msg *blockchain.ConsensusMsg) {
|
|
n.mu.Lock()
|
|
queues := make([]chan *blockchain.ConsensusMsg, len(n.queues))
|
|
copy(queues, n.queues)
|
|
n.mu.Unlock()
|
|
for _, q := range queues {
|
|
cp := *msg // copy to avoid concurrent signature-nil race in verifyMsgSig
|
|
q <- &cp
|
|
}
|
|
}
|
|
|
|
// committedBlocks collects blocks committed by an engine into a channel.
|
|
type committedBlocks struct {
|
|
ch chan *blockchain.Block
|
|
}
|
|
|
|
func (cb *committedBlocks) onCommit(b *blockchain.Block) {
|
|
cb.ch <- b
|
|
}
|
|
|
|
func newCommitted() *committedBlocks {
|
|
return &committedBlocks{ch: make(chan *blockchain.Block, 16)}
|
|
}
|
|
|
|
func (cb *committedBlocks) waitOne(t *testing.T, timeout time.Duration) *blockchain.Block {
|
|
t.Helper()
|
|
select {
|
|
case b := <-cb.ch:
|
|
return b
|
|
case <-time.After(timeout):
|
|
t.Fatal("timed out waiting for committed block")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
|
|
// TestSingleValidatorCommit verifies that a single-validator network commits
|
|
// blocks immediately (f=0, quorum=1).
|
|
func TestSingleValidatorCommit(t *testing.T) {
|
|
id := newID(t)
|
|
genesis := genesisFor(id)
|
|
|
|
committed := newCommitted()
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1, // seqNum = genesis+1
|
|
committed.onCommit,
|
|
net.broadcast,
|
|
)
|
|
net.addEngine(engine)
|
|
|
|
// Propose block 1 from genesis.
|
|
engine.Propose(genesis)
|
|
|
|
b := committed.waitOne(t, 3*time.Second)
|
|
if b.Index != 1 {
|
|
t.Errorf("expected committed block index 1, got %d", b.Index)
|
|
}
|
|
if b.Validator != id.PubKeyHex() {
|
|
t.Errorf("wrong validator in committed block")
|
|
}
|
|
}
|
|
|
|
// TestSingleValidatorMultipleBlocks verifies sequential block commitment.
|
|
func TestSingleValidatorMultipleBlocks(t *testing.T) {
|
|
id := newID(t)
|
|
genesis := genesisFor(id)
|
|
|
|
committed := newCommitted()
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
committed.onCommit,
|
|
net.broadcast,
|
|
)
|
|
net.addEngine(engine)
|
|
|
|
prev := genesis
|
|
for i := uint64(1); i <= 3; i++ {
|
|
engine.Propose(prev)
|
|
b := committed.waitOne(t, 3*time.Second)
|
|
if b.Index != i {
|
|
t.Errorf("block %d: expected index %d, got %d", i, i, b.Index)
|
|
}
|
|
engine.SyncSeqNum(i + 1)
|
|
prev = b
|
|
}
|
|
}
|
|
|
|
// TestThreeValidatorCommit verifies a 3-node network reaches consensus.
|
|
// Messages are delivered synchronously through the in-process network bus.
|
|
// With f=1, quorum = ⌈2*3/3⌉ = 2.
|
|
func TestThreeValidatorCommit(t *testing.T) {
|
|
ids := []*identity.Identity{newID(t), newID(t), newID(t)}
|
|
valSet := []string{ids[0].PubKeyHex(), ids[1].PubKeyHex(), ids[2].PubKeyHex()}
|
|
|
|
genesis := genesisFor(ids[0]) // block 0 signed by ids[0]
|
|
|
|
committed := [3]*committedBlocks{newCommitted(), newCommitted(), newCommitted()}
|
|
net := &network{}
|
|
|
|
for i, id := range ids {
|
|
idx := i
|
|
engine := consensus.NewEngine(
|
|
id, valSet, 1,
|
|
func(b *blockchain.Block) { committed[idx].onCommit(b) },
|
|
net.broadcast,
|
|
)
|
|
net.addEngine(engine)
|
|
}
|
|
|
|
// Leader for seqNum=1, view=0 is valSet[(1+0)%3] = ids[1].
|
|
// Find and trigger the leader.
|
|
for i, e := range net.engines {
|
|
_ = i
|
|
e.Propose(genesis)
|
|
}
|
|
|
|
// All three should commit the same block.
|
|
timeout := 5 * time.Second
|
|
var commitIdx [3]uint64
|
|
for i := 0; i < 3; i++ {
|
|
b := committed[i].waitOne(t, timeout)
|
|
commitIdx[i] = b.Index
|
|
}
|
|
for i, idx := range commitIdx {
|
|
if idx != 1 {
|
|
t.Errorf("engine %d committed block at wrong index: got %d, want 1", i, idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAddTransactionAndPropose verifies that pending transactions appear in committed blocks.
|
|
func TestAddTransactionAndPropose(t *testing.T) {
|
|
id := newID(t)
|
|
sender := newID(t)
|
|
genesis := genesisFor(id)
|
|
|
|
committed := newCommitted()
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
committed.onCommit,
|
|
net.broadcast,
|
|
)
|
|
net.addEngine(engine)
|
|
|
|
// Build a valid signed transaction.
|
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
|
tx := &blockchain.Transaction{
|
|
ID: "test-tx-1",
|
|
Type: blockchain.EventTransfer,
|
|
From: sender.PubKeyHex(),
|
|
To: id.PubKeyHex(),
|
|
Amount: 1000,
|
|
Fee: blockchain.MinFee,
|
|
Payload: payload,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
// Sign with canonical bytes (matching validateTx).
|
|
signData, _ := json.Marshal(struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Amount uint64 `json:"amount"`
|
|
Fee uint64 `json:"fee"`
|
|
Payload []byte `json:"payload"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
|
tx.Signature = sender.Sign(signData)
|
|
|
|
if err := engine.AddTransaction(tx); err != nil {
|
|
t.Fatalf("AddTransaction: %v", err)
|
|
}
|
|
|
|
engine.Propose(genesis)
|
|
|
|
b := committed.waitOne(t, 3*time.Second)
|
|
if len(b.Transactions) != 1 {
|
|
t.Errorf("expected 1 transaction in committed block, got %d", len(b.Transactions))
|
|
}
|
|
if b.Transactions[0].ID != "test-tx-1" {
|
|
t.Errorf("wrong transaction in committed block: %s", b.Transactions[0].ID)
|
|
}
|
|
}
|
|
|
|
// TestDuplicateTransactionRejected verifies the mempool deduplicates by TX ID.
|
|
func TestDuplicateTransactionRejected(t *testing.T) {
|
|
id := newID(t)
|
|
sender := newID(t)
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
func(*blockchain.Block) {},
|
|
net.broadcast,
|
|
)
|
|
|
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
|
tx := &blockchain.Transaction{
|
|
ID: "dup-tx",
|
|
Type: blockchain.EventTransfer,
|
|
From: sender.PubKeyHex(),
|
|
To: id.PubKeyHex(),
|
|
Amount: 1000,
|
|
Fee: blockchain.MinFee,
|
|
Payload: payload,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
signData, _ := json.Marshal(struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Amount uint64 `json:"amount"`
|
|
Fee uint64 `json:"fee"`
|
|
Payload []byte `json:"payload"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
|
tx.Signature = sender.Sign(signData)
|
|
|
|
if err := engine.AddTransaction(tx); err != nil {
|
|
t.Fatalf("first AddTransaction: %v", err)
|
|
}
|
|
if err := engine.AddTransaction(tx); err == nil {
|
|
t.Fatal("expected duplicate transaction to be rejected, but it was accepted")
|
|
}
|
|
}
|
|
|
|
// TestInvalidTxRejected verifies that transactions with bad signatures are rejected.
|
|
func TestInvalidTxRejected(t *testing.T) {
|
|
id := newID(t)
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
func(*blockchain.Block) {},
|
|
net.broadcast,
|
|
)
|
|
|
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
|
tx := &blockchain.Transaction{
|
|
ID: "bad-sig-tx",
|
|
Type: blockchain.EventTransfer,
|
|
From: id.PubKeyHex(),
|
|
To: id.PubKeyHex(),
|
|
Amount: 1000,
|
|
Fee: blockchain.MinFee,
|
|
Payload: payload,
|
|
Timestamp: time.Now().UTC(),
|
|
Signature: []byte("not-a-real-signature"),
|
|
}
|
|
if err := engine.AddTransaction(tx); err == nil {
|
|
t.Fatal("expected transaction with bad signature to be rejected")
|
|
}
|
|
}
|
|
|
|
// TestFeeBelowMinimumRejected verifies that sub-minimum fees are rejected by the engine.
|
|
func TestFeeBelowMinimumRejected(t *testing.T) {
|
|
id := newID(t)
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
func(*blockchain.Block) {},
|
|
net.broadcast,
|
|
)
|
|
|
|
payload, _ := json.Marshal(blockchain.TransferPayload{})
|
|
tx := &blockchain.Transaction{
|
|
ID: "low-fee-tx",
|
|
Type: blockchain.EventTransfer,
|
|
From: id.PubKeyHex(),
|
|
To: id.PubKeyHex(),
|
|
Amount: 1000,
|
|
Fee: blockchain.MinFee - 1, // one µT below minimum
|
|
Payload: payload,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
signData, _ := json.Marshal(struct {
|
|
ID string `json:"id"`
|
|
Type blockchain.EventType `json:"type"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Amount uint64 `json:"amount"`
|
|
Fee uint64 `json:"fee"`
|
|
Payload []byte `json:"payload"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp})
|
|
tx.Signature = id.Sign(signData)
|
|
|
|
if err := engine.AddTransaction(tx); err == nil {
|
|
t.Fatal("expected transaction with fee below MinFee to be rejected")
|
|
}
|
|
}
|
|
|
|
// TestUpdateValidators verifies that UpdateValidators takes effect on the next round.
|
|
// We start with a 1-validator network (quorum=1), commit one block, then shrink
|
|
// the set back to the same single validator — confirming the hot-reload path runs
|
|
// without panicking and that the engine continues to commit blocks normally.
|
|
func TestUpdateValidators(t *testing.T) {
|
|
id := newID(t)
|
|
genesis := genesisFor(id)
|
|
|
|
committed := newCommitted()
|
|
net := &network{}
|
|
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
committed.onCommit,
|
|
net.broadcast,
|
|
)
|
|
net.engines = []*consensus.Engine{engine}
|
|
|
|
// Block 1.
|
|
engine.Propose(genesis)
|
|
b1 := committed.waitOne(t, 3*time.Second)
|
|
engine.SyncSeqNum(2)
|
|
|
|
// Hot-reload: same single validator — ensures the method is exercised.
|
|
engine.UpdateValidators([]string{id.PubKeyHex()})
|
|
|
|
// Block 2 should still commit with the reloaded set.
|
|
engine.Propose(b1)
|
|
b2 := committed.waitOne(t, 3*time.Second)
|
|
if b2.Index != 2 {
|
|
t.Errorf("expected block index 2 after validator update, got %d", b2.Index)
|
|
}
|
|
}
|
|
|
|
// TestSyncSeqNum verifies that SyncSeqNum advances the engine's expected block index.
|
|
func TestSyncSeqNum(t *testing.T) {
|
|
id := newID(t)
|
|
net := &network{}
|
|
|
|
committed := newCommitted()
|
|
engine := consensus.NewEngine(
|
|
id,
|
|
[]string{id.PubKeyHex()},
|
|
1,
|
|
committed.onCommit,
|
|
net.broadcast,
|
|
)
|
|
net.engines = []*consensus.Engine{engine}
|
|
|
|
// Simulate receiving a chain sync that jumps to block 5.
|
|
engine.SyncSeqNum(5)
|
|
|
|
genesis := genesisFor(id)
|
|
// Build a fake block at index 5 to propose from.
|
|
b5 := &blockchain.Block{
|
|
Index: 4,
|
|
Timestamp: time.Now().UTC(),
|
|
Transactions: []*blockchain.Transaction{},
|
|
PrevHash: genesis.Hash,
|
|
Validator: id.PubKeyHex(),
|
|
TotalFees: 0,
|
|
}
|
|
b5.ComputeHash()
|
|
b5.Sign(id.PrivKey)
|
|
|
|
engine.Propose(b5)
|
|
b := committed.waitOne(t, 3*time.Second)
|
|
if b.Index != 5 {
|
|
t.Errorf("expected committed block index 5, got %d", b.Index)
|
|
}
|
|
}
|