package chaindata import ( "encoding/binary" "errors" "strings" "testing" "time" "git.smesh.lol/tiny-spawn" ) // fakeSource is a chain that answers from memory and can be told to block until cancelled. type fakeSource struct { position Position heights []uint32 filters []Filter failure string // block makes Sync wait until the request is cancelled, which is how the cancel path is // exercised without a timer. block bool // seen records the scripts of the last match. seen [][]byte // from records the last sync height. from uint32 // asked records the last filter range. askedFrom, askedCount uint32 // rawBlocks is what Block answers, by height. rawBlocks map[uint32][]byte // askedBlock records the last block height. askedBlock uint32 // broadcast records the last transaction the source was handed. broadcast []byte } // Position implements Source. func (f *fakeSource) Position(Context) (Position, error) { if f.failure != "" { return Position{}, errors.New(f.failure) } return f.position, nil } // Sync implements Source. func (f *fakeSource) Sync(ctx Context, from uint32) (Position, error) { f.from = from if f.failure != "" { return Position{}, errors.New(f.failure) } if f.block { <-ctx.Done() return Position{}, errors.New(ctx.Err()) } return Position{Tip: from + 1, Filters: from + 1}, nil } // Match implements Source. func (f *fakeSource) Match(_ Context, scripts [][]byte) ([]uint32, error) { f.seen = scripts if f.failure != "" { return nil, errors.New(f.failure) } return f.heights, nil } // Block implements Source. func (f *fakeSource) Block(_ Context, height uint32) ([]byte, error) { f.askedBlock = height if f.failure != "" { return nil, errors.New(f.failure) } raw, ok := f.rawBlocks[height] if !ok { return nil, errors.New("no block at that height") } return raw, nil } // Broadcast implements Source. func (f *fakeSource) Broadcast(_ Context, raw []byte) error { f.broadcast = append([]byte(nil), raw...) if f.failure != "" { return errors.New(f.failure) } return nil } // Filters implements Source. func (f *fakeSource) Filters(_ Context, from, count uint32) ([]Filter, error) { f.askedFrom, f.askedCount = from, count if f.failure != "" { return nil, errors.New(f.failure) } return f.filters, nil } // harness is one domain and one client wired through a duplex. type harness struct { client *Client stop tinyspawn.Breaker done <-chan struct{} source *fakeSource } // start spawns a domain over a fake source and returns the client. func start(t *testing.T, source *fakeSource) *harness { t.Helper() server, wallet := tinyspawn.NewBufferedDuplex[[]byte](8) serverStop := tinyspawn.NewBreaker() clientStop := tinyspawn.NewBreaker() done := tinyspawn.Spawn(Serve, Data{Source: source}, Chans{Link: server, Stop: serverStop}) client := NewClient("test", wallet, clientStop) t.Cleanup(func() { client.Close() if !serverStop.Closed() { serverStop.Close() } select { case <-done: case <-time.After(5 * time.Second): t.Error("the domain did not stop") } }) return &harness{client: client, stop: serverStop, done: done, source: source} } // TestCodecRoundTrip: every kind and method survives encode and decode, payload included. func TestCodecRoundTrip(t *testing.T) { for _, frame := range []Frame{ {Kind: KindRequest, ID: 1, Method: MethodStatus}, {Kind: KindRequest, ID: 2, Method: MethodSync, Payload: binary.LittleEndian.AppendUint32(nil, 42)}, {Kind: KindResult, ID: 3, Method: MethodMatch, Status: StatusOK, Payload: PutHeights(nil, []uint32{7, 9, 11})}, {Kind: KindCancel, ID: 4}, {Kind: KindClose, Status: StatusClosed, Payload: []byte("bye\x00")}, } { raw, err := Encode(frame) if err != nil { t.Fatalf("%+v: %v", frame, err) } if len(raw) != HeaderLen+len(frame.Payload) { t.Fatalf("encoded %d bytes for a %d-byte payload", len(raw), len(frame.Payload)) } decoded, err := Decode(raw) if err != nil { t.Fatalf("%+v: %v", frame, err) } if decoded.Kind != frame.Kind || decoded.ID != frame.ID || decoded.Method != frame.Method || decoded.Status != frame.Status { t.Fatalf("decoded %+v from %+v", decoded, frame) } if string(decoded.Payload) != string(frame.Payload) { t.Fatalf("payload %q from %q", decoded.Payload, frame.Payload) } } } // TestDecodeRefusesBadFrames: the shapes a decoder must not trust, each with its own // refusal, because a frame arrives from another instance. func TestDecodeRefusesBadFrames(t *testing.T) { good, err := Encode(Frame{Kind: KindRequest, ID: 1, Method: MethodStatus}) if err != nil { t.Fatal(err) } cases := map[string][]byte{ "short": good[:HeaderLen-1], "empty": nil, "wrong magic": mutate(good, 0, 0xff), "wrong version": mutate(good, 4, 9), "unknown kind": mutate(good, 5, 9), "trailing bytes": append(append([]byte{}, good...), 0x00), "length lies": mutate(good, 16, 200), "huge length": mutate(good, 19, 0xff), } for name, raw := range cases { if _, err := Decode(raw); err == nil { t.Errorf("%s was decoded", name) } else if !errors.Is(err, ErrBadFrame) { t.Errorf("%s failed with %v", name, err) } } // A payload beyond the cap is refused before it is allocated. large := Frame{Kind: KindRequest, Method: MethodMatch, Payload: make([]byte, MaxPayload+1)} if _, err := Encode(large); !errors.Is(err, ErrPayloadTooLarge) { t.Fatalf("an oversized payload was encoded: %v", err) } } // mutate copies raw and changes one byte. func mutate(raw []byte, at int, value byte) []byte { out := append([]byte{}, raw...) if at < len(out) { out[at] = value } return out } // TestPayloadCodecsRefuseCounts: a count larger than the payload is refused before // anything is allocated from it. func TestPayloadCodecsRefuseCounts(t *testing.T) { huge := binary.LittleEndian.AppendUint32(nil, 0xffffffff) if _, err := ReadHeights(huge); err == nil { t.Error("a height list with no heights behind its count was read") } if _, err := ReadScripts(huge); err == nil { t.Error("a script list with no scripts behind its count was read") } if _, err := ReadFilters(huge); err == nil { t.Error("a filter batch with nothing behind its count was read") } if _, _, err := ReadFilterRange(make([]byte, 3)); err == nil { t.Error("a 3-byte filter range was read") } if _, _, err := ReadFilterRange(PutFilterRange(nil, 0, MaxFilters+1)); err == nil { t.Error("an oversized filter range was read") } // Trailing bytes after a complete list are a malformed payload, not a second list. trailing := append(PutHeights(nil, []uint32{1}), 0x00) if _, err := ReadHeights(trailing); err == nil { t.Error("trailing bytes were accepted") } } // TestScriptsAndFiltersRoundTrip: the two payloads that carry byte strings. func TestScriptsAndFiltersRoundTrip(t *testing.T) { scripts := [][]byte{{0x00, 0x14, 0x75, 0x1e}, {0x51}, {0x6a}} payload, err := PutScripts(nil, scripts) if err != nil { t.Fatal(err) } decoded, err := ReadScripts(payload) if err != nil { t.Fatal(err) } if len(decoded) != len(scripts) { t.Fatalf("%d scripts from %d", len(decoded), len(scripts)) } for i := range scripts { if string(decoded[i]) != string(scripts[i]) { t.Fatalf("script %d is %x", i, decoded[i]) } } hash := make([]byte, 32) for i := range hash { hash[i] = byte(i) } filters := []Filter{ {Height: 100, BlockHash: hash, Filter: []byte{0x01, 0x02}}, {Height: 101, BlockHash: hash, Filter: []byte{0x03}}, } batch, err := PutFilters(nil, filters) if err != nil { t.Fatal(err) } back, err := ReadFilters(batch) if err != nil { t.Fatal(err) } if len(back) != 2 || back[0].Height != 100 || back[1].Height != 101 { t.Fatalf("filters = %+v", back) } if string(back[0].Filter) != string(filters[0].Filter) || string(back[1].BlockHash) != string(hash) { t.Fatalf("the batch did not survive: %+v", back[0]) } // A filter whose hash is not 32 bytes is refused rather than truncated. if _, err := PutFilters(nil, []Filter{{Height: 1, BlockHash: []byte{0x01}}}); err == nil { t.Fatal("a short block hash was encoded") } } // TestStatusSyncMatchAndFilters: the four commands over the channel. func TestStatusSyncMatchAndFilters(t *testing.T) { hash := make([]byte, 32) source := &fakeSource{ position: Position{Tip: 90, Filters: 88, Running: false}, heights: []uint32{91, 95}, filters: []Filter{{Height: 91, BlockHash: hash, Filter: []byte{0x07}}}, } run := start(t, source) position, err := run.client.Position() if err != nil { t.Fatal(err) } if position.Tip != 90 || position.Filters != 88 || position.Running { t.Fatalf("position = %+v", position) } synced, err := run.client.Sync(90) if err != nil { t.Fatal(err) } if source.from != 90 || synced.Tip != 91 { t.Fatalf("sync from %d answered %+v", source.from, synced) } script := []byte{0x00, 0x14, 0x75, 0x1e} heights, err := run.client.Match([][]byte{script}) if err != nil { t.Fatal(err) } if len(heights) != 2 || heights[0] != 91 || heights[1] != 95 { t.Fatalf("heights = %v", heights) } if len(source.seen) != 1 || string(source.seen[0]) != string(script) { t.Fatalf("the source saw %x", source.seen) } filters, err := run.client.Filters(91, 2) if err != nil { t.Fatal(err) } if source.askedFrom != 91 || source.askedCount != 2 { t.Fatalf("the source was asked %d/%d", source.askedFrom, source.askedCount) } if len(filters) != 1 || filters[0].Height != 91 || len(filters[0].Filter) != 1 { t.Fatalf("filters = %+v", filters) } } // TestFailureTravels: a chain that fails is a refusal the wallet reads, not a silence. func TestFailureTravels(t *testing.T) { run := start(t, &fakeSource{failure: "the bridge is unreachable"}) if _, err := run.client.Position(); err == nil || !strings.Contains(err.Error(), "bridge is unreachable") { t.Fatalf("position failed with %v", err) } if _, err := run.client.Sync(0); err == nil || !strings.Contains(err.Error(), "bridge is unreachable") { t.Fatalf("sync failed with %v", err) } if _, err := run.client.Match([][]byte{{0x51}}); err == nil || !strings.Contains(err.Error(), "bridge is unreachable") { t.Fatalf("match failed with %v", err) } if _, err := run.client.Filters(0, 1); err == nil || !strings.Contains(err.Error(), "bridge is unreachable") { t.Fatalf("filters failed with %v", err) } } // TestUnknownMethodAndBadPayload: a request the far end cannot serve is answered, because a // wallet waiting for an answer that never comes has to be reloaded. func TestUnknownMethodAndBadPayload(t *testing.T) { run := start(t, &fakeSource{}) if _, err := run.client.Do(Frame{Method: Method(99)}); err == nil || !strings.Contains(err.Error(), "unknown chain-data method") { t.Fatalf("an unknown method was answered with %v", err) } // A match with no scripts is refused by the client before it is sent. if _, err := run.client.Match(nil); err == nil { t.Fatal("a match with no scripts was sent") } // A sync without its height is a malformed payload, refused by the far end. if _, err := run.client.Do(Frame{Method: MethodSync}); err == nil || !strings.Contains(err.Error(), "4-byte height") { t.Fatalf("a sync with no height answered %v", err) } } // TestCancelStopsARequest: a cancel frame reaches a request that is already running. func TestCancelStopsARequest(t *testing.T) { run := start(t, &fakeSource{block: true}) answers := make(chan error, 1) go func() { _, err := run.client.Sync(1) answers <- err }() // Wait until the request is in flight, then cancel it. deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { run.client.mu.Lock() pending := len(run.client.pending) run.client.mu.Unlock() if pending > 0 { break } time.Sleep(time.Millisecond) } run.client.mu.Lock() ids := make([]uint32, 0, len(run.client.pending)) for id := range run.client.pending { ids = append(ids, id) } run.client.mu.Unlock() if len(ids) != 1 { t.Fatalf("%d requests in flight", len(ids)) } run.client.Cancel(ids[0]) select { case err := <-answers: if err == nil || !strings.Contains(err.Error(), "cancel") { t.Fatalf("the cancelled sync answered %v", err) } case <-time.After(5 * time.Second): t.Fatal("the cancel did not reach the request") } } // TestCloseFailsWhatIsInFlight: a close frame means the far end is going away, so a waiter // is told rather than left waiting. func TestCloseFailsWhatIsInFlight(t *testing.T) { server, wallet := tinyspawn.NewBufferedDuplex[[]byte](4) clientStop := tinyspawn.NewBreaker() client := NewClient("test", wallet, clientStop) answers := make(chan error, 1) go func() { _, err := client.Position() answers <- err }() // Read the request on the far end and answer it with a close instead of a result. select { case <-server.Recv(): case <-time.After(5 * time.Second): t.Fatal("the request never arrived") } closeFrame, err := Encode(Frame{Kind: KindClose, Status: StatusClosed, Payload: []byte("worker terminated\x00")}) if err != nil { t.Fatal(err) } select { case server.Tx <- closeFrame: case <-time.After(5 * time.Second): t.Fatal("the close frame could not be sent") } select { case err := <-answers: if err == nil || !strings.Contains(err.Error(), "worker terminated") { t.Fatalf("the close answered %v", err) } case <-time.After(5 * time.Second): t.Fatal("the close did not fail the request") } if !client.Closed() { t.Fatal("the client does not report itself closed") } // A request after the close fails immediately rather than waiting. if _, err := client.Position(); err == nil { t.Fatal("a closed client accepted a request") } _ = server } // TestEndpointCloseFailsTheDomain: the channel closing under the domain is treated like a // close frame, and the loop ends. func TestEndpointCloseFailsTheDomain(t *testing.T) { server, wallet := tinyspawn.NewBufferedDuplex[[]byte](4) serverStop := tinyspawn.NewBreaker() done := tinyspawn.Spawn(Serve, Data{Source: &fakeSource{}}, Chans{Link: server, Stop: serverStop}) // Closing the wallet's end is what a terminated worker looks like from inside. The // domain reads that end's transmit side, so closing it ends the loop. close(wallet.Tx) select { case <-done: case <-time.After(5 * time.Second): t.Fatal("the domain did not notice the channel closing") } } // TestRandomBytesNeverPanic: the decoder and the payload readers are the surface another // instance's bytes reach, so no input may panic. func TestRandomBytesNeverPanic(t *testing.T) { defer func() { if recovered := recover(); recovered != nil { t.Fatalf("a decoder panicked: %v", recovered) } }() state := uint32(0x12345678) next := func() byte { state = state*1664525 + 1013904223 return byte(state >> 24) } for i := 0; i < 2000; i++ { size := int(next()) + 1 raw := make([]byte, size) for j := range raw { raw[j] = next() } frame, err := Decode(raw) if err != nil { continue } _, _ = ReadPosition(frame.Payload) _, _ = ReadHeights(frame.Payload) _, _ = ReadScripts(frame.Payload) _, _ = ReadFilters(frame.Payload) _, _, _ = ReadFilterRange(frame.Payload) } } // TestBlockRoundTrip: a block request carries a height and an answer carries the raw bytes. func TestBlockRoundTrip(t *testing.T) { raw := make([]byte, 300) for index := range raw { raw[index] = byte(index) } source := &fakeSource{rawBlocks: map[uint32][]byte{120: raw}} h := start(t, source) got, err := h.client.Block(120) if err != nil { t.Fatal(err) } if string(got) != string(raw) { t.Fatalf("the block is %d bytes, want %d", len(got), len(raw)) } if source.askedBlock != 120 { t.Fatalf("the source was asked for %d", source.askedBlock) } } // TestABroadcastCarriesTheRawTransaction: the wallet signs locally, and the channel carries // the bytes to the chain unchanged. The answer is a status, not a second copy of the // transaction, because the peer never echoes it. func TestABroadcastCarriesTheRawTransaction(t *testing.T) { raw := []byte{0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x9c, 0xff} source := &fakeSource{} h := start(t, source) if err := h.client.Broadcast(raw); err != nil { t.Fatal(err) } if string(source.broadcast) != string(raw) { t.Fatalf("the source was handed %x, want %x", source.broadcast, raw) } } // TestAnEmptyBroadcastIsRefused: there is nothing to send, and sending an empty tx frame would // be a protocol error at the peer rather than at the caller. func TestAnEmptyBroadcastIsRefused(t *testing.T) { h := start(t, &fakeSource{}) if err := h.client.Broadcast(nil); err == nil { t.Fatal("an empty transaction was broadcast") } answer, err := h.client.Do(Frame{Method: MethodBroadcast, Payload: nil}) if err == nil { t.Fatal("an empty broadcast frame was answered") } if answer.Status != StatusMalformed { t.Fatalf("the status is %v", answer.Status) } } // TestAFailedBroadcastIsAnError: a chain that refuses the transaction says so, and the caller // does not mistake a refusal for a broadcast. func TestAFailedBroadcastIsAnError(t *testing.T) { source := &fakeSource{failure: "the peer is gone"} h := start(t, source) if err := h.client.Broadcast([]byte{1, 2, 3}); err == nil { t.Fatal("a refused broadcast was reported as sent") } } // TestBlockHeightMustBeFourBytes: a malformed request is refused, not interpreted. func TestBlockHeightMustBeFourBytes(t *testing.T) { source := &fakeSource{rawBlocks: map[uint32][]byte{}} h := start(t, source) answer, err := h.client.Do(Frame{Method: MethodBlock, Payload: []byte{1, 2}}) if err == nil { t.Fatal("a two-byte height was accepted") } if answer.Status != StatusMalformed { t.Fatalf("the status is %v", answer.Status) } } // TestAnOversizedAnswerIsRefused: the transport will not carry a payload past its maximum, and // the request fails rather than returning a truncated block. func TestAnOversizedAnswerIsRefused(t *testing.T) { source := &fakeSource{rawBlocks: map[uint32][]byte{1: make([]byte, MaxPayload+1)}} h := start(t, source) if _, err := h.client.Block(1); err == nil { t.Fatal("an oversized block was answered") } } // TestAFailedBlockIsAnError: a chain that cannot produce the block says so. func TestAFailedBlockIsAnError(t *testing.T) { source := &fakeSource{failure: "the peer is gone"} h := start(t, source) if _, err := h.client.Block(3); err == nil { t.Fatal("a failed block fetch reported success") } } // TestAnUnansweredRequestFailsWithAReason: a chain channel that never answers must not wait // for ever. A browser whose light-client worker never comes up is exactly this case, and the // old behaviour was a page spinner and no error anywhere. func TestAnUnansweredRequestFailsWithAReason(t *testing.T) { // One end of a duplex and no reader on the other: the frame is accepted and nothing // ever answers it. _, wallet := tinyspawn.NewBufferedDuplex[[]byte](1) client := NewClient("btc", wallet, tinyspawn.NewBreaker()) client.SetTimeout(20 * time.Millisecond) started := time.Now() _, err := client.Do(Frame{Method: MethodStatus}) if !errors.Is(err, ErrNoAnswer) { t.Fatalf("an unanswered request: %v, want ErrNoAnswer", err) } if elapsed := time.Since(started); elapsed > 2*time.Second { t.Fatalf("the request waited %s, the bound was 20ms", elapsed) } if !strings.Contains(err.Error(), "btc") || !strings.Contains(err.Error(), "chain channel") { t.Fatalf("the error does not name the chain and the cause: %v", err) } } // TestTheAnswerDeadlineDependsOnTheMethod: a status that takes a minute is a dead channel; a // first sync that takes minutes is doing its job. The default deadline must therefore not apply // to Sync, while an explicit one applies to everything. func TestTheAnswerDeadlineDependsOnTheMethod(t *testing.T) { _, wallet := tinyspawn.NewBufferedDuplex[[]byte](1) client := NewClient("btc", wallet, tinyspawn.NewBreaker()) if got := client.requestTimeout(MethodSync); got != 0 { t.Fatalf("the default deadline for Sync = %s, want none", got) } if got := client.requestTimeout(MethodStatus); got != DefaultRequestTimeout { t.Fatalf("the default deadline for status = %s, want %s", got, DefaultRequestTimeout) } client.SetTimeout(20 * time.Millisecond) if got := client.requestTimeout(MethodSync); got != 20*time.Millisecond { t.Fatalf("Sync under an explicit deadline = %s, want 20ms", got) } if got := client.requestTimeout(MethodStatus); got != 20*time.Millisecond { t.Fatalf("status under an explicit deadline = %s, want 20ms", got) } // Zero restores the per-method defaults, so Sync is unbounded again. client.SetTimeout(0) if got := client.requestTimeout(MethodSync); got != 0 { t.Fatalf("Sync after SetTimeout(0) = %s, want none", got) } if got := client.requestTimeout(MethodStatus); got != DefaultRequestTimeout { t.Fatalf("status after SetTimeout(0) = %s, want %s", got, DefaultRequestTimeout) } } // TestAnExplicitDeadlineBoundsSync: the default leaves Sync alone, but a caller that sets a // deadline means it, so an unanswered sync does not wait for ever behind the exemption. func TestAnExplicitDeadlineBoundsSync(t *testing.T) { _, wallet := tinyspawn.NewBufferedDuplex[[]byte](1) client := NewClient("btc", wallet, tinyspawn.NewBreaker()) client.SetTimeout(20 * time.Millisecond) started := time.Now() _, err := client.Do(Frame{Method: MethodSync}) if !errors.Is(err, ErrNoAnswer) { t.Fatalf("an unanswered sync under an explicit deadline: %v, want ErrNoAnswer", err) } if elapsed := time.Since(started); elapsed > 2*time.Second { t.Fatalf("the sync waited %s, the explicit bound was 20ms", elapsed) } }