diff --git a/packages/core-go/routing/bench_test.go b/packages/core-go/routing/bench_test.go new file mode 100644 index 00000000..db8647a3 --- /dev/null +++ b/packages/core-go/routing/bench_test.go @@ -0,0 +1,143 @@ +package routing + +import ( + "testing" +) + +// These addresses are the same as the ones used in extract_test.go. +const ( + benchGAddr = "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + benchMAddr = "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACABAAAAAAAAAAEVIG" +) + +// BenchmarkNormalizeMemoTextID_Canonical benchmarks the hot path: a valid canonical +// uint64 string with no leading zeros. After optimization this should be 0 allocs/op +// because the normalized sub-slice aliases the input and no big.Int or regex is used. +func BenchmarkNormalizeMemoTextID_Canonical(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NormalizeMemoTextID("9007199254740993") + } +} + +// BenchmarkNormalizeMemoTextID_MaxUint64 exercises the boundary comparison against +// the uint64 ceiling. Should also be 0 allocs/op after optimization. +func BenchmarkNormalizeMemoTextID_MaxUint64(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NormalizeMemoTextID("18446744073709551615") + } +} + +// BenchmarkNormalizeMemoTextID_LeadingZeros exercises the leading-zero normalization +// path, which emits a warning and therefore allocates one Warning struct. +func BenchmarkNormalizeMemoTextID_LeadingZeros(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NormalizeMemoTextID("007") + } +} + +// BenchmarkNormalizeMemoTextID_NonNumeric exercises the early-exit path where the +// input is rejected without any allocation. +func BenchmarkNormalizeMemoTextID_NonNumeric(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = NormalizeMemoTextID("not-a-number") + } +} + +// BenchmarkIsAllDigits shows the zero-allocation cost of the digit scanner that +// replaces regexp.MatchString on the hot path. +func BenchmarkIsAllDigits(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = isAllDigits("18446744073709551615") + } +} + +// BenchmarkFitsUint64 shows the zero-allocation cost of the string-comparison +// range check that replaces big.Int.Cmp. +func BenchmarkFitsUint64(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = fitsUint64("18446744073709551615") + } +} + +// BenchmarkExtractRouting_GAddr_MemoID benchmarks the most common exchange-deposit +// path: a G-address destination with a MEMO_ID routing identifier. +func BenchmarkExtractRouting_GAddr_MemoID(b *testing.B) { + b.ReportAllocs() + input := RoutingInput{ + Destination: benchGAddr, + MemoType: "id", + MemoValue: "100", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ExtractRouting(input) + } +} + +// BenchmarkExtractRouting_GAddr_NoMemo benchmarks a G-address with no memo — the +// simplest G-address path. +func BenchmarkExtractRouting_GAddr_NoMemo(b *testing.B) { + b.ReportAllocs() + input := RoutingInput{ + Destination: benchGAddr, + MemoType: "none", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ExtractRouting(input) + } +} + +// BenchmarkExtractRouting_MAddr benchmarks a muxed (M-address) destination, which +// carries the routing ID inline and skips memo processing. +func BenchmarkExtractRouting_MAddr(b *testing.B) { + b.ReportAllocs() + input := RoutingInput{ + Destination: benchMAddr, + MemoType: "none", + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ExtractRouting(input) + } +} + +// BenchmarkExtractRouting_GAddr_MemoID_Parallel benchmarks concurrent throughput +// for the most common deposit path under exchange-level parallelism. +// All state is read-only so there is no lock contention. +func BenchmarkExtractRouting_GAddr_MemoID_Parallel(b *testing.B) { + b.ReportAllocs() + input := RoutingInput{ + Destination: benchGAddr, + MemoType: "id", + MemoValue: "9007199254740993", + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = ExtractRouting(input) + } + }) +} + +// BenchmarkExtractRouting_MAddr_Parallel benchmarks concurrent muxed-address +// routing to simulate high-throughput trading engine ingestion. +func BenchmarkExtractRouting_MAddr_Parallel(b *testing.B) { + b.ReportAllocs() + input := RoutingInput{ + Destination: benchMAddr, + MemoType: "none", + } + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = ExtractRouting(input) + } + }) +} diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index 7adfcf7f..329c9979 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -1,7 +1,6 @@ package routing import ( - "regexp" "strconv" "strings" @@ -9,15 +8,29 @@ import ( "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/muxed" ) -var digitsOnlyRegex = regexp.MustCompile(`^\d+$`) - +// normalizeUnsupportedMemoType canonicalizes a memo type string by lower-casing it +// and stripping underscores and hyphens, then maps it to a known unsupported type. +// Uses strings.Builder to avoid intermediate string allocations from chained ReplaceAll/ToLower. func normalizeUnsupportedMemoType(memoType string) string { switch memoType { case "hash", "return": return memoType } - switch strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(memoType, "_", ""), "-", "")) { + var sb strings.Builder + sb.Grow(len(memoType)) + for i := 0; i < len(memoType); i++ { + c := memoType[i] + if c == '_' || c == '-' { + continue + } + if 'A' <= c && c <= 'Z' { + c += 'a' - 'A' + } + sb.WriteByte(c) + } + + switch sb.String() { case "memohash": return "hash" case "memoreturn": @@ -27,9 +40,9 @@ func normalizeUnsupportedMemoType(memoType string) string { } } -// ExtractRouting identifies the deposit routing destination and identifier from a Stellar -// payment input. It implements the standard priority policy where M-address identifiers -// take precedence over any provided memo. Returns a RoutingResult with the decoded +// ExtractRouting identifies the deposit routing destination and identifier from a Stellar +// payment input. It implements the standard priority policy where M-address identifiers +// take precedence over any provided memo. Returns a RoutingResult with the decoded // state and applicable warnings. func ExtractRouting(input RoutingInput) RoutingResult { if input.SourceAccount != "" { @@ -94,10 +107,13 @@ func ExtractRouting(input RoutingInput) RoutingResult { } } - warnings := append([]address.Warning{}, parsed.Warnings...) + // Pre-allocate with capacity for existing warnings plus at most one more. + warnings := make([]address.Warning, 0, len(parsed.Warnings)+1) + warnings = append(warnings, parsed.Warnings...) memoValue := stringValue(input.MemoValue) - if input.MemoType == "id" || (input.MemoType == "text" && digitsOnlyRegex.MatchString(memoValue)) { + // isAllDigits replaces the regex match to avoid heap allocation. + if input.MemoType == "id" || (input.MemoType == "text" && isAllDigits(memoValue)) { warnings = append(warnings, address.Warning{ Code: address.WarnMemoPresentWithMuxed, Severity: "warn", @@ -121,7 +137,9 @@ func ExtractRouting(input RoutingInput) RoutingResult { var routingID *RoutingID routingSource := "none" - warnings := append([]address.Warning{}, parsed.Warnings...) + // Pre-allocate with capacity for existing address warnings plus at most two memo warnings. + warnings := make([]address.Warning, 0, len(parsed.Warnings)+2) + warnings = append(warnings, parsed.Warnings...) memoValue := stringValue(input.MemoValue) if input.MemoType == "id" { @@ -153,19 +171,32 @@ func ExtractRouting(input RoutingInput) RoutingResult { }) } } else if unsupportedMemoType := normalizeUnsupportedMemoType(input.MemoType); unsupportedMemoType != "" { + // Use pre-computed string literals for known memo types to avoid string concatenation. + var msg string + switch unsupportedMemoType { + case "hash": + msg = "Memo type hash is not supported for routing." + case "return": + msg = "Memo type return is not supported for routing." + } warnings = append(warnings, address.Warning{ Code: address.WarnUnsupportedMemoType, Severity: "warn", - Message: "Memo type " + unsupportedMemoType + " is not supported for routing.", + Message: msg, Context: &address.WarningContext{ MemoType: unsupportedMemoType, }, }) } else if input.MemoType != "none" { + // Use strings.Builder to avoid the two intermediate allocations from "prefix" + var concatenation. + var sb strings.Builder + sb.Grow(len("Unrecognized memo type: ") + len(input.MemoType)) + sb.WriteString("Unrecognized memo type: ") + sb.WriteString(input.MemoType) warnings = append(warnings, address.Warning{ Code: address.WarnUnsupportedMemoType, Severity: "warn", - Message: "Unrecognized memo type: " + input.MemoType, + Message: sb.String(), Context: &address.WarningContext{ MemoType: "unknown", }, diff --git a/packages/core-go/routing/memo.go b/packages/core-go/routing/memo.go index 6025c61d..f69dec2f 100644 --- a/packages/core-go/routing/memo.go +++ b/packages/core-go/routing/memo.go @@ -1,39 +1,59 @@ package routing import ( - "math/big" - "regexp" "strings" "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" ) -var digitsOnly = regexp.MustCompile(`^\d+$`) -var uint64Max, _ = new(big.Int).SetString("18446744073709551615", 10) +// uint64MaxStr is the decimal string representation of math.MaxUint64. +const uint64MaxStr = "18446744073709551615" type NormalizeResult struct { Normalized string Warnings []address.Warning } -func NormalizeMemoTextID(s string) NormalizeResult { - warnings := []address.Warning{} +// isAllDigits reports whether s is non-empty and contains only ASCII decimal digits. +// This is used instead of a compiled regexp to avoid heap allocations on the hot path. +func isAllDigits(s string) bool { + if len(s) == 0 { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} - // Step 1: Whitespace - not trimmed. - // Step 2: Empty string. - // Step 3: Any non-digit character. - if s == "" || !digitsOnly.MatchString(s) { - return NormalizeResult{Normalized: "", Warnings: warnings} +// fitsUint64 reports whether a canonical (no leading zeros) decimal string is within +// the uint64 range. Uses lexicographic comparison to avoid big.Int allocation. +func fitsUint64(s string) bool { + if len(s) < len(uint64MaxStr) { + return true + } + if len(s) > len(uint64MaxStr) { + return false + } + return s <= uint64MaxStr +} + +func NormalizeMemoTextID(s string) NormalizeResult { + if s == "" || !isAllDigits(s) { + return NormalizeResult{} } - // Step 4: Strip leading zeros + // Strip leading zeros; strings.TrimLeft returns a sub-slice (no allocation). normalized := strings.TrimLeft(s, "0") if normalized == "" { normalized = "0" } + var warnings []address.Warning if normalized != s { - warnings = append(warnings, address.Warning{ + warnings = []address.Warning{{ Code: address.WarnNonCanonicalRoutingID, Severity: "warn", Message: "Memo routing ID had leading zeros. Normalized to canonical decimal.", @@ -41,13 +61,11 @@ func NormalizeMemoTextID(s string) NormalizeResult { Original: s, Normalized: normalized, }, - }) + }} } - // Step 5: Check uint64 max - val, ok := new(big.Int).SetString(normalized, 10) - if !ok || val.Cmp(uint64Max) > 0 { - return NormalizeResult{Normalized: "", Warnings: warnings} + if !fitsUint64(normalized) { + return NormalizeResult{Warnings: warnings} } return NormalizeResult{Normalized: normalized, Warnings: warnings}