blockchain sandbox: UTXO chain, p2p mining, attacker node
This commit is contained in:
@@ -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` — копия честного узла, но твоя. Меняй в ней что угодно.
|
||||
Остальные узлы крутят оригинальный код и слушают только сообщения.
|
||||
|
||||
Своруй монеты. Потрать дважды. Перепиши историю. Если сможешь.
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
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>
|
||||
Reference in New Issue
Block a user