Skip to content
Merged
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
143 changes: 143 additions & 0 deletions packages/core-go/routing/bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
55 changes: 43 additions & 12 deletions packages/core-go/routing/extract.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
package routing

import (
"regexp"
"strconv"
"strings"

"github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address"
"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":
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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",
Expand All @@ -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" {
Expand Down Expand Up @@ -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",
},
Expand Down
54 changes: 36 additions & 18 deletions packages/core-go/routing/memo.go
Original file line number Diff line number Diff line change
@@ -1,53 +1,71 @@
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.",
Normalization: &address.Normalization{
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}
Expand Down
Loading