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 // ── Feed (v2.0.0) ────────────────────────────────────────────────────────── // TestFeedCreatePost: post commits, indexes, credits the hosting relay. func TestFeedCreatePost(t *testing.T) { c := newChain(t) val := newIdentity(t) alice := newIdentity(t) // post author host := newIdentity(t) // hosting relay pubkey genesis := addGenesis(t, c, val) // Fund alice + host. const postSize = uint64(200) expectedFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), expectedFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) time.Sleep(2 * time.Millisecond) // ensure distinct txID (nanosec clock) fundHost := makeTx(blockchain.EventTransfer, val.PubKeyHex(), host.PubKeyHex(), blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundHost}) mustAddBlock(t, c, b1) hostBalBefore, _ := c.Balance(host.PubKeyHex()) h := sha256.Sum256([]byte("hello world post body")) postPayload := blockchain.CreatePostPayload{ PostID: "post1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(), } postTx := makeTx( blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, expectedFee, // Fee = base + size*byte_fee; amount = 0 mustJSON(postPayload), ) b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx}) mustAddBlock(t, c, b2) rec, err := c.Post("post1") if err != nil || rec == nil { t.Fatalf("Post(\"post1\") = %v, %v; want record", rec, err) } if rec.Author != alice.PubKeyHex() { t.Errorf("author: got %q want %q", rec.Author, alice.PubKeyHex()) } if rec.Size != postSize { t.Errorf("size: got %d want %d", rec.Size, postSize) } // Host should have been credited the full fee. hostBalAfter, _ := c.Balance(host.PubKeyHex()) if hostBalAfter != hostBalBefore+expectedFee { t.Errorf("host balance: got %d, want %d (delta %d)", hostBalAfter, hostBalBefore, expectedFee) } // PostsByAuthor should list it. posts, err := c.PostsByAuthor(alice.PubKeyHex(), 10) if err != nil { t.Fatalf("PostsByAuthor: %v", err) } if len(posts) != 1 || posts[0].PostID != "post1" { t.Errorf("PostsByAuthor: got %v, want [post1]", posts) } } // TestFeedInsufficientFee: size-based fee is enforced. func TestFeedInsufficientFee(t *testing.T) { c := newChain(t) val := newIdentity(t) alice := newIdentity(t) host := newIdentity(t) genesis := addGenesis(t, c, val) fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), 10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice}) mustAddBlock(t, c, b1) const postSize = uint64(1000) h := sha256.Sum256([]byte("body")) postPayload := blockchain.CreatePostPayload{ PostID: "underpaid", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(), } // Fee too low — base alone without the size component. // (Must still be ≥ MinFee so the chain-level block validation passes; // the per-event CREATE_POST check is what should reject it.) postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, blockchain.MinFee, mustJSON(postPayload)) b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx}) mustAddBlock(t, c, b2) // block commits, the tx is skipped (logged) if rec, _ := c.Post("underpaid"); rec != nil { t.Fatalf("post was stored despite insufficient fee: %+v", rec) } } // TestFeedFollowUnfollow: follow graph round-trips via indices. func TestFeedFollowUnfollow(t *testing.T) { c := newChain(t) val := newIdentity(t) alice := newIdentity(t) bob := newIdentity(t) genesis := addGenesis(t, c, val) fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), 5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice}) mustAddBlock(t, c, b1) followTx := makeTx(blockchain.EventFollow, alice.PubKeyHex(), bob.PubKeyHex(), 0, blockchain.MinFee, mustJSON(blockchain.FollowPayload{})) b2 := buildBlock(t, b1, val, []*blockchain.Transaction{followTx}) mustAddBlock(t, c, b2) following, _ := c.Following(alice.PubKeyHex()) if len(following) != 1 || following[0] != bob.PubKeyHex() { t.Errorf("Following: got %v, want [%s]", following, bob.PubKeyHex()) } followers, _ := c.Followers(bob.PubKeyHex()) if len(followers) != 1 || followers[0] != alice.PubKeyHex() { t.Errorf("Followers: got %v, want [%s]", followers, alice.PubKeyHex()) } // Unfollow. unfollowTx := makeTx(blockchain.EventUnfollow, alice.PubKeyHex(), bob.PubKeyHex(), 0, blockchain.MinFee, mustJSON(blockchain.UnfollowPayload{})) b3 := buildBlock(t, b2, val, []*blockchain.Transaction{unfollowTx}) mustAddBlock(t, c, b3) following, _ = c.Following(alice.PubKeyHex()) if len(following) != 0 { t.Errorf("Following after unfollow: got %v, want []", following) } } // TestFeedLikeUnlike: like toggles + cached count stays consistent. func TestFeedLikeUnlike(t *testing.T) { c := newChain(t) val := newIdentity(t) alice := newIdentity(t) // author bob := newIdentity(t) // liker host := newIdentity(t) genesis := addGenesis(t, c, val) const postSize = uint64(100) expectedPostFee := blockchain.BasePostFee + postSize*blockchain.PostByteFee fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), expectedPostFee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) time.Sleep(2 * time.Millisecond) fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(), 5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob}) mustAddBlock(t, c, b1) h := sha256.Sum256([]byte("likeable")) postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, expectedPostFee, mustJSON(blockchain.CreatePostPayload{ PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(), })) b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx}) mustAddBlock(t, c, b2) likeTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "", 0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"})) b3 := buildBlock(t, b2, val, []*blockchain.Transaction{likeTx}) mustAddBlock(t, c, b3) n, _ := c.LikeCount("p1") if n != 1 { t.Errorf("LikeCount after like: got %d, want 1", n) } liked, _ := c.HasLiked("p1", bob.PubKeyHex()) if !liked { t.Errorf("HasLiked after like: got false") } // Duplicate like — tx is skipped; counter stays at 1. dupTx := makeTx(blockchain.EventLikePost, bob.PubKeyHex(), "", 0, blockchain.MinFee, mustJSON(blockchain.LikePostPayload{PostID: "p1"})) b4 := buildBlock(t, b3, val, []*blockchain.Transaction{dupTx}) mustAddBlock(t, c, b4) if n2, _ := c.LikeCount("p1"); n2 != 1 { t.Errorf("LikeCount after duplicate: got %d, want 1 (tx should have been skipped)", n2) } unlikeTx := makeTx(blockchain.EventUnlikePost, bob.PubKeyHex(), "", 0, blockchain.MinFee, mustJSON(blockchain.UnlikePostPayload{PostID: "p1"})) b5 := buildBlock(t, b4, val, []*blockchain.Transaction{unlikeTx}) mustAddBlock(t, c, b5) n, _ = c.LikeCount("p1") if n != 0 { t.Errorf("LikeCount after unlike: got %d, want 0", n) } } // TestFeedDeletePostByOther: only the author may delete their post. func TestFeedDeletePostByOther(t *testing.T) { c := newChain(t) val := newIdentity(t) alice := newIdentity(t) mallory := newIdentity(t) // tries to delete alice's post host := newIdentity(t) genesis := addGenesis(t, c, val) const postSize = uint64(100) fee := blockchain.BasePostFee + postSize*blockchain.PostByteFee fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), fee+5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) time.Sleep(2 * time.Millisecond) fundMallory := makeTx(blockchain.EventTransfer, val.PubKeyHex(), mallory.PubKeyHex(), 5*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundMallory}) mustAddBlock(t, c, b1) h := sha256.Sum256([]byte("body")) postTx := makeTx(blockchain.EventCreatePost, alice.PubKeyHex(), "", 0, fee, mustJSON(blockchain.CreatePostPayload{ PostID: "p1", ContentHash: h[:], Size: postSize, HostingRelay: host.PubKeyHex(), })) b2 := buildBlock(t, b1, val, []*blockchain.Transaction{postTx}) mustAddBlock(t, c, b2) // Mallory tries to delete alice's post — block commits, tx is skipped. delTx := makeTx(blockchain.EventDeletePost, mallory.PubKeyHex(), "", 0, blockchain.MinFee, mustJSON(blockchain.DeletePostPayload{PostID: "p1"})) b3 := buildBlock(t, b2, val, []*blockchain.Transaction{delTx}) mustAddBlock(t, c, b3) rec, _ := c.Post("p1") if rec == nil || rec.Deleted { t.Fatalf("post was deleted by non-author: %+v", rec) } } // silence unused-import lint if fmt ever gets trimmed from the feed tests. var _ = fmt.Sprintf