Three tricks stellar-pathfinder uses to keep a Stellar corridor monitor testable in Go: recorded HTTP snapshots with hashes, clocks passed as arguments, and exit codes that mean something.
· 6 min read

Testing a Stellar Corridor Monitor in Go: Snapshots, Injected Clocks, and Exit Codes


Here is the freshness check from stellar-pathfinder at the commit reviewed for this post:

func checkFreshness(store runstore.Store, expected []monitor.Corridor, maxAge time.Duration, now time.Time, logger *slog.Logger) int

Two details in that signature are deliberate. now arrives as a parameter instead of coming from time.Now() inside the function, and the function returns an int rather than calling os.Exit itself. Both choices separate decision-making from process state, which makes the freshness rules testable without changing the system clock or terminating the test process.

The project is a corridor-integrity monitor. It prices stablecoin→fiat-token routes through the Horizon API, scores them against independent reference rates, and refuses to recommend a route when every option is unusable. One wrinkle before the code: the Go module is named github.com/Wayfare-labs/wayfare, so import paths in the source won’t match the repo name. The patterns are worth lifting for any Go service that leans on third-party APIs.

Recording upstream responses so tests never hit the network

cmd/ladder/main.go prices a corridor at a ladder of send sizes. The pricing path talks to Horizon for DEX paths and to a reference-rate provider for the fiat mid-market rate. Counterparty checks can add more HTTP requests, and the same recording client captures those too. A -record flag writes the response bodies into a snapshot directory.

Both the dex.Client and the reference-rate provider accept an *http.Client. Recording therefore means replacing one http.RoundTripper, rather than threading a recording flag through the parsing code. When checks are enabled, their runner receives that client as well.

Here’s the generic shape of the pattern, written from scratch:

package main

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sync"
)

// recordingTransport writes every response body to disk and hands an
// identical body back to the caller.
type recordingTransport struct {
	base http.RoundTripper
	dir  string

	mu sync.Mutex // RoundTrip is called from multiple goroutines
	n  int
}

func (t *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	resp, err := t.base.RoundTrip(req)
	if err != nil {
		return nil, err
	}

	body, err := io.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		return nil, err
	}

	t.mu.Lock()
	t.n++
	name := filepath.Join(t.dir, fmt.Sprintf("%03d.json", t.n))
	t.mu.Unlock()

	if err := os.WriteFile(name, body, 0o644); err != nil {
		return nil, fmt.Errorf("recording %s: %w", req.URL, err)
	}

	// Give the caller a fresh reader; the original is drained.
	resp.Body = io.NopCloser(bytes.NewReader(body))
	return resp, nil
}

Two details matter here. A transport can be used concurrently, so mutable recorder state such as the counter needs synchronization. Once the wrapper reads resp.Body, it must also replace the drained body before returning the response. Without the io.NopCloser wrapper, downstream code would see an empty body.

The real recorder does more than this sketch. snapshot.Recorder stores a snapshot.Corridor (send asset, receive asset, reference pair), the requested sizes, source metadata, response status, capture time, and a SHA-256 hash for each body. It also deduplicates repeated request keys and writes the captured interactions together when Save runs. By default, cmd/ladder refuses to record from a modified Git worktree. Passing -allow-dirty permits the run but marks the manifest as dirty, making the weaker provenance visible in the artifact.

Snapshots that verify their own bytes

cmd/hop-analysis/main.go is the consumer side. It replays the recorded corridor snapshots without network access and reports how often the best path routes through native XLM, alongside the best non-XLM alternative. The written-up finding lives in docs/native-xlm-routing.md, and the tool derives it from testdata/snapshots, so a reader can re-run the analysis.

snapshot.Load recomputes each response body’s SHA-256 and rejects a mismatch with the hash stored in the manifest. That catches accidental body edits and edits that leave the manifest unchanged. It is an integrity check, not a signature: someone who deliberately changes both the body and its recorded hash can still produce a self-consistent fixture, so review and Git history remain part of the provenance story.

The report types in that file also handle decimal.Decimal in JSON output nicely. Each numeric field appears twice, once as the typed value with json:"-", once pre-formatted as a string:

DestAmount  decimal.Decimal `json:"-"`
DestAmountS string          `json:"dest_amount"`

Serialising the string form preserves the exact decimal representation shopspring/decimal computed instead of sending it through a binary floating-point JSON number. The typed field remains available for in-process comparison; the string field is the wire representation.

Passing the clock in instead of calling time.Now

Back to checkFreshness. It walks the union of the scheduler’s expected corridors and whatever the store actually holds, then compares each record’s RecordedAt with the injected now. Future timestamps are clamped to an age of zero, so clock skew does not produce a negative age.

Given a small store interface and a fake, the whole thing collapses into a table test:

type record struct{ RecordedAt time.Time }

type store interface {
	Corridors(ctx context.Context) ([]string, error)
	Latest(ctx context.Context, key string) (*record, error)
}

type fakeStore struct {
	keys    []string
	records map[string]*record
	err     error
}

func (f *fakeStore) Corridors(context.Context) ([]string, error) {
	return f.keys, f.err
}

func (f *fakeStore) Latest(_ context.Context, key string) (*record, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.records[key], nil // nil, nil means "never recorded"
}

A test can pin now to a literal such as time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) and assert the returned code. The upstream tests capture one now value and derive deliberately aged records from it, with no time.Sleep. For a refresher on the arithmetic involved, the Go time package guide covers Sub, Add, and formatting.

Look at Latest returning (nil, nil). The freshness check treats that corridor as missing and returns status 1. It also fails when both the expected set and stored set are empty, rather than allowing an empty collection to pass vacuously.

Exit codes as a tested contract

checkFreshness returns an int, and main in cmd/wayfared/main.go turns that into the process exit status. The three codes mean genuinely different things:

  • 0 — everything is fresh
  • 1 — at least one corridor is stale or missing
  • 2 — the check itself could not run

The gap between 1 and 2 is operationally useful. A failure to list the store returns 2 because the check could not determine freshness; an individual corridor that is stale, missing, or unreadable contributes to status 1. The other wayfared modes also return process statuses for one-shot verification, rotation, and measurement work.

Keeping os.Exit out of the decision function makes ordinary in-process unit tests possible. os.Exit terminates the process immediately and deferred functions do not run, so testing a direct call usually requires a subprocess harness. Returning the code and letting main exit keeps the policy easy to test. If you’re building CLIs with these semantics, the flag package guide pairs well with this structure.

Making “not checked” different from “checked and clean”

server/api.go defines the Server struct with an optional *checks.Runner. A nil runner disables counterparty checks, and the response omits its findings block. The wire type follows the same pattern with *checks.FindingsJSON.

The obvious encoding breaks:

type Response struct {
	Rate     string    `json:"rate"`
	Findings []Finding `json:"findings,omitempty"`
}

omitempty drops a nil slice and a zero-length slice alike. A client has no way to distinguish “we ran the SEP issuer checks and found nothing wrong” from “we never ran them”. The fix is a pointer:

type Response struct {
	Rate     string     `json:"rate"`
	Findings *[]Finding `json:"findings,omitempty"`
}

Now nil omits the key and a pointer to an empty slice encodes as "findings": []. The project uses a pointer to a findings struct rather than this simplified pointer-to-slice shape, and route.WithFindings only attaches it when checks or metrics are present. The same attention to state appears in hop-analysis, where SizesParsed stays separate from SizesWithAnyPath: a parsed response with no paths is a market result, while a response that failed to parse produced no usable observation.

That distinction recurs across the codebase. The counterparty checks in checks/issuer_auth_flags.go, checks/issuer_drift.go, and checks/metric_deviation.go separate adverse findings from cases they cannot determine. The reusable lesson is to test the unavailable path explicitly: a store that cannot be queried is not evidence that every corridor is stale, and it should not share the same exit code.