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
797 lines
24 KiB
Go
797 lines
24 KiB
Go
package blockchain_test
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"go-blockchain/blockchain"
|
|
"go-blockchain/identity"
|
|
)
|
|
|
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
// newChain opens a fresh BadgerDB-backed chain in a temp directory and
|
|
// registers a cleanup that closes the DB then removes the directory.
|
|
// We avoid t.TempDir() because on Windows, BadgerDB's mmap'd value-log files
|
|
// may still be held open for a brief moment after Close() returns, causing
|
|
// the automatic TempDir cleanup to fail with "directory not empty".
|
|
// Using os.MkdirTemp + a retry loop works around this race.
|
|
func newChain(t *testing.T) *blockchain.Chain {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "dchain-test-*")
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp: %v", err)
|
|
}
|
|
c, err := blockchain.NewChain(dir)
|
|
if err != nil {
|
|
_ = os.RemoveAll(dir)
|
|
t.Fatalf("NewChain: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = c.Close()
|
|
// Retry removal to handle Windows mmap handle release delay.
|
|
for i := 0; i < 20; i++ {
|
|
if err := os.RemoveAll(dir); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
})
|
|
return c
|
|
}
|
|
|
|
// newIdentity generates a fresh Ed25519 + X25519 keypair for test use.
|
|
func newIdentity(t *testing.T) *identity.Identity {
|
|
t.Helper()
|
|
id, err := identity.Generate()
|
|
if err != nil {
|
|
t.Fatalf("identity.Generate: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// addGenesis creates and commits the genesis block signed by validator.
|
|
func addGenesis(t *testing.T, c *blockchain.Chain, validator *identity.Identity) *blockchain.Block {
|
|
t.Helper()
|
|
b := blockchain.GenesisBlock(validator.PubKeyHex(), validator.PrivKey)
|
|
if err := c.AddBlock(b); err != nil {
|
|
t.Fatalf("AddBlock(genesis): %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// txID produces a short deterministic transaction ID.
|
|
func txID(from string, typ blockchain.EventType) string {
|
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano())))
|
|
return hex.EncodeToString(h[:16])
|
|
}
|
|
|
|
// makeTx builds a minimal transaction with all required fields set.
|
|
// Signature is intentionally left nil — chain.applyTx does not re-verify
|
|
// Ed25519 tx signatures (that is the consensus engine's job).
|
|
func makeTx(typ blockchain.EventType, from, to string, amount, fee uint64, payload []byte) *blockchain.Transaction {
|
|
return &blockchain.Transaction{
|
|
ID: txID(from, typ),
|
|
Type: typ,
|
|
From: from,
|
|
To: to,
|
|
Amount: amount,
|
|
Fee: fee,
|
|
Payload: payload,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// mustJSON marshals v and panics on error (test helper only).
|
|
func mustJSON(v any) []byte {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// buildBlock wraps txs in a block that follows prev, computes hash, and signs
|
|
// it with validatorPriv. TotalFees is computed from the tx slice.
|
|
func buildBlock(t *testing.T, prev *blockchain.Block, validator *identity.Identity, txs []*blockchain.Transaction) *blockchain.Block {
|
|
t.Helper()
|
|
var totalFees uint64
|
|
for _, tx := range txs {
|
|
totalFees += tx.Fee
|
|
}
|
|
b := &blockchain.Block{
|
|
Index: prev.Index + 1,
|
|
Timestamp: time.Now().UTC(),
|
|
Transactions: txs,
|
|
PrevHash: prev.Hash,
|
|
Validator: validator.PubKeyHex(),
|
|
TotalFees: totalFees,
|
|
}
|
|
b.ComputeHash()
|
|
b.Sign(validator.PrivKey)
|
|
return b
|
|
}
|
|
|
|
// mustAddBlock calls c.AddBlock and fails the test on error.
|
|
func mustAddBlock(t *testing.T, c *blockchain.Chain, b *blockchain.Block) {
|
|
t.Helper()
|
|
if err := c.AddBlock(b); err != nil {
|
|
t.Fatalf("AddBlock (index %d): %v", b.Index, err)
|
|
}
|
|
}
|
|
|
|
// mustBalance reads the balance and fails on error.
|
|
func mustBalance(t *testing.T, c *blockchain.Chain, pubHex string) uint64 {
|
|
t.Helper()
|
|
bal, err := c.Balance(pubHex)
|
|
if err != nil {
|
|
t.Fatalf("Balance(%s): %v", pubHex[:8], err)
|
|
}
|
|
return bal
|
|
}
|
|
|
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
|
|
// 1. Genesis block credits GenesisAllocation to the validator.
|
|
func TestGenesisCreatesBalance(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
|
|
addGenesis(t, c, val)
|
|
|
|
bal := mustBalance(t, c, val.PubKeyHex())
|
|
if bal != blockchain.GenesisAllocation {
|
|
t.Errorf("expected GenesisAllocation=%d, got %d", blockchain.GenesisAllocation, bal)
|
|
}
|
|
}
|
|
|
|
// 2. Transfer moves tokens between two identities and leaves correct balances.
|
|
func TestTransfer(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
alice := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Fund alice via a transfer from validator.
|
|
const sendAmount = 100 * blockchain.Token
|
|
const fee = blockchain.MinFee
|
|
|
|
tx := makeTx(
|
|
blockchain.EventTransfer,
|
|
val.PubKeyHex(),
|
|
alice.PubKeyHex(),
|
|
sendAmount, fee,
|
|
mustJSON(blockchain.TransferPayload{}),
|
|
)
|
|
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
valBal := mustBalance(t, c, val.PubKeyHex())
|
|
aliceBal := mustBalance(t, c, alice.PubKeyHex())
|
|
|
|
// Validator: genesis - sendAmount - fee + fee (validator earns TotalFees back)
|
|
expectedVal := blockchain.GenesisAllocation - sendAmount - fee + fee
|
|
if valBal != expectedVal {
|
|
t.Errorf("validator balance: got %d, want %d", valBal, expectedVal)
|
|
}
|
|
if aliceBal != sendAmount {
|
|
t.Errorf("alice balance: got %d, want %d", aliceBal, sendAmount)
|
|
}
|
|
}
|
|
|
|
// 3. Transfer that exceeds sender's balance must fail AddBlock.
|
|
func TestTransferInsufficientFunds(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
alice := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// alice has 0 balance — try to spend 1 token
|
|
tx := makeTx(
|
|
blockchain.EventTransfer,
|
|
alice.PubKeyHex(),
|
|
val.PubKeyHex(),
|
|
1*blockchain.Token, blockchain.MinFee,
|
|
mustJSON(blockchain.TransferPayload{}),
|
|
)
|
|
b := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
|
|
|
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
|
if err := c.AddBlock(b); err != nil {
|
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
|
}
|
|
// Alice's balance must still be 0 — the skipped tx had no effect.
|
|
bal, err := c.Balance(alice.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("Balance: %v", err)
|
|
}
|
|
if bal != 0 {
|
|
t.Errorf("expected alice balance 0, got %d", bal)
|
|
}
|
|
}
|
|
|
|
// 4. EventRegisterKey stores X25519 key in IdentityInfo.
|
|
func TestRegisterKeyStoresIdentity(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
alice := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
payload := blockchain.RegisterKeyPayload{
|
|
PubKey: alice.PubKeyHex(),
|
|
Nickname: "alice",
|
|
PowNonce: 0,
|
|
PowTarget: "0",
|
|
X25519PubKey: alice.X25519PubHex(),
|
|
}
|
|
tx := makeTx(
|
|
blockchain.EventRegisterKey,
|
|
alice.PubKeyHex(),
|
|
"",
|
|
0, blockchain.RegistrationFee,
|
|
mustJSON(payload),
|
|
)
|
|
|
|
// Fund alice with enough to cover RegistrationFee before she registers.
|
|
fundTx := makeTx(
|
|
blockchain.EventTransfer,
|
|
val.PubKeyHex(),
|
|
alice.PubKeyHex(),
|
|
blockchain.RegistrationFee, blockchain.MinFee,
|
|
mustJSON(blockchain.TransferPayload{}),
|
|
)
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
|
|
mustAddBlock(t, c, b2)
|
|
|
|
info, err := c.IdentityInfo(alice.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("IdentityInfo: %v", err)
|
|
}
|
|
if !info.Registered {
|
|
t.Error("expected Registered=true after REGISTER_KEY tx")
|
|
}
|
|
if info.Nickname != "alice" {
|
|
t.Errorf("nickname: got %q, want %q", info.Nickname, "alice")
|
|
}
|
|
if info.X25519Pub != alice.X25519PubHex() {
|
|
t.Errorf("X25519Pub: got %q, want %q", info.X25519Pub, alice.X25519PubHex())
|
|
}
|
|
}
|
|
|
|
// 5. ContactRequest flow: pending → accepted → blocked.
|
|
func TestContactRequestFlow(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
alice := newIdentity(t) // requester
|
|
bob := newIdentity(t) // target
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Fund alice and bob for fees.
|
|
const contactAmt = blockchain.MinContactFee
|
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
|
contactAmt+2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
|
fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(),
|
|
2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
|
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
// Alice sends contact request to Bob.
|
|
reqTx := makeTx(
|
|
blockchain.EventContactRequest,
|
|
alice.PubKeyHex(),
|
|
bob.PubKeyHex(),
|
|
contactAmt, blockchain.MinFee,
|
|
mustJSON(blockchain.ContactRequestPayload{Intro: "Hey Bob!"}),
|
|
)
|
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx})
|
|
mustAddBlock(t, c, b2)
|
|
|
|
contacts, err := c.ContactRequests(bob.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("ContactRequests: %v", err)
|
|
}
|
|
if len(contacts) != 1 {
|
|
t.Fatalf("expected 1 contact record, got %d", len(contacts))
|
|
}
|
|
if contacts[0].Status != blockchain.ContactPending {
|
|
t.Errorf("status: got %q, want %q", contacts[0].Status, blockchain.ContactPending)
|
|
}
|
|
|
|
// Bob accepts.
|
|
acceptTx := makeTx(
|
|
blockchain.EventAcceptContact,
|
|
bob.PubKeyHex(),
|
|
alice.PubKeyHex(),
|
|
0, blockchain.MinFee,
|
|
mustJSON(blockchain.AcceptContactPayload{}),
|
|
)
|
|
b3 := buildBlock(t, b2, val, []*blockchain.Transaction{acceptTx})
|
|
mustAddBlock(t, c, b3)
|
|
|
|
contacts, err = c.ContactRequests(bob.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("ContactRequests after accept: %v", err)
|
|
}
|
|
if len(contacts) != 1 || contacts[0].Status != blockchain.ContactAccepted {
|
|
t.Errorf("expected accepted, got %v", contacts)
|
|
}
|
|
|
|
// Bob then blocks Alice (status transitions from accepted → blocked).
|
|
blockTx := makeTx(
|
|
blockchain.EventBlockContact,
|
|
bob.PubKeyHex(),
|
|
alice.PubKeyHex(),
|
|
0, blockchain.MinFee,
|
|
mustJSON(blockchain.BlockContactPayload{}),
|
|
)
|
|
b4 := buildBlock(t, b3, val, []*blockchain.Transaction{blockTx})
|
|
mustAddBlock(t, c, b4)
|
|
|
|
contacts, err = c.ContactRequests(bob.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("ContactRequests after block: %v", err)
|
|
}
|
|
if len(contacts) != 1 || contacts[0].Status != blockchain.ContactBlocked {
|
|
t.Errorf("expected blocked, got %v", contacts)
|
|
}
|
|
}
|
|
|
|
// 6. ContactRequest with amount below MinContactFee must fail.
|
|
func TestContactRequestInsufficientFee(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
alice := newIdentity(t)
|
|
bob := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Fund alice.
|
|
fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(),
|
|
blockchain.MinContactFee+blockchain.MinFee, blockchain.MinFee,
|
|
mustJSON(blockchain.TransferPayload{}))
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
// Amount is one µT below MinContactFee.
|
|
reqTx := makeTx(
|
|
blockchain.EventContactRequest,
|
|
alice.PubKeyHex(),
|
|
bob.PubKeyHex(),
|
|
blockchain.MinContactFee-1, blockchain.MinFee,
|
|
mustJSON(blockchain.ContactRequestPayload{}),
|
|
)
|
|
b := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx})
|
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
|
if err := c.AddBlock(b); err != nil {
|
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
|
}
|
|
// No pending contact record must exist for bob←alice.
|
|
contacts, err := c.ContactRequests(bob.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("ContactRequests: %v", err)
|
|
}
|
|
if len(contacts) != 0 {
|
|
t.Errorf("expected 0 pending contacts, got %d (tx should have been skipped)", len(contacts))
|
|
}
|
|
}
|
|
|
|
// 7. InitValidators seeds keys; ValidatorSet returns them all.
|
|
func TestValidatorSetInit(t *testing.T) {
|
|
c := newChain(t)
|
|
ids := []*identity.Identity{newIdentity(t), newIdentity(t), newIdentity(t)}
|
|
keys := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
keys[i] = id.PubKeyHex()
|
|
}
|
|
|
|
if err := c.InitValidators(keys); err != nil {
|
|
t.Fatalf("InitValidators: %v", err)
|
|
}
|
|
|
|
set, err := c.ValidatorSet()
|
|
if err != nil {
|
|
t.Fatalf("ValidatorSet: %v", err)
|
|
}
|
|
if len(set) != len(keys) {
|
|
t.Fatalf("expected %d validators, got %d", len(keys), len(set))
|
|
}
|
|
got := make(map[string]bool, len(set))
|
|
for _, k := range set {
|
|
got[k] = true
|
|
}
|
|
for _, k := range keys {
|
|
if !got[k] {
|
|
t.Errorf("key %s missing from validator set", k[:8])
|
|
}
|
|
}
|
|
}
|
|
|
|
// 8. EventAddValidator adds a new validator via a real block.
|
|
//
|
|
// Updated for P2.1 (stake-gated admission): the candidate must first have
|
|
// at least MinValidatorStake (1 T = 1_000_000 µT) locked via a STAKE tx
|
|
// and be credited enough balance to do so. Multi-sig approval is trivially
|
|
// met here because the initial set has only one validator — ⌈2/3⌉ of 1
|
|
// is 1, which the tx sender provides implicitly.
|
|
func TestAddValidatorTx(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t) // initial validator
|
|
newVal := newIdentity(t) // to be added
|
|
|
|
// Seed the initial validator.
|
|
if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil {
|
|
t.Fatalf("InitValidators: %v", err)
|
|
}
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Fund the candidate enough to stake.
|
|
fundTx := makeTx(
|
|
blockchain.EventTransfer,
|
|
val.PubKeyHex(),
|
|
newVal.PubKeyHex(),
|
|
2*blockchain.MinValidatorStake, blockchain.MinFee,
|
|
mustJSON(blockchain.TransferPayload{}),
|
|
)
|
|
// Candidate stakes the minimum.
|
|
stakeTx := makeTx(
|
|
blockchain.EventStake,
|
|
newVal.PubKeyHex(),
|
|
newVal.PubKeyHex(),
|
|
blockchain.MinValidatorStake, blockchain.MinFee,
|
|
nil,
|
|
)
|
|
preBlock := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx, stakeTx})
|
|
mustAddBlock(t, c, preBlock)
|
|
|
|
tx := makeTx(
|
|
blockchain.EventAddValidator,
|
|
val.PubKeyHex(),
|
|
newVal.PubKeyHex(),
|
|
0, blockchain.MinFee,
|
|
mustJSON(blockchain.AddValidatorPayload{Reason: "test"}),
|
|
)
|
|
b1 := buildBlock(t, preBlock, val, []*blockchain.Transaction{tx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
set, err := c.ValidatorSet()
|
|
if err != nil {
|
|
t.Fatalf("ValidatorSet: %v", err)
|
|
}
|
|
found := false
|
|
for _, k := range set {
|
|
if k == newVal.PubKeyHex() {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("new validator %s not found in set after ADD_VALIDATOR tx", newVal.PubKeyHex()[:8])
|
|
}
|
|
}
|
|
|
|
// 9. EventRemoveValidator removes a key from the set.
|
|
//
|
|
// Updated for P2.2 (multi-sig forced removal): the sender and the
|
|
// cosigners must together reach ⌈2/3⌉ of the current set. Here we have
|
|
// 3 validators, so 2 approvals are needed. `val` sends, `coSigner` adds
|
|
// a signature for RemoveDigest(removeMe.Pub).
|
|
func TestRemoveValidatorTx(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
coSigner := newIdentity(t)
|
|
removeMe := newIdentity(t)
|
|
|
|
// All three start as validators (ceil(2/3 * 3) = 2 approvals needed).
|
|
if err := c.InitValidators([]string{val.PubKeyHex(), coSigner.PubKeyHex(), removeMe.PubKeyHex()}); err != nil {
|
|
t.Fatalf("InitValidators: %v", err)
|
|
}
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// coSigner produces an off-chain approval for removing removeMe.
|
|
sig := coSigner.Sign(blockchain.RemoveDigest(removeMe.PubKeyHex()))
|
|
|
|
tx := makeTx(
|
|
blockchain.EventRemoveValidator,
|
|
val.PubKeyHex(),
|
|
removeMe.PubKeyHex(),
|
|
0, blockchain.MinFee,
|
|
mustJSON(blockchain.RemoveValidatorPayload{
|
|
Reason: "test",
|
|
CoSignatures: []blockchain.ValidatorCoSig{
|
|
{PubKey: coSigner.PubKeyHex(), Signature: sig},
|
|
},
|
|
}),
|
|
)
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
set, err := c.ValidatorSet()
|
|
if err != nil {
|
|
t.Fatalf("ValidatorSet: %v", err)
|
|
}
|
|
for _, k := range set {
|
|
if k == removeMe.PubKeyHex() {
|
|
t.Errorf("removed validator %s still in set", removeMe.PubKeyHex()[:8])
|
|
}
|
|
}
|
|
}
|
|
|
|
// 10. ADD_VALIDATOR tx from a non-validator must fail.
|
|
func TestAddValidatorNotAValidator(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
nonVal := newIdentity(t)
|
|
target := newIdentity(t)
|
|
|
|
if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil {
|
|
t.Fatalf("InitValidators: %v", err)
|
|
}
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Fund nonVal so the debit doesn't fail first (it should fail on validator check).
|
|
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), nonVal.PubKeyHex(),
|
|
10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
badTx := makeTx(
|
|
blockchain.EventAddValidator,
|
|
nonVal.PubKeyHex(), // not a validator
|
|
target.PubKeyHex(),
|
|
0, blockchain.MinFee,
|
|
mustJSON(blockchain.AddValidatorPayload{}),
|
|
)
|
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{badTx})
|
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
|
if err := c.AddBlock(b2); err != nil {
|
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
|
}
|
|
// target must NOT have been added as a validator (tx was skipped).
|
|
vset, err := c.ValidatorSet()
|
|
if err != nil {
|
|
t.Fatalf("ValidatorSet: %v", err)
|
|
}
|
|
for _, v := range vset {
|
|
if v == target.PubKeyHex() {
|
|
t.Error("target was added as validator despite tx being from a non-validator (should have been skipped)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 11. RelayProof with valid FeeSig transfers the relay fee from sender to relay.
|
|
func TestRelayProofClaimsFee(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
sender := newIdentity(t)
|
|
relay := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
const relayFeeUT = 5_000 * blockchain.MicroToken
|
|
|
|
// Fund sender with enough to cover relay fee and tx fee.
|
|
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(),
|
|
relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
senderBalBefore := mustBalance(t, c, sender.PubKeyHex())
|
|
relayBalBefore := mustBalance(t, c, relay.PubKeyHex())
|
|
|
|
envelopeID := "env-abc123"
|
|
authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT)
|
|
feeSig := sender.Sign(authBytes)
|
|
|
|
envelopeHash := sha256.Sum256([]byte("fake-ciphertext"))
|
|
proofPayload := blockchain.RelayProofPayload{
|
|
EnvelopeID: envelopeID,
|
|
EnvelopeHash: envelopeHash[:],
|
|
SenderPubKey: sender.PubKeyHex(),
|
|
FeeUT: relayFeeUT,
|
|
FeeSig: feeSig,
|
|
RelayPubKey: relay.PubKeyHex(),
|
|
DeliveredAt: time.Now().Unix(),
|
|
}
|
|
tx := makeTx(
|
|
blockchain.EventRelayProof,
|
|
relay.PubKeyHex(),
|
|
"",
|
|
0, blockchain.MinFee,
|
|
mustJSON(proofPayload),
|
|
)
|
|
|
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
|
|
mustAddBlock(t, c, b2)
|
|
|
|
senderBalAfter := mustBalance(t, c, sender.PubKeyHex())
|
|
relayBalAfter := mustBalance(t, c, relay.PubKeyHex())
|
|
|
|
if senderBalAfter != senderBalBefore-relayFeeUT {
|
|
t.Errorf("sender balance: got %d, want %d (before %d - fee %d)",
|
|
senderBalAfter, senderBalBefore-relayFeeUT, senderBalBefore, relayFeeUT)
|
|
}
|
|
if relayBalAfter != relayBalBefore+relayFeeUT {
|
|
t.Errorf("relay balance: got %d, want %d (before %d + fee %d)",
|
|
relayBalAfter, relayBalBefore+relayFeeUT, relayBalBefore, relayFeeUT)
|
|
}
|
|
}
|
|
|
|
// 12. RelayProof with wrong FeeSig must fail AddBlock.
|
|
func TestRelayProofBadSig(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
sender := newIdentity(t)
|
|
relay := newIdentity(t)
|
|
imposter := newIdentity(t) // signs instead of sender
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
const relayFeeUT = 5_000 * blockchain.MicroToken
|
|
|
|
// Fund sender.
|
|
fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(),
|
|
relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
|
b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx})
|
|
mustAddBlock(t, c, b1)
|
|
|
|
senderBalBefore := mustBalance(t, c, sender.PubKeyHex())
|
|
|
|
envelopeID := "env-xyz"
|
|
authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT)
|
|
// Imposter signs, not the actual sender.
|
|
badFeeSig := imposter.Sign(authBytes)
|
|
|
|
envelopeHash := sha256.Sum256([]byte("ciphertext"))
|
|
proofPayload := blockchain.RelayProofPayload{
|
|
EnvelopeID: envelopeID,
|
|
EnvelopeHash: envelopeHash[:],
|
|
SenderPubKey: sender.PubKeyHex(), // claims sender, but sig is from imposter
|
|
FeeUT: relayFeeUT,
|
|
FeeSig: badFeeSig,
|
|
RelayPubKey: relay.PubKeyHex(),
|
|
DeliveredAt: time.Now().Unix(),
|
|
}
|
|
tx := makeTx(
|
|
blockchain.EventRelayProof,
|
|
relay.PubKeyHex(),
|
|
"",
|
|
0, blockchain.MinFee,
|
|
mustJSON(proofPayload),
|
|
)
|
|
b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx})
|
|
// AddBlock must succeed — the bad tx is skipped rather than rejecting the block.
|
|
if err := c.AddBlock(b2); err != nil {
|
|
t.Fatalf("AddBlock returned unexpected error: %v", err)
|
|
}
|
|
// Sender's balance must be unchanged — the skipped tx had no effect.
|
|
senderBalAfter, err := c.Balance(sender.PubKeyHex())
|
|
if err != nil {
|
|
t.Fatalf("Balance: %v", err)
|
|
}
|
|
if senderBalAfter != senderBalBefore {
|
|
t.Errorf("sender balance changed despite bad-sig tx: before=%d after=%d",
|
|
senderBalBefore, senderBalAfter)
|
|
}
|
|
}
|
|
|
|
// 13. Adding the same block index twice must fail.
|
|
func TestDuplicateBlockRejected(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Build block 1.
|
|
b1 := buildBlock(t, genesis, val, nil)
|
|
mustAddBlock(t, c, b1)
|
|
|
|
// Build an independent block also claiming index 1 (different hash).
|
|
b1dup := &blockchain.Block{
|
|
Index: 1,
|
|
Timestamp: time.Now().Add(time.Millisecond).UTC(),
|
|
Transactions: []*blockchain.Transaction{},
|
|
PrevHash: genesis.Hash,
|
|
Validator: val.PubKeyHex(),
|
|
TotalFees: 0,
|
|
}
|
|
b1dup.ComputeHash()
|
|
b1dup.Sign(val.PrivKey)
|
|
|
|
// The chain tip is already at index 1; the new block has index 1 but a
|
|
// different prevHash (its own prev is genesis too but tip.Hash ≠ genesis.Hash).
|
|
if err := c.AddBlock(b1dup); err == nil {
|
|
t.Fatal("expected AddBlock to fail for duplicate index, but it succeeded")
|
|
}
|
|
}
|
|
|
|
// 14. Block with wrong prevHash must fail.
|
|
func TestChainLinkageRejected(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
|
|
// Create a block with a garbage prevHash.
|
|
garbagePrev := make([]byte, 32)
|
|
if _, err := rand.Read(garbagePrev); err != nil {
|
|
t.Fatalf("rand.Read: %v", err)
|
|
}
|
|
badBlock := &blockchain.Block{
|
|
Index: 1,
|
|
Timestamp: time.Now().UTC(),
|
|
Transactions: []*blockchain.Transaction{},
|
|
PrevHash: garbagePrev,
|
|
Validator: val.PubKeyHex(),
|
|
TotalFees: 0,
|
|
}
|
|
badBlock.ComputeHash()
|
|
badBlock.Sign(val.PrivKey)
|
|
|
|
if err := c.AddBlock(badBlock); err == nil {
|
|
t.Fatal("expected AddBlock to fail for wrong prevHash, but it succeeded")
|
|
}
|
|
|
|
// Tip must still be genesis.
|
|
tip := c.Tip()
|
|
if tip.Index != genesis.Index {
|
|
t.Errorf("tip index after rejection: got %d, want %d", tip.Index, genesis.Index)
|
|
}
|
|
}
|
|
|
|
// 15. Tip advances with each successfully committed block.
|
|
func TestTipUpdates(t *testing.T) {
|
|
c := newChain(t)
|
|
val := newIdentity(t)
|
|
|
|
if tip := c.Tip(); tip != nil {
|
|
t.Fatalf("tip on empty chain: expected nil, got index %d", tip.Index)
|
|
}
|
|
|
|
genesis := addGenesis(t, c, val)
|
|
if tip := c.Tip(); tip == nil || tip.Index != 0 {
|
|
t.Fatalf("tip after genesis: expected index 0, got %v", tip)
|
|
}
|
|
|
|
prev := genesis
|
|
for i := uint64(1); i <= 3; i++ {
|
|
b := buildBlock(t, prev, val, nil)
|
|
mustAddBlock(t, c, b)
|
|
|
|
tip := c.Tip()
|
|
if tip == nil {
|
|
t.Fatalf("tip is nil after block %d", i)
|
|
}
|
|
if tip.Index != i {
|
|
t.Errorf("tip.Index after block %d: got %d, want %d", i, tip.Index, i)
|
|
}
|
|
prev = b
|
|
}
|
|
}
|
|
|
|
// ─── compile-time guard ──────────────────────────────────────────────────────
|
|
|
|
// Ensure the identity package is used directly so the import is not trimmed.
|
|
var _ = identity.Generate
|
|
|
|
// Ensure ed25519 and hex are used directly (they may be used via helpers).
|
|
var _ = ed25519.PublicKey(nil)
|
|
var _ = hex.EncodeToString
|