Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core-dart/lib/src/routing/extract.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,30 @@ import '../muxed/decode.dart';
import 'routing_result.dart';
import 'memo.dart';

const _severityRank = {'info': 0, 'warn': 1, 'error': 2};

List<RoutingWarning> _filterBySeverity(List<RoutingWarning> 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.');
Expand Down
5 changes: 5 additions & 0 deletions packages/core-dart/lib/src/routing/routing_result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core-go/routing/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 35 additions & 4 deletions packages/core-go/routing/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,48 @@ 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"`
TargetChains []string `json:"targetChains"`
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.
Expand Down
8 changes: 7 additions & 1 deletion packages/core-ts/src/routing/extract.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions packages/core-ts/src/routing/types.ts
Original file line number Diff line number Diff line change
@@ -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<SeverityLevel, number> = { 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";
Expand Down
50 changes: 50 additions & 0 deletions packages/core-ts/src/test/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading