Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/ion-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func globalFlags() []cli.Flag {
&cli.StringFlag{Name: "checkpoint", Usage: "extra trusted checkpoint as height:blockhash (augments the network's shipped checkpoints)"},
&cli.StringSliceFlag{Name: "peer", Usage: "explicit peer host:port (repeatable; overrides DNS discovery)"},
&cli.StringFlag{Name: "anchor-peer", Usage: "reserved trusted/anchor peer host:port"},
&cli.StringSliceFlag{Name: "ion-peer", Usage: "ION overlay seed peer host:port for fast-sync bootstrap (repeatable; no public seeds yet, so operator-supplied)"},
&cli.IntFlag{Name: "outbound", Value: -1, Usage: "target outbound connection count (default 8)"},
&cli.IntFlag{Name: "concurrency", Value: -1, Usage: "block-download fan-out (default 16)"},
&cli.StringFlag{Name: "datadir", Usage: "directory for the anchor-index store (default per profile)"},
Expand Down Expand Up @@ -132,6 +133,9 @@ func buildConfig(cCtx *cli.Context) (*config.Config, error) {
if v := cCtx.String("anchor-peer"); v != "" {
opts = append(opts, config.WithAnchorPeer(v))
}
if v := cCtx.StringSlice("ion-peer"); len(v) > 0 {
opts = append(opts, config.WithIONPeers(v...))
}
if v := cCtx.Int("outbound"); v >= 0 {
opts = append(opts, config.WithTargetOutbound(v))
}
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type Config struct {
// Peering.
Peers []string // explicit peer "host:port" addresses; overrides DNS discovery when set
AnchorPeer string // reserved trusted/anchor peer "host:port"; "" = none
IONPeers []string // ION overlay seed peers "host:port" for fast-sync (#111); "" = none yet
TargetOutbound int // desired number of outbound connections

// Local resources.
Expand Down Expand Up @@ -231,6 +232,12 @@ func WithAnchorPeer(addr string) Option {
return func(c *Config) error { c.AnchorPeer = addr; return nil }
}

// WithIONPeers sets ION overlay seed peers (host:port) for fast-sync bootstrap (#111).
// There are no public ION seeds yet, so these are operator-supplied for now.
func WithIONPeers(peers ...string) Option {
return func(c *Config) error { c.IONPeers = peers; return nil }
}

// WithTargetOutbound sets the desired outbound connection count.
func WithTargetOutbound(n int) Option {
return func(c *Config) error { c.TargetOutbound = n; return nil }
Expand Down
2 changes: 2 additions & 0 deletions internal/config/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func TestAllOptionsApplied(t *testing.T) {
WithDataDir("/tmp/ion"),
WithPeers("1.2.3.4:8333", "5.6.7.8:8333"),
WithAnchorPeer("9.9.9.9:8333"),
WithIONPeers("1.1.1.1:8333", "2.2.2.2:8333"),
WithTargetOutbound(4),
WithConcurrency(8),
WithStoreEngine("bolt"),
Expand All @@ -28,6 +29,7 @@ func TestAllOptionsApplied(t *testing.T) {
}
checks := map[string]bool{
"prefix": c.Prefix == "ion:test:",
"ionPeers": len(c.IONPeers) == 2 && c.IONPeers[0] == "1.1.1.1:8333",
"genesis": c.GenesisHeight == 700000,
"confDepth": c.ConfirmationDepth == 12,
"maxReorg": c.MaxReorgDepth == 200,
Expand Down
9 changes: 7 additions & 2 deletions internal/p2p/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,13 @@ func TestPeerConfigBasics(t *testing.T) {
if !pc.DisableRelayTx {
t.Errorf("DisableRelayTx should be true (no mempool relay)")
}
if pc.Services != 0 {
t.Errorf("Services should be 0 (we serve nothing)")
// We serve no Bitcoin services, but we advertise the ION overlay bit (#114) so
// other ion-nodes can discover us for fast-sync.
if pc.Services != ServiceION {
t.Errorf("Services = %#x, want ServiceION (%#x) for overlay discovery", pc.Services, ServiceION)
}
if !SpeaksION(pc.Services) {
t.Error("our own advertised services should speak ION")
}
if pc.NewestBlock == nil {
t.Errorf("NewestBlock callback should be set")
Expand Down
54 changes: 54 additions & 0 deletions internal/p2p/overlay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package p2p

import (
"github.com/btcsuite/btcd/peer"
"github.com/btcsuite/btcd/wire"
)

// ServiceION is the Bitcoin P2P service bit an ion-node advertises (in its version
// message) to mark itself as a participant in the ION fast-sync overlay (epic #111).
// Bit 30 falls in the experimental range (bits 24-31) that Bitcoin reserves for
// exactly this use, so it cannot collide with a defined service bit (NODE_NETWORK,
// NODE_WITNESS, NODE_COMPACT_FILTERS, ...). It was chosen by dice roll β€” five rolls,
// take the fifth (scripts/pick_service_bit.py). This is a placeholder for the wider
// ecosystem; it can change before any real overlay deployment.
const ServiceION wire.ServiceFlag = 1 << 30

// SpeaksION reports whether an advertised service set includes the ION overlay bit,
// i.e. the peer is an ion-node we may fast-sync from or gossip with.
func SpeaksION(services wire.ServiceFlag) bool {
return services&ServiceION == ServiceION
}

// IONPeerCount returns how many currently-connected peers advertised the ION overlay
// service bit β€” the discovered overlay neighbourhood among our Bitcoin peers.
func (c *Client) IONPeerCount() int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for _, p := range c.peers {
if SpeaksION(p.Services()) {
n++
}
}
return n
}

// IONPeerAddrs returns the addresses of currently-connected peers that advertised the
// ION overlay service bit (the fast-sync / gossip candidates).
func (c *Client) IONPeerAddrs() []string {
c.mu.Lock()
defer c.mu.Unlock()
var out []string
for _, p := range c.peers {
if SpeaksION(p.Services()) {
out = append(out, p.Addr())
}
}
return out
}

// speaksION is the per-peer detector used internally (kept small for the gating
// callers in later phases: only send ION overlay messages to peers that advertised
// the bit, so a vanilla Bitcoin peer is never sent an unknown command).
func speaksION(p *peer.Peer) bool { return SpeaksION(p.Services()) }
39 changes: 39 additions & 0 deletions internal/p2p/overlay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package p2p

import (
"testing"

"github.com/btcsuite/btcd/wire"
)

func TestSpeaksION(t *testing.T) {
if ServiceION != 1<<30 {
t.Errorf("ServiceION = %#x, want 1<<30 (the dice-rolled experimental bit)", ServiceION)
}
// The chosen bit must be in the experimental range (24..31), so it cannot collide
// with a defined Bitcoin service bit.
if ServiceION < 1<<24 || ServiceION > 1<<31 {
t.Errorf("ServiceION %#x is outside the experimental range [1<<24, 1<<31]", ServiceION)
}
for _, defined := range []wire.ServiceFlag{
wire.SFNodeNetwork, wire.SFNodeGetUTXO, wire.SFNodeBloom, wire.SFNodeWitness,
wire.SFNodeNetworkLimited,
} {
if ServiceION&defined != 0 {
t.Errorf("ServiceION collides with a defined service bit %#x", defined)
}
}

if !SpeaksION(ServiceION) {
t.Error("ServiceION alone should speak ION")
}
if !SpeaksION(ServiceION | wire.SFNodeNetwork | wire.SFNodeWitness) {
t.Error("ION + other bits should speak ION")
}
if SpeaksION(0) {
t.Error("no services should not speak ION")
}
if SpeaksION(wire.SFNodeNetwork | wire.SFNodeWitness) {
t.Error("vanilla Bitcoin services should not speak ION")
}
}
8 changes: 5 additions & 3 deletions internal/p2p/peer.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,11 @@ func (c *Client) peerConfig() *peer.Config {
UserAgentName: userAgentName,
UserAgentVersion: userAgentVersion,
ChainParams: c.cfg.Params,
Services: 0, // we serve nothing
DisableRelayTx: true, // no mempool tx relay
AllowSelfConns: c.allowSelfConns,
// We serve no Bitcoin services (no blocks/bloom/filters), but we DO advertise
// the ION overlay bit so other ion-nodes can discover us for fast-sync (#111).
Services: ServiceION,
DisableRelayTx: true, // no mempool tx relay
AllowSelfConns: c.allowSelfConns,
NewestBlock: func() (*chainhash.Hash, int32, error) {
h := c.hc.TipHash()
return &h, c.hc.TipHeight(), nil
Expand Down
80 changes: 80 additions & 0 deletions internal/p2p/peer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,86 @@ func TestHandshakeAndHeaderSync(t *testing.T) {
}
}

// TestIONPeerDetection drives the real btcd version handshake against a stub server
// that does (or does not) advertise the ION overlay service bit, and confirms the
// client classifies the peer accordingly (#114) β€” the discovery foundation for
// fast-sync.
func TestIONPeerDetection(t *testing.T) {
params := &chaincfg.SimNetParams
cases := []struct {
name string
svc wire.ServiceFlag
want int
}{
{"ion server detected", ServiceION | wire.SFNodeNetwork, 1},
{"vanilla server not detected", wire.SFNodeNetwork, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()

ready := make(chan *peer.Peer, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
sp := peer.NewInboundPeer(&peer.Config{
UserAgentName: "stub-server",
UserAgentVersion: "0.0.1",
ChainParams: params,
Services: tc.svc,
AllowSelfConns: true,
})
sp.AssociateConnection(conn)
ready <- sp
}()

cfg, err := config.New("simnet")
if err != nil {
t.Fatalf("config: %v", err)
}
cl := New(cfg, chain.NewHeaderChain(params), log.Noop())
cl.allowSelfConns = true

conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
cp, err := peer.NewOutboundPeer(cl.peerConfig(), ln.Addr().String())
if err != nil {
t.Fatalf("NewOutboundPeer: %v", err)
}
cl.addPeer(cp)
cp.AssociateConnection(conn)
defer cp.Disconnect()

// The remote's advertised services are known once the version handshake
// completes (verack received).
deadline := time.Now().Add(10 * time.Second)
for !cp.VerAckReceived() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !cp.VerAckReceived() {
t.Fatal("handshake did not complete")
}
if got := cl.IONPeerCount(); got != tc.want {
t.Errorf("IONPeerCount = %d, want %d (server services %#x)", got, tc.want, tc.svc)
}

select {
case sp := <-ready:
sp.Disconnect()
default:
}
})
}
}

// TestRemovePeerKeepsBan guards #21: removePeer clears only a NON-banned peer's
// accrued soft-fault score on disconnect; a peer that crossed the ban threshold stays
// banned, so it cannot wipe its ban simply by disconnecting and reconnecting. (With
Expand Down
15 changes: 15 additions & 0 deletions scripts/pick_service_bit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Pick the ION overlay's Bitcoin P2P service bit by dice roll.

Bitcoin reserves service bits 24-31 for temporary/experimental use, so we roll
within that range to avoid colliding with defined bits (NODE_NETWORK, WITNESS,
COMPACT_FILTERS, ...). Roll 5 times, take the 5th β€” as decreed.
"""
import secrets

EXPERIMENTAL = list(range(24, 32)) # bits reserved for experiments
rolls = [secrets.choice(EXPERIMENTAL) for _ in range(5)]
chosen = rolls[-1]
print(f"rolls (bit positions): {rolls}")
print(f"chosen (5th roll): bit {chosen}")
print(f"ServiceION = 1 << {chosen} = {hex(1 << chosen)} ({1 << chosen})")
Loading