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