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:
vsecoder
2026-04-17 14:16:44 +03:00
commit 7e7393e4f8
196 changed files with 55947 additions and 0 deletions

796
blockchain/chain_test.go Normal file
View File

@@ -0,0 +1,796 @@
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