blockchain sandbox: UTXO chain, p2p mining, attacker node

This commit is contained in:
2026-07-26 20:37:26 +03:00
commit 7ce39c8964
18 changed files with 2536 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"blockchain/attacker"
"blockchain/config"
"blockchain/p2p"
"fmt"
"math"
"math/rand"
"time"
)
func main() {
cfg := &config.Default
numNodes := cfg.Sim.NumNodes
hashDelay := cfg.Sim.HashDelay
expectedBlock := time.Duration(float64(hashDelay) * math.Pow(16, float64(cfg.Chain.Difficulty)) / float64(numNodes))
cfg.P2P.NetDelay = expectedBlock / 300
fmt.Printf("Starting honest network: %d nodes, difficulty=%d\n", numNodes, cfg.Chain.Difficulty)
// 1. Start honest network
stop := make(chan struct{})
nodes := make([]*p2p.Node, numNodes)
for i := range nodes {
nodes[i] = p2p.NewNode()
nodes[i].HashDelay = hashDelay
for _, j := range rand.Perm(i)[:min(cfg.P2P.OutboundPeers, i)] {
nodes[i].AddPeer(nodes[j])
nodes[j].AddPeer(nodes[i])
}
go nodes[i].Run(stop)
}
// Let the network mine a few blocks first
fmt.Println("Waiting for network to mine some blocks...")
time.Sleep(5 * time.Second)
// 2. Create attacker node and connect to network
evil := attacker.NewNode()
evil.HashDelay = hashDelay
// Connect to a few honest nodes (like joining the network)
for _, j := range rand.Perm(len(nodes))[:min(3, len(nodes))] {
evil.AddPeer(nodes[j])
nodes[j].AddPeer(evil)
}
fmt.Printf("\n[ATTACKER] Joined network as %s\n", evil.ShortID())
fmt.Printf("[ATTACKER] Modify attacker/node.go Run() to implement your attack!\n\n")
go evil.Run(stop)
// Wait
select {}
}
+324
View File
@@ -0,0 +1,324 @@
package main
import (
"blockchain/chain"
"blockchain/config"
"blockchain/p2p"
"bytes"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"math/rand"
"net/http"
"time"
)
type API struct {
nodes []*p2p.Node
}
func (api *API) findNode(address string) *p2p.Node {
for _, node := range api.nodes {
if node.Wallet.Address() == address {
return node
}
}
return nil
}
func (api *API) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/tx", api.handleTx)
mux.HandleFunc("/balance", api.handleBalance)
mux.HandleFunc("/chain", api.handleChain)
mux.HandleFunc("/nodes", api.handleNodes)
mux.HandleFunc("/topology", api.handleTopology)
mux.HandleFunc("/events", api.handleEvents)
mux.Handle("/", http.FileServer(http.Dir("web")))
return cors(mux)
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r)
})
}
// balanceOf computes balance from a coin store snapshot.
func balanceOf(coins chain.CoinStore, owner ed25519.PublicKey) int {
sum := 0
for _, c := range coins {
if bytes.Equal(c.Owner, owner) {
sum += c.Amount
}
}
return sum
}
// coinsFor returns unspent coins owned by the given public key.
func coinsFor(coins chain.CoinStore, owner ed25519.PublicKey) []chain.UnspentCoin {
var result []chain.UnspentCoin
for _, c := range coins {
if bytes.Equal(c.Owner, owner) {
result = append(result, *c)
}
}
return result
}
func (api *API) handleTx(w http.ResponseWriter, r *http.Request) {
var req struct {
From string `json:"from"`
To string `json:"to"`
Amount int `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), 400)
return
}
node := api.findNode(req.From)
if node == nil {
http.Error(w, "unknown sender address", 404)
return
}
toKey, err := hex.DecodeString(req.To)
if err != nil {
http.Error(w, "invalid recipient address", 400)
return
}
snap := node.Chain.Snapshot()
unspent := coinsFor(snap.Coins, node.Wallet.PublicKey)
tx := chain.NewTransaction(node.Wallet, ed25519.PublicKey(toKey), req.Amount, unspent)
if tx == nil {
http.Error(w, "insufficient funds", 400)
return
}
node.MarkSeen(tx.TxID())
node.Mempool.Add(tx)
node.Broadcast(p2p.TxMsg{Tx: tx, From: node})
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"tx": tx.String(),
})
}
func (api *API) handleBalance(w http.ResponseWriter, r *http.Request) {
addr := r.URL.Query().Get("addr")
if addr == "" {
http.Error(w, "addr required", 400)
return
}
key, err := hex.DecodeString(addr)
if err != nil {
http.Error(w, "invalid address", 400)
return
}
snap := api.nodes[0].Chain.Snapshot()
json.NewEncoder(w).Encode(map[string]any{
"address": addr,
"balance": balanceOf(snap.Coins, ed25519.PublicKey(key)),
})
}
func (api *API) handleChain(w http.ResponseWriter, r *http.Request) {
type blockInfo struct {
Index int `json:"index"`
Hash string `json:"hash"`
PrevHash string `json:"prev_hash"`
Miner string `json:"miner"`
Txs []string `json:"txs"`
}
idx := 0
if q := r.URL.Query().Get("node"); q != "" {
fmt.Sscanf(q, "%d", &idx)
if idx < 0 || idx >= len(api.nodes) {
idx = 0
}
}
snap := api.nodes[idx].Chain.Snapshot()
blocks := make([]blockInfo, len(snap.Blocks))
for i, b := range snap.Blocks {
txs := make([]string, len(b.Transactions))
miner := ""
for j, tx := range b.Transactions {
txs[j] = tx.String()
if tx.IsCoinbase() && len(tx.Coins) > 0 {
miner = tx.Coins[0].OwnerHex()
}
}
blocks[i] = blockInfo{b.Height, b.Hash, b.PrevHash, miner, txs}
}
json.NewEncoder(w).Encode(blocks)
}
func (api *API) handleNodes(w http.ResponseWriter, r *http.Request) {
type nodeInfo struct {
Address string `json:"address"`
Balance int `json:"balance"`
HashDelay int `json:"hash_delay_us"`
ChainLen int `json:"chain_len"`
LastHash string `json:"last_hash"`
PendingTxs int `json:"pending_txs"`
}
nodes := make([]nodeInfo, len(api.nodes))
for i, n := range api.nodes {
snap := n.Chain.Snapshot()
last := snap.Blocks[len(snap.Blocks)-1]
nodes[i] = nodeInfo{
Address: n.Wallet.Address(),
Balance: balanceOf(snap.Coins, n.Wallet.PublicKey),
HashDelay: int(n.HashDelay.Microseconds()),
ChainLen: len(snap.Blocks),
LastHash: last.Hash,
PendingTxs: n.Mempool.Len(),
}
}
json.NewEncoder(w).Encode(nodes)
}
func (api *API) handleTopology(w http.ResponseWriter, r *http.Request) {
type topoNode struct {
ID string `json:"id"`
Peers []string `json:"peers"`
HashDelay int `json:"hash_delay_us"`
}
result := make([]topoNode, len(api.nodes))
for i, n := range api.nodes {
result[i] = topoNode{
ID: n.ShortID(),
Peers: n.PeerIDs(),
HashDelay: int(n.HashDelay.Microseconds()),
}
}
json.NewEncoder(w).Encode(result)
}
func (api *API) handleEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", 500)
return
}
for {
select {
case ev := <-p2p.Events:
data, _ := json.Marshal(ev)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
func main() {
// --- simulation parameters (Bitcoin-proportional) ---
cfg := &config.Default
numNodes := cfg.Sim.NumNodes
hashDelay := cfg.Sim.HashDelay
expectedBlock := time.Duration(float64(hashDelay) * math.Pow(16, float64(cfg.Chain.Difficulty)) / float64(numNodes))
cfg.P2P.NetDelay = expectedBlock / 300
fmt.Printf("Difficulty=%d Nodes=%d HashDelay=%v OutboundPeers=%d\n", cfg.Chain.Difficulty, numNodes, hashDelay, cfg.P2P.OutboundPeers)
fmt.Printf("Expected ~%v per block, net delay ~%v\n\n", expectedBlock.Round(time.Millisecond), cfg.P2P.NetDelay.Round(time.Microsecond))
stop := make(chan struct{})
nodes := make([]*p2p.Node, numNodes)
for i := range nodes {
nodes[i] = p2p.NewNode()
r := rand.Float64()
switch {
case r < 0.2:
nodes[i].HashDelay = hashDelay / 3
case r < 0.8:
nodes[i].HashDelay = hashDelay
default:
nodes[i].HashDelay = hashDelay * 3
}
for _, j := range rand.Perm(i)[:min(cfg.P2P.OutboundPeers, i)] {
nodes[i].AddPeer(nodes[j])
nodes[j].AddPeer(nodes[i])
}
go nodes[i].Run(stop)
}
fmt.Printf("%d nodes started\n", numNodes)
// bot: randomly send transactions
go func() {
for {
time.Sleep(time.Duration(250+rand.Intn(250)) * time.Millisecond)
from := nodes[rand.Intn(len(nodes))]
to := nodes[rand.Intn(len(nodes))]
if from == to {
continue
}
snap := from.Chain.Snapshot()
unspent := coinsFor(snap.Coins, from.Wallet.PublicKey)
balance := 0
for _, u := range unspent {
balance += u.Amount
}
if balance <= 0 {
continue
}
amount := 1 + rand.Intn(min(balance, 10))
tx := chain.NewTransaction(from.Wallet, to.Wallet.PublicKey, amount, unspent)
if tx == nil {
continue
}
from.MarkSeen(tx.TxID())
from.Mempool.Add(tx)
p2p.Events <- p2p.Event{Type: "tx_send", Node: from.ShortID(), From: from.ShortID(), BlockHash: to.ShortID(), TxCount: amount, Ts: time.Now().UnixMilli()}
from.Broadcast(p2p.TxMsg{Tx: tx, From: from})
}
}()
// periodic network health stats
go func() {
for {
time.Sleep(10 * time.Second)
heights := map[int]int{}
tips := map[string]int{}
for _, n := range nodes {
last := n.Chain.LastBlock()
heights[last.Height]++
tips[last.Hash[:8]]++
}
fmt.Printf("\n--- NETWORK STATS: heights=%v tips=%v ---\n\n", heights, tips)
}
}()
api := &API{nodes: nodes}
fmt.Println()
fmt.Println("=== http://localhost:8080 ===")
fmt.Println()
fmt.Println(" POST /tx — {from, to, amount}")
fmt.Println(" GET /balance?addr=... — check balance")
fmt.Println(" GET /chain — view blockchain")
fmt.Println(" GET /nodes — list all nodes")
if err := http.ListenAndServe(":8080", api.Handler()); err != nil {
fmt.Println("Error:", err)
}
}