commit 7ce39c896441ad6b636e54975ad22e82eac655ad Author: Artem Tsyrulnikov <1+root@noreply.git.tlartem.ru> Date: Sun Jul 26 20:22:30 2026 +0300 blockchain sandbox: UTXO chain, p2p mining, attacker node diff --git a/README.md b/README.md new file mode 100644 index 0000000..49e9b37 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# blockchain + +Моя копия Bitcoin на Go. Упрощена для обучения студентов. +Ноль зависимостей — только стандартная библиотека. + +## Запуск + +```sh +go run ./cmd/server +``` + +Открыть `http://localhost:8080`. Сеть поднимается сама: 30 узлов, каждый майнит, +шлёт транзакции соседям и спорит за главную ветку. Бот раз в четверть секунды +кидает случайный перевод — смотреть можно сразу, руками ничего слать не надо. + +Атакующий узел: + +```sh +go run ./cmd/attack +``` + +## Что в браузере + +Граф сети, рост цепи, форки и реорги в реальном времени. +События идут потоком через SSE — страница не опрашивает сервер, сервер сам шлёт. + +## API + +| Метод | Ручка | Что делает | +|---|---|---| +| POST | `/tx` | перевод, тело `{from, to, amount}` | +| GET | `/balance?addr=` | баланс адреса | +| GET | `/chain?node=N` | цепь глазами узла N | +| GET | `/nodes` | все узлы: баланс, длина цепи, мемпул | +| GET | `/topology` | кто с кем связан | +| GET | `/events` | поток событий, SSE | + +## Настройка + +Всё в `config/config.go`: + +```go +Difficulty: 4 // нулей в хеше. +1 = в 16 раз дольше блок +MiningReward: 10 // награда за блок +NumNodes: 30 // узлов в сети +HashDelay: 5ms // пауза между попытками. медленнее = реже блоки +OutboundPeers: 3 // связей на узел. в Bitcoin 8 +``` + +Время блока считается само: `HashDelay × 16^Difficulty / NumNodes`. +Сетевая задержка ставится в 1/300 от него — чтобы форки случались, но не постоянно. + +Хочешь чаще форки — подними `NetDelay` или урежь `OutboundPeers`. +Хочешь спокойную сеть — наоборот. + +## Устройство + +``` +chain/ блоки, UTXO, кошельки, мемпул, подписи ed25519 +p2p/ узел, пиры, майнер, обмен сообщениями, события +attacker/ форк честного узла — правь и ломай +cmd/server сеть + HTTP API + веб +cmd/attack запуск с атакующим +config/ все параметры +web/ интерфейс, один файл +``` + +## Атака + +`attacker/node.go` — копия честного узла, но твоя. Меняй в ней что угодно. +Остальные узлы крутят оригинальный код и слушают только сообщения. + +Своруй монеты. Потрать дважды. Перепиши историю. Если сможешь. diff --git a/attacker/node.go b/attacker/node.go new file mode 100644 index 0000000..ea6012e --- /dev/null +++ b/attacker/node.go @@ -0,0 +1,206 @@ +// Package attacker is a fork of the honest node. +// Modify this code to try to break the blockchain! +// +// You have full access to YOUR node — change any logic you want. +// But other nodes run the original honest code (p2p.Node). +// You can only communicate with them through messages (Broadcast). +// +// Can you steal coins? Double-spend? Rewrite history? +package attacker + +import ( + "blockchain/chain" + "blockchain/config" + "blockchain/p2p" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/rand" + "strconv" + "strings" + "sync" + "time" +) + +type Node struct { + Wallet *chain.Wallet + Chain *chain.Blockchain + Mempool *chain.Mempool + HashDelay time.Duration + conn chan p2p.Msg + syncCh chan p2p.Msg + peers map[p2p.Peer]*link + mx sync.Mutex + seen map[string]struct{} +} + +type link struct { + send chan p2p.Msg + latency time.Duration +} + +func newlink(target p2p.Peer, latency time.Duration) *link { + l := &link{ + send: make(chan p2p.Msg, 50), + latency: latency, + } + go func() { + for msg := range l.send { + jitter := time.Duration(rand.NormFloat64()*float64(l.latency)/4 + float64(l.latency)) + if jitter > 0 { + time.Sleep(jitter) + } + target.Receive(msg) + } + }() + return l +} + +func NewNode() *Node { + return &Node{ + Wallet: chain.NewWallet(), + Chain: chain.NewBlockchain(), + Mempool: chain.NewMempool(), + conn: make(chan p2p.Msg, 100), + syncCh: make(chan p2p.Msg, 10), + peers: make(map[p2p.Peer]*link), + seen: make(map[string]struct{}), + } +} + +// Receive implements p2p.Peer. +func (n *Node) Receive(msg p2p.Msg) { + select { + case n.conn <- msg: + default: + } +} + +// ReceiveSync implements p2p.Peer. +func (n *Node) ReceiveSync(msg p2p.Msg) { + select { + case n.syncCh <- msg: + default: + } +} + +func (n *Node) ShortID() string { + return n.Wallet.ShortAddress() +} + +func (n *Node) MarkSeen(hash string) bool { + n.mx.Lock() + defer n.mx.Unlock() + if _, ok := n.seen[hash]; ok { + return true + } + if len(n.seen) >= 10000 { + n.seen = make(map[string]struct{}) + } + n.seen[hash] = struct{}{} + return false +} + +func (n *Node) AddPeer(peer p2p.Peer) { + if peer == n { + return + } + n.mx.Lock() + defer n.mx.Unlock() + if _, exists := n.peers[peer]; exists { + return + } + n.peers[peer] = newlink(peer, config.Default.P2P.NetDelay) +} + +func (n *Node) Broadcast(msg p2p.Msg) { + n.mx.Lock() + links := make([]*link, 0, len(n.peers)) + for _, l := range n.peers { + links = append(links, l) + } + n.mx.Unlock() + + for _, l := range links { + select { + case l.send <- msg: + default: + } + } +} + +func (n *Node) Mine(block *chain.Block) bool { + prefix := strings.Repeat("0", config.Default.Chain.Difficulty) + base := block.HashBase() + for { + data := base + strconv.Itoa(block.Nonce) + h := sha256.Sum256([]byte(data)) + block.Hash = hex.EncodeToString(h[:]) + if strings.HasPrefix(block.Hash, prefix) { + return true + } + block.Nonce++ + if n.HashDelay > 0 { + time.Sleep(n.HashDelay) + } + } +} + +// Run is the main loop — MODIFY THIS to implement your attack! +// Right now it behaves like an honest node. +func (n *Node) Run(stop chan struct{}) { + // Start mining like an honest node + go n.runMiner(stop) + + for { + select { + case msg := <-n.conn: + switch m := msg.(type) { + case p2p.BlockMsg: + if n.MarkSeen(m.Block.Hash) { + continue + } + if n.Chain.AddBlock(m.Block) { + n.Mempool.RemoveMined(m.Block) + fmt.Printf("[ATTACKER %s] Accepted block #%d\n", n.ShortID(), m.Block.Height) + n.Broadcast(p2p.BlockMsg{Block: m.Block, From: n}) + } + case p2p.TxMsg: + if n.MarkSeen(m.Tx.TxID()) { + continue + } + n.Mempool.Add(m.Tx) + n.Broadcast(p2p.TxMsg{Tx: m.Tx, From: n}) + } + case msg := <-n.syncCh: + switch m := msg.(type) { + case p2p.BlocksMsg: + n.Chain.ReplaceBlocks(m.Blocks) + } + case <-stop: + return + } + } +} + +func (n *Node) runMiner(stop chan struct{}) { + for { + select { + case <-stop: + return + default: + } + + last := n.Chain.LastBlock() + txs := n.Mempool.CollectBlock(n.Wallet.PublicKey) + block := chain.NewBlock(last.Height+1, txs, last.Hash) + n.Mine(block) + + if n.Chain.AddBlock(block) { + n.Mempool.RemoveMined(block) + fmt.Printf("[ATTACKER %s] Mined block #%d, hash: %s\n", + n.ShortID(), block.Height, block.Hash[:16]) + n.Broadcast(p2p.BlockMsg{Block: block, From: n}) + } + } +} diff --git a/chain/block.go b/chain/block.go new file mode 100644 index 0000000..18110cd --- /dev/null +++ b/chain/block.go @@ -0,0 +1,47 @@ +package chain + +import ( + "blockchain/config" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" +) + +type Block struct { + Height int + Transactions []*Transaction + PrevHash string + Hash string + Nonce int +} + +func NewBlock(height int, txs []*Transaction, prevHash string) *Block { + return &Block{ + Height: height, + Transactions: txs, + PrevHash: prevHash, + } +} + +// HashBase returns the pre-computed prefix for nonce iteration. +func (b *Block) HashBase() string { + var sb strings.Builder + sb.WriteString(strconv.Itoa(b.Height)) + for _, tx := range b.Transactions { + sb.WriteString(tx.TxID()) + } + sb.WriteString(b.PrevHash) + return sb.String() +} + +func (b *Block) CalcHash() string { + data := b.HashBase() + strconv.Itoa(b.Nonce) + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +func (b *Block) CheckProofOfWork() bool { + prefix := strings.Repeat("0", config.Default.Chain.Difficulty) + return b.Hash == b.CalcHash() && strings.HasPrefix(b.Hash, prefix) +} diff --git a/chain/blockchain.go b/chain/blockchain.go new file mode 100644 index 0000000..fc07540 --- /dev/null +++ b/chain/blockchain.go @@ -0,0 +1,204 @@ +package chain + +import ( + "blockchain/config" + "bytes" + "sync" +) + +// Snapshot is a read-only view of the chain state for API queries. + +type Blockchain struct { + blocks []*Block + coins CoinStore + mx sync.RWMutex +} + +func NewBlockchain() *Blockchain { + genesis := NewBlock(0, nil, "") + genesis.Hash = genesis.CalcHash() + return &Blockchain{ + blocks: []*Block{genesis}, + coins: make(CoinStore), + } +} + +type Snapshot struct { + Blocks []*Block + Coins CoinStore +} + +// Snapshot returns a consistent read-only view of the chain state. +func (cs *Blockchain) Snapshot() Snapshot { + cs.mx.RLock() + defer cs.mx.RUnlock() + + blocks := make([]*Block, len(cs.blocks)) + copy(blocks, cs.blocks) + + return Snapshot{ + Blocks: blocks, + Coins: cs.coins, + } +} + +func (cs *Blockchain) LastBlock() *Block { + cs.mx.RLock() + defer cs.mx.RUnlock() + + return cs.blocks[len(cs.blocks)-1] +} + +func (cs *Blockchain) AddBlock(b *Block) bool { + cs.mx.Lock() + defer cs.mx.Unlock() + + return cs.addBlock(b) +} + +// addBlock validates and appends a block. Caller must hold write lock. +func (cs *Blockchain) addBlock(b *Block) bool { + last := cs.blocks[len(cs.blocks)-1] + if b.PrevHash != last.Hash || b.Height != last.Height+1 { + return false + } + if !b.CheckProofOfWork() { + return false + } + if !cs.validateTransactions(b.Transactions) { + return false + } + cs.applyBlock(b) + cs.blocks = append(cs.blocks, b) + return true +} + +func (cs *Blockchain) validateTransactions(txs []*Transaction) bool { + working := cs.coins.Clone() + coinbaseCount := 0 + + for _, tx := range txs { + if tx.IsCoinbase() { + coinbaseCount++ + if coinbaseCount > 1 { + return false + } + if len(tx.Coins) != 1 || tx.Coins[0].Amount != config.Default.Chain.MiningReward { + return false + } + continue + } + + if !cs.validateTx(tx, working) { + return false + } + + for _, s := range tx.Spends { + working.Spend(s.CoinID) + } + txID := tx.TxID() + for i, c := range tx.Coins { + working.Add(txID, i, c) + } + } + return true +} + +func (cs *Blockchain) validateTx(tx *Transaction, coins CoinStore) bool { + if !tx.Verify() { + return false + } + + inputSum := 0 + for _, s := range tx.Spends { + coin := coins.Get(s.CoinID) + if coin == nil { + return false + } + if !bytes.Equal(coin.Owner, s.Owner) { + return false + } + inputSum += coin.Amount + } + + outputSum := 0 + for _, c := range tx.Coins { + if c.Amount <= 0 { + return false + } + outputSum += c.Amount + } + + return outputSum <= inputSum +} + +func (cs *Blockchain) applyBlock(b *Block) { + for _, tx := range b.Transactions { + for _, s := range tx.Spends { + cs.coins.Spend(s.CoinID) + } + txID := tx.TxID() + for i, c := range tx.Coins { + cs.coins.Add(txID, i, c) + } + } +} + +func (cs *Blockchain) rebuildCoins() { + cs.coins = make(CoinStore) + for _, b := range cs.blocks { + cs.applyBlock(b) + } +} + +func (cs *Blockchain) ReplaceBlocks(blocks []*Block) bool { + cs.mx.Lock() + defer cs.mx.Unlock() + + if len(blocks) == 0 { + return false + } + + first := blocks[0] + attachIdx := -1 + for i, b := range cs.blocks { + if b.Hash == first.PrevHash { + attachIdx = i + break + } + } + if attachIdx == -1 { + return false + } + if attachIdx+1+len(blocks) <= len(cs.blocks) { + return false + } + + oldBlocks := make([]*Block, len(cs.blocks)) + copy(oldBlocks, cs.blocks) + oldCoins := cs.coins + + cs.blocks = cs.blocks[:attachIdx+1] + cs.rebuildCoins() + + for _, b := range blocks { + if !cs.addBlock(b) { + cs.blocks = oldBlocks + cs.coins = oldCoins + return false + } + } + return true +} + +// BlocksAfterHeight returns all blocks after the given height. +func (cs *Blockchain) BlocksAfterHeight(height int) []*Block { + cs.mx.RLock() + defer cs.mx.RUnlock() + if height+1 >= len(cs.blocks) { + return nil + } + result := make([]*Block, len(cs.blocks)-height-1) + copy(result, cs.blocks[height+1:]) + return result +} diff --git a/chain/mempool.go b/chain/mempool.go new file mode 100644 index 0000000..70b4650 --- /dev/null +++ b/chain/mempool.go @@ -0,0 +1,87 @@ +package chain + +import ( + "blockchain/config" + "crypto/ed25519" + "slices" + "sync" +) + +type Mempool struct { + transations []*Transaction + mx sync.Mutex +} + +func NewMempool() *Mempool { + return &Mempool{} +} + +func (m *Mempool) Add(tx *Transaction) { + if !tx.Verify() { + return + } + m.mx.Lock() + m.transations = append(m.transations, tx) + m.mx.Unlock() +} + +func (m *Mempool) Len() int { + m.mx.Lock() + defer m.mx.Unlock() + + return len(m.transations) +} + +func (m *Mempool) CollectBlock(miner ed25519.PublicKey) []*Transaction { + m.mx.Lock() + defer m.mx.Unlock() + + txs := []*Transaction{NewCoinbaseTransaction(miner, config.Default.Chain.MiningReward)} + spent := make(map[string]struct{}) + + isSpent := func(s CoinSpend) bool { + _, ok := spent[s.CoinID.Key()] + return ok + } + + for _, tx := range m.transations { + if slices.ContainsFunc(tx.Spends, isSpent) { + continue + } + + txs = append(txs, tx) + + for _, s := range tx.Spends { + spent[s.CoinID.Key()] = struct{}{} + } + } + + return txs +} + +func (m *Mempool) RemoveMined(block *Block) { + spent := make(map[string]struct{}) + for _, tx := range block.Transactions { + if tx.IsCoinbase() { + continue + } + for _, s := range tx.Spends { + spent[s.CoinID.Key()] = struct{}{} + } + } + if len(spent) == 0 { + return + } + + m.mx.Lock() + defer m.mx.Unlock() + + isSpent := func(s CoinSpend) bool { + _, ok := spent[s.CoinID.Key()] + return ok + } + + m.transations = slices.DeleteFunc(m.transations, func(tx *Transaction) bool { + return slices.ContainsFunc(tx.Spends, isSpent) + }) +} diff --git a/chain/transaction.go b/chain/transaction.go new file mode 100644 index 0000000..c98f54d --- /dev/null +++ b/chain/transaction.go @@ -0,0 +1,123 @@ +package chain + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +// CoinSpend references a coin being spent, with proof of ownership. +type CoinSpend struct { + CoinID CoinID + Owner ed25519.PublicKey + Signature []byte +} + +type Transaction struct { + Spends []CoinSpend + Coins []Coin + + cachedID string +} + +func (tx *Transaction) IsCoinbase() bool { + return len(tx.Spends) == 0 +} + +func (tx *Transaction) TxID() string { + if tx.cachedID != "" { + return tx.cachedID + } + + var b strings.Builder + for _, s := range tx.Spends { + b.WriteString(s.CoinID.TxHash) + b.WriteString(strconv.Itoa(s.CoinID.Index)) + } + for _, c := range tx.Coins { + b.Write(c.Owner) + b.WriteString(strconv.Itoa(c.Amount)) + } + h := sha256.Sum256([]byte(b.String())) + tx.cachedID = hex.EncodeToString(h[:]) + return tx.cachedID +} + +func (tx *Transaction) Verify() bool { + if tx.IsCoinbase() { + return true + } + + hash := []byte(tx.TxID()) + for _, s := range tx.Spends { + if s.Owner == nil { + return false + } + if !ed25519.Verify(s.Owner, hash, s.Signature) { + return false + } + } + + return true +} + +func (tx *Transaction) String() string { + if tx.IsCoinbase() { + return fmt.Sprintf("COINBASE -> %s: %d", tx.Coins[0].OwnerHex()[:7], tx.Coins[0].Amount) + } + + from := hex.EncodeToString(tx.Spends[0].Owner)[:7] + to := tx.Coins[0].OwnerHex()[:7] + + return fmt.Sprintf("%s -> %s: %d", from, to, tx.Coins[0].Amount) +} + +func NewCoinbaseTransaction(to ed25519.PublicKey, amount int) *Transaction { + return &Transaction{ + Coins: []Coin{{Amount: amount, Owner: to}}, + } +} + +func NewTransaction(wallet *Wallet, to ed25519.PublicKey, amount int, unspent []UnspentCoin) *Transaction { + var selected []UnspentCoin + var total int + + for _, u := range unspent { + selected = append(selected, u) + total += u.Amount + if total >= amount { + break + } + } + + if total < amount { + return nil + } + + coins := []Coin{{Amount: amount, Owner: to}} + + change := total - amount + + if change > 0 { + coins = append(coins, Coin{Amount: change, Owner: wallet.PublicKey}) + } + + spends := make([]CoinSpend, len(selected)) + for i, u := range selected { + spends[i] = CoinSpend{ + CoinID: u.CoinID, + Owner: wallet.PublicKey, + } + } + + tx := &Transaction{Spends: spends, Coins: coins} + hash := []byte(tx.TxID()) + for i := range tx.Spends { + tx.Spends[i].Signature = ed25519.Sign(wallet.PrivateKey, hash) + } + + return tx +} diff --git a/chain/utxo.go b/chain/utxo.go new file mode 100644 index 0000000..fc42787 --- /dev/null +++ b/chain/utxo.go @@ -0,0 +1,45 @@ +package chain + +import ( + "crypto/ed25519" + "encoding/hex" + "maps" + "strconv" +) + +type CoinID struct { + TxHash string + Index int +} + +func (id CoinID) Key() string { return id.TxHash + ":" + strconv.Itoa(id.Index) } + +type Coin struct { + Amount int + Owner ed25519.PublicKey +} + +func (c Coin) OwnerHex() string { return hex.EncodeToString(c.Owner) } + +type UnspentCoin struct { + Coin + CoinID CoinID +} + +type CoinStore map[string]*UnspentCoin + +func (s CoinStore) Add(txHash string, idx int, coin Coin) { + id := CoinID{TxHash: txHash, Index: idx} + s[id.Key()] = &UnspentCoin{Coin: coin, CoinID: id} +} + +func (s CoinStore) Spend(id CoinID) { delete(s, id.Key()) } + +func (s CoinStore) Get(id CoinID) *UnspentCoin { return s[id.Key()] } + +func (s CoinStore) Clone() CoinStore { + c := make(CoinStore, len(s)) + maps.Copy(c, s) + + return c +} diff --git a/chain/wallet.go b/chain/wallet.go new file mode 100644 index 0000000..bd99dfb --- /dev/null +++ b/chain/wallet.go @@ -0,0 +1,25 @@ +package chain + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" +) + +type Wallet struct { + PrivateKey ed25519.PrivateKey + PublicKey ed25519.PublicKey +} + +func NewWallet() *Wallet { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + return &Wallet{PrivateKey: priv, PublicKey: pub} +} + +func (w *Wallet) Address() string { + return hex.EncodeToString(w.PublicKey) +} + +func (w *Wallet) ShortAddress() string { + return w.Address()[:7] +} diff --git a/cmd/attack/main.go b/cmd/attack/main.go new file mode 100644 index 0000000..6460803 --- /dev/null +++ b/cmd/attack/main.go @@ -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 {} +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..e0cfa52 --- /dev/null +++ b/cmd/server/main.go @@ -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) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b72ece8 --- /dev/null +++ b/config/config.go @@ -0,0 +1,40 @@ +package config + +import "time" + +var Default = Config{ + Chain: ChainConfig{ + Difficulty: 4, + MiningReward: 10, + }, + P2P: P2PConfig{ + OutboundPeers: 3, // Bitcoin: 8 + MaxPeers: 20, // Bitcoin: 125 + }, + Sim: SimConfig{ + NumNodes: 30, + HashDelay: 5 * time.Millisecond, + }, +} + +type ChainConfig struct { + Difficulty int + MiningReward int +} + +type P2PConfig struct { + OutboundPeers int + MaxPeers int + NetDelay time.Duration +} + +type SimConfig struct { + NumNodes int + HashDelay time.Duration +} + +type Config struct { + Chain ChainConfig + P2P P2PConfig + Sim SimConfig +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e01da32 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module blockchain + +go 1.24.4 diff --git a/p2p/events.go b/p2p/events.go new file mode 100644 index 0000000..3d29460 --- /dev/null +++ b/p2p/events.go @@ -0,0 +1,24 @@ +package p2p + +import "time" + +type Event struct { + Type string `json:"type"` // "block_accept", "block_reject", "block_mine" + Node string `json:"node"` // short ID + BlockHash string `json:"block_hash"` // first 8 chars + Height int `json:"height"` + From string `json:"from"` // who sent it (short ID), empty if mined + Ts int64 `json:"ts"` // unix ms + TxCount int `json:"tx_count"` +} + +// Global event stream. Buffered so nodes never block. +var Events = make(chan Event, 1000) + +func emit(e Event) { + e.Ts = time.Now().UnixMilli() + select { + case Events <- e: + default: // drop if nobody listens + } +} diff --git a/p2p/miner.go b/p2p/miner.go new file mode 100644 index 0000000..c35856a --- /dev/null +++ b/p2p/miner.go @@ -0,0 +1,98 @@ +package p2p + +import ( + "blockchain/chain" + "blockchain/config" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + "time" +) + +type miner struct { + hashDelay *time.Duration + onMined func(*chain.Block, time.Duration) + work chan *chain.Block +} + +func newMiner(hashDelay *time.Duration, onMined func(*chain.Block, time.Duration)) *miner { + return &miner{ + hashDelay: hashDelay, + onMined: onMined, + work: make(chan *chain.Block, 1), + } +} + +// SubmitWork sends a new block template to the miner. +// If the miner is busy, the previous job is cancelled automatically. +func (m *miner) SubmitWork(block *chain.Block) { + // drain old work if any + select { + case <-m.work: + default: + } + m.work <- block +} + +func (m *miner) Run(stop <-chan struct{}) { + var ( + cancel chan struct{} + done chan bool + t0 time.Time + block *chain.Block + ) + + for { + select { + case block = <-m.work: + // cancel previous mining if running + if cancel != nil { + close(cancel) + <-done + } + cancel = make(chan struct{}) + done = make(chan bool, 1) + t0 = time.Now() + go func(b *chain.Block, c chan struct{}, d chan bool) { + d <- mine(b, c, *m.hashDelay) + }(block, cancel, done) + + case ok := <-done: + cancel = nil + done = nil + if ok { + m.onMined(block, time.Since(t0)) + } + + case <-stop: + if cancel != nil { + close(cancel) + } + return + } + } +} + +// mine перебирает nonce пока хеш не начнётся с нужного кол-ва нулей. +func mine(block *chain.Block, cancel chan struct{}, hashDelay time.Duration) bool { + prefix := strings.Repeat("0", config.Default.Chain.Difficulty) + base := block.HashBase() + for { + select { + case <-cancel: + return false + default: + } + data := base + strconv.Itoa(block.Nonce) + h := sha256.Sum256([]byte(data)) + block.Hash = hex.EncodeToString(h[:]) + if strings.HasPrefix(block.Hash, prefix) { + return true + } + block.Nonce++ + if hashDelay > 0 { + time.Sleep(hashDelay) + } + } +} diff --git a/p2p/msg.go b/p2p/msg.go new file mode 100644 index 0000000..a10783a --- /dev/null +++ b/p2p/msg.go @@ -0,0 +1,41 @@ +package p2p + +import "blockchain/chain" + +type Msg interface { + msg() +} + +type BlockMsg struct { + Block *chain.Block + From Peer +} + +type TxMsg struct { + Tx *chain.Transaction + From Peer +} + +type GetBlocksMsg struct { + Height int + From Peer +} + +type BlocksMsg struct { + Blocks []*chain.Block +} + +type GetPeersMsg struct { + From Peer +} + +type PeersMsg struct { + Peers []Peer +} + +func (BlockMsg) msg() {} +func (TxMsg) msg() {} +func (GetBlocksMsg) msg() {} +func (BlocksMsg) msg() {} +func (GetPeersMsg) msg() {} +func (PeersMsg) msg() {} diff --git a/p2p/node.go b/p2p/node.go new file mode 100644 index 0000000..101377f --- /dev/null +++ b/p2p/node.go @@ -0,0 +1,275 @@ +package p2p + +import ( + "blockchain/chain" + "blockchain/config" + "fmt" + "math/rand" + "sync" + "time" +) + +// link represents a network connection between two peers. +type link struct { + send chan Msg + latency time.Duration +} + +func newlink(target Peer, latency time.Duration) *link { + l := &link{ + send: make(chan Msg, 50), + latency: latency, + } + + if l.latency > 0 { + panic("newlink: latency < 0") + } + + go func() { + for msg := range l.send { + jitter := time.Duration(rand.NormFloat64()*float64(l.latency)/4 + float64(l.latency)) + if jitter > 0 { + time.Sleep(jitter) + } + target.Receive(msg) + } + }() + + return l +} + +type Node struct { + Wallet *chain.Wallet + Chain *chain.Blockchain + Mempool *chain.Mempool + HashDelay time.Duration + conn chan Msg + syncCh chan Msg + peers map[Peer]*link + mx sync.Mutex + miner *miner + seen map[string]struct{} +} + +func NewNode() *Node { + n := &Node{ + Wallet: chain.NewWallet(), + Chain: chain.NewBlockchain(), + Mempool: chain.NewMempool(), + conn: make(chan Msg, 100), + syncCh: make(chan Msg, 10), + peers: make(map[Peer]*link), + seen: make(map[string]struct{}), + } + n.miner = newMiner(&n.HashDelay, func(block *chain.Block, took time.Duration) { + if n.Chain.AddBlock(block) { + n.Mempool.RemoveMined(block) + fmt.Printf("[%s] Mined block #%d (%d txs), hash: %s took=%v nonce=%d\n", + n.ShortID(), block.Height, len(block.Transactions), block.Hash[:16], + took.Round(time.Millisecond), block.Nonce) + emit(Event{Type: "block_mine", Node: n.ShortID(), BlockHash: block.Hash[:8], Height: block.Height, TxCount: len(block.Transactions)}) + n.Broadcast(BlockMsg{Block: block, From: n}) + } else { + fmt.Printf("[%s] MINE-REJECT #%d (%d txs) took=%v — mined valid PoW but AddBlock failed\n", + n.ShortID(), block.Height, len(block.Transactions), took.Round(time.Millisecond)) + } + n.submitWork() + }) + return n +} + +// Receive implements Peer — delivers a network message. +func (n *Node) Receive(msg Msg) { + select { + case n.conn <- msg: + default: + } +} + +// ReceiveSync implements Peer — delivers a sync message. +func (n *Node) ReceiveSync(msg Msg) { + select { + case n.syncCh <- msg: + default: + } +} + +func (n *Node) submitWork() { + last := n.Chain.LastBlock() + txs := n.Mempool.CollectBlock(n.Wallet.PublicKey) + block := chain.NewBlock(last.Height+1, txs, last.Hash) + n.miner.SubmitWork(block) +} + +func (n *Node) ShortID() string { + return n.Wallet.ShortAddress() +} + +func (n *Node) MarkSeen(hash string) bool { + n.mx.Lock() + defer n.mx.Unlock() + + if _, ok := n.seen[hash]; ok { + return true + } + + const maxSeen = 10000 + + if len(n.seen) >= maxSeen { + n.seen = make(map[string]struct{}) + } + + n.seen[hash] = struct{}{} + + return false +} + +func (n *Node) AddPeer(peer Peer) { + if peer == n { + return + } + n.mx.Lock() + defer n.mx.Unlock() + if len(n.peers) >= config.Default.P2P.MaxPeers { + return + } + if _, exists := n.peers[peer]; exists { + return + } + n.peers[peer] = newlink(peer, config.Default.P2P.NetDelay) +} + +func (n *Node) PeerIDs() []string { + n.mx.Lock() + defer n.mx.Unlock() + ids := make([]string, 0, len(n.peers)) + for p := range n.peers { + ids = append(ids, p.ShortID()) + } + return ids +} + +func (n *Node) snapshotPeers() []Peer { + n.mx.Lock() + defer n.mx.Unlock() + peers := make([]Peer, 0, len(n.peers)) + for p := range n.peers { + peers = append(peers, p) + } + return peers +} + +func (n *Node) Send(to Peer, msg Msg) { + n.mx.Lock() + link, ok := n.peers[to] + n.mx.Unlock() + if !ok { + return + } + select { + case link.send <- msg: + default: + } +} + +func (n *Node) Broadcast(msg Msg) { + n.mx.Lock() + links := make([]*link, 0, len(n.peers)) + for _, l := range n.peers { + links = append(links, l) + } + n.mx.Unlock() + + for _, l := range links { + select { + case l.send <- msg: + default: + } + } +} + +func (n *Node) Run(stop chan struct{}) { + go n.miner.Run(stop) + n.submitWork() + + discover := time.NewTicker(10 * time.Second) + defer discover.Stop() + for { + select { + case <-discover.C: + peers := n.snapshotPeers() + if len(peers) > 0 { + n.Send(peers[rand.Intn(len(peers))], GetPeersMsg{From: n}) + } + case msg := <-n.conn: + switch m := msg.(type) { + case BlockMsg: + if n.MarkSeen(m.Block.Hash) { + continue + } + if n.Chain.AddBlock(m.Block) { + n.Mempool.RemoveMined(m.Block) + fromID := "" + if m.From != nil { + fromID = m.From.ShortID() + } + emit(Event{Type: "block_accept", Node: n.ShortID(), BlockHash: m.Block.Hash[:8], Height: m.Block.Height, From: fromID, TxCount: len(m.Block.Transactions)}) + n.Broadcast(BlockMsg{Block: m.Block, From: n}) + n.submitWork() + } else if m.Block.Height > n.Chain.LastBlock().Height { + fromID := "" + if m.From != nil { + fromID = m.From.ShortID() + } + emit(Event{Type: "block_reject", Node: n.ShortID(), BlockHash: m.Block.Hash[:8], Height: m.Block.Height, From: fromID}) + emit(Event{Type: "sync_request", Node: n.ShortID(), Height: n.Chain.LastBlock().Height, From: fromID}) + n.requestSync(m.From) + } + case TxMsg: + if n.MarkSeen(m.Tx.TxID()) { + continue + } + n.Mempool.Add(m.Tx) + n.Broadcast(TxMsg{Tx: m.Tx, From: n}) + case GetPeersMsg: + if m.From != nil { + n.Send(m.From, PeersMsg{Peers: n.snapshotPeers()}) + } + case PeersMsg: + for _, p := range m.Peers { + n.AddPeer(p) + } + } + case msg := <-n.syncCh: + switch m := msg.(type) { + case GetBlocksMsg: + blocks := n.Chain.BlocksAfterHeight(m.Height) + if len(blocks) > 0 && m.From != nil { + m.From.ReceiveSync(BlocksMsg{Blocks: blocks}) + } + case BlocksMsg: + oldHeight := n.Chain.LastBlock().Height + if n.Chain.ReplaceBlocks(m.Blocks) { + for _, b := range m.Blocks { + n.Mempool.RemoveMined(b) + } + newHeight := n.Chain.LastBlock().Height + emit(Event{Type: "reorg", Node: n.ShortID(), Height: newHeight, TxCount: newHeight - oldHeight}) + fmt.Printf("[%s] Synced chain to block #%d\n", + n.ShortID(), newHeight) + n.submitWork() + } + } + case <-stop: + return + } + } +} + +func (n *Node) requestSync(peer Peer) { + if peer == nil { + return + } + height := max(0, n.Chain.LastBlock().Height-5) + peer.ReceiveSync(GetBlocksMsg{Height: height, From: n}) +} diff --git a/p2p/peer.go b/p2p/peer.go new file mode 100644 index 0000000..33d2f2e --- /dev/null +++ b/p2p/peer.go @@ -0,0 +1,12 @@ +package p2p + +// Peer is the interface that any network participant must implement. +// Both honest nodes and attackers implement this — the network doesn't know the difference. +type Peer interface { + // Receive delivers a message to this peer (network layer). + Receive(msg Msg) + // ReceiveSync delivers a sync message (block sync protocol). + ReceiveSync(msg Msg) + // ShortID returns a short identifier for logging. + ShortID() string +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b4b3091 --- /dev/null +++ b/web/index.html @@ -0,0 +1,852 @@ + + +
+ + +