diff --git a/.github/workflows/ci-dart.yml b/.github/workflows/ci-dart.yml index e7eaa99e..26caa2be 100644 --- a/.github/workflows/ci-dart.yml +++ b/.github/workflows/ci-dart.yml @@ -6,19 +6,23 @@ 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: 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 -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 diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml index 01dc8901..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: @@ -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-\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 + echo "::error::Trailing whitespace found in Go source files. Remove it." + exit 1 + fi - run: cd packages/core-go && go test ./... 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 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 de848174..262d4d21 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 63be33f1..b99bfe90 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" WarnMissingRequiredMemo WarningCode = "MISSING_REQUIRED_MEMO" ) @@ -303,4 +304,4 @@ func validateContextFields(code WarningCode, ctx *WarningContext) error { } return nil -} +} \ No newline at end of file diff --git a/packages/core-go/routing/extract.go b/packages/core-go/routing/extract.go index 5d333262..579482fd 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 5c2c1ff1..0e037b9d 100644 --- a/packages/core-ts/src/address/types.ts +++ b/packages/core-ts/src/address/types.ts @@ -17,6 +17,7 @@ export type WarningCode = | "MEMO_ID_INVALID_FORMAT" | "UNSUPPORTED_MEMO_TYPE" | "INVALID_DESTINATION" + | "SANITIZED_HIDDEN_CHARS" | "MISSING_REQUIRED_MEMO"; export type Warning = @@ -93,4 +94,4 @@ export type ParseResult = }; }; -export { ErrorCode } from "./errors"; +export { ErrorCode } from "./errors"; \ No newline at end of file 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 972581b9..c56ce3b5 100644 --- a/packages/spec/schema.json +++ b/packages/spec/schema.json @@ -66,8 +66,8 @@ "enum": [ "MEMO_IGNORED_FOR_MUXED", "MEMO_PRESENT_WITH_MUXED", "CONTRACT_SENDER_DETECTED", "MEMO_TEXT_UNROUTABLE", - "MEMO_ID_INVALID_FORMAT", "MISSING_REQUIRED_MEMO", - "INVALID_STRKEY" + "MEMO_ID_INVALID_FORMAT", "SANITIZED_HIDDEN_CHARS", + "MISSING_REQUIRED_MEMO", "INVALID_STRKEY" ] }, "severity": { "type": "string", "enum": ["info", "warn", "error"] }, @@ -119,4 +119,4 @@ } } } -} +} \ No newline at end of file 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 972581b9..23e07f61 100644 --- a/spec/schema.json +++ b/spec/schema.json @@ -66,7 +66,8 @@ "enum": [ "MEMO_IGNORED_FOR_MUXED", "MEMO_PRESENT_WITH_MUXED", "CONTRACT_SENDER_DETECTED", "MEMO_TEXT_UNROUTABLE", - "MEMO_ID_INVALID_FORMAT", "MISSING_REQUIRED_MEMO", + "MEMO_ID_INVALID_FORMAT", "SANITIZED_HIDDEN_CHARS", + "MISSING_REQUIRED_MEMO", "INVALID_STRKEY" ] }, @@ -119,4 +120,4 @@ } } } -} +} \ No newline at end of file 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