diff --git a/cmd/ion-node/main.go b/cmd/ion-node/main.go index 1ea964c..6a27553 100644 --- a/cmd/ion-node/main.go +++ b/cmd/ion-node/main.go @@ -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)"}, @@ -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)) } diff --git a/internal/config/config.go b/internal/config/config.go index 28e94ef..736b002 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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 } diff --git a/internal/config/options_test.go b/internal/config/options_test.go index 08af5a3..f66c191 100644 --- a/internal/config/options_test.go +++ b/internal/config/options_test.go @@ -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"), @@ -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, diff --git a/internal/p2p/client_test.go b/internal/p2p/client_test.go index b1b2b5d..e5d24ff 100644 --- a/internal/p2p/client_test.go +++ b/internal/p2p/client_test.go @@ -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") diff --git a/internal/p2p/overlay.go b/internal/p2p/overlay.go new file mode 100644 index 0000000..2004a37 --- /dev/null +++ b/internal/p2p/overlay.go @@ -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()) } diff --git a/internal/p2p/overlay_test.go b/internal/p2p/overlay_test.go new file mode 100644 index 0000000..f9dfb2e --- /dev/null +++ b/internal/p2p/overlay_test.go @@ -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") + } +} diff --git a/internal/p2p/peer.go b/internal/p2p/peer.go index 08b4d8b..6978d52 100644 --- a/internal/p2p/peer.go +++ b/internal/p2p/peer.go @@ -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 diff --git a/internal/p2p/peer_test.go b/internal/p2p/peer_test.go index 0e58d4d..d99ee9a 100644 --- a/internal/p2p/peer_test.go +++ b/internal/p2p/peer_test.go @@ -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 diff --git a/scripts/pick_service_bit.py b/scripts/pick_service_bit.py new file mode 100644 index 0000000..1530718 --- /dev/null +++ b/scripts/pick_service_bit.py @@ -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})")