e2e_test.go raw

   1  package e2e
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"net"
   7  	"net/url"
   8  	"testing"
   9  	"time"
  10  
  11  	"git.smesh.lol/nostrino/internal/memrelay"
  12  	"git.smesh.lol/nostrino/internal/testnode"
  13  	"git.smesh.lol/nostrino/pkg/bridge"
  14  	"git.smesh.lol/nostrino/pkg/chain"
  15  	"git.smesh.lol/nostrino/pkg/crypto/chainhash"
  16  	"git.smesh.lol/nostrino/pkg/neutrino"
  17  	"git.smesh.lol/nostrino/pkg/relay"
  18  	"git.smesh.lol/nostrino/pkg/tunnel"
  19  	"git.smesh.lol/nostrino/pkg/ws"
  20  )
  21  
  22  type harness struct {
  23  	node   *testnode.Node
  24  	relay  *memrelay.Relay
  25  	bridge *bridge.Server
  26  	uri    *tunnel.URI
  27  	ctx    context.Context
  28  	cancel context.CancelFunc
  29  }
  30  
  31  func newHarness(t *testing.T, blocks int) *harness {
  32  	t.Helper()
  33  	profile := chain.Regtest
  34  	node, err := testnode.New(&profile, testnode.Config{Blocks: blocks})
  35  	if err != nil {
  36  		t.Fatalf("testnode: %v", err)
  37  	}
  38  	t.Cleanup(func() { node.Close() })
  39  	addr, err := node.Listen("127.0.0.1:0")
  40  	if err != nil {
  41  		t.Fatalf("listen: %v", err)
  42  	}
  43  	r := memrelay.New()
  44  	t.Cleanup(r.Close)
  45  	var gwKey [32]byte
  46  	gwKey[31] = 55
  47  	ctx, cancel := context.WithCancel(context.Background())
  48  	t.Cleanup(cancel)
  49  	factory := func(string) (relay.Transport, error) { return r.Dial("") }
  50  	b, err := bridge.Start(ctx, bridge.Config{
  51  		Relays:           []string{"mem://relay"},
  52  		NodeAddr:         addr,
  53  		Key:              gwKey,
  54  		Profile:          &profile,
  55  		Chains:           []string{"regtest"},
  56  		BridgeListen:     "127.0.0.1:0",
  57  		BridgePath:       "/nostrino",
  58  		TransportFactory: factory,
  59  		Logf:             t.Logf,
  60  	})
  61  	if err != nil {
  62  		t.Fatalf("bridge: %v", err)
  63  	}
  64  	t.Cleanup(func() { b.Close() })
  65  	go b.Run(ctx)
  66  	h := &harness{node: node, relay: r, bridge: b, uri: b.URI(), ctx: ctx, cancel: cancel}
  67  	return h
  68  }
  69  
  70  func (h *harness) client(t *testing.T, store neutrino.Store) *neutrino.Client {
  71  	t.Helper()
  72  	var clientKey [32]byte
  73  	clientKey[31] = 77
  74  	factory := func(string) (relay.Transport, error) { return h.relay.Dial("") }
  75  	c, err := neutrino.New(neutrino.Config{
  76  		Profile: &chain.Regtest,
  77  		Store:   store,
  78  		Dial: func(ctx context.Context) (net.Conn, error) {
  79  			return tunnel.Dial(ctx, tunnel.DialConfig{
  80  				URI:              h.uri,
  81  				Key:              clientKey,
  82  				Chain:            "regtest",
  83  				TransportFactory: factory,
  84  			})
  85  		},
  86  		RequireCompactFilters: true,
  87  		ReadTimeout:           20 * time.Second,
  88  		Logf:                  t.Logf,
  89  	})
  90  	if err != nil {
  91  		t.Fatalf("neutrino: %v", err)
  92  	}
  93  	t.Cleanup(func() { c.Close() })
  94  	return c
  95  }
  96  
  97  func TestSyncThroughBridge(t *testing.T) {
  98  	h := newHarness(t, 30)
  99  	store := neutrino.NewMemory()
 100  	c := h.client(t, store)
 101  	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
 102  	defer cancel()
 103  	res, err := c.Sync(ctx)
 104  	if err != nil {
 105  		t.Fatalf("sync: %v", err)
 106  	}
 107  	if res.TipHeight != h.node.Height() {
 108  		t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height())
 109  	}
 110  	if res.HeadersAdded != int(h.node.Height()) {
 111  		t.Fatalf("headers added %d, want %d", res.HeadersAdded, h.node.Height())
 112  	}
 113  	if res.FilterHeadersAdded != int(h.node.Height()+1) {
 114  		t.Fatalf("filter headers added %d, want %d", res.FilterHeadersAdded, h.node.Height()+1)
 115  	}
 116  	if res.FiltersAdded != int(h.node.Height()+1) {
 117  		t.Fatalf("filters added %d, want %d", res.FiltersAdded, h.node.Height()+1)
 118  	}
 119  	for height := uint32(1); height < h.node.Height(); height++ {
 120  		got, ok := store.Filter(height)
 121  		if !ok {
 122  			t.Fatalf("filter %d missing", height)
 123  		}
 124  		want, _ := h.node.Filter(height)
 125  		if !bytes.Equal(got, want) {
 126  			t.Fatalf("filter %d mismatch", height)
 127  		}
 128  	}
 129  
 130  	watch := h.node.Script(7)
 131  	matches, err := c.MatchScripts(0, 0, [][]byte{watch})
 132  	if err != nil {
 133  		t.Fatalf("match: %v", err)
 134  	}
 135  	if len(matches) != 1 || matches[0] != 7 {
 136  		t.Fatalf("matches %v, want [7]", matches)
 137  	}
 138  	none, err := c.MatchScripts(0, 0, [][]byte{[]byte("not-in-any-block")})
 139  	if err != nil {
 140  		t.Fatalf("match absent: %v", err)
 141  	}
 142  	if len(none) != 0 {
 143  		t.Fatalf("unexpected matches: %v", none)
 144  	}
 145  }
 146  
 147  func TestFileStoreResume(t *testing.T) {
 148  	h := newHarness(t, 12)
 149  	dir := t.TempDir()
 150  	store, err := neutrino.OpenFileStore(dir)
 151  	if err != nil {
 152  		t.Fatalf("open store: %v", err)
 153  	}
 154  	c := h.client(t, store)
 155  	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
 156  	defer cancel()
 157  	res, err := c.Sync(ctx)
 158  	if err != nil {
 159  		t.Fatalf("sync: %v", err)
 160  	}
 161  	if res.TipHeight != h.node.Height() {
 162  		t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height())
 163  	}
 164  	c.Close()
 165  	if err := store.Close(); err != nil {
 166  		t.Fatal(err)
 167  	}
 168  
 169  	reopened, err := neutrino.OpenFileStore(dir)
 170  	if err != nil {
 171  		t.Fatalf("reopen: %v", err)
 172  	}
 173  	defer reopened.Close()
 174  	tip, _, ok := reopened.HeaderTip()
 175  	if !ok || tip != h.node.Height() {
 176  		t.Fatalf("reopened tip %d ok=%v, want %d", tip, ok, h.node.Height())
 177  	}
 178  	ftip, ok := reopened.FilterTip()
 179  	if !ok || ftip != h.node.Height() {
 180  		t.Fatalf("reopened filter tip %d ok=%v", ftip, ok)
 181  	}
 182  }
 183  
 184  func TestReorgRollsBack(t *testing.T) {
 185  	h := newHarness(t, 10)
 186  	store := neutrino.NewMemory()
 187  	c := h.client(t, store)
 188  	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
 189  	defer cancel()
 190  	if _, err := c.Sync(ctx); err != nil {
 191  		t.Fatalf("first sync: %v", err)
 192  	}
 193  	oldScript := h.node.Script(7)
 194  	if matches, _ := c.MatchScripts(0, 0, [][]byte{oldScript}); len(matches) != 1 {
 195  		t.Fatalf("old chain match %v", matches)
 196  	}
 197  	if err := h.node.ForkAt(6, 4, "fork"); err != nil {
 198  		t.Fatalf("fork: %v", err)
 199  	}
 200  	res, err := c.Sync(ctx)
 201  	if err != nil {
 202  		t.Fatalf("second sync: %v", err)
 203  	}
 204  	if res.HeadersAdded != 4 {
 205  		t.Fatalf("headers after reorg %d, want 4", res.HeadersAdded)
 206  	}
 207  	if res.FiltersAdded != 4 {
 208  		t.Fatalf("filters after reorg %d, want 4", res.FiltersAdded)
 209  	}
 210  	if res.TipHeight != 10 {
 211  		t.Fatalf("tip %d, want 10", res.TipHeight)
 212  	}
 213  	if matches, _ := c.MatchScripts(0, 0, [][]byte{oldScript}); len(matches) != 0 {
 214  		t.Fatalf("old branch script still matches: %v", matches)
 215  	}
 216  	newScript := h.node.Script(7)
 217  	if matches, _ := c.MatchScripts(0, 0, [][]byte{newScript}); len(matches) != 1 || matches[0] != 7 {
 218  		t.Fatalf("new branch match %v, want [7]", matches)
 219  	}
 220  	got, ok := store.Filter(7)
 221  	if !ok {
 222  		t.Fatal("filter 7 missing")
 223  	}
 224  	want, _ := h.node.Filter(7)
 225  	if !bytes.Equal(got, want) {
 226  		t.Fatal("filter 7 was not replaced")
 227  	}
 228  	gotFH, ok := store.FilterHeader(7)
 229  	if !ok {
 230  		t.Fatal("filter header 7 missing")
 231  	}
 232  	wantFH, _ := h.node.FilterHeader(7)
 233  	if gotFH != wantFH {
 234  		t.Fatal("filter header 7 was not replaced")
 235  	}
 236  }
 237  
 238  func TestSyncOverWebSocket(t *testing.T) {
 239  	h := newHarness(t, 40)
 240  	if len(h.uri.Bridges) == 0 {
 241  		t.Fatal("bridge did not advertise a websocket URL")
 242  	}
 243  	store := neutrino.NewMemory()
 244  	c := wsClient(t, h, store)
 245  	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
 246  	defer cancel()
 247  	res, err := c.Sync(ctx)
 248  	if err != nil {
 249  		t.Fatalf("sync: %v", err)
 250  	}
 251  	if res.TipHeight != h.node.Height() {
 252  		t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height())
 253  	}
 254  	if res.FiltersAdded != int(h.node.Height()+1) {
 255  		t.Fatalf("filters %d, want %d", res.FiltersAdded, h.node.Height()+1)
 256  	}
 257  	matches, err := c.MatchScripts(0, 0, [][]byte{h.node.Script(7)})
 258  	if err != nil {
 259  		t.Fatal(err)
 260  	}
 261  	if len(matches) != 1 || matches[0] != 7 {
 262  		t.Fatalf("matches %v, want [7]", matches)
 263  	}
 264  }
 265  
 266  func wsClient(t *testing.T, h *harness, store neutrino.Store) *neutrino.Client {
 267  	t.Helper()
 268  	if len(h.uri.Bridges) == 0 {
 269  		t.Fatal("no bridge URL")
 270  	}
 271  	var key [32]byte
 272  	key[31] = 120
 273  	bridge := withChainURL(h.uri.Bridges[0], "regtest")
 274  	c, err := neutrino.New(neutrino.Config{
 275  		Profile:               &chain.Regtest,
 276  		Store:                 store,
 277  		Dial:                  func(context.Context) (net.Conn, error) { return ws.DialStream(bridge) },
 278  		RequireCompactFilters: true,
 279  		ReadTimeout:           20 * time.Second,
 280  		Logf:                  t.Logf,
 281  	})
 282  	if err != nil {
 283  		t.Fatal(err)
 284  	}
 285  	t.Cleanup(func() { c.Close() })
 286  	return c
 287  }
 288  
 289  func withChainURL(raw, chain string) string {
 290  	u, err := url.Parse(raw)
 291  	if err != nil {
 292  		return raw
 293  	}
 294  	q := u.Query()
 295  	q.Set("chain", chain)
 296  	u.RawQuery = q.Encode()
 297  	return u.String()
 298  }
 299  
 300  func TestSyncFromCheckpoint(t *testing.T) {
 301  	h := newHarness(t, 30)
 302  	const cpHeight = uint32(10)
 303  	cpHeader, ok := h.node.Header(cpHeight)
 304  	if !ok {
 305  		t.Fatal("no checkpoint header")
 306  	}
 307  	cpFilterHeader, ok := h.node.FilterHeader(cpHeight)
 308  	if !ok {
 309  		t.Fatal("no checkpoint filter header")
 310  	}
 311  	profile := chain.Regtest
 312  	profile.Checkpoint = &chain.Checkpoint{
 313  		Height:       cpHeight,
 314  		BlockHash:    cpHeader.BlockHash(),
 315  		FilterHeader: cpFilterHeader,
 316  	}
 317  	store := neutrino.NewMemory()
 318  	client := checkpointClient(t, h, &profile, store)
 319  	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
 320  	defer cancel()
 321  	res, err := client.Sync(ctx)
 322  	if err != nil {
 323  		t.Fatalf("sync: %v", err)
 324  	}
 325  	if res.StartHeight != cpHeight {
 326  		t.Fatalf("start height %d, want %d", res.StartHeight, cpHeight)
 327  	}
 328  	if res.TipHeight != h.node.Height() {
 329  		t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height())
 330  	}
 331  	want := int(h.node.Height() - cpHeight)
 332  	if res.HeadersAdded != want {
 333  		t.Fatalf("headers added %d, want %d", res.HeadersAdded, want)
 334  	}
 335  	if res.FilterHeadersAdded != want {
 336  		t.Fatalf("filter headers added %d, want %d", res.FilterHeadersAdded, want)
 337  	}
 338  	if res.FiltersAdded != want {
 339  		t.Fatalf("filters added %d, want %d", res.FiltersAdded, want)
 340  	}
 341  	if _, ok := store.Header(cpHeight); ok {
 342  		t.Fatal("checkpoint header should not be stored")
 343  	}
 344  	if _, ok := store.Filter(cpHeight); ok {
 345  		t.Fatal("filter at the checkpoint should not be stored")
 346  	}
 347  	matches, err := client.MatchScripts(0, 0, [][]byte{h.node.Script(20)})
 348  	if err != nil {
 349  		t.Fatal(err)
 350  	}
 351  	if len(matches) != 1 || matches[0] != 20 {
 352  		t.Fatalf("matches %v, want [20]", matches)
 353  	}
 354  	below, err := client.MatchScripts(0, 0, [][]byte{h.node.Script(7)})
 355  	if err != nil {
 356  		t.Fatal(err)
 357  	}
 358  	if len(below) != 0 {
 359  		t.Fatalf("matched below the checkpoint: %v", below)
 360  	}
 361  }
 362  
 363  func TestCheckpointLinkFailure(t *testing.T) {
 364  	h := newHarness(t, 20)
 365  	// A hash that is not on the node's chain: the first header cannot link.
 366  	profile := chain.Regtest
 367  	profile.Checkpoint = &chain.Checkpoint{
 368  		Height:       10,
 369  		BlockHash:    chainhash.DoubleHashH([]byte("not-a-block")),
 370  		FilterHeader: chainhash.DoubleHashH([]byte("not-a-filter")),
 371  	}
 372  	store := neutrino.NewMemory()
 373  	client := checkpointClient(t, h, &profile, store)
 374  	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 375  	defer cancel()
 376  	if _, err := client.Sync(ctx); err == nil {
 377  		t.Fatal("sync accepted a checkpoint that is not on the chain")
 378  	}
 379  }
 380  
 381  func checkpointClient(t *testing.T, h *harness, profile *chain.Profile, store neutrino.Store) *neutrino.Client {
 382  	t.Helper()
 383  	var clientKey [32]byte
 384  	clientKey[31] = 88
 385  	factory := func(string) (relay.Transport, error) { return h.relay.Dial("") }
 386  	c, err := neutrino.New(neutrino.Config{
 387  		Profile: profile,
 388  		Store:   store,
 389  		Dial: func(ctx context.Context) (net.Conn, error) {
 390  			return tunnel.Dial(ctx, tunnel.DialConfig{
 391  				URI:              h.uri,
 392  				Key:              clientKey,
 393  				Chain:            profile.Name,
 394  				TransportFactory: factory,
 395  			})
 396  		},
 397  		RequireCompactFilters: true,
 398  		ReadTimeout:           20 * time.Second,
 399  		Logf:                  t.Logf,
 400  	})
 401  	if err != nil {
 402  		t.Fatalf("neutrino: %v", err)
 403  	}
 404  	t.Cleanup(func() { c.Close() })
 405  	return c
 406  }
 407