package e2e import ( "bytes" "context" "net" "net/url" "testing" "time" "git.smesh.lol/nostrino/internal/memrelay" "git.smesh.lol/nostrino/internal/testnode" "git.smesh.lol/nostrino/pkg/bridge" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/crypto/chainhash" "git.smesh.lol/nostrino/pkg/neutrino" "git.smesh.lol/nostrino/pkg/relay" "git.smesh.lol/nostrino/pkg/tunnel" "git.smesh.lol/nostrino/pkg/ws" ) type harness struct { node *testnode.Node relay *memrelay.Relay bridge *bridge.Server uri *tunnel.URI ctx context.Context cancel context.CancelFunc } func newHarness(t *testing.T, blocks int) *harness { t.Helper() profile := chain.Regtest node, err := testnode.New(&profile, testnode.Config{Blocks: blocks}) if err != nil { t.Fatalf("testnode: %v", err) } t.Cleanup(func() { node.Close() }) addr, err := node.Listen("127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } r := memrelay.New() t.Cleanup(r.Close) var gwKey [32]byte gwKey[31] = 55 ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) factory := func(string) (relay.Transport, error) { return r.Dial("") } b, err := bridge.Start(ctx, bridge.Config{ Relays: []string{"mem://relay"}, NodeAddr: addr, Key: gwKey, Profile: &profile, Chains: []string{"regtest"}, BridgeListen: "127.0.0.1:0", BridgePath: "/nostrino", TransportFactory: factory, Logf: t.Logf, }) if err != nil { t.Fatalf("bridge: %v", err) } t.Cleanup(func() { b.Close() }) go b.Run(ctx) h := &harness{node: node, relay: r, bridge: b, uri: b.URI(), ctx: ctx, cancel: cancel} return h } func (h *harness) client(t *testing.T, store neutrino.Store) *neutrino.Client { t.Helper() var clientKey [32]byte clientKey[31] = 77 factory := func(string) (relay.Transport, error) { return h.relay.Dial("") } c, err := neutrino.New(neutrino.Config{ Profile: &chain.Regtest, Store: store, Dial: func(ctx context.Context) (net.Conn, error) { return tunnel.Dial(ctx, tunnel.DialConfig{ URI: h.uri, Key: clientKey, Chain: "regtest", TransportFactory: factory, }) }, RequireCompactFilters: true, ReadTimeout: 20 * time.Second, Logf: t.Logf, }) if err != nil { t.Fatalf("neutrino: %v", err) } t.Cleanup(func() { c.Close() }) return c } func TestSyncThroughBridge(t *testing.T) { h := newHarness(t, 30) store := neutrino.NewMemory() c := h.client(t, store) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() res, err := c.Sync(ctx) if err != nil { t.Fatalf("sync: %v", err) } if res.TipHeight != h.node.Height() { t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height()) } if res.HeadersAdded != int(h.node.Height()) { t.Fatalf("headers added %d, want %d", res.HeadersAdded, h.node.Height()) } if res.FilterHeadersAdded != int(h.node.Height()+1) { t.Fatalf("filter headers added %d, want %d", res.FilterHeadersAdded, h.node.Height()+1) } if res.FiltersAdded != int(h.node.Height()+1) { t.Fatalf("filters added %d, want %d", res.FiltersAdded, h.node.Height()+1) } for height := uint32(1); height < h.node.Height(); height++ { got, ok := store.Filter(height) if !ok { t.Fatalf("filter %d missing", height) } want, _ := h.node.Filter(height) if !bytes.Equal(got, want) { t.Fatalf("filter %d mismatch", height) } } watch := h.node.Script(7) matches, err := c.MatchScripts(0, 0, [][]byte{watch}) if err != nil { t.Fatalf("match: %v", err) } if len(matches) != 1 || matches[0] != 7 { t.Fatalf("matches %v, want [7]", matches) } none, err := c.MatchScripts(0, 0, [][]byte{[]byte("not-in-any-block")}) if err != nil { t.Fatalf("match absent: %v", err) } if len(none) != 0 { t.Fatalf("unexpected matches: %v", none) } } func TestFileStoreResume(t *testing.T) { h := newHarness(t, 12) dir := t.TempDir() store, err := neutrino.OpenFileStore(dir) if err != nil { t.Fatalf("open store: %v", err) } c := h.client(t, store) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() res, err := c.Sync(ctx) if err != nil { t.Fatalf("sync: %v", err) } if res.TipHeight != h.node.Height() { t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height()) } c.Close() if err := store.Close(); err != nil { t.Fatal(err) } reopened, err := neutrino.OpenFileStore(dir) if err != nil { t.Fatalf("reopen: %v", err) } defer reopened.Close() tip, _, ok := reopened.HeaderTip() if !ok || tip != h.node.Height() { t.Fatalf("reopened tip %d ok=%v, want %d", tip, ok, h.node.Height()) } ftip, ok := reopened.FilterTip() if !ok || ftip != h.node.Height() { t.Fatalf("reopened filter tip %d ok=%v", ftip, ok) } } func TestReorgRollsBack(t *testing.T) { h := newHarness(t, 10) store := neutrino.NewMemory() c := h.client(t, store) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if _, err := c.Sync(ctx); err != nil { t.Fatalf("first sync: %v", err) } oldScript := h.node.Script(7) if matches, _ := c.MatchScripts(0, 0, [][]byte{oldScript}); len(matches) != 1 { t.Fatalf("old chain match %v", matches) } if err := h.node.ForkAt(6, 4, "fork"); err != nil { t.Fatalf("fork: %v", err) } res, err := c.Sync(ctx) if err != nil { t.Fatalf("second sync: %v", err) } if res.HeadersAdded != 4 { t.Fatalf("headers after reorg %d, want 4", res.HeadersAdded) } if res.FiltersAdded != 4 { t.Fatalf("filters after reorg %d, want 4", res.FiltersAdded) } if res.TipHeight != 10 { t.Fatalf("tip %d, want 10", res.TipHeight) } if matches, _ := c.MatchScripts(0, 0, [][]byte{oldScript}); len(matches) != 0 { t.Fatalf("old branch script still matches: %v", matches) } newScript := h.node.Script(7) if matches, _ := c.MatchScripts(0, 0, [][]byte{newScript}); len(matches) != 1 || matches[0] != 7 { t.Fatalf("new branch match %v, want [7]", matches) } got, ok := store.Filter(7) if !ok { t.Fatal("filter 7 missing") } want, _ := h.node.Filter(7) if !bytes.Equal(got, want) { t.Fatal("filter 7 was not replaced") } gotFH, ok := store.FilterHeader(7) if !ok { t.Fatal("filter header 7 missing") } wantFH, _ := h.node.FilterHeader(7) if gotFH != wantFH { t.Fatal("filter header 7 was not replaced") } } func TestSyncOverWebSocket(t *testing.T) { h := newHarness(t, 40) if len(h.uri.Bridges) == 0 { t.Fatal("bridge did not advertise a websocket URL") } store := neutrino.NewMemory() c := wsClient(t, h, store) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() res, err := c.Sync(ctx) if err != nil { t.Fatalf("sync: %v", err) } if res.TipHeight != h.node.Height() { t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height()) } if res.FiltersAdded != int(h.node.Height()+1) { t.Fatalf("filters %d, want %d", res.FiltersAdded, h.node.Height()+1) } matches, err := c.MatchScripts(0, 0, [][]byte{h.node.Script(7)}) if err != nil { t.Fatal(err) } if len(matches) != 1 || matches[0] != 7 { t.Fatalf("matches %v, want [7]", matches) } } func wsClient(t *testing.T, h *harness, store neutrino.Store) *neutrino.Client { t.Helper() if len(h.uri.Bridges) == 0 { t.Fatal("no bridge URL") } var key [32]byte key[31] = 120 bridge := withChainURL(h.uri.Bridges[0], "regtest") c, err := neutrino.New(neutrino.Config{ Profile: &chain.Regtest, Store: store, Dial: func(context.Context) (net.Conn, error) { return ws.DialStream(bridge) }, RequireCompactFilters: true, ReadTimeout: 20 * time.Second, Logf: t.Logf, }) if err != nil { t.Fatal(err) } t.Cleanup(func() { c.Close() }) return c } func withChainURL(raw, chain string) string { u, err := url.Parse(raw) if err != nil { return raw } q := u.Query() q.Set("chain", chain) u.RawQuery = q.Encode() return u.String() } func TestSyncFromCheckpoint(t *testing.T) { h := newHarness(t, 30) const cpHeight = uint32(10) cpHeader, ok := h.node.Header(cpHeight) if !ok { t.Fatal("no checkpoint header") } cpFilterHeader, ok := h.node.FilterHeader(cpHeight) if !ok { t.Fatal("no checkpoint filter header") } profile := chain.Regtest profile.Checkpoint = &chain.Checkpoint{ Height: cpHeight, BlockHash: cpHeader.BlockHash(), FilterHeader: cpFilterHeader, } store := neutrino.NewMemory() client := checkpointClient(t, h, &profile, store) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() res, err := client.Sync(ctx) if err != nil { t.Fatalf("sync: %v", err) } if res.StartHeight != cpHeight { t.Fatalf("start height %d, want %d", res.StartHeight, cpHeight) } if res.TipHeight != h.node.Height() { t.Fatalf("tip %d, want %d", res.TipHeight, h.node.Height()) } want := int(h.node.Height() - cpHeight) if res.HeadersAdded != want { t.Fatalf("headers added %d, want %d", res.HeadersAdded, want) } if res.FilterHeadersAdded != want { t.Fatalf("filter headers added %d, want %d", res.FilterHeadersAdded, want) } if res.FiltersAdded != want { t.Fatalf("filters added %d, want %d", res.FiltersAdded, want) } if _, ok := store.Header(cpHeight); ok { t.Fatal("checkpoint header should not be stored") } if _, ok := store.Filter(cpHeight); ok { t.Fatal("filter at the checkpoint should not be stored") } matches, err := client.MatchScripts(0, 0, [][]byte{h.node.Script(20)}) if err != nil { t.Fatal(err) } if len(matches) != 1 || matches[0] != 20 { t.Fatalf("matches %v, want [20]", matches) } below, err := client.MatchScripts(0, 0, [][]byte{h.node.Script(7)}) if err != nil { t.Fatal(err) } if len(below) != 0 { t.Fatalf("matched below the checkpoint: %v", below) } } func TestCheckpointLinkFailure(t *testing.T) { h := newHarness(t, 20) // A hash that is not on the node's chain: the first header cannot link. profile := chain.Regtest profile.Checkpoint = &chain.Checkpoint{ Height: 10, BlockHash: chainhash.DoubleHashH([]byte("not-a-block")), FilterHeader: chainhash.DoubleHashH([]byte("not-a-filter")), } store := neutrino.NewMemory() client := checkpointClient(t, h, &profile, store) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if _, err := client.Sync(ctx); err == nil { t.Fatal("sync accepted a checkpoint that is not on the chain") } } func checkpointClient(t *testing.T, h *harness, profile *chain.Profile, store neutrino.Store) *neutrino.Client { t.Helper() var clientKey [32]byte clientKey[31] = 88 factory := func(string) (relay.Transport, error) { return h.relay.Dial("") } c, err := neutrino.New(neutrino.Config{ Profile: profile, Store: store, Dial: func(ctx context.Context) (net.Conn, error) { return tunnel.Dial(ctx, tunnel.DialConfig{ URI: h.uri, Key: clientKey, Chain: profile.Name, TransportFactory: factory, }) }, RequireCompactFilters: true, ReadTimeout: 20 * time.Second, Logf: t.Logf, }) if err != nil { t.Fatalf("neutrino: %v", err) } t.Cleanup(func() { c.Close() }) return c }