diff --git a/.github/workflows/verify-parity.yml b/.github/workflows/verify-parity.yml index d2f1ea74..91422844 100644 --- a/.github/workflows/verify-parity.yml +++ b/.github/workflows/verify-parity.yml @@ -63,6 +63,7 @@ jobs: - run: cd packages/core-dart && dart test test/web_compat --platform chrome env: CHROME_EXECUTABLE: ${{ steps.setup-chrome.outputs.chrome-path }} + CHROME_ARGS: --no-sandbox parity-gate: name: parity gate 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..6774964f 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) { + 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..233413a5 100644 --- a/packages/spec/vectors.json +++ b/packages/spec/vectors.json @@ -1,5 +1,6 @@ { "spec_version": "1.0.0", + "description": "Normative test vectors for the Stellar Address Kit. This file is the single source of truth for routing logic across TypeScript, Go, and Dart implementations. Any change to routing behavior MUST start here.", "cases": [ { "module": "muxed_encode", @@ -246,6 +247,25 @@ "positive" ] }, + { + "module": "extract_routing", + "description": "G-address + MEMO_ID routing with 2^53+1 canary", + "input": { + "destination": "GA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1", + "memoType": "id", + "memoValue": "9007199254740993" + }, + "expected": { + "destinationBaseAccount": "GA7QYNF7SZFX4X7X5JFZZ3UQ6BXHDSY2RKVKZKX5FFQJ1ZMZX1", + "routingId": "9007199254740993", + "routingSource": "memo", + "warnings": [] + }, + "tags": [ + "positive", + "edge" + ] + }, { "module": "extract_routing", "description": "G-address + MEMO_TEXT numeric routing", @@ -292,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"] } ] } 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"] } ] }