diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9494abb..a25678b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: - uses: actions/setup-go@v6 with: + go-version: "1.22" go-version: ${{ env.GO_VERSION }} cache: true diff --git a/route/cost.go b/route/cost.go index eafe06f..cf5dcbc 100644 --- a/route/cost.go +++ b/route/cost.go @@ -1,12 +1,17 @@ // CostDecomposition breaks the effective transfer cost into separately-reported -// components: FX loss, fees, slippage, and expected failure cost. +// components: FX loss, network fees, anchor fee, slippage, and expected +// failure cost. // // Currently, the verdict reports a single loss percentage against fair value. // That number is useful but opaque. Showing the decomposition turns a single // verdict into actionable information. // -// Each component is computed and reported independently. Expected failure cost -// stays explicitly unknown until failure history exists. +// Each component is computed and reported independently. Network fees and +// anchor fees are reported separately because they have different sources: +// network fees are Stellar base-fee charges per operation, while anchor fees +// are the anchor's own charge for a conversion, obtainable via SEP-38 when +// the anchor publishes an ANCHOR_QUOTE_SERVER. Expected failure cost stays +// explicitly unknown until failure history exists. package route import ( @@ -18,7 +23,8 @@ type CostComponent string const ( CostFXLoss CostComponent = "fx_loss" - CostFees CostComponent = "fees" + CostNetworkFees CostComponent = "network_fees" + CostAnchorFee CostComponent = "anchor_fee" CostSlippage CostComponent = "slippage" CostExpectedFailure CostComponent = "expected_failure" ) @@ -40,7 +46,8 @@ type CostDecomposition struct { // Decompose splits a priced route's effective transfer cost into components. func Decompose(q Quote, mid decimal.Decimal) CostDecomposition { - parts := make([]CostPart, 0, 4) + _ = mid // reserved: will carry the independent mid-market rate for future FX-loss-from-mid calculation + parts := make([]CostPart, 0, 5) // FX loss: difference between effective rate and mid, as a percentage. fxLossPct := q.LossPct @@ -52,20 +59,43 @@ func Decompose(q Quote, mid decimal.Decimal) CostDecomposition { Determined: true, }) - // Fees: undetermined. A Stellar path payment charges a base fee per + // Network fees: undetermined. A Stellar path payment charges a base fee per // operation, and a multi-hop path is more operations than a direct one, // but Decompose sees only a Quote and has neither the path's operation // count nor a currently-effective base fee. Naming the gap keeps the // units honest: unavailable is unknown, not a default, and small is not // zero. parts = append(parts, CostPart{ - Component: CostFees, + Component: CostNetworkFees, Amount: decimal.Zero, Pct: decimal.Zero, Determined: false, Reason: "network fee not measured; determining it requires the path's operation count and the current Stellar base fee", }) + // Anchor fee: the anchor's own charge for a conversion, obtainable via + // SEP-38 when the anchor publishes an ANCHOR_QUOTE_SERVER in its + // stellar.toml. A DEX-only route has no anchor involved, and an anchor + // that does not publish a quote server has no machine-readable rate — the + // absence is a fact about the anchor rather than a zero fee. + // + // When a KindAnchorSEP38 quote is available, its FeeInBuyAsset (already + // normalised for denomination by sep38.Quote) can be wired here. + anchorFeeReason := "anchor fee not available; the anchor does not publish an ANCHOR_QUOTE_SERVER, so its fee cannot be obtained programmatically" + if q.Kind == KindAnchorSEP38 { + // TODO: extract anchor fee from a sep38.Quote when a corridor with + // SEP-38 support is wired in. The sep38.Quote.FeeInBuyAsset field + // already carries the converted fee. + anchorFeeReason = "anchor fee available via SEP-38 but no corridor with a published quote server has been priced yet" + } + parts = append(parts, CostPart{ + Component: CostAnchorFee, + Amount: decimal.Zero, + Pct: decimal.Zero, + Determined: false, + Reason: anchorFeeReason, + }) + // Slippage: undetermined without a comparison across sizes. parts = append(parts, CostPart{ Component: CostSlippage, diff --git a/route/cost_test.go b/route/cost_test.go index 927092f..f331081 100644 --- a/route/cost_test.go +++ b/route/cost_test.go @@ -36,15 +36,15 @@ func TestCostDecomposeSplitsCorrectly(t *testing.T) { if d.TotalLossPct.StringFixed(2) != "24.80" { t.Errorf("TotalLossPct = %s, want 24.80", d.TotalLossPct) } - if len(d.Parts) != 4 { - t.Fatalf("expected 4 cost parts, got %d", len(d.Parts)) + if len(d.Parts) != 5 { + t.Fatalf("expected 5 cost parts, got %d", len(d.Parts)) } seen := map[CostComponent]bool{} for _, p := range d.Parts { seen[p.Component] = true } - for _, comp := range []CostComponent{CostFXLoss, CostFees, CostSlippage, CostExpectedFailure} { + for _, comp := range []CostComponent{CostFXLoss, CostNetworkFees, CostAnchorFee, CostSlippage, CostExpectedFailure} { if !seen[comp] { t.Errorf("missing cost component: %s", comp) } @@ -58,18 +58,29 @@ func TestCostDecomposeSplitsCorrectly(t *testing.T) { t.Error("FX loss should be determined") } - fees := d.Parts[1] - if fees.Component != CostFees { - t.Errorf("second component = %s, want fees", fees.Component) + netFees := d.Parts[1] + if netFees.Component != CostNetworkFees { + t.Errorf("second component = %s, want network_fees", netFees.Component) } - if fees.Determined { - t.Error("fees must be undetermined when the network fee and operation count are not known") + if netFees.Determined { + t.Error("network_fees must be undetermined when the network fee and operation count are not known") } - if fees.Reason == "" { - t.Error("undetermined fees must carry a reason") + if netFees.Reason == "" { + t.Error("undetermined network_fees must carry a reason") } - slippage := d.Parts[2] + aFee := d.Parts[2] + if aFee.Component != CostAnchorFee { + t.Errorf("third component = %s, want anchor_fee", aFee.Component) + } + if aFee.Determined { + t.Error("anchor_fee must be undetermined when the anchor does not publish a quote server") + } + if aFee.Reason == "" { + t.Error("undetermined anchor_fee must carry a reason") + } + + slippage := d.Parts[3] if slippage.Determined { t.Error("slippage should be undetermined without a size comparison") } @@ -77,7 +88,7 @@ func TestCostDecomposeSplitsCorrectly(t *testing.T) { t.Error("undetermined slippage must carry a reason") } - failCost := d.Parts[3] + failCost := d.Parts[4] if failCost.Determined { t.Error("expected failure cost must be undetermined") } @@ -184,7 +195,7 @@ func TestLadderAttachesDecompositionToPricedRungs(t *testing.T) { for _, p := range priced.Decomposition.Parts { seen[p.Component] = true } - for _, comp := range []CostComponent{CostFXLoss, CostFees, CostSlippage, CostExpectedFailure} { + for _, comp := range []CostComponent{CostFXLoss, CostNetworkFees, CostAnchorFee, CostSlippage, CostExpectedFailure} { if !seen[comp] { t.Errorf("priced rung decomposition missing component %s", comp) } @@ -247,8 +258,8 @@ func TestCostBlockJSONShape(t *testing.T) { if total != "4.46" { t.Errorf("total_loss_pct = %q, want the decimal string \"4.46\"", total) } - if len(parts) != 4 { - t.Fatalf("cost block carries %d parts, want 4", len(parts)) + if len(parts) != 5 { + t.Fatalf("cost block carries %d parts, want 5", len(parts)) } // Every part must carry component and determined. @@ -261,6 +272,16 @@ func TestCostBlockJSONShape(t *testing.T) { } } + // Only fx_loss is determined — it is computed from the observed effective + // rate against mid. The other four components have no observation or + // computation behind them, so each must carry a reason and no number: + // network_fees, anchor_fee, slippage and expected failure are unknown, + // never zero. Fees in particular used to be reported as a determined + // zero; #96 was filed against exactly that, and Decompose now reports + // it undetermined as network_fees. Anchor fees are a separate component + // from network fees (#169): an anchor's own charge is obtainable via + // SEP-38 when one publishes ANCHOR_QUOTE_SERVER; the absence is a fact + // about the anchor. // The determined component carries amount and pct as strings; the // undetermined ones carry none, only a reason. if got := componentOf(t, parts[0]); got != string(CostFXLoss) { @@ -268,7 +289,7 @@ func TestCostBlockJSONShape(t *testing.T) { } assertDeterminedDecimalStrings(t, parts[0], "fx_loss") - for _, idx := range []int{1, 2, 3} { + for _, idx := range []int{1, 2, 3, 4} { p := parts[idx] if got := componentOf(t, p); got == string(CostFXLoss) { t.Fatalf("parts[%d].component = %q, want a non-fx component", idx, got) @@ -368,6 +389,80 @@ func TestCostDecomposeReasonsAreNonEmpty(t *testing.T) { } } +// TestAnchorFeeSeparateFromNetworkFees pins issue #169: anchor fees and +// network fees are separate components in the cost decomposition. For a DEX +// route (which has no anchor involved), both are undetermined, but for +// different reasons: network fees are unmeasured because the operation count +// and base fee are not available; anchor fees are absent because the route +// does not go through an anchor at all. When a route does go through an +// anchor that publishes ANCHOR_QUOTE_SERVER, the anchor fee can be priced +// via SEP-38; when it does not, the absence is a fact about the anchor. +func TestAnchorFeeSeparateFromNetworkFees(t *testing.T) { + q := Quote{ + Kind: KindDEX, + Description: "USDC -> XLM -> NGNC", + Source: "stellar-dex", + SendAsset: testUSDC(), + SendAmount: decimal.NewFromInt(100), + ReceiveAsset: testNGNC(), + ReceiveAmount: decimal.RequireFromString("112800.51"), + EffectiveRate: decimal.RequireFromString("1128.0051"), + ReferenceMid: decimal.RequireFromString("1500"), + LossPct: decimal.RequireFromString("24.80"), + LossAmount: decimal.RequireFromString("37199.49"), + Verdict: VerdictUnusable, + } + + d := Decompose(q, decimal.RequireFromString("1500")) + + var netFees, anchorFee *CostPart + for i := range d.Parts { + switch d.Parts[i].Component { + case CostNetworkFees: + netFees = &d.Parts[i] + case CostAnchorFee: + anchorFee = &d.Parts[i] + } + } + + if netFees == nil { + t.Fatal("decomposition is missing network_fees component") + } + if anchorFee == nil { + t.Fatal("decomposition is missing anchor_fee component") + } + + // Network fees are undetermined: the operation count and base fee are not + // available to Decompose. + if netFees.Determined { + t.Error("network_fees must be undetermined: the path operation count and Stellar base fee are not available") + } + if netFees.Reason == "" { + t.Error("undetermined network_fees must carry a reason") + } else if !strings.Contains(strings.ToLower(netFees.Reason), "network") { + t.Errorf("network_fees reason should mention network fee, got: %s", netFees.Reason) + } + + // Anchor fee is undetermined for a DEX route: there is no anchor involved, + // and the anchor's own fee is not part of on-chain DEX pricing. + if anchorFee.Determined { + t.Error("anchor_fee must be undetermined for a DEX route: no anchor is involved") + } + if anchorFee.Reason == "" { + t.Error("undetermined anchor_fee must carry a reason") + } + // The reason should mention ANCHOR_QUOTE_SERVER to make the absence + // actionable: a reader can tell whether the anchor could be priced. + if !strings.Contains(anchorFee.Reason, "ANCHOR_QUOTE_SERVER") { + t.Errorf("anchor_fee reason should mention ANCHOR_QUOTE_SERVER to make the absence actionable, got: %s", anchorFee.Reason) + } + + // The two components are distinct — neither is a duplicate or alias. + if netFees.Component == anchorFee.Component { + t.Error("network_fees and anchor_fee must be distinct components") + } +} + // TestCostNoDeterminedComponentDefaultsToZero pins the project's rule that an // unavailable quantity is unknown, not a default: every component that is // genuinely unmeasured must report Determined: false, so that no consumer is @@ -399,7 +494,7 @@ func TestCostNoDeterminedComponentDefaultsToZero(t *testing.T) { if !p.Determined { t.Error("fx_loss is computed from observed rates and must be determined") } - case CostFees, CostSlippage, CostExpectedFailure: + case CostNetworkFees, CostAnchorFee, CostSlippage, CostExpectedFailure: if p.Determined { t.Errorf( "%s must be undetermined: nothing was observed or computed "+