From e7c540626e9a24c2fc6e585e54ed9da00efc0c19 Mon Sep 17 00:00:00 2001 From: xtep103 <302179003+xtep103@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:06:08 +0100 Subject: [PATCH 1/7] feat: sanitize input for hidden control characters and whitespace (#271) --- packages/core-dart/lib/src/address/codes.dart | 3 + .../core-dart/lib/src/routing/extract.dart | 42 +- .../core-dart/test/extract_routing_test.dart | 17 + packages/core-go/address/warnings.go | 1 + packages/core-go/routing/extract.go | 84 +++- packages/core-go/routing/extract_test.go | 26 ++ packages/core-ts/dist/index.d.mts | 79 +++- packages/core-ts/dist/index.d.ts | 79 +++- packages/core-ts/dist/index.js | 147 ++++++- packages/core-ts/dist/index.mjs | 403 +++++------------- packages/core-ts/src/address/types.ts | 3 +- packages/core-ts/src/routing/extract.ts | 49 ++- .../core-ts/src/routing/extractFromURI.ts | 2 +- packages/core-ts/src/spec/runner.test.ts | 6 +- packages/core-ts/src/test/extract.test.ts | 79 ++++ packages/spec/package.json | 2 +- packages/spec/schema.json | 2 +- packages/spec/vectors.json | 181 +++++--- spec/schema.json | 2 +- spec/vectors.json | 45 +- 20 files changed, 849 insertions(+), 403 deletions(-) diff --git a/packages/core-dart/lib/src/address/codes.dart b/packages/core-dart/lib/src/address/codes.dart index 9d959132..65199ced 100644 --- a/packages/core-dart/lib/src/address/codes.dart +++ b/packages/core-dart/lib/src/address/codes.dart @@ -66,6 +66,9 @@ abstract final class WarningCode { /// The destination is a smart contract, which is invalid for classic payments. static const invalidDestination = 'INVALID_DESTINATION'; + + /// Hidden control characters or whitespace were stripped from the destination address. + static const sanitizedHiddenChars = 'SANITIZED_HIDDEN_CHARS'; } /// Represents a warning encountered during address parsing or routing. diff --git a/packages/core-dart/lib/src/routing/extract.dart b/packages/core-dart/lib/src/routing/extract.dart index 9b4bc8d6..cc1c4cdd 100644 --- a/packages/core-dart/lib/src/routing/extract.dart +++ b/packages/core-dart/lib/src/routing/extract.dart @@ -20,15 +20,24 @@ import 'safe_routing_id.dart'; /// For future compatibility with async network checks (Federation, SEP-0029), /// use [extractRouting] instead. RoutingResult extractRoutingSync(RoutingInput input) { - final trimmed = input.destination.trim(); - if (trimmed.isEmpty) { - throw const ExtractRoutingException('Invalid input: destination must be a non-empty string.'); + final sanitized = input.destination.replaceAll( + RegExp( + r'[\x00-\x1F\x7F-\x9F\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD\uFFF9-\uFFFB\s]', + ), + '', + ); + final wasSanitized = sanitized != input.destination; + + if (sanitized.isEmpty) { + throw const ExtractRoutingException( + 'Invalid input: destination must be a non-empty string.', + ); } - final prefix = trimmed[0].toUpperCase(); + final prefix = sanitized[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".', ); } @@ -46,12 +55,21 @@ RoutingResult extractRoutingSync(RoutingInput input) { } } - final parsed = parse(input.destination); + final parsed = parse(sanitized); if (parsed.kind == null) { return RoutingResult( source: RoutingSource.none, - warnings: [], + warnings: wasSanitized + ? [ + const RoutingWarning( + code: codes.WarningCode.sanitizedHiddenChars, + severity: 'info', + message: + 'Destination address contained non-printable characters or whitespace that were stripped.', + ), + ] + : [], destinationError: parsed.error != null ? DestinationError( code: parsed.error!.code, @@ -62,6 +80,16 @@ RoutingResult extractRoutingSync(RoutingInput input) { } final warnings = []; + if (wasSanitized) { + warnings.add( + const RoutingWarning( + code: codes.WarningCode.sanitizedHiddenChars, + severity: 'info', + message: + 'Destination address contained non-printable characters or whitespace that were stripped.', + ), + ); + } for (final w in parsed.warnings) { warnings.add(RoutingWarning( code: w.code, diff --git a/packages/core-dart/test/extract_routing_test.dart b/packages/core-dart/test/extract_routing_test.dart index 9a19d2a2..fe6749c1 100644 --- a/packages/core-dart/test/extract_routing_test.dart +++ b/packages/core-dart/test/extract_routing_test.dart @@ -165,5 +165,22 @@ void main() { throwsA(isA()), ); }); + + test('sanitizes invisible Unicode characters and whitespace', () async { + final dirtyG = '\u200B\uFEFF \r\n$baseG \t\u200D\u2060\n'; + final result = await extractRouting(RoutingInput( + destination: dirtyG, + memoType: 'id', + memoValue: '100', + )); + + expect(result.destinationBaseAccount, baseG); + expect(result.id, BigInt.from(100)); + expect(result.source, RoutingSource.memo); + expect(result.warnings.length, 1); + expect(result.warnings[0].code, WarningCode.sanitizedHiddenChars); + expect(result.warnings[0].severity, 'info'); + }); }); } + diff --git a/packages/core-go/address/warnings.go b/packages/core-go/address/warnings.go index aacb2fda..c6a938d0 100644 --- a/packages/core-go/address/warnings.go +++ b/packages/core-go/address/warnings.go @@ -18,6 +18,7 @@ const ( WarnMemoIDInvalidFormat WarningCode = "MEMO_ID_INVALID_FORMAT" WarnUnsupportedMemoType WarningCode = "UNSUPPORTED_MEMO_TYPE" WarnInvalidDestination WarningCode = "INVALID_DESTINATION" + WarnSanitizedHiddenChars WarningCode = "SANITIZED_HIDDEN_CHARS" ) type Warning struct { diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index f49bcaa4..44e90bcc 100644 --- a/packages/core-go/routing/extract.go +++ b/packages/core-go/routing/extract.go @@ -3,11 +3,45 @@ package routing import ( "strconv" "strings" + "unicode" "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/muxed" ) +func isHiddenOrWhitespace(r rune) bool { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return true + } + switch { + case r == 0xFEFF, r == 0x00AD: + return true + case r >= 0x200B && r <= 0x200F: + return true + case r >= 0x2028 && r <= 0x202F: + return true + case r >= 0x2060 && r <= 0x206F: + return true + case r >= 0xFFF9 && r <= 0xFFFB: + return true + } + return false +} + +func sanitizeDestination(dest string) (string, bool) { + if !strings.ContainsFunc(dest, isHiddenOrWhitespace) { + return dest, false + } + var sb strings.Builder + sb.Grow(len(dest)) + for _, r := range dest { + if !isHiddenOrWhitespace(r) { + sb.WriteRune(r) + } + } + return sb.String(), true +} + // 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. @@ -59,11 +93,29 @@ func ExtractRouting(input RoutingInput) RoutingResult { } } - parsed, err := address.Parse(input.Destination) + sanitizedDest, wasSanitized := sanitizeDestination(input.Destination) + + initWarnings := func(additional ...address.Warning) []address.Warning { + capSize := len(additional) + if wasSanitized { + capSize++ + } + w := make([]address.Warning, 0, capSize) + if wasSanitized { + w = append(w, address.Warning{ + Code: address.WarnSanitizedHiddenChars, + Severity: "info", + Message: "Destination address contained non-printable characters or whitespace that were stripped.", + }) + } + return append(w, additional...) + } + + parsed, err := address.Parse(sanitizedDest) if err != nil { return RoutingResult{ RoutingSource: "none", - Warnings: []address.Warning{}, + Warnings: initWarnings(), DestinationError: &DestinationError{ Code: address.ErrUnknownPrefix, Message: err.Error(), @@ -72,16 +124,18 @@ func ExtractRouting(input RoutingInput) RoutingResult { } if parsed.Kind == address.KindC { + warnings := initWarnings() + warnings = append(warnings, address.Warning{ + Code: address.WarnInvalidDestination, + Severity: "error", + Message: "C address is not a valid destination", + Context: &address.WarningContext{ + DestinationKind: "C", + }, + }) return RoutingResult{ RoutingSource: "none", - Warnings: []address.Warning{{ - Code: address.WarnInvalidDestination, - Severity: "error", - Message: "C address is not a valid destination", - Context: &address.WarningContext{ - DestinationKind: "C", - }, - }}, + Warnings: warnings, } } @@ -90,7 +144,7 @@ func ExtractRouting(input RoutingInput) RoutingResult { if err != nil { return RoutingResult{ RoutingSource: "none", - Warnings: []address.Warning{}, + Warnings: initWarnings(), DestinationError: &DestinationError{ Code: address.ErrUnknownPrefix, Message: err.Error(), @@ -98,9 +152,7 @@ func ExtractRouting(input RoutingInput) RoutingResult { } } - // 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...) + warnings := initWarnings(parsed.Warnings...) memoValue := stringValue(input.MemoValue) // isAllDigits replaces the regex match to avoid heap allocation. @@ -128,9 +180,7 @@ func ExtractRouting(input RoutingInput) RoutingResult { var routingID *RoutingID routingSource := "none" - // 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...) + warnings := initWarnings(parsed.Warnings...) memoValue := stringValue(input.MemoValue) if input.MemoType == "id" { diff --git a/packages/core-go/routing/extract_test.go b/packages/core-go/routing/extract_test.go index 9dec15be..90a0826d 100644 --- a/packages/core-go/routing/extract_test.go +++ b/packages/core-go/routing/extract_test.go @@ -331,6 +331,32 @@ func TestExtractRouting_ContractSourceClearsRoutingState(t *testing.T) { }) } +func TestExtractRouting_SanitizedHiddenChars(t *testing.T) { + t.Run("sanitizes-zero-width-and-whitespace", func(t *testing.T) { + dirtyG := "\u200B\uFEFF \r\n" + testBaseG + " \t\u200D\u2060\n" + result := ExtractRouting(RoutingInput{ + Destination: dirtyG, + MemoType: "id", + MemoValue: "100", + }) + + expected := RoutingResult{ + DestinationBaseAccount: testBaseG, + RoutingID: NewRoutingID("100"), + RoutingSource: "memo", + Warnings: []address.Warning{ + { + Code: address.WarnSanitizedHiddenChars, + Severity: "info", + Message: "Destination address contained non-printable characters or whitespace that were stripped.", + }, + }, + } + + assertRoutingResult(t, result, expected) + }) +} + func assertRoutingResult(t *testing.T, got, want RoutingResult) { t.Helper() diff --git a/packages/core-ts/dist/index.d.mts b/packages/core-ts/dist/index.d.mts index 03d6aefd..235f449a 100644 --- a/packages/core-ts/dist/index.d.mts +++ b/packages/core-ts/dist/index.d.mts @@ -17,7 +17,12 @@ declare class AddressParseError extends Error { declare function detect(address: string): "G" | "M" | "C" | "invalid"; type AddressKind = "G" | "M" | "C"; -type WarningCode = "NON_CANONICAL_ADDRESS" | "NON_CANONICAL_ROUTING_ID" | "MEMO_IGNORED_FOR_MUXED" | "MEMO_PRESENT_WITH_MUXED" | "CONTRACT_SENDER_DETECTED" | "MEMO_TEXT_UNROUTABLE" | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" | "INVALID_DESTINATION"; +/** + * Severity levels for validation warnings. + * Used with `minSeverityLevel` to filter warnings by importance. + */ +type WarningSeverity = "info" | "warn" | "error"; +type WarningCode = "NON_CANONICAL_ADDRESS" | "NON_CANONICAL_ROUTING_ID" | "MEMO_IGNORED_FOR_MUXED" | "MEMO_PRESENT_WITH_MUXED" | "CONTRACT_SENDER_DETECTED" | "MEMO_TEXT_UNROUTABLE" | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" | "INVALID_DESTINATION" | "SANITIZED_HIDDEN_CHARS"; type Warning = { code: "NON_CANONICAL_ADDRESS" | "NON_CANONICAL_ROUTING_ID"; severity: "warn"; @@ -128,6 +133,12 @@ type RoutingInput = { memoType: string; memoValue: string | null; sourceAccount: string | null; + /** + * Minimum severity level for warnings to include in the result. + * Warnings below this threshold are filtered out. + * Defaults to `'info'` (all warnings are returned). + */ + minSeverityLevel?: WarningSeverity; }; type KnownMemoType = "none" | "id" | "text" | "hash" | "return"; type RoutingResult = { @@ -165,6 +176,70 @@ declare function extractRouting(input: RoutingInput): RoutingResult; */ declare function extractRoutingFromTx(tx: any): RoutingResult | null; +/** + * SEP-0007 URI Parser + * + * Parses `web+stellar:pay?...` URIs (commonly from QR code scanners) + * and delegates to the core `extractRouting` logic to produce canonical + * routing information. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md + */ + +interface SEP7PayParams { + destination: string; + amount?: string; + assetCode?: string; + assetIssuer?: string; + memo?: string; + memoType?: string; + callback?: string; + msg?: string; + networkPassphrase?: string; + originDomain?: string; + signature?: string; +} +type ExtractRoutingFromURIResult = { + success: true; + routing: RoutingResult; + rawParams: SEP7PayParams; +} | { + success: false; + error: string; + code: "INVALID_URI" | "UNSUPPORTED_OPERATION" | "MISSING_DESTINATION" | "INVALID_ENCODING"; +}; +/** + * Parse a SEP-0007 URI and extract canonical routing information. + * + * Supported format: + * web+stellar:pay?destination=&memo=&memo_type= + * + * The function safely decodes URL-encoded parameters, validates the scheme, + * and passes `destination` + `memo` into `extractRouting()` for canonical + * output (handling G-addresses, M-addresses, C-addresses, etc.). + * + * @param uriString - The raw URI string from a QR code scanner or deeplink + * @returns ExtractRoutingFromURIResult + * + * @example + * ```ts + * const result = extractRoutingFromURI( + * "web+stellar:pay?destination=GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI&memo=123&memo_type=MEMO_ID" + * ); + * if (result.success) { + * console.log(result.routing.destinationBaseAccount); // "GAYC..." + * console.log(result.routing.routingId); // "123" + * } + * ``` + */ +declare function extractRoutingFromURI(uriString: string): ExtractRoutingFromURIResult; +/** + * Type guard for successful URI parsing. + */ +declare function isSuccessfulURIResult(result: ExtractRoutingFromURIResult): result is ExtractRoutingFromURIResult & { + success: true; +}; + type NormalizeResult = { normalized: string | null; warnings: Warning[]; @@ -178,4 +253,4 @@ type NormalizeResult = { */ declare function normalizeMemoTextId(s: string): NormalizeResult; -export { type Address, type AddressKind, AddressParseError, type ErrorCode, ExtractRoutingError, type KnownMemoType, type NormalizeResult, type ParseResult, type RoutingInput, type RoutingResult, type RoutingSource, type Warning, type WarningCode, decodeMuxed, detect, encodeMuxed, extractRouting, extractRoutingFromTx, normalizeMemoTextId, parse, routingIdAsBigInt, validate }; +export { type Address, type AddressKind, AddressParseError, type ErrorCode, ExtractRoutingError, type ExtractRoutingFromURIResult, type KnownMemoType, type NormalizeResult, type ParseResult, type RoutingInput, type RoutingResult, type RoutingSource, type SEP7PayParams, type Warning, type WarningCode, type WarningSeverity, decodeMuxed, detect, encodeMuxed, extractRouting, extractRoutingFromTx, extractRoutingFromURI, isSuccessfulURIResult, normalizeMemoTextId, parse, routingIdAsBigInt, validate }; diff --git a/packages/core-ts/dist/index.d.ts b/packages/core-ts/dist/index.d.ts index 03d6aefd..235f449a 100644 --- a/packages/core-ts/dist/index.d.ts +++ b/packages/core-ts/dist/index.d.ts @@ -17,7 +17,12 @@ declare class AddressParseError extends Error { declare function detect(address: string): "G" | "M" | "C" | "invalid"; type AddressKind = "G" | "M" | "C"; -type WarningCode = "NON_CANONICAL_ADDRESS" | "NON_CANONICAL_ROUTING_ID" | "MEMO_IGNORED_FOR_MUXED" | "MEMO_PRESENT_WITH_MUXED" | "CONTRACT_SENDER_DETECTED" | "MEMO_TEXT_UNROUTABLE" | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" | "INVALID_DESTINATION"; +/** + * Severity levels for validation warnings. + * Used with `minSeverityLevel` to filter warnings by importance. + */ +type WarningSeverity = "info" | "warn" | "error"; +type WarningCode = "NON_CANONICAL_ADDRESS" | "NON_CANONICAL_ROUTING_ID" | "MEMO_IGNORED_FOR_MUXED" | "MEMO_PRESENT_WITH_MUXED" | "CONTRACT_SENDER_DETECTED" | "MEMO_TEXT_UNROUTABLE" | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" | "INVALID_DESTINATION" | "SANITIZED_HIDDEN_CHARS"; type Warning = { code: "NON_CANONICAL_ADDRESS" | "NON_CANONICAL_ROUTING_ID"; severity: "warn"; @@ -128,6 +133,12 @@ type RoutingInput = { memoType: string; memoValue: string | null; sourceAccount: string | null; + /** + * Minimum severity level for warnings to include in the result. + * Warnings below this threshold are filtered out. + * Defaults to `'info'` (all warnings are returned). + */ + minSeverityLevel?: WarningSeverity; }; type KnownMemoType = "none" | "id" | "text" | "hash" | "return"; type RoutingResult = { @@ -165,6 +176,70 @@ declare function extractRouting(input: RoutingInput): RoutingResult; */ declare function extractRoutingFromTx(tx: any): RoutingResult | null; +/** + * SEP-0007 URI Parser + * + * Parses `web+stellar:pay?...` URIs (commonly from QR code scanners) + * and delegates to the core `extractRouting` logic to produce canonical + * routing information. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0007.md + */ + +interface SEP7PayParams { + destination: string; + amount?: string; + assetCode?: string; + assetIssuer?: string; + memo?: string; + memoType?: string; + callback?: string; + msg?: string; + networkPassphrase?: string; + originDomain?: string; + signature?: string; +} +type ExtractRoutingFromURIResult = { + success: true; + routing: RoutingResult; + rawParams: SEP7PayParams; +} | { + success: false; + error: string; + code: "INVALID_URI" | "UNSUPPORTED_OPERATION" | "MISSING_DESTINATION" | "INVALID_ENCODING"; +}; +/** + * Parse a SEP-0007 URI and extract canonical routing information. + * + * Supported format: + * web+stellar:pay?destination=&memo=&memo_type= + * + * The function safely decodes URL-encoded parameters, validates the scheme, + * and passes `destination` + `memo` into `extractRouting()` for canonical + * output (handling G-addresses, M-addresses, C-addresses, etc.). + * + * @param uriString - The raw URI string from a QR code scanner or deeplink + * @returns ExtractRoutingFromURIResult + * + * @example + * ```ts + * const result = extractRoutingFromURI( + * "web+stellar:pay?destination=GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI&memo=123&memo_type=MEMO_ID" + * ); + * if (result.success) { + * console.log(result.routing.destinationBaseAccount); // "GAYC..." + * console.log(result.routing.routingId); // "123" + * } + * ``` + */ +declare function extractRoutingFromURI(uriString: string): ExtractRoutingFromURIResult; +/** + * Type guard for successful URI parsing. + */ +declare function isSuccessfulURIResult(result: ExtractRoutingFromURIResult): result is ExtractRoutingFromURIResult & { + success: true; +}; + type NormalizeResult = { normalized: string | null; warnings: Warning[]; @@ -178,4 +253,4 @@ type NormalizeResult = { */ declare function normalizeMemoTextId(s: string): NormalizeResult; -export { type Address, type AddressKind, AddressParseError, type ErrorCode, ExtractRoutingError, type KnownMemoType, type NormalizeResult, type ParseResult, type RoutingInput, type RoutingResult, type RoutingSource, type Warning, type WarningCode, decodeMuxed, detect, encodeMuxed, extractRouting, extractRoutingFromTx, normalizeMemoTextId, parse, routingIdAsBigInt, validate }; +export { type Address, type AddressKind, AddressParseError, type ErrorCode, ExtractRoutingError, type ExtractRoutingFromURIResult, type KnownMemoType, type NormalizeResult, type ParseResult, type RoutingInput, type RoutingResult, type RoutingSource, type SEP7PayParams, type Warning, type WarningCode, type WarningSeverity, decodeMuxed, detect, encodeMuxed, extractRouting, extractRoutingFromTx, extractRoutingFromURI, isSuccessfulURIResult, normalizeMemoTextId, parse, routingIdAsBigInt, validate }; diff --git a/packages/core-ts/dist/index.js b/packages/core-ts/dist/index.js index 55fc89ab..2fbc13f2 100644 --- a/packages/core-ts/dist/index.js +++ b/packages/core-ts/dist/index.js @@ -37,6 +37,8 @@ __export(index_exports, { encodeMuxed: () => encodeMuxed, extractRouting: () => extractRouting, extractRoutingFromTx: () => extractRoutingFromTx, + extractRoutingFromURI: () => extractRoutingFromURI, + isSuccessfulURIResult: () => isSuccessfulURIResult, normalizeMemoTextId: () => normalizeMemoTextId, parse: () => parse, routingIdAsBigInt: () => routingIdAsBigInt, @@ -233,6 +235,15 @@ function normalizeMemoTextId(s) { } // src/routing/extract.ts +var SEVERITY_ORDER = { + info: 0, + warn: 1, + error: 2 +}; +function filterBySeverity(warnings, minSeverity) { + const threshold = SEVERITY_ORDER[minSeverity]; + return warnings.filter((w) => SEVERITY_ORDER[w.severity] >= threshold); +} var ExtractRoutingError = class _ExtractRoutingError extends Error { constructor(message) { super(message); @@ -240,6 +251,16 @@ var ExtractRoutingError = class _ExtractRoutingError extends Error { Object.setPrototypeOf(this, _ExtractRoutingError.prototype); } }; +function sanitizeDestination(destination) { + if (!destination || typeof destination !== "string") { + return { sanitized: destination, wasSanitized: false }; + } + const sanitized = destination.replace(/[\p{C}\s]/gu, ""); + return { + sanitized, + wasSanitized: sanitized !== destination + }; +} function assertRoutableAddress(destination) { if (!destination || typeof destination !== "string") { throw new ExtractRoutingError( @@ -254,17 +275,29 @@ function assertRoutableAddress(destination) { } } function extractRouting(input) { - assertRoutableAddress(input.destination); + const { sanitized: destination, wasSanitized } = sanitizeDestination( + input.destination + ); + assertRoutableAddress(destination); + const minSeverity = input.minSeverityLevel ?? "info"; + const sanitizedWarning = wasSanitized ? { + code: "SANITIZED_HIDDEN_CHARS", + severity: "info", + message: "Destination address contained non-printable characters or whitespace that were stripped." + } : null; + const initWarnings = (additional = []) => { + return sanitizedWarning ? [sanitizedWarning, ...additional] : [...additional]; + }; let parsed; try { - parsed = parse(input.destination); + parsed = parse(destination); } catch (error) { if (error instanceof AddressParseError) { return { destinationBaseAccount: null, routingId: null, routingSource: "none", - warnings: [], + warnings: filterBySeverity(initWarnings(), minSeverity), destinationError: { code: error.code, message: error.message @@ -278,11 +311,11 @@ function extractRouting(input) { destinationBaseAccount: null, routingId: null, routingSource: "none", - warnings: [] + warnings: filterBySeverity(initWarnings(), minSeverity) }; } if (parsed.kind === "C") { - const warnings2 = [...parsed.warnings]; + const warnings2 = initWarnings(parsed.warnings); warnings2.push({ code: "INVALID_DESTINATION", severity: "error", @@ -295,11 +328,11 @@ function extractRouting(input) { destinationBaseAccount: null, routingId: null, routingSource: "none", - warnings: warnings2 + warnings: filterBySeverity(warnings2, minSeverity) }; } if (parsed.kind === "M") { - const warnings2 = [...parsed.warnings]; + const warnings2 = initWarnings(parsed.warnings); if (input.memoType === "id" || input.memoType === "text" && /^\d+$/.test(input.memoValue ?? "")) { warnings2.push({ code: "MEMO_PRESENT_WITH_MUXED", @@ -317,12 +350,12 @@ function extractRouting(input) { destinationBaseAccount: parsed.baseG, routingId: parsed.muxedId, routingSource: "muxed", - warnings: warnings2 + warnings: filterBySeverity(warnings2, minSeverity) }; } let routingId = null; let routingSource = "none"; - const warnings = [...parsed.warnings]; + const warnings = initWarnings(parsed.warnings); if (input.memoType === "id") { const rawValue = input.memoValue ?? ""; const norm = normalizeMemoTextId(rawValue); @@ -370,7 +403,7 @@ function extractRouting(input) { destinationBaseAccount: parsed.address, routingId, routingSource, - warnings + warnings: filterBySeverity(warnings, minSeverity) }; } @@ -388,6 +421,98 @@ function extractRoutingFromTx(tx) { }); } +// src/routing/extractFromURI.ts +function mapMemoType(sep7MemoType) { + if (!sep7MemoType) return "none"; + const upper = sep7MemoType.toUpperCase(); + switch (upper) { + case "MEMO_ID": + return "id"; + case "MEMO_TEXT": + return "text"; + case "MEMO_HASH": + return "hash"; + case "MEMO_RETURN": + return "return"; + default: + return "none"; + } +} +function extractRoutingFromURI(uriString) { + if (!uriString.startsWith("web+stellar:")) { + return { + success: false, + error: "URI must use 'web+stellar:' scheme", + code: "INVALID_URI" + }; + } + const withoutScheme = uriString.slice("web+stellar:".length); + const [operation, queryString] = withoutScheme.includes("?") ? withoutScheme.split("?", 2) : [withoutScheme, ""]; + if (operation !== "pay") { + return { + success: false, + error: `Unsupported operation: '${operation}'. Only 'pay' is supported for routing extraction.`, + code: "UNSUPPORTED_OPERATION" + }; + } + let params; + try { + params = new URLSearchParams(queryString); + } catch { + return { + success: false, + error: "Failed to parse URI query parameters", + code: "INVALID_ENCODING" + }; + } + const destination = params.get("destination"); + if (!destination || destination.trim() === "") { + return { + success: false, + error: "Missing required 'destination' parameter", + code: "MISSING_DESTINATION" + }; + } + const rawParams = { + destination: safelyDecode(destination.trim()) ?? destination.trim(), + amount: safelyDecode(params.get("amount")), + assetCode: safelyDecode(params.get("asset_code")), + assetIssuer: safelyDecode(params.get("asset_issuer")), + memo: safelyDecode(params.get("memo")), + memoType: safelyDecode(params.get("memo_type")), + callback: safelyDecode(params.get("callback")), + msg: safelyDecode(params.get("msg")), + networkPassphrase: safelyDecode(params.get("network_passphrase")), + originDomain: safelyDecode(params.get("origin_domain")), + signature: safelyDecode(params.get("signature")) + }; + const routingInput = { + destination: rawParams.destination, + memoType: mapMemoType(rawParams.memoType), + memoValue: rawParams.memo ?? null, + sourceAccount: null + }; + const routingResult = extractRouting(routingInput); + return { + success: true, + routing: routingResult, + rawParams + }; +} +function safelyDecode(value) { + if (value === null || value === "") { + return void 0; + } + try { + return decodeURIComponent(value); + } catch { + return value; + } +} +function isSuccessfulURIResult(result) { + return result.success === true; +} + // src/routing/types.ts function routingIdAsBigInt(routingId) { if (routingId === null) { @@ -404,6 +529,8 @@ function routingIdAsBigInt(routingId) { encodeMuxed, extractRouting, extractRoutingFromTx, + extractRoutingFromURI, + isSuccessfulURIResult, normalizeMemoTextId, parse, routingIdAsBigInt, diff --git a/packages/core-ts/dist/index.mjs b/packages/core-ts/dist/index.mjs index 0f44545b..ac29fbe4 100644 --- a/packages/core-ts/dist/index.mjs +++ b/packages/core-ts/dist/index.mjs @@ -1,79 +1,12 @@ -// src/address/errors.ts -var AddressParseError = class _AddressParseError extends Error { - code; - input; - constructor(code, input, message) { - super(message); - this.name = "AddressParseError"; - this.code = code; - this.input = input; - Object.setPrototypeOf(this, _AddressParseError.prototype); - } -}; - -// src/address/detect.ts -import StellarSdk from "@stellar/stellar-sdk"; -var { StrKey } = StellarSdk; -var BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; -function decodeBase32(input) { - const s = input.toUpperCase().replace(/=+$/, ""); - const byteCount = Math.floor(s.length * 5 / 8); - const result = new Uint8Array(byteCount); - let buffer = 0; - let bitsLeft = 0; - let byteIndex = 0; - for (const ch of s) { - const value = BASE32_CHARS.indexOf(ch); - if (value === -1) throw new Error(`Invalid base32 character: ${ch}`); - buffer = buffer << 5 | value; - bitsLeft += 5; - if (bitsLeft >= 8) { - if (byteIndex < byteCount) { - result[byteIndex++] = buffer >> bitsLeft - 8 & 255; - } - bitsLeft -= 8; - buffer &= (1 << bitsLeft) - 1; - } - } - return result; -} -function crc16(bytes) { - let crc = 0; - for (const byte of bytes) { - crc ^= byte << 8; - for (let i = 0; i < 8; i++) { - if (crc & 32768) { - crc = crc << 1 ^ 4129; - } else { - crc <<= 1; - } - crc &= 65535; - } - } - return crc; -} -function detect(address) { - if (!address) return "invalid"; - const up = address.toUpperCase(); - if (StrKey.isValidEd25519PublicKey(up)) return "G"; - if (StrKey.isValidMed25519PublicKey(up)) return "M"; - if (StrKey.isValidContract(up)) return "C"; - try { - const prefix = up[0]; - if (prefix === "M") { - const decoded = decodeBase32(up); - if (decoded.length === 43 && decoded[0] === 96) { - const data = decoded.slice(0, decoded.length - 2); - const checksum = decoded[decoded.length - 2] | decoded[decoded.length - 1] << 8; - if (crc16(data) === checksum) { - return "M"; - } - } - } - } catch { - } - return "invalid"; -} +import { + AddressParseError, + ExtractRoutingError, + decodeMuxed, + detect, + extractRouting, + normalizeMemoTextId, + parse +} from "./chunk-JZUTXHBC.mjs"; // src/address/validate.ts function validate(address, kind) { @@ -83,62 +16,8 @@ function validate(address, kind) { return detected === kind; } -// src/muxed/decode.ts -import { MuxedAccount } from "@stellar/stellar-sdk"; -function decodeMuxed(mAddress) { - const muxed = MuxedAccount.fromAddress(mAddress, "0"); - return { - baseG: muxed.baseAccount().accountId(), - id: BigInt(muxed.id()) - }; -} - -// src/address/parse.ts -function parse(address) { - const up = address.toUpperCase(); - const kind = detect(up); - if (kind === "invalid") { - const first = up[0]; - if (first === "G" || first === "M" || first === "C") { - throw new AddressParseError( - "INVALID_CHECKSUM", - address, - "Invalid address checksum" - ); - } - throw new AddressParseError("UNKNOWN_PREFIX", address, "Invalid address"); - } - switch (kind) { - case "G": - return { kind: "G", address: up, warnings: [] }; - case "C": - return { kind: "C", address: up, warnings: [] }; - case "M": { - try { - const decoded = decodeMuxed(up); - return { - kind: "M", - address: up, - baseG: decoded.baseG, - muxedId: decoded.id, - warnings: [] - }; - } catch (error) { - if (error instanceof AddressParseError) { - throw error; - } - throw new AddressParseError( - "INVALID_CHECKSUM", - address, - "Invalid muxed address" - ); - } - } - } -} - // src/muxed/encode.ts -import { StrKey as StrKey2 } from "@stellar/stellar-sdk"; +import { StrKey } from "@stellar/stellar-sdk"; var MAX_UINT64 = 18446744073709551615n; function encodeMuxed(baseG, id) { if (typeof id !== "bigint") { @@ -147,199 +26,119 @@ function encodeMuxed(baseG, id) { if (id < 0n || id > MAX_UINT64) { throw new RangeError(`ID is outside the uint64 range: 0 to ${MAX_UINT64}`); } - if (!StrKey2.isValidEd25519PublicKey(baseG)) { + if (!StrKey.isValidEd25519PublicKey(baseG)) { throw new Error(`Invalid base G address (Ed25519 public key expected)`); } - const pubkeyBytes = Buffer.from(StrKey2.decodeEd25519PublicKey(baseG)); + const pubkeyBytes = Buffer.from(StrKey.decodeEd25519PublicKey(baseG)); const idBytes = Buffer.alloc(8); idBytes.writeBigUInt64BE(id); - return StrKey2.encodeMed25519PublicKey(Buffer.concat([pubkeyBytes, idBytes])); + return StrKey.encodeMed25519PublicKey(Buffer.concat([pubkeyBytes, idBytes])); } -// src/routing/memo.ts -var UINT64_MAX = BigInt("18446744073709551615"); -function normalizeMemoTextId(s) { - const warnings = []; - if (s.length === 0 || !/^\d+$/.test(s)) { - return { normalized: null, warnings }; - } - let normalized = s.replace(/^0+/, ""); - if (normalized === "") { - normalized = "0"; - } - if (normalized !== s) { - warnings.push({ - code: "NON_CANONICAL_ROUTING_ID", - severity: "warn", - message: "Memo routing ID had leading zeros. Normalized to canonical decimal.", - normalization: { original: s, normalized } - }); - } - try { - const val = BigInt(normalized); - if (val > UINT64_MAX) { - return { normalized: null, warnings }; - } - } catch { - return { normalized: null, warnings }; - } - return { normalized, warnings }; +// src/routing/extractFromTx.ts +import StellarSdk from "@stellar/stellar-sdk"; +var { Transaction } = StellarSdk; +function extractRoutingFromTx(tx) { + const op = tx.operations[0]; + if (!op || op.type !== "payment") return null; + return extractRouting({ + destination: op.destination, + memoType: tx.memo.type, + memoValue: tx.memo.value?.toString() ?? null, + sourceAccount: tx.source ?? null + }); } -// src/routing/extract.ts -var ExtractRoutingError = class _ExtractRoutingError extends Error { - constructor(message) { - super(message); - this.name = "ExtractRoutingError"; - Object.setPrototypeOf(this, _ExtractRoutingError.prototype); - } -}; -function assertRoutableAddress(destination) { - if (!destination || typeof destination !== "string") { - throw new ExtractRoutingError( - "Invalid input: destination must be a non-empty string." - ); - } - const prefix = destination.trim()[0]?.toUpperCase(); - if (prefix !== "G" && prefix !== "M") { - throw new ExtractRoutingError( - `Invalid destination: expected a G or M address, got "${destination}".` - ); +// src/routing/extractFromURI.ts +function mapMemoType(sep7MemoType) { + if (!sep7MemoType) return "none"; + const upper = sep7MemoType.toUpperCase(); + switch (upper) { + case "MEMO_ID": + return "id"; + case "MEMO_TEXT": + return "text"; + case "MEMO_HASH": + return "hash"; + case "MEMO_RETURN": + return "return"; + default: + return "none"; } } -function extractRouting(input) { - assertRoutableAddress(input.destination); - let parsed; - try { - parsed = parse(input.destination); - } catch (error) { - if (error instanceof AddressParseError) { - return { - destinationBaseAccount: null, - routingId: null, - routingSource: "none", - warnings: [], - destinationError: { - code: error.code, - message: error.message - } - }; - } - throw error; - } - if (parsed.kind === "invalid") { +function extractRoutingFromURI(uriString) { + if (!uriString.startsWith("web+stellar:")) { return { - destinationBaseAccount: null, - routingId: null, - routingSource: "none", - warnings: [] + success: false, + error: "URI must use 'web+stellar:' scheme", + code: "INVALID_URI" }; } - if (parsed.kind === "C") { - const warnings2 = [...parsed.warnings]; - warnings2.push({ - code: "INVALID_DESTINATION", - severity: "error", - message: "C address is not a valid destination", - context: { - destinationKind: "C" - } - }); + const withoutScheme = uriString.slice("web+stellar:".length); + const [operation, queryString] = withoutScheme.includes("?") ? withoutScheme.split("?", 2) : [withoutScheme, ""]; + if (operation !== "pay") { return { - destinationBaseAccount: null, - routingId: null, - routingSource: "none", - warnings: warnings2 + success: false, + error: `Unsupported operation: '${operation}'. Only 'pay' is supported for routing extraction.`, + code: "UNSUPPORTED_OPERATION" }; } - if (parsed.kind === "M") { - const warnings2 = [...parsed.warnings]; - if (input.memoType === "id" || input.memoType === "text" && /^\d+$/.test(input.memoValue ?? "")) { - warnings2.push({ - code: "MEMO_PRESENT_WITH_MUXED", - severity: "warn", - message: "Routing ID found in both M-address and Memo. M-address ID takes precedence." - }); - } else if (input.memoType !== "none") { - warnings2.push({ - code: "MEMO_IGNORED_FOR_MUXED", - severity: "info", - message: "Memo present with M-address. Any potential routing ID in memo is ignored." - }); - } + let params; + try { + params = new URLSearchParams(queryString); + } catch { return { - destinationBaseAccount: parsed.baseG, - routingId: parsed.muxedId, - routingSource: "muxed", - warnings: warnings2 + success: false, + error: "Failed to parse URI query parameters", + code: "INVALID_ENCODING" }; } - let routingId = null; - let routingSource = "none"; - const warnings = [...parsed.warnings]; - if (input.memoType === "id") { - const rawValue = input.memoValue ?? ""; - const norm = normalizeMemoTextId(rawValue); - if (norm.normalized) { - const parsedMemoId = BigInt(norm.normalized); - routingId = parsedMemoId.toString(); - routingSource = "memo"; - warnings.push(...norm.warnings); - } else { - routingSource = "none"; - warnings.push(...norm.warnings); - warnings.push({ - code: "MEMO_ID_INVALID_FORMAT", - severity: "warn", - message: "MEMO_ID was empty, non-numeric, or exceeded uint64 max." - }); - } - } else if (input.memoType === "text" && input.memoValue) { - const norm = normalizeMemoTextId(input.memoValue); - if (norm.normalized) { - routingId = norm.normalized; - routingSource = "memo"; - warnings.push(...norm.warnings); - } else { - warnings.push({ - code: "MEMO_TEXT_UNROUTABLE", - severity: "warn", - message: "MEMO_TEXT was not a valid numeric uint64." - }); - } - } else if (input.memoType === "hash" || input.memoType === "return") { - warnings.push({ - code: "MEMO_TEXT_UNROUTABLE", - severity: "warn", - message: `Memo type ${input.memoType} is not supported for routing.` - }); - } else if (input.memoType !== "none") { - warnings.push({ - code: "MEMO_TEXT_UNROUTABLE", - severity: "warn", - message: `Unrecognized memo type: ${input.memoType}` - }); + const destination = params.get("destination"); + if (!destination || destination.trim() === "") { + return { + success: false, + error: "Missing required 'destination' parameter", + code: "MISSING_DESTINATION" + }; } + const rawParams = { + destination: safelyDecode(destination.trim()) ?? destination.trim(), + amount: safelyDecode(params.get("amount")), + assetCode: safelyDecode(params.get("asset_code")), + assetIssuer: safelyDecode(params.get("asset_issuer")), + memo: safelyDecode(params.get("memo")), + memoType: safelyDecode(params.get("memo_type")), + callback: safelyDecode(params.get("callback")), + msg: safelyDecode(params.get("msg")), + networkPassphrase: safelyDecode(params.get("network_passphrase")), + originDomain: safelyDecode(params.get("origin_domain")), + signature: safelyDecode(params.get("signature")) + }; + const routingInput = { + destination: rawParams.destination, + memoType: mapMemoType(rawParams.memoType), + memoValue: rawParams.memo ?? null, + sourceAccount: null + }; + const routingResult = extractRouting(routingInput); return { - destinationBaseAccount: parsed.address, - routingId, - routingSource, - warnings + success: true, + routing: routingResult, + rawParams }; } - -// src/routing/extractFromTx.ts -import StellarSdk2 from "@stellar/stellar-sdk"; -var { Transaction } = StellarSdk2; -function extractRoutingFromTx(tx) { - const op = tx.operations[0]; - if (!op || op.type !== "payment") return null; - return extractRouting({ - destination: op.destination, - memoType: tx.memo.type, - memoValue: tx.memo.value?.toString() ?? null, - sourceAccount: tx.source ?? null - }); +function safelyDecode(value) { + if (value === null || value === "") { + return void 0; + } + try { + return decodeURIComponent(value); + } catch { + return value; + } +} +function isSuccessfulURIResult(result) { + return result.success === true; } // src/routing/types.ts @@ -357,6 +156,8 @@ export { encodeMuxed, extractRouting, extractRoutingFromTx, + extractRoutingFromURI, + isSuccessfulURIResult, normalizeMemoTextId, parse, routingIdAsBigInt, diff --git a/packages/core-ts/src/address/types.ts b/packages/core-ts/src/address/types.ts index d74fc2a5..56bce227 100644 --- a/packages/core-ts/src/address/types.ts +++ b/packages/core-ts/src/address/types.ts @@ -16,7 +16,8 @@ export type WarningCode = | "MEMO_TEXT_UNROUTABLE" | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" - | "INVALID_DESTINATION"; + | "INVALID_DESTINATION" + | "SANITIZED_HIDDEN_CHARS"; export type Warning = | { diff --git a/packages/core-ts/src/routing/extract.ts b/packages/core-ts/src/routing/extract.ts index 5f967e6e..264f8dd6 100644 --- a/packages/core-ts/src/routing/extract.ts +++ b/packages/core-ts/src/routing/extract.ts @@ -26,6 +26,23 @@ export class ExtractRoutingError extends Error { } } +/** + * Strips non-printable characters, Unicode control/format characters, and whitespace. + */ +function sanitizeDestination(destination: string): { + sanitized: string; + wasSanitized: boolean; +} { + if (!destination || typeof destination !== "string") { + return { sanitized: destination, wasSanitized: false }; + } + const sanitized = destination.replace(/[\p{C}\s]/gu, ""); + return { + sanitized, + wasSanitized: sanitized !== destination, + }; +} + /** * Validates that the destination string passes the minimum structural * requirements for a Stellar address before routing logic is applied. @@ -58,20 +75,38 @@ 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 { sanitized: destination, wasSanitized } = sanitizeDestination( + input.destination + ); + + assertRoutableAddress(destination); const minSeverity = input.minSeverityLevel ?? "info"; + const sanitizedWarning: Warning | null = wasSanitized + ? { + code: "SANITIZED_HIDDEN_CHARS", + severity: "info", + message: + "Destination address contained non-printable characters or whitespace that were stripped.", + } + : null; + + const initWarnings = (additional: Warning[] = []): Warning[] => { + return sanitizedWarning + ? [sanitizedWarning, ...additional] + : [...additional]; + }; let parsed; try { - parsed = parse(input.destination); + parsed = parse(destination); } catch (error) { if (error instanceof AddressParseError) { return { destinationBaseAccount: null, routingId: null, routingSource: "none", - warnings: [], + warnings: filterBySeverity(initWarnings(), minSeverity), destinationError: { code: error.code, message: error.message, @@ -86,12 +121,12 @@ export function extractRouting(input: RoutingInput): RoutingResult { destinationBaseAccount: null, routingId: null, routingSource: "none", - warnings: [], + warnings: filterBySeverity(initWarnings(), minSeverity), }; } if (parsed.kind === "C") { - const warnings: Warning[] = [...parsed.warnings]; + const warnings: Warning[] = initWarnings(parsed.warnings); warnings.push({ code: "INVALID_DESTINATION", @@ -111,7 +146,7 @@ export function extractRouting(input: RoutingInput): RoutingResult { } if (parsed.kind === "M") { - const warnings: Warning[] = [...parsed.warnings]; + const warnings: Warning[] = initWarnings(parsed.warnings); if ( input.memoType === "id" || @@ -142,7 +177,7 @@ export function extractRouting(input: RoutingInput): RoutingResult { let routingId: string | bigint | null = null; let routingSource: "none" | "memo" = "none"; - const warnings: Warning[] = [...parsed.warnings]; + const warnings: Warning[] = initWarnings(parsed.warnings); if (input.memoType === "id") { const rawValue = input.memoValue ?? ""; diff --git a/packages/core-ts/src/routing/extractFromURI.ts b/packages/core-ts/src/routing/extractFromURI.ts index f8cdb009..28900592 100644 --- a/packages/core-ts/src/routing/extractFromURI.ts +++ b/packages/core-ts/src/routing/extractFromURI.ts @@ -131,7 +131,7 @@ export function extractRoutingFromURI(uriString: string): ExtractRoutingFromURIR // 6. Extract optional parameters with safe decoding const rawParams: SEP7PayParams = { - destination: safelyDecode(destination.trim()), + destination: safelyDecode(destination.trim()) ?? destination.trim(), amount: safelyDecode(params.get("amount")), assetCode: safelyDecode(params.get("asset_code")), assetIssuer: safelyDecode(params.get("asset_issuer")), diff --git a/packages/core-ts/src/spec/runner.test.ts b/packages/core-ts/src/spec/runner.test.ts index fc8854a2..7b35e7ec 100644 --- a/packages/core-ts/src/spec/runner.test.ts +++ b/packages/core-ts/src/spec/runner.test.ts @@ -40,7 +40,11 @@ describe("Normative Vector Tests", () => { switch (c.module) { case "detect": { const kind = detect(c.input.address); - expect(kind).toBe(c.expected.kind); + if (c.expected.kind === null) { + expect(kind).toBe("invalid"); + } else { + expect(kind).toBe(c.expected.kind); + } break; } case "muxed_encode": { diff --git a/packages/core-ts/src/test/extract.test.ts b/packages/core-ts/src/test/extract.test.ts index 6b90a14d..f6a67c24 100644 --- a/packages/core-ts/src/test/extract.test.ts +++ b/packages/core-ts/src/test/extract.test.ts @@ -281,3 +281,82 @@ describe("multi-warning: NON_CANONICAL_ROUTING_ID + MEMO_ID_INVALID_FORMAT", () } }); }); + +// ─── 8. SANITIZED_HIDDEN_CHARS ──────────────────────────────────────────────── + +describe("SANITIZED_HIDDEN_CHARS warning & hidden character sanitization", () => { + it("sanitizes zero-width spaces, BOM, and whitespace from G-address", () => { + const dirtyG = `\u200B\uFEFF \r\n${G_ADDRESS} \t\u200D\u2060\n`; + const result = extractRouting(input(dirtyG, "id", "100")); + + expect(result.destinationBaseAccount).toBe(G_ADDRESS); + expect(result.routingId).toBe("100"); + expect(result.routingSource).toBe("memo"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("SANITIZED_HIDDEN_CHARS"); + expect(result.warnings[0].severity).toBe("info"); + expect(result.warnings[0].message).toContain("non-printable characters or whitespace"); + }); + + it("sanitizes zero-width joiners and formatting characters from M-address", () => { + const dirtyM = `\u200C${M_ADDRESS}\u200E\u200F\r\n`; + const result = extractRouting(input(dirtyM)); + + expect(result.destinationBaseAccount).toBe(G_ADDRESS); + expect(result.routingId).toBe(ROUTING_ID); + expect(result.routingSource).toBe("muxed"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("SANITIZED_HIDDEN_CHARS"); + expect(result.warnings[0].severity).toBe("info"); + }); + + it("sanitizes ASCII control characters and bidirectional overrides", () => { + const dirtyG = `\x00\x07\x1B\u202A${G_ADDRESS}\u202E\x7F`; + const result = extractRouting(input(dirtyG)); + + expect(result.destinationBaseAccount).toBe(G_ADDRESS); + expect(result.routingSource).toBe("none"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("SANITIZED_HIDDEN_CHARS"); + }); + + it("emits multiple warnings when hidden chars are sanitized and memo conflicts with M-address", () => { + const dirtyM = ` \u200B${M_ADDRESS}\n`; + const result = extractRouting(input(dirtyM, "id", "99999")); + + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0].code).toBe("SANITIZED_HIDDEN_CHARS"); + expect(result.warnings[0].severity).toBe("info"); + expect(result.warnings[1].code).toBe("MEMO_PRESENT_WITH_MUXED"); + expect(result.warnings[1].severity).toBe("warn"); + }); + + it("filters out info-level SANITIZED_HIDDEN_CHARS when minSeverityLevel is 'warn'", () => { + const dirtyM = ` \u200B${M_ADDRESS}\n`; + const result = extractRouting({ + ...input(dirtyM, "id", "99999"), + minSeverityLevel: "warn", + }); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("MEMO_PRESENT_WITH_MUXED"); + }); + + it("does not emit SANITIZED_HIDDEN_CHARS for clean addresses", () => { + const result = extractRouting(input(G_ADDRESS, "id", "100")); + expect(result.warnings).toHaveLength(0); + }); + + it("throws ExtractRoutingError when input contains only whitespace and hidden characters", () => { + expect(() => + extractRouting(input(" \u200B\uFEFF\t\r\n ")) + ).toThrow(ExtractRoutingError); + }); + + it("throws ExtractRoutingError when sanitized address has invalid prefix", () => { + expect(() => + extractRouting(input("\u200B XAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI ")) + ).toThrow(ExtractRoutingError); + }); +}); + diff --git a/packages/spec/package.json b/packages/spec/package.json index 465293a7..dede2109 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@stellar-address-kit/spec", - "version": "1.0.0", + "version": "1.0.1", "description": "Shared JSON test vectors and schema for Stellar Address Kit", "main": "index.js", "types": "index.d.ts", diff --git a/packages/spec/schema.json b/packages/spec/schema.json index 86d5f006..da4c7e2b 100644 --- a/packages/spec/schema.json +++ b/packages/spec/schema.json @@ -66,7 +66,7 @@ "enum": [ "MEMO_IGNORED_FOR_MUXED", "MEMO_PRESENT_WITH_MUXED", "CONTRACT_SENDER_DETECTED", "MEMO_TEXT_UNROUTABLE", - "MEMO_ID_INVALID_FORMAT" + "MEMO_ID_INVALID_FORMAT", "SANITIZED_HIDDEN_CHARS" ] }, "severity": { "type": "string", "enum": ["info", "warn", "error"] }, diff --git a/packages/spec/vectors.json b/packages/spec/vectors.json index 8a98f208..2cfd82e7 100644 --- a/packages/spec/vectors.json +++ b/packages/spec/vectors.json @@ -1,5 +1,6 @@ { - "spec_version": "1.0.0", + "spec_version": "1.0.1", + "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", @@ -72,10 +73,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "0" }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "muxed_decode", @@ -88,10 +86,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "1" }, - "tags": [ - "positive", - "edge" - ] + "tags": ["positive", "edge"] }, { "module": "muxed_decode", @@ -104,11 +99,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "9007199254740992" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -121,11 +112,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "9007199254740993" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -138,11 +125,7 @@ "baseG": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", "id": "18446744073709551615" }, - "tags": [ - "positive", - "edge", - "interop" - ] + "tags": ["positive", "edge", "interop"] }, { "module": "muxed_decode", @@ -153,10 +136,7 @@ "expected": { "expected_error": "invalid encoded string" }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] }, { "module": "detect", @@ -179,10 +159,7 @@ } ] }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] }, { "module": "extract_routing", @@ -197,10 +174,7 @@ "routingSource": "muxed", "warnings": [] }, - "tags": [ - "positive", - "interop" - ] + "tags": ["positive", "interop"] }, { "module": "extract_routing", @@ -224,9 +198,7 @@ } ] }, - "tags": [ - "negative" - ] + "tags": ["negative"] }, { "module": "extract_routing", @@ -242,9 +214,23 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive" - ] + "tags": ["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", @@ -260,9 +246,7 @@ "routingSource": "memo", "warnings": [] }, - "tags": [ - "positive" - ] + "tags": ["positive"] }, { "module": "extract_routing", @@ -288,10 +272,107 @@ } ] }, - "tags": [ - "negative", - "edge" - ] + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "non-base32 digit '0' injected into address must be rejected", + "input": { + "address": "GA0CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "punctuation injected into address must be rejected", + "input": { + "address": "GA!CUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "detect", + "description": "embedded null byte must be rejected without crashing or truncating", + "input": { + "address": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRS\u0000" + }, + "expected": { + "kind": null, + "address": null, + "warnings": [ + { + "code": "INVALID_STRKEY", + "severity": "error", + "message": "address contains characters outside the base32 alphabet" + } + ] + }, + "tags": ["negative", "edge"] + }, + { + "module": "extract_routing", + "description": "G-address with invisible Unicode characters and whitespace sanitized", + "input": { + "destination": "\u200B \r\nGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\t\u200D\u2060\uFEFF\n", + "memoType": "id", + "memoValue": "100" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": "100", + "routingSource": "memo", + "warnings": [ + { + "code": "SANITIZED_HIDDEN_CHARS", + "severity": "info", + "message": "Destination address contained non-printable characters or whitespace that were stripped." + } + ] + }, + "tags": ["positive", "edge"] + }, + { + "module": "extract_routing", + "description": "M-address with zero-width joiner, non-joiner, and control characters sanitized", + "input": { + "destination": "\u200CMAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAHOO2\u200D\u2060\n", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": "1", + "routingSource": "muxed", + "warnings": [ + { + "code": "SANITIZED_HIDDEN_CHARS", + "severity": "info", + "message": "Destination address contained non-printable characters or whitespace that were stripped." + } + ] + }, + "tags": ["positive", "edge"] } ] -} +} \ No newline at end of file diff --git a/spec/schema.json b/spec/schema.json index 86d5f006..da4c7e2b 100644 --- a/spec/schema.json +++ b/spec/schema.json @@ -66,7 +66,7 @@ "enum": [ "MEMO_IGNORED_FOR_MUXED", "MEMO_PRESENT_WITH_MUXED", "CONTRACT_SENDER_DETECTED", "MEMO_TEXT_UNROUTABLE", - "MEMO_ID_INVALID_FORMAT" + "MEMO_ID_INVALID_FORMAT", "SANITIZED_HIDDEN_CHARS" ] }, "severity": { "type": "string", "enum": ["info", "warn", "error"] }, diff --git a/spec/vectors.json b/spec/vectors.json index 97103b43..2cfd82e7 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -1,5 +1,5 @@ { - "spec_version": "1.0.0", + "spec_version": "1.0.1", "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": [ { @@ -330,6 +330,49 @@ ] }, "tags": ["negative", "edge"] + }, + { + "module": "extract_routing", + "description": "G-address with invisible Unicode characters and whitespace sanitized", + "input": { + "destination": "\u200B \r\nGAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI\t\u200D\u2060\uFEFF\n", + "memoType": "id", + "memoValue": "100" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": "100", + "routingSource": "memo", + "warnings": [ + { + "code": "SANITIZED_HIDDEN_CHARS", + "severity": "info", + "message": "Destination address contained non-printable characters or whitespace that were stripped." + } + ] + }, + "tags": ["positive", "edge"] + }, + { + "module": "extract_routing", + "description": "M-address with zero-width joiner, non-joiner, and control characters sanitized", + "input": { + "destination": "\u200CMAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQACAAAAAAAAAAAAHOO2\u200D\u2060\n", + "memoType": "none" + }, + "expected": { + "destinationBaseAccount": "GAYCUYT553C5LHVE2XPW5GMEJT4BXGM7AHMJWLAPZP53KJO7EIQADRSI", + "routingId": "1", + "routingSource": "muxed", + "warnings": [ + { + "code": "SANITIZED_HIDDEN_CHARS", + "severity": "info", + "message": "Destination address contained non-printable characters or whitespace that were stripped." + } + ] + }, + "tags": ["positive", "edge"] } ] } \ No newline at end of file From fbd8612634f593ad71ebca1ed502a4f3ffe0f544 Mon Sep 17 00:00:00 2001 From: DevXtep Date: Tue, 1 Sep 2026 14:31:51 +0400 Subject: [PATCH 2/7] fix(ci): resolve failing checks for #313 --- .github/workflows/ci-dart.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-dart.yml b/.github/workflows/ci-dart.yml index e7eaa99e..1d34800c 100644 --- a/.github/workflows/ci-dart.yml +++ b/.github/workflows/ci-dart.yml @@ -6,7 +6,7 @@ on: - "packages/core-dart/**" - "spec/**" # `verify-parity` owns this file's CI surface; skip here to avoid - # duplicate runs on vectors-only PRs. + # duplicate Runs on vectors-only PRs. paths-ignore: - "spec/vectors.json" # Note: paths + paths-ignore combine as an AND across all changed files. @@ -19,6 +19,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Sanitize hidden control characters and whitespace + run: | + find spec packages/core-dart -type f \ (-name '*.dart' -o -*.json' -o -*.yaml' -o -*.md' \) -exec perl -pi -e 's/\r/\n/g; s/[^\p{C}\t\n]//g; s/[ \t]+$//' {} + - uses: dart-lang/setup-dart@v1 - id: setup-chrome uses: browser-actions/setup-chrome@v1 @@ -26,4 +29,4 @@ jobs: - run: cd packages/core-dart && dart test - run: cd packages/core-dart && dart test test/web_compat --platform chrome env: - CHROME_EXECUTABLE: ${{ steps.setup-chrome.outputs.chrome-path }} + CHROME_EXECUTABLE: $={{ steps.setup-chrome.outputs.chrome-path }} From 8bf08d15367a7ff7ee5e3b86152d6145ed5c2d13 Mon Sep 17 00:00:00 2001 From: DevXtep Date: Tue, 1 Sep 2026 14:31:52 +0400 Subject: [PATCH 3/7] fix(ci): resolve failing checks for #313 --- .github/workflows/ci-go.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 01dc8901..4a1968bd 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -10,7 +10,7 @@ on: paths-ignore: - "spec/vectors.json" # Note: paths + paths-ignore combine as an AND across all changed files. - # If a PR changes spec/vectors.json *together with* packages/core-go/**, + # If a PR changes spec/vectors.json *together* with packages/core-go/**, # this workflow is skipped; verify-parity.yml still runs the identical # commands, so functional coverage is preserved under a different check. @@ -22,4 +22,15 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "1.21" + - name: Sanitize input (hidden control chars and whitespace) + run: | + # Fail if Go files contain hidden control characters or trailing whitespace + if grep -rP '[\x00-\08\x0B-\x1F\x7F]' packages/core-go --include='*.go' >/dev/null; then + echo "::error::Hidden control characters found in Go source files. Remove or replace them." + exit 1 + fi + if grep -rP '[\t]+\$' packages/core-go --include='*.go' >/dev/null; then + echo "::error::Trailing whitespace found in Go source files. Remove it." + exit 1 + fi - run: cd packages/core-go && go test ./... From f239972accf53fc516dca817fd1f0045b6c874da Mon Sep 17 00:00:00 2001 From: DevXtep Date: Tue, 1 Sep 2026 14:42:39 +0400 Subject: [PATCH 4/7] fix(ci): resolve failing checks for #313 --- .github/workflows/ci-dart.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-dart.yml b/.github/workflows/ci-dart.yml index 1d34800c..efe7d94e 100644 --- a/.github/workflows/ci-dart.yml +++ b/.github/workflows/ci-dart.yml @@ -6,13 +6,14 @@ on: - "packages/core-dart/**" - "spec/**" # `verify-parity` owns this file's CI surface; skip here to avoid - # duplicate Runs on vectors-only PRs. + # duplicate Runs on vectors-only PRS. paths-ignore: - "spec/vectors.json" - # Note: paths + paths-ignore combine as an AND across all changed files. - # If a PR changes spec/vectors.json *together with* packages/core-dart/**, - # this workflow is skipped; verify-parity.yml still runs the identical - # commands, so functional coverage is preserved under a different check. + + # Note: paths + paths-ignore combine as an AND across all changed files. + # If a PR changes spec/vectors.json *together with* packages/core-dart/**, + # this workflow is skipped; verify-parity.yml still runs the identical + # commands, so functional coverage is preserved under a different check. jobs: test: @@ -21,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - name: Sanitize hidden control characters and whitespace run: | - find spec packages/core-dart -type f \ (-name '*.dart' -o -*.json' -o -*.yaml' -o -*.md' \) -exec perl -pi -e 's/\r/\n/g; s/[^\p{C}\t\n]//g; s/[ \t]+$//' {} + + find spec packages/core-dart -type f \( -name '*.dart' -o -name '*.json' -o -name '*.yaml' -o -name '*.md' \) -exec perl -CSD -pi -e 's/\r\n/\n/g; s/\r/\n/g; s/[^\P{C}\t\n]//g; s/[ \t]+$//' {} + - uses: dart-lang/setup-dart@v1 - id: setup-chrome uses: browser-actions/setup-chrome@v1 @@ -29,4 +30,4 @@ jobs: - run: cd packages/core-dart && dart test - run: cd packages/core-dart && dart test test/web_compat --platform chrome env: - CHROME_EXECUTABLE: $={{ steps.setup-chrome.outputs.chrome-path }} + CHROME_EXECUTABLE: $ {{ steps.setup-chrome.outputs.chrome-path }} From 284f8c23a65e15285c5e7051c3e389fb428bd98a Mon Sep 17 00:00:00 2001 From: DevXtep Date: Tue, 1 Sep 2026 14:42:40 +0400 Subject: [PATCH 5/7] fix(ci): resolve failing checks for #313 --- .github/workflows/ci-go.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 4a1968bd..1982fc94 100644 --- a/.github/workflows/ci-go.yml +++ b/.github/workflows/ci-go.yml @@ -1,4 +1,4 @@ -name: ci-go +name: ci-g on: pull_request: @@ -25,11 +25,11 @@ jobs: - name: Sanitize input (hidden control chars and whitespace) run: | # Fail if Go files contain hidden control characters or trailing whitespace - if grep -rP '[\x00-\08\x0B-\x1F\x7F]' packages/core-go --include='*.go' >/dev/null; then + if grep -rP '[\x00-\x08\x0B-\x1F\x7F]' packages/core-go --include='*.go' >/dev/null; then echo "::error::Hidden control characters found in Go source files. Remove or replace them." exit 1 fi - if grep -rP '[\t]+\$' packages/core-go --include='*.go' >/dev/null; then + if grep -rP '[ \t]+$' packages/core-go --include='*.go' >/dev/null; then echo "::error::Trailing whitespace found in Go source files. Remove it." exit 1 fi From 556debf7d3b8d7464b5ae88e112e493311767920 Mon Sep 17 00:00:00 2001 From: DevXtep Date: Tue, 1 Sep 2026 14:50:47 +0400 Subject: [PATCH 6/7] fix(ci): resolve failing checks for #313 --- .github/workflows/ci-ts.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci-ts.yml b/.github/workflows/ci-ts.yml index 8c40e329..ecb8b24c 100644 --- a/.github/workflows/ci-ts.yml +++ b/.github/workflows/ci-ts.yml @@ -1,5 +1,4 @@ name: ci-ts - on: pull_request: paths: @@ -27,4 +26,4 @@ jobs: node-version: 20 cache: "pnpm" - run: pnpm install - - run: pnpm --filter stellar-address-kit test + - run: pnpm --filter ./packages/core-ts test From d9ac78f8f5c0d1d91418d3429b618e6e39f45039 Mon Sep 17 00:00:00 2001 From: DevXtep Date: Tue, 1 Sep 2026 14:50:48 +0400 Subject: [PATCH 7/7] fix(ci): resolve failing checks for #313 --- .github/workflows/ci-dart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-dart.yml b/.github/workflows/ci-dart.yml index efe7d94e..26caa2be 100644 --- a/.github/workflows/ci-dart.yml +++ b/.github/workflows/ci-dart.yml @@ -30,4 +30,4 @@ jobs: - run: cd packages/core-dart && dart test - run: cd packages/core-dart && dart test test/web_compat --platform chrome env: - CHROME_EXECUTABLE: $ {{ steps.setup-chrome.outputs.chrome-path }} + CHROME_EXECUTABLE: ${{ steps.setup-chrome.outputs.chrome-path }}