diff --git a/.gitignore b/.gitignore index 2bede6e5..7493c4de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ __pycache__/ build/ +node_modules/ diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index 0adb846b..48b03c52 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -4,15 +4,30 @@ 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.'); } @@ -20,7 +35,7 @@ RoutingResult extractRoutingSync(RoutingInput input) { 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".', ); } @@ -38,7 +53,7 @@ RoutingResult extractRoutingSync(RoutingInput input) { } } - final parsed = parse(input.destination); + final parsed = parse(sanitized); if (parsed.kind == null) { return RoutingResult( diff --git a/packages/core-dart/test/extract_routing_test.dart b/packages/core-dart/test/extract_routing_test.dart index 0c42a3cf..9a19d2a2 100644 --- a/packages/core-dart/test/extract_routing_test.dart +++ b/packages/core-dart/test/extract_routing_test.dart @@ -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')), ); }); diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index f49bcaa4..07d5c621 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -1,6 +1,7 @@ package routing import ( + "log" "strconv" "strings" @@ -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 @@ -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", diff --git a/packages/core-ts/src/routing/extract.ts b/packages/core-ts/src/routing/extract.ts index 8f632b2a..a082be1f 100644 --- a/packages/core-ts/src/routing/extract.ts +++ b/packages/core-ts/src/routing/extract.ts @@ -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. @@ -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 { diff --git a/packages/core-ts/src/routing/extractFromURI.test.ts b/packages/core-ts/src/routing/extractFromURI.test.ts index 9c0c4bdf..18e26838 100644 --- a/packages/core-ts/src/routing/extractFromURI.test.ts +++ b/packages/core-ts/src/routing/extractFromURI.test.ts @@ -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", () => { @@ -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(); } }); diff --git a/packages/core-ts/src/routing/extractFromURI.ts b/packages/core-ts/src/routing/extractFromURI.ts index f8cdb009..0360ff98 100644 --- a/packages/core-ts/src/routing/extractFromURI.ts +++ b/packages/core-ts/src/routing/extractFromURI.ts @@ -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, }; } diff --git a/packages/spec/vectors.json b/packages/spec/vectors.json index 8a98f208..93744e1b 100644 --- a/packages/spec/vectors.json +++ b/packages/spec/vectors.json @@ -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"] } ] } diff --git a/spec/vectors.json b/spec/vectors.json index 566a99b5..233413a5 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -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"] } ] }