diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index b780a438..947453b8 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -4,10 +4,30 @@ import '../muxed/decode.dart'; import 'routing_result.dart'; import 'memo.dart'; +const _severityRank = {'info': 0, 'warn': 1, 'error': 2}; + +List _filterBySeverity(List warnings, String min) { + final minRank = _severityRank[min] ?? 0; + return warnings.where((w) => (_severityRank[w.severity] ?? 0) >= minRank).toList(); +} + /// Extracts deposit routing information from a Stellar payment input. /// Following the standard priority policy, M-address identifiers take /// precedence over any provided memo. RoutingResult extractRouting(RoutingInput input) { + final result = _extractRouting(input); + final min = input.minSeverityLevel ?? 'info'; + if (min == 'info') return result; + return RoutingResult( + source: result.source, + id: result.id, + warnings: _filterBySeverity(result.warnings.toList(), min), + destinationBaseAccount: result.destinationBaseAccount, + destinationError: result.destinationError, + ); +} + +RoutingResult _extractRouting(RoutingInput input) { final trimmed = input.destination.trim(); if (trimmed.isEmpty) { throw const ExtractRoutingException('Invalid input: destination must be a non-empty string.'); diff --git a/packages/core-dart/lib/src/routing/routing_result.dart b/packages/core-dart/lib/src/routing/routing_result.dart index 3181c493..c7f9d717 100644 --- a/packages/core-dart/lib/src/routing/routing_result.dart +++ b/packages/core-dart/lib/src/routing/routing_result.dart @@ -103,11 +103,16 @@ class RoutingInput { /// The source account address of the transaction. final String? sourceAccount; + /// Minimum severity level for warnings to include in the result. + /// Defaults to 'info' (all warnings returned). Valid values: 'info', 'warn', 'error'. + final String? minSeverityLevel; + RoutingInput({ required this.destination, required this.memoType, this.memoValue, this.sourceAccount, + this.minSeverityLevel, }); } diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index 7adfcf7f..99f47bcf 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -32,6 +32,14 @@ func normalizeUnsupportedMemoType(memoType string) string { // take precedence over any provided memo. Returns a RoutingResult with the decoded // state and applicable warnings. func ExtractRouting(input RoutingInput) RoutingResult { + result := extractRouting(input) + if input.MinSeverityLevel != "" && input.MinSeverityLevel != SeverityInfo { + result.Warnings = filterWarnings(result.Warnings, input.MinSeverityLevel) + } + return result +} + +func extractRouting(input RoutingInput) RoutingResult { if input.SourceAccount != "" { source, err := address.Parse(input.SourceAccount) if err == nil && source.Kind == address.KindC { diff --git a/packages/core-go/routing/types.go b/packages/core-go/routing/types.go index 49aa4418..79216129 100644 --- a/packages/core-go/routing/types.go +++ b/packages/core-go/routing/types.go @@ -2,6 +2,36 @@ package routing import "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" +// SeverityLevel represents a warning severity threshold. +type SeverityLevel string + +const ( + SeverityInfo SeverityLevel = "info" + SeverityWarn SeverityLevel = "warn" + SeverityError SeverityLevel = "error" +) + +var severityRank = map[SeverityLevel]int{ + SeverityInfo: 0, + SeverityWarn: 1, + SeverityError: 2, +} + +// filterWarnings removes warnings below the given severity threshold. +func filterWarnings(warnings []address.Warning, min SeverityLevel) []address.Warning { + if min == SeverityInfo { + return warnings + } + minRank := severityRank[min] + out := warnings[:0:0] + for _, w := range warnings { + if severityRank[SeverityLevel(w.Severity)] >= minRank { + out = append(out, w) + } + } + return out +} + // RoutingInput represents incoming routing payload data. type RoutingInput struct { SourceAddress string `json:"sourceAddress"` @@ -9,10 +39,11 @@ type RoutingInput struct { Metadata map[string]string `json:"metadata,omitempty"` // Backward-compatible fields used by current extraction flow. - Destination string `json:"destination,omitempty"` - MemoType string `json:"memoType,omitempty"` - MemoValue string `json:"memoValue,omitempty"` - SourceAccount string `json:"sourceAccount,omitempty"` + Destination string `json:"destination,omitempty"` + MemoType string `json:"memoType,omitempty"` + MemoValue string `json:"memoValue,omitempty"` + SourceAccount string `json:"sourceAccount,omitempty"` + MinSeverityLevel SeverityLevel `json:"minSeverityLevel,omitempty"` } // RoutingResult represents routing output data. diff --git a/packages/core-ts/src/routing/extract.ts b/packages/core-ts/src/routing/extract.ts index 8f632b2a..92d81514 100644 --- a/packages/core-ts/src/routing/extract.ts +++ b/packages/core-ts/src/routing/extract.ts @@ -1,4 +1,4 @@ -import { RoutingInput, RoutingResult } from "./types"; +import { RoutingInput, RoutingResult, filterBySeverity } from "./types"; import { Warning } from "../address/types"; import { parse } from "../address/parse"; import { AddressParseError } from "../address/errors"; @@ -44,6 +44,12 @@ function assertRoutableAddress(destination: string): void { * @returns A result containing the base account, routing ID, source, and any warnings. */ export function extractRouting(input: RoutingInput): RoutingResult { + const result = _extractRouting(input); + const min = input.minSeverityLevel ?? "info"; + return min === "info" ? result : { ...result, warnings: filterBySeverity(result.warnings, min) }; +} + +function _extractRouting(input: RoutingInput): RoutingResult { assertRoutableAddress(input.destination); let parsed; diff --git a/packages/core-ts/src/routing/types.ts b/packages/core-ts/src/routing/types.ts index 504e2882..b6e1787c 100644 --- a/packages/core-ts/src/routing/types.ts +++ b/packages/core-ts/src/routing/types.ts @@ -1,12 +1,23 @@ import { ErrorCode, Warning, WarningCode } from "../address/types"; +export type { Warning, WarningCode } from "../address/types"; export type RoutingSource = "muxed" | "memo" | "none"; +export type SeverityLevel = "info" | "warn" | "error"; + +const SEVERITY_RANK: Record = { info: 0, warn: 1, error: 2 }; + +export function filterBySeverity(warnings: Warning[], min: SeverityLevel): Warning[] { + const minRank = SEVERITY_RANK[min]; + return warnings.filter((w) => SEVERITY_RANK[w.severity as SeverityLevel] >= minRank); +} + export type RoutingInput = { destination: string; memoType: string; memoValue: string | null; sourceAccount: string | null; + minSeverityLevel?: SeverityLevel; }; export type KnownMemoType = "none" | "id" | "text" | "hash" | "return"; diff --git a/packages/core-ts/src/test/extract.test.ts b/packages/core-ts/src/test/extract.test.ts index 6b90a14d..960ff149 100644 --- a/packages/core-ts/src/test/extract.test.ts +++ b/packages/core-ts/src/test/extract.test.ts @@ -281,3 +281,53 @@ describe("multi-warning: NON_CANONICAL_ROUTING_ID + MEMO_ID_INVALID_FORMAT", () } }); }); + +// ─── 8. minSeverityLevel filtering ─────────────────────────────────────────── + +describe("minSeverityLevel filtering", () => { + // Setup: M-address + non-routable memo → MEMO_IGNORED_FOR_MUXED (severity: 'info') + it("defaults to 'info' – returns all warnings when minSeverityLevel is omitted", () => { + const result = extractRouting(input(M_ADDRESS, "text", "order-ref")); + expect(result.warnings.some((w) => w.severity === "info")).toBe(true); + }); + + it("minSeverityLevel 'info' includes info warnings", () => { + const result = extractRouting({ ...input(M_ADDRESS, "text", "order-ref"), minSeverityLevel: "info" }); + expect(result.warnings.some((w) => w.code === "MEMO_IGNORED_FOR_MUXED")).toBe(true); + }); + + it("minSeverityLevel 'warn' excludes info warnings", () => { + const result = extractRouting({ ...input(M_ADDRESS, "text", "order-ref"), minSeverityLevel: "warn" }); + expect(result.warnings.every((w) => w.severity !== "info")).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + + it("minSeverityLevel 'warn' retains warn-severity warnings", () => { + const result = extractRouting({ ...input(G_ADDRESS, "id", ""), minSeverityLevel: "warn" }); + expect(result.warnings.some((w) => w.code === "MEMO_ID_INVALID_FORMAT")).toBe(true); + }); + + it("minSeverityLevel 'error' only retains error-severity warnings", () => { + // MEMO_PRESENT_WITH_MUXED is 'warn', should be filtered out + const warnResult = extractRouting({ ...input(M_ADDRESS, "id", "99"), minSeverityLevel: "error" }); + expect(warnResult.warnings).toHaveLength(0); + }); + + it("minSeverityLevel 'error' retains error-severity warnings", () => { + // To get an 'error' warning we need to force one through parse (C-address goes through + // assertRoutableAddress guard before reaching parsed.kind === "C"). We can't test + // INVALID_DESTINATION via extractRouting directly, but we can verify no error warnings + // are dropped when they exist by checking the base case. + const result = extractRouting({ ...input(G_ADDRESS, "id", "007"), minSeverityLevel: "error" }); + // NON_CANONICAL_ROUTING_ID is 'warn', so it should be filtered + expect(result.warnings.filter((w) => w.severity === "warn")).toHaveLength(0); + }); + + it("routing result fields are unaffected by minSeverityLevel", () => { + const base = extractRouting(input(M_ADDRESS, "text", "order-ref")); + const filtered = extractRouting({ ...input(M_ADDRESS, "text", "order-ref"), minSeverityLevel: "warn" }); + expect(filtered.routingSource).toBe(base.routingSource); + expect(filtered.destinationBaseAccount).toBe(base.destinationBaseAccount); + expect(filtered.routingId).toBe(base.routingId); + }); +});