chore: initial commit for v0.0.1
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
This commit is contained in:
438
consensus/pbft_test.go
Normal file
438
consensus/pbft_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user