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
+73
View File
@@ -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` — копия честного узла, но твоя. Меняй в ней что угодно.
Остальные узлы крутят оригинальный код и слушают только сообщения.
Своруй монеты. Потрать дважды. Перепиши историю. Если сможешь.
+206
View File
@@ -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})
}
}
}
+47
View File
@@ -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)
}
+204
View File
@@ -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
}
+87
View File
@@ -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)
})
}
+123
View File
@@ -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
}
+45
View File
@@ -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
}
+25
View File
@@ -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]
}
+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)
}
}
+40
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module blockchain
go 1.24.4
+24
View File
@@ -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
}
}
+98
View File
@@ -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)
}
}
}
+41
View File
@@ -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() {}
+275
View File
@@ -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})
}
+12
View File
@@ -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
}
+852
View File
@@ -0,0 +1,852 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blockchain Explorer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'SF Mono', 'Fira Code', monospace; background: #0a0a0f; color: #e0e0e0; }
.layout { display: grid; grid-template-columns: 280px 1fr; height: 100vh; }
.sidebar {
background: #0e0e16; border-right: 1px solid #1a1a2a; padding: 16px;
overflow-y: auto; display: flex; flex-direction: column;
}
.sidebar h2 { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 10px; }
.sidebar .stats { font-size: 11px; color: #444; margin-bottom: 12px; }
.sidebar .stats span { color: #00ff88; }
/* network health panel */
.net-health {
margin-bottom: 12px; padding: 8px; border-radius: 6px;
background: #0c0c14; border: 1px solid #1a1a2a;
}
.net-health .nh-title {
font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 6px;
}
.nh-row { display: flex; justify-content: space-between; align-items: center; font-size: 11px; margin-bottom: 4px; }
.nh-row .nh-label { color: #666; }
.nh-row .nh-val { font-weight: bold; }
.nh-row .nh-val.good { color: #00ff88; }
.nh-row .nh-val.warn { color: #ffe66d; }
.nh-row .nh-val.bad { color: #ff4444; }
.consensus-bar {
height: 8px; border-radius: 4px; background: #1a1a2a; overflow: hidden;
display: flex; margin-top: 4px;
}
.consensus-bar .cb-seg {
height: 100%; transition: width 0.5s ease;
}
.nodes-list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; flex: 1; }
.chain-group {
background: #0c0c14; border-radius: 5px;
padding: 4px; display: flex; flex-direction: column; gap: 2px;
border: 1px solid #1a1a2a;
transition: border-color 0.3s;
}
.chain-group.fork-majority { border-color: #00ff8844; }
.chain-group.fork-minority { border-color: #ff444444; }
.chain-group.fork-stale { border-color: #ff444422; opacity: 0.6; }
.chain-group-header {
display: flex; justify-content: space-between; align-items: center;
font-size: 9px; color: #444; padding: 2px 6px;
}
.chain-group-header .cg-hash { color: #333; }
.chain-group-header .cg-len { color: #00ff88; }
.cg-tag {
font-size: 8px; padding: 1px 5px; border-radius: 3px; font-weight: bold;
text-transform: uppercase; letter-spacing: 0.5px;
}
.cg-tag.majority { background: #00ff8822; color: #00ff88; }
.cg-tag.fork { background: #ff444422; color: #ff4444; }
.cg-tag.stale { background: #ff444411; color: #ff4444; opacity: 0.6; }
.node-row {
display: flex; justify-content: space-between; align-items: center;
padding: 3px 6px; border-radius: 3px; font-size: 11px;
background: #12121a; transition: background 0.3s;
border-left: 3px solid transparent;
}
.node-row.highlight { background: #1a2a1a; }
.node-row .n-left { display: flex; align-items: center; gap: 5px; }
.node-row .n-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.node-row .n-addr { font-size: 10px; }
.node-row .n-power { font-size: 9px; color: #555; }
.node-row .n-bal { color: #ffe66d; font-weight: bold; min-width: 40px; text-align: right; }
.node-row .n-bal.zero { color: #333; font-weight: normal; }
.main { padding: 16px; overflow: hidden; display: flex; flex-direction: column; }
.chain-strip {
height: 110px; min-height: 110px; max-height: 110px;
overflow-x: auto; overflow-y: hidden;
padding: 8px 10px; border-bottom: 1px solid #1a1a2a;
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
flex-wrap: nowrap;
}
.chain-strip .block-card {
width: 100px !important; height: 88px !important; min-width: 100px;
padding: 6px 8px !important; animation: none !important;
}
.chain-strip .block-top .b-idx { font-size: 13px; }
.chain-strip .block-top .b-hash { font-size: 7px; }
.chain-strip .block-txs .mini-tx { font-size: 8px; gap: 2px; }
.chain-strip .block-txs .mini-tx .mt-amt { font-size: 8px; }
.chain-strip .chain-link { height: 88px; font-size: 12px; }
.chain-strip .more-indicator { height: 88px; font-size: 9px; }
.chain-strip .genesis-label { font-size: 8px; }
.chain-strip .block-detail { display: none !important; }
.graph-wrap { flex: 1; position: relative; }
.graph-wrap canvas { width: 100%; height: 100%; display: block; }
.layers {
position: absolute; top: 8px; right: 8px; z-index: 5;
background: #0e0e16dd; border: 1px solid #1a1a2a; border-radius: 6px;
padding: 8px 10px; display: flex; flex-direction: column; gap: 4px;
backdrop-filter: blur(8px);
}
.layers .l-title {
font-size: 8px; color: #555; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 2px;
}
.layer-btn {
display: flex; align-items: center; gap: 6px; cursor: pointer;
font-size: 10px; color: #666; padding: 2px 0; user-select: none;
transition: color 0.2s;
}
.layer-btn:hover { color: #aaa; }
.layer-btn.on { color: #e0e0e0; }
.layer-btn .l-dot {
width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid #444;
transition: background 0.2s, border-color 0.2s;
}
.layer-btn.on .l-dot { border-color: currentColor; }
.chain {
display: flex; flex-wrap: wrap; align-items: flex-start; gap: 6px;
padding: 0;
}
.chain-link { color: #2a2a3a; font-size: 16px; display: flex; align-items: center; height: 160px; flex-shrink: 0; }
/* block card - square */
.block-card {
width: 160px; height: 160px; flex-shrink: 0;
background: #111118; border-radius: 10px; border: 1px solid #222;
display: flex; flex-direction: column;
padding: 10px; cursor: pointer;
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
position: relative;
animation: blockPop 0.4s ease-out;
overflow: hidden;
}
.block-card:hover { background: #181822; transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0,0,0,0.4); }
@keyframes blockPop {
0% { opacity: 0; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}
.block-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.block-top .b-idx { font-weight: bold; font-size: 16px; }
.block-top .b-hash { color: #2a2a3a; font-size: 8px; }
/* transaction list inside block */
.block-txs {
display: flex; flex-direction: column; gap: 2px;
flex: 1; overflow: hidden;
}
.mini-tx {
display: flex; align-items: center; gap: 3px;
font-size: 11px; line-height: 1.4;
white-space: nowrap;
}
.mini-tx .mt-arrow { color: #333; }
.mini-tx .mt-amt { color: #ffe66d; margin-left: auto; display: flex; align-items: center; gap: 2px; }
.mini-tx.coinbase { color: #00ff88; }
.mini-tx.coinbase .mt-amt { color: #f7931a; }
.coin { color: #f7931a; margin-left: 1px; }
.genesis-label { color: #333; font-size: 10px; margin: auto; }
/* detail popup */
.block-detail {
display: none; position: absolute; top: calc(100% + 6px); left: 0;
z-index: 10; width: 300px; padding: 10px;
background: #16161e; border: 1px solid #2a2a3a; border-radius: 8px;
font-size: 10px; color: #888;
box-shadow: 0 8px 30px rgba(0,0,0,0.6);
}
.block-card.open .block-detail { display: block; }
.block-detail .d-row { padding: 2px 0; word-break: break-all; }
.block-detail .d-label { color: #555; }
.block-detail .d-sep { border-top: 1px solid #222; margin: 4px 0; }
.block-detail .d-tx { padding: 3px 0; display: flex; align-items: center; gap: 4px; font-size: 10px; }
.block-detail .d-tx .dt-arrow { color: #444; }
.block-detail .d-tx .dt-amt { color: #ffe66d; font-weight: bold; margin-left: auto; }
.more-indicator { color: #333; font-size: 11px; flex-shrink: 0; padding: 0 8px; display: flex; align-items: center; height: 160px; }
::-webkit-scrollbar { width: 4px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #222; border-radius: 2px; }
@media (max-width: 700px) {
.layout { grid-template-columns: 1fr; grid-template-rows: 180px 1fr; }
}
</style>
</head>
<body>
<div class="layout">
<div class="sidebar">
<h2>Nodes</h2>
<div class="stats">
Total: <span id="node-count">0</span> &nbsp;
Supply: <span id="total-supply">0</span>
</div>
<div class="net-health" id="net-health">
<div class="nh-title">Network Health</div>
<div class="nh-row">
<span class="nh-label">Consensus</span>
<span class="nh-val good" id="nh-consensus">100%</span>
</div>
<div class="nh-row">
<span class="nh-label">Forks</span>
<span class="nh-val" id="nh-forks">1</span>
</div>
<div class="nh-row">
<span class="nh-label">Max height</span>
<span class="nh-val" id="nh-height">0</span>
</div>
<div class="consensus-bar" id="consensus-bar"></div>
</div>
<div class="nodes-list" id="nodes-list"></div>
</div>
<div class="main">
<div class="chain-strip" id="chain"></div>
<div class="graph-wrap">
<canvas id="graph-canvas"></canvas>
<div class="layers">
<div class="l-title">Layers</div>
<div class="layer-btn on" data-layer="propagation"><span class="l-dot" style="background:#4488ff"></span>Block propagation</div>
<div class="layer-btn" data-layer="txs"><span class="l-dot" style="background:#ffe66d"></span>Transactions</div>
<div class="layer-btn" data-layer="mempool"><span class="l-dot" style="background:#f7931a"></span>Mempool</div>
<div class="layer-btn" data-layer="heights"><span class="l-dot" style="background:#ffe66d"></span>Chain height</div>
<div class="layer-btn" data-layer="forks"><span class="l-dot" style="background:#ff4444"></span>Fork groups</div>
<div class="layer-btn" data-layer="sync"><span class="l-dot" style="background:#cc44ff"></span>Sync beams</div>
<div class="layer-btn" data-layer="reorg"><span class="l-dot" style="background:#ff8844"></span>Reorgs</div>
</div>
</div>
</div>
</div>
<script>
const API = '';
let lastBlockCount = 0;
let prevNodeBalances = {};
let selectedNode = 0;
const NODE_COLORS = [
'#ff6b6b', '#4ecdc4', '#ffe66d', '#a78bfa',
'#f97316', '#06b6d4', '#ec4899', '#84cc16',
'#e879f9', '#2dd4bf', '#fb923c', '#67e8f9',
'#f472b6', '#bef264', '#c084fc', '#5eead4',
'#fca5a5', '#a5f3fc', '#fde047', '#d8b4fe'
];
const nodeColorMap = {};
let colorIdx = 0;
function nodeColor(addr) {
if (!addr) return '#555';
if (!nodeColorMap[addr]) {
nodeColorMap[addr] = NODE_COLORS[colorIdx % NODE_COLORS.length];
colorIdx++;
}
return nodeColorMap[addr];
}
function short(s) {
if (!s) return '';
return s.slice(0, 7);
}
function parseTx(txStr) {
const m = txStr.match(/^(.+) -> (.+): (\d+)$/);
if (!m) return null;
return { from: m[1], to: m[2], amount: m[3] };
}
function powerLabel(us) {
if (us === 0) return 'max';
return us + '\u00B5s';
}
document.addEventListener('click', (e) => {
const card = e.target.closest('.block-card');
document.querySelectorAll('.block-card.open').forEach(el => {
if (el !== card) el.classList.remove('open');
});
if (card) card.classList.toggle('open');
});
async function fetchNodes() {
try {
const resp = await fetch(API + '/nodes');
const nodes = await resp.json();
const list = document.getElementById('nodes-list');
// group nodes by chain they see (last_hash)
const groups = {};
let totalSupply = 0;
for (const n of nodes) {
totalSupply += n.balance;
const key = n.last_hash;
if (!groups[key]) groups[key] = { len: n.chain_len, hash: n.last_hash, nodes: [] };
groups[key].nodes.push(n);
}
// sort groups: longest chain first, then by number of nodes
const sorted = Object.values(groups).sort((a, b) => b.len - a.len || b.nodes.length - a.nodes.length);
const totalNodes = nodes.length;
const maxLen = sorted.length > 0 ? sorted[0].len : 0;
const majorityGroup = sorted[0];
const majorityPct = totalNodes > 0 ? Math.round((majorityGroup.nodes.length / totalNodes) * 100) : 100;
const forkCount = sorted.length;
// update health panel
const nhConsensus = document.getElementById('nh-consensus');
nhConsensus.textContent = majorityPct + '%';
nhConsensus.className = 'nh-val ' + (majorityPct >= 80 ? 'good' : majorityPct >= 50 ? 'warn' : 'bad');
const nhForks = document.getElementById('nh-forks');
nhForks.textContent = forkCount;
nhForks.className = 'nh-val ' + (forkCount <= 1 ? 'good' : forkCount <= 3 ? 'warn' : 'bad');
document.getElementById('nh-height').textContent = '#' + (maxLen - 1);
// consensus bar — each segment is a fork
const FORK_COLORS = ['#00ff88', '#ff4444', '#ff8844', '#ffcc44', '#cc44ff', '#4488ff', '#44ffcc', '#ff44aa'];
let barHtml = '';
for (let i = 0; i < sorted.length; i++) {
const pct = (sorted[i].nodes.length / totalNodes) * 100;
const col = i === 0 ? FORK_COLORS[0] : FORK_COLORS[(i % (FORK_COLORS.length - 1)) + 1];
barHtml += `<div class="cb-seg" style="width:${pct}%;background:${col}"></div>`;
}
document.getElementById('consensus-bar').innerHTML = barHtml;
// render node groups
let html = '';
for (let gi = 0; gi < sorted.length; gi++) {
const g = sorted[gi];
const isMajority = gi === 0 && sorted.length > 1;
const isStale = g.len < maxLen - 2;
const forkClass = sorted.length <= 1 ? '' : isMajority ? 'fork-majority' : isStale ? 'fork-stale' : 'fork-minority';
const tagClass = sorted.length <= 1 ? '' : isMajority ? 'majority' : isStale ? 'stale' : 'fork';
const tagLabel = sorted.length <= 1 ? '' : isMajority ? 'majority' : isStale ? 'stale fork' : 'fork';
const forkColor = gi === 0 ? FORK_COLORS[0] : FORK_COLORS[(gi % (FORK_COLORS.length - 1)) + 1];
html += `<div class="chain-group ${forkClass}">
<div class="chain-group-header">
<span class="cg-len">#${g.len - 1} \u2022 ${g.nodes.length} nodes</span>
${tagLabel ? `<span class="cg-tag ${tagClass}">${tagLabel}</span>` : ''}
<span class="cg-hash">${short(g.hash)}</span>
</div>`;
for (const n of g.nodes) {
const balClass = n.balance === 0 ? 'n-bal zero' : 'n-bal';
const addr = short(n.address);
const color = nodeColor(n.address);
const prevBal = prevNodeBalances[addr];
const changed = prevBal !== undefined && prevBal !== n.balance;
html += `<div class="node-row ${changed ? 'highlight' : ''}" style="border-left-color:${color}">
<span class="n-left">
<span class="n-dot" style="background:${color}"></span>
<span class="n-addr" style="color:${color}">${addr}</span>
<span class="n-power">${powerLabel(n.hash_delay_us)}</span>
</span>
<span class="${balClass}">${n.balance} <span class="coin">\u20BF</span></span>
</div>`;
prevNodeBalances[addr] = n.balance;
}
html += '</div>';
}
list.innerHTML = html;
document.getElementById('node-count').textContent = nodes.length;
document.getElementById('total-supply').innerHTML = totalSupply + ' <span class="coin">\u20BF</span>';
} catch(e) {}
}
async function fetchChain() {
try {
const resp = await fetch(API + '/chain');
const blocks = await resp.json();
const container = document.getElementById('chain');
if (blocks.length === lastBlockCount) return;
container.innerHTML = '';
const maxShow = 60;
const start = Math.max(0, blocks.length - maxShow);
if (start > 0) {
container.innerHTML += `<span class="more-indicator">\u2026${start}</span>`;
container.innerHTML += `<span class="chain-link">\u2500\u2500</span>`;
}
for (let i = start; i < blocks.length; i++) {
const b = blocks[i];
const mc = nodeColor(b.miner);
let miniTxs = '', detailTxs = '', txCount = 0;
for (const txStr of b.txs) {
const tx = parseTx(txStr);
if (!tx) continue;
txCount++;
const isCB = tx.from === 'COINBASE';
const fc = nodeColor(tx.from), tc = nodeColor(tx.to);
if (txCount <= 4) {
miniTxs += `<div class="mini-tx ${isCB ? 'coinbase' : ''}">
<span style="color:${isCB ? '#00ff88' : fc}">${isCB ? '\u26CF' : tx.from}</span>
<span class="mt-arrow">\u2192</span>
<span style="color:${tc}">${tx.to}</span>
<span class="mt-amt">${tx.amount}<span class="coin">\u20BF</span></span>
</div>`;
}
detailTxs += `<div class="d-tx">
<span style="color:${isCB ? '#00ff88' : fc}">${isCB ? 'COINBASE' : tx.from}</span>
<span class="dt-arrow">\u2192</span>
<span style="color:${tc}">${tx.to}</span>
<span class="dt-amt">${tx.amount}<span class="coin">\u20BF</span></span>
</div>`;
}
if (txCount > 4) {
miniTxs += `<div class="mini-tx" style="color:#444">+${txCount - 4} more</div>`;
}
const isGenesis = !b.miner;
container.innerHTML += `
<div class="block-card" style="border-color:${isGenesis ? '#222' : mc}22; border-top: 3px solid ${isGenesis ? '#333' : mc}">
<div class="block-top">
<span class="b-idx" style="color:${mc}">#${b.index}</span>
<span class="b-hash">${b.hash.slice(0, 8)}</span>
</div>
${isGenesis ? '<span class="genesis-label">genesis</span>' : `<div style="flex:1;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:bold;color:${mc}88">${txCount}<span style="font-size:10px;color:#555;margin-left:3px">tx</span></div>`}
<div class="block-detail">
<div class="d-row"><span class="d-label">Hash </span>${b.hash}</div>
<div class="d-row"><span class="d-label">Prev </span>${b.prev_hash || 'none'}</div>
${b.miner ? `<div class="d-row"><span class="d-label">Miner </span><span style="color:${mc}">${b.miner}</span></div>` : ''}
${detailTxs ? `<div class="d-sep"></div>${detailTxs}` : ''}
</div>
</div>`;
if (i < blocks.length - 1) {
container.innerHTML += `<span class="chain-link">\u2500\u25B6</span>`;
}
}
lastBlockCount = blocks.length;
container.scrollLeft = container.scrollWidth;
} catch(e) {}
}
// graph auto-init
// --- layers ---
const layers = { propagation: true, txs: false, mempool: false, heights: false, forks: false, sync: false, reorg: false };
document.querySelectorAll('.layer-btn').forEach(btn => {
btn.addEventListener('click', () => {
const layer = btn.dataset.layer;
layers[layer] = !layers[layer];
btn.classList.toggle('on', layers[layer]);
});
});
// --- network graph ---
let graphInited = false;
let topoNodes = [];
let topoEdges = [];
let idMap = {};
const nodeRadius = 10;
const HOP_DELAY = 350; // block ~2.2s, diameter ~3 hops → wave finishes in ~1s
// layer data
let syncBeams = []; // {fromIdx, toIdx, time}
let reorgFlashes = []; // {idx, time, depth}
let txBeams = []; // {fromIdx, toIdx, time}
let nodeHeights = {}; // id → chain_len
let nodeTips = {}; // id → last_hash (for fork coloring)
let nodePending = {}; // id → pending tx count
async function initGraph() {
if (graphInited) return;
graphInited = true;
const resp = await fetch(API + '/topology');
const topo = await resp.json();
topo.forEach((n, i) => { idMap[n.id] = i; });
const W = 800, H = 600;
topoNodes = topo.map((n, i) => ({
id: n.id,
x: W/2 + (Math.random() - 0.5) * W * 0.8,
y: H/2 + (Math.random() - 0.5) * H * 0.8,
vx: 0, vy: 0,
peers: n.peers,
peerIdx: n.peers.map(p => idMap[p]).filter(p => p !== undefined),
color: nodeColor(n.id),
lit: 0, litColor: '', litTime: 0, isMiner: false, txFlash: 0
}));
const edgeSet = new Set();
for (const n of topo) {
const i = idMap[n.id];
for (const pid of n.peers) {
const j = idMap[pid];
if (j === undefined) continue;
const key = Math.min(i,j) + '-' + Math.max(i,j);
if (!edgeSet.has(key)) { edgeSet.add(key); topoEdges.push({a: i, b: j}); }
}
}
// periodic fetch of heights & tips for height/fork layers
async function fetchHeights() {
try {
const r = await fetch(API + '/nodes');
const nodes = await r.json();
for (const n of nodes) {
const sid = short(n.address);
nodeHeights[sid] = n.chain_len - 1;
nodeTips[sid] = n.last_hash;
nodePending[sid] = n.pending_txs || 0;
}
} catch(e) {}
}
fetchHeights();
setInterval(fetchHeights, 1500);
// SSE events
let currentBlockHash = '';
const blockHops = {};
const sse = new EventSource(API + '/events');
sse.onmessage = (e) => {
const ev = JSON.parse(e.data);
const ni = idMap[ev.node];
if (ni === undefined) return;
const hash = ev.block_hash;
if (ev.type === 'block_mine') {
currentBlockHash = hash;
blockHops[hash] = { [ev.node]: 0 };
for (const nd of topoNodes) { nd.lit = 0; nd.isMiner = false; }
topoNodes[ni].lit = 1;
topoNodes[ni].litColor = '#00ff88';
topoNodes[ni].litTime = performance.now();
topoNodes[ni].isMiner = true;
} else if (ev.type === 'block_accept') {
if (!blockHops[hash]) blockHops[hash] = {};
const senderHop = (ev.from && blockHops[hash][ev.from] !== undefined) ? blockHops[hash][ev.from] : 0;
const myHop = senderHop + 1;
blockHops[hash][ev.node] = myHop;
const capturedHash = hash;
setTimeout(() => {
if (capturedHash !== currentBlockHash) return; // stale block, skip
if (topoNodes[ni].lit > 0) return;
topoNodes[ni].lit = 1;
topoNodes[ni].litColor = '#4488ff';
topoNodes[ni].litTime = performance.now();
}, myHop * HOP_DELAY);
} else if (ev.type === 'sync_request') {
const fi = idMap[ev.from];
if (fi !== undefined) {
syncBeams.push({fromIdx: ni, toIdx: fi, time: performance.now()});
}
} else if (ev.type === 'reorg') {
reorgFlashes.push({idx: ni, time: performance.now(), depth: ev.tx_count});
} else if (ev.type === 'tx_send') {
// ev.node = sender, ev.block_hash = receiver shortID, ev.tx_count = amount
const ri = idMap[ev.block_hash];
topoNodes[ni].txFlash = performance.now();
if (ri !== undefined) {
txBeams.push({fromIdx: ni, toIdx: ri, time: performance.now(), amount: ev.tx_count});
}
}
};
setInterval(() => {
const keys = Object.keys(blockHops);
if (keys.length > 10) { for (const k of keys.slice(0, keys.length - 10)) delete blockHops[k]; }
}, 10000);
drawGraph();
}
function drawGraph() {
const canvas = document.getElementById('graph-canvas');
const wrap = canvas.parentElement;
const dpr = window.devicePixelRatio || 1;
canvas.width = wrap.clientWidth * dpr;
canvas.height = wrap.clientHeight * dpr;
canvas.style.width = wrap.clientWidth + 'px';
canvas.style.height = wrap.clientHeight + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const W = wrap.clientWidth, H = wrap.clientHeight;
const now = performance.now();
// --- force simulation ---
const kSpring = 0.004, rep = 10000, damp = 0.85, idealLen = 160;
for (let i = 0; i < topoNodes.length; i++) {
for (let j = i+1; j < topoNodes.length; j++) {
let dx = topoNodes[j].x - topoNodes[i].x;
let dy = topoNodes[j].y - topoNodes[i].y;
let d = Math.sqrt(dx*dx + dy*dy) || 1;
let f = rep / (d * d);
topoNodes[i].vx -= dx/d * f; topoNodes[i].vy -= dy/d * f;
topoNodes[j].vx += dx/d * f; topoNodes[j].vy += dy/d * f;
}
}
for (const e of topoEdges) {
const a = topoNodes[e.a], b = topoNodes[e.b];
let dx = b.x - a.x, dy = b.y - a.y;
let d = Math.sqrt(dx*dx + dy*dy) || 1;
let f = kSpring * (d - idealLen);
a.vx += dx/d * f; a.vy += dy/d * f;
b.vx -= dx/d * f; b.vy -= dy/d * f;
}
for (const n of topoNodes) {
n.vx += (W/2 - n.x) * 0.001;
n.vy += (H/2 - n.y) * 0.001;
n.vx *= damp; n.vy *= damp;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(20, Math.min(W - 20, n.x));
n.y = Math.max(20, Math.min(H - 20, n.y));
}
// --- fork group colors ---
const FORK_PALETTE = ['#4488ff', '#ff4444', '#ff8844', '#ffcc44', '#cc44ff', '#44ffcc'];
const tipToColor = {};
let tipIdx = 0;
if (layers.forks) {
const tipCounts = {};
for (const id in nodeTips) { tipCounts[nodeTips[id]] = (tipCounts[nodeTips[id]] || 0) + 1; }
const sortedTips = Object.entries(tipCounts).sort((a,b) => b[1] - a[1]);
for (const [tip] of sortedTips) {
tipToColor[tip] = FORK_PALETTE[tipIdx % FORK_PALETTE.length];
tipIdx++;
}
}
// --- draw ---
ctx.clearRect(0, 0, W, H);
// edges
for (const e of topoEdges) {
const a = topoNodes[e.a], b = topoNodes[e.b];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
if (layers.propagation && a.lit > 0 && b.lit > 0) {
ctx.lineWidth = 2; ctx.strokeStyle = '#00ff8844';
} else if (layers.forks && nodeTips[a.id] && nodeTips[b.id] && nodeTips[a.id] !== nodeTips[b.id]) {
ctx.lineWidth = 1.5; ctx.strokeStyle = '#ff444444';
} else {
ctx.lineWidth = 0.5; ctx.strokeStyle = '#1a1a2a';
}
ctx.stroke();
}
// tx beams — yellow traveling dots from sender to receiver
if (layers.txs) {
for (let i = txBeams.length - 1; i >= 0; i--) {
const tb = txBeams[i];
const age = now - tb.time;
if (age > 600) { txBeams.splice(i, 1); continue; }
const t = age / 600;
const a = topoNodes[tb.fromIdx], b = topoNodes[tb.toIdx];
const px = a.x + (b.x - a.x) * t;
const py = a.y + (b.y - a.y) * t;
const alpha = 1 - t * 0.5;
// line
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(px, py);
ctx.strokeStyle = '#ffe66d' + Math.round(alpha * 60).toString(16).padStart(2, '0');
ctx.lineWidth = 1;
ctx.stroke();
// dot
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fillStyle = '#ffe66d' + Math.round(alpha * 220).toString(16).padStart(2, '0');
ctx.fill();
}
}
// sync beams
if (layers.sync) {
for (let i = syncBeams.length - 1; i >= 0; i--) {
const s = syncBeams[i];
const age = now - s.time;
if (age > 3000) { syncBeams.splice(i, 1); continue; }
const alpha = 1 - age / 3000;
const a = topoNodes[s.fromIdx], b = topoNodes[s.toIdx];
ctx.beginPath();
ctx.setLineDash([6, 4]);
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = '#cc44ff' + Math.round(alpha * 200).toString(16).padStart(2, '0');
ctx.lineWidth = 2;
ctx.stroke();
ctx.setLineDash([]);
// arrow head
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const mx = a.x + (b.x - a.x) * 0.6, my = a.y + (b.y - a.y) * 0.6;
ctx.beginPath();
ctx.moveTo(mx, my);
ctx.lineTo(mx - 8*Math.cos(angle-0.4), my - 8*Math.sin(angle-0.4));
ctx.lineTo(mx - 8*Math.cos(angle+0.4), my - 8*Math.sin(angle+0.4));
ctx.closePath();
ctx.fillStyle = '#cc44ff' + Math.round(alpha * 200).toString(16).padStart(2, '0');
ctx.fill();
}
}
// nodes
for (const n of topoNodes) {
const age = now - n.litTime;
const isLit = n.lit > 0;
const tip = nodeTips[n.id];
const forkColor = layers.forks && tip ? (tipToColor[tip] || '#555') : null;
// propagation ring
if (layers.propagation && isLit && age < 800) {
const ringProgress = age / 800;
const ringR = nodeRadius + 30 * ringProgress;
ctx.beginPath();
ctx.arc(n.x, n.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = n.litColor + Math.round((1 - ringProgress) * 180).toString(16).padStart(2, '0');
ctx.lineWidth = 3 * (1 - ringProgress);
ctx.stroke();
}
// glow
if (layers.propagation && isLit) {
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 8, 0, Math.PI * 2);
ctx.fillStyle = n.litColor + '30';
ctx.fill();
}
// node circle
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius, 0, Math.PI * 2);
if (layers.forks && forkColor) {
ctx.fillStyle = forkColor;
} else if (layers.propagation && isLit) {
ctx.fillStyle = n.litColor;
} else {
ctx.fillStyle = '#333';
}
ctx.fill();
ctx.strokeStyle = '#555'; ctx.lineWidth = 0.5; ctx.stroke();
// miner ring
if (n.isMiner && layers.propagation) {
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 3, 0, Math.PI * 2);
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
}
// mempool ring
if (layers.mempool) {
const pending = nodePending[n.id] || 0;
if (pending > 0) {
const thickness = Math.min(pending * 1.5, 12);
const alpha = Math.min(0.3 + pending * 0.05, 0.8);
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 3 + thickness / 2, 0, Math.PI * 2);
ctx.strokeStyle = '#f7931a' + Math.round(alpha * 255).toString(16).padStart(2, '0');
ctx.lineWidth = thickness;
ctx.stroke();
// count label
ctx.fillStyle = '#f7931a';
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(pending, n.x, n.y - nodeRadius - 4);
}
}
// tx flash — small yellow ping
if (layers.txs && n.txFlash > 0) {
const txAge = now - n.txFlash;
if (txAge < 400) {
const tp = txAge / 400;
ctx.beginPath();
ctx.arc(n.x, n.y, nodeRadius + 10 * tp, 0, Math.PI * 2);
ctx.strokeStyle = '#ffe66d' + Math.round((1 - tp) * 150).toString(16).padStart(2, '0');
ctx.lineWidth = 2 * (1 - tp);
ctx.stroke();
}
}
// height label inside node
if (layers.heights && nodeHeights[n.id] !== undefined) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 8px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(nodeHeights[n.id], n.x, n.y);
}
// node ID below
ctx.fillStyle = '#ffffff33';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(n.id, n.x, n.y + nodeRadius + 4);
}
// reorg flashes
if (layers.reorg) {
for (let i = reorgFlashes.length - 1; i >= 0; i--) {
const r = reorgFlashes[i];
const age = now - r.time;
if (age > 2000) { reorgFlashes.splice(i, 1); continue; }
const n = topoNodes[r.idx];
const alpha = 1 - age / 2000;
// pulsing red ring
const ringR = nodeRadius + 20 + 15 * Math.sin(age / 100);
ctx.beginPath();
ctx.arc(n.x, n.y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = '#ff4444' + Math.round(alpha * 200).toString(16).padStart(2, '0');
ctx.lineWidth = 3;
ctx.stroke();
// label
ctx.fillStyle = '#ff8844' + Math.round(alpha * 255).toString(16).padStart(2, '0');
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
ctx.fillText('REORG +' + r.depth, n.x, n.y - nodeRadius - 14);
}
}
requestAnimationFrame(drawGraph);
}
fetchNodes();
fetchChain();
initGraph();
setInterval(fetchChain, 1000);
setInterval(fetchNodes, 1000);
</script>
</body>
</html>