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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
__pycache__/
build/
node_modules/
27 changes: 21 additions & 6 deletions packages/core-dart/lib/src/routing/extract.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,38 @@ import '../muxed/decode.dart';
import 'routing_result.dart';
import 'memo.dart';

/// Extracts deposit routing information from a Stellar payment input.
/// Following the standard priority policy, M-address identifiers take
/// precedence over any provided memo.
String _sanitizeAddress(String raw) {
final cleaned = raw
.replaceAll(RegExp(
r'[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5'
r'\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFEFF\uFFA0'
r'\r\n\t]',
), '')
.trim();
return cleaned;
}

/// Extracts deposit routing information from a Stellar payment input synchronously.
///
/// This is the synchronous variant for pure string parsing.
/// For future compatibility with async network checks (Federation, SEP-0029),
/// use [extractRouting] instead.
RoutingResult extractRoutingSync(RoutingInput input) {
final trimmed = input.destination.trim();
final original = input.destination;
final sanitized = _sanitizeAddress(original);
if (sanitized != original.trim()) {
print('[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.');
}

final trimmed = sanitized.trim();
if (trimmed.isEmpty) {
throw const ExtractRoutingException('Invalid input: destination must be a non-empty string.');
}

final prefix = trimmed[0].toUpperCase();
if (prefix != 'G' && prefix != 'M') {
throw ExtractRoutingException(
'Invalid destination: expected a G or M address, got "${input.destination}".',
'Invalid destination: expected a G or M address, got "$sanitized".',
);
}

Expand All @@ -38,7 +53,7 @@ RoutingResult extractRoutingSync(RoutingInput input) {
}
}

final parsed = parse(input.destination);
final parsed = parse(sanitized);

if (parsed.kind == null) {
return RoutingResult(
Expand Down
5 changes: 3 additions & 2 deletions packages/core-dart/test/extract_routing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ void main() {
result.id == null &&
result.source == RoutingSource.none &&
result.destinationError == null &&
result.warnings.map((w) => w.code).toList() ==
['memo-ignored', 'MEMO_TEXT_UNROUTABLE'])),
result.warnings.length == 2 &&
result.warnings[0].code == 'memo-ignored' &&
result.warnings[1].code == 'MEMO_TEXT_UNROUTABLE')),
);
});

Expand Down
30 changes: 29 additions & 1 deletion packages/core-go/routing/extract.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package routing

import (
"log"
"strconv"
"strings"

Expand Down Expand Up @@ -40,6 +41,28 @@ func normalizeUnsupportedMemoType(memoType string) string {
}
}

// sanitizeAddress strips hidden Unicode control characters and invisible
// formatting characters from an address string, then trims whitespace.
func sanitizeAddress(raw string) (string, bool) {
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
if r <= 0x1f || (0x7f <= r && r <= 0x9f) ||
r == 0xad || r == 0x34f || r == 0x61c ||
r == 0x115f || r == 0x1160 || r == 0x17b4 || r == 0x17b5 ||
(0x180b <= r && r <= 0x180e) ||
(0x200b <= r && r <= 0x200f) ||
(0x202a <= r && r <= 0x202e) ||
(0x2060 <= r && r <= 0x206f) ||
r == 0x3164 || r == 0xfeff || r == 0xffa0 {
continue
}
b.WriteRune(r)
}
cleaned := strings.TrimSpace(b.String())
return cleaned, cleaned != strings.TrimSpace(raw)
}

// 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
Expand All @@ -59,7 +82,12 @@ func ExtractRouting(input RoutingInput) RoutingResult {
}
}

parsed, err := address.Parse(input.Destination)
sanitized, wasSanitized := sanitizeAddress(input.Destination)
if wasSanitized {
log.Println("[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.")
}

parsed, err := address.Parse(sanitized)
if err != nil {
return RoutingResult{
RoutingSource: "none",
Expand Down
17 changes: 15 additions & 2 deletions packages/core-ts/src/routing/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export class ExtractRoutingError extends Error {
}
}

function sanitizeAddress(raw: string): string {
return raw
.replace(/[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFEFF\uFFA0]/g, '')
.replace(/[\r\n\t]/g, '')
.trim();
}

/**
* Validates that the destination string passes the minimum structural
* requirements for a Stellar address before routing logic is applied.
Expand Down Expand Up @@ -44,11 +51,17 @@ function assertRoutableAddress(destination: string): void {
* @returns A result containing the base account, routing ID, source, and any warnings.
*/
export function extractRouting(input: RoutingInput): RoutingResult {
assertRoutableAddress(input.destination);
const rawDestination = input.destination;
const sanitizedDestination = sanitizeAddress(rawDestination);
if (sanitizedDestination !== rawDestination) {
console.info('[stellar-address-kit] SANITIZED_HIDDEN_CHARS: Input contained hidden characters and was sanitized before processing.');
}
const sanitizedInput: RoutingInput = { ...input, destination: sanitizedDestination };
assertRoutableAddress(sanitizedInput.destination);

let parsed;
try {
parsed = parse(input.destination);
parsed = parse(sanitizedInput.destination);
} catch (error) {
if (error instanceof AddressParseError) {
return {
Expand Down
8 changes: 4 additions & 4 deletions packages/core-ts/src/routing/extractFromURI.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isSuccessfulURIResult } from "../src/lib/extractRoutingFromURI";
import { extractRoutingFromURI } from '../lib/extractRoutingFromURI';
import { isSuccessfulURIResult } from "./extractFromURI";
import { extractRoutingFromURI } from './extractFromURI';

describe("extractRoutingFromURI", () => {
describe("scheme validation", () => {
Expand Down Expand Up @@ -128,12 +128,12 @@ describe("extractRoutingFromURI", () => {
describe("M-address handling", () => {
it("passes M-address to extractRouting for canonical expansion", () => {
const result = extractRoutingFromURI(
"web+stellar:pay?destination=MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLT7AV7Y6S33Z6S3CHBAAAAAAAAAAAAABQD"
"web+stellar:pay?destination=MBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OAAAAAAAAAAAPOGVY"
);
expect(result.success).toBe(true);
if (isSuccessfulURIResult(result)) {
// extractRouting should expand M-address to G-address + routingId
expect(result.routing.address).toMatch(/^G/);
expect(result.routing.destinationBaseAccount).toMatch(/^G/);
expect(result.routing.routingId).toBeDefined();
}
});
Expand Down
14 changes: 12 additions & 2 deletions packages/core-ts/src/routing/extractFromURI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,20 @@ export function extractRoutingFromURI(uriString: string): ExtractRoutingFromURIR
// 8. Delegate to core extractRouting logic
const routingResult = extractRouting(routingInput);

// 9. Return combined result
// 9. Normalize returned routing fields for URI extraction.
// Some Stellar SDK methods may return String wrapper objects instead of
// primitive strings for parsed address components.
const normalizedRouting = {
...routingResult,
destinationBaseAccount:
routingResult.destinationBaseAccount === null
? null
: String(routingResult.destinationBaseAccount),
};

return {
success: true,
routing: routingResult,
routing: normalizedRouting,
rawParams,
};
}
Expand Down
75 changes: 75 additions & 0 deletions packages/spec/vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,81 @@
"negative",
"edge"
]
},
{
"module": "extract_routing",
"description": "G-address with leading zero-width space - should resolve identically",
"input": {
"destination": "\u200bGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "G-address with trailing CRLF - should resolve identically",
"input": {
"destination": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\r\n",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "M-address with embedded ZWNJ - should resolve identically",
"input": {
"destination": "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAC\u200cABAAAAAAAAAAEVIG",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": "9007199254740993",
"routingSource": "muxed",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "G-address with BOM prefix - should resolve identically",
"input": {
"destination": "\ufeffGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "G-address with scattered invisible characters - should resolve identically",
"input": {
"destination": "\u200bGAYCUYT553C5LHVE2XP\u200cW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\uFEFF",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
}
]
}
75 changes: 75 additions & 0 deletions spec/vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,81 @@
"negative",
"edge"
]
},
{
"module": "extract_routing",
"description": "G-address with leading zero-width space - should resolve identically",
"input": {
"destination": "\u200bGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "G-address with trailing CRLF - should resolve identically",
"input": {
"destination": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\r\n",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "M-address with embedded ZWNJ - should resolve identically",
"input": {
"destination": "MAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQAC\u200cABAAAAAAAAAAEVIG",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": "9007199254740993",
"routingSource": "muxed",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "G-address with BOM prefix - should resolve identically",
"input": {
"destination": "\ufeffGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
},
{
"module": "extract_routing",
"description": "G-address with scattered invisible characters - should resolve identically",
"input": {
"destination": "\u200bGAYCUYT553C5LHVE2XP\u200cW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\uFEFF",
"memoType": "none"
},
"expected": {
"destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI",
"routingId": null,
"routingSource": "none",
"warnings": []
},
"tags": ["positive", "regression"]
}
]
}