From b8074d8f5706841abeb55ed42425ffcbe7e9cbab Mon Sep 17 00:00:00 2001
From: Dev_Mabes <122737438+Mabel-003@users.noreply.github.com>
Date: Thu, 27 Aug 2026 07:49:26 +0000
Subject: [PATCH 1/3] route: detect and report chained fiat dependencies
Extend the corridor integrity model to detect chained fiat dependencies
beyond a single intermediate. A corridor whose dependency is itself
derivative now reports the full chain with depth, measured integrity of
each link, and explicit 'not measured' states for unmeasured links.
The current classify() model counts fiat hops per path and returns the
union of fiat intermediaries, but never asks whether those intermediaries
are themselves derivative. This means a corridor that looks clean (single
dependency) may hide a deep chain where the weakest link is invisible.
Changes:
- Add DependencyNode type representing one link in a dependency tree
- Add measureChain() function that recursively queries Horizon for each
dependency's own paths and classifies them
- Add cycle detection via visited set; self-references are structurally
impossible (classify skips the destination)
- Cap recursion at maxDependencyDepth=5 (matching Horizon protocol cap)
- Add DependencyChainJSON/DependencyNodeJSON wire types; new
dependency_chain field on CorridorJSON (omitempty, additive)
- Update derivative warning text: measured dependencies show their
integrity status; unmeasured ones carry 'may compound an unmeasured loss'
- Thread chain through LadderResult, summarise(), and ToCorridorJSON
- Store chain in runstore.Record for stale-path round-trip
- Add 8 new tests: depth-1, depth-2, cycle, NO-MARKET dependency,
wire shape, backward compat, direct-has-no-chain, helper functions
Wire shape change is additive (omitempty on new field), preserving
backward compatibility. depends_on flat array retained unchanged.
Close #22
---
route/ladder.go | 35 +++-
route/route.go | 213 ++++++++++++++++++-
route/route_test.go | 478 +++++++++++++++++++++++++++++++++++++++++++
route/wire.go | 73 +++++++
runstore/convert.go | 3 +
runstore/runstore.go | 8 +
server/api.go | 3 +
7 files changed, 797 insertions(+), 16 deletions(-)
diff --git a/route/ladder.go b/route/ladder.go
index 82af705..8fb12d7 100644
--- a/route/ladder.go
+++ b/route/ladder.go
@@ -91,6 +91,12 @@ type LadderResult struct {
Integrity Integrity
DependsOn []asset.Asset
+ // Chain is the full dependency tree when the corridor is derivative.
+ // It is the union across all rungs: if any rung discovered additional
+ // dependencies, they appear here. Nil when the corridor is not
+ // derivative.
+ Chain []DependencyNode
+
ReferenceMid decimal.Decimal
ReferenceSource string
@@ -330,6 +336,7 @@ func (l *LadderResult) summarise() {
anyDirect bool
allNoMarket = true
deps = map[string]asset.Asset{}
+ chainMap = map[string]DependencyNode{}
firstErr error
)
@@ -363,6 +370,9 @@ func (l *LadderResult) summarise() {
for _, d := range r.Result.DependsOn {
deps[d.Code+":"+d.Issuer] = d
}
+ for _, c := range r.Result.Chain {
+ chainMap[c.Asset.Code+":"+c.Asset.Issuer] = c
+ }
case IntegrityNoMarket:
// leaves allNoMarket intact
default:
@@ -410,6 +420,13 @@ func (l *LadderResult) summarise() {
sort.Slice(l.DependsOn, func(i, j int) bool {
return l.DependsOn[i].Code < l.DependsOn[j].Code
})
+ l.Chain = make([]DependencyNode, 0, len(chainMap))
+ for _, c := range chainMap {
+ l.Chain = append(l.Chain, c)
+ }
+ sort.Slice(l.Chain, func(i, j int) bool {
+ return l.Chain[i].Asset.Code < l.Chain[j].Asset.Code
+ })
default:
l.Integrity = IntegrityUnknown
}
@@ -447,11 +464,19 @@ func (l *LadderResult) finding(anyPriced bool, firstErr error) string {
var prefix string
if l.Integrity == IntegrityDerivative {
- prefix = fmt.Sprintf(
- "Derivative corridor: every path from %s to %s routes through %s, so "+
- "%s has no independent market and these figures compound %s's cost "+
- "with its own. ",
- send, recv, describeAssets(l.DependsOn), recv, describeAssets(l.DependsOn))
+ if allMeasured(l.Chain) {
+ prefix = fmt.Sprintf(
+ "Derivative corridor: every path from %s to %s routes through %s, so "+
+ "%s has no independent market and these figures inherit %s's "+
+ "liquidity and failure modes. ",
+ send, recv, describeAssets(l.DependsOn), recv, describeAssets(l.DependsOn))
+ } else {
+ prefix = fmt.Sprintf(
+ "Derivative corridor: every path from %s to %s routes through %s, so "+
+ "%s has no independent market. Their own integrity was not fully "+
+ "measured, so these figures may compound an unmeasured loss. ",
+ send, recv, describeAssets(l.DependsOn), recv)
+ }
}
var body string
diff --git a/route/route.go b/route/route.go
index 55fca5f..4c933c5 100644
--- a/route/route.go
+++ b/route/route.go
@@ -172,6 +172,29 @@ func (i Integrity) Priceable() bool {
return i == IntegrityDirect || i == IntegrityDerivative
}
+// maxDependencyDepth bounds recursive dependency measurement. The Stellar
+// protocol caps path length at 5 intermediate hops (XDR Asset path<5>),
+// so the real depth can never exceed that. The cap exists to prevent a
+// corrupted or expanded registry from turning one corridor request into
+// unbounded Horizon fan-out.
+const maxDependencyDepth = 5
+
+// DependencyNode is one link in a dependency chain. It is a tree, not a
+// flat list: a dependency can itself depend on other fiat tokens.
+//
+// Measured reports whether the node's Integrity was determined by an actual
+// Horizon query. When Measured is false, Integrity is the zero value
+// (IntegrityUnknown) and Reason explains why measurement did not happen.
+// A consumer must never present an unmeasured node as though its integrity
+// were a finding.
+type DependencyNode struct {
+ Asset asset.Asset
+ Integrity Integrity
+ Measured bool
+ Reason string
+ Dependencies []DependencyNode
+}
+
// Kind identifies how a route delivers value.
type Kind string
@@ -307,6 +330,12 @@ type Result struct {
// is IntegrityDerivative. Empty otherwise.
DependsOn []asset.Asset
+ // Chain is the full dependency tree when Integrity is
+ // IntegrityDerivative. Each node carries the dependency's own measured
+ // integrity (or an explicit "not measured" state). Nil when Integrity
+ // is not DERIVATIVE.
+ Chain []DependencyNode
+
// Notes record corridor-level facts that no single quote captures.
Notes []string
}
@@ -379,6 +408,7 @@ func (e *Engine) Quote(ctx context.Context, req Request) (*Result, error) {
default:
res.Integrity = d.integrity
res.DependsOn = d.dependsOn
+ res.Chain = d.chain
res.Notes = append(res.Notes, unknownHopNote(d.unknownHops)...)
if d.quote != nil {
res.Quotes = append(res.Quotes, *d.quote)
@@ -393,12 +423,18 @@ func (e *Engine) Quote(ctx context.Context, req Request) (*Result, error) {
"This is the absence of a price, not a bad one.",
req.SendAsset.Code, req.ReceiveAsset.Code))
case IntegrityDerivative:
- res.Notes = append(res.Notes, fmt.Sprintf(
- "Derivative corridor: every available path to %s routes through %s. "+
- "There is no independent market, so this corridor carries %s's "+
- "liquidity and failure modes in addition to its own.",
- req.ReceiveAsset.Code, describeAssets(res.DependsOn),
- describeAssets(res.DependsOn)))
+ if allMeasured(res.Chain) {
+ res.Notes = append(res.Notes, fmt.Sprintf(
+ "Derivative corridor: every available path to %s routes through %s. "+
+ "There is no independent market, so this corridor carries their "+
+ "liquidity and failure modes in addition to its own.",
+ req.ReceiveAsset.Code, describeAssets(res.DependsOn)))
+ } else {
+ res.Notes = append(res.Notes, fmt.Sprintf(
+ "Derivative corridor: every available path to %s routes through %s, "+
+ "but their own integrity was not fully measured.",
+ req.ReceiveAsset.Code, describeAssets(res.DependsOn)))
+ }
}
sort.SliceStable(res.Quotes, func(i, j int) bool {
@@ -450,6 +486,7 @@ func (e *Engine) unscored(ctx context.Context, req Request, ref refrate.Rate) (*
if d, err := e.quoteDEX(ctx, req, ref); err == nil {
res.Integrity = d.integrity
res.DependsOn = d.dependsOn
+ res.Chain = d.chain
res.Notes = append(res.Notes, unknownHopNote(d.unknownHops)...)
if d.quote != nil {
// Carried for its route description and receive amount;
@@ -481,6 +518,7 @@ type dexResult struct {
quote *Quote
integrity Integrity
dependsOn []asset.Asset
+ chain []DependencyNode
// unknownHops are the intermediate assets pathfinding routed through
// that are neither native XLM nor registered in the asset registry. They
@@ -522,6 +560,36 @@ func unknownHopNote(unknown []asset.Asset) []string {
"asset registry. An unrecognised hop is currently treated as having an "+
"independent market; see asset/known.go for the bounded false-negative.",
strings.Join(names, ", "))}
+
+// describeChainStatus renders the measured integrity of each dependency
+// for a human-readable warning.
+func describeChainStatus(nodes []DependencyNode) string {
+ parts := make([]string, len(nodes))
+ for i, n := range nodes {
+ switch {
+ case !n.Measured:
+ parts[i] = fmt.Sprintf("%s (not measured: %s)", n.Asset.Code, n.Reason)
+ case n.Integrity == IntegrityDirect:
+ parts[i] = fmt.Sprintf("%s (DIRECT, independent market exists)", n.Asset.Code)
+ case n.Integrity == IntegrityDerivative:
+ parts[i] = fmt.Sprintf("%s (DERIVATIVE, depends on %s)",
+ n.Asset.Code, describeAssets(fiatAssets(n.Dependencies)))
+ case n.Integrity == IntegrityNoMarket:
+ parts[i] = fmt.Sprintf("%s (NO-MARKET)", n.Asset.Code)
+ default:
+ parts[i] = fmt.Sprintf("%s (UNKNOWN)", n.Asset.Code)
+ }
+ }
+ return strings.Join(parts, ", ")
+}
+
+// fiatAssets extracts the asset from each DependencyNode.
+func fiatAssets(nodes []DependencyNode) []asset.Asset {
+ out := make([]asset.Asset, len(nodes))
+ for i, n := range nodes {
+ out[i] = n.Asset
+ }
+ return out
}
// classify determines corridor integrity from the complete set of paths.
@@ -588,6 +656,113 @@ func classify(paths []dex.Path, dest asset.Asset) (Integrity, []asset.Asset, []a
return IntegrityDerivative, deps, unknownList
}
+// measureChain recursively determines the integrity of each fiat dependency.
+//
+// For every dependency returned by classify, it queries Horizon for paths
+// from sendAsset to that dependency, classifies those paths, and recurses
+// into any newly discovered fiat intermediaries — building a tree whose
+// depth reflects how many layers of fiat-to-fiat routing exist.
+//
+// The visited set prevents cycles: if A depends on B and B depends on A,
+// the second encounter stops recursion and reports the link as unmeasured.
+// The depth cap (maxDependencyDepth) prevents unbounded fan-out from a
+// corrupted registry.
+//
+// Each Horizon call is one StrictSendPaths round trip. The total cost per
+// call to measureChain is at most len(deps) × maxDependencyDepth, which
+// with the current registry (4 fiat tokens) and protocol cap (5 hops) is
+// at most 20 calls. In practice, corridors depend on 1-2 intermediaries,
+// so the cost is 1-2 extra Horizon calls per rung.
+func (e *Engine) measureChain(
+ ctx context.Context,
+ sendAsset asset.Asset,
+ sendAmount decimal.Decimal,
+ deps []asset.Asset,
+ visited map[string]bool,
+ depth int,
+) []DependencyNode {
+ if depth >= maxDependencyDepth {
+ nodes := make([]DependencyNode, len(deps))
+ for i, d := range deps {
+ nodes[i] = DependencyNode{
+ Asset: d,
+ Reason: "depth cap exceeded",
+ Measured: false,
+ }
+ }
+ return nodes
+ }
+
+ nodes := make([]DependencyNode, 0, len(deps))
+ for _, dep := range deps {
+ key := dep.Code + ":" + dep.Issuer
+ if visited[key] {
+ nodes = append(nodes, DependencyNode{
+ Asset: dep,
+ Reason: "cycle detected",
+ Measured: false,
+ })
+ continue
+ }
+
+ visited[key] = true
+
+ depPaths, err := e.DEX.StrictSendPaths(ctx, sendAsset, sendAmount, dep)
+ if err != nil {
+ nodes = append(nodes, DependencyNode{
+ Asset: dep,
+ Reason: fmt.Sprintf("Horizon error: %v", err),
+ Measured: false,
+ })
+ continue
+ }
+
+ depIntegrity, depFiatHops, _ := classify(depPaths, dep)
+ node := DependencyNode{
+ Asset: dep,
+ Integrity: depIntegrity,
+ Measured: true,
+ }
+
+ if depIntegrity == IntegrityDerivative && len(depFiatHops) > 0 {
+ node.Dependencies = e.measureChain(
+ ctx, sendAsset, sendAmount, depFiatHops, visited, depth+1)
+ }
+
+ nodes = append(nodes, node)
+ }
+
+ sort.Slice(nodes, func(i, j int) bool {
+ return nodes[i].Asset.Code < nodes[j].Asset.Code
+ })
+ return nodes
+}
+
+// chainDepth returns the maximum depth of a dependency tree.
+func chainDepth(nodes []DependencyNode) int {
+ max := 0
+ for _, n := range nodes {
+ d := chainDepth(n.Dependencies)
+ if d+1 > max {
+ max = d + 1
+ }
+ }
+ return max
+}
+
+// allMeasured reports whether every node in the tree was actually measured.
+func allMeasured(nodes []DependencyNode) bool {
+ for _, n := range nodes {
+ if !n.Measured {
+ return false
+ }
+ if !allMeasured(n.Dependencies) {
+ return false
+ }
+ }
+ return true
+}
+
// quoteDEX prices the on-chain leg via Horizon pathfinding, and classifies
// the corridor's structure from the same set of paths.
func (e *Engine) quoteDEX(ctx context.Context, req Request, ref refrate.Rate) (*dexResult, error) {
@@ -601,6 +776,14 @@ func (e *Engine) quoteDEX(ctx context.Context, req Request, ref refrate.Rate) (*
return &dexResult{integrity: integrity}, nil
}
+ var chain []DependencyNode
+ if integrity == IntegrityDerivative {
+ visited := map[string]bool{
+ req.ReceiveAsset.Code + ":" + req.ReceiveAsset.Issuer: true,
+ }
+ chain = e.measureChain(ctx, req.SendAsset, req.SendAmount, dependsOn, visited, 0)
+ }
+
// Horizon generally returns its best path first, but that is not a
// documented guarantee, so the maximum is selected explicitly.
best := paths[0]
@@ -640,10 +823,18 @@ func (e *Engine) quoteDEX(ctx context.Context, req Request, ref refrate.Rate) (*
// quote as well as the result means a caller rendering a single route
// cannot present the number without the dependency attached to it.
if integrity == IntegrityDerivative {
- q.Warnings = append(q.Warnings, fmt.Sprintf(
- "derivative corridor: every path routes through %s, so this rate "+
- "compounds %s's loss with this corridor's own",
- describeAssets(dependsOn), describeAssets(dependsOn)))
+ if allMeasured(chain) {
+ q.Warnings = append(q.Warnings, fmt.Sprintf(
+ "derivative corridor: every path routes through %s (measured: %s), "+
+ "so this corridor inherits their liquidity and failure modes",
+ describeAssets(dependsOn), describeChainStatus(chain)))
+ } else {
+ q.Warnings = append(q.Warnings, fmt.Sprintf(
+ "derivative corridor: every path routes through %s, but their "+
+ "own integrity was not fully measured — this rate may compound "+
+ "an unmeasured loss",
+ describeAssets(dependsOn)))
+ }
}
probe := e.ProbeAmount
@@ -659,5 +850,5 @@ func (e *Engine) quoteDEX(ctx context.Context, req Request, ref refrate.Rate) (*
}
}
}
- return &dexResult{quote: q, integrity: integrity, dependsOn: dependsOn, unknownHops: unknownHops}, nil
+ return &dexResult{quote: q, integrity: integrity, dependsOn: dependsOn, chain: chain, unknownHops: unknownHops}, nil
}
diff --git a/route/route_test.go b/route/route_test.go
index bc0e849..ff1fe23 100644
--- a/route/route_test.go
+++ b/route/route_test.go
@@ -763,3 +763,481 @@ func TestUnknownOnlyPathIsTheDocumentedFalseNegative(t *testing.T) {
t.Errorf("expected the note to surface BLND, got notes: %v", res.Notes)
}
}
+
+// ---------------------------------------------------------------------------
+// Dependency chain tests
+// ---------------------------------------------------------------------------
+
+// chainHorizonStub returns a server that dispatches based on the
+// destination_assets query parameter, allowing multi-asset chain tests.
+// Keys in the routes map should be asset codes (e.g. "NGNC"); the
+// handler matches on the code portion of "CODE:ISSUER" or plain "CODE".
+func chainHorizonStub(t *testing.T, routes map[string]string) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ dest := r.URL.Query().Get("destination_assets")
+ // Horizon sends "CODE:ISSUER" — extract just the code.
+ code := dest
+ if idx := strings.Index(dest, ":"); idx != -1 {
+ code = dest[:idx]
+ }
+ body, ok := routes[code]
+ if !ok {
+ body = `{"_embedded":{"records":[]}}`
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(body))
+ }))
+}
+
+// ghscDirectNGNCResponse is a modified fixture where NGNC is measured as
+// having an independent market (XLM path avoids fiat intermediaries).
+// This is the same as liveStrictSendResponse but for the USDC→NGNC pair,
+// meaning NGNC's integrity is DIRECT when measured.
+const ngncDirectResponse = liveStrictSendResponse
+
+// TestChainMeasuredDirect verifies that when a derivative corridor's
+// dependency is measured, the chain carries the measured integrity.
+// USDC→GHSC depends on NGNC; USDC→NGNC has an XLM path (bridge asset),
+// so NGNC is DIRECT. The chain should be depth 1 with NGNC measured as DIRECT.
+func TestChainMeasuredDirect(t *testing.T) {
+ srv := chainHorizonStub(t, map[string]string{
+ "GHSC": ghscViaNGNCResponse,
+ "NGNC": ngncDirectResponse,
+ })
+ defer srv.Close()
+
+ e := &Engine{
+ DEX: &dex.Client{HorizonURL: srv.URL},
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ "USD/GHS": decimal.RequireFromString("11.7625"),
+ }),
+ }
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
+ t.Fatalf("Chain = %v, want exactly NGNC", res.Chain)
+ }
+ node := res.Chain[0]
+ if !node.Measured {
+ t.Error("NGNC should be measured")
+ }
+ if node.Integrity != IntegrityDirect {
+ t.Errorf("NGNC integrity = %s, want DIRECT", node.Integrity)
+ }
+ if len(node.Dependencies) != 0 {
+ t.Errorf("NGNC should have no sub-dependencies, got %v", node.Dependencies)
+ }
+
+ // The warning should use the measured variant.
+ warnings := strings.Join(res.Quotes[0].Warnings, " ")
+ if !strings.Contains(warnings, "DIRECT, independent market exists") {
+ t.Errorf("expected measured warning with market status, got: %v",
+ res.Quotes[0].Warnings)
+ }
+}
+
+// TestChainDepthTwo verifies recursive chain measurement through two levels.
+// USDC→TOKEN_C depends on TOKEN_B, TOKEN_B depends on TOKEN_A, TOKEN_A is
+// DIRECT (reached via XLM, a bridge asset).
+func TestChainDepthTwo(t *testing.T) {
+ // USDC→GHSC depends on KESC, KESC depends on NGNC, NGNC is DIRECT.
+
+ ghscViaKescResponse := `{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "` + asset.LinkIOIssuer + `" }
+ ]
+ }
+ ]
+ }
+}`
+
+ kescViaNGNCResponse := `{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "KESC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "` + asset.LinkIOIssuer + `" }
+ ]
+ }
+ ]
+ }
+}`
+
+ srv := chainHorizonStub(t, map[string]string{
+ "GHSC": ghscViaKescResponse,
+ "KESC": kescViaNGNCResponse,
+ "NGNC": ngncDirectResponse,
+ })
+ defer srv.Close()
+
+ e := &Engine{
+ DEX: &dex.Client{HorizonURL: srv.URL},
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ "USD/GHS": decimal.RequireFromString("11.7625"),
+ }),
+ }
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ // Chain: GHSC depends on KESC (depth 2), KESC depends on NGNC (depth 1),
+ // NGNC is DIRECT (depth 0).
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "KESC" {
+ t.Fatalf("Chain top level = %v, want KESC", res.Chain)
+ }
+ kescNode := res.Chain[0]
+ if !kescNode.Measured {
+ t.Error("KESC should be measured")
+ }
+ if kescNode.Integrity != IntegrityDerivative {
+ t.Errorf("KESC integrity = %s, want DERIVATIVE", kescNode.Integrity)
+ }
+ if len(kescNode.Dependencies) != 1 || kescNode.Dependencies[0].Asset.Code != "NGNC" {
+ t.Fatalf("KESC dependencies = %v, want NGNC", kescNode.Dependencies)
+ }
+ ngncNode := kescNode.Dependencies[0]
+ if !ngncNode.Measured {
+ t.Error("NGNC should be measured")
+ }
+ if ngncNode.Integrity != IntegrityDirect {
+ t.Errorf("NGNC integrity = %s, want DIRECT", ngncNode.Integrity)
+ }
+
+ // All measured.
+ if !allMeasured(res.Chain) {
+ t.Error("all nodes should be measured in this chain")
+ }
+}
+
+// TestChainCycleTerminates verifies that a circular dependency does not
+// cause infinite recursion. When USDC→A routes through B and USDC→B
+// routes through A, the second encounter is detected as a cycle and
+// reported as unmeasured.
+func TestChainCycleTerminates(t *testing.T) {
+ // We can't easily create new fiat tokens, so we simulate the cycle
+ // by using the actual fiat tokens in a way that creates mutual
+ // dependency. But the registry is fixed. Instead, we test the
+ // measureChain logic directly with a mock that creates a cycle
+ // between NGNC and GHSC by returning GHSC paths through NGNC and
+ // NGNC paths through GHSC.
+ //
+ // Note: in reality, USDC→NGNC does NOT go through GHSC (NGNC is
+ // direct). But we can force the cycle by returning a custom response
+ // for NGNC that routes through GHSC.
+
+ ngncViaGHSCResponse := `{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "NGNC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "GHSC",
+ "asset_issuer": "` + asset.LinkIOIssuer + `" }
+ ]
+ }
+ ]
+ }
+}`
+
+ srv := chainHorizonStub(t, map[string]string{
+ "GHSC": ghscViaNGNCResponse,
+ "NGNC": ngncViaGHSCResponse,
+ })
+ defer srv.Close()
+
+ e := &Engine{
+ DEX: &dex.Client{HorizonURL: srv.URL},
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ "USD/GHS": decimal.RequireFromString("11.7625"),
+ }),
+ }
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ // The chain should have NGNC as the top-level dependency.
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
+ t.Fatalf("Chain top level = %v, want NGNC", res.Chain)
+ }
+ ngncNode := res.Chain[0]
+ if !ngncNode.Measured {
+ t.Error("NGNC should be measured (first encounter)")
+ }
+ if ngncNode.Integrity != IntegrityDerivative {
+ t.Errorf("NGNC integrity = %s, want DERIVATIVE", ngncNode.Integrity)
+ }
+
+ // NGNC depends on GHSC, but GHSC is already visited (it's the
+ // destination), so it should be reported as unmeasured with cycle reason.
+ if len(ngncNode.Dependencies) != 1 || ngncNode.Dependencies[0].Asset.Code != "GHSC" {
+ t.Fatalf("NGNC dependencies = %v, want GHSC", ngncNode.Dependencies)
+ }
+ ghscNode := ngncNode.Dependencies[0]
+ if ghscNode.Measured {
+ t.Error("GHSC should NOT be measured (cycle detected)")
+ }
+ if ghscNode.Reason != "cycle detected" {
+ t.Errorf("GHSC reason = %q, want 'cycle detected'", ghscNode.Reason)
+ }
+}
+
+// TestChainDependencyHasNoMarket verifies that a dependency whose own
+// market is NO-MARKET is reported honestly in the chain.
+func TestChainDependencyHasNoMarket(t *testing.T) {
+ // USDC→GHSC depends on KESC, and USDC→KESC has no paths.
+ ghscViaKescResponse := `{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "` + asset.LinkIOIssuer + `" }
+ ]
+ }
+ ]
+ }
+}`
+
+ srv := chainHorizonStub(t, map[string]string{
+ "GHSC": ghscViaKescResponse,
+ "KESC": kescEmptyResponse,
+ })
+ defer srv.Close()
+
+ e := &Engine{
+ DEX: &dex.Client{HorizonURL: srv.URL},
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ "USD/GHS": decimal.RequireFromString("11.7625"),
+ }),
+ }
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "KESC" {
+ t.Fatalf("Chain = %v, want KESC", res.Chain)
+ }
+ kescNode := res.Chain[0]
+ if !kescNode.Measured {
+ t.Error("KESC should be measured")
+ }
+ if kescNode.Integrity != IntegrityNoMarket {
+ t.Errorf("KESC integrity = %s, want NO-MARKET", kescNode.Integrity)
+ }
+
+ // Since not all nodes are measured cleanly (NO-MARKET is measured but
+ // the warning text differs), check the warning uses the unmeasured path.
+ // Actually NO-MARKET is measured — the node is Measured=true. The
+ // allMeasured check passes. The describeChainStatus renders it as
+ // "KESC (NO-MARKET)".
+ if !allMeasured(res.Chain) {
+ t.Error("all nodes should be measured (NO-MARKET is still a measurement)")
+ }
+}
+
+// TestChainWireShape verifies the JSON wire shape of the dependency chain.
+func TestChainWireShape(t *testing.T) {
+ srv := chainHorizonStub(t, map[string]string{
+ "GHSC": ghscViaNGNCResponse,
+ "NGNC": ngncDirectResponse,
+ })
+ defer srv.Close()
+
+ e := &Engine{
+ DEX: &dex.Client{HorizonURL: srv.URL},
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ "USD/GHS": decimal.RequireFromString("11.7625"),
+ }),
+ }
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ chain := ToDependencyChainJSON(res.Chain)
+ if chain == nil {
+ t.Fatal("chain should not be nil for a derivative corridor")
+ }
+ if chain.Depth != 1 {
+ t.Errorf("depth = %d, want 1", chain.Depth)
+ }
+ if len(chain.DependsOn) != 1 {
+ t.Fatalf("depends_on = %d nodes, want 1", len(chain.DependsOn))
+ }
+ node := chain.DependsOn[0]
+ if node.Code != "NGNC" {
+ t.Errorf("code = %s, want NGNC", node.Code)
+ }
+ if !node.Measured {
+ t.Error("measured should be true")
+ }
+ if node.Integrity != "DIRECT" {
+ t.Errorf("integrity = %s, want DIRECT", node.Integrity)
+ }
+ if node.Peg != "NGN" {
+ t.Errorf("peg = %s, want NGN", node.Peg)
+ }
+ if len(node.Dependencies) != 0 {
+ t.Errorf("sub-dependencies = %d, want 0", len(node.Dependencies))
+ }
+}
+
+// TestChainBackwardCompatible verifies that the flat depends_on array is
+// still present alongside the new dependency_chain on the wire.
+func TestChainBackwardCompatible(t *testing.T) {
+ srv := chainHorizonStub(t, map[string]string{
+ "GHSC": ghscViaNGNCResponse,
+ "NGNC": ngncDirectResponse,
+ })
+ defer srv.Close()
+
+ e := &Engine{
+ DEX: &dex.Client{HorizonURL: srv.URL},
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ "USD/GHS": decimal.RequireFromString("11.7625"),
+ }),
+ }
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ // Simulate what a consumer sees: the JSON must have both depends_on
+ // and dependency_chain.
+ if len(res.DependsOn) != 1 || res.DependsOn[0].Code != "NGNC" {
+ t.Errorf("DependsOn = %v, want NGNC", res.DependsOn)
+ }
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
+ t.Errorf("Chain = %v, want NGNC", res.Chain)
+ }
+}
+
+// TestDirectCorridorHasNoChain verifies that a direct corridor does not
+// produce a dependency chain.
+func TestDirectCorridorHasNoChain(t *testing.T) {
+ srv := horizonStub(t, liveStrictSendResponse)
+ defer srv.Close()
+
+ e := &Engine{DEX: &dex.Client{HorizonURL: srv.URL}, RefRate: usdToNGN("1500")}
+ res, err := e.Quote(context.Background(), ngnRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDirect {
+ t.Errorf("Integrity = %s, want DIRECT", res.Integrity)
+ }
+ if res.Chain != nil {
+ t.Errorf("Chain = %v, want nil for direct corridor", res.Chain)
+ }
+}
+
+// TestAllMeasuredAndChainDepth are unit tests for the helper functions.
+func TestAllMeasuredAndChainDepth(t *testing.T) {
+ t.Run("all measured", func(t *testing.T) {
+ nodes := []DependencyNode{
+ {Asset: asset.NGNC(), Measured: true, Integrity: IntegrityDirect},
+ }
+ if !allMeasured(nodes) {
+ t.Error("expected all measured")
+ }
+ if chainDepth(nodes) != 1 {
+ t.Errorf("depth = %d, want 1", chainDepth(nodes))
+ }
+ })
+
+ t.Run("unmeasured node", func(t *testing.T) {
+ nodes := []DependencyNode{
+ {Asset: asset.NGNC(), Measured: false, Reason: "cycle detected"},
+ }
+ if allMeasured(nodes) {
+ t.Error("should not be all measured")
+ }
+ })
+
+ t.Run("nested depth", func(t *testing.T) {
+ nodes := []DependencyNode{
+ {
+ Asset: asset.GHSC(),
+ Measured: true,
+ Integrity: IntegrityDerivative,
+ Dependencies: []DependencyNode{
+ {Asset: asset.NGNC(), Measured: true, Integrity: IntegrityDirect},
+ },
+ },
+ }
+ if !allMeasured(nodes) {
+ t.Error("expected all measured")
+ }
+ if chainDepth(nodes) != 2 {
+ t.Errorf("depth = %d, want 2", chainDepth(nodes))
+ }
+ })
+
+ t.Run("empty", func(t *testing.T) {
+ if !allMeasured(nil) {
+ t.Error("nil should be all measured")
+ }
+ if chainDepth(nil) != 0 {
+ t.Errorf("depth = %d, want 0", chainDepth(nil))
+ }
+ })
+}
+
+// TestDescribeChainStatus verifies the human-readable chain status rendering.
+func TestDescribeChainStatus(t *testing.T) {
+ nodes := []DependencyNode{
+ {Asset: asset.NGNC(), Measured: true, Integrity: IntegrityDirect},
+ {Asset: asset.KESC(), Measured: false, Reason: "Horizon error: timeout"},
+ }
+ got := describeChainStatus(nodes)
+ if !strings.Contains(got, "NGNC (DIRECT, independent market exists)") {
+ t.Errorf("expected NGNC DIRECT status, got: %s", got)
+ }
+ if !strings.Contains(got, "KESC (not measured: Horizon error: timeout)") {
+ t.Errorf("expected KESC unmeasured status, got: %s", got)
+ }
+}
diff --git a/route/wire.go b/route/wire.go
index f8bf44d..d38021c 100644
--- a/route/wire.go
+++ b/route/wire.go
@@ -80,6 +80,12 @@ type CorridorJSON struct {
Integrity string `json:"integrity"`
DependsOn []AssetJSON `json:"depends_on"`
+ // DependencyChain is the full tree of dependencies when the corridor
+ // is derivative. Nil when Integrity is not DERIVATIVE. Each node
+ // carries the dependency's measured integrity or an explicit "not
+ // measured" state with a reason.
+ DependencyChain *DependencyChainJSON `json:"dependency_chain,omitempty"`
+
ReferenceMid string `json:"reference_mid"`
ReferenceSource string `json:"reference_source"`
ReferencePair string `json:"reference_pair"`
@@ -184,6 +190,32 @@ type AssetJSON struct {
Asset string `json:"asset,omitempty"`
}
+// DependencyChainJSON is the wire representation of a dependency tree.
+// It is present only when the corridor is derivative (integrity = "DERIVATIVE").
+// Nil on the wire means the corridor is direct or has no market — never an
+// empty object.
+type DependencyChainJSON struct {
+ Depth int `json:"depth"`
+ DependsOn []DependencyNodeJSON `json:"depends_on"`
+}
+
+// DependencyNodeJSON is one link in a dependency chain on the wire.
+//
+// Measured is always present. When false, Integrity carries no meaningful
+// value (it will be "UNKNOWN") and Reason explains why: cycle detected,
+// depth cap exceeded, Horizon error, or context cancelled. A consumer
+// rendering this node must not present it as though its integrity were a
+// finding — an unmeasured link has no finding to present.
+type DependencyNodeJSON struct {
+ Code string `json:"code"`
+ Issuer string `json:"issuer,omitempty"`
+ Peg string `json:"peg,omitempty"`
+ Integrity string `json:"integrity"`
+ Measured bool `json:"measured"`
+ Reason string `json:"reason,omitempty"`
+ Dependencies []DependencyNodeJSON `json:"dependencies,omitempty"`
+}
+
func ToAssetJSON(a asset.Asset) AssetJSON {
j := AssetJSON{Code: a.Code, Issuer: a.Issuer}
if a.Identifiable() {
@@ -195,6 +227,46 @@ func ToAssetJSON(a asset.Asset) AssetJSON {
return j
}
+// ToDependencyChainJSON renders a dependency tree for the wire.
+//
+// Nil input produces nil output (omitted from JSON via omitempty). An empty
+// slice produces a chain with depth 0 and an empty depends_on array, which
+// is structurally valid but semantically should not occur — a derivative
+// corridor always has at least one dependency.
+func ToDependencyChainJSON(nodes []DependencyNode) *DependencyChainJSON {
+ if len(nodes) == 0 {
+ return nil
+ }
+ out := &DependencyChainJSON{
+ Depth: chainDepth(nodes),
+ DependsOn: make([]DependencyNodeJSON, 0, len(nodes)),
+ }
+ for _, n := range nodes {
+ out.DependsOn = append(out.DependsOn, toDependencyNodeJSON(n))
+ }
+ return out
+}
+
+func toDependencyNodeJSON(n DependencyNode) DependencyNodeJSON {
+ j := DependencyNodeJSON{
+ Code: n.Asset.Code,
+ Issuer: n.Asset.Issuer,
+ Integrity: n.Integrity.String(),
+ Measured: n.Measured,
+ Reason: n.Reason,
+ }
+ if peg, ok := asset.FiatPeg(n.Asset); ok {
+ j.Peg = peg
+ }
+ if len(n.Dependencies) > 0 {
+ j.Dependencies = make([]DependencyNodeJSON, 0, len(n.Dependencies))
+ for _, d := range n.Dependencies {
+ j.Dependencies = append(j.Dependencies, toDependencyNodeJSON(d))
+ }
+ }
+ return j
+}
+
func ToQuoteJSON(q *Quote) *QuoteJSON {
if q == nil {
return nil
@@ -292,6 +364,7 @@ func ToCorridorJSON(l *LadderResult, pair string) CorridorJSON {
for _, d := range l.DependsOn {
out.DependsOn = append(out.DependsOn, ToAssetJSON(d))
}
+ out.DependencyChain = ToDependencyChainJSON(l.Chain)
for _, r := range l.Rungs {
rj := RungJSON{
diff --git a/runstore/convert.go b/runstore/convert.go
index ae74bf6..0f4e10d 100644
--- a/runstore/convert.go
+++ b/runstore/convert.go
@@ -68,6 +68,9 @@ func FromCorridorJSON(c route.CorridorJSON) *Record {
for _, d := range c.DependsOn {
r.DependsOn = append(r.DependsOn, d.Code)
}
+ if c.DependencyChain != nil {
+ r.DependencyChain = c.DependencyChain
+ }
for _, rung := range c.Rungs {
out := Rung{
diff --git a/runstore/runstore.go b/runstore/runstore.go
index fc9deed..e422539 100644
--- a/runstore/runstore.go
+++ b/runstore/runstore.go
@@ -38,6 +38,7 @@ import (
"time"
"github.com/Wayfare-labs/wayfare/checks"
+ "github.com/Wayfare-labs/wayfare/route"
)
// Version is the record schema version.
@@ -160,6 +161,13 @@ type Record struct {
Checks []checks.CheckJSON `json:"checks,omitempty"`
Metrics []checks.MetricJSON `json:"metrics,omitempty"`
+ // DependencyChain is the full dependency tree, stored word-for-word
+ // from the wire so the stale path can serve it back identically.
+ // Absent (nil) for corridors that are not derivative, and absent for
+ // records written before this field existed — the stale path treats
+ // nil as "chain not available", which is honest rather than fabricated.
+ DependencyChain *route.DependencyChainJSON `json:"dependency_chain,omitempty"`
+
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
}
diff --git a/server/api.go b/server/api.go
index d9c51c6..1b1ee2f 100644
--- a/server/api.go
+++ b/server/api.go
@@ -454,6 +454,9 @@ func staleJSON(rec *runstore.Record, pair string, now time.Time) route.CorridorJ
for _, code := range rec.DependsOn {
out.DependsOn = append(out.DependsOn, route.AssetJSON{Code: code})
}
+ if rec.DependencyChain != nil {
+ out.DependencyChain = rec.DependencyChain
+ }
for _, r := range rec.Rungs {
rj := route.RungJSON{
SendAmount: r.SendAmount,
From 9ea686df44f9f7a328347b199f0e492e11ccacf3 Mon Sep 17 00:00:00 2001
From: Dev_Mabes <122737438+Mabel-003@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:15:27 +0000
Subject: [PATCH 2/3] server/ui: make corridor monitor usable on mobile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Transform the fixed-width 900px single-column layout into a responsive
design that works at 360px without horizontal page scroll.
Table: convert from six-column table to card layout on mobile (≤640px).
Each rung becomes a bordered card with the send amount as the header
and labeled key-value pairs for Receive, Rate, Loss, Verdict, and Path.
The thead is visually hidden but accessible. Chosen over column collapse
or horizontal scroll because all six data points remain visible and
labeled without any horizontal movement — the audience for these
corridors is disproportionately on phones.
Controls: select fills remaining width, buttons become equal-width
side-by-side pair, all interactive elements get 44px min-height tap
targets.
Charts: SVG viewBox already scales via width:100%; no changes needed
— labels are small but legible as an overview, with detailed numbers
in the table below.
Panels, legend grid, finding rows, and provenance badge all adapt
to narrower widths. All styles use CSS custom properties so dark mode
is inherited automatically.
Close #16
---
server/index.html | 80 ++++++++++++++++++++++++++++++++++-------------
1 file changed, 58 insertions(+), 22 deletions(-)
diff --git a/server/index.html b/server/index.html
index 7ebb14c..ee27df2 100644
--- a/server/index.html
+++ b/server/index.html
@@ -173,6 +173,42 @@
font-size: .85rem; color: var(--muted);
border-left: 3px solid var(--border); padding-left: .7rem;
}
+ @media (max-width: 640px) {
+ html, body { overflow-x: hidden; }
+ .wrap { padding: 1.5rem 1rem 3rem; max-width: 100%; }
+ .controls select { flex: 1 1 0; min-height: 44px; font-size: 1rem; }
+ .controls button { flex: 1 1 calc(50% - .3rem); min-height: 44px; font-size: 1rem; }
+ .panel { padding: .9rem 1rem; }
+ .scroll table { border: 0; }
+ .scroll table thead {
+ clip: rect(0 0 0 0); height: 1px; margin: -1px;
+ overflow: hidden; position: absolute; width: 1px;
+ }
+ .scroll table tr {
+ display: block; margin-bottom: .75rem;
+ border: 1px solid var(--border); border-radius: 8px;
+ padding: .6rem .7rem; background: var(--panel);
+ }
+ .scroll table td {
+ display: flex; justify-content: space-between; align-items: baseline;
+ padding: .3rem .15rem; border-bottom: 1px solid var(--grid);
+ text-align: right; font-size: .85rem;
+ }
+ .scroll table td:last-child { border-bottom: 0; }
+ .scroll table td::before {
+ content: attr(data-label); font-weight: 550; text-align: left;
+ color: var(--muted); font-size: .72rem; text-transform: uppercase;
+ letter-spacing: .04em; flex-shrink: 0; margin-right: .75rem;
+ }
+ .scroll table td:first-child {
+ display: block; text-align: left; font-weight: 600; font-size: .95rem;
+ border-bottom: 1px solid var(--grid); padding-bottom: .45rem; margin-bottom: .1rem;
+ }
+ .scroll table td:first-child::before { display: none; }
+ .legend-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .finding-row { flex-wrap: wrap; gap: .4rem; }
+ .f-state { min-width: auto; }
+ }
@@ -370,33 +406,33 @@ Wayfare
: r.integrity === 'NO-MARKET' ? 'no path exists at this size'
: 'not priced';
if (scored) {
- return `| ${esc(r.send_amount)} |
- ${why} |
- ${esc(r.integrity)} |
`;
+ return `| ${esc(r.send_amount)} |
+ ${why} |
+ ${esc(r.integrity)} |
`;
}
- return `| ${esc(r.send_amount)} |
- ${why} |
- ${esc(r.integrity)} |
`;
+ return `| ${esc(r.send_amount)} |
+ ${why} |
+ ${esc(r.integrity)} |
`;
}
const q = r.quote;
if (scored) {
return `
- | ${esc(r.send_amount)} |
- ${esc(q.receive_amount)} |
- ${esc(q.effective_rate)} |
- ${formatPct(q.loss_pct)}% |
- ${esc(q.verdict)} |
- ${esc(q.description)} |
+ ${esc(r.send_amount)} |
+ ${esc(q.receive_amount)} |
+ ${esc(q.effective_rate)} |
+ ${formatPct(q.loss_pct)}% |
+ ${esc(q.verdict)} |
+ ${esc(q.description)} |
`;
}
// When not scored, show structural facts only: send, receive, rate, path.
// Loss and verdict are omitted — they are derived from a benchmark the
// engine refused to stand behind.
return `
- | ${esc(r.send_amount)} |
- ${esc(q.receive_amount)} |
- ${esc(q.effective_rate)} |
- ${esc(q.description)} |
+ ${esc(r.send_amount)} |
+ ${esc(q.receive_amount)} |
+ ${esc(q.effective_rate)} |
+ ${esc(q.description)} |
`;
}).join('');
@@ -835,12 +871,12 @@ Stored runs
const div = ref.divergence_pct
? ` div ${esc(ref.divergence_pct)}%` : '';
return `
- | ${esc(r.recorded_at)} |
- ${r.seq} |
- ${esc(r.integrity)}${via} |
- ${esc(r.floor_loss_pct)}% |
- ${esc(r.worst_loss_pct)}% |
- ${esc(src)}${div} |
+ ${esc(r.recorded_at)} |
+ ${r.seq} |
+ ${esc(r.integrity)}${via} |
+ ${esc(r.floor_loss_pct)}% |
+ ${esc(r.worst_loss_pct)}% |
+ ${esc(src)}${div} |
`;
}).join('');
From 93671127b4d1d48703a9c1331b32c40ba55a50df Mon Sep 17 00:00:00 2001
From: Dev_Mabes <122737438+Mabel-003@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:44:24 +0000
Subject: [PATCH 3/3] route,server: harden dependency-chain logic and replay
chain tests from recorded fixtures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address the CodeRabbit/Fury03 review on the dependency-chain and mobile-UI
work:
- ladder.go: the rung chainMap built in summarise() overwrote entries
unconditionally, so one rung's unmeasured placeholder could erase another
rung's real measurement of the same dependency. A measured node now always
wins over an unmeasured one in the union.
- route.go: measureChain shared a single visited map across the whole tree,
so a dependency shared between sibling branches was mislabelled as a
cycle. Each branch now works from its own copy of the ancestor path, and
only a node already on that path counts as a cycle.
- Chain tests replay scenario fixtures through snapshot.Replayer instead of
httptest.NewServer. The constructed chain cases (depth-2, cycle, no-market
dependency, sibling sharing) do not exist on the recorded mainnet set, so
the fixtures live under testdata/chain-snapshots, captured through the
standard snapshot.Recorder and labelled as scenario fixtures in each
manifest.
- TestChainBackwardCompatible now asserts on the serialized JSON (both the
flat depends_on array and the new dependency_chain key) rather than Go
struct fields.
- server/index.html: the first (send-amount) cell of a mobile card row keeps
its label visible — the ::before hide rule is gone.
- New regression tests pin both logic fixes: a measured chain node survives
ladder aggregation, and sibling-shared dependencies are measured, not
flagged as cycles.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
route/chain_dependency_test.go | 428 ++++++++++++++++++
route/ladder.go | 17 +-
route/route.go | 40 +-
route/route_test.go | 411 -----------------
server/index.html | 1 -
.../manifest.json | 66 +++
.../responses/001-paths-strict-send-100.json | 31 ++
.../responses/002-paths-strict-send-100.json | 18 +
.../responses/003-paths-strict-send-10.json | 31 ++
.../manifest.json | 77 ++++
.../responses/001-paths-strict-send-100.json | 18 +
.../responses/002-paths-strict-send-100.json | 18 +
.../responses/003-paths-strict-send-100.json | 24 +
.../responses/004-paths-strict-send-10.json | 18 +
.../manifest.json | 66 +++
.../responses/001-paths-strict-send-100.json | 31 ++
.../responses/002-paths-strict-send-100.json | 24 +
.../responses/003-paths-strict-send-10.json | 31 ++
.../manifest.json | 66 +++
.../responses/001-paths-strict-send-100.json | 18 +
.../responses/002-paths-strict-send-100.json | 1 +
.../responses/003-paths-strict-send-10.json | 18 +
.../manifest.json | 77 ++++
.../responses/001-paths-strict-send-100.json | 26 ++
.../responses/002-paths-strict-send-100.json | 18 +
.../responses/003-paths-strict-send-100.json | 24 +
.../responses/004-paths-strict-send-10.json | 26 ++
.../manifest.json | 55 +++
.../responses/001-paths-strict-send-100.json | 24 +
.../responses/002-paths-strict-send-10.json | 24 +
30 files changed, 1298 insertions(+), 429 deletions(-)
create mode 100644 route/chain_dependency_test.go
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/manifest.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/001-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/002-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/003-paths-strict-send-10.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/manifest.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/001-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/002-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/003-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/004-paths-strict-send-10.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/manifest.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/001-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/002-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/003-paths-strict-send-10.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/manifest.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/001-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/002-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/003-paths-strict-send-10.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/manifest.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/001-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/002-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/003-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/004-paths-strict-send-10.json
create mode 100644 testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/manifest.json
create mode 100644 testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/001-paths-strict-send-100.json
create mode 100644 testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/002-paths-strict-send-10.json
diff --git a/route/chain_dependency_test.go b/route/chain_dependency_test.go
new file mode 100644
index 0000000..832be87
--- /dev/null
+++ b/route/chain_dependency_test.go
@@ -0,0 +1,428 @@
+package route
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/shopspring/decimal"
+
+ "github.com/Wayfare-labs/wayfare/asset"
+ "github.com/Wayfare-labs/wayfare/dex"
+ "github.com/Wayfare-labs/wayfare/refrate"
+ "github.com/Wayfare-labs/wayfare/snapshot"
+)
+
+// The dependency-chain tests replay scenario fixtures through
+// snapshot.Replayer, like every other recorded test in this package. The
+// cases they pin — a dependency that is itself derivative, a cycle, a
+// dependency with no market at the tested size — do not occur on the
+// recorded mainnet set, so each fixture under testdata/chain-snapshots is
+// a scenario captured once from a synthetic upstream through the standard
+// snapshot.Recorder (see the note in each manifest). They live in their own
+// directory rather than testdata/snapshots so corridor tools that walk that
+// directory (cmd/hop-analysis, the recorded-integrity tests) keep seeing
+// exactly the three mainnet corridors. Replaying them keeps the same
+// guarantee as the real recordings: a request the fixture does not know
+// about, such as a regression that adds an extra Horizon call, fails loudly
+// with snapshot.ErrNotRecorded instead of passing silently.
+
+// chainSnap loads the scenario fixture for one dependency-chain case.
+func chainSnap(t *testing.T, prefix string) *snapshot.Manifest {
+ t.Helper()
+ matches, err := filepath.Glob(filepath.Join("..", "testdata", "chain-snapshots", prefix+"-*"))
+ if err != nil || len(matches) == 0 {
+ t.Fatalf("no chain fixture matching %q under testdata/chain-snapshots", prefix)
+ }
+ m, err := snapshot.Load(matches[0])
+ if err != nil {
+ t.Fatalf("loading chain fixture %s: %v", matches[0], err)
+ }
+ return m
+}
+
+// chainEngine builds an engine answering only from a chain scenario fixture,
+// with the reference mid pinned so the assertions are about the chain rather
+// than about whatever the rate provider says today.
+func chainEngine(m *snapshot.Manifest, pair, mid string) *Engine {
+ return &Engine{
+ DEX: &dex.Client{
+ HorizonURL: "https://horizon.stellar.org",
+ HTTPClient: m.HTTPClient(),
+ },
+ RefRate: refrate.NewStatic(map[string]decimal.Decimal{
+ pair: decimal.RequireFromString(mid),
+ }),
+ }
+}
+
+// TestChainMeasuredDirect verifies that when a derivative corridor's
+// dependency is measured, the chain carries the measured integrity.
+// USDC→GHSC depends on NGNC; USDC→NGNC has an XLM path (bridge asset),
+// so NGNC is DIRECT. The chain should be depth 1 with NGNC measured as DIRECT.
+func TestChainMeasuredDirect(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-direct-ngnc"), "USD/GHS", "11.7625")
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
+ t.Fatalf("Chain = %v, want exactly NGNC", res.Chain)
+ }
+ node := res.Chain[0]
+ if !node.Measured {
+ t.Error("NGNC should be measured")
+ }
+ if node.Integrity != IntegrityDirect {
+ t.Errorf("NGNC integrity = %s, want DIRECT", node.Integrity)
+ }
+ if len(node.Dependencies) != 0 {
+ t.Errorf("NGNC should have no sub-dependencies, got %v", node.Dependencies)
+ }
+
+ // The warning should use the measured variant.
+ warnings := strings.Join(res.Quotes[0].Warnings, " ")
+ if !strings.Contains(warnings, "DIRECT, independent market exists") {
+ t.Errorf("expected measured warning with market status, got: %v",
+ res.Quotes[0].Warnings)
+ }
+}
+
+// TestChainDepthTwo verifies recursive chain measurement through two levels.
+// USDC→GHSC depends on KESC, KESC depends on NGNC, NGNC is DIRECT (reached
+// via XLM, a bridge asset).
+func TestChainDepthTwo(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-depth-two"), "USD/GHS", "11.7625")
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ // Chain: GHSC depends on KESC (depth 2), KESC depends on NGNC (depth 1),
+ // NGNC is DIRECT (depth 0).
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "KESC" {
+ t.Fatalf("Chain top level = %v, want KESC", res.Chain)
+ }
+ kescNode := res.Chain[0]
+ if !kescNode.Measured {
+ t.Error("KESC should be measured")
+ }
+ if kescNode.Integrity != IntegrityDerivative {
+ t.Errorf("KESC integrity = %s, want DERIVATIVE", kescNode.Integrity)
+ }
+ if len(kescNode.Dependencies) != 1 || kescNode.Dependencies[0].Asset.Code != "NGNC" {
+ t.Fatalf("KESC dependencies = %v, want NGNC", kescNode.Dependencies)
+ }
+ ngncNode := kescNode.Dependencies[0]
+ if !ngncNode.Measured {
+ t.Error("NGNC should be measured")
+ }
+ if ngncNode.Integrity != IntegrityDirect {
+ t.Errorf("NGNC integrity = %s, want DIRECT", ngncNode.Integrity)
+ }
+
+ // All measured.
+ if !allMeasured(res.Chain) {
+ t.Error("all nodes should be measured in this chain")
+ }
+}
+
+// TestChainCycleTerminates verifies that a circular dependency does not
+// cause infinite recursion. When USDC→NGNC routes through GHSC while GHSC is
+// already on the path (it is the destination), the second encounter is
+// detected as a cycle and reported as unmeasured.
+func TestChainCycleTerminates(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-cycle"), "USD/GHS", "11.7625")
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ // The chain should have NGNC as the top-level dependency.
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
+ t.Fatalf("Chain top level = %v, want NGNC", res.Chain)
+ }
+ ngncNode := res.Chain[0]
+ if !ngncNode.Measured {
+ t.Error("NGNC should be measured (first encounter)")
+ }
+ if ngncNode.Integrity != IntegrityDerivative {
+ t.Errorf("NGNC integrity = %s, want DERIVATIVE", ngncNode.Integrity)
+ }
+
+ // NGNC depends on GHSC, but GHSC is already visited (it's the
+ // destination), so it should be reported as unmeasured with cycle reason.
+ if len(ngncNode.Dependencies) != 1 || ngncNode.Dependencies[0].Asset.Code != "GHSC" {
+ t.Fatalf("NGNC dependencies = %v, want GHSC", ngncNode.Dependencies)
+ }
+ ghscNode := ngncNode.Dependencies[0]
+ if ghscNode.Measured {
+ t.Error("GHSC should NOT be measured (cycle detected)")
+ }
+ if ghscNode.Reason != "cycle detected" {
+ t.Errorf("GHSC reason = %q, want 'cycle detected'", ghscNode.Reason)
+ }
+}
+
+// TestChainDependencyHasNoMarket verifies that a dependency whose own
+// market is NO-MARKET is reported honestly in the chain.
+func TestChainDependencyHasNoMarket(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-no-market"), "USD/GHS", "11.7625")
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "KESC" {
+ t.Fatalf("Chain = %v, want KESC", res.Chain)
+ }
+ kescNode := res.Chain[0]
+ if !kescNode.Measured {
+ t.Error("KESC should be measured")
+ }
+ if kescNode.Integrity != IntegrityNoMarket {
+ t.Errorf("KESC integrity = %s, want NO-MARKET", kescNode.Integrity)
+ }
+
+ // NO-MARKET is still a measurement, so the whole chain is measured.
+ if !allMeasured(res.Chain) {
+ t.Error("all nodes should be measured (NO-MARKET is still a measurement)")
+ }
+}
+
+// TestChainSharedDependencyIsNotACycle pins the visited-set rule: GHSC
+// depends on KESC and NGNC, and KESC also depends on NGNC. NGNC is a
+// sibling-shared sub-dependency — it appears on two branches of the tree —
+// which is not a cycle, and both top-level dependencies must come back
+// measured rather than the second one being mislabelled "cycle detected".
+func TestChainSharedDependencyIsNotACycle(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-shared"), "USD/GHS", "11.7625")
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDerivative {
+ t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
+ }
+
+ // Both dependencies must be measured, in code order.
+ if len(res.Chain) != 2 || res.Chain[0].Asset.Code != "KESC" || res.Chain[1].Asset.Code != "NGNC" {
+ t.Fatalf("Chain top level = %v, want [KESC NGNC]", res.Chain)
+ }
+ for _, n := range res.Chain {
+ if !n.Measured {
+ t.Errorf("%s should be measured, not %q", n.Asset.Code, n.Reason)
+ }
+ }
+
+ // KESC is derivative and its own dependency (NGNC) is measured.
+ kescNode := res.Chain[0]
+ if kescNode.Integrity != IntegrityDerivative {
+ t.Errorf("KESC integrity = %s, want DERIVATIVE", kescNode.Integrity)
+ }
+ if len(kescNode.Dependencies) != 1 || kescNode.Dependencies[0].Asset.Code != "NGNC" {
+ t.Fatalf("KESC dependencies = %v, want NGNC", kescNode.Dependencies)
+ }
+ if !kescNode.Dependencies[0].Measured {
+ t.Error("NGNC under KESC should be measured")
+ }
+
+ // NGNC appears again as a sibling top-level dependency, measured.
+ ngncNode := res.Chain[1]
+ if ngncNode.Integrity != IntegrityDirect {
+ t.Errorf("NGNC integrity = %s, want DIRECT", ngncNode.Integrity)
+ }
+
+ if !allMeasured(res.Chain) {
+ t.Error("every node should be measured; none of the shared dependencies is a cycle")
+ }
+}
+
+// TestChainWireShape verifies the JSON wire shape of the dependency chain.
+func TestChainWireShape(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-direct-ngnc"), "USD/GHS", "11.7625")
+ res, err := e.Quote(context.Background(), ghsRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ chain := ToDependencyChainJSON(res.Chain)
+ if chain == nil {
+ t.Fatal("chain should not be nil for a derivative corridor")
+ }
+ if chain.Depth != 1 {
+ t.Errorf("depth = %d, want 1", chain.Depth)
+ }
+ if len(chain.DependsOn) != 1 {
+ t.Fatalf("depends_on = %d nodes, want 1", len(chain.DependsOn))
+ }
+ node := chain.DependsOn[0]
+ if node.Code != "NGNC" {
+ t.Errorf("code = %s, want NGNC", node.Code)
+ }
+ if !node.Measured {
+ t.Error("measured should be true")
+ }
+ if node.Integrity != "DIRECT" {
+ t.Errorf("integrity = %s, want DIRECT", node.Integrity)
+ }
+ if node.Peg != "NGN" {
+ t.Errorf("peg = %s, want NGN", node.Peg)
+ }
+ if len(node.Dependencies) != 0 {
+ t.Errorf("sub-dependencies = %d, want 0", len(node.Dependencies))
+ }
+}
+
+// TestChainBackwardCompatible pins the compatibility contract on the
+// serialized document rather than on Go struct fields: a consumer that only
+// knows the flat depends_on array must still find it in the same JSON as a
+// chain-aware one finds dependency_chain.
+func TestChainBackwardCompatible(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ghsc-chain-direct-ngnc"), "USD/GHS", "11.7625")
+ lr, err := e.Ladder(context.Background(), LadderRequest{
+ SendAsset: asset.USDC(),
+ ReceiveAsset: asset.GHSC(),
+ Sizes: []decimal.Decimal{decimal.NewFromInt(100)},
+ ReferenceBase: "USD",
+ ReferenceQuote: "GHS",
+ })
+ if err != nil {
+ t.Fatalf("Ladder: %v", err)
+ }
+
+ raw, err := json.Marshal(ToCorridorJSON(lr, "USD/GHS"))
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var doc map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &doc); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ // The flat depends_on array is what a pre-chain consumer reads.
+ var flat []struct {
+ Code string `json:"code"`
+ }
+ if err := json.Unmarshal(doc["depends_on"], &flat); err != nil {
+ t.Fatalf("depends_on: %v", err)
+ }
+ if len(flat) != 1 || flat[0].Code != "NGNC" {
+ t.Errorf("depends_on = %v, want exactly NGNC", flat)
+ }
+
+ // The chain is additive: dependency_chain must be present alongside it.
+ chainRaw, ok := doc["dependency_chain"]
+ if !ok {
+ t.Fatal("serialized corridor is missing dependency_chain")
+ }
+ var chain struct {
+ Depth int `json:"depth"`
+ DependsOn []struct {
+ Code string `json:"code"`
+ Measured bool `json:"measured"`
+ } `json:"depends_on"`
+ }
+ if err := json.Unmarshal(chainRaw, &chain); err != nil {
+ t.Fatalf("dependency_chain: %v", err)
+ }
+ if chain.Depth != 1 {
+ t.Errorf("dependency_chain depth = %d, want 1", chain.Depth)
+ }
+ if len(chain.DependsOn) != 1 || chain.DependsOn[0].Code != "NGNC" ||
+ !chain.DependsOn[0].Measured {
+ t.Errorf("dependency_chain depends_on = %+v, want NGNC measured",
+ chain.DependsOn)
+ }
+}
+
+// TestLadderChainKeepsMeasuredOverUnmeasured pins the aggregation rule for
+// chains across rungs: the union across the ladder may not let one rung's
+// unmeasured placeholder erase another rung's measurement of the same
+// dependency.
+func TestLadderChainKeepsMeasuredOverUnmeasured(t *testing.T) {
+ measured := []DependencyNode{
+ {Asset: asset.NGNC(), Measured: true, Integrity: IntegrityDirect},
+ }
+ unmeasured := []DependencyNode{
+ {Asset: asset.NGNC(), Measured: false, Reason: "Horizon error: timeout"},
+ }
+
+ mk := func(first, second []DependencyNode) *LadderResult {
+ return &LadderResult{
+ Request: LadderRequest{
+ SendAsset: asset.USDC(),
+ ReceiveAsset: asset.GHSC(),
+ ReferenceBase: "USD",
+ ReferenceQuote: "GHS",
+ },
+ Rungs: []Rung{
+ {SendAmount: decimal.NewFromInt(1), Result: &Result{
+ Integrity: IntegrityDerivative,
+ DependsOn: []asset.Asset{asset.NGNC()},
+ Chain: first,
+ }},
+ {SendAmount: decimal.NewFromInt(10), Result: &Result{
+ Integrity: IntegrityDerivative,
+ DependsOn: []asset.Asset{asset.NGNC()},
+ Chain: second,
+ }},
+ },
+ }
+ }
+
+ // Unmeasured rung first, measured rung second: the measurement wins.
+ l := mk(unmeasured, measured)
+ l.summarise()
+ if len(l.Chain) != 1 || !l.Chain[0].Measured {
+ t.Fatalf("Chain = %v, want the measured NGNC to survive aggregation", l.Chain)
+ }
+ if got := l.Chain[0].Integrity; got != IntegrityDirect {
+ t.Errorf("NGNC integrity = %s, want DIRECT", got)
+ }
+
+ // Measured rung first, unmeasured second: the measurement is kept.
+ l = mk(measured, unmeasured)
+ l.summarise()
+ if len(l.Chain) != 1 || !l.Chain[0].Measured {
+ t.Fatalf("Chain = %v, want the measured NGNC to keep its place", l.Chain)
+ }
+}
+
+// TestDirectCorridorHasNoChain verifies that a direct corridor does not
+// produce a dependency chain.
+func TestDirectCorridorHasNoChain(t *testing.T) {
+ e := chainEngine(chainSnap(t, "usdc-ngnc-chain-direct"), "USD/NGN", "1500")
+ res, err := e.Quote(context.Background(), ngnRequest("100"))
+ if err != nil {
+ t.Fatalf("Quote: %v", err)
+ }
+
+ if res.Integrity != IntegrityDirect {
+ t.Errorf("Integrity = %s, want DIRECT", res.Integrity)
+ }
+ if res.Chain != nil {
+ t.Errorf("Chain = %v, want nil for direct corridor", res.Chain)
+ }
+}
diff --git a/route/ladder.go b/route/ladder.go
index 8fb12d7..7baaea7 100644
--- a/route/ladder.go
+++ b/route/ladder.go
@@ -93,8 +93,10 @@ type LadderResult struct {
// Chain is the full dependency tree when the corridor is derivative.
// It is the union across all rungs: if any rung discovered additional
- // dependencies, they appear here. Nil when the corridor is not
- // derivative.
+ // dependencies, they appear here, and when the same dependency was
+ // measured on some rungs but unmeasured on others the measured node
+ // wins — one rung's failed request never erases another's finding.
+ // Nil when the corridor is not derivative.
Chain []DependencyNode
ReferenceMid decimal.Decimal
@@ -371,7 +373,16 @@ func (l *LadderResult) summarise() {
deps[d.Code+":"+d.Issuer] = d
}
for _, c := range r.Result.Chain {
- chainMap[c.Asset.Code+":"+c.Asset.Issuer] = c
+ // A rung that failed to measure a dependency reports it
+ // unmeasured. That must never overwrite a real measurement
+ // from another rung: the union across rungs keeps the
+ // strongest evidence for each dependency, so a measured
+ // node only ever replaces an unmeasured placeholder.
+ key := c.Asset.Code + ":" + c.Asset.Issuer
+ existing, ok := chainMap[key]
+ if !ok || (c.Measured && !existing.Measured) {
+ chainMap[key] = c
+ }
}
case IntegrityNoMarket:
// leaves allNoMarket intact
diff --git a/route/route.go b/route/route.go
index 4c933c5..0c5b68a 100644
--- a/route/route.go
+++ b/route/route.go
@@ -560,6 +560,7 @@ func unknownHopNote(unknown []asset.Asset) []string {
"asset registry. An unrecognised hop is currently treated as having an "+
"independent market; see asset/known.go for the bounded false-negative.",
strings.Join(names, ", "))}
+}
// describeChainStatus renders the measured integrity of each dependency
// for a human-readable warning.
@@ -663,22 +664,25 @@ func classify(paths []dex.Path, dest asset.Asset) (Integrity, []asset.Asset, []a
// into any newly discovered fiat intermediaries — building a tree whose
// depth reflects how many layers of fiat-to-fiat routing exist.
//
-// The visited set prevents cycles: if A depends on B and B depends on A,
-// the second encounter stops recursion and reports the link as unmeasured.
-// The depth cap (maxDependencyDepth) prevents unbounded fan-out from a
-// corrupted registry.
+// ancestors names the assets already on the path from the corridor's
+// destination down to the node currently being expanded. Each dependency
+// branch works from its own copy, so a dependency shared between siblings is
+// measured once per branch rather than mislabelled as a cycle — only a node
+// already on the current root-to-leaf path is a true cycle, and the second
+// encounter of one reports the link as unmeasured. The depth cap
+// (maxDependencyDepth) prevents unbounded fan-out from a corrupted registry.
//
-// Each Horizon call is one StrictSendPaths round trip. The total cost per
-// call to measureChain is at most len(deps) × maxDependencyDepth, which
-// with the current registry (4 fiat tokens) and protocol cap (5 hops) is
-// at most 20 calls. In practice, corridors depend on 1-2 intermediaries,
-// so the cost is 1-2 extra Horizon calls per rung.
+// Each Horizon call is one StrictSendPaths round trip. Per node the cost is
+// bounded by len(deps) × maxDependencyDepth calls, and sharing a dependency
+// between branches re-measures it rather than looping; with the current
+// registry (4 fiat tokens) and protocol cap (5 hops) a corridor request
+// stays within a handful of extra calls.
func (e *Engine) measureChain(
ctx context.Context,
sendAsset asset.Asset,
sendAmount decimal.Decimal,
deps []asset.Asset,
- visited map[string]bool,
+ ancestors map[string]bool,
depth int,
) []DependencyNode {
if depth >= maxDependencyDepth {
@@ -696,7 +700,16 @@ func (e *Engine) measureChain(
nodes := make([]DependencyNode, 0, len(deps))
for _, dep := range deps {
key := dep.Code + ":" + dep.Issuer
- if visited[key] {
+
+ // Each branch copies the ancestor path before adding itself, so
+ // siblings never see each other's progress. The cycle check is
+ // therefore "is this dependency already on the path from the
+ // destination to here", which is the only true cycle.
+ path := make(map[string]bool, len(ancestors)+1)
+ for k := range ancestors {
+ path[k] = true
+ }
+ if path[key] {
nodes = append(nodes, DependencyNode{
Asset: dep,
Reason: "cycle detected",
@@ -704,8 +717,7 @@ func (e *Engine) measureChain(
})
continue
}
-
- visited[key] = true
+ path[key] = true
depPaths, err := e.DEX.StrictSendPaths(ctx, sendAsset, sendAmount, dep)
if err != nil {
@@ -726,7 +738,7 @@ func (e *Engine) measureChain(
if depIntegrity == IntegrityDerivative && len(depFiatHops) > 0 {
node.Dependencies = e.measureChain(
- ctx, sendAsset, sendAmount, depFiatHops, visited, depth+1)
+ ctx, sendAsset, sendAmount, depFiatHops, path, depth+1)
}
nodes = append(nodes, node)
diff --git a/route/route_test.go b/route/route_test.go
index ff1fe23..34560bf 100644
--- a/route/route_test.go
+++ b/route/route_test.go
@@ -764,417 +764,6 @@ func TestUnknownOnlyPathIsTheDocumentedFalseNegative(t *testing.T) {
}
}
-// ---------------------------------------------------------------------------
-// Dependency chain tests
-// ---------------------------------------------------------------------------
-
-// chainHorizonStub returns a server that dispatches based on the
-// destination_assets query parameter, allowing multi-asset chain tests.
-// Keys in the routes map should be asset codes (e.g. "NGNC"); the
-// handler matches on the code portion of "CODE:ISSUER" or plain "CODE".
-func chainHorizonStub(t *testing.T, routes map[string]string) *httptest.Server {
- t.Helper()
- return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- dest := r.URL.Query().Get("destination_assets")
- // Horizon sends "CODE:ISSUER" — extract just the code.
- code := dest
- if idx := strings.Index(dest, ":"); idx != -1 {
- code = dest[:idx]
- }
- body, ok := routes[code]
- if !ok {
- body = `{"_embedded":{"records":[]}}`
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(body))
- }))
-}
-
-// ghscDirectNGNCResponse is a modified fixture where NGNC is measured as
-// having an independent market (XLM path avoids fiat intermediaries).
-// This is the same as liveStrictSendResponse but for the USDC→NGNC pair,
-// meaning NGNC's integrity is DIRECT when measured.
-const ngncDirectResponse = liveStrictSendResponse
-
-// TestChainMeasuredDirect verifies that when a derivative corridor's
-// dependency is measured, the chain carries the measured integrity.
-// USDC→GHSC depends on NGNC; USDC→NGNC has an XLM path (bridge asset),
-// so NGNC is DIRECT. The chain should be depth 1 with NGNC measured as DIRECT.
-func TestChainMeasuredDirect(t *testing.T) {
- srv := chainHorizonStub(t, map[string]string{
- "GHSC": ghscViaNGNCResponse,
- "NGNC": ngncDirectResponse,
- })
- defer srv.Close()
-
- e := &Engine{
- DEX: &dex.Client{HorizonURL: srv.URL},
- RefRate: refrate.NewStatic(map[string]decimal.Decimal{
- "USD/GHS": decimal.RequireFromString("11.7625"),
- }),
- }
- res, err := e.Quote(context.Background(), ghsRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- if res.Integrity != IntegrityDerivative {
- t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
- }
-
- if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
- t.Fatalf("Chain = %v, want exactly NGNC", res.Chain)
- }
- node := res.Chain[0]
- if !node.Measured {
- t.Error("NGNC should be measured")
- }
- if node.Integrity != IntegrityDirect {
- t.Errorf("NGNC integrity = %s, want DIRECT", node.Integrity)
- }
- if len(node.Dependencies) != 0 {
- t.Errorf("NGNC should have no sub-dependencies, got %v", node.Dependencies)
- }
-
- // The warning should use the measured variant.
- warnings := strings.Join(res.Quotes[0].Warnings, " ")
- if !strings.Contains(warnings, "DIRECT, independent market exists") {
- t.Errorf("expected measured warning with market status, got: %v",
- res.Quotes[0].Warnings)
- }
-}
-
-// TestChainDepthTwo verifies recursive chain measurement through two levels.
-// USDC→TOKEN_C depends on TOKEN_B, TOKEN_B depends on TOKEN_A, TOKEN_A is
-// DIRECT (reached via XLM, a bridge asset).
-func TestChainDepthTwo(t *testing.T) {
- // USDC→GHSC depends on KESC, KESC depends on NGNC, NGNC is DIRECT.
-
- ghscViaKescResponse := `{
- "_embedded": {
- "records": [
- {
- "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
- "source_amount": "100.0000000",
- "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
- "destination_amount": "100.0000000",
- "path": [
- { "asset_type": "credit_alphanum4", "asset_code": "KESC",
- "asset_issuer": "` + asset.LinkIOIssuer + `" }
- ]
- }
- ]
- }
-}`
-
- kescViaNGNCResponse := `{
- "_embedded": {
- "records": [
- {
- "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
- "source_amount": "100.0000000",
- "destination_asset_type": "credit_alphanum4", "destination_asset_code": "KESC",
- "destination_amount": "100.0000000",
- "path": [
- { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
- "asset_issuer": "` + asset.LinkIOIssuer + `" }
- ]
- }
- ]
- }
-}`
-
- srv := chainHorizonStub(t, map[string]string{
- "GHSC": ghscViaKescResponse,
- "KESC": kescViaNGNCResponse,
- "NGNC": ngncDirectResponse,
- })
- defer srv.Close()
-
- e := &Engine{
- DEX: &dex.Client{HorizonURL: srv.URL},
- RefRate: refrate.NewStatic(map[string]decimal.Decimal{
- "USD/GHS": decimal.RequireFromString("11.7625"),
- }),
- }
- res, err := e.Quote(context.Background(), ghsRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- if res.Integrity != IntegrityDerivative {
- t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
- }
-
- // Chain: GHSC depends on KESC (depth 2), KESC depends on NGNC (depth 1),
- // NGNC is DIRECT (depth 0).
- if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "KESC" {
- t.Fatalf("Chain top level = %v, want KESC", res.Chain)
- }
- kescNode := res.Chain[0]
- if !kescNode.Measured {
- t.Error("KESC should be measured")
- }
- if kescNode.Integrity != IntegrityDerivative {
- t.Errorf("KESC integrity = %s, want DERIVATIVE", kescNode.Integrity)
- }
- if len(kescNode.Dependencies) != 1 || kescNode.Dependencies[0].Asset.Code != "NGNC" {
- t.Fatalf("KESC dependencies = %v, want NGNC", kescNode.Dependencies)
- }
- ngncNode := kescNode.Dependencies[0]
- if !ngncNode.Measured {
- t.Error("NGNC should be measured")
- }
- if ngncNode.Integrity != IntegrityDirect {
- t.Errorf("NGNC integrity = %s, want DIRECT", ngncNode.Integrity)
- }
-
- // All measured.
- if !allMeasured(res.Chain) {
- t.Error("all nodes should be measured in this chain")
- }
-}
-
-// TestChainCycleTerminates verifies that a circular dependency does not
-// cause infinite recursion. When USDC→A routes through B and USDC→B
-// routes through A, the second encounter is detected as a cycle and
-// reported as unmeasured.
-func TestChainCycleTerminates(t *testing.T) {
- // We can't easily create new fiat tokens, so we simulate the cycle
- // by using the actual fiat tokens in a way that creates mutual
- // dependency. But the registry is fixed. Instead, we test the
- // measureChain logic directly with a mock that creates a cycle
- // between NGNC and GHSC by returning GHSC paths through NGNC and
- // NGNC paths through GHSC.
- //
- // Note: in reality, USDC→NGNC does NOT go through GHSC (NGNC is
- // direct). But we can force the cycle by returning a custom response
- // for NGNC that routes through GHSC.
-
- ngncViaGHSCResponse := `{
- "_embedded": {
- "records": [
- {
- "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
- "source_amount": "100.0000000",
- "destination_asset_type": "credit_alphanum4", "destination_asset_code": "NGNC",
- "destination_amount": "100.0000000",
- "path": [
- { "asset_type": "credit_alphanum4", "asset_code": "GHSC",
- "asset_issuer": "` + asset.LinkIOIssuer + `" }
- ]
- }
- ]
- }
-}`
-
- srv := chainHorizonStub(t, map[string]string{
- "GHSC": ghscViaNGNCResponse,
- "NGNC": ngncViaGHSCResponse,
- })
- defer srv.Close()
-
- e := &Engine{
- DEX: &dex.Client{HorizonURL: srv.URL},
- RefRate: refrate.NewStatic(map[string]decimal.Decimal{
- "USD/GHS": decimal.RequireFromString("11.7625"),
- }),
- }
- res, err := e.Quote(context.Background(), ghsRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- if res.Integrity != IntegrityDerivative {
- t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
- }
-
- // The chain should have NGNC as the top-level dependency.
- if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
- t.Fatalf("Chain top level = %v, want NGNC", res.Chain)
- }
- ngncNode := res.Chain[0]
- if !ngncNode.Measured {
- t.Error("NGNC should be measured (first encounter)")
- }
- if ngncNode.Integrity != IntegrityDerivative {
- t.Errorf("NGNC integrity = %s, want DERIVATIVE", ngncNode.Integrity)
- }
-
- // NGNC depends on GHSC, but GHSC is already visited (it's the
- // destination), so it should be reported as unmeasured with cycle reason.
- if len(ngncNode.Dependencies) != 1 || ngncNode.Dependencies[0].Asset.Code != "GHSC" {
- t.Fatalf("NGNC dependencies = %v, want GHSC", ngncNode.Dependencies)
- }
- ghscNode := ngncNode.Dependencies[0]
- if ghscNode.Measured {
- t.Error("GHSC should NOT be measured (cycle detected)")
- }
- if ghscNode.Reason != "cycle detected" {
- t.Errorf("GHSC reason = %q, want 'cycle detected'", ghscNode.Reason)
- }
-}
-
-// TestChainDependencyHasNoMarket verifies that a dependency whose own
-// market is NO-MARKET is reported honestly in the chain.
-func TestChainDependencyHasNoMarket(t *testing.T) {
- // USDC→GHSC depends on KESC, and USDC→KESC has no paths.
- ghscViaKescResponse := `{
- "_embedded": {
- "records": [
- {
- "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
- "source_amount": "100.0000000",
- "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
- "destination_amount": "100.0000000",
- "path": [
- { "asset_type": "credit_alphanum4", "asset_code": "KESC",
- "asset_issuer": "` + asset.LinkIOIssuer + `" }
- ]
- }
- ]
- }
-}`
-
- srv := chainHorizonStub(t, map[string]string{
- "GHSC": ghscViaKescResponse,
- "KESC": kescEmptyResponse,
- })
- defer srv.Close()
-
- e := &Engine{
- DEX: &dex.Client{HorizonURL: srv.URL},
- RefRate: refrate.NewStatic(map[string]decimal.Decimal{
- "USD/GHS": decimal.RequireFromString("11.7625"),
- }),
- }
- res, err := e.Quote(context.Background(), ghsRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- if res.Integrity != IntegrityDerivative {
- t.Fatalf("Integrity = %s, want DERIVATIVE", res.Integrity)
- }
-
- if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "KESC" {
- t.Fatalf("Chain = %v, want KESC", res.Chain)
- }
- kescNode := res.Chain[0]
- if !kescNode.Measured {
- t.Error("KESC should be measured")
- }
- if kescNode.Integrity != IntegrityNoMarket {
- t.Errorf("KESC integrity = %s, want NO-MARKET", kescNode.Integrity)
- }
-
- // Since not all nodes are measured cleanly (NO-MARKET is measured but
- // the warning text differs), check the warning uses the unmeasured path.
- // Actually NO-MARKET is measured — the node is Measured=true. The
- // allMeasured check passes. The describeChainStatus renders it as
- // "KESC (NO-MARKET)".
- if !allMeasured(res.Chain) {
- t.Error("all nodes should be measured (NO-MARKET is still a measurement)")
- }
-}
-
-// TestChainWireShape verifies the JSON wire shape of the dependency chain.
-func TestChainWireShape(t *testing.T) {
- srv := chainHorizonStub(t, map[string]string{
- "GHSC": ghscViaNGNCResponse,
- "NGNC": ngncDirectResponse,
- })
- defer srv.Close()
-
- e := &Engine{
- DEX: &dex.Client{HorizonURL: srv.URL},
- RefRate: refrate.NewStatic(map[string]decimal.Decimal{
- "USD/GHS": decimal.RequireFromString("11.7625"),
- }),
- }
- res, err := e.Quote(context.Background(), ghsRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- chain := ToDependencyChainJSON(res.Chain)
- if chain == nil {
- t.Fatal("chain should not be nil for a derivative corridor")
- }
- if chain.Depth != 1 {
- t.Errorf("depth = %d, want 1", chain.Depth)
- }
- if len(chain.DependsOn) != 1 {
- t.Fatalf("depends_on = %d nodes, want 1", len(chain.DependsOn))
- }
- node := chain.DependsOn[0]
- if node.Code != "NGNC" {
- t.Errorf("code = %s, want NGNC", node.Code)
- }
- if !node.Measured {
- t.Error("measured should be true")
- }
- if node.Integrity != "DIRECT" {
- t.Errorf("integrity = %s, want DIRECT", node.Integrity)
- }
- if node.Peg != "NGN" {
- t.Errorf("peg = %s, want NGN", node.Peg)
- }
- if len(node.Dependencies) != 0 {
- t.Errorf("sub-dependencies = %d, want 0", len(node.Dependencies))
- }
-}
-
-// TestChainBackwardCompatible verifies that the flat depends_on array is
-// still present alongside the new dependency_chain on the wire.
-func TestChainBackwardCompatible(t *testing.T) {
- srv := chainHorizonStub(t, map[string]string{
- "GHSC": ghscViaNGNCResponse,
- "NGNC": ngncDirectResponse,
- })
- defer srv.Close()
-
- e := &Engine{
- DEX: &dex.Client{HorizonURL: srv.URL},
- RefRate: refrate.NewStatic(map[string]decimal.Decimal{
- "USD/GHS": decimal.RequireFromString("11.7625"),
- }),
- }
- res, err := e.Quote(context.Background(), ghsRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- // Simulate what a consumer sees: the JSON must have both depends_on
- // and dependency_chain.
- if len(res.DependsOn) != 1 || res.DependsOn[0].Code != "NGNC" {
- t.Errorf("DependsOn = %v, want NGNC", res.DependsOn)
- }
- if len(res.Chain) != 1 || res.Chain[0].Asset.Code != "NGNC" {
- t.Errorf("Chain = %v, want NGNC", res.Chain)
- }
-}
-
-// TestDirectCorridorHasNoChain verifies that a direct corridor does not
-// produce a dependency chain.
-func TestDirectCorridorHasNoChain(t *testing.T) {
- srv := horizonStub(t, liveStrictSendResponse)
- defer srv.Close()
-
- e := &Engine{DEX: &dex.Client{HorizonURL: srv.URL}, RefRate: usdToNGN("1500")}
- res, err := e.Quote(context.Background(), ngnRequest("100"))
- if err != nil {
- t.Fatalf("Quote: %v", err)
- }
-
- if res.Integrity != IntegrityDirect {
- t.Errorf("Integrity = %s, want DIRECT", res.Integrity)
- }
- if res.Chain != nil {
- t.Errorf("Chain = %v, want nil for direct corridor", res.Chain)
- }
-}
-
// TestAllMeasuredAndChainDepth are unit tests for the helper functions.
func TestAllMeasuredAndChainDepth(t *testing.T) {
t.Run("all measured", func(t *testing.T) {
diff --git a/server/index.html b/server/index.html
index ee27df2..21cc23c 100644
--- a/server/index.html
+++ b/server/index.html
@@ -204,7 +204,6 @@
display: block; text-align: left; font-weight: 600; font-size: .95rem;
border-bottom: 1px solid var(--grid); padding-bottom: .45rem; margin-bottom: .1rem;
}
- .scroll table td:first-child::before { display: none; }
.legend-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.finding-row { flex-wrap: wrap; gap: .4rem; }
.f-state { min-width: auto; }
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/manifest.json b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/manifest.json
new file mode 100644
index 0000000..2a3c2f8
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/manifest.json
@@ -0,0 +1,66 @@
+{
+ "format": "wayfare.snapshot",
+ "version": 1,
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "corridor": {
+ "send": {
+ "code": "USDC",
+ "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
+ },
+ "receive": {
+ "code": "GHSC",
+ "issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"
+ },
+ "reference_pair": "USD/GHS"
+ },
+ "sizes": [
+ "100"
+ ],
+ "sources": {
+ "horizon": {
+ "provider": "synthetic",
+ "base_url": "https://horizon.stellar.org"
+ },
+ "reference": {
+ "base_url": "static"
+ }
+ },
+ "notes": [
+ "scenario fixture: captured from a synthetic upstream for the dependency-chain cases that are not present on the recorded mainnet set; see route/chain_dependency_test.go"
+ ],
+ "interactions": [
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/001-paths-strict-send-100.json",
+ "body_sha256": "sha256:e9d6aff68a73de0081c653812eab6962f6195212b263f0a8069586b9be5df85a"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/002-paths-strict-send-100.json",
+ "body_sha256": "sha256:864c1e7a0e342a5942f099a3f1a7c0713712b0c202b0e1498779e48de46a6a41"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/003-paths-strict-send-10.json",
+ "body_sha256": "sha256:e9d6aff68a73de0081c653812eab6962f6195212b263f0a8069586b9be5df85a"
+ }
+ ]
+}
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/001-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/001-paths-strict-send-100.json
new file mode 100644
index 0000000..afc01f8
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/001-paths-strict-send-100.json
@@ -0,0 +1,31 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "155.5600000",
+ "path": [
+ { "asset_type": "native" },
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "150.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/002-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/002-paths-strict-send-100.json
new file mode 100644
index 0000000..f24e393
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/002-paths-strict-send-100.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "GHSC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/003-paths-strict-send-10.json b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/003-paths-strict-send-10.json
new file mode 100644
index 0000000..afc01f8
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-cycle-20260902T123516Z/responses/003-paths-strict-send-10.json
@@ -0,0 +1,31 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "155.5600000",
+ "path": [
+ { "asset_type": "native" },
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "150.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/manifest.json b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/manifest.json
new file mode 100644
index 0000000..a9da5b0
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/manifest.json
@@ -0,0 +1,77 @@
+{
+ "format": "wayfare.snapshot",
+ "version": 1,
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "corridor": {
+ "send": {
+ "code": "USDC",
+ "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
+ },
+ "receive": {
+ "code": "GHSC",
+ "issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"
+ },
+ "reference_pair": "USD/GHS"
+ },
+ "sizes": [
+ "100"
+ ],
+ "sources": {
+ "horizon": {
+ "provider": "synthetic",
+ "base_url": "https://horizon.stellar.org"
+ },
+ "reference": {
+ "base_url": "static"
+ }
+ },
+ "notes": [
+ "scenario fixture: captured from a synthetic upstream for the dependency-chain cases that are not present on the recorded mainnet set; see route/chain_dependency_test.go"
+ ],
+ "interactions": [
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/001-paths-strict-send-100.json",
+ "body_sha256": "sha256:b4a7e5eeafaf8102970383555e59252891d9fb5efe639a2123863f191bcb407f"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=KESC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=KESC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/002-paths-strict-send-100.json",
+ "body_sha256": "sha256:2a4336bba6b72283995b99ad2fbf15da43d668cc5b79abeb8518a22b9b8f353a"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/003-paths-strict-send-100.json",
+ "body_sha256": "sha256:12170ad5459b6c1d59ff4c63883df804caaa8291b0abf2a592f1336fb5b28311"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/004-paths-strict-send-10.json",
+ "body_sha256": "sha256:b4a7e5eeafaf8102970383555e59252891d9fb5efe639a2123863f191bcb407f"
+ }
+ ]
+}
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/001-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/001-paths-strict-send-100.json
new file mode 100644
index 0000000..b87dd72
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/001-paths-strict-send-100.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/002-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/002-paths-strict-send-100.json
new file mode 100644
index 0000000..aa6ab84
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/002-paths-strict-send-100.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "KESC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/003-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/003-paths-strict-send-100.json
new file mode 100644
index 0000000..57ccf15
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/003-paths-strict-send-100.json
@@ -0,0 +1,24 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "65100.1379550",
+ "path": [ { "asset_type": "native" } ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "21785.7821141",
+ "path": []
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/004-paths-strict-send-10.json b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/004-paths-strict-send-10.json
new file mode 100644
index 0000000..b87dd72
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-depth-two-20260902T123516Z/responses/004-paths-strict-send-10.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/manifest.json b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/manifest.json
new file mode 100644
index 0000000..25d5b02
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/manifest.json
@@ -0,0 +1,66 @@
+{
+ "format": "wayfare.snapshot",
+ "version": 1,
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "corridor": {
+ "send": {
+ "code": "USDC",
+ "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
+ },
+ "receive": {
+ "code": "GHSC",
+ "issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"
+ },
+ "reference_pair": "USD/GHS"
+ },
+ "sizes": [
+ "100"
+ ],
+ "sources": {
+ "horizon": {
+ "provider": "synthetic",
+ "base_url": "https://horizon.stellar.org"
+ },
+ "reference": {
+ "base_url": "static"
+ }
+ },
+ "notes": [
+ "scenario fixture: captured from a synthetic upstream for the dependency-chain cases that are not present on the recorded mainnet set; see route/chain_dependency_test.go"
+ ],
+ "interactions": [
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/001-paths-strict-send-100.json",
+ "body_sha256": "sha256:e9d6aff68a73de0081c653812eab6962f6195212b263f0a8069586b9be5df85a"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/002-paths-strict-send-100.json",
+ "body_sha256": "sha256:12170ad5459b6c1d59ff4c63883df804caaa8291b0abf2a592f1336fb5b28311"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/003-paths-strict-send-10.json",
+ "body_sha256": "sha256:e9d6aff68a73de0081c653812eab6962f6195212b263f0a8069586b9be5df85a"
+ }
+ ]
+}
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/001-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/001-paths-strict-send-100.json
new file mode 100644
index 0000000..afc01f8
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/001-paths-strict-send-100.json
@@ -0,0 +1,31 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "155.5600000",
+ "path": [
+ { "asset_type": "native" },
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "150.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/002-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/002-paths-strict-send-100.json
new file mode 100644
index 0000000..57ccf15
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/002-paths-strict-send-100.json
@@ -0,0 +1,24 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "65100.1379550",
+ "path": [ { "asset_type": "native" } ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "21785.7821141",
+ "path": []
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/003-paths-strict-send-10.json b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/003-paths-strict-send-10.json
new file mode 100644
index 0000000..afc01f8
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-direct-ngnc-20260902T123516Z/responses/003-paths-strict-send-10.json
@@ -0,0 +1,31 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "155.5600000",
+ "path": [
+ { "asset_type": "native" },
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "150.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/manifest.json b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/manifest.json
new file mode 100644
index 0000000..c1a661a
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/manifest.json
@@ -0,0 +1,66 @@
+{
+ "format": "wayfare.snapshot",
+ "version": 1,
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "corridor": {
+ "send": {
+ "code": "USDC",
+ "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
+ },
+ "receive": {
+ "code": "GHSC",
+ "issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"
+ },
+ "reference_pair": "USD/GHS"
+ },
+ "sizes": [
+ "100"
+ ],
+ "sources": {
+ "horizon": {
+ "provider": "synthetic",
+ "base_url": "https://horizon.stellar.org"
+ },
+ "reference": {
+ "base_url": "static"
+ }
+ },
+ "notes": [
+ "scenario fixture: captured from a synthetic upstream for the dependency-chain cases that are not present on the recorded mainnet set; see route/chain_dependency_test.go"
+ ],
+ "interactions": [
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/001-paths-strict-send-100.json",
+ "body_sha256": "sha256:b4a7e5eeafaf8102970383555e59252891d9fb5efe639a2123863f191bcb407f"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=KESC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=KESC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/002-paths-strict-send-100.json",
+ "body_sha256": "sha256:cb8391a385f66a1a9eecc2ab0110eccf561aa565c48f8c709b897fc1e4fe230d"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/003-paths-strict-send-10.json",
+ "body_sha256": "sha256:b4a7e5eeafaf8102970383555e59252891d9fb5efe639a2123863f191bcb407f"
+ }
+ ]
+}
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/001-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/001-paths-strict-send-100.json
new file mode 100644
index 0000000..b87dd72
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/001-paths-strict-send-100.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/002-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/002-paths-strict-send-100.json
new file mode 100644
index 0000000..b0f2e8b
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/002-paths-strict-send-100.json
@@ -0,0 +1 @@
+{"_embedded":{"records":[]}}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/003-paths-strict-send-10.json b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/003-paths-strict-send-10.json
new file mode 100644
index 0000000..b87dd72
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-no-market-20260902T123516Z/responses/003-paths-strict-send-10.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/manifest.json b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/manifest.json
new file mode 100644
index 0000000..da9a1c0
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/manifest.json
@@ -0,0 +1,77 @@
+{
+ "format": "wayfare.snapshot",
+ "version": 1,
+ "recorded_at": "2026-09-02T12:38:45Z",
+ "corridor": {
+ "send": {
+ "code": "USDC",
+ "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
+ },
+ "receive": {
+ "code": "GHSC",
+ "issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"
+ },
+ "reference_pair": "USD/GHS"
+ },
+ "sizes": [
+ "100"
+ ],
+ "sources": {
+ "horizon": {
+ "provider": "synthetic",
+ "base_url": "https://horizon.stellar.org"
+ },
+ "reference": {
+ "base_url": "static"
+ }
+ },
+ "notes": [
+ "scenario fixture: captured from a synthetic upstream for the dependency-chain cases that are not present on the recorded mainnet set; see route/chain_dependency_test.go"
+ ],
+ "interactions": [
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:38:45Z",
+ "body_file": "responses/001-paths-strict-send-100.json",
+ "body_sha256": "sha256:280a05e5442b2d3931527154e89fe18f32482353bea32ceda6dd2a761d535fde"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=KESC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=KESC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:38:45Z",
+ "body_file": "responses/002-paths-strict-send-100.json",
+ "body_sha256": "sha256:2a4336bba6b72283995b99ad2fbf15da43d668cc5b79abeb8518a22b9b8f353a"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:38:45Z",
+ "body_file": "responses/003-paths-strict-send-100.json",
+ "body_sha256": "sha256:12170ad5459b6c1d59ff4c63883df804caaa8291b0abf2a592f1336fb5b28311"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=GHSC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:38:45Z",
+ "body_file": "responses/004-paths-strict-send-10.json",
+ "body_sha256": "sha256:280a05e5442b2d3931527154e89fe18f32482353bea32ceda6dd2a761d535fde"
+ }
+ ]
+}
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/001-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/001-paths-strict-send-100.json
new file mode 100644
index 0000000..7ad637a
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/001-paths-strict-send-100.json
@@ -0,0 +1,26 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
+ "destination_amount": "90.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/002-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/002-paths-strict-send-100.json
new file mode 100644
index 0000000..aa6ab84
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/002-paths-strict-send-100.json
@@ -0,0 +1,18 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "KESC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/003-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/003-paths-strict-send-100.json
new file mode 100644
index 0000000..57ccf15
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/003-paths-strict-send-100.json
@@ -0,0 +1,24 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "65100.1379550",
+ "path": [ { "asset_type": "native" } ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "21785.7821141",
+ "path": []
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/004-paths-strict-send-10.json b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/004-paths-strict-send-10.json
new file mode 100644
index 0000000..7ad637a
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ghsc-chain-shared-20260902T123845Z/responses/004-paths-strict-send-10.json
@@ -0,0 +1,26 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
+ "destination_amount": "100.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "KESC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4", "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4", "destination_asset_code": "GHSC",
+ "destination_amount": "90.0000000",
+ "path": [
+ { "asset_type": "credit_alphanum4", "asset_code": "NGNC",
+ "asset_issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6" }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/manifest.json b/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/manifest.json
new file mode 100644
index 0000000..490a996
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/manifest.json
@@ -0,0 +1,55 @@
+{
+ "format": "wayfare.snapshot",
+ "version": 1,
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "corridor": {
+ "send": {
+ "code": "USDC",
+ "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
+ },
+ "receive": {
+ "code": "NGNC",
+ "issuer": "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"
+ },
+ "reference_pair": "USD/NGN"
+ },
+ "sizes": [
+ "100"
+ ],
+ "sources": {
+ "horizon": {
+ "provider": "synthetic",
+ "base_url": "https://horizon.stellar.org"
+ },
+ "reference": {
+ "base_url": "static"
+ }
+ },
+ "notes": [
+ "scenario fixture: captured from a synthetic upstream for the dependency-chain cases that are not present on the recorded mainnet set; see route/chain_dependency_test.go"
+ ],
+ "interactions": [
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=100\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/001-paths-strict-send-100.json",
+ "body_sha256": "sha256:12170ad5459b6c1d59ff4c63883df804caaa8291b0abf2a592f1336fb5b28311"
+ },
+ {
+ "kind": "horizon",
+ "method": "GET",
+ "key": "GET /paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=NGNC%3AGASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6\u0026source_amount=10\u0026source_asset_code=USDC\u0026source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN\u0026source_asset_type=credit_alphanum4",
+ "status": 200,
+ "content_type": "application/json",
+ "recorded_at": "2026-09-02T12:35:16Z",
+ "body_file": "responses/002-paths-strict-send-10.json",
+ "body_sha256": "sha256:12170ad5459b6c1d59ff4c63883df804caaa8291b0abf2a592f1336fb5b28311"
+ }
+ ]
+}
diff --git a/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/001-paths-strict-send-100.json b/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/001-paths-strict-send-100.json
new file mode 100644
index 0000000..57ccf15
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/001-paths-strict-send-100.json
@@ -0,0 +1,24 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "65100.1379550",
+ "path": [ { "asset_type": "native" } ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "21785.7821141",
+ "path": []
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/002-paths-strict-send-10.json b/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/002-paths-strict-send-10.json
new file mode 100644
index 0000000..57ccf15
--- /dev/null
+++ b/testdata/chain-snapshots/usdc-ngnc-chain-direct-20260902T123516Z/responses/002-paths-strict-send-10.json
@@ -0,0 +1,24 @@
+{
+ "_embedded": {
+ "records": [
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "65100.1379550",
+ "path": [ { "asset_type": "native" } ]
+ },
+ {
+ "source_asset_type": "credit_alphanum4",
+ "source_asset_code": "USDC",
+ "source_amount": "100.0000000",
+ "destination_asset_type": "credit_alphanum4",
+ "destination_asset_code": "NGNC",
+ "destination_amount": "21785.7821141",
+ "path": []
+ }
+ ]
+ }
+}
\ No newline at end of file